Skip to content

Commit 31ab42e

Browse files
authored
fix(appkit): non-blocking typegen on Analytics (#406)
* fix(appkit): don't crash typegen when the warehouse is unreachable Type generation threw an uncaught error whenever the SQL warehouse was down. Every DESCRIBE QUERY failed, all queries degraded to an unknown result, and generateFromEntryPoint unconditionally threw an aggregate "Type generation failed" error that escaped uncaught at the Vite plugin (un-awaited generate()) and the CLI (sync cmd.parse()) call sites. Distinguish connectivity failures from genuine SQL errors: - Connectivity (executeStatement rejects): degrade silently. Reuse the last-known-good cached type if present, otherwise emit an unknown result. Never fatal, so a transient outage no longer fails a build. - SQL error (reachable warehouse, DESCRIBE FAILED): surface via a typed TypegenSyntaxError so the existing prod-throws / dev-warns gate applies. Eligible to fail prod builds only. Also stop caching unknown results: only successful describes with a result schema are persisted, so a transient outage never poisons the cache and a fixed query recovers on the next run. PR1 of 2 (user-visible behavior). PR2 will await the Vite buildStart/watcher, use parseAsync().catch() in the CLI, and add degrade/throw regression tests. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * test(appkit): cover typegen warehouse-down classification and throw seam Add the regression coverage the warehouse-down crash slipped through: the prior suite tested rejection->unknown as graceful but never connected it to the aggregate throw in generateFromEntryPoint. query-registry (generate-queries.test.ts): - connectivity reuses the last-known-good cached type - empty result (described, no columns) -> unknown, not syntax, not cached - syntax (FAILED) -> recorded in syntaxErrors, not cached - cache HIT serves the stored type without a warehouse call - legacy retry-flagged entry is re-described, not reused - mixed run records only the syntax failure; failures are not persisted generateFromEntryPoint (index.test.ts): - syntax errors throw TypegenSyntaxError - connectivity-only failures do NOT throw (the warehouse-down regression) - the .d.ts is written before the throw Layers 1+2 of the test plan; Layer 3 (analytics vite-plugin) and Layer 4 (CLI exit codes) land in PR2 with their await/parseAsync refactors. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix: classify typegen describe rejections Signed-off-by: Atila Fassina <atila@fassina.eu> * fix(appkit): harden typegen describe error handling Signed-off-by: Atila Fassina <atila@fassina.eu> * feat(appkit): warehouse-aware typegen pre-flight Add a warehouse-status pre-flight to typegen and rework error handling so a stopped, starting, or unreachable warehouse degrades gracefully instead of crashing or emitting EMPTY types. - Classify connectivity (incl. the SDK's "Can't connect to <url>"/code-500 DNS wrapper) as OFFLINE, and non-terminal describe states (PENDING/RUNNING) as degraded rather than EMPTY. - Surface typegen errors as their actionable message (no internal stack trace); format and de-duplicate the failure output. - Add a warehouse status probe + pure pre-flight policy; block in the CLI/build, roll forward (degrade) in dev. - Dev: regenerate types in the background once the warehouse reaches RUNNING (single-flight guarded); auto-start a stopped warehouse in dev. - CLI: --no-block degrades instead of describing so postinstall never blocks. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * refactor(appkit): collapse typegen pre-flight modes to non-blocking|blocking Replace the dev/blocking/degrade modes with two: non-blocking (default) and blocking. non-blocking always degrades (skip probe + DESCRIBE, write cache-or- unknown instantly); blocking keeps the current probe+wait flow. The CLI default flips to non-blocking and --no-block becomes a positive --block flag. The dev plugin runs the foreground in non-blocking (instant degrade) while its background warehouse-watch regenerates in blocking so real types still land. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * feat(appkit): blocking typegen auto-starts a stopped warehouse and waits In blocking mode a STOPPED/STOPPING warehouse is now started and waited on (startWaitProceed: startWarehouse -> waitUntilRunning -> describe) instead of failing fast. Only DELETED/DELETING is fatal. STARTING still waits; RUNNING describes. The write-the-.d.ts-then-throw invariant is preserved for the fatal and wait-timeout paths. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix(appkit): dev typegen background-describes a running warehouse The dev background lifecycle returned early for RUNNING, a leftover from when the foreground described it synchronously. Now that the foreground always degrades instantly (non-blocking), RUNNING must also background-describe or a running warehouse never gets real types in dev. Only DELETED/DELETING leaves degraded; single-flight coalescing and abort-on-shutdown are preserved. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * feat(appkit): non-blocking typegen CLI spawns a detached blocking worker The default (non-blocking) generate-types now writes degraded types, then spawns a detached `generate-types --block` worker behind a single-flight lock and exits 0 -- so postinstall/predev never block on warehouse state. The worker does the full blocking lifecycle in the background, refreshes real types, and releases the lock on exit (process-exit guard covers a hard fail). A stale lock from a crashed worker is stolen after 6 min; spawn failure is non-fatal to the foreground. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * chore(appkit): wire app template to non-blocking typegen, document --block Template postinstall/predev now run the non-blocking default `appkit generate-types` (instant degrade + background refresh) instead of a dedicated no-block script; prebuild keeps `--block` for accurate CI types. Removed the now redundant `typegen:no-block` script. Documented the non-blocking default and the `--block` flag in the type-generation guide. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix(appkit): don't print typegen SQL errors twice A failed DESCRIBE logged the raw SQL error message per query during the describe loop, and the aggregated TypegenSyntaxError (printed by the Vite plugin / CLI) carries the same message in its formatted block -- so every SQL syntax error showed up twice in dev. Drop the per-query warn; the formatted block and the summary table already surface it. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * fix(appkit): forward execArgv so the detached typegen worker runs under tsx The non-blocking CLI spawned its background worker as `node <argv[1]>` without the parent's node/loader flags. Run from source via tsx, argv[1] is a .ts file that plain node can't parse, so the worker died silently (detached + stdio ignore) and the degraded types never refreshed -- the queries appeared to never run. Forward process.execArgv, which carries tsx's --require/--import loader flags (and is empty for the built bin, so production is unaffected), so the worker runs under the same runtime as the parent. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> * refactor(appkit): rename typegen --block flag to --wait Rename the user-facing CLI flag --block to --wait (commander option property block -> wait), including the detached worker's self-spawn arg, the --help example, the template prebuild script, and the type-generation guide. The internal "blocking"/"non-blocking" PreflightMode names are unchanged -- they describe runtime behaviour and aren't user-facing. The flag only ever existed on this branch (unreleased), so no deprecation alias is needed. Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu> --------- Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 2609f34 commit 31ab42e

