From 7d99ef651787fb21ba8520ef1deb9f9ec0272e3b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 17:42:23 +0200 Subject: [PATCH 1/7] feat(analytics): add metric view metadata pipeline Signed-off-by: Atila Fassina --- docs/docs/development/type-generation.md | 14 +- packages/appkit-ui/src/react/hooks/index.ts | 1 + packages/appkit-ui/src/react/hooks/types.ts | 11 + .../appkit/src/plugins/analytics/analytics.ts | 36 +- .../appkit/src/plugins/analytics/mv/index.ts | 1 + .../src/plugins/analytics/mv/metadata.ts | 55 +++ .../plugins/analytics/tests/metric.test.ts | 371 +++++++++++++++++- .../appkit/src/plugins/analytics/types.ts | 20 +- packages/appkit/src/type-generator/errors.ts | 14 - packages/appkit/src/type-generator/index.ts | 87 ++-- .../mv-registry/render-types.ts | 123 +++++- .../src/type-generator/query-registry.ts | 13 +- .../__snapshots__/mv-registry.test.ts.snap | 58 ++- .../src/type-generator/tests/index.test.ts | 132 +++++-- .../type-generator/tests/mv-registry.test.ts | 163 ++++++-- .../tests/sync-metric-views-types.test.ts | 49 ++- .../tests/unreachable-warehouse-gate.test.ts | 23 +- .../type-generator/tests/vite-plugin.test.ts | 20 +- .../appkit/src/type-generator/vite-plugin.ts | 17 +- .../src/cli/commands/generate-types.test.ts | 4 +- .../shared/src/cli/commands/generate-types.ts | 2 +- packages/shared/src/index.ts | 1 + packages/shared/src/metric-metadata.ts | 18 + packages/shared/src/sse/analytics.ts | 20 +- 24 files changed, 1031 insertions(+), 222 deletions(-) create mode 100644 packages/appkit/src/plugins/analytics/mv/metadata.ts create mode 100644 packages/shared/src/metric-metadata.ts diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index bbce8d855..b7727ecef 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -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 single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The declaration 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. (`metric-views.ts` is a real source file rather than a `.d.ts` because it *also* carries a runtime `metricViewsMetadata` constant alongside the augmentation — see [Metric-view types](#metric-view-types).) TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. ## Vite plugin: `appKitTypesPlugin` @@ -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('', …)` 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('', …)` 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 (the same metadata as a value, not just types) — inject it via `analytics({ metricViewsMetadata })` so the metric route can carry per-column display metadata in its response payload. The type augmentation erases at build; the constant is a normal named export and is tree-shaken away when unused. See [the analytics plugin's metric-view docs](../plugins/analytics.md) for the hook + format-utility wiring. -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`): diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 63b639761..b110c3845 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -12,6 +12,7 @@ export type { InferServingChunk, InferServingRequest, InferServingResponse, + MetricRegistry, PluginRegistry, QueryRegistry, ServingAlias, diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index aa0df8905..7cb95cd99 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -247,3 +247,14 @@ export type InferServingRequest = ? Req : Record : Record; + +// ============================================================================ +// 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 {} diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index ed9fc112b..0433ce20e 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -35,6 +35,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "./metric"; import { QueryProcessor } from "./query"; @@ -557,6 +558,19 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw err; } + // Per-column metadata slice for the responding metric, scoped to the + // requested measures/dimensions. Pure response DECORATION: computed once + // from the injected config value, threaded into the `result` message below, + // and deliberately NOT part of the cache key or the SQL. 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 @@ -653,7 +667,9 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { ); // Reuse the query route's JSON delivery: INLINE JSON_ARRAY with // an ARROW_STREAM-inline fallback, returning plain rows in a - // `result` message — byte-identical envelope to `/query`. + // `result` message — byte-identical envelope to `/query`. The + // per-column `metadata` is stamped AFTER this cached call returns + // (see below), never baked into the cached message. return await self._executeJsonArrayPath( executor, statement, @@ -696,7 +712,17 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw ExecutionError.statementFailed(inner); } - yield sqlResult.data as AnalyticsStreamMessage; + // Stamp the FRESH per-column metadata onto the (possibly cached) result + // message. The cache key excludes metadata and the cached message never + // carries it, so a cache HIT after a redeploy that changed a column's + // display_name/format serves the current metadata, not a stale copy. + // `undefined` leaves the field absent — envelope-identical to `/query`. + const resultMessage = sqlResult.data as AnalyticsSseMessage; + yield ( + metadata !== undefined + ? { ...resultMessage, metadata } + : resultMessage + ) as AnalyticsStreamMessage; }, streamExecutionSettings, executorKey, @@ -708,6 +734,12 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`, * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result` * message. External links are never used for the JSON fallback. + * + * This returns the bare `result` message (rows + status/statement_id) and is + * cached by the caller. The metric route's per-column `metadata` is stamped + * onto the message AFTER the cached call returns (so a cache hit never serves + * stale metadata), never inside here — keeping the cached payload + * metadata-free and byte-identical to a plain `/query` result. */ private async _executeJsonArrayPath( executor: AnalyticsPlugin, diff --git a/packages/appkit/src/plugins/analytics/mv/index.ts b/packages/appkit/src/plugins/analytics/mv/index.ts index 17c97793e..beb12a4b9 100644 --- a/packages/appkit/src/plugins/analytics/mv/index.ts +++ b/packages/appkit/src/plugins/analytics/mv/index.ts @@ -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"; diff --git a/packages/appkit/src/plugins/analytics/mv/metadata.ts b/packages/appkit/src/plugins/analytics/mv/metadata.ts new file mode 100644 index 000000000..359d122d9 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -0,0 +1,55 @@ +import type { MetricColumnMeta, MetricViewsMetadata } from "shared"; + +/** + * Compute the per-column metadata slice for a metric response, scoped to the + * columns the request actually asked for. + * + * `all` is the build-generated {@link MetricViewsMetadata} the app injects via + * `analytics({ metricViewsMetadata })` — a per-metric map of `measures` / + * `dimensions` to their {@link MetricColumnMeta}. This flattens the requested + * measures and dimensions for `key` into a single `Record` for + * the SSE `result` message, so the client can label/format only the columns it + * queried. + * + * This is pure **response decoration**: it never touches the cache key or the + * SQL, and reads only from the injected value (never disk / DESCRIBE at runtime). + * + * Returns `undefined` (rather than an empty object) when there is nothing to + * stamp — so the caller can omit the field entirely and the message stays + * byte-identical to a plain `/query` result: + * - `all` is absent (no metadata injected), or + * - `key` is not an own property of `all` (unknown metric; uses + * {@link Object.hasOwn} so a prototype member like `toString` never + * resolves to a bogus entry), or + * - none of the requested columns are present in the metadata (fully + * degraded / unknown columns). + * + * Requested columns that are absent from the metadata are simply omitted — a + * degraded/unknown column produces no entry rather than a placeholder. + */ +export function selectMetricMetadata( + all: MetricViewsMetadata | undefined, + key: string, + measures: string[], + dimensions: string[] | undefined, +): Record | undefined { + if (!all || !Object.hasOwn(all, key)) { + return undefined; + } + + const entry = all[key]; + const slice: Record = {}; + + 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; +} diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 2fe78927e..4b5a2818c 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -8,6 +8,7 @@ import { mockServiceContext, setupDatabricksEnv, } from "@tools/test-helpers"; +import type { MetricViewsMetadata } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AppManager } from "../../../app"; import { ServiceContext } from "../../../context/service-context"; @@ -18,6 +19,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "../metric"; import type { @@ -134,7 +136,7 @@ function writeRegistry( writeFileSync(path.join(dir, "definitions.json"), body); } -describe("analytics metric route (Phase 1)", () => { +describe("analytics metric route", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -294,8 +296,8 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimensions + GROUP BY ALL. Bare dimensions here; date_trunc - // grain application (via timeDimension) is covered in its own block below. + // ── dimensions + GROUP BY ALL. Bare dimensions here; date_trunc grain + // application (via timeDimension) is covered in its own block below. describe("buildMetricSql dimensions + GROUP BY", () => { const registration: MetricRegistration = { key: "revenue", @@ -366,7 +368,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2a: timeGrain + timeDimension → date_trunc on the named column. + // ── timeGrain + timeDimension → date_trunc on the named column. // The grain is a grammar-gated single-quoted literal; the column keeps its // plain alias; other dimensions render bare; GROUP BY ALL is present. describe("buildMetricSql timeGrain + timeDimension (date_trunc)", () => { @@ -420,7 +422,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimension identifier safety. A dimension is backtick-quoted at + // ── dimension identifier safety. A dimension is backtick-quoted at // interpolation, so an injection-shaped name is neutralized (inert quoted // column), and only an unquotable (control-char) name throws. describe("buildMetricSql dimension identifier safety (quoting)", () => { @@ -461,7 +463,7 @@ describe("analytics metric route (Phase 1)", () => { }); // ── Envelope parity — streams warehouse_status* then a `result` message, - // byte-identical to the /query route's JSON SSE path. + // the same event shape as the /query route's JSON SSE path. describe("_handleMetricRoute SSE envelope", () => { test("streams warehouse_status then a result message with aliased rows", async () => { const plugin = pluginForDir( @@ -588,6 +590,253 @@ describe("analytics metric route (Phase 1)", () => { expect(mockRes.status).toHaveBeenCalledWith(400); }); + + // ── Metadata stamping. The injected `metricViewsMetadata` is sliced to the + // requested columns and stamped into the `result` message; it is pure + // decoration (no SQL / cache-key effect). See `selectMetricMetadata` below + // for the unit-level scoping tests. + const REVENUE_METADATA: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + }; + + /** Extract the parsed `result` SSE payload from the mock response writes. */ + function readResultPayload(mockRes: ReturnType) { + const dataLine = (mockRes.write as any).mock.calls + .map((call: any[]) => call[0] as string) + .find( + (s: string) => + s.startsWith("data: ") && s.includes('"type":"result"'), + ); + if (!dataLine) return undefined; + return JSON.parse(dataLine.slice("data: ".length).trim()); + } + + test("stamps the per-column metadata slice into the result message", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234, region: "EMEA" }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"], dimensions: ["region"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // Only the requested columns are present — `mrr`/`segment` are omitted. + expect(payload.metadata).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits the metadata field entirely when no metadata is injected (envelope parity with /query)", async () => { + const plugin = pluginForDir( + config, // no metricViewsMetadata + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // Envelope parity with a plain `/query` result: the `metadata` key is + // absent, not present-but-undefined. + expect(payload).toBeDefined(); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + expect(payload.data).toEqual([{ arr: 1234 }]); + }); + + test("omits metadata when only degraded/unknown columns are requested", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ unknown_measure: 1 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["unknown_measure"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + }); + + test("metadata presence does NOT change the SQL or the cache key", async () => { + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + // Capture the composed cache key the inner `execute` hands to the shared + // CacheManager mock — the same key whether or not metadata is injected. + const cacheKeyFor = async (mvMeta?: MetricViewsMetadata) => { + mockCacheInstance.getOrExecute.mockClear(); + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + createMockResponse(), + ); + // First getOrExecute call is the SQL execution's cache interceptor. + const call = mockCacheInstance.getOrExecute.mock.calls[0]; + return { cacheKey: call[0], userKey: call[2] }; + }; + + const withMeta = await cacheKeyFor(REVENUE_METADATA); + const withoutMeta = await cacheKeyFor(undefined); + + expect(withMeta.cacheKey).toEqual(withoutMeta.cacheKey); + expect(withMeta.userKey).toEqual(withoutMeta.userKey); + // And the SQL is unchanged (measures/dimensions only). + expect(executeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + statement: + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL", + }), + expect.any(AbortSignal), + ); + }); + + test("cache HIT serves the FRESH metadata, not the metadata baked in at cache-fill time", async () => { + // Regression: metadata was formerly stamped INSIDE the cached execute(), + // so a cache hit replayed the OLD labels/formats even after a redeploy + // changed them. The cache key excludes metadata, so the SQL result is a + // hit across the two runs below; only the injected metadata differs. + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + const runWithMetadata = async (mvMeta: MetricViewsMetadata) => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + mockRes, + ); + return readResultPayload(mockRes); + }; + + // First run fills the cache with the OLD labels. + const oldMeta: MetricViewsMetadata = { + revenue: { + measures: { arr: { type: "decimal", display_name: "ARR (old)" } }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const first = await runWithMetadata(oldMeta); + expect(first.metadata.arr.display_name).toBe("ARR (old)"); + + // Second run: same body → SQL cache HIT (executeStatement not called + // again), but the app now injects NEW labels. The response must reflect + // the fresh metadata, not the stale copy from the cached message. + executeMock.mockClear(); + const newMeta: MetricViewsMetadata = { + revenue: { + measures: { + arr: { + type: "decimal", + display_name: "ARR (new)", + format: "$#,##0", + }, + }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const second = await runWithMetadata(newMeta); + + expect(executeMock).not.toHaveBeenCalled(); // SQL served from cache + expect(second.metadata.arr.display_name).toBe("ARR (new)"); + expect(second.metadata.arr.format).toBe("$#,##0"); + }); }); // ── 503-vs-404 latching + dormancy. @@ -807,9 +1056,9 @@ describe("analytics metric route (Phase 1)", () => { }); // ── loadMetricRegistry: config parse against the landed metricSourceSchema. -// The loader reads the config file THROUGH an `AppManager` (Phase 2), so each -// test points an `AppManager` at its temp dir instead of passing a bare -// directory string. The loader is stateless — it reads + parses on every call +// The loader reads the config file THROUGH an `AppManager`, so each test points +// an `AppManager` at its temp dir instead of passing a bare directory string. +// The loader is stateless — it reads + parses on every call // (no memoization), so there is no cache to reset between tests. describe("loadMetricRegistry", () => { let dir: string; @@ -942,9 +1191,9 @@ describe("loadMetricRegistry", () => { }); }); -// ── Phase 2: the structured filter engine (translator + validator). -// Registry-free: names are grammar-gated, values are parameterized. No -// allowlist, no op⇄dimension-type check. +// ── The structured filter engine (translator + validator). Registry-free: +// names are grammar-gated, values are parameterized. No allowlist, no +// op⇄dimension-type check. describe("metric — filter translator", () => { const registration: MetricRegistration = { key: "revenue", @@ -1662,8 +1911,8 @@ describe("metric — filter translator", () => { }); // The warehouse-authoritative unknown-name parity test (sanitized - // clientMessage/errorCode envelope) lands here because the Phase 1 harness - // can drive the metric route end-to-end and assert on the SSE error bytes. + // clientMessage/errorCode envelope) lands here because this harness can drive + // the metric route end-to-end and assert on the SSE error bytes. describe("warehouse-authoritative unknown-name parity", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -1745,7 +1994,7 @@ describe("metric — filter translator", () => { }); }); -// ── Phase 3: cache-key composition. `composeMetricCacheKey` produces the +// ── cache-key composition. `composeMetricCacheKey` produces the // array `CacheManager.generateKey` concatenates + sha256s; the invariants // below are what make the cache both correct (semantically equal calls collapse) // and safe (distinct args / executors never collide). @@ -1985,7 +2234,7 @@ describe("composeMetricCacheKey", () => { }); }); -// ── Phase 3: executor-key isolation. The key is what scopes the cache — `"sp"` +// ── executor-key isolation. The key is what scopes the cache — `"sp"` // shares it across all callers, a per-user hash isolates OBO callers. The raw // identity must never enter the key verbatim (privacy: cache keys are logged // and persisted). @@ -2058,12 +2307,12 @@ describe("deriveMetricExecutorKey", () => { }); }); -// ── Phase 3: lane dispatch at the handler level. The lane comes from the +// ── lane dispatch at the handler level. The lane comes from the // registration (the entry's `executor` in definitions.json), NOT the URL: // OBO-lane routes through `asUser(req)`, SP-lane through the default executor. // A missing/whitespace OBO identity must land on the canonical 401 envelope, // never an out-of-envelope 500. -describe("metric route — lane dispatch (Phase 3)", () => { +describe("metric route — lane dispatch", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -2232,3 +2481,89 @@ describe("metric route — lane dispatch (Phase 3)", () => { expect(executeMock).not.toHaveBeenCalled(); }); }); + +// ── metadata slicing. `selectMetricMetadata` flattens the injected +// per-metric metadata down to only the requested columns for the SSE `result` +// message. It is pure and total; the invariants below are what keep the stamp +// scoped, degrade-safe, and prototype-safe. +describe("selectMetricMetadata", () => { + const all: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + orders: { + measures: { cnt: { type: "bigint" } }, + dimensions: {}, + }, + }; + + test("returns only the requested measures and dimensions (flat slice)", () => { + expect(selectMetricMetadata(all, "revenue", ["arr"], ["region"])).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits requested columns absent from the metadata (degraded/unknown cols)", () => { + // `mrr` is known; `ebitda` and `country` are not → dropped, not placeheld. + expect( + selectMetricMetadata(all, "revenue", ["mrr", "ebitda"], ["country"]), + ).toEqual({ + mrr: { type: "decimal", display_name: "MRR" }, + }); + }); + + test("undefined when no metadata is injected (all absent)", () => { + expect( + selectMetricMetadata(undefined, "revenue", ["arr"], ["region"]), + ).toBeUndefined(); + }); + + test("undefined for an unknown metric key", () => { + expect( + selectMetricMetadata(all, "nope", ["arr"], undefined), + ).toBeUndefined(); + }); + + test("undefined when none of the requested columns are present (empty slice)", () => { + expect( + selectMetricMetadata(all, "revenue", ["unknown"], ["also_unknown"]), + ).toBeUndefined(); + }); + + test("undefined when dimensions is undefined and no measures match", () => { + expect( + selectMetricMetadata(all, "orders", ["missing"], undefined), + ).toBeUndefined(); + }); + + test("handles undefined dimensions (measures only)", () => { + expect(selectMetricMetadata(all, "orders", ["cnt"], undefined)).toEqual({ + cnt: { type: "bigint" }, + }); + }); + + test.each(["__proto__", "constructor", "toString", "hasOwnProperty"])( + "inherited Object.prototype key %j → undefined (own-property lookup)", + (dangerousKey) => { + expect( + selectMetricMetadata(all, dangerousKey, ["arr"], undefined), + ).toBeUndefined(); + }, + ); + + test("does not resolve a requested column to an inherited prototype member", () => { + // `toString` is an inherited member of the measures object, not an own + // entry — it must not leak into the slice as a bogus function value. + expect( + selectMetricMetadata(all, "revenue", ["toString"], ["hasOwnProperty"]), + ).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index 83e4f737c..feb535e6f 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,7 +1,17 @@ -import type { BasePluginConfig } from "shared"; +import type { + BasePluginConfig, + MetricColumnMeta, + MetricViewsMetadata, +} from "shared"; export interface IAnalyticsConfig extends BasePluginConfig { timeout?: number; + /** + * Build-generated per-metric column metadata, keyed by metric key. The + * metric route scopes this to the requested measures and dimensions before + * attaching it to the SSE result. It never affects SQL or cache identity. + */ + metricViewsMetadata?: MetricViewsMetadata; /** * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL * warehouse to reach RUNNING before failing the request. Defaults to 5 min. @@ -61,7 +71,13 @@ export interface WarehouseStatus { */ export type AnalyticsStreamMessage = | { type: "warehouse_status"; status: WarehouseStatus } - | { type: "result"; data: unknown[] } + | { + type: "result"; + data?: unknown[]; + status?: unknown; + statement_id?: string; + metadata?: Record; + } | { type: "arrow"; statement_id: string; diff --git a/packages/appkit/src/type-generator/errors.ts b/packages/appkit/src/type-generator/errors.ts index 8a074795c..a6053f576 100644 --- a/packages/appkit/src/type-generator/errors.ts +++ b/packages/appkit/src/type-generator/errors.ts @@ -161,8 +161,6 @@ const AUTH_ERROR_STATUSES = new Set([401, 403]); export function classifyBlockingFailure( error: unknown, ): "deterministic" | "environmental" { - // Deterministic: check first so they're never swallowed by environmental rules. - // Walk the error chain to find any deterministic status. const seen = new Set(); const stack = [error]; @@ -179,18 +177,6 @@ export function classifyBlockingFailure( stack.push(...getErrorChildren(current)); } - // Environmental: auth, connectivity, unrecognized, default. - const topLevelStatus = getErrorStatus(error); - if (topLevelStatus !== undefined && AUTH_ERROR_STATUSES.has(topLevelStatus)) { - return "environmental"; - } - - if (isConnectivityError(error)) { - return "environmental"; - } - - // Default: any unrecognized failure or no status (DELETED/DELETING messages, - // timeout messages, plain Error objects) → environmental. return "environmental"; } diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 286874852..4f7a058d7 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -60,10 +60,6 @@ const logger = createLogger("type-generator"); */ const MV_PREFLIGHT_WAIT_MAX_MS = 300_000; -/** - * Generate a loud warning message for environmental failures with committed types present. - * @param cause - coarse cause label: "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other) - */ function determineWarningMessage( cause: "auth" | "unreachable" | "unavailable", warehouseId: string, @@ -85,18 +81,20 @@ function plural(count: number, singular: string, pluralForm = `${singular}s`) { } /** - * Check if committed type artifacts exist (at least one of the requested surfaces). - * Serving types are excluded (gitignored, never part of the gate). - * Returns true if either the analytics or metric-views committed .d.ts file exists. + * Check that every type artifact required by this run exists. + * Serving types are excluded (gitignored, never part of the gate). When metric + * views are configured, their `.ts` artifact is required in addition to the + * analytics declarations because it also provides runtime metadata exports. */ -function hasCommittedTypes( +function hasRequiredCommittedTypes( analyticsOutFile: string, - metricViewsOutFile: string | undefined, + requiredMetricViewsOutFile: string | undefined, ): boolean { const hasAnalytics = existsSync(analyticsOutFile); - const hasMetrics = - metricViewsOutFile !== undefined && existsSync(metricViewsOutFile); - return hasAnalytics || hasMetrics; + const hasRequiredMetrics = + requiredMetricViewsOutFile === undefined || + existsSync(requiredMetricViewsOutFile); + return hasAnalytics && hasRequiredMetrics; } function isQueryDegraded(schema: QuerySchema): boolean { @@ -326,7 +324,7 @@ async function probeWarehouseState( * `metric-views` directory of `queryFolder` (so query-only callers keep * working); when neither is given, the metric path is skipped. * @param options.mvOutFile - optional output file for the MetricRegistry - * augmentation. Defaults to a sibling `metric-views.d.ts` file under the same + * augmentation. Defaults to a sibling `metric-views.ts` file under the same * directory as `outFile`. Skipped entirely if `definitions.json` is absent. * @param options.metricFetcher - optional DescribeFetcher used by * {@link syncMetrics} (tests inject a mock; production lazily builds a @@ -374,6 +372,8 @@ export async function generateFromEntryPoint(options: { let hadEnvironmentalFailure = false; // Track the coarse cause of the environmental failure for the warning message. let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; + // Set when definitions.json makes metric-views.ts a required build input. + let metricTypesRequired = false; if (queryFolder) { const result = await generateQueriesFromDescribe(queryFolder, warehouseId, { @@ -428,7 +428,7 @@ export async function generateFromEntryPoint(options: { mode, // In blocking mode, never overwrite committed metric types with a // degraded result — including on runs that go on to throw, so a failing - // build leaves the committed .d.ts intact. Non-blocking always writes. + // build leaves the committed type file intact. Non-blocking always writes. suppressDegradedWrite: mode === "blocking", }); } catch (configError) { @@ -444,6 +444,8 @@ export async function generateFromEntryPoint(options: { ); } + metricTypesRequired = !mvResult.noConfig; + // Deleted/deleting-warehouse fatal preflight (blocking mode only); // empty (no-op) when definitions.json is absent or in non-blocking mode. // Only deterministic fatals are recorded in fatalErrors. @@ -489,7 +491,10 @@ export async function generateFromEntryPoint(options: { const resolvedMvFile = options.mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); - const hasTypes = hasCommittedTypes(outFile, resolvedMvFile); + const hasTypes = hasRequiredCommittedTypes( + outFile, + metricTypesRequired ? resolvedMvFile : undefined, + ); if (hasTypes) { // Committed types present: emit loud warning and exit 0. @@ -499,12 +504,12 @@ export async function generateFromEntryPoint(options: { ); logger.warn(warningMessage); } else { - // No committed types: crash with a generic message. + // A required committed type file is missing: crash with a generic message. throw new TypegenFatalError( [ { name: "type-generator", - message: `Warehouse ${warehouseId} could not be reached and no committed types exist. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated .d.ts files.`, + message: `Warehouse ${warehouseId} could not be reached and required committed type files are missing. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated type files.`, }, ], warehouseId, @@ -564,14 +569,16 @@ export interface SyncMetricViewsTypesResult { * * @param options.metricViewsFolder - folder that holds `definitions.json` (`/config/metric-views`). * @param options.warehouseId - SQL warehouse used for `DESCRIBE TABLE EXTENDED`. - * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`. + * @param options.metricOutFile - output path for the MetricRegistry `.ts` (the + * generated source carries both the `declare module` augmentation and the + * runtime `metricViewsMetadata` const). * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). * @param options.metricFetcher - optional injected {@link DescribeFetcher} * @param options.mode - preflight/gate policy, default `"describe-now"`. When set to `"blocking"`, - * metric-view .d.ts writes are suppressed if any metric is degraded (to preserve committed files). + * metric-view `.ts` writes are suppressed if any metric is degraded (to preserve committed files). * @param options.suppressDegradedWrite - when true (only in `mode === "blocking"` context), skip * the metricOutFile write if any metric schema has `degraded === true`. Used to prevent - * overwriting committed .d.ts files with degraded types in blocking mode. + * overwriting committed type files with degraded types in blocking mode. */ export async function syncMetricViewsTypes(options: { metricViewsFolder: string; @@ -693,15 +700,12 @@ export async function syncMetricViewsTypes(options: { // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs degrade // a not-ready / unreachable warehouse rather than throwing. if (!isConnectivityError(err)) { - // Classify: deterministic (404/400) or environmental (auth, etc). + // Deterministic failures become fatal errors; environmental failures + // degrade for the committed-types gate. const classification = classifyBlockingFailure(err); if (classification === "deterministic") { - // Keep as fatal preflight for deterministic errors (404/400). preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } else { - // Environmental: set preflightFatalMessage so DESCRIBE is skipped, but - // mark hadEnvironmentalFailure so the gate handles it later (not added - // to fatalErrors). preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; hadEnvironmentalFailure = true; environmentalCause = classifyEnvironmentalCause(err); @@ -727,15 +731,9 @@ export async function syncMetricViewsTypes(options: { let described: MetricSchema[]; let failures: MetricSyncFailure[] = []; if (preflightFatalMessage !== undefined) { - // Fatal preflight (deleted/deleting warehouse or deterministic error): - // skip DESCRIBE, emit degraded schemas so both artifacts are still written, - // and record one fatal error per describe-needed key (cache hits are - // unaffected) ONLY if it's a deterministic error. Environmental failures - // degrade silently. The degraded schemas are not cached (see the write - // block), so a later pass re-probes. + // Environmental failures degrade for the committed-types gate; deterministic + // failures record one fatal error per key. Degraded schemas are not cached. described = describeNeeded.map(emptyMetricSchema); - // Only deterministic fatals (404/400) record errors; environmental failures - // degrade silently for the has-types gate to handle. if (!hadEnvironmentalFailure) { for (const entry of describeNeeded) { fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); @@ -795,9 +793,7 @@ export async function syncMetricViewsTypes(options: { environmentalCause = environmentalCause ?? "unavailable"; } } else { - // Un-probed DESCRIBEs deliberately skipped, not failures: emit each - // describe-needed key as a degraded schema so both artifacts exist; cache - // hits keep serving last-known-good. This is an environmental failure path. + // Un-probed DESCRIBEs emit degraded schemas; cache hits remain last-known-good. described = describeNeeded.map(emptyMetricSchema); logger.info( "Warehouse %s is not running — wrote degraded metric types (permissive) for %d metric view(s) (%s); they will refresh once the warehouse is available.", @@ -861,7 +857,7 @@ export async function syncMetricViewsTypes(options: { // Same anti-clobber rule as the query path: when suppressDegradedWrite is set // (blocking mode), skip the write if any metric degraded, preserving the - // committed metric-views.d.ts. Non-blocking mode always writes. + // committed metric-views.ts. Non-blocking mode always writes. const shouldWriteMetrics = !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); @@ -873,6 +869,21 @@ export async function syncMetricViewsTypes(options: { "utf-8", ); } + // Sweep a stale sibling `metric-views.d.ts` from a pre-`.ts` version. Older + // typegen emitted an ambient `.d.ts`; the current output is a real `.ts` at + // `metricOutFile`. Left behind, the old sibling would duplicate the + // `declare module` augmentation and re-introduce the bare side-effect import + // the new header deliberately drops. Best-effort: only removed when the new + // file is itself a `.ts` (never delete the file we just wrote), ENOENT-safe. + if (metricOutFile.endsWith(".ts") && !metricOutFile.endsWith(".d.ts")) { + const staleDts = `${metricOutFile.slice(0, -".ts".length)}.d.ts`; + try { + await fs.unlink(staleDts); + logger.debug("Removed stale generated types at %s", staleDts); + } catch { + // No stale sibling — nothing to clean up. + } + } logger.debug( "Wrote MetricRegistry augmentation for %d metric(s)%s", @@ -914,4 +925,4 @@ export type { export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; -export const METRIC_TYPES_FILE = "metric-views.d.ts"; +export const METRIC_TYPES_FILE = "metric-views.ts"; diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index f70e584c8..c1614c229 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -116,6 +116,35 @@ function renderDegradedMetricEntry(schema: MetricSchema): string { }`; } +type RenderedMetadataField = readonly [name: string, value: string]; + +// Build the canonical rendered fields shared by type-level and runtime +// metadata. `time_grain` is type-only and is included only when requested. +function metadataFields( + col: MetricColumnMetadata, + includeTimeGrain = false, +): RenderedMetadataField[] { + const fields: RenderedMetadataField[] = [["type", JSON.stringify(col.type)]]; + const optionalFields = [ + ["display_name", col.displayName], + ["format", col.format], + ["description", col.description], + ] as const; + + for (const [name, value] of optionalFields) { + if (value) { + fields.push([name, JSON.stringify(value)]); + } + } + + if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { + const grainTuple = col.timeGrains.map((g) => JSON.stringify(g)).join(", "); + fields.push(["time_grain", `readonly [${grainTuple}]`]); + } + + return fields; +} + // Render the type-level shape of a column's semantic-metadata map // for the `metadata` field of a MetricRegistry entry. function renderMetadataMap( @@ -127,23 +156,9 @@ function renderMetadataMap( const inner = cols .map((col) => { - const fields: string[] = [`type: ${JSON.stringify(col.type)}`]; - if (col.displayName) { - fields.push(`display_name: ${JSON.stringify(col.displayName)}`); - } - if (col.format) { - fields.push(`format: ${JSON.stringify(col.format)}`); - } - if (col.description) { - fields.push(`description: ${JSON.stringify(col.description)}`); - } - if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { - const grainTuple = col.timeGrains - .map((g) => JSON.stringify(g)) - .join(", "); - fields.push(`time_grain: readonly [${grainTuple}]`); - } - const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(";\n"); + const fieldsBlock = metadataFields(col, includeTimeGrain) + .map(([name, value]) => `${indent} ${name}: ${value}`) + .join(";\n"); return `${indent}${JSON.stringify(col.name)}: { ${fieldsBlock}; ${indent}}`; @@ -155,6 +170,63 @@ ${inner}; }`; } +// Render one column's runtime metadata object literal — the value-side twin of +// a `renderMetadataMap` entry. Sources the SAME per-column fields +// (type/display_name/format/description) but omits `time_grain` (not part of +// MetricColumnMeta). Strings go through JSON.stringify so quotes/backticks in +// display_name/description stay escape-safe. +function renderMetadataValueField(col: MetricColumnMetadata): string { + const fields = metadataFields(col).map( + ([name, value]) => `${name}: ${value}`, + ); + return `{ ${fields.join(", ")} }`; +} + +// Render the runtime value map (measures or dimensions) for one metric — an +// object literal keyed by column name. Empty → `{}` (the value twin of the +// type-level `Record`, which is a type-only construct). +function renderMetadataValueMap( + cols: MetricColumnMetadata[], + indent: string, +): string { + if (cols.length === 0) return "{}"; + const inner = cols + .map( + (col) => + `${indent} ${JSON.stringify(col.name)}: ${renderMetadataValueField(col)}`, + ) + .join(",\n"); + return `{ +${inner}, +${indent}}`; +} + +// Render the runtime `metricViewsMetadata` const — a value twin of the +// type-level `metadata` blocks, conforming to MetricViewsMetadata from +// "shared". Emitted `as const`. Iterates `schemas` in the SAME order as the +// type augmentation. A degraded schema (empty measure/dimension arrays) +// contributes empty `measures: {}` / `dimensions: {}` maps, consistent with +// its degraded type block. +function renderMetricViewsMetadata(schemas: MetricSchema[]): string { + if (schemas.length === 0) { + return "export const metricViewsMetadata = {} as const;\n"; + } + const entries = schemas + .map((schema) => { + const measures = renderMetadataValueMap(schema.measures, " "); + const dimensions = renderMetadataValueMap(schema.dimensions, " "); + return ` ${JSON.stringify(schema.key)}: { + measures: ${measures}, + dimensions: ${dimensions}, + }`; + }) + .join(",\n"); + return `export const metricViewsMetadata = { +${entries}, +} as const; +`; +} + // Render the augmentation block for the appkit-ui MetricRegistry interface. function renderMetricRegistry(schemas: MetricSchema[]): string { if (schemas.length === 0) { @@ -172,12 +244,23 @@ ${entries}; `; } -// Build the full metric-views.d.ts file from a list of metric schemas. +/** + * Build the full metric-views.ts file from a list of metric schemas. + * + * This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the + * erasable `declare module` type augmentation AND a runtime value export + * (`metricViewsMetadata`). It must therefore never emit a runtime side-effect + * import — a bare `import "@databricks/appkit-ui/react"` would execute the + * client package entry on the Node server. The header is a type-only + * `import type {} from "..."`, which (a) compiles to zero runtime code and + * (b) anchors the module so the global `declare module` augmentation resolves. + */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { return `// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; -${renderMetricRegistry(schemas)}`; +import type {} from "@databricks/appkit-ui/react"; +${renderMetricRegistry(schemas)} +${renderMetricViewsMetadata(schemas)}`; } diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 720c61443..108957915 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -762,31 +762,20 @@ export async function generateQueriesFromDescribe( isEnvironmental = true; environmentalCause = "unreachable"; } else { - // Classify the exception: deterministic (404/400) or environmental (auth, etc). const classification = classifyBlockingFailure(err); if (classification === "deterministic") { - // Build-failing deterministic error (bad warehouse id, malformed request). decision = "fatal"; fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; - isEnvironmental = false; } else { - // Environmental: auth, timeouts, unrecognized, etc. Degrade for the - // has-types gate to handle later. decision = "degradeAll"; isEnvironmental = true; environmentalCause = classifyEnvironmentalCause(err); - fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } } } } - // Record blocking-mode environmental failures for the has-types gate. - if ( - mode === "blocking" && - ((decision === "degradeAll" && isEnvironmental) || - (decision === "fatal" && isEnvironmental)) - ) { + if (mode === "blocking" && decision !== "proceed" && isEnvironmental) { hadEnvironmentalFailure = true; } diff --git a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap index 6970320c2..7d1992ec9 100644 --- a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap +++ b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap @@ -3,7 +3,7 @@ exports[`generateMetricTypeDeclarations — snapshot > emits TimeGrain union for a metric view with time-typed + regular dimensions 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "revenue": { @@ -48,13 +48,26 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "revenue": { + measures: { + "arr": { type: "DECIMAL(38,2)", description: "Annual recurring revenue" }, + }, + dimensions: { + "created_at": { type: "TIMESTAMP" }, + "region": { type: "STRING" }, + "segment": { type: "STRING" }, + }, + }, +} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits a stable MetricRegistry augmentation for a mixed sp + obo input 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "customer_metrics": { @@ -138,23 +151,47 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "customer_metrics": { + measures: { + "churn_rate": { type: "DOUBLE", display_name: "Churn Rate", format: "0.0%" }, + }, + dimensions: { + "csm_email": { type: "STRING" }, + "billing_date": { type: "DATE" }, + }, + }, + "revenue": { + measures: { + "arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00", description: "Annual recurring revenue" }, + "mrr": { type: "DECIMAL(38,2)", description: "Monthly recurring revenue" }, + }, + dimensions: { + "region": { type: "STRING" }, + "created_at": { type: "TIMESTAMP" }, + }, + }, +} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits an empty MetricRegistry interface when no metrics are registered 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry {} } + +export const metricViewsMetadata = {} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits permissive types for a degraded entry and accurate empty unions for a confirmed-empty entry 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */ @@ -195,5 +232,18 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "cold_metric": { + measures: {}, + dimensions: {}, + }, + "dims_only": { + measures: {}, + dimensions: { + "region": { type: "STRING" }, + }, + }, +} as const; " `; diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index b14bc85f6..4f6aaf709 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -292,8 +292,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // not passed explicitly, so these tests only pass `queryFolder` below. const metricViewsFolder = path.join(metricsDir, "metric-views"); const outFile = path.join(metricsDir, "generated", "analytics.d.ts"); - // Default: the metric .d.ts is a sibling of `outFile`. - const metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); + // Default: the metric .ts is a sibling of `outFile`. + const metricFile = path.join(metricsDir, "generated", "metric-views.ts"); const describeResponse: DatabricksStatementExecutionResponse = { statement_id: "stmt-mock", @@ -325,6 +325,13 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }; + const committedMetricTypes = + "// committed metric types\nexport const metricViewsMetadata = {};\n"; + const writeCommittedMetricTypes = () => { + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + fs.writeFileSync(metricFile, committedMetricTypes, "utf-8"); + }; + beforeEach(() => { vi.clearAllMocks(); mocks.cacheFile.contents = undefined; @@ -342,7 +349,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { fs.rmSync(metricsDir, { recursive: true, force: true }); }); - test("writes metric-views.d.ts when definitions.json exists", async () => { + test("writes metric-views.ts when definitions.json exists", async () => { writeMetricConfig(); await expect( @@ -359,9 +366,19 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('"revenue"'); expect(declarations).toContain('"total_revenue": number'); expect(declarations).toContain('"region": string'); - // Semantic metadata (SQL type) rides in the .d.ts type-level `metadata` + // Semantic metadata (SQL type) rides in the type-level `metadata` // block — the sole carrier now that the JSON bundle is gone. expect(declarations).toContain('"DECIMAL(38,2)"'); + // The generated file is a real `.ts`, so it also carries the runtime + // `metricViewsMetadata` const (value twin of the type-level metadata). + expect(declarations).toContain("export const metricViewsMetadata"); + expect(declarations).toContain("as const"); + // ...and NEVER a runtime side-effect import that would execute the client + // package entry on the Node server — only a zero-runtime type-only import. + expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); + expect(declarations).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); }); test("emits no metric artifacts and no errors when definitions.json is absent", async () => { @@ -464,7 +481,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain("timeGrains: string"); }); - // ── Non-blocking warehouse gate: metric DESCRIBEs honor the #406 contract ── + // ── Non-blocking warehouse gate: metric DESCRIBEs are skipped when the + // warehouse isn't running (degraded types still emitted) ── test("non-blocking + warehouse not running: skips all DESCRIBEs but still emits degraded artifacts", async () => { fs.writeFileSync( @@ -638,6 +656,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { writeMetricConfig(); + writeCommittedMetricTypes(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -664,7 +683,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { const warned = warnSpy.mock.calls.flat().map(String).join("\n"); expect(warned).not.toContain("metric sync failed"); // Degraded artifacts are suppressed, not written (to preserve committed types). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -744,9 +763,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + DELETED: environmental failure with committed types → no throw, warning emitted", async () => { - // DELETED is environmental. Since the query path writes analytics.d.ts - // (even with empty registry), committed types exist, so emit warning + return 0. + // DELETED is environmental. Both required committed artifacts exist, so + // emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("DELETED"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -770,7 +790,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is NEVER cached (mirrors the query path): the key is // left uncached so a later pass re-probes, and no stale/sticky entry can be @@ -780,9 +800,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + preflight wait rejects with a timeout: environmental failure with committed types → no throw, warning emitted", async () => { - // Timeout is environmental. Since the query path writes analytics.d.ts, - // committed types exist, so emit warning + return 0. + // Timeout is environmental. Both required committed artifacts exist, so + // emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockRejectedValue( new Error( @@ -816,7 +837,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached — the key stays uncached for the next // pass to re-probe. @@ -831,6 +852,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // Degraded artifacts are NOT written in blocking mode when there are no failures // (to preserve committed good types). writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockResolvedValue("STOPPED"); // The fall-through DESCRIBE hits a still-cold warehouse: non-terminal @@ -873,7 +895,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // degraded the key. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); // Degraded artifacts are suppressed, not written (to preserve committed types). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached; the key stays uncached and the next // describe-capable pass re-probes it (convergence via re-describe, not via a @@ -891,9 +913,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { ])( "blocking + warehouse deleted mid-wait (probe read %s): environmental failure with committed types → no throw, warning emitted", async (probedState, startsWarehouse) => { - // DELETED mid-wait is environmental. Since the query path writes - // analytics.d.ts, committed types exist, so emit warning + return 0. + // DELETED mid-wait is environmental. Both required committed artifacts + // exist, so emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue(probedState); mocks.startWarehouse.mockResolvedValue(undefined); // The warehouse was deleted while the preflight waited: the wait @@ -916,7 +939,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached — no sticky entry to serve later. const metrics = @@ -1094,7 +1117,7 @@ describe("generateFromEntryPoint — metric cache section", () => { // derives it from queryFolder when not passed explicitly. const metricViewsFolder = path.join(cacheTestDir, "metric-views"); const outFile = path.join(cacheTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); + const metricFile = path.join(cacheTestDir, "generated", "metric-views.ts"); const describeResponseFor = ( measure: string, @@ -1824,11 +1847,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { const queryFolder = path.join(antiClobberDir, "queries"); const metricViewsFolder = path.join(antiClobberDir, "metric-views"); const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); - const metricFile = path.join( - antiClobberDir, - "generated", - "metric-views.d.ts", - ); + const metricFile = path.join(antiClobberDir, "generated", "metric-views.ts"); const degradedQuerySchema = (name: string) => ({ name, @@ -1931,7 +1950,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(content).toContain("offline_query"); }); - test("blocking mode + degraded metric (no failures): no write to metric-views.d.ts", async () => { + test("blocking mode + degraded metric (no failures): no write to metric-views.ts", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -1991,7 +2010,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(fs.existsSync(outFile)).toBe(false); }); - test("blocking mode + non-degraded metric: writes to metric-views.d.ts normally", async () => { + test("blocking mode + non-degraded metric: writes to metric-views.ts normally", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2039,7 +2058,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(content).toContain('"total_revenue": number'); }); - test("non-blocking mode + degraded metric: writes to metric-views.d.ts anyway", async () => { + test("non-blocking mode + degraded metric: writes to metric-views.ts anyway", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2131,11 +2150,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { const queryFolder = path.join(warningTestDir, "queries"); const metricViewsFolder = path.join(warningTestDir, "metric-views"); const outFile = path.join(warningTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join( - warningTestDir, - "generated", - "metric-views.d.ts", - ); + const metricFile = path.join(warningTestDir, "generated", "metric-views.ts"); beforeEach(() => { vi.clearAllMocks(); @@ -2315,8 +2330,8 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); - test("partial presence: only analytics.d.ts exists (metric absent) + environmental → warning emitted (partial presence counts)", async () => { - // Keep analytics.d.ts but remove metric file + test("analytics.d.ts alone satisfies the gate when metric views are not configured", async () => { + // Keep analytics.d.ts but remove the unneeded metric file. expect(fs.existsSync(outFile)).toBe(true); fs.rmSync(metricFile, { force: true }); @@ -2339,7 +2354,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { mode: "blocking", }); - // Warning emitted because at least one committed type exists (analytics.d.ts) + // No metric config exists, so analytics.d.ts is the only required file. const warnCalls = warnSpy.mock.calls .flat() .map(String) @@ -2354,6 +2369,51 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); + test("metric config + missing metric-views.ts + environmental failure → crash", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + expect(fs.existsSync(outFile)).toBe(true); + expect(fs.existsSync(metricFile)).toBe(false); + + mocks.getWarehouseState.mockRejectedValue( + Object.assign(new Error("PERMISSION_DENIED: cannot read warehouse"), { + status: 403, + }), + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-metric-missing", + mode: "blocking", + }).then( + () => undefined, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((error as Error).message); + expect(message).toContain("required committed type files are missing"); + expect(message).toContain("commit the generated type files"); + expect( + warnSpy.mock.calls + .flat() + .map(String) + .some((value) => + value.includes("AppKit typegen: using committed types"), + ), + ).toBe(false); + } finally { + warnSpy.mockRestore(); + } + }); + test("warning output is ANSI-free (plain text for CI log parsing)", async () => { // Query path returns environmental failure mocks.generateQueriesFromDescribe.mockResolvedValue({ @@ -2453,7 +2513,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", beforeEach(() => { vi.clearAllMocks(); mocks.cacheFile.contents = undefined; - // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. + // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.ts. fs.rmSync(gateDir, { recursive: true, force: true }); fs.mkdirSync(queryFolder, { recursive: true }); // A degraded query in blocking mode → write suppressed → nothing on disk. @@ -2481,7 +2541,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", (e: unknown) => e, ); - // Core safety path: no committed .d.ts to fall back on → build must fail. + // Core safety path: no committed type file to fall back on → build must fail. expect(err).toBeInstanceOf(TypegenFatalError); const message = stripAnsi((err as Error).message); expect(message).toContain("generate-types --wait"); @@ -2491,7 +2551,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", }); test("blocking + environmental failure + only serving.d.ts present → still crashes (serving excluded from gate)", async () => { - // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.d.ts stay absent. + // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.ts stay absent. fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync( path.join(path.dirname(outFile), "serving.d.ts"), diff --git a/packages/appkit/src/type-generator/tests/mv-registry.test.ts b/packages/appkit/src/type-generator/tests/mv-registry.test.ts index 6fa77693e..b796a7203 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -227,12 +227,12 @@ describe("resolveMetricConfig", () => { }); }); -// ── Phase 2: UC-accurate FQN naming validation. The source FQN is validated -// against UC_FQN_PATTERN (single-sourced from the zod-free +// ── UC-accurate FQN naming validation. The source FQN is validated against +// UC_FQN_PATTERN (single-sourced from the zod-free // packages/shared/src/schemas/metric-fqn.ts, shared with the canonical Zod -// schema). The old hand-rolled segment charset [a-zA-Z0-9_-] was flagged in -// PR #433 review (pkosiec) as "more restrictive than UC"; these tests pin the -// arity/dot/charset rules and the now-accepted UC-legal characters. +// schema). A hand-rolled segment charset [a-zA-Z0-9_-] would be more +// restrictive than UC; these tests pin the arity/dot/charset rules and the +// UC-legal characters that must be accepted. describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { const sourceOf = (source: string) => ({ metricViews: { revenue: { source } }, @@ -289,8 +289,8 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { ).toThrowError(/the schema part .* contains a character/); }); - // ── Regression: UC-legal characters the OLD [a-zA-Z0-9_-] regex rejected - // now PASS. PR #433 review (pkosiec): "more restrictive than UC". ─────── + // ── UC-legal characters a narrow [a-zA-Z0-9_-] regex would reject must be + // accepted (hyphens, mixed case, non-ASCII). ─────────────────────────── test("accepts hyphens, mixed case, and non-ASCII names UC permits", () => { for (const source of [ "prod-data.analytics.revenue", @@ -364,10 +364,9 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { }); }); -// ── Input caps (inline-only at v1): the canonical Zod schema has no caps -// yet — aligning it is a PR4 rider, so these fixtures deliberately do NOT -// run through metricSourceSchema (they'd pass it) and stay out of the -// parity suite below. +// ── Input caps (inline-only at v1): the canonical Zod schema does not yet +// carry these caps, so these fixtures deliberately do NOT run through +// metricSourceSchema (they'd pass it) and stay out of the parity suite below. describe("resolveMetricConfig — input caps", () => { const manyViews = (count: number) => Object.fromEntries( @@ -426,10 +425,10 @@ describe("resolveMetricConfig — input caps", () => { // inline; this block is the drift alarm for them. TEST-ONLY import of the Zod schema. // // Caps divergence: the inline validator enforces v1 input caps (≤200 entries, -// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not -// carry yet — aligning the Zod schema is a PR4 rider. Cap fixtures therefore -// live in the dedicated caps suite above and are asserted on the inline side -// only; do NOT add them here expecting metricSourceSchema to reject them. +// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not carry +// yet. Cap fixtures therefore live in the dedicated caps suite above and are +// asserted on the inline side only; do NOT add them here expecting +// metricSourceSchema to reject them. describe("resolveMetricConfig — parity with shared metricSourceSchema", () => { const accepts: Array<{ name: string; config: Record }> = [ { @@ -775,11 +774,11 @@ describe("createWorkspaceDescribeFetcher", () => { }); test("a backtick-bearing FQN is now accepted and safely quoted (UC permits it, quoting doubles it)", async () => { - // Under the old hand-rolled segment charset ([a-zA-Z0-9_-]) a backtick was - // rejected outright. UC actually permits a backtick inside a quoted name, - // and quoteFqnForSql (Phase 1) makes it injection-safe by doubling it. So - // naming validation now accepts it and the statement quotes it as a single - // identifier rather than refusing the FQN. + // A narrow segment charset ([a-zA-Z0-9_-]) would reject a backtick outright. + // UC actually permits a backtick inside a quoted name, and quoteFqnForSql + // makes it injection-safe by doubling it. So naming validation accepts it + // and the statement quotes it as a single identifier rather than refusing + // the FQN. const { client, statements } = stubClient(); const fetcher = createWorkspaceDescribeFetcher(client, "wh-1"); @@ -843,7 +842,7 @@ describe("extractMetricColumns", () => { expect(extractMetricColumns({ unrelated: true })).toEqual([]); }); - // ── Phase 2: time-typed dimensions ──────────────────────────────────── + // ── time-typed dimensions ────────────────────────────────────────────── test("infers all 7 standard grains for a TIMESTAMP dimension", () => { const cols = extractMetricColumns({ columns: [ @@ -1408,7 +1407,7 @@ describe("syncMetrics — bounded-concurrency scheduling", () => { expect(schemas.map((s) => s.key)).toEqual(keys); // Rejected entries land in `failures` (stable entry order) AND are - // degraded — the Phase-1 matrix, unchanged by chunking. + // degraded — the failure matrix is unchanged by chunking. expect(failures.map((f) => f.key)).toEqual(["m02", "m06", "m11"]); for (const failure of failures) { expect(failure.source).toBe(`demo.public.${failure.key}`); @@ -1583,7 +1582,7 @@ describe("generateMetricTypeDeclarations — snapshot", () => { expect(output).toContain("measures: Record"); }); - // ── Phase 2: time-typed dim + multiple non-time dims fixture ───────── + // ── time-typed dim + multiple non-time dims fixture ────────────────── test("emits TimeGrain union for a metric view with time-typed + regular dimensions", async () => { const resolution = resolveMetricConfig({ metricViews: { @@ -1623,8 +1622,114 @@ describe("generateMetricTypeDeclarations — snapshot", () => { }); }); -// ── Phase 5: semantic-metadata extraction (display_name + format) ───────── -describe("extractMetricColumns — Phase 5 semantic metadata", () => { +// ── The emitted file is a real `.ts` carrying BOTH the (erasable) `declare +// module` type augmentation AND a runtime `metricViewsMetadata` value. It must +// never emit a runtime side-effect import (that would execute the client +// package entry on the Node server) — only a zero-runtime type-only import. +describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { + test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { + const resolution = resolveMetricConfig({ + metricViews: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // Type half: the augmentation is still present, unchanged in shape. + expect(output).toContain('declare module "@databricks/appkit-ui/react"'); + expect(output).toContain("interface MetricRegistry"); + // Value half: a runtime const conforming to MetricViewsMetadata, `as const`. + expect(output).toContain("export const metricViewsMetadata = {"); + expect(output).toContain("} as const;"); + // The measure/dimension maps carry the SAME per-column fields as the type + // block (type/display_name/format), keyed by column name. + expect(output).toContain( + '"arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00" }', + ); + expect(output).toContain('"region": { type: "STRING" }'); + }); + + test("uses a zero-runtime type-only import, never a side-effect import", () => { + const output = generateMetricTypeDeclarations([]); + // A bare `import "..."` in a `.ts` would EXECUTE the client entry on the + // Node server — it must never be emitted. + expect(output).not.toContain('import "@databricks/appkit-ui/react"'); + expect(output).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); + }); + + test("emits an empty metricViewsMetadata for no registered metrics", () => { + const output = generateMetricTypeDeclarations([]); + expect(output).toContain("export const metricViewsMetadata = {} as const;"); + // Empty type augmentation stays too. + expect(output).toContain("interface MetricRegistry {}"); + }); + + test("a degraded schema contributes empty measures/dimensions value maps", async () => { + const resolution = resolveMetricConfig({ + metricViews: { cold: { source: "appkit_demo.public.cold" } }, + }); + // Non-terminal DESCRIBE → degraded schema (empty column arrays). + const fetcher = + async (): Promise => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + // Value side of a degraded entry: empty maps, consistent with its + // `Record` metadata type block. + expect(output).toContain(`"cold": { + measures: {}, + dimensions: {}, + }`); + }); + + test("escapes quotes/backticks in display_name and description via JSON.stringify", async () => { + const resolution = resolveMetricConfig({ + metricViews: { revenue: { source: "appkit_demo.public.revenue" } }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + // A double quote AND a backtick — both must survive into a valid + // TS string literal in the runtime const. + display_name: 'Net "ARR" `growth`', + comment: 'Revenue with a " quote', + }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // JSON.stringify escapes the embedded double quotes; the backtick rides + // through unescaped inside a double-quoted literal (valid TS). + expect(output).toContain('display_name: "Net \\"ARR\\" `growth`"'); + expect(output).toContain('description: "Revenue with a \\" quote"'); + }); +}); + +// ── semantic-metadata extraction (display_name + format) ────────────────── +describe("extractMetricColumns — semantic metadata", () => { test("captures display_name from a measure column", () => { const cols = extractMetricColumns({ columns: [ @@ -1946,12 +2051,12 @@ describe("extractMetricColumns — Phase 5 semantic metadata", () => { }); }); -// ── Key-order determinism: the .d.ts emitter sorts metric keys with a +// ── Key-order determinism: the emitter sorts metric keys with a // locale-independent (code-unit) comparator. localeCompare-style collation // would interleave mixed-case keys ("ARPU", "churn", "Revenue") and could vary // by machine/locale, drifting the emitted augmentation between builds. describe("artifact key-order determinism", () => { - test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.d.ts", async () => { + test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.ts", async () => { const resolution = resolveMetricConfig({ metricViews: { Revenue: { source: "a.b.r" }, @@ -1973,7 +2078,7 @@ describe("artifact key-order determinism", () => { }); const { schemas } = await syncMetrics(resolution, fetcher); - // Entry keys in the .d.ts appear as ` "": {` lines (4-space + // Entry keys in the augmentation appear as ` "": {` lines (4-space // indent — metadata column maps sit deeper and don't match). const declarations = generateMetricTypeDeclarations(schemas); const dtsKeys = [...declarations.matchAll(/^ {4}"([^"]+)": \{$/gm)].map( @@ -1984,7 +2089,7 @@ describe("artifact key-order determinism", () => { }); }); -// ── Phase 2: syncMetrics propagates timeGrains end-to-end ──────────────── +// ── syncMetrics propagates timeGrains end-to-end ───────────────────────── describe("syncMetrics — time-typed dimension propagation", () => { test("propagates inferred grains onto the resulting MetricSchema", async () => { const resolution = resolveMetricConfig({ diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 337892aa8..d6d4154ce 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -128,7 +128,7 @@ describe("syncMetricViewsTypes", () => { tmpRoot, "shared", "appkit-types", - "metric-views.d.ts", + "metric-views.ts", ); }); @@ -146,7 +146,7 @@ describe("syncMetricViewsTypes", () => { metricFetcher: fetcher, }); - // The .d.ts exists on disk. + // The generated .ts exists on disk. expect(fs.existsSync(metricOutFile)).toBe(true); // Result reports both keys, no failures, config present. @@ -158,7 +158,7 @@ describe("syncMetricViewsTypes", () => { ]); expect(result.metricOutFile).toBe(metricOutFile); - // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- + // --- metric-views.ts: MetricRegistry augmentation for both metrics --- const declarations = fs.readFileSync(metricOutFile, "utf-8"); expect(declarations).toContain("interface MetricRegistry"); expect(declarations).toContain('"revenue"'); @@ -172,9 +172,48 @@ describe("syncMetricViewsTypes", () => { expect(declarations).toContain('lane: "sp"'); // The TIMESTAMP dimension carries inferred time grains in its @timeGrain tag. expect(declarations).toContain("@timeGrain"); - // The semantic metadata (format spec, SQL type) rides in the .d.ts's - // type-level `metadata` block — the sole carrier now the JSON is gone. + // The semantic metadata (format spec, SQL type) rides in the type-level + // `metadata` block — the sole carrier now the JSON is gone. expect(declarations).toContain('"$#,##0.00"'); + // The file is a real `.ts`: it also carries the runtime `metricViewsMetadata` + // const, and never a runtime side-effect import (only a type-only one). + expect(declarations).toContain("export const metricViewsMetadata"); + expect(declarations).toContain("as const"); + expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); + expect(declarations).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); + }); + + test("removes a stale sibling metric-views.d.ts left by a pre-.ts version on upgrade", async () => { + writeMixedConfig(); + + // Simulate an app upgraded from a version that emitted an ambient + // `metric-views.d.ts`. Left in place beside the new `.ts`, it would + // duplicate the `declare module` augmentation and re-introduce the bare + // side-effect import the new header drops. + const staleDts = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); + fs.mkdirSync(path.dirname(staleDts), { recursive: true }); + fs.writeFileSync( + staleDts, + '// old\nimport "@databricks/appkit-ui/react";\n', + ); + + await syncMetricViewsTypes({ + metricViewsFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + // The new .ts is written and the stale .d.ts sibling is swept. + expect(fs.existsSync(metricOutFile)).toBe(true); + expect(fs.existsSync(staleDts)).toBe(false); }); test("returns noConfig and writes nothing when definitions.json is absent", async () => { diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts index 0a4dde781..f2c542ebc 100644 --- a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -2,20 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; -/** - * End-to-end coverage for the `--wait` has-types gate when query DESCRIBE - * cannot produce a schema for environmental reasons. - * - * The sibling `index.test.ts` mocks `generateQueriesFromDescribe`, so its gate - * tests hand the entry point a `hadEnvironmentalFailure: true` they wrote - * themselves — they would still pass if the query path never set that flag. - * That is exactly how the original bug survived: the query path reported - * `false` for a connectivity failure and no test joined the two halves. - * - * Here only the client boundary is mocked. The real query path classifies the - * failure and the real gate decides, so a regression in either half fails a - * test. - */ +/** Exercises the blocking fallback gate through the real query path. */ const mocks = vi.hoisted(() => ({ getWarehouse: vi.fn(), @@ -93,12 +80,9 @@ describe("--wait gate: environmental query failures (real query path)", () => { (e: unknown) => e, ); - // The regression this guards: the run used to resolve, write nothing, and - // exit 0 — leaving the build to fail later with no usable diagnostic. expect(err).toBeInstanceOf(TypegenFatalError); expect((err as Error).message).toContain("generate-types --wait"); expect(fs.existsSync(outFile)).toBe(false); - // Preflight failed, so no DESCRIBE was attempted. expect(mocks.executeStatement).not.toHaveBeenCalled(); }); @@ -124,10 +108,7 @@ describe("--wait gate: environmental query failures (real query path)", () => { expect(warnings).toContain("AppKit typegen: using committed types"); expect(warnings).toContain("wh-unreachable"); - // The label the query path now supplies; it was unreachable in practice - // while connectivity failures reported no cause at all. expect(warnings).toContain("warehouse unreachable"); - // Anti-clobber: the degraded result must not overwrite what was committed. expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); } finally { warnSpy.mockRestore(); @@ -189,7 +170,6 @@ describe("--wait gate: environmental query failures (real query path)", () => { test("non-blocking mode stays silent and writes degraded types", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - // Must not throw: the non-blocking default never fails on warehouse state. await generateFromEntryPoint({ outFile, queryFolder, @@ -203,7 +183,6 @@ describe("--wait gate: environmental query failures (real query path)", () => { .filter((s) => s.includes("using committed types")); expect(gateWarnings).toEqual([]); - // Degraded types are written here — the gate is blocking-only. expect(fs.existsSync(outFile)).toBe(true); expect(fs.readFileSync(outFile, "utf-8")).toContain("result: unknown"); } finally { diff --git a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts index 997be7f08..52c25c9fc 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -392,7 +392,7 @@ describe("appKitTypesPlugin — metric option plumbing", () => { test("a custom mvOutFile reaches generateFromEntryPoint", async () => { const plugin = appKitTypesPlugin({ - mvOutFile: "custom/types/metric-views.d.ts", + mvOutFile: "custom/types/metric-views.ts", }); getHook( plugin, @@ -405,13 +405,23 @@ describe("appKitTypesPlugin — metric option plumbing", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ - mvOutFile: path.resolve( - process.cwd(), - "custom/types/metric-views.d.ts", - ), + mvOutFile: path.resolve(process.cwd(), "custom/types/metric-views.ts"), }), ); }); + + test("rejects a .d.ts custom mvOutFile up front (it would emit a runtime const into an ambient decl → TS1039)", () => { + const plugin = appKitTypesPlugin({ + mvOutFile: "custom/types/metric-views.d.ts", + }); + const configResolved = getHook( + plugin, + "configResolved", + ); + expect(() => + configResolved({ root: path.join(process.cwd(), "client") }), + ).toThrow(/must be a \.ts file, not a \.d\.ts/); + }); }); describe("appKitTypesPlugin — background warehouse watch", () => { diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 9ab1544d9..78aeb6d8b 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -35,8 +35,10 @@ interface AppKitTypesPluginOptions { /* Path to the output d.ts file (relative to client folder). */ outFile?: string; /** - * Path to the metric registry d.ts file (relative to client folder). - * Defaults to a sibling of `outFile`, computed by the generator. + * Path to the metric registry `.ts` file (relative to client folder). + * Defaults to a sibling of `outFile`, computed by the generator. The + * generated source carries both the `declare module` augmentation and the + * runtime `metricViewsMetadata` const, so it is a real `.ts`, not a `.d.ts`. */ mvOutFile?: string; /** @@ -330,6 +332,17 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // final path is identical (the default outFile above lives in // shared//), and a customized outFile now keeps its metric // sibling next to it instead of pinning it under shared/. + // + // Reject a `.d.ts` metric out-path up front: the metric file is a real + // `.ts` source carrying a runtime `const` (metricViewsMetadata), which is + // illegal inside an ambient declaration file (TS1039). Fail fast with a + // clear message rather than emitting a file that won't compile. + if (options?.mvOutFile?.endsWith(".d.ts")) { + throw new Error( + `appKitAnalyticsTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + + "The metric-views file carries a runtime const, which cannot live in an ambient .d.ts.", + ); + } mvOutFile = options?.mvOutFile !== undefined ? path.resolve(projectRoot, options.mvOutFile) diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 30255cc87..1c2f5c855 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -235,7 +235,7 @@ describe("generate-types foreground spawn orchestration", () => { }); test("reports the metric artifact when config/metric-views/definitions.json exists", async () => { - // The metric path is additive: generateFromEntryPoint emits metric-views.d.ts + // The metric path is additive: generateFromEntryPoint emits metric-views.ts // as a sibling of the query out file whenever the config is present. The CLI // announces it off the same dormancy signal. const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); @@ -249,7 +249,7 @@ describe("generate-types foreground spawn orchestration", () => { const logged = consoleLog.mock.calls.flat().map(String); expect(logged).toContain(`Generated query types: ${outFile}`); expect(logged).toContain( - `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.d.ts")}`, + `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.ts")}`, ); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 03ab43c0d..f38f323a2 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -95,7 +95,7 @@ async function runGenerateTypes( if (fs.existsSync(metricConfig)) { const typesDir = path.dirname(resolvedOutFile); console.log( - `Generated metric types: ${path.join(typesDir, "metric-views.d.ts")}`, + `Generated metric types: ${path.join(typesDir, "metric-views.ts")}`, ); } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d036e0dbd..4b7c08ba1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent"; export * from "./cache"; export * from "./execute"; export * from "./genie"; +export * from "./metric-metadata"; export * from "./plugin"; export * from "./sql"; export * from "./sse/analytics"; diff --git a/packages/shared/src/metric-metadata.ts b/packages/shared/src/metric-metadata.ts new file mode 100644 index 000000000..b58a08fdc --- /dev/null +++ b/packages/shared/src/metric-metadata.ts @@ -0,0 +1,18 @@ +/** Per-column display metadata for a UC Metric View column, sourced from the + * YAML 1.1 display_name/format attributes + SQL type. Loose enough that an + * `as const` generated literal assigns to it. */ +export interface MetricColumnMeta { + type: string; + display_name?: string; + format?: string; + description?: string; +} +/** Build-time-generated metadata for every registered metric view, keyed by + * metric key. Injected into the analytics plugin via `analytics({ metricViewsMetadata })`. */ +export type MetricViewsMetadata = Record< + string, + { + measures: Record; + dimensions: Record; + } +>; diff --git a/packages/shared/src/sse/analytics.ts b/packages/shared/src/sse/analytics.ts index 41022672c..5ae9fcfc3 100644 --- a/packages/shared/src/sse/analytics.ts +++ b/packages/shared/src/sse/analytics.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { MetricColumnMeta } from "../metric-metadata"; /** * Wire protocol for analytics SSE messages emitted by `/api/analytics/query`. @@ -37,15 +38,23 @@ export const AnalyticsResultMessage = z.object({ // `unknown` so we don't bake the SDK's detailed shape into the contract. status: z.unknown().optional(), statement_id: z.string().optional(), + // Per-column display metadata for a metric-view result (display_name / + // format / type). Kept loose (`z.record(z.string(), z.unknown())`) for the + // same "keep client validation cheap" reason as `data` — the server + // constructs it via the typed builder, so the per-column shape is enforced + // at the source. Absent for plain `/query` results. + metadata: z.record(z.string(), z.unknown()).optional(), }); /** * TS-level shape of a successful row-shaped result message. * * **Kept in sync by hand** with `AnalyticsResultMessage` above. The Zod - * schema is intentionally loose (`z.array(z.unknown())`) to keep client + * schema is intentionally loose (`z.array(z.unknown())` for `data`, + * `z.record(z.string(), z.unknown())` for `metadata`) to keep client * validation cheap; this interface narrows `data` to - * `Record[]` so consumers don't have to cast at every + * `Record[]` and `metadata` to + * `Record` so consumers don't have to cast at every * call site. If you add a field to the Zod schema, add it here too. */ export interface AnalyticsResultMessage { @@ -53,6 +62,7 @@ export interface AnalyticsResultMessage { data?: Record[]; status?: unknown; statement_id?: string; + metadata?: Record; } /** @@ -72,7 +82,11 @@ export type AnalyticsSseMessage = z.infer; export function makeResultMessage( data: Record[] | undefined, - extras: { status?: unknown; statement_id?: string } = {}, + extras: { + status?: unknown; + statement_id?: string; + metadata?: Record; + } = {}, ): AnalyticsResultMessage { return { type: "result", data, ...extras }; } From b58b51c18ea2408d4577a86766ae2285b8a8fd92 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 19:25:43 +0200 Subject: [PATCH 2/7] chore(appkit): remove AI slop from metric view metadata pipeline Comment and documentation cleanup only; no behavior change. - Collapse the "metadata never affects SQL or cache identity" invariant from six sites down to the one that owns it (selectMetricMetadata). - Reduce the duplicated "never emit a side-effect import" rationale to the emitter plus the test that asserts it. - Drop caps-emphasis and compress the render-types helper preambles to match the density of their pre-existing siblings. - De-narrate the cache-hit metadata test, which described the branch's own development arc rather than the invariant. - Fix a broken docs cross-reference that pointed at hook and format-utility wiring the analytics plugin docs do not contain. - Normalize metric-metadata.ts JSDoc and export spacing to the conventions used elsewhere in packages/shared. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/docs/development/type-generation.md | 4 +-- .../appkit/src/plugins/analytics/analytics.ts | 28 +++++---------- .../src/plugins/analytics/mv/metadata.ts | 34 +++++++----------- .../plugins/analytics/tests/metric.test.ts | 15 ++++---- .../appkit/src/plugins/analytics/types.ts | 2 +- .../mv-registry/render-types.ts | 35 ++++++++----------- .../src/type-generator/tests/index.test.ts | 3 +- .../type-generator/tests/mv-registry.test.ts | 13 ++++--- .../tests/sync-metric-views-types.test.ts | 9 +++-- packages/shared/src/metric-metadata.ts | 16 ++++++--- 10 files changed, 66 insertions(+), 93 deletions(-) diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index b7727ecef..cf3076889 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -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.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The declaration 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. (`metric-views.ts` is a real source file rather than a `.d.ts` because it *also* carries a runtime `metricViewsMetadata` constant alongside the augmentation — see [Metric-view types](#metric-view-types).) 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` @@ -103,7 +103,7 @@ The app template wires this up for you: `postinstall` and `predev` run the non-b `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.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` 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 (the same metadata as a value, not just types) — inject it via `analytics({ metricViewsMetadata })` so the metric route can carry per-column display metadata in its response payload. The type augmentation erases at build; the constant is a normal named export and is tree-shaken away when unused. See [the analytics plugin's metric-view docs](../plugins/analytics.md) for the hook + format-utility wiring. +- `metric-views.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` 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.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. diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 0433ce20e..cb24ef725 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -558,12 +558,9 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw err; } - // Per-column metadata slice for the responding metric, scoped to the - // requested measures/dimensions. Pure response DECORATION: computed once - // from the injected config value, threaded into the `result` message below, - // and deliberately NOT part of the cache key or the SQL. Absent config → - // `undefined` → the `result` message omits the field (envelope-identical to - // `/query`). + // 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, @@ -667,9 +664,7 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { ); // Reuse the query route's JSON delivery: INLINE JSON_ARRAY with // an ARROW_STREAM-inline fallback, returning plain rows in a - // `result` message — byte-identical envelope to `/query`. The - // per-column `metadata` is stamped AFTER this cached call returns - // (see below), never baked into the cached message. + // `result` message — byte-identical envelope to `/query`. return await self._executeJsonArrayPath( executor, statement, @@ -712,11 +707,10 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw ExecutionError.statementFailed(inner); } - // Stamp the FRESH per-column metadata onto the (possibly cached) result - // message. The cache key excludes metadata and the cached message never - // carries it, so a cache HIT after a redeploy that changed a column's - // display_name/format serves the current metadata, not a stale copy. - // `undefined` leaves the field absent — envelope-identical to `/query`. + // Stamp the metadata onto the (possibly cached) result message. The + // cached message never carries it, so a hit after a redeploy that + // changed a column's display_name/format serves the current metadata + // rather than a stale copy. const resultMessage = sqlResult.data as AnalyticsSseMessage; yield ( metadata !== undefined @@ -734,12 +728,6 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`, * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result` * message. External links are never used for the JSON fallback. - * - * This returns the bare `result` message (rows + status/statement_id) and is - * cached by the caller. The metric route's per-column `metadata` is stamped - * onto the message AFTER the cached call returns (so a cache hit never serves - * stale metadata), never inside here — keeping the cached payload - * metadata-free and byte-identical to a plain `/query` result. */ private async _executeJsonArrayPath( executor: AnalyticsPlugin, diff --git a/packages/appkit/src/plugins/analytics/mv/metadata.ts b/packages/appkit/src/plugins/analytics/mv/metadata.ts index 359d122d9..1f3b0790c 100644 --- a/packages/appkit/src/plugins/analytics/mv/metadata.ts +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -1,31 +1,21 @@ import type { MetricColumnMeta, MetricViewsMetadata } from "shared"; /** - * Compute the per-column metadata slice for a metric response, scoped to the - * columns the request actually asked for. + * Flatten the injected {@link MetricViewsMetadata} for `key` into a single + * `Record` covering only the requested measures and dimensions, + * so the client can label/format just the columns it queried. * - * `all` is the build-generated {@link MetricViewsMetadata} the app injects via - * `analytics({ metricViewsMetadata })` — a per-metric map of `measures` / - * `dimensions` to their {@link MetricColumnMeta}. This flattens the requested - * measures and dimensions for `key` into a single `Record` for - * the SSE `result` message, so the client can label/format only 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). * - * This is 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 entirely and the message stays - * byte-identical to a plain `/query` result: - * - `all` is absent (no metadata injected), or - * - `key` is not an own property of `all` (unknown metric; uses - * {@link Object.hasOwn} so a prototype member like `toString` never - * resolves to a bogus entry), or - * - none of the requested columns are present in the metadata (fully - * degraded / unknown columns). - * - * Requested columns that are absent from the metadata are simply omitted — a - * degraded/unknown column produces no entry rather than a placeholder. + * 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, diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 4b5a2818c..5ae72695e 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -771,11 +771,10 @@ describe("analytics metric route", () => { ); }); - test("cache HIT serves the FRESH metadata, not the metadata baked in at cache-fill time", async () => { - // Regression: metadata was formerly stamped INSIDE the cached execute(), - // so a cache hit replayed the OLD labels/formats even after a redeploy - // changed them. The cache key excludes metadata, so the SQL result is a - // hit across the two runs below; only the injected metadata differs. + test("a cache hit serves the current metadata, not the copy from cache-fill time", async () => { + // The cache key excludes metadata, so the SQL result is a hit across the + // two runs below; only the injected metadata differs. Stamping inside the + // cached call would replay stale labels/formats after a redeploy. const registry = { revenue: { key: "revenue", @@ -815,9 +814,9 @@ describe("analytics metric route", () => { const first = await runWithMetadata(oldMeta); expect(first.metadata.arr.display_name).toBe("ARR (old)"); - // Second run: same body → SQL cache HIT (executeStatement not called - // again), but the app now injects NEW labels. The response must reflect - // the fresh metadata, not the stale copy from the cached message. + // Second run: same body → SQL cache hit (executeStatement not called + // again), but the app now injects new labels, which must reach the + // response. executeMock.mockClear(); const newMeta: MetricViewsMetadata = { revenue: { diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index feb535e6f..cd17de945 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -9,7 +9,7 @@ export interface IAnalyticsConfig extends BasePluginConfig { /** * Build-generated per-metric column metadata, keyed by metric key. The * metric route scopes this to the requested measures and dimensions before - * attaching it to the SSE result. It never affects SQL or cache identity. + * attaching it to the SSE result. */ metricViewsMetadata?: MetricViewsMetadata; /** diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index c1614c229..f625e6618 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -118,8 +118,8 @@ function renderDegradedMetricEntry(schema: MetricSchema): string { type RenderedMetadataField = readonly [name: string, value: string]; -// Build the canonical rendered fields shared by type-level and runtime -// metadata. `time_grain` is type-only and is included only when requested. +// Rendered per-column fields shared by the type-level and runtime metadata. +// `time_grain` is type-only, so it is included only when requested. function metadataFields( col: MetricColumnMetadata, includeTimeGrain = false, @@ -170,11 +170,9 @@ ${inner}; }`; } -// Render one column's runtime metadata object literal — the value-side twin of -// a `renderMetadataMap` entry. Sources the SAME per-column fields -// (type/display_name/format/description) but omits `time_grain` (not part of -// MetricColumnMeta). Strings go through JSON.stringify so quotes/backticks in -// display_name/description stay escape-safe. +// Render one column's runtime metadata literal — the value-side twin of a +// `renderMetadataMap` entry, minus `time_grain` (not part of MetricColumnMeta). +// Strings go through JSON.stringify to stay escape-safe. function renderMetadataValueField(col: MetricColumnMetadata): string { const fields = metadataFields(col).map( ([name, value]) => `${name}: ${value}`, @@ -182,9 +180,8 @@ function renderMetadataValueField(col: MetricColumnMetadata): string { return `{ ${fields.join(", ")} }`; } -// Render the runtime value map (measures or dimensions) for one metric — an -// object literal keyed by column name. Empty → `{}` (the value twin of the -// type-level `Record`, which is a type-only construct). +// Render one metric's runtime measures/dimensions map, keyed by column name. +// Empty → `{}`, the value twin of the type-level `Record`. function renderMetadataValueMap( cols: MetricColumnMetadata[], indent: string, @@ -203,10 +200,7 @@ ${indent}}`; // Render the runtime `metricViewsMetadata` const — a value twin of the // type-level `metadata` blocks, conforming to MetricViewsMetadata from -// "shared". Emitted `as const`. Iterates `schemas` in the SAME order as the -// type augmentation. A degraded schema (empty measure/dimension arrays) -// contributes empty `measures: {}` / `dimensions: {}` maps, consistent with -// its degraded type block. +// "shared" and emitted `as const` in the same key order as the augmentation. function renderMetricViewsMetadata(schemas: MetricSchema[]): string { if (schemas.length === 0) { return "export const metricViewsMetadata = {} as const;\n"; @@ -247,13 +241,12 @@ ${entries}; /** * Build the full metric-views.ts file from a list of metric schemas. * - * This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the - * erasable `declare module` type augmentation AND a runtime value export - * (`metricViewsMetadata`). It must therefore never emit a runtime side-effect - * import — a bare `import "@databricks/appkit-ui/react"` would execute the - * client package entry on the Node server. The header is a type-only - * `import type {} from "..."`, which (a) compiles to zero runtime code and - * (b) anchors the module so the global `declare module` augmentation resolves. + * A real `.ts` source file, not a `.d.ts`, because it carries the erasable + * `declare module` augmentation alongside a runtime `metricViewsMetadata` + * export. The header must therefore stay a type-only `import type {} from`: + * it compiles to zero runtime code while still anchoring the module so the + * augmentation resolves, whereas a bare `import "@databricks/appkit-ui/react"` + * would execute the client package entry on the Node server. */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 4f6aaf709..9753b41e2 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -373,8 +373,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // `metricViewsMetadata` const (value twin of the type-level metadata). expect(declarations).toContain("export const metricViewsMetadata"); expect(declarations).toContain("as const"); - // ...and NEVER a runtime side-effect import that would execute the client - // package entry on the Node server — only a zero-runtime type-only import. + // ...and a type-only import, never a runtime side-effect one. expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); expect(declarations).toContain( 'import type {} from "@databricks/appkit-ui/react"', diff --git a/packages/appkit/src/type-generator/tests/mv-registry.test.ts b/packages/appkit/src/type-generator/tests/mv-registry.test.ts index b796a7203..ff78a15fa 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -1622,10 +1622,9 @@ describe("generateMetricTypeDeclarations — snapshot", () => { }); }); -// ── The emitted file is a real `.ts` carrying BOTH the (erasable) `declare -// module` type augmentation AND a runtime `metricViewsMetadata` value. It must -// never emit a runtime side-effect import (that would execute the client -// package entry on the Node server) — only a zero-runtime type-only import. +// ── The emitted file is a real `.ts` carrying the erasable `declare module` +// augmentation alongside a runtime `metricViewsMetadata` value, so its header +// must stay a type-only import. See `generateMetricTypeDeclarations`. describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { const resolution = resolveMetricConfig({ @@ -1655,7 +1654,7 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", // Value half: a runtime const conforming to MetricViewsMetadata, `as const`. expect(output).toContain("export const metricViewsMetadata = {"); expect(output).toContain("} as const;"); - // The measure/dimension maps carry the SAME per-column fields as the type + // The measure/dimension maps carry the same per-column fields as the type // block (type/display_name/format), keyed by column name. expect(output).toContain( '"arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00" }', @@ -1665,8 +1664,8 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", test("uses a zero-runtime type-only import, never a side-effect import", () => { const output = generateMetricTypeDeclarations([]); - // A bare `import "..."` in a `.ts` would EXECUTE the client entry on the - // Node server — it must never be emitted. + // A bare `import "..."` in a `.ts` would execute the client entry on the + // Node server. expect(output).not.toContain('import "@databricks/appkit-ui/react"'); expect(output).toContain( 'import type {} from "@databricks/appkit-ui/react"', diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index d6d4154ce..5cdb9f7b7 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -175,8 +175,8 @@ describe("syncMetricViewsTypes", () => { // The semantic metadata (format spec, SQL type) rides in the type-level // `metadata` block — the sole carrier now the JSON is gone. expect(declarations).toContain('"$#,##0.00"'); - // The file is a real `.ts`: it also carries the runtime `metricViewsMetadata` - // const, and never a runtime side-effect import (only a type-only one). + // The file is a real `.ts`, so it also carries the runtime const and a + // type-only import. expect(declarations).toContain("export const metricViewsMetadata"); expect(declarations).toContain("as const"); expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); @@ -189,9 +189,8 @@ describe("syncMetricViewsTypes", () => { writeMixedConfig(); // Simulate an app upgraded from a version that emitted an ambient - // `metric-views.d.ts`. Left in place beside the new `.ts`, it would - // duplicate the `declare module` augmentation and re-introduce the bare - // side-effect import the new header drops. + // `metric-views.d.ts`, which would duplicate the augmentation if left + // beside the new `.ts`. const staleDts = path.join( tmpRoot, "shared", diff --git a/packages/shared/src/metric-metadata.ts b/packages/shared/src/metric-metadata.ts index b58a08fdc..75a9b4af7 100644 --- a/packages/shared/src/metric-metadata.ts +++ b/packages/shared/src/metric-metadata.ts @@ -1,14 +1,20 @@ -/** Per-column display metadata for a UC Metric View column, sourced from the - * YAML 1.1 display_name/format attributes + SQL type. Loose enough that an - * `as const` generated literal assigns to it. */ +/** + * Per-column display metadata for a UC Metric View column, sourced from the + * YAML 1.1 `display_name`/`format` attributes plus the SQL type. Loose enough + * that an `as const` generated literal assigns to it. + */ export interface MetricColumnMeta { type: string; display_name?: string; format?: string; description?: string; } -/** Build-time-generated metadata for every registered metric view, keyed by - * metric key. Injected into the analytics plugin via `analytics({ metricViewsMetadata })`. */ + +/** + * Build-time-generated metadata for every registered metric view, keyed by + * metric key. Injected into the analytics plugin via + * `analytics({ metricViewsMetadata })`. + */ export type MetricViewsMetadata = Record< string, { From c2be2acc5e356c92c4126ebd52ae11f451377565 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 19:26:09 +0200 Subject: [PATCH 3/7] fix(appkit): keep committed metric types when a degraded pass skips the write The stale-sibling sweep unlinked metric-views.d.ts unconditionally once the out file was a .ts. In blocking mode a degraded pass suppresses the replacement write, so an app still carrying a legacy metric-views.d.ts lost its only committed metric types and the --wait gate had nothing to fall back on. Guard the sweep on the new file actually existing, and pin the behavior with a test covering the suppressed-write path. Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 10 ++++-- .../tests/sync-metric-views-types.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 4f7a058d7..904b02972 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -874,8 +874,14 @@ export async function syncMetricViewsTypes(options: { // `metricOutFile`. Left behind, the old sibling would duplicate the // `declare module` augmentation and re-introduce the bare side-effect import // the new header deliberately drops. Best-effort: only removed when the new - // file is itself a `.ts` (never delete the file we just wrote), ENOENT-safe. - if (metricOutFile.endsWith(".ts") && !metricOutFile.endsWith(".d.ts")) { + // file is itself a `.ts` and exists (never delete the only committed metric + // types when a degraded blocking pass suppressed the replacement write), + // ENOENT-safe. + if ( + metricOutFile.endsWith(".ts") && + !metricOutFile.endsWith(".d.ts") && + existsSync(metricOutFile) + ) { const staleDts = `${metricOutFile.slice(0, -".ts".length)}.d.ts`; try { await fs.unlink(staleDts); diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 5cdb9f7b7..239118c6c 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -215,6 +215,40 @@ describe("syncMetricViewsTypes", () => { expect(fs.existsSync(staleDts)).toBe(false); }); + test("preserves a legacy metric-views.d.ts when a degraded blocking pass suppresses the replacement write", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + const legacyDts = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); + fs.mkdirSync(path.dirname(legacyDts), { recursive: true }); + const committedContent = "// committed legacy metric types\n"; + fs.writeFileSync(legacyDts, committedContent); + + await syncMetricViewsTypes({ + metricViewsFolder, + warehouseId: "wh-1", + metricOutFile, + mode: "blocking", + suppressDegradedWrite: true, + metricFetcher: async () => ({ + statement_id: "stmt-pending", + status: { state: "PENDING" }, + }), + }); + + expect(fs.existsSync(metricOutFile)).toBe(false); + expect(fs.readFileSync(legacyDts, "utf-8")).toBe(committedContent); + }); + test("returns noConfig and writes nothing when definitions.json is absent", async () => { const result = await syncMetricViewsTypes({ metricViewsFolder, From ff53b3063fc8582d4e40bc5afaa96e7ccb9eef36 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 5 Aug 2026 10:27:33 +0200 Subject: [PATCH 4/7] chore(appkit): tighten stale-sweep comment and drop stray caps-emphasis Comment-only follow-up to b58b51c1; no behavior change. Collapse the stale metric-views.d.ts sweep comment from eight lines to four, dropping the re-derivation of the emitter's type-only-import contract that render-types.ts already documents, and describe the existsSync guard by what it observably does rather than restating a rationale the derived staleDts path does not support. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/plugins/analytics/tests/metric.test.ts | 2 +- packages/appkit/src/type-generator/index.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 5ae72695e..e704ae309 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -804,7 +804,7 @@ describe("analytics metric route", () => { return readResultPayload(mockRes); }; - // First run fills the cache with the OLD labels. + // First run fills the cache with the old labels. const oldMeta: MetricViewsMetadata = { revenue: { measures: { arr: { type: "decimal", display_name: "ARR (old)" } }, diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 904b02972..f229ce5c8 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -869,14 +869,10 @@ export async function syncMetricViewsTypes(options: { "utf-8", ); } - // Sweep a stale sibling `metric-views.d.ts` from a pre-`.ts` version. Older - // typegen emitted an ambient `.d.ts`; the current output is a real `.ts` at - // `metricOutFile`. Left behind, the old sibling would duplicate the - // `declare module` augmentation and re-introduce the bare side-effect import - // the new header deliberately drops. Best-effort: only removed when the new - // file is itself a `.ts` and exists (never delete the only committed metric - // types when a degraded blocking pass suppressed the replacement write), - // ENOENT-safe. + // Sweep the ambient `metric-views.d.ts` a pre-`.ts` version left behind, + // which would otherwise duplicate the augmentation the new `.ts` emits. + // Skipped unless the replacement was actually written, so a degraded + // blocking pass leaves an app's only committed metric types in place. if ( metricOutFile.endsWith(".ts") && !metricOutFile.endsWith(".d.ts") && From 7115767fbb112ebe4ac829913207916987e1da8a Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 5 Aug 2026 14:31:33 +0200 Subject: [PATCH 5/7] fix(appkit): name the right plugin in the mvOutFile rejection error The .d.ts guard threw with an appKitAnalyticsTypesPlugin prefix, but the exported plugin is appKitTypesPlugin, sending anyone tracing the failure after a name the codebase no longer has. Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/vite-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 78aeb6d8b..3c79fc193 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -339,7 +339,7 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // clear message rather than emitting a file that won't compile. if (options?.mvOutFile?.endsWith(".d.ts")) { throw new Error( - `appKitAnalyticsTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + + `appKitTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + "The metric-views file carries a runtime const, which cannot live in an ambient .d.ts.", ); } From f1ebf5dbbed61ef3f0654234c46826d47cb2b920 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 5 Aug 2026 14:43:32 +0200 Subject: [PATCH 6/7] refactor(shared): rename MetricColumnMeta to MetricViewColumnDisplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetricColumnMeta and the build-time MetricColumnMetadata differed only in a suffix, so the pair read as if Meta vs Metadata encoded the wire/build split. It does not: MetricViewsMetadata is wire-side too. Reviewers went looking for the distinction and found nothing. Name the wire type after what it carries — the display attributes the doc comment already describes — and put the difference in the prefix, where the two names no longer collide at a glance. Signed-off-by: Atila Fassina --- packages/appkit/src/plugins/analytics/mv/metadata.ts | 6 +++--- packages/appkit/src/plugins/analytics/types.ts | 4 ++-- .../src/type-generator/mv-registry/render-types.ts | 5 +++-- packages/shared/src/metric-metadata.ts | 6 +++--- packages/shared/src/sse/analytics.ts | 10 +++++----- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/mv/metadata.ts b/packages/appkit/src/plugins/analytics/mv/metadata.ts index 1f3b0790c..e67302ef8 100644 --- a/packages/appkit/src/plugins/analytics/mv/metadata.ts +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -1,4 +1,4 @@ -import type { MetricColumnMeta, MetricViewsMetadata } from "shared"; +import type { MetricViewColumnDisplay, MetricViewsMetadata } from "shared"; /** * Flatten the injected {@link MetricViewsMetadata} for `key` into a single @@ -22,13 +22,13 @@ export function selectMetricMetadata( key: string, measures: string[], dimensions: string[] | undefined, -): Record | undefined { +): Record | undefined { if (!all || !Object.hasOwn(all, key)) { return undefined; } const entry = all[key]; - const slice: Record = {}; + const slice: Record = {}; for (const measure of measures) { if (Object.hasOwn(entry.measures, measure)) { diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index cd17de945..c070740d6 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,6 +1,6 @@ import type { BasePluginConfig, - MetricColumnMeta, + MetricViewColumnDisplay, MetricViewsMetadata, } from "shared"; @@ -76,7 +76,7 @@ export type AnalyticsStreamMessage = data?: unknown[]; status?: unknown; statement_id?: string; - metadata?: Record; + metadata?: Record; } | { type: "arrow"; diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index f625e6618..761eb9f36 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -171,8 +171,9 @@ ${inner}; } // Render one column's runtime metadata literal — the value-side twin of a -// `renderMetadataMap` entry, minus `time_grain` (not part of MetricColumnMeta). -// Strings go through JSON.stringify to stay escape-safe. +// `renderMetadataMap` entry, minus `time_grain` (not part of +// MetricViewColumnDisplay). Strings go through JSON.stringify to stay +// escape-safe. function renderMetadataValueField(col: MetricColumnMetadata): string { const fields = metadataFields(col).map( ([name, value]) => `${name}: ${value}`, diff --git a/packages/shared/src/metric-metadata.ts b/packages/shared/src/metric-metadata.ts index 75a9b4af7..7fa672861 100644 --- a/packages/shared/src/metric-metadata.ts +++ b/packages/shared/src/metric-metadata.ts @@ -3,7 +3,7 @@ * YAML 1.1 `display_name`/`format` attributes plus the SQL type. Loose enough * that an `as const` generated literal assigns to it. */ -export interface MetricColumnMeta { +export interface MetricViewColumnDisplay { type: string; display_name?: string; format?: string; @@ -18,7 +18,7 @@ export interface MetricColumnMeta { export type MetricViewsMetadata = Record< string, { - measures: Record; - dimensions: Record; + measures: Record; + dimensions: Record; } >; diff --git a/packages/shared/src/sse/analytics.ts b/packages/shared/src/sse/analytics.ts index 5ae9fcfc3..6ee5a5abe 100644 --- a/packages/shared/src/sse/analytics.ts +++ b/packages/shared/src/sse/analytics.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { MetricColumnMeta } from "../metric-metadata"; +import type { MetricViewColumnDisplay } from "../metric-metadata"; /** * Wire protocol for analytics SSE messages emitted by `/api/analytics/query`. @@ -54,15 +54,15 @@ export const AnalyticsResultMessage = z.object({ * `z.record(z.string(), z.unknown())` for `metadata`) to keep client * validation cheap; this interface narrows `data` to * `Record[]` and `metadata` to - * `Record` so consumers don't have to cast at every - * call site. If you add a field to the Zod schema, add it here too. + * `Record` so consumers don't have to cast + * at every call site. If you add a field to the Zod schema, add it here too. */ export interface AnalyticsResultMessage { type: "result"; data?: Record[]; status?: unknown; statement_id?: string; - metadata?: Record; + metadata?: Record; } /** @@ -85,7 +85,7 @@ export function makeResultMessage( extras: { status?: unknown; statement_id?: string; - metadata?: Record; + metadata?: Record; } = {}, ): AnalyticsResultMessage { return { type: "result", data, ...extras }; From 16ba972d0c9d5dcac2bb4f25b86e2a4deab38821 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 5 Aug 2026 15:07:13 +0200 Subject: [PATCH 7/7] chore(appkit): trim duplicated rationale from the metadata pipeline comments Third deslop pass, covering the comments the earlier two did not reach. Each of these said the same thing more than once: - the "it's a real .ts because it carries a runtime const" rationale was stated in three places; keep it where the header is emitted and where the .d.ts path is rejected, drop the restatement - the SSE metadata field re-derived the "keep client validation cheap" reasoning the adjacent interface already documents - the cache-hit rationale appeared at both the definition and the yield site; keep the definition-site one - "value twin" was echoed across three consecutive render helpers Comment-only: no behavior, no generated output, no test changes. Signed-off-by: Atila Fassina --- .../appkit/src/plugins/analytics/analytics.ts | 6 ++--- .../mv-registry/render-types.ts | 22 +++++++------------ packages/shared/src/sse/analytics.ts | 7 ++---- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index cb24ef725..a3f14f4af 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -707,10 +707,8 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw ExecutionError.statementFailed(inner); } - // Stamp the metadata onto the (possibly cached) result message. The - // cached message never carries it, so a hit after a redeploy that - // changed a column's display_name/format serves the current metadata - // rather than a stale copy. + // Stamp the metadata onto the (possibly cached) result message; the + // cached message never carries it. const resultMessage = sqlResult.data as AnalyticsSseMessage; yield ( metadata !== undefined diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index 761eb9f36..ff342dd2f 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -170,10 +170,8 @@ ${inner}; }`; } -// Render one column's runtime metadata literal — the value-side twin of a -// `renderMetadataMap` entry, minus `time_grain` (not part of -// MetricViewColumnDisplay). Strings go through JSON.stringify to stay -// escape-safe. +// Value-side twin of a `renderMetadataMap` entry, minus `time_grain` (not part +// of MetricViewColumnDisplay). function renderMetadataValueField(col: MetricColumnMetadata): string { const fields = metadataFields(col).map( ([name, value]) => `${name}: ${value}`, @@ -182,7 +180,6 @@ function renderMetadataValueField(col: MetricColumnMetadata): string { } // Render one metric's runtime measures/dimensions map, keyed by column name. -// Empty → `{}`, the value twin of the type-level `Record`. function renderMetadataValueMap( cols: MetricColumnMetadata[], indent: string, @@ -199,9 +196,8 @@ ${inner}, ${indent}}`; } -// Render the runtime `metricViewsMetadata` const — a value twin of the -// type-level `metadata` blocks, conforming to MetricViewsMetadata from -// "shared" and emitted `as const` in the same key order as the augmentation. +// Render the runtime `metricViewsMetadata` const, emitted `as const` in the +// same key order as the augmentation. function renderMetricViewsMetadata(schemas: MetricSchema[]): string { if (schemas.length === 0) { return "export const metricViewsMetadata = {} as const;\n"; @@ -242,12 +238,10 @@ ${entries}; /** * Build the full metric-views.ts file from a list of metric schemas. * - * A real `.ts` source file, not a `.d.ts`, because it carries the erasable - * `declare module` augmentation alongside a runtime `metricViewsMetadata` - * export. The header must therefore stay a type-only `import type {} from`: - * it compiles to zero runtime code while still anchoring the module so the - * augmentation resolves, whereas a bare `import "@databricks/appkit-ui/react"` - * would execute the client package entry on the Node server. + * The header must stay a type-only `import type {} from`: it anchors the module + * so the augmentation resolves while compiling to zero runtime code, whereas a + * bare `import "@databricks/appkit-ui/react"` would execute the client package + * entry on the Node server. */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], diff --git a/packages/shared/src/sse/analytics.ts b/packages/shared/src/sse/analytics.ts index 6ee5a5abe..eaff6dd12 100644 --- a/packages/shared/src/sse/analytics.ts +++ b/packages/shared/src/sse/analytics.ts @@ -38,11 +38,8 @@ export const AnalyticsResultMessage = z.object({ // `unknown` so we don't bake the SDK's detailed shape into the contract. status: z.unknown().optional(), statement_id: z.string().optional(), - // Per-column display metadata for a metric-view result (display_name / - // format / type). Kept loose (`z.record(z.string(), z.unknown())`) for the - // same "keep client validation cheap" reason as `data` — the server - // constructs it via the typed builder, so the per-column shape is enforced - // at the source. Absent for plain `/query` results. + // Per-column display metadata for a metric-view result; absent for plain + // `/query` results. Kept loose for the same reason as `data` above. metadata: z.record(z.string(), z.unknown()).optional(), });