Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/docs/development/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ npx @databricks/appkit generate-types [rootDir] [outFile] [warehouseId]
npx @databricks/appkit generate-types --no-cache
```

### Warehouse readiness and the `--wait` flag

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.

Pass `--wait` for CI and production builds, where accurate types must be present before the build proceeds:

```bash
npx @databricks/appkit generate-types --wait
```

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`.

## How it works

The type generator:
Expand Down
203 changes: 179 additions & 24 deletions packages/appkit/src/type-generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,169 @@
import fs from "node:fs/promises";
import path from "node:path";
import dotenv from "dotenv";
import pc from "picocolors";
import { createLogger } from "../logging/logger";
import {
migrateProjectConfig,
removeOldGeneratedTypes,
resolveProjectRoot,
} from "./migration";
import type { PreflightMode } from "./preflight";
import { generateQueriesFromDescribe } from "./query-registry";
import { generateServingTypes as generateServingTypesImpl } from "./serving/generator";
import type { QuerySchema } from "./types";
import type { QueryFatalError, QuerySchema, QuerySyntaxError } from "./types";

dotenv.config();

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

type TypegenFailure = QuerySyntaxError | QueryFatalError;

function plural(count: number, singular: string, pluralForm = `${singular}s`) {
return count === 1 ? singular : pluralForm;
}

function formatFailureRows(
label: string,
queries: TypegenFailure[],
color: (value: string) => string,
) {
if (queries.length === 0) return [];

// Group by message so a shared failure — e.g. a warehouse-level fatal that
// hits every query identically — prints once instead of repeating per row.
const byMessage = new Map<string, string[]>();
for (const { name, message } of queries) {
const names = byMessage.get(message);
if (names) names.push(name);
else byMessage.set(message, [name]);
}

const maxNameLen = Math.max(...queries.map((query) => query.name.length));
const tag = color(label.padEnd(7));
const rows: string[] = [];
for (const [message, names] of byMessage) {
// Unique message → keep the compact one-line `tag name message` form.
if (names.length === 1) {
rows.push(
` ${tag} ${pc.bold(names[0].padEnd(maxNameLen))} ${pc.dim(message)}`,
);
continue;
}
// Shared message → print it once, then list the affected query names.
rows.push(
` ${tag} ${pc.dim(message)} ${pc.dim(`(${names.length} ${plural(names.length, "query", "queries")})`)}`,
);
rows.push(
` ${names.map((name) => pc.bold(name)).join(pc.dim(", "))}`,
);
}
return rows;
}

function formatTypegenFailureMessage(options: {
syntaxErrors: QuerySyntaxError[];
fatalErrors?: QueryFatalError[];
warehouseId?: string;
title: string;
causes: string[];
nextStep: string;
}) {
const { syntaxErrors, fatalErrors = [], warehouseId, title } = options;
const total = syntaxErrors.length + fatalErrors.length;
const separator = pc.dim("─".repeat(60));
const warehouse = warehouseId
? ` against ${pc.dim(`warehouse ${warehouseId}`)}`
: "";

return [
` ${pc.bold(pc.red("Type generation failed"))}`,
` ${separator}`,
` ${title}: ${total} ${plural(total, "query", "queries")} could not be described${warehouse}.`,
` AppKit wrote generated types with ${pc.bold("result: unknown")} for the failed ${plural(total, "query", "queries")}.`,
"",
...formatFailureRows("SQL ERR", syntaxErrors, pc.red),
...(syntaxErrors.length > 0 && fatalErrors.length > 0 ? [""] : []),
...formatFailureRows("FATAL", fatalErrors, pc.red),
"",
` ${pc.bold("Common causes")}`,
...options.causes.map((cause) => ` - ${cause}`),
"",
` ${pc.bold("Next step")}`,
` ${options.nextStep}`,
].join("\n");
}