19 files changed

Lines changed: 3490 additions & 157 deletions

docs/docs/development/type-generation.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ npx @databricks/appkit generate-types [rootDir] [outFile] [warehouseId]
7272
npx @databricks/appkit generate-types --no-cache
7373
```
7474

75+
### Warehouse readiness and the `--wait` flag
76+
77+
By default, `generate-types` is **non-blocking**: it never waits on — or fails because of — your SQL warehouse. It writes the best types it can immediately (reusing cached types where the query is unchanged, otherwise `result: unknown`) and then spawns a detached background worker that refreshes the real types once the warehouse is ready. This keeps `npm install` (postinstall) and `npm run dev` (predev) fast and resilient to a cold or briefly-unreachable warehouse. The dev Vite plugin behaves the same way: types appear instantly and refresh in place once the warehouse is live.
78+
79+
Pass `--wait` for CI and production builds, where accurate types must be present before the build proceeds:
80+
81+
```bash
82+
npx @databricks/appkit generate-types --wait
83+
```
84+
85+
In blocking mode the generator starts a stopped warehouse, waits (bounded) for it to reach `RUNNING`, and then describes your queries. It fails only when the configured warehouse no longer exists (deleted/deleting), so a transient outage or a cold warehouse degrades gracefully rather than breaking the build. The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`.
86+
7587
## How it works
7688

7789
The type generator:

packages/appkit/src/type-generator/index.ts

