Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 44 additions & 23 deletions packages/appkit/src/type-generator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,39 @@ import {
} from "./migration";
import { generateQueriesFromDescribe } from "./query-registry";
import { generateServingTypes as generateServingTypesImpl } from "./serving/generator";
import type { QuerySchema } from "./types";
import type { QuerySchema, QuerySyntaxError } from "./types";

dotenv.config();

const logger = createLogger("type-generator");

/**
* Thrown when one or more queries fail `DESCRIBE QUERY` against a *reachable*
* warehouse — i.e. genuine SQL errors (bad table, syntax, incompatible type),
* as opposed to a connectivity failure (warehouse unreachable), which degrades
* silently. Whether this is fatal is the caller's decision: the Vite plugin and
* CLI fail the build in production and warn-only in development.
*/
export class TypegenSyntaxError extends Error {
readonly queries: QuerySyntaxError[];

constructor(queries: QuerySyntaxError[], warehouseId?: string) {
const names = queries.map((q) => q.name).join(", ");
super(
[
`Type generation failed: ${queries.length} ${queries.length === 1 ? "query" : "queries"} could not be described: ${names}.`,
`DESCRIBE QUERY failed for these queries — see the error codes above for details.`,
`Common causes: SQL syntax errors, missing tables/views, or warehouse format incompatibilities.`,
warehouseId
? `To debug: run the failing query directly in a SQL editor against warehouse ${warehouseId}.`
: `To debug: run the failing query directly in a SQL editor.`,
].join("\n"),
);
this.name = "TypegenSyntaxError";
this.queries = queries;
}
}