/**
* 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[];
readonly fatalQueries: QueryFatalError[];

constructor(
queries: QuerySyntaxError[],
warehouseId?: string,
fatalQueries: QueryFatalError[] = [],
) {
super(
formatTypegenFailureMessage({
syntaxErrors: queries,
fatalErrors: fatalQueries,
warehouseId,
title: "DESCRIBE QUERY failed",
causes: [
"SQL syntax errors",
"missing tables or views",
"warehouse format incompatibilities",
],
nextStep: warehouseId
? `Run each SQL ERR query directly in a Databricks SQL editor against warehouse ${pc.bold(warehouseId)}.`
: "Run each SQL ERR query directly in a Databricks SQL editor.",
}),
);
this.name = "TypegenSyntaxError";
this.queries = queries;
this.fatalQueries = fatalQueries;
}
}

/**
* Thrown when DESCRIBE QUERY could not be requested because of a non-SQL fatal
* setup/request problem, such as missing permissions, invalid warehouse IDs, or
* malformed SDK configuration. Like TypegenSyntaxError, this is thrown only
* after the declaration file has been written with `result: unknown` entries.
*/
export class TypegenFatalError extends Error {
readonly queries: QueryFatalError[];

constructor(queries: QueryFatalError[], warehouseId?: string) {
super(
formatTypegenFailureMessage({
syntaxErrors: [],
fatalErrors: queries,
warehouseId,
title: "DESCRIBE QUERY could not be requested",
causes: [
"missing warehouse permissions",
"invalid warehouse ID",
"authentication failure",
"SDK configuration errors",
],
nextStep: warehouseId
? `Verify access to warehouse ${pc.bold(warehouseId)} and rerun type generation.`
: "Verify warehouse access and rerun type generation.",
}),
);
this.name = "TypegenFatalError";
this.queries = queries;
}
}

/**
* Generate type declarations for QueryRegistry
* Create the d.ts file from the plugin routes and query schemas
Expand Down Expand Up @@ -57,35 +206,30 @@ export async function generateFromEntryPoint(options: {
queryFolder?: string;
warehouseId: string;
noCache?: boolean;
mode?: PreflightMode;
}) {
const { outFile, queryFolder, warehouseId, noCache } = options;
const {
outFile,
queryFolder,
warehouseId,
noCache,
mode = "non-blocking",
} = options;
const projectRoot = resolveProjectRoot(outFile);

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[] = [];
let fatalErrors: QueryFatalError[] = [];
if (queryFolder) {
const result = await generateQueriesFromDescribe(queryFolder, warehouseId, {
noCache,
mode,
});
queryRegistry = result.schemas;
syntaxErrors = result.syntaxErrors ?? [];
fatalErrors = result.fatalErrors ?? [];
}

const typeDeclarations = generateTypeDeclarations(queryRegistry);
Expand All @@ -97,6 +241,17 @@ 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. Connectivity failures pass silently so a
// transient warehouse outage never blocks a build; genuine SQL errors and
// non-connectivity fatal request failures surface after the file write.
if (syntaxErrors.length > 0) {
throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors);
}
if (fatalErrors.length > 0) {
throw new TypegenFatalError(fatalErrors, warehouseId);
}

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

Expand Down
63 changes: 63 additions & 0 deletions packages/appkit/src/type-generator/preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { WarehouseState } from "./warehouse-status";

/**
* How aggressively typegen should react to a not-ready warehouse.
* - `non-blocking`: never describe and never probe the warehouse — emit
* best-available types (cache where the SQL hash matches, else `unknown`) and
* return at once. The default for interactive/foreground runs that can't
* afford to block on (or fail because of) a warehouse, even a RUNNING one.
* - `blocking`: a startable warehouse is worth waiting for, and a stopped one
* is worth starting — only a deleted/deleting warehouse is a hard failure.
*/
export type PreflightMode = "non-blocking" | "blocking";

/**
* What the caller should do given a warehouse state and mode.
* - `proceed`: run DESCRIBE now.
* - `degradeAll`: skip DESCRIBE; emit degraded (cached/`unknown`) types.
* - `waitThenProceed`: wait for the warehouse to start, then run DESCRIBE.
* - `startWaitProceed`: start the stopped warehouse, wait for RUNNING, then
* run DESCRIBE.
* - `fatal`: stop — the warehouse can't serve this run.
*/
export type PreflightDecision =
| "proceed"
| "degradeAll"
| "waitThenProceed"
| "startWaitProceed"
| "fatal";

/**
* Pure policy mapping a warehouse state + mode to a preflight decision.
*
* Unknown/unexpected states fall through to `proceed`: the describe loop and
* its per-query backstop already degrade gracefully, so we don't want a new
* SDK state value to turn into a spurious `fatal`.
*/
export function decidePreflight(
state: WarehouseState,
mode: PreflightMode,
): PreflightDecision {
// `non-blocking` never describes regardless of state: emit cached/`unknown`
// types and return. The caller short-circuits before probing, so this is only
// a belt-and-suspenders mapping, but it keeps the policy total and
// self-contained.
if (mode === "non-blocking") return "degradeAll";

// `blocking`: a starting warehouse is worth waiting for, a stopped one is
// worth starting (then waiting), and only a deleted/deleting one is fatal.
switch (state) {
case "RUNNING":
return "proceed";
case "STARTING":
return "waitThenProceed";
case "STOPPED":
case "STOPPING":
return "startWaitProceed";
case "DELETED":
case "DELETING":
return "fatal";
default:
return "proceed";
}
}
Loading
Loading