diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index bbce8d855..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.d.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The `.d.ts` files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.ts` — a real source file rather than a `.d.ts` because it also carries a runtime `metricViewsMetadata` constant alongside the augmentation. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. ## Vite plugin: `appKitTypesPlugin` @@ -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 carrying that metadata as a value — inject it via `analytics({ metricViewsMetadata })` so the [metric route](../plugins/analytics.md#metric-views) can attach per-column display metadata to its response payload. -If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. +If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. `definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`): 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..a3f14f4af 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,16 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw err; } + // Computed here, outside the cached execute below, so a cache hit still + // serves the current metadata. Absent config → `undefined` → the `result` + // message omits the field (envelope-identical to `/query`). + const metadata = selectMetricMetadata( + this.config.metricViewsMetadata, + key, + request.measures, + request.dimensions, + ); + // Cache key. Composed over the canonicalized args (sorted measures/ // dimensions, stable-sorted predicates, grain, timeDimension, limit) plus // the `executorKey` — `"sp"` shares the cache across all users, a per-user @@ -696,7 +707,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw ExecutionError.statementFailed(inner); } - yield sqlResult.data as AnalyticsStreamMessage; + // Stamp the metadata onto the (possibly cached) result message; the + // cached message never carries it. + const resultMessage = sqlResult.data as AnalyticsSseMessage; + yield ( + metadata !== undefined + ? { ...resultMessage, metadata } + : resultMessage + ) as AnalyticsStreamMessage; }, streamExecutionSettings, executorKey, 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..e67302ef8 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -0,0 +1,45 @@ +import type { MetricViewColumnDisplay, MetricViewsMetadata } from "shared"; + +/** + * 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. + * + * Pure response decoration: it never touches the cache key or the SQL, and + * reads only from the injected value (never disk / DESCRIBE at runtime). + * + * Lookups go through {@link Object.hasOwn}, so neither an inherited metric key + * nor an inherited column name (`toString`, `__proto__`, …) can resolve to a + * bogus entry. Requested columns absent from the metadata are omitted rather + * than placeheld. + * + * Returns `undefined` rather than an empty object when there is nothing to + * stamp, so the caller can omit the field and keep the message byte-identical + * to a plain `/query` result. + */ +export function selectMetricMetadata( + all: MetricViewsMetadata | undefined, + key: string, + measures: string[], + dimensions: string[] | undefined, +): Record | 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..e704ae309 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,252 @@ 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("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", + 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, which must reach the + // response. + 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 +1055,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 +1190,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 +1910,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 +1993,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 +2233,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 +2306,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 +2480,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..c070740d6 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, + MetricViewColumnDisplay, + 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. + */ + 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 e8d47feac..45cfce711 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, @@ -311,7 +307,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 @@ -411,7 +407,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) { @@ -490,7 +486,7 @@ export async function generateFromEntryPoint(options: { [ { name: "type-generator", - message: `Warehouse ${warehouseId} could not provide schemas and the required committed type ${plural(missingCommittedTypes.length, "artifact is", "artifacts are")} missing: ${missingCommittedTypes.join(", ")}. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated .d.ts files.`, + message: `Warehouse ${warehouseId} could not provide schemas and the required committed type ${plural(missingCommittedTypes.length, "artifact is", "artifacts are")} missing: ${missingCommittedTypes.join(", ")}. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated type files.`, }, ], warehouseId, @@ -550,14 +546,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; @@ -679,15 +677,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); @@ -713,15 +708,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 }); @@ -781,9 +770,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.", @@ -847,7 +834,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); @@ -859,6 +846,23 @@ export async function syncMetricViewsTypes(options: { "utf-8", ); } + // 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") && + existsSync(metricOutFile) + ) { + 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", @@ -900,4 +904,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..ff342dd2f 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]; + +// 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, +): 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,54 @@ ${inner}; }`; } +// 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}`, + ); + return `{ ${fields.join(", ")} }`; +} + +// Render one metric's runtime measures/dimensions map, keyed by column name. +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, 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"; + } + 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 +235,20 @@ ${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. + * + * 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[], ): 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 830752d61..618f42db6 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", @@ -326,7 +326,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }; const writeCommittedMetricTypes = () => { - const committed = "// committed metric types\n"; + // Mirrors a real committed metric-views.ts: the augmentation plus the + // runtime const, so a preserved fallback stays a loadable module. + const committed = + "// committed metric types\nexport const metricViewsMetadata = {};\n"; fs.mkdirSync(path.dirname(metricFile), { recursive: true }); fs.writeFileSync(metricFile, committed, "utf-8"); return committed; @@ -349,7 +352,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( @@ -366,9 +369,18 @@ 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 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"', + ); }); test("emits no metric artifacts and no errors when definitions.json is absent", async () => { @@ -471,7 +483,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( @@ -660,7 +673,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as Error).message).toContain("metric-views.d.ts"); + expect((error as Error).message).toContain("metric-views.ts"); expect(fs.existsSync(outFile)).toBe(true); expect(fs.existsSync(metricFile)).toBe(false); }); @@ -1129,7 +1142,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, @@ -1859,11 +1872,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, @@ -1966,7 +1975,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({ @@ -2026,7 +2035,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({ @@ -2074,7 +2083,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({ @@ -2166,11 +2175,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(); @@ -2390,6 +2395,55 @@ 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); + // Per-surface gate: only the metric surface failed, so the remedy names + // metric-views.ts specifically rather than the whole artifact set. + expect(message).toContain( + "required committed type artifact is missing: metric-views.ts", + ); + 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({ @@ -2489,7 +2543,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. @@ -2517,7 +2571,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"); @@ -2527,7 +2581,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..ff78a15fa 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,113 @@ 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 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({ + 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. + 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 +2050,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 +2077,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 +2088,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..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 @@ -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,81 @@ 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`, 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"'); + 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`, which would duplicate the augmentation if left + // beside the new `.ts`. + 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("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 () => { 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 e96233b6b..6c0b6ecc3 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(), @@ -57,7 +44,7 @@ const { generateFromEntryPoint, TypegenFatalError } = await import("../index"); const testDir = path.join(__dirname, "__output_unreachable_gate__"); const queryFolder = path.join(testDir, "queries"); const outFile = path.join(testDir, "generated", "analytics.d.ts"); -const metricFile = path.join(testDir, "generated", "metric-views.d.ts"); +const metricFile = path.join(testDir, "generated", "metric-views.ts"); /** DNS-style transport failure: what a CI runner without warehouse egress sees. */ function unreachableError() { @@ -94,12 +81,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(); }); @@ -125,10 +109,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(); @@ -210,7 +191,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, @@ -224,7 +204,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..3c79fc193 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( + `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.", + ); + } 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..7fa672861 --- /dev/null +++ b/packages/shared/src/metric-metadata.ts @@ -0,0 +1,24 @@ +/** + * 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 MetricViewColumnDisplay { + 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..eaff6dd12 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 { MetricViewColumnDisplay } from "../metric-metadata"; /** * Wire protocol for analytics SSE messages emitted by `/api/analytics/query`. @@ -37,22 +38,28 @@ 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; absent for plain + // `/query` results. Kept loose for the same reason as `data` above. + 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 - * call site. If you add a field to the Zod schema, add it here too. + * `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 { type: "result"; data?: Record[]; status?: unknown; statement_id?: string; + metadata?: Record; } /** @@ -72,7 +79,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 }; }