/**
* Generate type declarations for QueryRegistry
* Create the d.ts file from the plugin routes and query schemas
Expand Down Expand Up @@ -64,28 +91,13 @@ export async function generateFromEntryPoint(options: {
logger.debug("Starting type generation...");

let queryRegistry: QuerySchema[] = [];
if (queryFolder)
queryRegistry = await generateQueriesFromDescribe(
queryFolder,
warehouseId,
{
noCache,
},
);

const failedQueries = queryRegistry.filter((q) =>
q.type.includes("result: unknown"),
);
if (failedQueries.length > 0) {
const names = failedQueries.map((q) => q.name).join(", ");
throw new Error(
[
`Type generation failed: ${failedQueries.length} ${failedQueries.length === 1 ? "query" : "queries"} could not be described: ${names}.`,
`DESCRIBE QUERY failed for these queries — see the error codes above for details.`,
`Common causes: SQL syntax errors, missing tables/views, or warehouse format incompatibilities.`,
`To debug: run the failing query directly in a SQL editor against warehouse ${warehouseId}.`,
].join("\n"),
);
let syntaxErrors: QuerySyntaxError[] = [];
if (queryFolder) {
const result = await generateQueriesFromDescribe(queryFolder, warehouseId, {
noCache,
});
queryRegistry = result.schemas;
syntaxErrors = result.syntaxErrors;
}

const typeDeclarations = generateTypeDeclarations(queryRegistry);
Expand All @@ -97,6 +109,15 @@ export async function generateFromEntryPoint(options: {
await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts");
await migrateProjectConfig(projectRoot);

// Types are always written above — including `result: unknown` for any query
// that could not be described — so a transient warehouse outage never blocks a
// build. Only a genuine SQL error against a REACHABLE warehouse is surfaced as
// a throw; the Vite plugin / CLI apply the prod-fails / dev-warns gate.
// Connectivity failures are absent from `syntaxErrors`, so they pass silently.
if (syntaxErrors.length > 0) {
throw new TypegenSyntaxError(syntaxErrors, warehouseId);
}

logger.debug("Type generation complete!");
}

Expand Down
153 changes: 121 additions & 32 deletions packages/appkit/src/type-generator/query-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache";
import { Spinner } from "./spinner";
import {
type DatabricksStatementExecutionResponse,
type QueryGenerationResult,
type QuerySchema,
type QuerySyntaxError,
sqlTypeToHelper,
sqlTypeToMarker,
} from "./types";
Expand Down Expand Up @@ -82,6 +84,17 @@ function parseError(raw: string): { code?: string; message: string } {
return { message: raw };
}

function isConnectivityError(raw: string): boolean {
return (
/\b(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH)\b/i.test(
raw,
) ||
/\b(connection refused|connection reset|fetch failed|network error|socket hang up|timed? ?out|timeout)\b/i.test(
raw,
)
);
}

/**
* Extract parameters from a SQL query
* @param sql - the SQL query to extract parameters from
Expand Down Expand Up @@ -272,7 +285,7 @@ export async function generateQueriesFromDescribe(
queryFolder: string,
warehouseId: string,
options: { noCache?: boolean; concurrency?: number } = {},
): Promise<QuerySchema[]> {
): Promise<QueryGenerationResult> {
Comment on lines 285 to +288
const { noCache = false, concurrency: rawConcurrency = 10 } = options;
const concurrency =
typeof rawConcurrency === "number" && Number.isFinite(rawConcurrency)
Expand Down Expand Up @@ -314,7 +327,9 @@ export async function generateQueriesFromDescribe(
const logEntries: Array<{
queryName: string;
status: "HIT" | "MISS";
failed?: boolean;
// Absent for clean hits/misses. "syntax" = bad SQL on a reachable warehouse;
// "connectivity" = warehouse unreachable; "empty" = described but no columns.
kind?: "syntax" | "connectivity" | "empty";
error?: { code?: string; message: string };
}> = [];

Expand Down Expand Up @@ -369,20 +384,32 @@ export async function generateQueriesFromDescribe(
// Phase 2: Execute all uncached DESCRIBE calls in parallel
type DescribeResult =
| {
// Described successfully with a result schema — the only case we cache.
status: "ok";
index: number;
schema: QuerySchema;
cacheEntry: { hash: string; type: string; retry: boolean };
}
| {
status: "fail";
// Reachable warehouse ran DESCRIBE and rejected the statement — a
// genuine SQL error. Eligible to fail the build; never cached.
status: "syntax";
index: number;
schema: QuerySchema;
cacheEntry: { hash: string; type: string; retry: boolean };
error: { code?: string; message: string };
}
| {
// DESCRIBE succeeded but returned no columns — soft `unknown`. Not a
// failure, not cached, retried next run.
status: "empty";
index: number;
schema: QuerySchema;
};

const freshResults: Array<{ index: number; schema: QuerySchema }> = [];
// Genuine SQL errors (reachable warehouse). Connectivity failures are NOT
// recorded here — they degrade silently so a transient outage isn't fatal.
const syntaxErrors: QuerySyntaxError[] = [];

if (uncachedQueries.length > 0) {
let completed = 0;
Expand Down Expand Up @@ -416,25 +443,31 @@ export async function generateQueriesFromDescribe(
);

if (result.status.state === "FAILED") {
// The warehouse was reachable and ran DESCRIBE, but the statement
// failed — a genuine SQL error (bad table, syntax, incompatible type).
const sqlError =
result.status.error?.message || "Query execution failed";
logger.warn("DESCRIBE failed for %s: %s", queryName, sqlError);
const type = generateUnknownResultQuery(sql, queryName);
return {
status: "fail",
status: "syntax",
index,
schema: { name: queryName, type },
cacheEntry: { hash: sqlHash, type, retry: true },
error: parseError(sqlError),
};
}

const { type, hasResults } = convertToQueryType(result, sql, queryName);
if (!hasResults) {
// Described, but no result columns. Emit `unknown` and retry next run;
// do not cache (we never persist `result: unknown`).
return { status: "empty", index, schema: { name: queryName, type } };
}
return {
status: "ok",
index,
schema: { name: queryName, type },
cacheEntry: { hash: sqlHash, type, retry: !hasResults },
cacheEntry: { hash: sqlHash, type, retry: false },
};
};

Expand All @@ -450,28 +483,61 @@ export async function generateQueriesFromDescribe(
if (entry.status === "fulfilled") {
const res = entry.value;
freshResults.push({ index: res.index, schema: res.schema });
cache.queries[queryName] = res.cacheEntry;
logEntries.push({
queryName,
status: "MISS",
failed: res.status === "fail",
error: res.status === "fail" ? res.error : undefined,
});

if (res.status === "ok") {
// Only a successful describe with a result schema is cached.
cache.queries[queryName] = res.cacheEntry;
logEntries.push({ queryName, status: "MISS" });
} else if (res.status === "syntax") {
// Genuine SQL error — record it for the caller's prod/dev gate.
// Not cached: re-described next run so a fixed query recovers.
syntaxErrors.push({ name: queryName, message: res.error.message });
logEntries.push({
queryName,
status: "MISS",
kind: "syntax",
error: res.error,
});
} else {
// status === "empty": described, no columns. Soft unknown, not cached.
logEntries.push({ queryName, status: "MISS", kind: "empty" });
}
} else {
// executeStatement rejected before the warehouse returned a statement
// result. Only clear transport failures are treated as offline; auth,
// bad warehouse IDs, malformed requests, and SDK/config failures stay
// fatal so users fix the underlying setup issue.
const { sql, sqlHash, index } = uncachedQueries[batchOffset + i];
const reason =
entry.reason instanceof Error
? entry.reason.message
: String(entry.reason);
logger.warn("DESCRIBE rejected for %s: %s", queryName, reason);
const type = generateUnknownResultQuery(sql, queryName);
const error = parseError(reason);
if (!isConnectivityError(reason)) {
spinner.stop("");
throw new Error(
`DESCRIBE request failed for ${queryName}: ${error.message}`,
);
}
const prior = cache.queries[queryName];
const canReusePrior = prior?.hash === sqlHash && !prior.retry;
const type = canReusePrior
? prior.type
: generateUnknownResultQuery(sql, queryName);
logger.warn(
"DESCRIBE unreachable for %s: %s — %s",
queryName,
reason,
canReusePrior
? "reusing last cached type"
: "emitting unknown (no matching cache)",
);
freshResults.push({ index, schema: { name: queryName, type } });
cache.queries[queryName] = { hash: sqlHash, type, retry: true };
logEntries.push({
queryName,
status: "MISS",
failed: true,
error: parseError(reason),
kind: "connectivity",
error,
});
}
}
Expand Down Expand Up @@ -507,36 +573,59 @@ export async function generateQueriesFromDescribe(
);
console.log(` ${separator}`);
for (const entry of logEntries) {
const tag = entry.failed
? pc.bold(pc.red("ERROR"))
: entry.status === "HIT"
? `cache ${pc.bold(pc.green("HIT "))}`
: `cache ${pc.bold(pc.yellow("MISS "))}`;
let tag: string;
switch (entry.kind) {
case "syntax":
tag = pc.bold(pc.red("SQL ERR"));
break;
case "connectivity":
tag = pc.bold(pc.yellow("OFFLINE"));
break;
case "empty":
tag = pc.dim("EMPTY ");
break;
default:
tag =
entry.status === "HIT"
? `cache ${pc.bold(pc.green("HIT "))}`
: `cache ${pc.bold(pc.yellow("MISS "))}`;
}
const rawName = entry.queryName.padEnd(maxNameLen);
const name = entry.failed ? pc.dim(pc.strikethrough(rawName)) : rawName;
// Only genuine SQL errors are struck through. Connectivity/empty kept a
// usable type (reused or unknown), so they read as degraded, not broken.
const name =
entry.kind === "syntax" ? pc.dim(pc.strikethrough(rawName)) : rawName;
const errorCode = entry.error?.message.match(/\[([^\]]+)\]/)?.[1];
const reason = errorCode ? ` ${pc.dim(errorCode)}` : "";
Comment on lines 598 to 599
console.log(` ${tag} ${name}${reason}`);
}
const newCount = logEntries.filter(
(e) => e.status === "MISS" && !e.failed,
(e) => e.status === "MISS" && !e.kind,
).length;
const cacheCount = logEntries.filter(
(e) => e.status === "HIT" && !e.failed,
const cacheCount = logEntries.filter((e) => e.status === "HIT").length;
const syntaxCount = logEntries.filter((e) => e.kind === "syntax").length;
const offlineCount = logEntries.filter(
(e) => e.kind === "connectivity",
).length;
const errorCount = logEntries.filter((e) => e.failed).length;
const emptyCount = logEntries.filter((e) => e.kind === "empty").length;
console.log(` ${separator}`);
const parts = [`${newCount} new`, `${cacheCount} from cache`];
if (errorCount > 0)
parts.push(`${errorCount} ${errorCount === 1 ? "error" : "errors"}`);
if (syntaxCount > 0)
parts.push(
`${syntaxCount} SQL ${syntaxCount === 1 ? "error" : "errors"}`,
);
if (offlineCount > 0) parts.push(`${offlineCount} unreachable`);
if (emptyCount > 0) parts.push(`${emptyCount} empty`);
console.log(` ${parts.join(", ")}. ${pc.dim(`${elapsed}s`)}`);
console.log("");
}

// Merge and sort by original file index for deterministic output
return [...cachedResults, ...freshResults]
const schemas = [...cachedResults, ...freshResults]
.sort((a, b) => a.index - b.index)
.map((r) => r.schema);

return { schemas, syntaxErrors };
}

/**
Expand Down
Loading
Loading