Lines changed: 179 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,169 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import dotenv from "dotenv";
4+
import pc from "picocolors";
45
import { createLogger } from "../logging/logger";
56
import {
67
migrateProjectConfig,
78
removeOldGeneratedTypes,
89
resolveProjectRoot,
910
} from "./migration";
11+
import type { PreflightMode } from "./preflight";
1012
import { generateQueriesFromDescribe } from "./query-registry";
1113
import { generateServingTypes as generateServingTypesImpl } from "./serving/generator";
12-
import type { QuerySchema } from "./types";
14+
import type { QueryFatalError, QuerySchema, QuerySyntaxError } from "./types";
1315

1416
dotenv.config();
1517

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

20+
type TypegenFailure = QuerySyntaxError | QueryFatalError;
21+
22+
function plural(count: number, singular: string, pluralForm = `${singular}s`) {
23+
return count === 1 ? singular : pluralForm;
24+
}
25+
26+
function formatFailureRows(
27+
label: string,
28+
queries: TypegenFailure[],
29+
color: (value: string) => string,
30+
) {
31+
if (queries.length === 0) return [];
32+
33+
// Group by message so a shared failure — e.g. a warehouse-level fatal that
34+
// hits every query identically — prints once instead of repeating per row.
35+
const byMessage = new Map<string, string[]>();
36+
for (const { name, message } of queries) {
37+
const names = byMessage.get(message);
38+
if (names) names.push(name);
39+
else byMessage.set(message, [name]);
40+
}
41+
42+
const maxNameLen = Math.max(...queries.map((query) => query.name.length));
43+
const tag = color(label.padEnd(7));
44+
const rows: string[] = [];
45+
for (const [message, names] of byMessage) {
46+
// Unique message → keep the compact one-line `tag name message` form.
47+
if (names.length === 1) {
48+
rows.push(
49+
` ${tag} ${pc.bold(names[0].padEnd(maxNameLen))} ${pc.dim(message)}`,
50+
);
51+
continue;
52+
}
53+
// Shared message → print it once, then list the affected query names.
54+
rows.push(
55+
` ${tag} ${pc.dim(message)} ${pc.dim(`(${names.length} ${plural(names.length, "query", "queries")})`)}`,
56+
);
57+
rows.push(
58+
` ${names.map((name) => pc.bold(name)).join(pc.dim(", "))}`,
59+
);
60+
}
61+
return rows;
62+
}
63+
64+
function formatTypegenFailureMessage(options: {
65+
syntaxErrors: QuerySyntaxError[];
66+
fatalErrors?: QueryFatalError[];
67+
warehouseId?: string;
68+
title: string;
69+
causes: string[];
70+
nextStep: string;
71+
}) {
72+
const { syntaxErrors, fatalErrors = [], warehouseId, title } = options;
73+
const total = syntaxErrors.length + fatalErrors.length;
74+
const separator = pc.dim("─".repeat(60));
75+
const warehouse = warehouseId
76+
? ` against ${pc.dim(`warehouse ${warehouseId}`)}`
77+
: "";
78+
79+
return [
80+
` ${pc.bold(pc.red("Type generation failed"))}`,
81+
` ${separator}`,
82+
` ${title}: ${total} ${plural(total, "query", "queries")} could not be described${warehouse}.`,
83+
` AppKit wrote generated types with ${pc.bold("result: unknown")} for the failed ${plural(total, "query", "queries")}.`,
84+
"",
85+
...formatFailureRows("SQL ERR", syntaxErrors, pc.red),
86+
...(syntaxErrors.length > 0 && fatalErrors.length > 0 ? [""] : []),
87+
...formatFailureRows("FATAL", fatalErrors, pc.red),
88+
"",
89+
` ${pc.bold("Common causes")}`,
90+
...options.causes.map((cause) => ` - ${cause}`),
91+
"",
92+
` ${pc.bold("Next step")}`,
93+
` ${options.nextStep}`,
94+
].join("\n");
95+
}
96+
97+
/**
98+
* Thrown when one or more queries fail `DESCRIBE QUERY` against a *reachable*
99+
* warehouse — i.e. genuine SQL errors (bad table, syntax, incompatible type),
100+
* as opposed to a connectivity failure (warehouse unreachable), which degrades
101+
* silently. Whether this is fatal is the caller's decision: the Vite plugin and
102+
* CLI fail the build in production and warn-only in development.
103+
*/
104+
export class TypegenSyntaxError extends Error {
105+
readonly queries: QuerySyntaxError[];
106+
readonly fatalQueries: QueryFatalError[];
107+
108+
constructor(
109+
queries: QuerySyntaxError[],
110+
warehouseId?: string,
111+
fatalQueries: QueryFatalError[] = [],
112+
) {
113+
super(
114+
formatTypegenFailureMessage({
115+
syntaxErrors: queries,
116+
fatalErrors: fatalQueries,
117+
warehouseId,
118+
title: "DESCRIBE QUERY failed",
119+
causes: [
120+
"SQL syntax errors",
121+
"missing tables or views",
122+
"warehouse format incompatibilities",
123+
],
124+
nextStep: warehouseId
125+
? `Run each SQL ERR query directly in a Databricks SQL editor against warehouse ${pc.bold(warehouseId)}.`
126+
: "Run each SQL ERR query directly in a Databricks SQL editor.",
127+
}),
128+
);
129+
this.name = "TypegenSyntaxError";
130+
this.queries = queries;
131+
this.fatalQueries = fatalQueries;
132+
}
133+
}
134+
135+
/**
136+
* Thrown when DESCRIBE QUERY could not be requested because of a non-SQL fatal
137+
* setup/request problem, such as missing permissions, invalid warehouse IDs, or
138+
* malformed SDK configuration. Like TypegenSyntaxError, this is thrown only
139+
* after the declaration file has been written with `result: unknown` entries.
140+
*/
141+
export class TypegenFatalError extends Error {
142+
readonly queries: QueryFatalError[];
143+
144+
constructor(queries: QueryFatalError[], warehouseId?: string) {
145+
super(
146+
formatTypegenFailureMessage({
147+
syntaxErrors: [],
148+
fatalErrors: queries,
149+
warehouseId,
150+
title: "DESCRIBE QUERY could not be requested",
151+
causes: [
152+
"missing warehouse permissions",
153+
"invalid warehouse ID",
154+
"authentication failure",
155+
"SDK configuration errors",
156+
],
157+
nextStep: warehouseId
158+
? `Verify access to warehouse ${pc.bold(warehouseId)} and rerun type generation.`
159+
: "Verify warehouse access and rerun type generation.",
160+
}),
161+
);
162+
this.name = "TypegenFatalError";
163+
this.queries = queries;
164+
}
165+
}
166+
18167
/**
19168
* Generate type declarations for QueryRegistry
20169
* Create the d.ts file from the plugin routes and query schemas
@@ -57,35 +206,30 @@ export async function generateFromEntryPoint(options: {
57206
queryFolder?: string;
58207
warehouseId: string;
59208
noCache?: boolean;
209+
mode?: PreflightMode;
60210
}) {
61-
const { outFile, queryFolder, warehouseId, noCache } = options;
211+
const {
212+
outFile,
213+
queryFolder,
214+
warehouseId,
215+
noCache,
216+
mode = "non-blocking",
217+
} = options;
62218
const projectRoot = resolveProjectRoot(outFile);
63219

64220
logger.debug("Starting type generation...");
65221

66222
let queryRegistry: QuerySchema[] = [];
67-
if (queryFolder)
68-
queryRegistry = await generateQueriesFromDescribe(
69-
queryFolder,
70-
warehouseId,
71-
{
72-
noCache,
73-
},
74-
);
75-
76-
const failedQueries = queryRegistry.filter((q) =>
77-
q.type.includes("result: unknown"),
78-
);
79-
if (failedQueries.length > 0) {
80-
const names = failedQueries.map((q) => q.name).join(", ");
81-
throw new Error(
82-
[
83-
`Type generation failed: ${failedQueries.length} ${failedQueries.length === 1 ? "query" : "queries"} could not be described: ${names}.`,
84-
`DESCRIBE QUERY failed for these queries — see the error codes above for details.`,
85-
`Common causes: SQL syntax errors, missing tables/views, or warehouse format incompatibilities.`,
86-
`To debug: run the failing query directly in a SQL editor against warehouse ${warehouseId}.`,
87-
].join("\n"),
88-
);
223+
let syntaxErrors: QuerySyntaxError[] = [];
224+
let fatalErrors: QueryFatalError[] = [];
225+
if (queryFolder) {
226+
const result = await generateQueriesFromDescribe(queryFolder, warehouseId, {
227+
noCache,
228+
mode,
229+
});
230+
queryRegistry = result.schemas;
231+
syntaxErrors = result.syntaxErrors ?? [];
232+
fatalErrors = result.fatalErrors ?? [];
89233
}
90234

91235
const typeDeclarations = generateTypeDeclarations(queryRegistry);
@@ -97,6 +241,17 @@ export async function generateFromEntryPoint(options: {
97241
await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts");
98242
await migrateProjectConfig(projectRoot);
99243

244+
// Types are always written above — including `result: unknown` for any query
245+
// that could not be described. Connectivity failures pass silently so a
246+
// transient warehouse outage never blocks a build; genuine SQL errors and
247+
// non-connectivity fatal request failures surface after the file write.
248+
if (syntaxErrors.length > 0) {
249+
throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors);
250+
}
251+
if (fatalErrors.length > 0) {
252+
throw new TypegenFatalError(fatalErrors, warehouseId);
253+
}
254+
100255
logger.debug("Type generation complete!");
101256
}
102257

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { WarehouseState } from "./warehouse-status";
2+
3+
/**
4+
* How aggressively typegen should react to a not-ready warehouse.
5+
* - `non-blocking`: never describe and never probe the warehouse — emit
6+
* best-available types (cache where the SQL hash matches, else `unknown`) and
7+
* return at once. The default for interactive/foreground runs that can't
8+
* afford to block on (or fail because of) a warehouse, even a RUNNING one.
9+
* - `blocking`: a startable warehouse is worth waiting for, and a stopped one
10+
* is worth starting — only a deleted/deleting warehouse is a hard failure.
11+
*/
12+
export type PreflightMode = "non-blocking" | "blocking";
13+
14+
/**
15+
* What the caller should do given a warehouse state and mode.
16+
* - `proceed`: run DESCRIBE now.
17+
* - `degradeAll`: skip DESCRIBE; emit degraded (cached/`unknown`) types.
18+
* - `waitThenProceed`: wait for the warehouse to start, then run DESCRIBE.
19+
* - `startWaitProceed`: start the stopped warehouse, wait for RUNNING, then
20+
* run DESCRIBE.
21+
* - `fatal`: stop — the warehouse can't serve this run.
22+
*/
23+
export type PreflightDecision =
24+
| "proceed"
25+
| "degradeAll"
26+
| "waitThenProceed"
27+
| "startWaitProceed"
28+
| "fatal";
29+
30+
/**
31+
* Pure policy mapping a warehouse state + mode to a preflight decision.
32+
*
33+
* Unknown/unexpected states fall through to `proceed`: the describe loop and
34+
* its per-query backstop already degrade gracefully, so we don't want a new
35+
* SDK state value to turn into a spurious `fatal`.
36+
*/
37+
export function decidePreflight(
38+
state: WarehouseState,
39+
mode: PreflightMode,
40+
): PreflightDecision {
41+
// `non-blocking` never describes regardless of state: emit cached/`unknown`
42+
// types and return. The caller short-circuits before probing, so this is only
43+
// a belt-and-suspenders mapping, but it keeps the policy total and
44+
// self-contained.
45+
if (mode === "non-blocking") return "degradeAll";
46+
47+
// `blocking`: a starting warehouse is worth waiting for, a stopped one is
48+
// worth starting (then waiting), and only a deleted/deleting one is fatal.
49+
switch (state) {
50+
case "RUNNING":
51+
return "proceed";
52+
case "STARTING":
53+
return "waitThenProceed";
54+
case "STOPPED":
55+
case "STOPPING":
56+
return "startWaitProceed";
57+
case "DELETED":
58+
case "DELETING":
59+
return "fatal";
60+
default:
61+
return "proceed";
62+
}
63+
}

0 commit comments

Comments
 (0)