Skip to content
14 changes: 7 additions & 7 deletions docs/docs/development/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ AppKit can automatically generate TypeScript types for your SQL queries, providi

Generate type-safe TypeScript declarations for query keys, parameters, and result rows.

All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.d.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The `.d.ts` files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig.
All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.ts` — a real source file rather than a `.d.ts` because it also carries a runtime `metricViewsMetadata` constant alongside the augmentation. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig.

## Vite plugin: `appKitTypesPlugin`

Expand Down Expand Up @@ -84,28 +84,28 @@ npx @databricks/appkit generate-types --wait

#### CI resilience: committed types as fallback

In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed `.d.ts` files** (`shared/appkit-types/analytics.d.ts`, `metric-views.d.ts`) as the fallback when the warehouse is unreachable. These committed files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete.
In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed type files** (`shared/appkit-types/analytics.d.ts` and, when Metric Views are configured, `shared/appkit-types/metric-views.ts`) as the fallback when the warehouse is unreachable. These generated files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete.

The generator **never overwrites committed types with degraded (`result: unknown`) types** — it writes real types, or it does not write at all.

A **two-bucket failure taxonomy** determines whether the build crashes or falls back to committed types:

- **Deterministic failures (always crash):** SQL syntax errors in your queries (genuine DESCRIBE failure against a reachable warehouse), HTTP 404 (bad or unknown warehouse ID), HTTP 400 (malformed request). These are developer or configuration errors that committed types must not hide.
- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If committed types exist, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If no committed types exist, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the `.d.ts` files.
- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If every type file required by the app exists, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If a required file is missing, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the generated type files.

The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types.

**Note:** If your app declares only metric views and no `config/queries/`, the first build still writes an empty `analytics.d.ts`, which counts as "committed types present" for the gate. An environmental failure will then fall back and warn rather than crash, even on a first build — an accepted v1 simplification.
For a Metric Views app, `metric-views.ts` must already exist before an environmental failure can fall back successfully. Unlike a declaration-only artifact, this file also exports the runtime `metricViewsMetadata` value consumed by the server, so `analytics.d.ts` alone cannot satisfy the gate.

The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`.

## Metric-view types

`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes `metric-views.d.ts` into `shared/appkit-types/`:
`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes `metric-views.ts` into `shared/appkit-types/`:

- `metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('<key>', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level.
- `metric-views.ts` — augments the `MetricRegistry` interface so `useMetricView('<key>', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. The same file also exports a runtime `metricViewsMetadata` constant carrying that metadata as a value — inject it via `analytics({ metricViewsMetadata })` so the [metric route](../plugins/analytics.md#metric-views) can attach per-column display metadata to its response payload.

If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode.
If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode.

`definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`):

Expand Down
1 change: 1 addition & 0 deletions packages/appkit-ui/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
InferServingChunk,
InferServingRequest,
InferServingResponse,
MetricRegistry,
PluginRegistry,
QueryRegistry,
ServingAlias,
Expand Down
11 changes: 11 additions & 0 deletions packages/appkit-ui/src/react/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,14 @@ export type InferServingRequest<K> =
? Req
: Record<string, unknown>
: Record<string, unknown>;

// ============================================================================
// Metric View Registry
// ============================================================================

/**
* Metric view registry populated through module augmentation by the generated
* `metric-views.ts` file.
*/
// biome-ignore lint/suspicious/noEmptyInterface: intentionally empty — populated via module augmentation (generated metric-views.ts)
export interface MetricRegistry {}
20 changes: 19 additions & 1 deletion packages/appkit/src/plugins/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
composeMetricCacheKey,
deriveMetricExecutorKey,
loadMetricRegistry,
selectMetricMetadata,
validateMetricRequest,
} from "./metric";
import { QueryProcessor } from "./query";
Expand Down Expand Up @@ -557,6 +558,16 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider {
throw err;
}

// Computed here, outside the cached execute below, so a cache hit still
// serves the current metadata. Absent config → `undefined` → the `result`
// message omits the field (envelope-identical to `/query`).
const metadata = selectMetricMetadata(
this.config.metricViewsMetadata,
key,
request.measures,
request.dimensions,
);

// Cache key. Composed over the canonicalized args (sorted measures/
// dimensions, stable-sorted predicates, grain, timeDimension, limit) plus
// the `executorKey` — `"sp"` shares the cache across all users, a per-user
Expand Down Expand Up @@ -696,7 +707,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider {
throw ExecutionError.statementFailed(inner);
}

yield sqlResult.data as AnalyticsStreamMessage;
// Stamp the metadata onto the (possibly cached) result message; the
// cached message never carries it.
const resultMessage = sqlResult.data as AnalyticsSseMessage;
yield (
metadata !== undefined
? { ...resultMessage, metadata }
: resultMessage
) as AnalyticsStreamMessage;
},
streamExecutionSettings,
executorKey,
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/plugins/analytics/mv/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { composeMetricCacheKey, deriveMetricExecutorKey } from "./cache";
export { buildMetricSql } from "./formatters";
export { selectMetricMetadata } from "./metadata";
export { loadMetricRegistry } from "./registry";
export { validateMetricRequest } from "./schemas";
45 changes: 45 additions & 0 deletions packages/appkit/src/plugins/analytics/mv/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { MetricViewColumnDisplay, MetricViewsMetadata } from "shared";

/**
* Flatten the injected {@link MetricViewsMetadata} for `key` into a single
* `Record<column, meta>` covering only the requested measures and dimensions,
* so the client can label/format just the columns it queried.
*
* Pure response decoration: it never touches the cache key or the SQL, and
* reads only from the injected value (never disk / DESCRIBE at runtime).
*
* Lookups go through {@link Object.hasOwn}, so neither an inherited metric key
* nor an inherited column name (`toString`, `__proto__`, …) can resolve to a
* bogus entry. Requested columns absent from the metadata are omitted rather
* than placeheld.
*
* Returns `undefined` rather than an empty object when there is nothing to
* stamp, so the caller can omit the field and keep the message byte-identical
* to a plain `/query` result.
*/
export function selectMetricMetadata(
all: MetricViewsMetadata | undefined,
key: string,
measures: string[],
dimensions: string[] | undefined,
): Record<string, MetricViewColumnDisplay> | undefined {
if (!all || !Object.hasOwn(all, key)) {
return undefined;
}

const entry = all[key];
const slice: Record<string, MetricViewColumnDisplay> = {};

for (const measure of measures) {
if (Object.hasOwn(entry.measures, measure)) {
slice[measure] = entry.measures[measure];
}
}
for (const dimension of dimensions ?? []) {
if (Object.hasOwn(entry.dimensions, dimension)) {
slice[dimension] = entry.dimensions[dimension];
}
}

return Object.keys(slice).length > 0 ? slice : undefined;
}
Loading
Loading