diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 1f4745f52..d79530cf5 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -2,5 +2,5 @@ test-results/ playwright-report/ -# Auto-generated types (endpoint-specific, varies per developer) +# Auto-generated types (regenerated on `pnpm dev` by appKitTypesPlugin) shared/appkit-types/serving.d.ts \ No newline at end of file diff --git a/apps/dev-playground/config/queries/metric-views.json b/apps/dev-playground/config/queries/metric-views.json new file mode 100644 index 000000000..75dd7d198 --- /dev/null +++ b/apps/dev-playground/config/queries/metric-views.json @@ -0,0 +1,11 @@ +{ + "metricViews": { + "revenue": { + "source": "appkit_demo.public.revenue_metrics" + }, + "customers": { + "source": "appkit_demo.public.customer_metrics", + "executor": "user" + } + } +} diff --git a/apps/dev-playground/shared/appkit-types/metric-views.d.ts b/apps/dev-playground/shared/appkit-types/metric-views.d.ts new file mode 100644 index 000000000..e0ada3749 --- /dev/null +++ b/apps/dev-playground/shared/appkit-types/metric-views.d.ts @@ -0,0 +1,110 @@ +// Auto-generated by AppKit - DO NOT EDIT +// Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build +import "@databricks/appkit-ui/react"; +declare module "@databricks/appkit-ui/react" { + interface MetricRegistry { + "customers": { + key: "customers"; + source: "appkit_demo.public.customer_metrics"; + lane: "obo"; + measures: { + /** @sqlType bigint */ + "active_accounts": number; + /** @sqlType decimal */ + "churn_rate": number; + /** @sqlType double */ + "avg_ltv": number; + }; + dimensions: { + /** @sqlType string */ + "segment": string; + /** @sqlType string */ + "region": string; + /** @sqlType string */ + "csm_email": string; + }; + measureKeys: "active_accounts" | "churn_rate" | "avg_ltv"; + dimensionKeys: "segment" | "region" | "csm_email"; + timeGrains: never; + metadata: { + measures: { + "active_accounts": { + type: "bigint"; + }; + "churn_rate": { + type: "decimal"; + }; + "avg_ltv": { + type: "double"; + }; + }; + dimensions: { + "segment": { + type: "string"; + }; + "region": { + type: "string"; + }; + "csm_email": { + type: "string"; + }; + }; + }; + }; + "revenue": { + key: "revenue"; + source: "appkit_demo.public.revenue_metrics"; + lane: "sp"; + measures: { + /** @sqlType double */ + "mrr": number; + /** @sqlType double */ + "arr": number; + /** @sqlType double */ + "new_arr": number; + /** @sqlType double */ + "churned_arr": number; + }; + dimensions: { + /** @sqlType string */ + "region": string; + /** @sqlType string */ + "segment": string; + /** @sqlType timestamp_ltz @timeGrain day|hour|minute|month|quarter|week|year */ + "created_at": string; + }; + measureKeys: "mrr" | "arr" | "new_arr" | "churned_arr"; + dimensionKeys: "region" | "segment" | "created_at"; + timeGrains: "day" | "hour" | "minute" | "month" | "quarter" | "week" | "year"; + metadata: { + measures: { + "mrr": { + type: "double"; + }; + "arr": { + type: "double"; + description: "Annualized contract value across all active subscriptions"; + }; + "new_arr": { + type: "double"; + }; + "churned_arr": { + type: "double"; + }; + }; + dimensions: { + "region": { + type: "string"; + }; + "segment": { + type: "string"; + }; + "created_at": { + type: "timestamp_ltz"; + time_grain: readonly ["day", "hour", "minute", "month", "quarter", "week", "year"]; + }; + }; + }; + }; + } +} diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 48d239ff9..b1bdeee74 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 plugin (e.g. `analytics.d.ts`). They 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.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. ## Vite plugin: `appKitTypesPlugin` @@ -84,6 +84,31 @@ npx @databricks/appkit generate-types --wait In blocking mode the generator starts a stopped warehouse, waits (bounded) for it to reach `RUNNING`, and then describes your queries. It fails only when the configured warehouse no longer exists (deleted/deleting), so a transient outage or a cold warehouse degrades gracefully rather than breaking the build. The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. +## Metric-view types + +`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/queries/metric-views.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/`: + +- `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. + +If `metric-views.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` that same situation fails the build so CI never ships incomplete metric types. A malformed `metric-views.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. + +`metric-views.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`): + +```json +{ + "$schema": "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + "metricViews": { + "revenue": { "source": "catalog.schema.revenue_metrics" }, + "customers": { + "source": "catalog.schema.customer_metrics", + "executor": "user" + } + } +} +``` + +The optional `$schema` line enables editor autocomplete and validation against the published schema. + ## How it works The type generator: @@ -121,7 +146,7 @@ const { data } = useAnalyticsQuery("users_list", { }); // TypeScript knows the shape of the result rows -data?.forEach(row => { +data?.forEach((row) => { console.log(row.email); // ✓ autocomplete works }); ``` diff --git a/packages/appkit/src/type-generator/cache.ts b/packages/appkit/src/type-generator/cache.ts index a05a6ab25..b5cc67ed3 100644 --- a/packages/appkit/src/type-generator/cache.ts +++ b/packages/appkit/src/type-generator/cache.ts @@ -26,17 +26,19 @@ interface CacheEntry { * `hash` is md5 over `"|"` — the two config inputs that * determine a DESCRIBE — so editing either invalidates the entry. `schema` * is the full {@link MetricSchema} persisted verbatim (it is JSON-safe by - * design), letting a warm pass regenerate both metric artifacts without a - * single warehouse call. `retry: true` marks a SELF-CONVERGING degraded - * outcome (DESCRIBE skipped behind a not-running warehouse, unanswered, or - * transiently failed): the cached schema still renders artifacts, but the - * next eligible pass re-describes exactly these keys so degraded schemas - * converge to real ones. A degraded schema with `retry: false` is a STICKY - * failure — a deterministic DESCRIBE failure (bad FQN, unparseable - * response, zero columns) or a deleted warehouse — that re-describing the - * unchanged entry cannot fix; it hits like any cached entry until the - * config hash changes or the cache is bypassed, and the type generator - * warns about it on every pass that serves it. + * design), letting a warm pass regenerate the metric artifact without a + * single warehouse call. + * + * The cache holds ONLY successful (non-degraded) describes. A degraded + * outcome — DESCRIBE skipped behind a not-running or deleted warehouse, + * unanswered, or a per-key failure — is rendered into the emitted artifact + * but never written here (mirroring the query path, which "Never persists + * `result: unknown`"). A key that degraded on one pass is therefore left + * uncached and simply re-described on the next eligible pass; there is no + * sticky degraded entry to serve. `retry` is vestigial — always `false`, + * mirroring the query path's only cache write — and is retained solely for + * on-disk shape compatibility with existing version-3 caches (the revival + * gate still checks it is a boolean). */ export interface MetricCacheEntry { hash: string; diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 90dcaae7d..10ea7d7f8 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -19,7 +19,6 @@ import { } from "./migration"; import { readMetricConfig, resolveMetricConfig } from "./mv-registry/config"; import { createWorkspaceDescribeFetcher } from "./mv-registry/describe"; -import { generateMetricsMetadataJson } from "./mv-registry/metadata"; import { generateMetricTypeDeclarations } from "./mv-registry/render-types"; import { emptyMetricSchema, syncMetrics } from "./mv-registry/sync"; import type { @@ -46,7 +45,7 @@ dotenv.config(); const logger = createLogger("type-generator"); /** - * Upper bound (~5 min) on how long the metric path's `blocking`-mode preflight + * Upper bound (~5 min) on how long the Metric Views path's `blocking`-mode preflight * waits for a warehouse to reach RUNNING. Mirrors the query path's (unexported) * `PREFLIGHT_WAIT_MAX_MS` in query-registry.ts. */ @@ -78,7 +77,6 @@ function formatFailureRows( const tag = color(label.padEnd(7)); const rows: string[] = []; for (const [message, names] of byMessage) { - // Unique message → keep the compact one-line `tag name message` form. if (names.length === 1) { rows.push( ` ${tag} ${pc.bold(names[0].padEnd(maxNameLen))} ${pc.dim(message)}`, @@ -237,13 +235,16 @@ declare module "@databricks/appkit-ui/react" { * never start the warehouse — unlike the metric DESCRIBE statements it guards, * whose execution auto-starts a stopped warehouse and waits on it. * - * Returns the observed state so the gate can distinguish a transient - * not-running state (STOPPED/STARTING/... → degraded entries that retry) from a - * terminal one (DELETED/DELETING → degraded entries pinned sticky). Takes the - * lazy client *getter* (not a client) so the probe also absorbs client - * construction failure. A connectivity blip returns `undefined`, which the gate - * reads as transient not-running; a deterministic failure (auth, bad id) is - * re-thrown so the gate can classify it fatal rather than silently degrading. + * Returns the observed state so the gate can decide whether to DESCRIBE now + * (only when RUNNING) or emit degraded artifacts for a later pass to refresh. + * Degraded outcomes are never cached, so a not-running warehouse — transient + * (STOPPED/STARTING) or terminal (DELETED/DELETING) — simply leaves its keys + * uncached to be re-probed next pass; a terminal warehouse is only ever a hard + * failure via the blocking preflight. Takes the lazy client *getter* (not a + * client) so the probe also absorbs client construction failure. A connectivity + * blip returns `undefined`, which the gate reads as transient not-running; a + * deterministic failure (auth, bad id) is re-thrown so the gate can classify it + * fatal rather than silently degrading. */ async function probeWarehouseState( getClient: () => WorkspaceClient, @@ -277,12 +278,8 @@ async function probeWarehouseState( * degraded types immediately. `"blocking"` waits for / starts the warehouse * first, failing the build only for a deleted/deleting one. * @param options.mvOutFile - optional output file for the MetricRegistry - * augmentation. Defaults to a sibling `metric.d.ts` file under the same + * augmentation. Defaults to a sibling `metric-views.d.ts` file under the same * directory as `outFile`. Skipped entirely if `metric-views.json` is absent. - * @param options.mvMetadataOutFile - optional output file for the - * build-time semantic metadata JSON bundle (`metrics.metadata.json`). - * Defaults to a sibling of `mvOutFile`. Skipped entirely if - * `metric-views.json` is absent. * @param options.metricFetcher - optional DescribeFetcher used by * {@link syncMetrics} (tests inject a mock; production lazily builds a * default WorkspaceClient-backed one). An injected fetcher always runs: it @@ -296,7 +293,6 @@ export async function generateFromEntryPoint(options: { noCache?: boolean; mode?: PreflightMode; mvOutFile?: string; - mvMetadataOutFile?: string; metricFetcher?: DescribeFetcher; }) { const { @@ -306,7 +302,6 @@ export async function generateFromEntryPoint(options: { noCache, mode = "non-blocking", mvOutFile, - mvMetadataOutFile, metricFetcher, } = options; const projectRoot = resolveProjectRoot(outFile); @@ -332,349 +327,440 @@ export async function generateFromEntryPoint(options: { await fs.writeFile(outFile, typeDeclarations, "utf-8"); // Metric-view types: only emit when metric-views.json exists. The path is - // purely additive — apps that never adopt metric views must not produce - // empty noise. + // purely additive — apps that never adopt metric views must not produce empty + // noise. Delegate to the unified metric pipeline in syncMetricViewsTypes, + // forwarding this run's mode verbatim: `non-blocking` keeps its status-only + // #406 gate, `blocking` keeps its preflight, and both serve only good (never + // degraded) cache hits. The unified fn returns early + // with `noConfig: true` when metric-views.json is absent, so the additive + // "only when it exists" behavior is preserved here by simply ignoring it. + // + // Failure surfacing mirrors the query path and follows this run's mode: + // - a deleted/deleting-warehouse fatal preflight always surfaces (blocking + // mode only) via `fatalErrors` — unchanged; + // - in `blocking` mode (`--wait`, and the production Vite build) per-key + // DESCRIBE failures (a bad/unreachable source — a config error) are folded + // into `fatalErrors` too, so the end-of-run throw fails the build after the + // writes: `--wait` must not ship permissive types for a misconfigured view; + // - in `non-blocking` mode (the default CLI run, and dev Vite) nothing is + // escalated here: syncMetricViewsTypes already warns per failed/degraded + // view, permissive types were written, and a `--wait` rerun (or the + // detached background worker) converges. + // A warehouse that merely isn't ready (degraded, NOT a per-key failure) is not + // escalated even under `--wait` — that stays the existing soft degrade so infra + // flakiness can't break a build; only a deleted/deleting warehouse is fatal (at + // preflight). A malformed metric-views.json (JSON parse, or FQN/schema + // validation) is the only way syncMetricViewsTypes throws; it's a deterministic + // developer error, re-thrown as a message-only TypegenFatalError that fails in + // every mode (like a query TypegenSyntaxError) instead of bubbling a raw stack. if (queryFolder) { - const mvConfig = await readMetricConfig(queryFolder); - if (mvConfig) { - const resolution = resolveMetricConfig(mvConfig); - - // Metric schemas persist in the shared typegen cache as a `metrics` - // section (sibling of `queries`, same file/version), keyed by metric key - // with md5("|") as the change detector. Loaded strictly - // AFTER the query path's own load → mutate → save cycle, so the single - // metric-side save below can never clobber a query entry. - const cache = await loadCache(); - - // The section is consumed through a null-prototype copy: metric keys - // are user-controlled config input and "__proto__" passes the metric - // key regex — on a plain object, writing it would hit the - // Object.prototype setter (mutating the object's prototype and silently - // dropping the entry) instead of storing data. A null prototype also - // keeps partition reads from resolving inherited names ("constructor", - // "toString", ...) as phantom entries. - const mvCacheSection: Record = - Object.create(null); - if (!noCache && cache.metrics) { - for (const key of Object.keys(cache.metrics)) { - mvCacheSection[key] = cache.metrics[key]; - } - } + const mvFile = + mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); - // Partition BEFORE any gate/preflight decision: a hit (structurally valid - // entry, hash match, not retry-flagged) is served from cache no matter - // what the warehouse is doing — a degraded pass falls back to - // last-known-good schemas, exactly like queries degrade to cached types. - // Only the remainder (new, edited, retry-flagged, or unrevivable entries) - // is eligible for DESCRIBE, so a fully-warm pass makes zero warehouse - // calls and constructs zero clients. - const hitSchemas = new Map(); - const describeNeeded: typeof resolution.entries = []; - // Degraded cached schemas pinned `retry: false` are sticky failures: they - // serve their permissive schema like any hit, but are collected here for - // the single notice below so the misconfiguration isn't silently hidden. - const stickyDegradedHits: string[] = []; - for (const entry of resolution.entries) { - const prior = mvCacheSection[entry.key]; - if ( - prior !== undefined && - isRevivableMetricCacheEntry(prior) && - prior.hash === metricCacheHash(entry.source, entry.lane) && - !prior.retry - ) { - hitSchemas.set(entry.key, prior.schema); - if (prior.schema.degraded === true) { - stickyDegradedHits.push(entry.key); - } - } else { - describeNeeded.push(entry); - } - } + let mvResult: SyncMetricViewsTypesResult; + try { + mvResult = await syncMetricViewsTypes({ + queryFolder, + warehouseId, + metricOutFile: mvFile, + cache: !noCache, + metricFetcher, + mode, + }); + } catch (configError) { + // syncMetricViewsTypes only throws for a malformed metric-views.json — + // re-throw as a message-only TypegenFatalError (see the note above). + throw new TypegenFatalError( + [ + { + name: "metric-views.json", + message: getErrorDiagnostic(configError), + }, + ], + warehouseId, + ); + } - if (stickyDegradedHits.length > 0) { - logger.warn( - "cached failure for %s — fix the entry in metric-views.json or run with --no-cache to retry.", - stickyDegradedHits.join(", "), - ); - } + // Deleted/deleting-warehouse fatal preflight (blocking mode only); empty + // (no-op) when metric-views.json is absent or in non-blocking mode. + for (const fe of mvResult.fatalErrors) { + fatalErrors.push(fe); + } - // At most ONE WorkspaceClient per pass for the whole metric path: the - // status probe, the blocking preflight, and the default DESCRIBE fetcher - // share this lazily-created instance, so a pass that never contacts the - // warehouse constructs zero clients. - let mvClient: WorkspaceClient | undefined; - const getMvClient = (): WorkspaceClient => { - mvClient ??= new WorkspaceClient({}); - return mvClient; - }; - - // Blocking-mode preflight: ensure the warehouse is running before the - // DESCRIBE batch (probe → decide → wait / start+wait; only - // DELETED/DELETING is fatal). Deliberately split from the query path's - // preflight — metric views may bind a different warehouse in future. Two - // softenings vs the query preflight: a failed probe and a timed-out wait - // are NOT fatal here — we fall through to syncMetrics, which classifies a - // still-not-ready warehouse as degraded rather than failing the build. - let preflightFatalMessage: string | undefined; - if ( - mode === "blocking" && - metricFetcher === undefined && - describeNeeded.length > 0 - ) { - try { - const state = await getWarehouseState(getMvClient(), warehouseId); - const decision = decidePreflight(state, mode); - if (decision === "fatal") { - preflightFatalMessage = `warehouse ${warehouseId} is ${state}`; - } else if (decision === "startWaitProceed") { - // treatStoppedAsTransient rides out the stale pre-start - // STOPPED/STOPPING reading, same as the query preflight. - await startWarehouse(getMvClient(), warehouseId); - const settled = await waitUntilRunning(getMvClient(), warehouseId, { - maxMs: MV_PREFLIGHT_WAIT_MAX_MS, - treatStoppedAsTransient: true, - }); - if (settled !== "RUNNING") { - // With treatStoppedAsTransient, a non-RUNNING resolve is - // exactly DELETED/DELETING — the warehouse was deleted while - // we waited. Fatal, same as catching it at decision time. - preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; - } - } else if (decision === "waitThenProceed") { - const settled = await waitUntilRunning(getMvClient(), warehouseId, { - maxMs: MV_PREFLIGHT_WAIT_MAX_MS, - }); - if (settled === "DELETED" || settled === "DELETING") { - // Deleted mid-wait: fatal. A STOPPED/STOPPING resolve (this - // wait runs without treatStoppedAsTransient) stays a soft - // fall-through — a stopped warehouse is startable, so it - // degrades and converges rather than failing the build. - preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; - } - } - } catch (err) { - // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs - // degrade a not-ready / unreachable warehouse rather than throwing. A - // deterministic failure (auth, bad warehouse id, a timed-out start) - // is fatal — surface it instead of stalling ~5 min against a - // not-ready warehouse, mirroring the query path's preflight catch. - if (!isConnectivityError(err)) { - preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; - } - } + // Blocking (`--wait` / prod Vite) escalates per-key DESCRIBE failures — a + // bad or unreachable source, i.e. a config error — to build failures so the + // end-of-run throw fails after the writes. A warehouse that's merely not + // ready stays degraded (the preflight above + syncMetrics own that), even + // under `--wait`; non-blocking leaves failures as syncMetricViewsTypes' own + // warnings (permissive types already written). + if (mode === "blocking") { + for (const failure of mvResult.failures) { + fatalErrors.push({ + name: failure.key, + message: `metric view ${failure.key} (${failure.source}) could not be described: ${failure.reason}`, + }); } + } + } - // Honor the non-blocking preflight contract (#406) for metric DESCRIBEs: - // a `DESCRIBE TABLE EXTENDED ... AS JSON` waits up to 30s per key and - // auto-starts a stopped warehouse — exactly what "non-blocking" promises - // not to do. So one status-only probe (which can't start the warehouse) - // decides whether to DESCRIBE now or emit degraded artifacts for a later - // blocking run; it keeps the observed state so the skip can tell a - // transient not-running warehouse from a terminal DELETED/DELETING one. - let gateState: WarehouseState | undefined; - let describeNow = - metricFetcher !== undefined || - mode !== "non-blocking" || - describeNeeded.length === 0; - if (!describeNow) { - try { - gateState = await probeWarehouseState(getMvClient, warehouseId); - } catch (err) { - // probeWarehouseState only throws on a deterministic failure (auth, - // bad warehouse id) — a connectivity blip already returned undefined. - // Pin it fatal through the same path as a fatal blocking preflight. - preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; - } - describeNow = gateState === "RUNNING"; - } + await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); + await migrateProjectConfig(projectRoot); - let described: MetricSchema[]; - let failures: MetricSyncFailure[] = []; - // True when this pass skipped DESCRIBE for a reason that can never - // self-converge — a deleted/deleting warehouse (fatal preflight or gate - // skip). The write site pins those degraded outcomes sticky. - let terminalSkip = false; - if (preflightFatalMessage !== undefined) { - // Fatal preflight (deleted/deleting warehouse): fail like the query - // path — skip DESCRIBE, emit degraded schemas so both artifacts are - // still written, and record one fatal error per describe-needed key - // (cache hits are unaffected). The end-of-run throw below surfaces them - // after the writes. Terminal, so these entries are pinned sticky. - described = describeNeeded.map(emptyMetricSchema); - terminalSkip = true; - for (const entry of describeNeeded) { - fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); - } - } else if (describeNeeded.length === 0) { - // Nothing left to describe — every configured key was a cache hit. - // syncMetrics would be a no-op (and building its fetcher would - // construct a client for nothing); artifacts regenerate from cache. - described = []; - } else if (describeNow) { - const fetcher = - metricFetcher ?? - createWorkspaceDescribeFetcher(getMvClient(), warehouseId); - ({ schemas: described, failures } = await syncMetrics( - { entries: describeNeeded }, - fetcher, - )); - - // Surface DESCRIBE failures loudly: a misconfigured metric-views.json - // would otherwise silently ship an empty entry that the runtime - // fail-closed gate 503s in production. syncMetrics is log-free; this - // caller is the single owner of failure logging. - if (failures.length > 0) { - for (const f of failures) { - logger.warn( - "metric sync failed for %s (%s): %s", - f.key, - f.source, - f.reason, - ); - } - } + // Types are always written above — including `result: unknown` for any Metric View that could not be described. + if (syntaxErrors.length > 0) { + throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors); + } + if (fatalErrors.length > 0) { + throw new TypegenFatalError(fatalErrors, warehouseId); + } - // Degraded-but-not-failed keys: the warehouse answered with a - // non-terminal state (stopped / cold-starting), so their schemas are - // unknown — not errors. One summary line, no per-key warns; failed - // keys are excluded (the warn loop above already reported them). - const failedKeys = new Set(failures.map((f) => f.key)); - const degradedKeys = described - .filter((s) => s.degraded && !failedKeys.has(s.key)) - .map((s) => s.key); - if (degradedKeys.length > 0) { - logger.info( - "Warehouse %s did not return schemas for %d metric view(s) (%s) — wrote degraded metric types (permissive); they will refresh once the warehouse is available.", - warehouseId, - degradedKeys.length, - degradedKeys.join(", "), - ); - } - } else { - // Un-probed DESCRIBEs deliberately skipped, not failures: emit each - // describe-needed key as a degraded schema (permissive types) so both - // artifacts exist; cache hits keep serving last-known-good. A transient - // state refreshes on a later RUNNING pass; a DELETED/DELETING probe is - // terminal, so those keys are pinned sticky below. - described = describeNeeded.map(emptyMetricSchema); - terminalSkip = gateState === "DELETED" || gateState === "DELETING"; - 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.", - warehouseId, - describeNeeded.length, - describeNeeded.map((e) => e.key).join(", "), - ); - } + logger.debug("Type generation complete!"); +} - // Persist outcomes for exactly the keys this pass owned (the - // describe-needed set); hits were partitioned out above and are never - // rewritten, so a warehouse-down pass keeps last-known-good entries. A - // successful DESCRIBE caches `retry: false`; a degraded outcome caches - // `retry: true` only when re-describing could later succeed (non-terminal - // state or transient failure), else sticky `retry: false`. One save per - // pass; with `noCache` the section started empty, so it's overwritten. - const failureByKey = new Map(); - for (const failure of failures) { - failureByKey.set(failure.key, failure); - } - for (let i = 0; i < describeNeeded.length; i++) { - // syncMetrics (and both .map(emptyMetricSchema) branches) return - // one schema per entry in entry order, so described[i] always - // belongs to describeNeeded[i]. - const entry = describeNeeded[i]; - const failure = failureByKey.get(entry.key); - mvCacheSection[entry.key] = { - hash: metricCacheHash(entry.source, entry.lane), - schema: described[i], - retry: - described[i].degraded === true && - !terminalSkip && - (failure === undefined || failure.transient === true), - }; - } +/** + * Result of a {@link syncMetricViewsTypes} run, returned to the caller (the CLI + * directly, or {@link generateFromEntryPoint} which delegates to it) so it can + * report what happened and decide its exit code. + */ +export interface SyncMetricViewsTypesResult { + metricOutFile?: string; + schemas: MetricSchema[]; + failures: MetricSyncFailure[]; + /** + * `true` when no `metric-views.json` was found in the query folder, so nothing + * was synced. The metric path is additive — its absence is not an error. + */ + noConfig: boolean; + /** + * Per-key fatal preflight errors (empty except in the `blocking`-mode + * deleted/deleting-warehouse and deterministic-preflight-failure cases). The + * artifacts are still written; {@link generateFromEntryPoint} surfaces these + * by throwing {@link TypegenFatalError} after the writes. A `"describe-now"` + * run sets no blocking preflight, so for that mode this is always empty. + */ + fatalErrors: Array<{ name: string; message: string }>; +} + +/** + * Unified metric-view type-generation pipeline behind {@link + * generateFromEntryPoint}'s metric section (which forwards its + * `"non-blocking"`/`"blocking"` mode). Also directly callable with the default + * `"describe-now"` mode for a focused, always-converge metric refresh. + * + * The shared typegen cache (the `metrics` section of `.appkit-types-cache.json`, same {@link metricCacheHash} change-detector and {@link MetricCacheEntry} shape) means a second run over an unchanged, healthy config makes zero warehouse calls. `cache === false` (the CLI's `--no-cache`) ignores the cached section entirely (every key becomes describe-needed) and overwrites it with this pass's results. + * + * The `mode` toggle is the ONLY axis that differs between callers: + * - `"describe-now"` (the default for a direct call): no preflight, no status probe — DESCRIBE every key that isn't a clean cache hit. + * - `"non-blocking"` (dev/Vite default): one status-only probe, DESCRIBE only when the warehouse is already RUNNING, else emit degraded artifacts immediately. + * + * Across every mode the cache holds only successful describes — a degraded outcome is written into the emitted artifacts but never cached (mirroring the query path, which "Never persists `result: unknown`"). So a cache hit is always a good schema, and a key that degraded on one pass is simply re-described on the next; there is no sticky degraded entry and no "serve stale permissive types" path. + * - `"blocking"`: wait for / start the warehouse first (only a deleted/deleting one is fatal), then DESCRIBE. Degraded cache hits are served, same as non-blocking. A fatal preflight is reported via {@link SyncMetricViewsTypesResult.fatalErrors} (the artifacts are still written) so the caller can throw after the writes. + * + * An injected `metricFetcher` always runs — it hits no warehouse, so it bypasses both the blocking preflight and the non-blocking gate regardless of mode. + * + * @param options.queryFolder - folder that holds `metric-views.json` (conventionally `/config/queries`). Returns early with `noConfig: true` when the file is absent — additive, never an error. + * @param options.warehouseId - SQL warehouse used for `DESCRIBE TABLE EXTENDED`. + * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`. + * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). Mirrors the `noCache` convention on {@link generateFromEntryPoint}: gate the cache READ (`!noCache`) and overwrite the `metrics` section on SAVE. + * @param options.metricFetcher - optional injected {@link DescribeFetcher} + * (tests pass a mock; production lazily builds a WorkspaceClient-backed one). + * @param options.mode - preflight/gate policy, default `"describe-now"`. See above; a direct call may omit it (taking `"describe-now"`), while {@link generateFromEntryPoint} forwards its own {@link PreflightMode}. + */ +export async function syncMetricViewsTypes(options: { + queryFolder: string; + warehouseId: string; + metricOutFile: string; + cache?: boolean; + metricFetcher?: DescribeFetcher; + mode?: "describe-now" | "non-blocking" | "blocking"; +}): Promise { + const { + queryFolder, + warehouseId, + metricOutFile, + cache: cacheEnabled, + metricFetcher, + mode = "describe-now", + } = options; + + // Only `cache === false` disables caching; `undefined`/`true` keep it on. + const noCache = cacheEnabled === false; + + const mvConfig = await readMetricConfig(queryFolder); + if (!mvConfig) { + // No metric-views.json — additive path stays dormant. The CLI turns this + // into a friendly "nothing to sync" message and exits 0; + // generateFromEntryPoint simply ignores `noConfig`. + return { schemas: [], failures: [], fatalErrors: [], noConfig: true }; + } + + const resolution = resolveMetricConfig(mvConfig); + + const fatalErrors: Array<{ name: string; message: string }> = []; + + // Load the shared typegen cache and copy its `metrics` section into a null-prototype map. + const cache = await loadCache(); + const mvCacheSection: Record = Object.create(null); + if (!noCache && cache.metrics) { + for (const key of Object.keys(cache.metrics)) { + mvCacheSection[key] = cache.metrics[key]; + } + } + + // Partition BEFORE any gate/preflight decision: a hit (a structurally valid, + // hash-matching, NON-degraded cached entry) is served from cache no matter + // what the warehouse is doing. The cache only ever holds successful describes + // (a degraded outcome is never persisted — see the write block below), so the + // `degraded !== true` guard is normally moot; it also defends against a stale + // degraded entry left by an older writer, which re-describes instead of + // serving. Everything else (new, edited, unrevivable, or degraded) is eligible + // for DESCRIBE, so a fully-warm pass makes zero warehouse calls and constructs + // zero clients. Mirrors the query path: only a good result is cache-servable. + const hitSchemas = new Map(); + const describeNeeded: typeof resolution.entries = []; + for (const entry of resolution.entries) { + const prior = mvCacheSection[entry.key]; + if ( + prior !== undefined && + isRevivableMetricCacheEntry(prior) && + prior.hash === metricCacheHash(entry.source, entry.lane) && + prior.schema.degraded !== true + ) { + hitSchemas.set(entry.key, prior.schema); + } else { + describeNeeded.push(entry); + } + } - // Prune entries whose key is no longer configured, so a removed metric - // doesn't haunt the cache file forever. - const configuredKeys = new Set(resolution.entries.map((e) => e.key)); - let prunedCount = 0; - for (const key of Object.keys(mvCacheSection)) { - if (!configuredKeys.has(key)) { - delete mvCacheSection[key]; - prunedCount++; + let mvClient: WorkspaceClient | undefined; + const getMvClient = (): WorkspaceClient => { + mvClient ??= new WorkspaceClient({}); + return mvClient; + }; + + // Blocking-mode preflight: ensure the warehouse is running before the MV DESCRIBE + // batch (probe → decide → wait / start+wait; only DELETED/DELETING is fatal). Two softenings vs the query preflight: a failed probe and a timed-out wait are NOT fatal here — we fall through to syncMetrics, which classifies a still-not-ready warehouse as degraded rather than failing the build. Skipped for `describe-now`/`non-blocking` (only `mode === "blocking"` enters here). + let preflightFatalMessage: string | undefined; + if ( + mode === "blocking" && + metricFetcher === undefined && + describeNeeded.length > 0 + ) { + try { + const state = await getWarehouseState(getMvClient(), warehouseId); + const decision = decidePreflight(state, mode); + if (decision === "fatal") { + preflightFatalMessage = `warehouse ${warehouseId} is ${state}`; + } else if (decision === "startWaitProceed") { + // treatStoppedAsTransient rides out the stale pre-start STOPPED/STOPPING + // reading, same as the query preflight. + await startWarehouse(getMvClient(), warehouseId); + const settled = await waitUntilRunning(getMvClient(), warehouseId, { + maxMs: MV_PREFLIGHT_WAIT_MAX_MS, + treatStoppedAsTransient: true, + }); + if (settled !== "RUNNING") { + // With treatStoppedAsTransient, a non-RUNNING resolve is exactly + // DELETED/DELETING — the warehouse was deleted while we waited. + preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; + } + } else if (decision === "waitThenProceed") { + const settled = await waitUntilRunning(getMvClient(), warehouseId, { + maxMs: MV_PREFLIGHT_WAIT_MAX_MS, + }); + if (settled === "DELETED" || settled === "DELETING") { + // Deleted mid-wait: fatal. + preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; } } - - // Save when this pass produced outcomes, bypassed the cache, or pruned - // — a warm pass over a shrunk config has nothing to describe but must - // still shrink the file. - if (describeNeeded.length > 0 || noCache || prunedCount > 0) { - cache.metrics = mvCacheSection; - await saveCache(cache); + } catch (err) { + // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs degrade + // a not-ready / unreachable warehouse rather than throwing. + if (!isConnectivityError(err)) { + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } + } + } - // Merge cached hits with fresh results back into config order - // (resolution.entries order — the renderers sort internally where - // determinism matters). - const describedByKey = new Map(); - for (const schema of described) { - describedByKey.set(schema.key, schema); - } - const mvSchemas = resolution.entries.map((entry) => { - const schema = - hitSchemas.get(entry.key) ?? describedByKey.get(entry.key); - if (schema !== undefined) return schema; - // Defensive: every entry is either a cache hit or describe-needed (and - // every describe-needed entry yields exactly one schema above), so this - // should be unreachable. If the invariant ever breaks, warn loudly but - // still emit a permissive degraded schema — the metric path never - // crashes a build over a single entry. + // Honor the non-blocking preflight contract for metric DESCRIBEs: a + // `DESCRIBE TABLE EXTENDED ... AS JSON` waits up to 30s per key and auto-starts + // a stopped warehouse. So one status-only probe decides whether to DESCRIBE now or + // emit degraded artifacts for a later blocking run; + // it keeps the observed state so the skip can tell a transient not-running warehouse + // from a terminal DELETED/DELETING one. + let gateState: WarehouseState | undefined; + let describeNow = + metricFetcher !== undefined || + mode !== "non-blocking" || + describeNeeded.length === 0; + if (!describeNow) { + try { + gateState = await probeWarehouseState(getMvClient, warehouseId); + } catch (err) { + // probeWarehouseState only throws on a deterministic failure (auth, bad + // warehouse id) — a connectivity blip already returned undefined. Pin it + // fatal through the same path as a fatal blocking preflight. + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + } + describeNow = gateState === "RUNNING"; + } + + let described: MetricSchema[]; + let failures: MetricSyncFailure[] = []; + if (preflightFatalMessage !== undefined) { + // Fatal preflight (deleted/deleting warehouse): fail like the query path — + // skip DESCRIBE, emit degraded schemas so both artifacts are still written, + // and record one fatal error per describe-needed key (cache hits are + // unaffected). The caller surfaces them after the writes. The degraded + // schemas are not cached (see the write block), so a later pass re-probes. + described = describeNeeded.map(emptyMetricSchema); + for (const entry of describeNeeded) { + fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); + } + } else if (describeNeeded.length === 0) { + // Nothing left to describe — every configured key was a cache hit. + // syncMetrics would be a no-op (and building its fetcher would construct a + // client for nothing); artifacts regenerate from cache. + described = []; + } else if (describeNow) { + const fetcher = + metricFetcher ?? + createWorkspaceDescribeFetcher(getMvClient(), warehouseId); + ({ schemas: described, failures } = await syncMetrics( + { entries: describeNeeded }, + fetcher, + )); + + // Surface DESCRIBE failures loudly: a misconfigured metric-views.json would + // otherwise silently ship an empty entry that the runtime fail-closed gate + // 503s in production. syncMetrics is log-free; this caller is the single + // owner of failure logging. + if (failures.length > 0) { + for (const f of failures) { logger.warn( - "no schema resolved for metric key %s — emitting degraded types (should not happen)", - entry.key, + "metric sync failed for %s (%s): %s", + f.key, + f.source, + f.reason, ); - return emptyMetricSchema(entry); - }); + } + } - const mvFile = - mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); - const mvDeclarations = generateMetricTypeDeclarations(mvSchemas); - await fs.mkdir(path.dirname(mvFile), { recursive: true }); - await fs.writeFile(mvFile, mvDeclarations, "utf-8"); - - // Emit the semantic-metadata JSON bundle alongside the .d.ts. The hook - // imports this artifact (via a registration call from the consuming - // app) and exposes the per-metric subset on its return value. - const mvMetadataFile = - mvMetadataOutFile ?? - path.join(path.dirname(mvFile), METRIC_METADATA_FILE); - const metadataJson = generateMetricsMetadataJson(mvSchemas); - await fs.mkdir(path.dirname(mvMetadataFile), { recursive: true }); - await fs.writeFile(mvMetadataFile, metadataJson, "utf-8"); - - logger.debug( - "Wrote MetricRegistry augmentation + metadata bundle for %d metric(s)%s", - mvSchemas.length, - failures.length > 0 ? ` (${failures.length} failure(s))` : "", + // Degraded-but-not-failed keys: the warehouse answered with a non-terminal + // state (stopped / cold-starting), so their schemas are unknown. + const failedKeys = new Set(failures.map((f) => f.key)); + const degradedKeys = described + .filter((s) => s.degraded && !failedKeys.has(s.key)) + .map((s) => s.key); + if (degradedKeys.length > 0) { + logger.info( + "Warehouse %s did not return schemas for %d metric view(s) (%s) — wrote degraded metric types (permissive); they will refresh once the warehouse is available.", + warehouseId, + degradedKeys.length, + degradedKeys.join(", "), ); } + } 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. These degraded schemas are not cached + // (see the write block), so every skipped key — whether the warehouse is + // transiently not-running or terminally DELETED — is re-probed on the next + // pass rather than pinned. (A DELETED warehouse is only ever fatal via the + // blocking preflight above; non-blocking degrades and stays resilient, per + // the non-blocking typegen contract.) + 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.", + warehouseId, + describeNeeded.length, + describeNeeded.map((e) => e.key).join(", "), + ); } - // One-time migration: remove old generated file and patch project configs - await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); - await migrateProjectConfig(projectRoot); + // Persist outcomes for exactly the keys this pass owned (the describe-needed + // set); hits were partitioned out above and are never rewritten, so a + // warehouse-down pass keeps last-known-good entries. + // + // Only a SUCCESSFUL (non-degraded) describe is cached — mirroring the query + // path, which caches a describe result and "Never persists `result: unknown`" + // (query-registry.ts). A degraded outcome (skipped behind a not-running or + // deleted warehouse, unanswered, or a per-key DESCRIBE failure) is written + // into the in-memory `schemas` that render this pass's .d.ts, but is NEVER + // cached: the key stays uncached, so the next eligible pass simply + // re-describes it. Any stale entry for a now-degraded key is deleted, so the + // invariant "the cache holds only good schemas" holds after every pass — no + // sticky degraded entry can be served (the bug this replaces), and `--wait` + // re-describes a still-bad source every run instead of serving it green. + for (let i = 0; i < describeNeeded.length; i++) { + // syncMetrics return one schema per entry in entry order, so described[i] always belongs to describeNeeded[i]. + const entry = describeNeeded[i]; + if (described[i].degraded === true) { + delete mvCacheSection[entry.key]; + continue; + } + mvCacheSection[entry.key] = { + hash: metricCacheHash(entry.source, entry.lane), + schema: described[i], + // Vestigial, mirrors the query path's only cache write (always false): a + // persisted entry is by construction a good result, so it never needs a + // re-describe flag. Kept for on-disk shape compatibility with existing + // version-3 caches (isRevivableMetricCacheEntry gates on a boolean). + retry: false, + }; + } - // Types are always written above — including `result: unknown` for any query - // that could not be described. Connectivity failures pass silently so a - // transient warehouse outage never blocks a build; genuine SQL errors and - // non-connectivity fatal request failures surface after the file write. - if (syntaxErrors.length > 0) { - throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors); + // Prune entries whose key is no longer configured + const configuredKeys = new Set(resolution.entries.map((e) => e.key)); + let prunedCount = 0; + for (const key of Object.keys(mvCacheSection)) { + if (!configuredKeys.has(key)) { + delete mvCacheSection[key]; + prunedCount++; + } } - if (fatalErrors.length > 0) { - throw new TypegenFatalError(fatalErrors, warehouseId); + + // Save when this pass produced outcomes, bypassed the cache, or pruned. + if (describeNeeded.length > 0 || noCache || prunedCount > 0) { + cache.metrics = mvCacheSection; + await saveCache(cache); } - logger.debug("Type generation complete!"); + // Merge cached hits with fresh results back into config order. + const describedByKey = new Map(); + for (const schema of described) { + describedByKey.set(schema.key, schema); + } + const schemas = resolution.entries.map((entry) => { + const schema = hitSchemas.get(entry.key) ?? describedByKey.get(entry.key); + if (schema !== undefined) return schema; + logger.warn( + "no schema resolved for metric key %s — emitting degraded types (should not happen)", + entry.key, + ); + return emptyMetricSchema(entry); + }); + + await fs.mkdir(path.dirname(metricOutFile), { recursive: true }); + await fs.writeFile( + metricOutFile, + generateMetricTypeDeclarations(schemas), + "utf-8", + ); + + logger.debug( + "Wrote MetricRegistry augmentation for %d metric(s)%s", + schemas.length, + failures.length > 0 ? ` (${failures.length} failure(s))` : "", + ); + + return { + metricOutFile, + schemas, + failures, + fatalErrors, + noConfig: false, + }; } // Rolldown tree-shaking only preserves "own exports" (locally defined) — not re-exports. @@ -693,21 +779,7 @@ export type { MetricSyncResult, }; -/** Directory name for generated AppKit type declaration files. */ export const TYPES_DIR = "appkit-types"; -/** Default filename for analytics query type declarations. */ export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; -/** Default filename for serving endpoint type declarations. */ export const SERVING_TYPES_FILE = "serving.d.ts"; -/** Default filename for metric-view registry type declarations. */ -export const METRIC_TYPES_FILE = "metric.d.ts"; -/** - * Default filename for the build-time semantic-metadata JSON bundle, sibling of - * {@link METRIC_TYPES_FILE}. Shape is `Record` (UC FQN and execution lane are server-side concerns, kept out - * of this client-shipped artifact). The consuming app imports it at build time - * and registers it via `@databricks/appkit-ui/format`'s - * `registerMetricsMetadata()`, so the React hook returns per-metric `metadata` - * without a second network round-trip. - */ -export const METRIC_METADATA_FILE = "metrics.metadata.json"; +export const METRIC_TYPES_FILE = "metric-views.d.ts"; diff --git a/packages/appkit/src/type-generator/mv-registry/config.ts b/packages/appkit/src/type-generator/mv-registry/config.ts index ceea27dad..1995f0723 100644 --- a/packages/appkit/src/type-generator/mv-registry/config.ts +++ b/packages/appkit/src/type-generator/mv-registry/config.ts @@ -34,12 +34,12 @@ const FQN_SEGMENT_NAMES = ["catalog", "schema", "metric_view"] as const; const FQN_SEGMENT_COUNT = FQN_SEGMENT_NAMES.length; /** - * Locale-independent comparator (UTF-16 code-unit order) shared by BOTH artifact - * key orderings. Plain `sort()` is locale-sensitive, so keys could order - * differently across environments and invalidate the cache hash — this keeps the - * ordering stable everywhere. + * Locale-independent comparator (UTF-16 code-unit order) for metric-view key + * ordering. Plain `sort()` is locale-sensitive, so keys could order differently + * across environments and invalidate the cache hash — this keeps the ordering + * stable everywhere. */ -export function compareKeys(a: string, b: string): number { +function compareKeys(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } diff --git a/packages/appkit/src/type-generator/mv-registry/metadata.ts b/packages/appkit/src/type-generator/mv-registry/metadata.ts deleted file mode 100644 index 9f0df19a2..000000000 --- a/packages/appkit/src/type-generator/mv-registry/metadata.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { compareKeys } from "./config"; -import type { MetricColumnMetadata, MetricSchema } from "./types"; - -interface MetricColumnSemanticMetadata { - type: string; - display_name?: string; - format?: string; - description?: string; - /** Only emitted on dimension entries that resolved to a TIMESTAMP* or DATE SQL type. */ - time_grain?: readonly string[]; -} - -interface MetricSemanticMetadataEntry { - measures: Record; - dimensions: Record; -} - -type MetricsMetadataBundle = Record; - -/** - * Converts a list of metric schemas into a JSON metadata bundle. - */ -export function buildMetricsMetadataBundle( - schemas: MetricSchema[], -): MetricsMetadataBundle { - // Null-prototype maps: metric keys and column names are controlled outside - // this package, and "__proto__" is legal input. - const bundle: MetricsMetadataBundle = Object.create(null); - const sortedSchemas = [...schemas].sort((a, b) => compareKeys(a.key, b.key)); - - for (const schema of sortedSchemas) { - const measures: Record = - Object.create(null); - for (const m of schema.measures) { - measures[m.name] = buildColumnMetadata(m); - } - - const dimensions: Record = - Object.create(null); - for (const d of schema.dimensions) { - dimensions[d.name] = buildColumnMetadata(d); - } - - bundle[schema.key] = { - measures, - dimensions, - }; - } - - return bundle; -} - -function buildColumnMetadata( - col: MetricColumnMetadata, -): MetricColumnSemanticMetadata { - const entry: MetricColumnSemanticMetadata = { type: col.type }; - if (col.displayName) entry.display_name = col.displayName; - if (col.format) entry.format = col.format; - if (col.description) entry.description = col.description; - if (!col.isMeasure && col.timeGrains && col.timeGrains.length > 0) { - entry.time_grain = [...col.timeGrains]; - } - return entry; -} - -/** - * Serialize the metadata bundle to a stable, human-readable JSON string. - */ -export function generateMetricsMetadataJson(schemas: MetricSchema[]): string { - const bundle = buildMetricsMetadataBundle(schemas); - return `${JSON.stringify(bundle, null, 2)}\n`; -} 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 09a23d6c7..f70e584c8 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -172,7 +172,7 @@ ${entries}; `; } -// Build the full metric.d.ts file from a list of metric schemas. +// Build the full metric-views.d.ts file from a list of metric schemas. export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { 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 5f10f29aa..6970320c2 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 @@ -197,58 +197,3 @@ declare module "@databricks/appkit-ui/react" { } " `; - -exports[`generateMetricsMetadataJson — snapshot > serializes a representative metric view with display_name + format + time_grain 1`] = ` -"{ - "customer_metrics": { - "measures": { - "churn_rate": { - "type": "DOUBLE", - "display_name": "Churn Rate", - "format": "0.0%" - } - }, - "dimensions": { - "csm_email": { - "type": "STRING", - "display_name": "CSM Email" - } - } - }, - "revenue": { - "measures": { - "arr": { - "type": "DECIMAL(38,2)", - "display_name": "Annual Recurring Revenue", - "format": "$#,##0.00", - "description": "ARR per quarter" - }, - "growth_rate": { - "type": "DOUBLE", - "display_name": "Growth Rate", - "format": "0.0%" - } - }, - "dimensions": { - "region": { - "type": "STRING", - "display_name": "Region" - }, - "created_at": { - "type": "TIMESTAMP", - "display_name": "Period", - "time_grain": [ - "day", - "hour", - "minute", - "month", - "quarter", - "week", - "year" - ] - } - } - } -} -" -`; diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index e85ba4309..c469bb115 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -276,13 +276,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { const metricsDir = path.join(__dirname, "__output_metrics__"); const queryFolder = path.join(metricsDir, "queries"); const outFile = path.join(metricsDir, "generated", "analytics.d.ts"); - // Defaults: metric artifacts are siblings of `outFile`. - const metricFile = path.join(metricsDir, "generated", "metric.d.ts"); - const metadataFile = path.join( - metricsDir, - "generated", - "metrics.metadata.json", - ); + // Default: the metric .d.ts is a sibling of `outFile`. + const metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); const describeResponse: DatabricksStatementExecutionResponse = { statement_id: "stmt-mock", @@ -330,7 +325,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { fs.rmSync(metricsDir, { recursive: true, force: true }); }); - test("writes metric.d.ts and metrics.metadata.json when metric-views.json exists", async () => { + test("writes metric-views.d.ts when metric-views.json exists", async () => { writeMetricConfig(); await expect( @@ -347,10 +342,9 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('"revenue"'); expect(declarations).toContain('"total_revenue": number'); expect(declarations).toContain('"region": string'); - - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); - expect(bundle.revenue.dimensions.region.type).toBe("STRING"); + // Semantic metadata (SQL type) rides in the .d.ts type-level `metadata` + // block — the sole carrier now that the JSON bundle is gone. + expect(declarations).toContain('"DECIMAL(38,2)"'); }); test("emits no metric artifacts and no errors when metric-views.json is absent", async () => { @@ -365,7 +359,6 @@ describe("generateFromEntryPoint — metric-view emission", () => { // Query types are still written; the metric path stays fully dormant. expect(fs.existsSync(outFile)).toBe(true); expect(fs.existsSync(metricFile)).toBe(false); - expect(fs.existsSync(metadataFile)).toBe(false); }); test("a failing metric fetcher warns but query type generation still succeeds", async () => { @@ -406,8 +399,6 @@ describe("generateFromEntryPoint — metric-view emission", () => { const declarations = fs.readFileSync(metricFile, "utf-8"); expect(declarations).toContain('"revenue"'); expect(declarations).toContain("measureKeys: string"); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); }); test("a non-terminal DESCRIBE response degrades without failing: no warn, one info line, permissive types", async () => { @@ -454,9 +445,6 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain("measureKeys: string"); expect(declarations).toContain("dimensionKeys: string"); expect(declarations).toContain("timeGrains: string"); - // The metadata bundle keeps its locked frontend-safe shape. - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); }); // ── Non-blocking warehouse gate: metric DESCRIBEs honor the #406 contract ── @@ -499,9 +487,6 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('lane: "obo"'); expect(declarations).toContain("measureKeys: string"); expect(declarations).toContain("timeGrains: string"); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); - expect(bundle.churn).toEqual({ measures: {}, dimensions: {} }); // Nothing failed (we deliberately didn't probe each key), so no // per-key "metric sync failed" warnings — just the single @@ -541,11 +526,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { warehouse_id: "wh-1", }), ); - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - '"total_revenue": number', - ); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); + const declarations = fs.readFileSync(metricFile, "utf-8"); + expect(declarations).toContain('"total_revenue": number'); + // The SQL type rides in the .d.ts type-level `metadata` block. + expect(declarations).toContain('"DECIMAL(38,2)"'); }); // ── Blocking-mode preflight: mirrors the query path's ensure-running flow ── @@ -573,6 +557,96 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }); + test("blocking + a per-key DESCRIBE failure: escalates to a build failure (TypegenFatalError)", async () => { + writeMetricConfig(); + + // An injected fetcher always runs and bypasses preflight; throwing makes the + // key a deterministic DESCRIBE failure. Non-blocking only warns (covered + // above) — but `--wait` promised correct types, so it must fail the build. + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + metricFetcher: async () => { + throw new Error("DESCRIBE exploded"); + }, + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + expect((error as Error).message).toContain("revenue"); + expect((error as Error).message).toContain("DESCRIBE exploded"); + + // Write-first semantics: the degraded artifacts still ship before the throw. + expect(fs.existsSync(metricFile)).toBe(true); + }); + + test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { + writeMetricConfig(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + // PENDING = the warehouse answered but produced no rows yet → degraded, not + // a per-key failure. Unlike a bad source (which `--wait` fails), a not-ready + // warehouse stays a soft degrade even under `--wait`, so infra flakiness + // can't break the build (mirrors the STOPPED-resolve preflight case). + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + metricFetcher: async () => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }), + }), + ).resolves.toBeUndefined(); + + const warned = warnSpy.mock.calls.flat().map(String).join("\n"); + expect(warned).not.toContain("metric sync failed"); + // Permissive artifacts still ship. + const declarations = fs.readFileSync(metricFile, "utf-8"); + expect(declarations).toContain('"revenue"'); + expect(declarations).toContain("measureKeys: string"); + } finally { + warnSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + test("malformed metric-views.json: a clean TypegenFatalError, not a raw parse error (any mode)", async () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + "{ not valid", + ); + + // Default (non-blocking) mode: a malformed config is a deterministic + // developer error and must fail loudly in every mode, surfaced as a + // message-only TypegenFatalError rather than a bubbled SyntaxError stack. + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + expect((error as Error).message).toContain("metric-views.json"); + // Query types were written before the metric config was read. + expect(fs.existsSync(outFile)).toBe(true); + }); + test("blocking + STOPPED: preflight starts the warehouse and waits for RUNNING before DESCRIBEs", async () => { writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STOPPED"); @@ -651,16 +725,12 @@ describe("generateFromEntryPoint — metric-view emission", () => { const declarations = fs.readFileSync(metricFile, "utf-8"); expect(declarations).toContain('"revenue"'); expect(declarations).toContain("measureKeys: string"); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); - - // D′: the fatal skip is terminal — a deleted warehouse can never serve - // these keys, so the degraded entries are pinned sticky (retry: false) - // and later passes surface them via the sticky-hit notice instead of - // re-describing forever. - const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics; - expect(metrics.revenue.retry).toBe(false); - expect(metrics.revenue.schema.degraded).toBe(true); + + // 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 + // served on a subsequent --wait run. + const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics ?? {}; + expect(metrics.revenue).toBeUndefined(); }); test("blocking + preflight wait rejects with a timeout: fatal after artifacts (no silent stall)", async () => { @@ -708,16 +778,14 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); expect(mocks.executeStatement).not.toHaveBeenCalled(); // ... but degraded artifacts are still written before the throw. - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); expect(fs.readFileSync(metricFile, "utf-8")).toContain( "measureKeys: string", ); - // Terminal skip → sticky, like the decision-time fatal. - const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics; - expect(metrics.revenue.retry).toBe(false); - expect(metrics.revenue.schema.degraded).toBe(true); + // The degraded outcome is not cached — the key stays uncached for the next + // pass to re-probe. + const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics ?? {}; + expect(metrics.revenue).toBeUndefined(); }); test("blocking + preflight wait resolves non-RUNNING (STOPPED): degrades, does not throw", async () => { @@ -766,17 +834,15 @@ describe("generateFromEntryPoint — metric-view emission", () => { // The DESCRIBE batch still ran (fall-through), and its non-terminal answer // degraded the key per Phase 1 semantics. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); expect(fs.readFileSync(metricFile, "utf-8")).toContain( "measureKeys: string", ); - // D′: a still-startable warehouse is transient degradation — cached with - // retry: true so the next describe-capable pass converges it. - const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics; - expect(metrics.revenue.retry).toBe(true); - expect(metrics.revenue.schema.degraded).toBe(true); + // 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 + // cached retry flag). + const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics ?? {}; + expect(metrics.revenue).toBeUndefined(); }); test.each<[string, boolean]>([ @@ -786,7 +852,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // STARTING probe → wait-only; a DELETED resolve is fatal there too. ["STARTING", false], ])( - "blocking + warehouse deleted mid-wait (probe read %s): fatal after artifacts, sticky cache entry", + "blocking + warehouse deleted mid-wait (probe read %s): fatal after artifacts, degraded outcome not cached", async (probedState, startsWarehouse) => { writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue(probedState); @@ -824,13 +890,11 @@ describe("generateFromEntryPoint — metric-view emission", () => { const declarations = fs.readFileSync(metricFile, "utf-8"); expect(declarations).toContain('"revenue"'); expect(declarations).toContain("measureKeys: string"); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); - // D′: terminal skip — sticky, like the decision-time fatal. - const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics; - expect(metrics.revenue.retry).toBe(false); - expect(metrics.revenue.schema.degraded).toBe(true); + // The degraded outcome is not cached — no sticky entry to serve later. + const metrics = + JSON.parse(mocks.cacheFile.contents ?? "{}").metrics ?? {}; + expect(metrics.revenue).toBeUndefined(); }, ); @@ -902,7 +966,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(vi.mocked(WorkspaceClient)).not.toHaveBeenCalled(); expect(fs.existsSync(metricFile)).toBe(true); - expect(JSON.parse(fs.readFileSync(metadataFile, "utf-8"))).toEqual({}); + // An empty metricViews map emits an empty MetricRegistry augmentation. + expect(fs.readFileSync(metricFile, "utf-8")).toContain( + "interface MetricRegistry {}", + ); }); test("an injected metricFetcher bypasses the gate even when non-blocking + stopped", async () => { @@ -950,8 +1017,9 @@ describe("generateFromEntryPoint — metric-view emission", () => { ).resolves.toBeUndefined(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); + expect(fs.readFileSync(metricFile, "utf-8")).toContain( + "measureKeys: string", + ); }); test("non-blocking: a deterministic status-probe failure (auth) is fatal after artifacts", async () => { @@ -986,8 +1054,6 @@ describe("generateFromEntryPoint — metric-view emission", () => { // No DESCRIBE ran; degraded artifacts still written before the throw. expect(mocks.executeStatement).not.toHaveBeenCalled(); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue).toEqual({ measures: {}, dimensions: {} }); expect(fs.readFileSync(metricFile, "utf-8")).toContain( "measureKeys: string", ); @@ -998,12 +1064,7 @@ describe("generateFromEntryPoint — metric cache section", () => { const cacheTestDir = path.join(__dirname, "__output_metric_cache__"); const queryFolder = path.join(cacheTestDir, "queries"); const outFile = path.join(cacheTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join(cacheTestDir, "generated", "metric.d.ts"); - const metadataFile = path.join( - cacheTestDir, - "generated", - "metrics.metadata.json", - ); + const metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); const describeResponseFor = ( measure: string, @@ -1066,7 +1127,7 @@ describe("generateFromEntryPoint — metric cache section", () => { fs.rmSync(cacheTestDir, { recursive: true, force: true }); }); - test("warm pass: unchanged config makes zero DESCRIBEs, zero probes, zero clients — artifacts rewritten byte-identical from cache", async () => { + test("warm pass: unchanged config makes zero DESCRIBEs, zero probes, zero clients — the .d.ts is rewritten byte-identical from cache", async () => { writeConfig({ revenue: { source: "demo.sales.revenue" } }); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue( @@ -1076,11 +1137,9 @@ describe("generateFromEntryPoint — metric cache section", () => { await expect(run()).resolves.toBeUndefined(); expect(mocks.executeStatement).toHaveBeenCalledTimes(1); const firstDeclarations = fs.readFileSync(metricFile, "utf-8"); - const firstBundle = fs.readFileSync(metadataFile, "utf-8"); - // Wipe the artifacts so pass 2 provably rewrites them from cache alone. + // Wipe the artifact so pass 2 provably rewrites it from cache alone. fs.rmSync(metricFile); - fs.rmSync(metadataFile); vi.clearAllMocks(); await expect(run()).resolves.toBeUndefined(); @@ -1090,7 +1149,6 @@ describe("generateFromEntryPoint — metric cache section", () => { // ... and the whole pass constructed zero SDK clients. expect(vi.mocked(WorkspaceClient)).not.toHaveBeenCalled(); expect(fs.readFileSync(metricFile, "utf-8")).toBe(firstDeclarations); - expect(fs.readFileSync(metadataFile, "utf-8")).toBe(firstBundle); }); test("single-entry edit: only the edited key is re-described", async () => { @@ -1136,8 +1194,8 @@ describe("generateFromEntryPoint — metric cache section", () => { await expect(run()).resolves.toBeUndefined(); // Pass 1: churn added while the warehouse is down. The gate skips its - // DESCRIBE; churn is cached degraded with retry: true. revenue stays a - // hit and its good entry is NOT overwritten. + // DESCRIBE; churn degrades and is NOT cached (only good describes are). The + // revenue hit is untouched and keeps its cached good entry. vi.clearAllMocks(); mocks.getWarehouseState.mockResolvedValue("STOPPED"); writeConfig({ @@ -1146,16 +1204,17 @@ describe("generateFromEntryPoint — metric cache section", () => { }); await expect(run()).resolves.toBeUndefined(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(savedCache().metrics.churn.retry).toBe(true); - expect(savedCache().metrics.churn.schema.degraded).toBe(true); + // churn degraded → left uncached; revenue's good entry survived. + expect(savedCache().metrics.churn).toBeUndefined(); expect(savedCache().metrics.revenue.retry).toBe(false); + expect(savedCache().metrics.revenue.schema.degraded).not.toBe(true); // Artifacts mix the cached real schema with the degraded newcomer. expect(fs.readFileSync(metricFile, "utf-8")).toContain( '"total_revenue": number', ); - // Pass 2: blocking with the warehouse RUNNING. Only the retry-flagged - // key is described; the hit is untouched. + // Pass 2: blocking with the warehouse RUNNING. churn is uncached, so it is + // the only key re-described; the revenue hit is untouched. vi.clearAllMocks(); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue( @@ -1168,7 +1227,9 @@ describe("generateFromEntryPoint — metric cache section", () => { statement: "DESCRIBE TABLE EXTENDED `demo`.`sales`.`churn` AS JSON", }), ); + // churn now has a good cached entry. expect(savedCache().metrics.churn.retry).toBe(false); + expect(savedCache().metrics.churn.schema.degraded).not.toBe(true); const refreshed = fs.readFileSync(metricFile, "utf-8"); expect(refreshed).toContain('"monthly_churn": number'); @@ -1187,18 +1248,17 @@ describe("generateFromEntryPoint — metric cache section", () => { vi.clearAllMocks(); mocks.getWarehouseState.mockResolvedValue("STOPPED"); fs.rmSync(metricFile); - fs.rmSync(metadataFile); await expect(run()).resolves.toBeUndefined(); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(mocks.getWarehouseState).not.toHaveBeenCalled(); - // The artifacts carry the cached REAL unions — not degraded-open types. + // The .d.ts carries the cached REAL unions — not degraded-open types — + // and its type-level `metadata` block still carries the SQL type. const declarations = fs.readFileSync(metricFile, "utf-8"); expect(declarations).toContain('"total_revenue": number'); expect(declarations).not.toContain("measureKeys: string"); - const bundle = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); + expect(declarations).toContain('"DECIMAL(38,2)"'); // The good entry survived the warehouse-down pass un-overwritten. expect(savedCache().metrics.revenue.retry).toBe(false); }); @@ -1326,9 +1386,9 @@ describe("generateFromEntryPoint — metric cache section", () => { expect(metrics.revenue.retry).toBe(false); }); - // ── D′ sticky/transient retry semantics ─────────────────────────────── + // ── Degraded outcomes are never cached (mirrors the query path) ──────── - test("D′ write matrix: transient failures and non-terminal states retry, deterministic failures stick", async () => { + test("write matrix: every degraded outcome is left uncached; only a successful describe is cached", async () => { writeConfig({ failed_stmt: { source: "demo.sales.failed_stmt" }, fetch_reject: { source: "demo.sales.fetch_reject" }, @@ -1380,72 +1440,47 @@ describe("generateFromEntryPoint — metric cache section", () => { } const metrics = savedCache().metrics; - // Transient fetch rejection → re-describe next eligible pass. - expect(metrics.fetch_reject.retry).toBe(true); - expect(metrics.fetch_reject.schema.degraded).toBe(true); - // Non-terminal statement state (not a failure at all) → retry. - expect(metrics.pending.retry).toBe(true); - expect(metrics.pending.schema.degraded).toBe(true); - // Deterministic failures → STICKY: degraded schema cached, no retry. - for (const key of ["failed_stmt", "no_rows", "no_columns"]) { - expect(metrics[key].retry).toBe(false); - expect(metrics[key].schema.degraded).toBe(true); + // Every degraded outcome — transient (fetch reject, PENDING) OR + // deterministic (FAILED statement, zero rows, zero columns) — is left + // uncached, so the next eligible pass simply re-describes it. No sticky + // entry, no cached degrade to serve. + for (const key of [ + "fetch_reject", + "pending", + "failed_stmt", + "no_rows", + "no_columns", + ]) { + expect(metrics[key]).toBeUndefined(); } - // Success → real schema, no retry. + // Only the successful describe is cached — a real schema, retry: false. expect(metrics.good.retry).toBe(false); expect(metrics.good.schema.degraded).toBeUndefined(); }); - test.each<[string, boolean]>([ - // Startable / transient states converge on a later pass → retry. - ["STOPPED", true], - ["STARTING", true], - // A deleted warehouse can never converge → sticky. - ["DELETED", false], - ["DELETING", false], - ])( - "D′ gate skip: a %s probe caches the skipped keys with retry: %s", - async (state, retry) => { + test.each(["STOPPED", "STARTING", "DELETED", "DELETING"])( + "gate skip: a %s probe leaves the skipped keys uncached (never sticky)", + async (state) => { writeConfig({ revenue: { source: "demo.sales.revenue" } }); mocks.getWarehouseState.mockResolvedValue(state); // Non-blocking never throws — even for a deleted warehouse the pass - // degrades; only the cache disposition differs. + // degrades. The degraded outcome is not cached regardless of state, so + // the key is re-probed next pass rather than pinned. await expect(run()).resolves.toBeUndefined(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - const metrics = savedCache().metrics; - expect(metrics.revenue.retry).toBe(retry); - expect(metrics.revenue.schema.degraded).toBe(true); + expect(savedCache().metrics?.revenue).toBeUndefined(); + // The permissive artifact is still written this pass. + expect(fs.readFileSync(metricFile, "utf-8")).toContain( + "measureKeys: string", + ); }, ); - test("D′ gate skip on DELETED: the sticky entry hits on the next pass and surfaces via the notice", async () => { - writeConfig({ revenue: { source: "demo.sales.revenue" } }); - mocks.getWarehouseState.mockResolvedValue("DELETED"); - await expect(run()).resolves.toBeUndefined(); - expect(savedCache().metrics.revenue.retry).toBe(false); - - // Warm pass: the sticky entry is a HIT — zero describes, zero probes — - // and the notice names it. - vi.clearAllMocks(); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(run()).resolves.toBeUndefined(); - expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(mocks.getWarehouseState).not.toHaveBeenCalled(); - const stickyLines = warnSpy.mock.calls - .map((call) => call.map(String).join(" ")) - .filter((line) => line.includes("cached failure")); - expect(stickyLines).toHaveLength(1); - expect(stickyLines[0]).toContain("revenue"); - } finally { - warnSpy.mockRestore(); - } - }); - - test("sticky-hit notice: a warm pass over a sticky entry describes nothing and warns once naming the key", async () => { - // Pass 1: a deterministic DESCRIBE failure pins the key sticky. + test("a re-run after a degraded pass re-describes the key (no sticky serve)", async () => { + // Pass 1: a deterministic DESCRIBE failure degrades the key. It is NOT + // cached, and the describing pass reports the failure itself. writeConfig({ revenue: { source: "demo.sales.revenue" } }); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue({ @@ -1455,45 +1490,65 @@ describe("generateFromEntryPoint — metric cache section", () => { const firstWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect(run()).resolves.toBeUndefined(); - // The describing pass reports the failure itself — the cached-failure - // notice is reserved for passes that merely SERVE the sticky entry. const warned = firstWarnSpy.mock.calls.flat().map(String).join("\n"); expect(warned).toContain("metric sync failed for revenue"); + // No sticky-cache "cached failure" notice exists anymore. expect(warned).not.toContain("cached failure"); } finally { firstWarnSpy.mockRestore(); } - expect(savedCache().metrics.revenue.retry).toBe(false); + expect(savedCache().metrics?.revenue).toBeUndefined(); - // Pass 2 (warm): hash match + retry: false ⇒ HIT. No describes, no - // probes, exactly one warn naming the key and the escape hatches. + // Pass 2: the key is uncached, so it is RE-DESCRIBED (not served from a + // sticky entry). This time the source resolves — it converges to a real + // schema with no --no-cache needed. vi.clearAllMocks(); + mocks.getWarehouseState.mockResolvedValue("RUNNING"); + mocks.executeStatement.mockResolvedValue( + describeResponseFor("total_revenue"), + ); + await expect(run()).resolves.toBeUndefined(); + expect(mocks.executeStatement).toHaveBeenCalledTimes(1); + expect(savedCache().metrics.revenue.retry).toBe(false); + expect(savedCache().metrics.revenue.schema.degraded).not.toBe(true); + expect(fs.readFileSync(metricFile, "utf-8")).toContain( + '"total_revenue": number', + ); + }); + + test("--wait over a still-bad source re-describes and fails the build (never green from a cached degrade)", async () => { + // Pass 1 (non-blocking): bad source degrades, uncached. + writeConfig({ revenue: { source: "demo.sales.revenue" } }); + mocks.getWarehouseState.mockResolvedValue("RUNNING"); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-mock", + status: { state: "FAILED", error: { message: "no such table" } }, + }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect(run()).resolves.toBeUndefined(); - expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(mocks.getWarehouseState).not.toHaveBeenCalled(); - - const warnedLines = warnSpy.mock.calls.map((call) => - call.map(String).join(" "), - ); - const stickyLines = warnedLines.filter((line) => - line.includes("cached failure"), - ); - expect(stickyLines).toHaveLength(1); - expect(stickyLines[0]).toContain("revenue"); - expect(stickyLines[0]).toContain("metric-views.json"); - expect(stickyLines[0]).toContain("--no-cache"); - // Nothing was described, so no fresh per-key failure warns. - expect(warnedLines.join("\n")).not.toContain("metric sync failed"); } finally { warnSpy.mockRestore(); } + expect(savedCache().metrics?.revenue).toBeUndefined(); - // The sticky degraded schema still renders permissive artifacts. - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - "measureKeys: string", + // Pass 2 (--wait / blocking): the key is uncached, so --wait re-describes + // it against the still-bad source and escalates to a build failure — it + // can NOT green-build by serving a cached degrade (the bug this replaces). + vi.clearAllMocks(); + mocks.getWarehouseState.mockResolvedValue("RUNNING"); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-mock", + status: { state: "FAILED", error: { message: "no such table" } }, + }); + const error = await run({ mode: "blocking" }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, ); + expect(error).toBeInstanceOf(TypegenFatalError); + expect(mocks.executeStatement).toHaveBeenCalledTimes(1); }); test("no sticky-hit notice when the warm pass serves only good entries", async () => { @@ -1517,7 +1572,7 @@ describe("generateFromEntryPoint — metric cache section", () => { } }); - test("sticky convergence: editing the source (hash change) re-describes a sticky key", async () => { + test("convergence: editing the source (hash change) re-describes a previously degraded key", async () => { writeConfig({ revenue: { source: "demo.sales.revenue" } }); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue({ @@ -1527,10 +1582,11 @@ describe("generateFromEntryPoint — metric cache section", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect(run()).resolves.toBeUndefined(); - expect(savedCache().metrics.revenue.retry).toBe(false); + // Degraded → not cached. + expect(savedCache().metrics?.revenue).toBeUndefined(); - // The user fixes the FQN: hash changes, the sticky entry is - // invalidated, and the key converges to a real schema. + // The user fixes the FQN: the uncached key is re-described (a new hash + // would force it too) and converges to a real schema. vi.clearAllMocks(); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue( @@ -1558,7 +1614,7 @@ describe("generateFromEntryPoint — metric cache section", () => { ); }); - test("sticky convergence: noCache re-describes a sticky key despite the matching hash", async () => { + test("convergence: noCache re-describes a previously degraded key", async () => { writeConfig({ revenue: { source: "demo.sales.revenue" } }); mocks.getWarehouseState.mockResolvedValue("RUNNING"); mocks.executeStatement.mockResolvedValue({ @@ -1568,7 +1624,8 @@ describe("generateFromEntryPoint — metric cache section", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect(run()).resolves.toBeUndefined(); - expect(savedCache().metrics.revenue.retry).toBe(false); + // Degraded → not cached. + expect(savedCache().metrics?.revenue).toBeUndefined(); vi.clearAllMocks(); mocks.getWarehouseState.mockResolvedValue("RUNNING"); 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 d1a5cd864..068861405 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -11,10 +11,6 @@ import { parseDescribeTableExtendedJson, quoteFqnForSql, } from "../mv-registry/describe"; -import { - buildMetricsMetadataBundle, - generateMetricsMetadataJson, -} from "../mv-registry/metadata"; import { generateMetricTypeDeclarations } from "../mv-registry/render-types"; import { syncMetrics } from "../mv-registry/sync"; import type { DatabricksStatementExecutionResponse } from "../types"; @@ -1947,324 +1943,12 @@ describe("extractMetricColumns — Phase 5 semantic metadata", () => { }); }); -// ── Phase 5: metadata bundle generation ─────────────────────────────────── -describe("buildMetricsMetadataBundle", () => { - test("emits per-metric measures + dimensions records keyed by name", 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", - comment: "ARR for the period", - }, - { name: "region", type: "STRING", is_measure: false }, - { name: "created_at", type: "TIMESTAMP", is_measure: false }, - ], - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - const bundle = buildMetricsMetadataBundle(schemas); - - expect(bundle.revenue).toMatchObject({ - measures: { - arr: { - type: "DECIMAL(38,2)", - display_name: "Annual Recurring Revenue", - format: "$#,##0.00", - description: "ARR for the period", - }, - }, - dimensions: { - region: { - type: "STRING", - }, - created_at: { - type: "TIMESTAMP", - time_grain: [ - "day", - "hour", - "minute", - "month", - "quarter", - "week", - "year", - ], - }, - }, - }); - // Defense-in-depth: the client-shipped bundle must not carry server-side - // concerns (UC FQN, execution lane). They live in metric-views.json - // server-side. - expect(bundle.revenue).not.toHaveProperty("source"); - expect(bundle.revenue).not.toHaveProperty("lane"); - }); - - test("preserves stable alphabetical key order across metrics", async () => { - const resolution = resolveMetricConfig({ - metricViews: { - z_metric: { source: "demo.public.z_metric" }, - a_metric: { source: "demo.public.a_metric" }, - }, - }); - - const fetcher = async () => - mockDescribeResponse({ - columns: [{ name: "v", type: "DECIMAL", is_measure: true }], - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - const bundle = buildMetricsMetadataBundle(schemas); - expect(Object.keys(bundle)).toEqual(["a_metric", "z_metric"]); - }); - - test("omits absent fields rather than emitting null/empty placeholders", async () => { - const resolution = resolveMetricConfig({ - metricViews: { revenue: { source: "demo.public.revenue" } }, - }); - - const fetcher = async () => - mockDescribeResponse({ - columns: [{ name: "arr", type: "DECIMAL", is_measure: true }], - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - const bundle = buildMetricsMetadataBundle(schemas); - const arr = bundle.revenue.measures.arr; - expect(arr.type).toBe("DECIMAL"); - expect(arr.display_name).toBeUndefined(); - expect(arr.format).toBeUndefined(); - expect(arr.description).toBeUndefined(); - expect(arr.time_grain).toBeUndefined(); - }); - - test("degraded schemas emit empty maps and never leak the degraded flag", async () => { - const resolution = resolveMetricConfig({ - metricViews: { cold_metric: { source: "demo.public.cold_metric" } }, - }); - - // Non-terminal DESCRIBE (cold warehouse) → degraded schema. - const fetcher = - async (): Promise => ({ - statement_id: "stmt-mock", - status: { state: "PENDING" }, - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - expect(schemas[0].degraded).toBe(true); - - const bundle = buildMetricsMetadataBundle(schemas); - // The frontend-safe artifact has a locked shape: degraded keys emit - // empty maps... - expect(bundle.cold_metric).toEqual({ measures: {}, dimensions: {} }); - // ...and the degraded marker is a build-time concern that must NOT leak. - expect(bundle.cold_metric).not.toHaveProperty("degraded"); - }); - - test("only emits time_grain on time-typed dimensions, never on measures", async () => { - const resolution = resolveMetricConfig({ - metricViews: { revenue: { source: "demo.public.revenue" } }, - }); - - const fetcher = async () => - mockDescribeResponse({ - columns: [ - // Even when a measure resolves to a temporal type (rare but possible - // for MEASURE() expressions like MAX(event_at)), no grains should be - // emitted — measures aren't grouped on. Grain inference is gated on - // is_measure: false in extractMetricColumns. - { name: "last_event_at", type: "TIMESTAMP", is_measure: true }, - { name: "ts", type: "TIMESTAMP", is_measure: false }, - ], - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - const bundle = buildMetricsMetadataBundle(schemas); - expect(bundle.revenue.measures.last_event_at.time_grain).toBeUndefined(); - expect(bundle.revenue.dimensions.ts.time_grain).toEqual([ - "day", - "hour", - "minute", - "month", - "quarter", - "week", - "year", - ]); - }); - - test("a __proto__ metric key and a __proto__ column name are emitted as own enumerable properties (no prototype pollution)", async () => { - // "__proto__" passes the metric key regex, so a config can genuinely - // declare it — and a workspace column can genuinely be named it. The - // bundle (and its per-entry maps) are null-prototype, so the write - // stores data instead of hitting the Object.prototype setter (which - // would swap the map's prototype and silently drop the key from the - // emitted JSON). Object-literal syntax would set the prototype at - // construction, so the config arrives via JSON.parse — exactly like the - // real metric-views.json read. - const config = JSON.parse( - '{"metricViews":{"__proto__":{"source":"demo.evil.proto"},"revenue":{"source":"demo.sales.revenue"}}}', - ); - const resolution = resolveMetricConfig(config); - expect(resolution.entries.map((e) => e.key)).toEqual([ - "__proto__", - "revenue", - ]); - - const fetcher = async () => - mockDescribeResponse({ - columns: [ - { name: "__proto__", type: "DOUBLE", is_measure: true }, - { name: "region", type: "STRING", is_measure: false }, - ], - }); - - const { schemas, failures } = await syncMetrics(resolution, fetcher); - expect(failures).toEqual([]); - - // Pre-serialization: own keys on the null-prototype maps. - const bundle = buildMetricsMetadataBundle(schemas); - expect(Object.hasOwn(bundle, "__proto__")).toBe(true); - expect(Object.hasOwn(bundle, "revenue")).toBe(true); - - // The emitted metrics.metadata.json carries both as own enumerable - // properties (JSON.parse creates own data properties for __proto__). - const parsed = JSON.parse(generateMetricsMetadataJson(schemas)); - expect(Object.keys(parsed)).toEqual(["__proto__", "revenue"]); - expect(Object.hasOwn(parsed, "__proto__")).toBe(true); - const protoEntry = Object.getOwnPropertyDescriptor( - parsed, - "__proto__", - )?.value; - expect(Object.hasOwn(protoEntry.measures, "__proto__")).toBe(true); - expect(Object.hasOwn(parsed.revenue.measures, "__proto__")).toBe(true); - expect( - Object.getOwnPropertyDescriptor(parsed.revenue.measures, "__proto__") - ?.value, - ).toEqual({ type: "DOUBLE" }); - - // And no global prototype pollution leaked out of the build. - expect(({} as Record).polluted).toBeUndefined(); - expect(({} as Record).measures).toBeUndefined(); - expect(Object.prototype).not.toHaveProperty("measures"); - }); -}); - -// ── Phase 5: metadata JSON serialization ────────────────────────────────── -describe("generateMetricsMetadataJson — snapshot", () => { - test("serializes a representative metric view with display_name + format + time_grain", async () => { - const resolution = resolveMetricConfig({ - metricViews: { - revenue: { source: "appkit_demo.public.revenue_metrics" }, - customer_metrics: { - source: "appkit_demo.public.customer_metrics", - executor: "user", - }, - }, - }); - - const fetcher = async (fqn: string) => - fqn.endsWith("revenue_metrics") - ? mockDescribeResponse({ - columns: [ - { - name: "arr", - type: "DECIMAL(38,2)", - is_measure: true, - display_name: "Annual Recurring Revenue", - format: "$#,##0.00", - comment: "ARR per quarter", - }, - { - name: "growth_rate", - type: "DOUBLE", - is_measure: true, - display_name: "Growth Rate", - format: "0.0%", - }, - { - name: "region", - type: "STRING", - is_measure: false, - display_name: "Region", - }, - { - name: "created_at", - type: "TIMESTAMP", - is_measure: false, - display_name: "Period", - }, - ], - }) - : mockDescribeResponse({ - columns: [ - { - name: "churn_rate", - type: "DOUBLE", - is_measure: true, - display_name: "Churn Rate", - format: "0.0%", - }, - { - name: "csm_email", - type: "STRING", - is_measure: false, - display_name: "CSM Email", - }, - ], - }); - - const { schemas } = await syncMetrics(resolution, fetcher); - const json = generateMetricsMetadataJson(schemas); - expect(json).toMatchSnapshot(); - - // Guard against snapshot blind-update: structural assertions on the parsed JSON. - const parsed = JSON.parse(json); - expect(Object.keys(parsed)).toEqual(["customer_metrics", "revenue"]); - expect(parsed.revenue.measures.arr.format).toBe("$#,##0.00"); - expect(parsed.revenue.measures.arr.display_name).toBe( - "Annual Recurring Revenue", - ); - // Time grains are inferred from the SQL type and ordered lexicographically. - // TIMESTAMP → all 7 standard grains. - expect(parsed.revenue.dimensions.created_at.time_grain).toEqual([ - "day", - "hour", - "minute", - "month", - "quarter", - "week", - "year", - ]); - // The client-shipped artifact must not carry server-side concerns: - // UC FQN (`source`) and execution lane (`lane`) live in metric-views.json - // and are consumed only on the server. Asserting their absence catches - // accidental re-introduction in code review or refactors. - expect(parsed.revenue).not.toHaveProperty("source"); - expect(parsed.revenue).not.toHaveProperty("lane"); - expect(parsed.customer_metrics).not.toHaveProperty("source"); - expect(parsed.customer_metrics).not.toHaveProperty("lane"); - }); - - test("emits `{}` when no metrics are registered", () => { - expect(generateMetricsMetadataJson([])).toBe("{}\n"); - }); -}); - -// ── Key-order determinism across artifacts: both emitters sort with ONE -// shared locale-independent (code-unit) comparator. localeCompare-style -// collation would interleave mixed-case keys ("ARPU", "churn", "Revenue") -// and could vary by machine/locale, drifting the .d.ts from the bundle. +// ── Key-order determinism: the .d.ts 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 identically (code-unit) in metric.d.ts and metrics.metadata.json", async () => { + test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.d.ts", async () => { const resolution = resolveMetricConfig({ metricViews: { Revenue: { source: "a.b.r" }, @@ -2292,12 +1976,8 @@ describe("artifact key-order determinism", () => { const dtsKeys = [...declarations.matchAll(/^ {4}"([^"]+)": \{$/gm)].map( (m) => m[1], ); - const bundleKeys = Object.keys( - JSON.parse(generateMetricsMetadataJson(schemas)), - ); expect(dtsKeys).toEqual(["ARPU", "Revenue", "churn"]); - expect(bundleKeys).toEqual(dtsKeys); }); }); 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 new file mode 100644 index 000000000..dfc650dcc --- /dev/null +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -0,0 +1,327 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { DescribeFetcher } from "../mv-registry/types"; +import type { DatabricksStatementExecutionResponse } from "../types"; + +/** + * Unit tests for the metric-only `syncMetricViewsTypes` export — the unified + * metric pipeline behind `generateFromEntryPoint`'s metric section (and directly + * callable in its default `describe-now` mode). A mock {@link DescribeFetcher} is + * injected so the + * pipeline (read config → resolve → [cache partition] → syncMetrics → write + * the .d.ts) runs without a warehouse, asserting the MetricRegistry + * augmentation lands for a mixed fixture (a service-principal metric + an OBO + * metric; measures + a time-typed dimension + a format spec) and that the + * shared typegen cache is honored (default) / bypassed (`cache: false`). + */ + +// In-memory stand-in for the on-disk typegen cache file so the focused metric +// sync's loadCache/saveCache never touch node_modules/.databricks and each test +// controls cache state. hashSQL / metricCacheHash / isRevivableMetricCacheEntry +// / CACHE_VERSION pass through unmocked (mirrors index.test.ts). +const mocks = vi.hoisted(() => ({ + cacheFile: { contents: undefined as string | undefined }, +})); + +vi.mock("../cache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCache: vi.fn(async () => { + const raw = mocks.cacheFile.contents; + if (raw !== undefined) { + try { + const parsed = JSON.parse(raw) as Awaited< + ReturnType + >; + if (parsed.version === actual.CACHE_VERSION) { + return parsed; + } + } catch { + // Corrupted "file": fall through to the fresh-cache default. + } + } + return { version: actual.CACHE_VERSION, queries: {} }; + }), + saveCache: vi.fn(async (cache: unknown) => { + mocks.cacheFile.contents = JSON.stringify(cache, null, 2); + }), + }; +}); + +const { syncMetricViewsTypes } = await import("../index"); + +/** + * Build a representative DESCRIBE TABLE EXTENDED ... AS JSON response: one row, + * one cell, a JSON-string payload (the Statement Execution API shape). + */ +function mockDescribeResponse( + payload: unknown, +): DatabricksStatementExecutionResponse { + return { + statement_id: "stmt-mock", + status: { state: "SUCCEEDED" }, + result: { data_array: [[JSON.stringify(payload)]] }, + }; +} + +// Per-FQN DESCRIBE payloads for the mixed fixture. `revenue` (SP lane) exercises +// a currency `format` spec on its measure; `churn` (OBO lane) exercises a +// time-typed dimension (TIMESTAMP → time grains inferred from the SQL type). +const DESCRIBE_BY_FQN: Record = { + "demo.sales.revenue": { + columns: [ + { + name: "total_revenue", + type: "DECIMAL(38,2)", + is_measure: true, + format: "$#,##0.00", + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }, + "demo.sales.churn": { + columns: [ + { name: "churn_rate", type: "DOUBLE", is_measure: true }, + { name: "event_time", type: "TIMESTAMP", is_measure: false }, + ], + }, +}; + +describe("syncMetricViewsTypes", () => { + let tmpRoot: string; + let queryFolder: string; + let metricOutFile: string; + + // A spy fetcher so cache tests can assert which FQNs were (re)described. + const fetcher = vi.fn(async (fqn) => { + const payload = DESCRIBE_BY_FQN[fqn]; + if (payload === undefined) { + throw new Error(`unexpected FQN in test fetcher: ${fqn}`); + } + return mockDescribeResponse(payload); + }); + + const writeMixedConfig = () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + JSON.stringify({ + metricViews: { + // SP lane (default executor). + revenue: { source: "demo.sales.revenue" }, + // OBO lane (executor: "user"). + churn: { source: "demo.sales.churn", executor: "user" }, + }, + }), + ); + }; + + beforeEach(() => { + fetcher.mockClear(); + mocks.cacheFile.contents = undefined; + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sync-metric-types-")); + queryFolder = path.join(tmpRoot, "config", "queries"); + fs.mkdirSync(queryFolder, { recursive: true }); + metricOutFile = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test("writes the MetricRegistry augmentation for a mixed SP + OBO fixture", async () => { + writeMixedConfig(); + + const result = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + // The .d.ts exists on disk. + expect(fs.existsSync(metricOutFile)).toBe(true); + + // Result reports both keys, no failures, config present. + expect(result.noConfig).toBe(false); + expect(result.failures).toEqual([]); + expect(result.schemas.map((s) => s.key).sort()).toEqual([ + "churn", + "revenue", + ]); + expect(result.metricOutFile).toBe(metricOutFile); + + // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- + const declarations = fs.readFileSync(metricOutFile, "utf-8"); + expect(declarations).toContain("interface MetricRegistry"); + expect(declarations).toContain('"revenue"'); + expect(declarations).toContain('"churn"'); + // Measure + dimension column types render as TS primitives. + expect(declarations).toContain('"total_revenue": number'); + expect(declarations).toContain('"region": string'); + expect(declarations).toContain('"churn_rate": number'); + // The OBO metric's lane is captured in its entry. + expect(declarations).toContain('lane: "obo"'); + 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. + expect(declarations).toContain('"$#,##0.00"'); + }); + + test("returns noConfig and writes nothing when metric-views.json is absent", async () => { + const result = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + expect(result.noConfig).toBe(true); + expect(result.schemas).toEqual([]); + expect(result.failures).toEqual([]); + expect(fs.existsSync(metricOutFile)).toBe(false); + }); + + // --- cache behavior (default ON) ------------------------------------------- + + test("default (cache on): a warm second run over an unchanged config serves cache hits and describes nothing", async () => { + writeMixedConfig(); + + // First run: both keys are cache misses → both described, results persisted. + await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + + fetcher.mockClear(); + + // Second run, same config: both keys hit the cache → zero DESCRIBE calls, + // and the artifacts are still regenerated from the cached schemas. + const result = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(result.failures).toEqual([]); + expect(result.schemas.map((s) => s.key).sort()).toEqual([ + "churn", + "revenue", + ]); + // Cached schemas still render the real (non-degraded) types. + const declarations = fs.readFileSync(metricOutFile, "utf-8"); + expect(declarations).toContain('"total_revenue": number'); + expect(declarations).toContain('"churn_rate": number'); + }); + + test("cache: false (--no-cache) re-describes every key even when a warm cache exists", async () => { + writeMixedConfig(); + + // Warm the cache. + await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + + fetcher.mockClear(); + + // cache: false ignores the warm section → both keys re-described. + await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + cache: false, + metricFetcher: fetcher, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + test("a degraded/failed cached entry is re-described, not served (stricter hit rule)", async () => { + // Config with one entry whose first DESCRIBE fails (degraded), warming a + // sticky cache entry; the second run must re-describe it rather than ship + // the degraded schema. + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + // First run: fetcher throws → degraded schema + a failure, cached retry:true. + fetcher.mockRejectedValueOnce(new Error("TABLE_OR_VIEW_NOT_FOUND")); + const first = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + expect(first.failures).toHaveLength(1); + expect(fetcher).toHaveBeenCalledTimes(1); + + fetcher.mockClear(); + + // Second run, unchanged config, cache ON: the degraded entry is NOT a hit + // (degraded !== true clause + retry:true) → re-described, now succeeds. + const second = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(second.failures).toEqual([]); + const declarations = fs.readFileSync(metricOutFile, "utf-8"); + expect(declarations).toContain('"total_revenue": number'); + }); + + test("a removed metric key is pruned from the cache section", async () => { + writeMixedConfig(); + + // Warm both keys. + await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + const afterFirst = JSON.parse(mocks.cacheFile.contents ?? "{}"); + expect(Object.keys(afterFirst.metrics).sort()).toEqual([ + "churn", + "revenue", + ]); + + // Shrink the config to a single key. + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + const afterSecond = JSON.parse(mocks.cacheFile.contents ?? "{}"); + expect(Object.keys(afterSecond.metrics)).toEqual(["revenue"]); + }); +}); 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 b6092587f..755225561 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -361,14 +361,14 @@ describe("appKitTypesPlugin — metric option plumbing", () => { else process.env.DATABRICKS_WAREHOUSE_ID = savedWarehouseId; }); - test("passes unset metric out files through as undefined so the generator defaults them to siblings of outFile", async () => { + test("passes an unset metric out file through as undefined so the generator defaults it to a sibling of outFile", async () => { await runPlugin(); await flush(); - // configResolved resolves only outFile (and explicitly-provided metric - // paths) against projectRoot (config.root/..). Unset metric options stay + // configResolved resolves only outFile (and an explicitly-provided metric + // path) against projectRoot (config.root/..). An unset metric option stays // undefined so generateFromEntryPoint computes its sibling-of-outFile - // defaults — identical final paths in the all-defaults case, since the + // default — identical final path in the all-defaults case, since the // default outFile below lives in shared//. expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ @@ -377,15 +377,13 @@ describe("appKitTypesPlugin — metric option plumbing", () => { `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, ), mvOutFile: undefined, - mvMetadataOutFile: undefined, }), ); }); - test("custom mvOutFile/mvMetadataOutFile reach generateFromEntryPoint", async () => { + test("a custom mvOutFile reaches generateFromEntryPoint", async () => { const plugin = appKitTypesPlugin({ - mvOutFile: "custom/types/metric.d.ts", - mvMetadataOutFile: "custom/types/metrics.metadata.json", + mvOutFile: "custom/types/metric-views.d.ts", }); getHook( plugin, @@ -398,10 +396,9 @@ describe("appKitTypesPlugin — metric option plumbing", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ - mvOutFile: path.resolve(process.cwd(), "custom/types/metric.d.ts"), - mvMetadataOutFile: path.resolve( + mvOutFile: path.resolve( process.cwd(), - "custom/types/metrics.metadata.json", + "custom/types/metric-views.d.ts", ), }), ); diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 635a530f6..b9d894011 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -38,13 +38,6 @@ interface AppKitTypesPluginOptions { * Defaults to a sibling of `outFile`, computed by the generator. */ mvOutFile?: string; - /** - * Path to the metric semantic-metadata JSON file (relative to client folder). - * Build-time artifact — defaults to a sibling of {@link mvOutFile} - * (itself a sibling of `outFile`), computed by the generator. Skipped - * automatically when `metric-views.json` is absent. - */ - mvMetadataOutFile?: string; /** Folders to watch for changes. */ watchFolders?: string[]; } @@ -58,7 +51,6 @@ interface AppKitTypesPluginOptions { export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { let outFile: string; let mvOutFile: string | undefined; - let mvMetadataOutFile: string | undefined; let watchFolders: string[]; // Single-flight state for runGenerate(). `inFlight` is the promise of the @@ -109,7 +101,6 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { noCache: false, mode, mvOutFile, - mvMetadataOutFile, }); } catch (error) { // TypegenSyntaxError / TypegenFatalError carry a complete, actionable @@ -312,20 +303,16 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { projectRoot, options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, ); - // Metric out-paths resolve against projectRoot only when explicitly - // provided; unset options pass through as undefined so the generator - // computes its sibling-of-outFile defaults. In the all-defaults case - // the final paths are identical (the default outFile above lives in + // The metric out-path resolves against projectRoot only when explicitly + // provided; an unset option passes through as undefined so the generator + // computes its sibling-of-outFile default. In the all-defaults case the + // final path is identical (the default outFile above lives in // shared//), and a customized outFile now keeps its metric - // siblings next to it instead of pinning them under shared/. + // sibling next to it instead of pinning it under shared/. mvOutFile = options?.mvOutFile !== undefined ? path.resolve(projectRoot, options.mvOutFile) : undefined; - mvMetadataOutFile = - options?.mvMetadataOutFile !== undefined - ? path.resolve(projectRoot, options.mvMetadataOutFile) - : undefined; watchFolders = options?.watchFolders ?? [ path.join(process.cwd(), "config", "queries"), ]; diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index d61e8c534..b03ccabee 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -4,7 +4,14 @@ export default defineConfig([ { publint: true, name: "@databricks/appkit", - entry: ["src/index.ts", "src/beta.ts"], + // `./type-generator` is a public subpath export consumed cross-package by the + // `appkit generate-types` CLI via a dynamic import Rolldown can't see. It must + // be its own entry so the names the CLI imports at runtime + // (generateFromEntryPoint — which additively emits metric-view types — and + // generateServingTypes) are preserved under unbundle tree-shaking. Without it, + // the subpath's runtime exports collapse to only the names appkit's own Vite + // plugins import — silently dropping the CLI's. + entry: ["src/index.ts", "src/beta.ts", "src/type-generator/index.ts"], outDir: "dist", hash: false, format: "esm", diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index df65f047c..a8f2153a0 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -50,6 +50,9 @@ const { vi.mock("@databricks/appkit/type-generator", () => ({ generateFromEntryPoint, generateServingTypes, + // The CLI joins this with the out file's directory to report the emitted + // metric artifact; mirror the real exported constant. + METRIC_TYPES_FILE: "metric-views.d.ts", })); // Mock the detached spawn so we can assert how the worker is launched without @@ -230,4 +233,33 @@ describe("generate-types foreground spawn orchestration", () => { expect(spawn).not.toHaveBeenCalled(); expect(acquireSpawnLock).not.toHaveBeenCalled(); }); + + test("reports the metric artifact when config/queries/metric-views.json exists", async () => { + // The metric path is additive: generateFromEntryPoint emits metric-views.d.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"); + fs.writeFileSync( + path.join(tmpRoot, "config", "queries", "metric-views.json"), + JSON.stringify({ metricViews: { revenue: { source: "c.s.revenue" } } }), + ); + + await runCli([tmpRoot, outFile, "wh-123"]); + + 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")}`, + ); + }); + + test("omits the metric artifact line when metric-views.json is absent (dormant)", async () => { + const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); + + await runCli([tmpRoot, outFile, "wh-123"]); + + const logged = consoleLog.mock.calls.flat().map(String).join("\n"); + expect(logged).toContain("Generated query types:"); + expect(logged).not.toContain("Generated metric types:"); + }); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 03c1631a1..1d1060384 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -75,6 +75,20 @@ async function runGenerateTypes( mode, }); console.log(`Generated query types: ${resolvedOutFile}`); + + // generateFromEntryPoint also emits the metric-view types additively + // when config/queries/metric-views.json exists (it stays dormant + // otherwise), writing metric-views.d.ts as a sibling of the query out + // file. Mirror that to report the artifact; a degraded/failed view is + // warned (default non-blocking) or has already thrown (--wait) inside + // the call above. + const metricConfig = path.join(queryFolder, "metric-views.json"); + if (fs.existsSync(metricConfig)) { + const typesDir = path.dirname(resolvedOutFile); + console.log( + `Generated metric types: ${path.join(typesDir, typeGen.METRIC_TYPES_FILE)}`, + ); + } } } else { console.error( diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index d03dd547a..49391c3aa 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -1,14 +1,35 @@ -// Type declarations for optional @databricks/appkit/type-generator module +/** + * Ambient, intentionally NARROWED mirror of `@databricks/appkit/type-generator`. + * + * `shared` must not statically depend on `appkit` (it is a leaf package), so the + * CLI reaches appkit's type-generator through a dynamic + * `import("@databricks/appkit/type-generator")`; this declaration types that + * import without a build-time dependency on appkit. + * + * The mirror is deliberately narrower than the real export: it declares only the + * surface the `generate-types` CLI actually uses — `generateFromEntryPoint` + * (which emits query AND, additively, metric-view types), `generateServingTypes`, + * the two error classes the CLI catches by `name`, and the metric artifact + * filename constant the CLI uses to report the emitted path. Metric-view types + * are produced inside `generateFromEntryPoint`, so the CLI no longer calls + * `syncMetricViewsTypes` directly and that export is not mirrored. + * + * DRIFT WARNING: there is NO compile-time link to appkit's real types — if the + * real `generateFromEntryPoint` / `generateServingTypes` (or the exported + * constants) change, this declaration will NOT fail to compile and must be + * re-synced by hand against `packages/appkit/src/type-generator/index.ts`. + */ declare module "@databricks/appkit/type-generator" { export function generateFromEntryPoint(options: { queryFolder?: string; outFile: string; warehouseId: string; noCache?: boolean; - // Warehouse preflight policy. "non-blocking" never probes the warehouse and - // never describes (emits cached/`unknown` types and returns immediately); - // "blocking" waits for a startable warehouse and treats a stopped one as - // fatal. + // Warehouse preflight policy. "non-blocking" emits cached/`unknown` query + // types and permissive metric types and returns immediately, warning on any + // degraded/failed metric view; "blocking" (the CLI's `--wait`) waits for a + // startable warehouse, treats a stopped one as fatal, and fails the run on + // any metric view that still can't be described. mode?: "non-blocking" | "blocking"; }): Promise; @@ -25,4 +46,8 @@ declare module "@databricks/appkit/type-generator" { outFile: string; noCache?: boolean; }): Promise; + + // Metric artifact filename (written as a sibling of the query out file). The + // CLI joins this with the out file's directory to report the emitted path. + export const METRIC_TYPES_FILE: string; } diff --git a/template/_gitignore b/template/_gitignore index 23adbc24e..29895e7c0 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -11,3 +11,4 @@ playwright-report/ # Auto-generated types (endpoint-specific, varies per developer) shared/appkit-types/serving.d.ts +