From f50b70edd7099a0602c7480c09ba8213489f595f Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 22 Jun 2026 20:32:13 +0200 Subject: [PATCH 01/13] chore(shared): appkit mv sync CLI (metric views) Adds an appkit mv sync command that fetches Unity Catalog metric-view schemas and emits metric.d.ts plus metrics.metadata.json outside the Vite dev loop (CI, non-Vite builds, manual refresh). The command lives in shared and reaches appkit's sync core via dynamic import of the type-generator entry with an ambient declaration and a graceful appkit-absent fallback, so shared keeps no static appkit dependency. A new appkit syncMetricViewsTypes export reuses the existing metric writers, adaptive describe fetcher and persistent cache helpers, so the emitted bundle matches the Vite plugin output. Config is validated against metricSourceSchema before sync, an absent default file exits zero for dormancy while error modes exit non-zero with distinct messages, and interactive and non-interactive flows mirror plugin create. Flags are --warehouse-id, --metric-views-json-path, --output-dir and --no-cache. Fourth change in the metric-views decomposition after #427, #429 and #433. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 796 +++++++++++------- .../tests/sync-metric-views-types.test.ts | 350 ++++++++ packages/appkit/tsdown.config.ts | 9 +- .../src/cli/commands/metric-views/index.ts | 20 + .../commands/metric-views/sync/sync.test.ts | 646 ++++++++++++++ .../cli/commands/metric-views/sync/sync.ts | 422 ++++++++++ .../validate-metric-views-source.test.ts | 185 ++++ .../validate-metric-views-source.ts | 79 ++ .../src/cli/commands/type-generator.d.ts | 51 ++ packages/shared/src/cli/index.ts | 2 + 10 files changed, 2241 insertions(+), 319 deletions(-) create mode 100644 packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts create mode 100644 packages/shared/src/cli/commands/metric-views/index.ts create mode 100644 packages/shared/src/cli/commands/metric-views/sync/sync.test.ts create mode 100644 packages/shared/src/cli/commands/metric-views/sync/sync.ts create mode 100644 packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts create mode 100644 packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 90dcaae7d..83fe4def5 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -333,348 +333,508 @@ export async function generateFromEntryPoint(options: { // 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. + // 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 + // keep last-known-good cache serving + the sticky-degraded notice. 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 that flag. Fatal preflight errors come back in + // `fatalErrors` (empty except for a deleted/deleting warehouse in blocking + // mode) so the end-of-run throw below surfaces them after the writes, exactly + // as the inline block did. 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); + const mvMetadataFile = + mvMetadataOutFile ?? + path.join(path.dirname(mvFile), METRIC_METADATA_FILE); + const mvResult = await syncMetricViewsTypes({ + queryFolder, + warehouseId, + metricOutFile: mvFile, + metricMetadataOutFile: mvMetadataFile, + cache: !noCache, + metricFetcher, + mode, + }); + for (const fe of mvResult.fatalErrors) { + fatalErrors.push(fe); + } + } - // 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); - } - } + // One-time migration: remove old generated file and patch project configs + await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); + await migrateProjectConfig(projectRoot); - 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(", "), - ); - } + // Types are always written above — including `result: unknown` for any query + // that could not be described. Connectivity failures pass silently so a + // transient warehouse outage never blocks a build; genuine SQL errors and + // non-connectivity fatal request failures surface after the file write. + if (syntaxErrors.length > 0) { + throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors); + } + if (fatalErrors.length > 0) { + throw new TypegenFatalError(fatalErrors, warehouseId); + } - // 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)}`; - } - } - } + logger.debug("Type generation complete!"); +} - // 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"; - } +/** + * 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 { + /** Absolute path the MetricRegistry `.d.ts` was written to (undefined when no config). */ + metricOutFile?: string; + /** Absolute path the semantic-metadata JSON bundle was written to (undefined when no config). */ + metricMetadataOutFile?: string; + /** Schemas emitted, one per configured metric key (empty when no config). */ + schemas: MetricSchema[]; + /** Per-entry DESCRIBE failures surfaced by {@link syncMetrics}. */ + 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. The CLI never sets + * `mode`, so for `"describe-now"` this is always empty. + */ + fatalErrors: Array<{ name: string; message: string }>; +} - 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, - ); - } - } +/** + * Unified metric-view type-generation pipeline. Backs BOTH the `appkit mv sync` + * CLI (default `"describe-now"` mode) and {@link generateFromEntryPoint}'s + * metric section (which forwards its dev `"non-blocking"`/`"blocking"` mode). + * + * It does the focused metric pipeline ONLY — it never describes analytics + * queries and never writes `analytics.d.ts` / `serving.d.ts`. The pipeline: + * read config ({@link readMetricConfig}) → resolve ({@link resolveMetricConfig}) + * → partition cache hits vs describe-needed → optional warehouse preflight / + * #406 status gate → describe ({@link syncMetrics} over + * {@link createWorkspaceDescribeFetcher}) → persist + prune the `metrics` + * cache section → merge → write `metric.d.ts` + * ({@link generateMetricTypeDeclarations}) and `metrics.metadata.json` + * ({@link generateMetricsMetadataJson}). + * + * 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"` (default, the CLI): no preflight, no #406 status probe — + * DESCRIBE every key that isn't a clean cache hit. The hit predicate is + * STRICTER here: a degraded/sticky cached entry is NEVER served (it is + * re-described), so a focused `mv sync` always converges to correct types, + * and the sticky-degraded notice never fires (nothing degraded is served). + * - `"non-blocking"` (dev/Vite default): honor the #406 contract — one + * status-only probe, DESCRIBE only when the warehouse is already RUNNING, + * else emit degraded artifacts immediately. Degraded cache hits ARE served + * (last-known-good) and surfaced via the sticky-degraded notice. + * - `"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.metricMetadataOutFile - output path for the semantic-metadata + * JSON bundle. + * @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; the CLI omits it (taking `"describe-now"`), + * {@link generateFromEntryPoint} forwards its own {@link PreflightMode}. + */ +export async function syncMetricViewsTypes(options: { + queryFolder: string; + warehouseId: string; + metricOutFile: string; + metricMetadataOutFile: string; + cache?: boolean; + metricFetcher?: DescribeFetcher; + mode?: "describe-now" | "non-blocking" | "blocking"; +}): Promise { + const { + queryFolder, + warehouseId, + metricOutFile, + metricMetadataOutFile, + cache: cacheEnabled, + metricFetcher, + mode = "describe-now", + } = options; - // 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(", "), - ); - } + // Only `cache === false` disables caching; `undefined`/`true` keep it on. + const noCache = cacheEnabled === false; - // 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), - }; + 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. Metric keys are user-controlled config and + // "__proto__"/"constructor" pass the metric key regex — a null prototype + // keeps a malicious/edge key from hitting an Object.prototype setter on write + // or resolving inherited names as phantom entries on read. With `noCache`, the + // section starts empty (every entry describe-needed) and is overwritten on + // save below. + 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]; + } + } + + // Dev modes (`non-blocking`/`blocking`) serve degraded cache hits as + // last-known-good — exactly like queries degrade to cached types — and + // surface them via the sticky-degraded notice. `describe-now` (the CLI) is an + // explicit "make my types correct now" action, so it NEVER serves a + // degraded/sticky entry: that entry is re-described instead, and no degraded + // hit is served, so the notice never fires. + const serveDegraded = mode !== "describe-now"; + + // Partition BEFORE any gate/preflight decision: a hit (structurally valid + // entry, hash match, not retry-flagged, and — unless serving degraded — not + // degraded) is served from cache no matter what the warehouse is doing. Only + // the remainder (new, edited, retry-flagged, unrevivable, or — in + // `describe-now` — degraded 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` that are SERVED as hits are + // sticky failures: they serve their permissive schema, but are collected here + // for the single notice below so the misconfiguration isn't silently hidden. + // (Empty in `describe-now`, which never serves a degraded hit.) + 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 && + (serveDegraded || prior.schema.degraded !== true) + ) { + hitSchemas.set(entry.key, prior.schema); + if (prior.schema.degraded === true) { + stickyDegradedHits.push(entry.key); } + } 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++; + 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(", "), + ); + } + + // 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). + // 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. 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}`; } } - - // 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. 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)}`; } + } + } - // 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 (#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. `describe-now` and `blocking` both + // start `describeNow = true` (`mode !== "non-blocking"`), so this gate is + // skipped for them — `describe-now` describes directly, `blocking` already ran + // its preflight above. + 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[] = []; + // 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. Never set in + // `describe-now` (no preflight/gate runs there). + 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 caller 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( - "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 — 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. (Only reachable in + // `non-blocking` mode.) + 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(", "), + ); } - // 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. 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`. In `describe-now` there is no preflight/gate, so + // `terminalSkip` is always false and this reduces to "retry a degraded outcome + // unless it was a deterministic DESCRIBE failure" — a deterministic failure + // won't loop forever, and the stricter hit rule re-describes it next run + // anyway. 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), + }; + } - // 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 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++; + } } - if (fatalErrors.length > 0) { - throw new TypegenFatalError(fatalErrors, warehouseId); + + // 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. With `noCache` the section started empty, so it's overwritten. + 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 (renderers sort + // internally where determinism matters). Every describe-needed entry yields + // exactly one schema above, so the final fallback is defensive only. + 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; + // 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. + 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", + ); + + await fs.mkdir(path.dirname(metricMetadataOutFile), { recursive: true }); + await fs.writeFile( + metricMetadataOutFile, + generateMetricsMetadataJson(schemas), + "utf-8", + ); + + logger.debug( + "Wrote MetricRegistry augmentation + metadata bundle for %d metric(s)%s", + schemas.length, + failures.length > 0 ? ` (${failures.length} failure(s))` : "", + ); + + return { + metricOutFile, + metricMetadataOutFile, + schemas, + failures, + fatalErrors, + noConfig: false, + }; } // Rolldown tree-shaking only preserves "own exports" (locally defined) — not re-exports. 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..dae4ec3af --- /dev/null +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -0,0 +1,350 @@ +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 that backs the + * `appkit mv sync` CLI. A mock {@link DescribeFetcher} is injected so the + * pipeline (read config → resolve → [cache partition] → syncMetrics → write + * artifacts) runs without a warehouse, asserting BOTH artifacts land 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; + let metricMetadataOutFile: 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.d.ts"); + metricMetadataOutFile = path.join( + tmpRoot, + "shared", + "appkit-types", + "metrics.metadata.json", + ); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test("writes BOTH artifacts for a mixed SP + OBO fixture", async () => { + writeMixedConfig(); + + const result = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricMetadataOutFile, + metricFetcher: fetcher, + }); + + // Both artifacts exist on disk. + expect(fs.existsSync(metricOutFile)).toBe(true); + expect(fs.existsSync(metricMetadataOutFile)).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); + expect(result.metricMetadataOutFile).toBe(metricMetadataOutFile); + + // --- metric.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"); + + // --- metrics.metadata.json: per-metric semantic bundle --- + const bundle = JSON.parse(fs.readFileSync(metricMetadataOutFile, "utf-8")); + // SP metric: currency format spec is preserved on the measure. + expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); + expect(bundle.revenue.measures.total_revenue.format).toBe("$#,##0.00"); + expect(bundle.revenue.dimensions.region.type).toBe("STRING"); + // OBO metric: time-typed dimension carries its inferred time_grain set. + expect(bundle.churn.measures.churn_rate.type).toBe("DOUBLE"); + expect(bundle.churn.dimensions.event_time.type).toBe("TIMESTAMP"); + expect(bundle.churn.dimensions.event_time.time_grain).toEqual( + expect.arrayContaining(["day", "hour", "minute", "month", "year"]), + ); + }); + + test("returns noConfig and writes nothing when metric-views.json is absent", async () => { + const result = await syncMetricViewsTypes({ + queryFolder, + warehouseId: "wh-1", + metricOutFile, + metricMetadataOutFile, + metricFetcher: fetcher, + }); + + expect(result.noConfig).toBe(true); + expect(result.schemas).toEqual([]); + expect(result.failures).toEqual([]); + expect(fs.existsSync(metricOutFile)).toBe(false); + expect(fs.existsSync(metricMetadataOutFile)).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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + 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, + metricMetadataOutFile, + metricFetcher: fetcher, + }); + + const afterSecond = JSON.parse(mocks.cacheFile.contents ?? "{}"); + expect(Object.keys(afterSecond.metrics)).toEqual(["revenue"]); + }); +}); diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index d61e8c534..730757e71 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` CLI (`appkit mv sync` / `generate-types`) via a dynamic import + // Rolldown can't see. It must be its own entry so its declared public API + // (syncMetricViewsTypes + METRIC_TYPES_FILE / METRIC_METADATA_FILE, alongside + // generateFromEntryPoint / generateServingTypes) is 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/metric-views/index.ts b/packages/shared/src/cli/commands/metric-views/index.ts new file mode 100644 index 000000000..7e03dd2fc --- /dev/null +++ b/packages/shared/src/cli/commands/metric-views/index.ts @@ -0,0 +1,20 @@ +import { Command } from "commander"; +import { metricViewsSyncCommand } from "./sync/sync"; + +/** + * Parent command for UC Metric View operations. + * + * Phase 1 exposes a single subcommand (`sync`). Future subcommands + * (`list` / `validate` / `describe`) plug in here so users have one top-level + * surface for everything related to Metric Views. Sibling of `plugin`, + * `setup`, `generate-types`, `lint`, `docs`, `codemod`. + */ +export const metricViewsCommand = new Command("mv") + .description("Metric-view management commands (UC Metric Views)") + .addCommand(metricViewsSyncCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit mv sync --warehouse-id 1234abcd5678efgh`, + ); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts new file mode 100644 index 000000000..bbd8b183b --- /dev/null +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts @@ -0,0 +1,646 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + test, + vi, +} from "vitest"; + +// --- Module mocks ----------------------------------------------------------- +// vi.mock factories are hoisted above the file, so the spies they return must be +// created in a hoisted block too. Mirrors generate-types.test.ts. +const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = + vi.hoisted(() => ({ + // The mock stands in for the appkit export: it WRITES both artifacts (so + // the test can assert they land in the temp dir) and reports its inputs. + syncMetricViewsTypes: vi.fn( + async (opts: { + queryFolder: string; + warehouseId: string; + metricOutFile: string; + metricMetadataOutFile: string; + cache?: boolean; + }) => { + const nodeFs = require("node:fs") as typeof import("node:fs"); + const nodePath = require("node:path") as typeof import("node:path"); + nodeFs.mkdirSync(nodePath.dirname(opts.metricOutFile), { + recursive: true, + }); + nodeFs.writeFileSync(opts.metricOutFile, "// metric.d.ts\n"); + nodeFs.writeFileSync(opts.metricMetadataOutFile, "{}\n"); + // Annotate the array element types so the inferred return type is wide + // enough for `mockResolvedValueOnce` overrides that populate `failures` + // (an empty literal would otherwise infer `never[]`). + const schemas: Array<{ key: string; source: string; lane: string }> = [ + { key: "revenue", source: "demo.sales.revenue", lane: "sp" }, + ]; + const failures: Array<{ + key: string; + source: string; + reason: string; + transient: boolean; + }> = []; + return { + metricOutFile: opts.metricOutFile, + metricMetadataOutFile: opts.metricMetadataOutFile, + schemas, + failures, + noConfig: false, + }; + }, + ), + METRIC_TYPES_FILE: "metric.d.ts", + METRIC_METADATA_FILE: "metrics.metadata.json", + })); + +// The library type-generator is an optional/ambient module; mock it so the +// command's `await import("@databricks/appkit/type-generator")` resolves to +// spies and never touches a warehouse. +vi.mock("@databricks/appkit/type-generator", () => ({ + syncMetricViewsTypes, + METRIC_TYPES_FILE, + METRIC_METADATA_FILE, +})); + +// --- @clack/prompts mock ---------------------------------------------------- +// Drive the interactive path deterministically: each `text` prompt returns the +// next queued answer; `isCancel` recognizes the shared CANCEL symbol so a queued +// cancel triggers the graceful-exit branch. intro/outro/cancel/spinner are +// no-op spies (the spinner object exposes start/stop). +const clackMocks = vi.hoisted(() => { + const CANCEL = Symbol("clack:cancel"); + return { + CANCEL, + // Answers consumed in prompt order (warehouse id, config path, output dir). + textAnswers: [] as Array, + text: vi.fn(), + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + spinnerStart: vi.fn(), + spinnerStop: vi.fn(), + }; +}); + +vi.mock("@clack/prompts", () => ({ + intro: clackMocks.intro, + outro: clackMocks.outro, + cancel: clackMocks.cancel, + isCancel: (value: unknown) => value === clackMocks.CANCEL, + text: (...args: unknown[]) => { + clackMocks.text(...args); + return Promise.resolve( + clackMocks.textAnswers.length > 0 + ? clackMocks.textAnswers.shift() + : undefined, + ); + }, + spinner: () => ({ + start: clackMocks.spinnerStart, + stop: clackMocks.spinnerStop, + }), +})); + +import { metricViewsSyncCommand } from "./sync"; + +/** + * Drive the real commander command the way the bin does. `metricViewsSyncCommand` + * is a module-level singleton, so commander retains option values parsed by a + * previous call (absent options are NOT reset between `parseAsync` calls); + * clear that stored state first so each invocation parses from a clean slate. + * Resetting `_optionValueSources` to `{}` also clears the per-option `default` + * source bookkeeping, so a no-flag parse leaves every source `undefined` — the + * interactive-detection check keys on the `cli` source, so that reads correctly + * as "no user flag". + */ +async function runCli(args: string[]): Promise { + const cmd = metricViewsSyncCommand as unknown as { + _optionValues: Record; + _optionValueSources: Record; + }; + cmd._optionValues = {}; + cmd._optionValueSources = {}; + await metricViewsSyncCommand.parseAsync(args, { from: "user" }); +} + +describe("appkit mv sync", () => { + let tmpRoot: string; + let queryFolder: string; + let consoleLog: Mock; + let consoleError: Mock; + let originalCwd: string; + const prevWarehouse = process.env.DATABRICKS_WAREHOUSE_ID; + + beforeEach(() => { + vi.clearAllMocks(); + clackMocks.textAnswers = []; + originalCwd = process.cwd(); + tmpRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "metric-sync-cli-")), + ); + queryFolder = path.join(tmpRoot, "config", "queries"); + fs.mkdirSync(queryFolder, { recursive: true }); + delete process.env.DATABRICKS_WAREHOUSE_ID; + // `--root-dir` was dropped in Phase 3; the command resolves cwd-relative + // paths against process.cwd(), so anchor cwd at the temp root (mirrors + // promote.test.ts). + process.chdir(tmpRoot); + + consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}) as Mock; + consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}) as Mock; + }); + + afterEach(() => { + process.chdir(originalCwd); + vi.restoreAllMocks(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + if (prevWarehouse === undefined) { + delete process.env.DATABRICKS_WAREHOUSE_ID; + } else { + process.env.DATABRICKS_WAREHOUSE_ID = prevWarehouse; + } + }); + + const writeConfig = () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + }; + + // --- Non-interactive: flag parsing + option mapping ------------------------ + + test("calls the appkit entry with resolved paths and writes both artifacts", async () => { + writeConfig(); + + await runCli(["--warehouse-id", "wh-123"]); + + const expectedMetricOut = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric.d.ts", + ); + const expectedMetadataOut = path.join( + tmpRoot, + "shared", + "appkit-types", + "metrics.metadata.json", + ); + + // The appkit entry was called once with the resolved options. Cache is the + // commander default; after the test harness reset it is undefined (no flag, + // source cleared) — i.e. caching stays ON downstream. + expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); + expect(syncMetricViewsTypes).toHaveBeenCalledWith({ + queryFolder, + warehouseId: "wh-123", + metricOutFile: expectedMetricOut, + metricMetadataOutFile: expectedMetadataOut, + cache: undefined, + }); + + // Both artifacts landed in the temp dir. + expect(fs.existsSync(expectedMetricOut)).toBe(true); + expect(fs.existsSync(expectedMetadataOut)).toBe(true); + }); + + test("falls back to DATABRICKS_WAREHOUSE_ID when --warehouse-id is omitted (and stays non-interactive via another flag)", async () => { + writeConfig(); + process.env.DATABRICKS_WAREHOUSE_ID = "wh-env"; + + // Pass --output-dir so the env var alone doesn't have to force + // non-interactive (it must not — see the dedicated interactive test). + await runCli(["--output-dir", "shared/appkit-types"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ warehouseId: "wh-env" }), + ); + }); + + test("honors --metric-views-json-path / --output-dir overrides for path resolution", async () => { + const customConfigDir = path.join(tmpRoot, "custom", "cfg"); + fs.mkdirSync(customConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(customConfigDir, "metric-views.json"), + JSON.stringify({ metricViews: {} }), + ); + + await runCli([ + "--warehouse-id", + "wh-123", + "--metric-views-json-path", + "custom/cfg/metric-views.json", + "--output-dir", + "build/types", + ]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith({ + queryFolder: customConfigDir, + warehouseId: "wh-123", + metricOutFile: path.join(tmpRoot, "build", "types", "metric.d.ts"), + metricMetadataOutFile: path.join( + tmpRoot, + "build", + "types", + "metrics.metadata.json", + ), + cache: undefined, + }); + }); + + test("absolute --metric-views-json-path / --output-dir are used as-is", async () => { + const absConfig = path.join( + tmpRoot, + "config", + "queries", + "metric-views.json", + ); + writeConfig(); + const absOut = path.join(tmpRoot, "abs-out"); + + await runCli([ + "--warehouse-id", + "wh-123", + "--metric-views-json-path", + absConfig, + "--output-dir", + absOut, + ]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ + queryFolder: path.dirname(absConfig), + metricOutFile: path.join(absOut, "metric.d.ts"), + metricMetadataOutFile: path.join(absOut, "metrics.metadata.json"), + }), + ); + }); + + test("friendly message + no appkit call when metric-views.json is absent", async () => { + await runCli(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + const logged = consoleLog.mock.calls.flat().map(String).join("\n"); + expect(logged).toContain("Nothing to sync"); + }); + + test("appkit absent: recognizable error message + non-zero exit", async () => { + writeConfig(); + // Model the dynamic import failing as it does when @databricks/appkit + // isn't installed. + syncMetricViewsTypes.mockRejectedValueOnce( + new Error("Cannot find module '@databricks/appkit/type-generator'"), + ); + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + + await runCli(["--warehouse-id", "wh-123"]); + + const errored = consoleError.mock.calls.flat().map(String).join("\n"); + expect(errored).toContain( + "appkit mv sync is only available with @databricks/appkit installed", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + + // --- --no-cache propagation ------------------------------------------------ + + test("--no-cache forwards cache: false to syncMetricViewsTypes", async () => { + writeConfig(); + + await runCli(["--warehouse-id", "wh-123", "--no-cache"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ cache: false }), + ); + }); + + test("without --no-cache, cache is not disabled (default ON downstream)", async () => { + writeConfig(); + + await runCli(["--warehouse-id", "wh-123"]); + + const call = syncMetricViewsTypes.mock.calls[0][0] as { cache?: boolean }; + // Either undefined (harness reset clears the default source) or true (live + // commander default) — the load-bearing invariant is that it is NOT false. + expect(call.cache).not.toBe(false); + }); + + // --- Phase 2: error taxonomy ------------------------------------------------ + // Every error mode exits non-zero (1) with a distinct, recognizable message. + // The command always `return`s right after `process.exit`, so a no-op exit + // spy lets execution stop cleanly and we assert the captured code + message. + + /** + * Drive the CLI with `process.exit` spied to a no-op (the command returns + * immediately after calling it), returning the spy so the test can assert the + * exit code and the captured stderr. + */ + async function runCliCapturingExit(args: string[]): Promise { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never) as unknown as Mock; + await runCli(args); + return exitSpy; + } + + const erroredText = () => + consoleError.mock.calls.flat().map(String).join("\n"); + + test("explicit --metric-views-json-path to a missing file: non-zero + recognizable message", async () => { + const missing = path.join(tmpRoot, "nowhere", "metric-views.json"); + + const exitSpy = await runCliCapturingExit([ + "--warehouse-id", + "wh-123", + "--metric-views-json-path", + missing, + ]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(erroredText()).toContain("metric-views.json not found"); + }); + + test("explicit --metric-views-json-path with a non-metric-views.json basename: rejected before syncing", async () => { + // A valid config that EXISTS but is not named metric-views.json. The appkit + // reader resolves `/metric-views.json`, so without the basename guard + // the CLI would validate this file but sync a different (sibling/absent) one. + const customDir = path.join(tmpRoot, "custom"); + fs.mkdirSync(customDir, { recursive: true }); + fs.writeFileSync( + path.join(customDir, "my-config.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + const exitSpy = await runCliCapturingExit([ + "--warehouse-id", + "wh-123", + "--metric-views-json-path", + "custom/my-config.json", + ]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(erroredText()).toContain( + "must point to a file named metric-views.json", + ); + }); + + test("malformed JSON: non-zero + 'not valid JSON' message, never imports appkit", async () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + "{ this is not json", + ); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(erroredText()).toContain("is not valid JSON"); + }); + + test("schema-invalid config (bad FQN): non-zero + path:message list, never imports appkit", async () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + // Two-part FQN — fails the three-part UC FQN grammar. + JSON.stringify({ metricViews: { revenue: { source: "main.cm" } } }), + ); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + const errored = erroredText(); + expect(errored).toContain("invalid"); + // Humanized path of the failing field. + expect(errored).toContain("metricViews.revenue.source"); + }); + + test("schema-invalid config (unknown executor): non-zero + path:message list", async () => { + fs.writeFileSync( + path.join(queryFolder, "metric-views.json"), + JSON.stringify({ + metricViews: { revenue: { source: "main.a.cm", executor: "robot" } }, + }), + ); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(erroredText()).toContain("metricViews.revenue.executor"); + }); + + test("missing warehouse id (after a valid config): non-zero + recognizable message", async () => { + writeConfig(); + // No --warehouse-id and no DATABRICKS_WAREHOUSE_ID (cleared in beforeEach). + // Pass --output-dir so the run is non-interactive (no flag → interactive). + + const exitSpy = await runCliCapturingExit([ + "--output-dir", + "shared/appkit-types", + ]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(erroredText()).toContain("no warehouse ID"); + }); + + test("absent DEFAULT config with no warehouse id: still exits 0 (dormancy invariant)", async () => { + // No config file, no warehouse id — the additive path must stay dormant and + // NOT error on the missing warehouse. Pass a flag so this stays + // non-interactive (the dormancy decision is path-independent). + const exitSpy = await runCliCapturingExit([ + "--output-dir", + "shared/appkit-types", + ]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + // Dormancy takes the early-return path, so exit() is never called at all. + expect(exitSpy).not.toHaveBeenCalled(); + const logged = consoleLog.mock.calls.flat().map(String).join("\n"); + expect(logged).toContain("Nothing to sync"); + }); + + test("unreachable warehouse / auth failure (syncMetricViewsTypes throws): non-zero + message surfaced", async () => { + writeConfig(); + syncMetricViewsTypes.mockRejectedValueOnce( + new Error("warehouse wh-123 is unreachable: connection refused"), + ); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + // The action wrapper prints the thrown error's message verbatim. + expect(erroredText()).toContain( + "warehouse wh-123 is unreachable: connection refused", + ); + }); + + test("per-entry DESCRIBE failure (unreachable FQN): non-zero + lists the failed metric", async () => { + writeConfig(); + // The appkit export writes degraded types and returns `failures` rather + // than throwing for a missing/unreachable metric view FQN. + syncMetricViewsTypes.mockResolvedValueOnce({ + metricOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric.d.ts", + ), + metricMetadataOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metrics.metadata.json", + ), + schemas: [], + failures: [ + { + key: "revenue", + source: "demo.sales.revenue", + reason: "TABLE_OR_VIEW_NOT_FOUND", + transient: false, + }, + ], + noConfig: false, + }); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + const errored = erroredText(); + expect(errored).toContain("could not be described"); + expect(errored).toContain("revenue"); + expect(errored).toContain("TABLE_OR_VIEW_NOT_FOUND"); + }); + + // --- Phase 3: interactive flow ---------------------------------------------- + + test("no flags → interactive: prompts fire and resolved values reach syncMetricViewsTypes", async () => { + writeConfig(); + // Answers in prompt order: warehouse id, config path (blank → default), + // output dir (blank → default). + clackMocks.textAnswers = ["wh-interactive", "", ""]; + + await runCli([]); + + // intro/outro + three text prompts fired. + expect(clackMocks.intro).toHaveBeenCalledTimes(1); + expect(clackMocks.text).toHaveBeenCalledTimes(3); + expect(clackMocks.outro).toHaveBeenCalledTimes(1); + // Spinner wrapped the sync. + expect(clackMocks.spinnerStart).toHaveBeenCalledTimes(1); + expect(clackMocks.spinnerStop).toHaveBeenCalledTimes(1); + + // The interactive answer reached the appkit entry; blank path answers fell + // back to the canonical defaults (cwd-anchored). + expect(syncMetricViewsTypes).toHaveBeenCalledWith({ + queryFolder, + warehouseId: "wh-interactive", + metricOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric.d.ts", + ), + metricMetadataOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metrics.metadata.json", + ), + cache: true, + }); + }); + + test("interactive: a non-blank config path / output dir answer is honored", async () => { + const customConfigDir = path.join(tmpRoot, "alt", "cfg"); + fs.mkdirSync(customConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(customConfigDir, "metric-views.json"), + JSON.stringify({ metricViews: {} }), + ); + clackMocks.textAnswers = [ + "wh-interactive", + "alt/cfg/metric-views.json", + "alt/out", + ]; + + await runCli([]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ + queryFolder: customConfigDir, + warehouseId: "wh-interactive", + metricOutFile: path.join(tmpRoot, "alt", "out", "metric.d.ts"), + }), + ); + }); + + test("interactive: env var alone does NOT force non-interactive (prompts still run)", async () => { + writeConfig(); + process.env.DATABRICKS_WAREHOUSE_ID = "wh-env"; + // Blank warehouse answer → falls back to the env var downstream. + clackMocks.textAnswers = ["", "", ""]; + + await runCli([]); + + expect(clackMocks.intro).toHaveBeenCalledTimes(1); + expect(clackMocks.text).toHaveBeenCalledTimes(3); + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ warehouseId: "wh-env" }), + ); + }); + + test("interactive cancel (first prompt): graceful cancel + non-zero exit, no appkit call", async () => { + writeConfig(); + // First prompt returns the cancel symbol. + clackMocks.textAnswers = [clackMocks.CANCEL]; + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never) as unknown as Mock; + + await runCli([]); + + expect(clackMocks.cancel).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + }); + + test("interactive cancel (later prompt): graceful cancel + non-zero exit", async () => { + writeConfig(); + // First answer ok, second prompt cancels. + clackMocks.textAnswers = ["wh-1", clackMocks.CANCEL]; + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never) as unknown as Mock; + + await runCli([]); + + expect(clackMocks.cancel).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts new file mode 100644 index 000000000..677a986ed --- /dev/null +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.ts @@ -0,0 +1,422 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { cancel, intro, isCancel, outro, spinner, text } from "@clack/prompts"; +import { Command } from "commander"; +import { + formatMetricViewsSourceErrors, + validateMetricViewsSource, +} from "../validate-metric-views-source"; + +/** + * Options parsed by commander for `appkit mv sync`. + * + * Phase 3 locks the flag surface to exactly four options and adds the + * interactive clack flow: + * - `--warehouse-id` (+ `DATABRICKS_WAREHOUSE_ID` fallback) + * - `--metric-views-json-path` (canonical config path) + * - `--output-dir` (artifact output directory; replaces Phase 1's `--out-dir`) + * - `--no-cache` (commander negation → `cache === false` disables the + * metric type-generation cache) + * + * Phase 1's interim `--root-dir` is dropped: relative `--metric-views-json-path` + * / `--output-dir` resolve against `process.cwd()`, mirroring how + * `generate-types` anchors its defaults at the current directory. + */ +export interface MetricViewsSyncOptions { + warehouseId?: string; + /** + * Path to metric-views.json. Default: + * `/config/queries/metric-views.json`. Canonical flag name in the + * locked spec. + */ + metricViewsJsonPath?: string; + /** Output directory for metric.d.ts + metrics.metadata.json (default: /shared/appkit-types). */ + outputDir?: string; + /** + * Caching toggle. Commander's `--no-cache` sets this to `false` (and leaves it + * `true`/absent otherwise); `cache === false` is the single signal that + * disables the metric type-generation cache when forwarded to + * `syncMetricViewsTypes`. + */ + cache?: boolean; +} + +/** Default filename for the metric source config (post-#433 name). */ +const METRIC_VIEWS_CONFIG_FILE = "metric-views.json"; + +/** + * Non-zero exit code for `appkit mv sync` failure modes. Every error mode + * exits with this same code and a distinct, recognizable message (the failure + * mode is identified by that message, not by a bespoke per-mode code — keeping + * the single Phase-1 exit mechanism). The dormant (no-config) and success cases + * take an early `return`, exiting 0 naturally. + */ +const EXIT_FAILURE = 1; + +/** Resolved, absolute paths the sync run operates on. */ +interface ResolvedPaths { + /** Folder that holds metric-views.json (the appkit export reads from a folder). */ + queryFolder: string; + /** Absolute path to metric-views.json. */ + configPath: string; + /** Whether the config path came from an explicit `--metric-views-json-path`. */ + explicitConfigPath: boolean; + /** Output directory for the generated artifacts. */ + outDir: string; +} + +/** + * Resolve config + output paths from the (interactive- or flag-supplied) + * options. Relative paths resolve against `process.cwd()` (Phase 1's + * `--root-dir` was dropped in Phase 3). + */ +function resolvePaths(options: { + metricViewsJsonPath?: string; + outputDir?: string; +}): ResolvedPaths { + const cwd = process.cwd(); + + // metric-views.json: --metric-views-json-path > /config/queries/metric-views.json. + const explicitConfigPath = options.metricViewsJsonPath !== undefined; + const configPath = options.metricViewsJsonPath + ? path.isAbsolute(options.metricViewsJsonPath) + ? options.metricViewsJsonPath + : path.resolve(cwd, options.metricViewsJsonPath) + : path.join(cwd, "config", "queries", METRIC_VIEWS_CONFIG_FILE); + + // Output paths under shared/appkit-types — matches how generate-types + // resolves its output directory. + const outDir = options.outputDir + ? path.isAbsolute(options.outputDir) + ? options.outputDir + : path.resolve(cwd, options.outputDir) + : path.join(cwd, "shared", "appkit-types"); + + return { + queryFolder: path.dirname(configPath), + configPath, + explicitConfigPath, + outDir, + }; +} + +/** + * The shared sync core for BOTH the interactive and non-interactive paths: + * resolve paths → existence check (dormancy vs missing) → read + `JSON.parse` + * → schema-validate → require warehouse → ONLY THEN dynamic-import appkit + + * `syncMetricViewsTypes`. Reaches the appkit metric-sync core through a dynamic + * `import("@databricks/appkit/type-generator")` — the exact pattern + * `generate-types.ts` uses — so the `shared` CLI package carries NO static + * dependency on `@databricks/appkit` and compiles without it. + * + * Validation runs entirely before the dynamic import, so a misconfigured + * `metric-views.json` fails fast with a precise message and never touches a + * warehouse (or even requires appkit to be installed). + * + * `onProgress` lets the interactive path drive a clack spinner around the + * appkit call; the non-interactive path passes nothing (plain console logs). + */ +async function runMetricViewsSync( + options: MetricViewsSyncOptions, + onProgress?: { start(): void; succeed(msg: string): void; fail(): void }, +): Promise { + try { + const { queryFolder, configPath, explicitConfigPath, outDir } = + resolvePaths(options); + + // `--metric-views-json-path` selects WHICH metric-views.json to sync, but the + // appkit reader resolves `/metric-views.json` from the folder, so + // a differently-named file would be validated here yet never synced (appkit + // would read a sibling metric-views.json, or none). Reject the mismatch + // explicitly instead of silently validating one file and syncing another. + if ( + explicitConfigPath && + path.basename(configPath) !== METRIC_VIEWS_CONFIG_FILE + ) { + console.error( + `Error: --metric-views-json-path must point to a file named ${METRIC_VIEWS_CONFIG_FILE} (got "${path.basename(configPath)}").`, + ); + process.exit(EXIT_FAILURE); + return; + } + + // Existence is checked before anything else (including the warehouse-id + // requirement) so the dormancy invariant holds unconditionally: + // - DEFAULT path absent → additive path is dormant, exit 0. An opt-in + // project that never adopted metric views must NOT error, even without a + // warehouse configured. + // - EXPLICIT --metric-views-json-path absent → the user named a file that + // isn't there; that's a real error, exit non-zero. + if (!fs.existsSync(configPath)) { + if (explicitConfigPath) { + console.error(`Error: metric-views.json not found at ${configPath}.`); + process.exit(EXIT_FAILURE); + return; + } + console.log( + `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, + ); + return; + } + + // Read + parse the config before touching appkit. A malformed file is a + // user error with a precise location, not an appkit/warehouse failure. + const rawConfig = fs.readFileSync(configPath, "utf-8"); + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(rawConfig); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.error(`Error: ${configPath} is not valid JSON: ${reason}`); + process.exit(EXIT_FAILURE); + return; + } + + // Schema-validate against the canonical metricSourceSchema (single source + // of truth) BEFORE the dynamic import. Bad FQN grammar, an unknown + // executor, an unrecognized key, or a bad metric key fail here with the + // `path: message` list — never as an opaque downstream error. + const validation = validateMetricViewsSource(parsedConfig); + if (!validation.valid) { + console.error(`Error: invalid ${configPath}:`); + console.error(formatMetricViewsSourceErrors(validation.errors)); + process.exit(EXIT_FAILURE); + return; + } + + // The warehouse is only needed once we have a valid config to sync; require + // it here (after dormancy + validation) so a dormant/invalid project never + // trips on a missing warehouse. + const warehouseId = + options.warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; + if (!warehouseId) { + console.error( + "Error: no warehouse ID. Pass --warehouse-id or set DATABRICKS_WAREHOUSE_ID.", + ); + process.exit(EXIT_FAILURE); + return; + } + + const typeGen = await import("@databricks/appkit/type-generator"); + + const metricOutFile = path.join(outDir, typeGen.METRIC_TYPES_FILE); + const metricMetadataOutFile = path.join( + outDir, + typeGen.METRIC_METADATA_FILE, + ); + + onProgress?.start(); + let result: Awaited>; + try { + result = await typeGen.syncMetricViewsTypes({ + queryFolder, + metricOutFile, + metricMetadataOutFile, + warehouseId, + // `--no-cache` → cache === false disables the metric typegen cache; + // absent/true keeps the default (cache on). Forwarded verbatim. + cache: options.cache, + }); + } catch (err) { + onProgress?.fail(); + throw err; + } + + if (result.noConfig) { + // Defensive: the existence check above already handled the dormant case, + // but syncMetricViewsTypes re-checks the folder, so honor its verdict too. + onProgress?.fail(); + console.log( + `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, + ); + return; + } + + // Per-entry DESCRIBE failures (missing/unreachable FQN, type errors against + // a reachable warehouse) come back in `failures` rather than thrown — the + // appkit export writes permissive/degraded types and returns. Surface them + // as a hard failure so a misconfigured FQN does not silently ship. + if (result.failures.length > 0) { + onProgress?.fail(); + console.error( + `Error: ${result.failures.length} metric view(s) could not be described:`, + ); + for (const failure of result.failures) { + console.error( + ` ${failure.key} (${failure.source}): ${failure.reason}`, + ); + } + process.exit(EXIT_FAILURE); + return; + } + + onProgress?.succeed(`Generated metric types: ${metricOutFile}`); + console.log(`Generated metric types: ${metricOutFile}`); + console.log(`Generated metric metadata: ${metricMetadataOutFile}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Cannot find module") + ) { + console.error( + "Error: appkit mv sync is only available with @databricks/appkit installed.", + ); + console.error("Please install @databricks/appkit to use this command."); + process.exit(EXIT_FAILURE); + return; + } + // Errors thrown by syncMetricViewsTypes — an unreachable warehouse, an auth + // failure, or a fatal DESCRIBE setup problem (TypegenFatalError) — carry + // their own recognizable message. Re-throw so the action wrapper prints it + // and exits non-zero, keeping the message verbatim. + throw error; + } +} + +/** + * Interactive flow (mirrors `plugin create`'s `runInteractive`): `intro` → + * `text` prompts (warehouse id, config path, output dir, each guarded by + * `isCancel`) → `spinner` around the sync → `outro`. Each prompt's value is + * folded back into {@link MetricViewsSyncOptions} and handed to {@link runMetricViewsSync} + * — the SAME validation + taxonomy + appkit call the flag path uses. + * + * Empty answers fall through as `undefined`, so the warehouse prompt's blank + * input still lets the `DATABRICKS_WAREHOUSE_ID` fallback apply, and blank + * path prompts use the canonical defaults. + */ +async function runInteractive(): Promise { + intro("Sync UC Metric View types"); + + // A cancelled prompt (Ctrl-C) is a graceful, non-zero exit. The explicit + // `return` after `process.exit` keeps control flow correct under a no-op exit + // (tests) — without it, a cancelled flow would fall through to the next + // prompt and eventually run the sync. + const cancelled = (): never => { + cancel("Cancelled."); + process.exit(1); + }; + + const warehouseId = await text({ + message: "SQL Warehouse ID", + placeholder: process.env.DATABRICKS_WAREHOUSE_ID + ? `${process.env.DATABRICKS_WAREHOUSE_ID} (from DATABRICKS_WAREHOUSE_ID)` + : "1234abcd5678efgh", + // Optional: a blank value defers to DATABRICKS_WAREHOUSE_ID (validated + // downstream by runMetricViewsSync, which errors if neither is set). + }); + if (isCancel(warehouseId)) return cancelled(); + + const metricViewsJsonPath = await text({ + message: "Path to metric-views.json", + placeholder: "config/queries/metric-views.json", + // Optional: blank uses the canonical default path. + }); + if (isCancel(metricViewsJsonPath)) return cancelled(); + + const outputDir = await text({ + message: "Output directory for generated types", + placeholder: "shared/appkit-types", + // Optional: blank uses the canonical default output dir. + }); + if (isCancel(outputDir)) return cancelled(); + + const trimmed = (value: string | symbol): string | undefined => { + if (typeof value !== "string") return undefined; + const t = value.trim(); + return t.length > 0 ? t : undefined; + }; + + const options: MetricViewsSyncOptions = { + warehouseId: trimmed(warehouseId), + metricViewsJsonPath: trimmed(metricViewsJsonPath), + outputDir: trimmed(outputDir), + // Interactive runs use the default cache behavior (cache on); --no-cache is + // a non-interactive flag. + cache: true, + }; + + const s = spinner(); + await runMetricViewsSync(options, { + start: () => s.start("Describing metric views…"), + succeed: (msg) => s.stop(msg), + fail: () => s.stop("Failed."), + }); + + outro("Metric types synced."); +} + +/** + * Entry point for the `metric sync` action. Detection mirrors `plugin create`'s + * interactive-vs-non-interactive split, but keys on commander's per-option value + * SOURCE (`getOptionValueSource(name) === "cli"`) rather than presence: + * - NO user-provided flag (every option's source is `default`/absent) → + * interactive prompts. + * - ANY user-provided flag → the flag-driven (non-interactive) path. + * + * Keying on the `cli` source (not value presence) is deliberate: a + * `DATABRICKS_WAREHOUSE_ID` env default does NOT populate any CLI option, so it + * never forces non-interactive; and `--no-cache`'s default (`cache: true`, + * source `default`) is correctly ignored, while an explicit `--no-cache` + * (source `cli`) does select non-interactive. + */ +async function runMetricViewsSyncAction( + options: MetricViewsSyncOptions, + command: Command, +): Promise { + const FLAG_OPTION_NAMES = [ + "warehouseId", + "metricViewsJsonPath", + "outputDir", + "cache", + ] as const; + const hasUserFlag = FLAG_OPTION_NAMES.some( + (name) => command.getOptionValueSource(name) === "cli", + ); + + if (hasUserFlag) { + await runMetricViewsSync(options); + } else { + await runInteractive(); + } +} + +export const metricViewsSyncCommand = new Command("sync") + .description( + "Sync UC Metric View schemas: DESCRIBE every entry in metric-views.json, then emit metric.d.ts + metrics.metadata.json.", + ) + .option( + "--warehouse-id ", + "Databricks SQL Warehouse ID (overrides DATABRICKS_WAREHOUSE_ID env var)", + ) + .option( + "--metric-views-json-path ", + "Path to metric-views.json (default: config/queries/metric-views.json)", + ) + .option( + "--output-dir ", + "Output directory for metric.d.ts and metrics.metadata.json (default: shared/appkit-types)", + ) + .option("--no-cache", "Disable the metric type-generation cache") + .addHelpText( + "after", + ` +Run with no flags for an interactive prompt; pass any flag for non-interactive mode. + +Examples: + $ appkit mv sync + $ appkit mv sync --warehouse-id 1234abcd5678efgh + $ appkit mv sync --warehouse-id 1234abcd5678efgh --metric-views-json-path config/queries/metric-views.json + $ appkit mv sync --warehouse-id 1234abcd5678efgh --output-dir shared/appkit-types + $ appkit mv sync --warehouse-id 1234abcd5678efgh --no-cache + +Environment variables: + DATABRICKS_WAREHOUSE_ID SQL warehouse ID (used when --warehouse-id is omitted) + DATABRICKS_HOST Databricks workspace URL (consumed by the SDK)`, + ) + .action((opts: MetricViewsSyncOptions, command: Command) => + runMetricViewsSyncAction(opts, command).catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(EXIT_FAILURE); + }), + ); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts new file mode 100644 index 000000000..bd4d4fc27 --- /dev/null +++ b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "vitest"; +import { + formatMetricViewsSourceErrors, + humanizeMetricViewsPath, + validateMetricViewsSource, +} from "./validate-metric-views-source"; + +/** + * Fixtures are derived from the ACTUAL canonical schema + * (`packages/shared/src/schemas/metric-source.ts`) and the UC FQN grammar + * (`packages/shared/src/schemas/metric-fqn.ts`): + * - FQN: exactly three dot-separated segments; each segment may contain any + * character EXCEPT ASCII control chars (U+0000-U+001F), space (U+0020), + * forward slash, period, or DELETE (U+007F). Non-ASCII letters and hyphens + * are explicitly legal. + * - executor: enum "app_service_principal" (default) | "user". + * - metric key (record key): /^[a-zA-Z_][a-zA-Z0-9_]*$/. + * - root + entry objects are .strict() — unknown keys are rejected. + */ + +describe("validateMetricViewsSource", () => { + describe("accepts", () => { + test("a valid three-part FQN with the default executor", () => { + const result = validateMetricViewsSource({ + metricViews: { + revenue: { source: "main.analytics.customer_metrics" }, + }, + }); + expect(result.valid).toBe(true); + }); + + test('executor: "user"', () => { + const result = validateMetricViewsSource({ + metricViews: { + revenue: { source: "main.analytics.cm", executor: "user" }, + }, + }); + expect(result.valid).toBe(true); + }); + + test('executor: "app_service_principal"', () => { + const result = validateMetricViewsSource({ + metricViews: { + revenue: { + source: "main.analytics.cm", + executor: "app_service_principal", + }, + }, + }); + expect(result.valid).toBe(true); + }); + + test("FQN segments with non-ASCII letters and hyphens (UC delimited-identifier grammar)", () => { + const result = validateMetricViewsSource({ + metricViews: { + // café (combining acute), prod-data (hyphen), métricas (non-ASCII). + rev: { source: "café.prod-data.métricas" }, + }, + }); + expect(result.valid).toBe(true); + }); + + test("a $schema key alongside metricViews", () => { + const result = validateMetricViewsSource({ + $schema: + "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + metricViews: { revenue: { source: "main.a.cm" } }, + }); + expect(result.valid).toBe(true); + }); + + test("an empty metricViews map", () => { + const result = validateMetricViewsSource({ metricViews: {} }); + expect(result.valid).toBe(true); + }); + + test("a completely empty object (metricViews is optional)", () => { + const result = validateMetricViewsSource({}); + expect(result.valid).toBe(true); + }); + }); + + describe("rejects", () => { + test("an FQN segment containing a space", () => { + const result = validateMetricViewsSource({ + metricViews: { rev: { source: "main.bad name.cm" } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].path).toBe("metricViews.rev.source"); + }); + + test("an FQN segment containing a forward slash", () => { + const result = validateMetricViewsSource({ + metricViews: { rev: { source: "main.a/b.cm" } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + expect(result.errors[0].path).toBe("metricViews.rev.source"); + }); + + test("a two-part FQN (a literal dot inside what should be one segment)", () => { + const result = validateMetricViewsSource({ + metricViews: { rev: { source: "main.cm" } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + expect(result.errors[0].path).toBe("metricViews.rev.source"); + }); + + test("an unknown executor value", () => { + const result = validateMetricViewsSource({ + metricViews: { rev: { source: "main.a.cm", executor: "robot" } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + expect(result.errors[0].path).toBe("metricViews.rev.executor"); + }); + + test("an unknown entry key (entries are .strict())", () => { + const result = validateMetricViewsSource({ + metricViews: { rev: { source: "main.a.cm", ttl: 5 } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + // Unrecognized-keys issues attach to the containing object. + expect(result.errors[0].path).toBe("metricViews.rev"); + expect(result.errors[0].message.toLowerCase()).toContain("ttl"); + }); + + test("a metric key that is not a valid identifier", () => { + const result = validateMetricViewsSource({ + metricViews: { "1bad": { source: "main.a.cm" } }, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + expect(result.errors[0].path).toBe("metricViews.1bad"); + }); + + test("an unknown top-level key (root is .strict())", () => { + const result = validateMetricViewsSource({ + metricViews: {}, + unexpected: true, + }); + expect(result.valid).toBe(false); + if (result.valid) throw new Error("expected invalid"); + // Root-level issue → humanized as "(root)". + expect(result.errors[0].path).toBe("(root)"); + }); + }); +}); + +describe("humanizeMetricViewsPath", () => { + test("empty path renders as (root)", () => { + expect(humanizeMetricViewsPath([])).toBe("(root)"); + expect(humanizeMetricViewsPath(undefined)).toBe("(root)"); + }); + + test("nested object keys join with dots", () => { + expect(humanizeMetricViewsPath(["metricViews", "revenue", "source"])).toBe( + "metricViews.revenue.source", + ); + }); + + test("numeric segments render as bracket indices", () => { + expect(humanizeMetricViewsPath(["a", 0, "b"])).toBe("a[0].b"); + }); +}); + +describe("formatMetricViewsSourceErrors", () => { + test("renders each issue as an indented `path: message` line", () => { + const out = formatMetricViewsSourceErrors([ + { path: "metricViews.revenue.source", message: "Invalid string" }, + { path: "(root)", message: 'Unrecognized key: "foo"' }, + ]); + expect(out).toBe( + ' metricViews.revenue.source: Invalid string\n (root): Unrecognized key: "foo"', + ); + }); + + test("an empty issue list renders as an empty string", () => { + expect(formatMetricViewsSourceErrors([])).toBe(""); + }); +}); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts new file mode 100644 index 000000000..fd0aa8b0d --- /dev/null +++ b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts @@ -0,0 +1,79 @@ +import { + type MetricSource, + metricSourceSchema, +} from "../../../schemas/metric-source"; + +/** + * A single metric-source validation issue. `path` is a humanized property path + * (e.g. `metricViews.revenue.source`) suitable for direct CLI output, and + * `message` is the schema's own diagnostic. Mirrors the `SemanticIssue` shape + * used by the plugin-manifest validator so CLI output stays uniform across + * commands. + */ +export interface MetricViewsSourceIssue { + path: string; + message: string; +} + +/** Result of {@link validateMetricViewsSource}. */ +export type ValidateMetricViewsSourceResult = + | { valid: true; source: MetricSource } + | { valid: false; errors: MetricViewsSourceIssue[] }; + +/** + * Humanize a Zod issue path (array of object keys / array indices) into a + * single string like `metricViews.revenue.source`. Numeric segments render as + * `[n]`; an empty path (a root-level issue, e.g. an unrecognized top-level key) + * renders as `(root)`. Mirrors `humanizePath` in the plugin-manifest validator. + */ +export function humanizeMetricViewsPath( + path: ReadonlyArray | undefined, +): string { + if (!path || path.length === 0) return "(root)"; + + let out = ""; + for (const key of path) { + if (typeof key === "number") { + out += `[${key}]`; + } else { + const str = String(key); + out += out.length === 0 ? str : `.${str}`; + } + } + return out.length === 0 ? "(root)" : out; +} + +/** + * Validate a parsed `metric-views.json` object against the canonical + * {@link metricSourceSchema}. The schema is the single source of truth (it also + * backs the generated JSON schema and the type-generator runtime); this helper + * only adapts its `safeParse` result into a CLI-friendly issue list. + * + * On success the original input is returned (typed as {@link MetricSource}), + * not a re-emitted copy. + */ +export function validateMetricViewsSource( + obj: unknown, +): ValidateMetricViewsSourceResult { + const result = metricSourceSchema.safeParse(obj); + if (result.success) { + return { valid: true, source: result.data }; + } + const errors = result.error.issues.map((issue) => ({ + path: humanizeMetricViewsPath(issue.path as ReadonlyArray), + message: issue.message, + })); + return { valid: false, errors }; +} + +/** + * Format metric-source validation issues for CLI output. Each issue renders on + * its own line indented by two spaces as ` : `. Mirrors + * `formatValidationErrors` in the plugin-manifest validator so the two commands + * present schema errors identically. + */ +export function formatMetricViewsSourceErrors( + issues: MetricViewsSourceIssue[], +): string { + return issues.map((issue) => ` ${issue.path}: ${issue.message}`).join("\n"); +} diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index d03dd547a..b9576cc3d 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -25,4 +25,55 @@ declare module "@databricks/appkit/type-generator" { outFile: string; noCache?: boolean; }): Promise; + + /** Execution lane: `sp` (service principal) or `obo` (on-behalf-of). */ + type MetricLane = "sp" | "obo"; + + /** Per-metric schema captured by {@link syncMetricViewsTypes}. */ + interface MetricSchema { + key: string; + source: string; + lane: MetricLane; + measures: unknown[]; + dimensions: unknown[]; + degraded?: boolean; + } + + /** One per-entry DESCRIBE failure surfaced by the metric sync. */ + interface MetricSyncFailure { + key: string; + source: string; + reason: string; + transient: boolean; + } + + /** Result of {@link syncMetricViewsTypes}. */ + interface SyncMetricViewsTypesResult { + metricOutFile?: string; + metricMetadataOutFile?: string; + schemas: MetricSchema[]; + failures: MetricSyncFailure[]; + /** `true` when no metric-views.json was found — nothing was synced. */ + noConfig: boolean; + } + + /** + * Metric-only sync entry: read metric-views.json from `queryFolder`, DESCRIBE + * every entry (minus clean cache hits), and write `metric.d.ts` + + * `metrics.metadata.json`. Does NOT generate analytics query types. Backs + * `appkit mv sync`. `cache` defaults to ON; only `cache === false` (the + * CLI's `--no-cache`) disables the shared metric type-generation cache. + */ + export function syncMetricViewsTypes(options: { + queryFolder: string; + warehouseId: string; + metricOutFile: string; + metricMetadataOutFile: string; + cache?: boolean; + }): Promise; + + /** Default filename for the generated MetricRegistry declarations. */ + export const METRIC_TYPES_FILE: string; + /** Default filename for the build-time semantic-metadata JSON bundle. */ + export const METRIC_METADATA_FILE: string; } diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index aa60157c8..764c90c1a 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -8,6 +8,7 @@ import { codemodCommand } from "./commands/codemod/index.js"; import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; +import { metricViewsCommand } from "./commands/metric-views/index.js"; import { pluginCommand } from "./commands/plugin/index.js"; import { setupCommand } from "./commands/setup.js"; @@ -28,5 +29,6 @@ cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); +cmd.addCommand(metricViewsCommand); await cmd.parseAsync(); From 55d58996969ba71ed88e7e5908eb51cf1c499ce8 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 24 Jun 2026 10:44:58 +0200 Subject: [PATCH 02/13] refactor(shared): rename humanizeMetricViewsPath to formatMetricViewsPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "format" describes what the helper does — render a Zod issue path as a CLI string — without the "humanize" framing it borrowed from the plugin-manifest validator's humanizePath. Update its caller, tests, and doc comments to match. Also trim redundant comments in the type-generator .d.ts shim and align the syncMetricViewsTypes doc with the appkit source. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../commands/metric-views/sync/sync.test.ts | 2 +- .../validate-metric-views-source.test.ts | 14 ++++++------- .../validate-metric-views-source.ts | 20 +++++++++---------- .../src/cli/commands/type-generator.d.ts | 20 +++++++++---------- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts index bbd8b183b..97b20d136 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts @@ -429,7 +429,7 @@ describe("appkit mv sync", () => { expect(exitSpy).toHaveBeenCalledWith(1); const errored = erroredText(); expect(errored).toContain("invalid"); - // Humanized path of the failing field. + // CLI path of the failing field. expect(errored).toContain("metricViews.revenue.source"); }); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts index bd4d4fc27..f6b88c9d4 100644 --- a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts +++ b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { + formatMetricViewsPath, formatMetricViewsSourceErrors, - humanizeMetricViewsPath, validateMetricViewsSource, } from "./validate-metric-views-source"; @@ -145,26 +145,26 @@ describe("validateMetricViewsSource", () => { }); expect(result.valid).toBe(false); if (result.valid) throw new Error("expected invalid"); - // Root-level issue → humanized as "(root)". + // Root-level issues render as "(root)". expect(result.errors[0].path).toBe("(root)"); }); }); }); -describe("humanizeMetricViewsPath", () => { +describe("formatMetricViewsPath", () => { test("empty path renders as (root)", () => { - expect(humanizeMetricViewsPath([])).toBe("(root)"); - expect(humanizeMetricViewsPath(undefined)).toBe("(root)"); + expect(formatMetricViewsPath([])).toBe("(root)"); + expect(formatMetricViewsPath(undefined)).toBe("(root)"); }); test("nested object keys join with dots", () => { - expect(humanizeMetricViewsPath(["metricViews", "revenue", "source"])).toBe( + expect(formatMetricViewsPath(["metricViews", "revenue", "source"])).toBe( "metricViews.revenue.source", ); }); test("numeric segments render as bracket indices", () => { - expect(humanizeMetricViewsPath(["a", 0, "b"])).toBe("a[0].b"); + expect(formatMetricViewsPath(["a", 0, "b"])).toBe("a[0].b"); }); }); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts index fd0aa8b0d..5ff0f0ae3 100644 --- a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts +++ b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts @@ -4,11 +4,10 @@ import { } from "../../../schemas/metric-source"; /** - * A single metric-source validation issue. `path` is a humanized property path - * (e.g. `metricViews.revenue.source`) suitable for direct CLI output, and - * `message` is the schema's own diagnostic. Mirrors the `SemanticIssue` shape - * used by the plugin-manifest validator so CLI output stays uniform across - * commands. + * A single metric-source validation issue. `path` is formatted for direct CLI + * output (e.g. `metricViews.revenue.source`), and `message` is the schema's own + * diagnostic. Mirrors the `SemanticIssue` shape used by the plugin-manifest + * validator so CLI output stays uniform across commands. */ export interface MetricViewsSourceIssue { path: string; @@ -21,12 +20,11 @@ export type ValidateMetricViewsSourceResult = | { valid: false; errors: MetricViewsSourceIssue[] }; /** - * Humanize a Zod issue path (array of object keys / array indices) into a - * single string like `metricViews.revenue.source`. Numeric segments render as - * `[n]`; an empty path (a root-level issue, e.g. an unrecognized top-level key) - * renders as `(root)`. Mirrors `humanizePath` in the plugin-manifest validator. + * Format a Zod issue path (array of object keys / array indices) as a CLI path + * like `metricViews.revenue.source`. Numeric segments render as `[n]`; an empty + * path (a root-level issue, e.g. an unrecognized top-level key) renders as `(root)`. */ -export function humanizeMetricViewsPath( +export function formatMetricViewsPath( path: ReadonlyArray | undefined, ): string { if (!path || path.length === 0) return "(root)"; @@ -60,7 +58,7 @@ export function validateMetricViewsSource( return { valid: true, source: result.data }; } const errors = result.error.issues.map((issue) => ({ - path: humanizeMetricViewsPath(issue.path as ReadonlyArray), + path: formatMetricViewsPath(issue.path as ReadonlyArray), message: issue.message, })); return { valid: false, errors }; diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index b9576cc3d..155ec2ce5 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -1,4 +1,3 @@ -// Type declarations for optional @databricks/appkit/type-generator module declare module "@databricks/appkit/type-generator" { export function generateFromEntryPoint(options: { queryFolder?: string; @@ -26,7 +25,6 @@ declare module "@databricks/appkit/type-generator" { noCache?: boolean; }): Promise; - /** Execution lane: `sp` (service principal) or `obo` (on-behalf-of). */ type MetricLane = "sp" | "obo"; /** Per-metric schema captured by {@link syncMetricViewsTypes}. */ @@ -53,16 +51,20 @@ declare module "@databricks/appkit/type-generator" { metricMetadataOutFile?: string; schemas: MetricSchema[]; failures: MetricSyncFailure[]; - /** `true` when no metric-views.json was found — nothing was synced. */ + // `true` when no metric-views.json was found — nothing was synced. noConfig: boolean; } /** - * Metric-only sync entry: read metric-views.json from `queryFolder`, DESCRIBE - * every entry (minus clean cache hits), and write `metric.d.ts` + - * `metrics.metadata.json`. Does NOT generate analytics query types. Backs - * `appkit mv sync`. `cache` defaults to ON; only `cache === false` (the - * CLI's `--no-cache`) disables the shared metric type-generation cache. + * Generate the metric-view type artifacts used by `appkit mv sync`. + * + * Reads `metric-views.json` from `queryFolder`, DESCRIBEs any metric views + * that are missing from the cache, then writes `metric.d.ts` and + * `metrics.metadata.json`. This only syncs metric-view types; analytics query + * types are generated separately. + * + * The cache is enabled by default. Pass `cache: false` to force fresh + * DESCRIBE results, matching the CLI's `--no-cache` flag. */ export function syncMetricViewsTypes(options: { queryFolder: string; @@ -72,8 +74,6 @@ declare module "@databricks/appkit/type-generator" { cache?: boolean; }): Promise; - /** Default filename for the generated MetricRegistry declarations. */ export const METRIC_TYPES_FILE: string; - /** Default filename for the build-time semantic-metadata JSON bundle. */ export const METRIC_METADATA_FILE: string; } From a7063ba9320877b3c2ad35ceae6231c35ad5f857 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 25 Jun 2026 08:46:06 +0200 Subject: [PATCH 03/13] chore(shared): warn when mv sync writes degraded metric types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appkit mv sync` runs in describe-now mode, where a not-ready (cold or starting) warehouse can return no schema for a metric view WITHOUT a hard DESCRIBE failure: the appkit export writes permissive (degraded) types and reports them via `schemas[].degraded` with an empty `failures` list. The CLI only inspected `failures`, so this path fell through to the success message and exited 0 with no signal, silently shipping permissive `unknown` types. Add a degraded-schema check after the failures gate: name the affected views, tell the user to rerun once the warehouse is available, and exit 0 — a not-ready warehouse is transient, not a hard failure (genuine DESCRIBE failures still exit non-zero, unchanged). Also align the ambient `@databricks/appkit/type-generator` shim's `SyncMetricViewsTypesResult` with the real export by adding `fatalErrors` (always empty for the CLI's describe-now mode; declared to keep the cross-package contract honest). Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../commands/metric-views/sync/sync.test.ts | 59 ++++++++++++++++++- .../cli/commands/metric-views/sync/sync.ts | 18 ++++++ .../src/cli/commands/type-generator.d.ts | 4 ++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts index 97b20d136..4aeadf073 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts @@ -36,9 +36,12 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = // Annotate the array element types so the inferred return type is wide // enough for `mockResolvedValueOnce` overrides that populate `failures` // (an empty literal would otherwise infer `never[]`). - const schemas: Array<{ key: string; source: string; lane: string }> = [ - { key: "revenue", source: "demo.sales.revenue", lane: "sp" }, - ]; + const schemas: Array<{ + key: string; + source: string; + lane: string; + degraded?: boolean; + }> = [{ key: "revenue", source: "demo.sales.revenue", lane: "sp" }]; const failures: Array<{ key: string; source: string; @@ -133,6 +136,7 @@ describe("appkit mv sync", () => { let queryFolder: string; let consoleLog: Mock; let consoleError: Mock; + let consoleWarn: Mock; let originalCwd: string; const prevWarehouse = process.env.DATABRICKS_WAREHOUSE_ID; @@ -155,6 +159,9 @@ describe("appkit mv sync", () => { consoleError = vi .spyOn(console, "error") .mockImplementation(() => {}) as Mock; + consoleWarn = vi + .spyOn(console, "warn") + .mockImplementation(() => {}) as Mock; }); afterEach(() => { @@ -361,6 +368,8 @@ describe("appkit mv sync", () => { const erroredText = () => consoleError.mock.calls.flat().map(String).join("\n"); + const warnedText = () => consoleWarn.mock.calls.flat().map(String).join("\n"); + test("explicit --metric-views-json-path to a missing file: non-zero + recognizable message", async () => { const missing = path.join(tmpRoot, "nowhere", "metric-views.json"); @@ -534,6 +543,50 @@ describe("appkit mv sync", () => { expect(errored).toContain("TABLE_OR_VIEW_NOT_FOUND"); }); + test("degraded-but-not-failed (warehouse not ready): warns and exits 0 with permissive types", async () => { + writeConfig(); + // A not-ready warehouse returns no schema for a key WITHOUT a hard failure: + // `syncMetricViewsTypes` writes permissive (degraded) types and reports them + // via `schemas[].degraded` with an empty `failures` list. Unlike a per-entry + // DESCRIBE failure, the CLI must treat this as a WARNING (exit 0), not a hard + // failure — the entries refresh on a rerun once the warehouse is available. + syncMetricViewsTypes.mockResolvedValueOnce({ + metricOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric.d.ts", + ), + metricMetadataOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metrics.metadata.json", + ), + schemas: [ + { + key: "revenue", + source: "demo.sales.revenue", + lane: "sp", + degraded: true, + }, + ], + failures: [], + noConfig: false, + }); + + const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); + // Degraded is a warning, NOT a hard failure — the command exits 0. + expect(exitSpy).not.toHaveBeenCalled(); + const warned = warnedText(); + expect(warned).toContain("could not be described"); + expect(warned).toContain("revenue"); + expect(warned).toContain("permissive"); + expect(warned).toContain("Rerun"); + }); + // --- Phase 3: interactive flow ---------------------------------------------- test("no flags → interactive: prompts fire and resolved values reach syncMetricViewsTypes", async () => { diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts index 677a986ed..282b2ab00 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.ts @@ -251,6 +251,24 @@ async function runMetricViewsSync( return; } + // Degraded-but-not-failed (e.g. a not-ready warehouse returned no schema for + // some keys): the permissive types ARE written, so unlike `result.failures` + // above this is a warning — not a hard failure — and the command still exits + // 0. The degraded entries refresh on a rerun once the warehouse is available. + const degradedKeys = result.schemas + .filter((schema) => schema.degraded) + .map((schema) => schema.key); + if (degradedKeys.length > 0) { + onProgress?.succeed( + `Generated permissive metric types: ${metricOutFile}`, + ); + console.warn( + `Warning: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described — the warehouse wasn't ready, so permissive types were written. Rerun \`appkit mv sync\` once the warehouse is available.`, + ); + console.log(`Generated metric metadata: ${metricMetadataOutFile}`); + return; + } + onProgress?.succeed(`Generated metric types: ${metricOutFile}`); console.log(`Generated metric types: ${metricOutFile}`); console.log(`Generated metric metadata: ${metricMetadataOutFile}`); diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 155ec2ce5..1782a495b 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -53,6 +53,10 @@ declare module "@databricks/appkit/type-generator" { failures: MetricSyncFailure[]; // `true` when no metric-views.json was found — nothing was synced. noConfig: boolean; + // Per-key fatal preflight errors. Always empty for `mv sync` (the CLI uses + // the default `describe-now` mode); populated only by the dev/Vite blocking + // path. Declared to match the real export's result contract. + fatalErrors: Array<{ name: string; message: string }>; } /** From 58f125ea9e4aab3b18391d554e7d40aa26f17ecd Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 25 Jun 2026 12:15:39 +0200 Subject: [PATCH 04/13] chore: rename metric typegen artifacts to metric-views.* Rename the generated metric-view artifacts for naming consistency with the `metric-views.json` source config (and to fix the metric/metrics singular-vs- plural split): - METRIC_TYPES_FILE: metric.d.ts -> metric-views.d.ts - METRIC_METADATA_FILE: metrics.metadata.json -> metric-views.metadata.json Touches the constants, the dev/Vite generate path, the ambient type-generator shim, the shared `mv sync` CLI, and all test assertions across appkit + shared. The Metric Views feature is pre-release (not shipped), so no migration is needed. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 12 +++---- .../mv-registry/render-types.ts | 2 +- .../src/type-generator/tests/index.test.ts | 10 +++--- .../type-generator/tests/mv-registry.test.ts | 4 +-- .../tests/sync-metric-views-types.test.ts | 13 +++++--- .../type-generator/tests/vite-plugin.test.ts | 11 ++++--- .../commands/metric-views/sync/sync.test.ts | 32 +++++++++---------- .../cli/commands/metric-views/sync/sync.ts | 6 ++-- .../src/cli/commands/type-generator.d.ts | 4 +-- 9 files changed, 51 insertions(+), 43 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 83fe4def5..bd78cdf9a 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -277,10 +277,10 @@ 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`). + * build-time semantic metadata JSON bundle (`metric-views.metadata.json`). * Defaults to a sibling of `mvOutFile`. Skipped entirely if * `metric-views.json` is absent. * @param options.metricFetcher - optional DescribeFetcher used by @@ -421,8 +421,8 @@ export interface SyncMetricViewsTypesResult { * → partition cache hits vs describe-needed → optional warehouse preflight / * #406 status gate → describe ({@link syncMetrics} over * {@link createWorkspaceDescribeFetcher}) → persist + prune the `metrics` - * cache section → merge → write `metric.d.ts` - * ({@link generateMetricTypeDeclarations}) and `metrics.metadata.json` + * cache section → merge → write `metric-views.d.ts` + * ({@link generateMetricTypeDeclarations}) and `metric-views.metadata.json` * ({@link generateMetricsMetadataJson}). * * The shared typegen cache (the `metrics` section of `.appkit-types-cache.json`, @@ -860,7 +860,7 @@ 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"; +export const METRIC_TYPES_FILE = "metric-views.d.ts"; /** * Default filename for the build-time semantic-metadata JSON bundle, sibling of * {@link METRIC_TYPES_FILE}. Shape is `Record { 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 metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); const metadataFile = path.join( metricsDir, "generated", - "metrics.metadata.json", + "metric-views.metadata.json", ); const describeResponse: DatabricksStatementExecutionResponse = { @@ -330,7 +330,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 and metric-views.metadata.json when metric-views.json exists", async () => { writeMetricConfig(); await expect( @@ -998,11 +998,11 @@ 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 metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); const metadataFile = path.join( cacheTestDir, "generated", - "metrics.metadata.json", + "metric-views.metadata.json", ); const describeResponseFor = ( 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..ae074014c 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -2135,7 +2135,7 @@ describe("buildMetricsMetadataBundle", () => { expect(Object.hasOwn(bundle, "__proto__")).toBe(true); expect(Object.hasOwn(bundle, "revenue")).toBe(true); - // The emitted metrics.metadata.json carries both as own enumerable + // The emitted metric-views.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"]); @@ -2264,7 +2264,7 @@ describe("generateMetricsMetadataJson — snapshot", () => { // collation would interleave mixed-case keys ("ARPU", "churn", "Revenue") // and could vary by machine/locale, drifting the .d.ts from the bundle. 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 identically (code-unit) in metric-views.d.ts and metric-views.metadata.json", async () => { const resolution = resolveMetricConfig({ metricViews: { Revenue: { source: "a.b.r" }, diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index dae4ec3af..3037f0036 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -123,12 +123,17 @@ describe("syncMetricViewsTypes", () => { 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.d.ts"); + metricOutFile = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); metricMetadataOutFile = path.join( tmpRoot, "shared", "appkit-types", - "metrics.metadata.json", + "metric-views.metadata.json", ); }); @@ -161,7 +166,7 @@ describe("syncMetricViewsTypes", () => { expect(result.metricOutFile).toBe(metricOutFile); expect(result.metricMetadataOutFile).toBe(metricMetadataOutFile); - // --- metric.d.ts: MetricRegistry augmentation for both metrics --- + // --- 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"'); @@ -176,7 +181,7 @@ describe("syncMetricViewsTypes", () => { // The TIMESTAMP dimension carries inferred time grains in its @timeGrain tag. expect(declarations).toContain("@timeGrain"); - // --- metrics.metadata.json: per-metric semantic bundle --- + // --- metric-views.metadata.json: per-metric semantic bundle --- const bundle = JSON.parse(fs.readFileSync(metricMetadataOutFile, "utf-8")); // SP metric: currency format spec is preserved on the measure. expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); 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..b93a96181 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -384,8 +384,8 @@ describe("appKitTypesPlugin — metric option plumbing", () => { test("custom mvOutFile/mvMetadataOutFile reach generateFromEntryPoint", async () => { const plugin = appKitTypesPlugin({ - mvOutFile: "custom/types/metric.d.ts", - mvMetadataOutFile: "custom/types/metrics.metadata.json", + mvOutFile: "custom/types/metric-views.d.ts", + mvMetadataOutFile: "custom/types/metric-views.metadata.json", }); getHook( plugin, @@ -398,10 +398,13 @@ describe("appKitTypesPlugin — metric option plumbing", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ - mvOutFile: path.resolve(process.cwd(), "custom/types/metric.d.ts"), + mvOutFile: path.resolve( + process.cwd(), + "custom/types/metric-views.d.ts", + ), mvMetadataOutFile: path.resolve( process.cwd(), - "custom/types/metrics.metadata.json", + "custom/types/metric-views.metadata.json", ), }), ); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts index 4aeadf073..5108fc534 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts @@ -31,7 +31,7 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = nodeFs.mkdirSync(nodePath.dirname(opts.metricOutFile), { recursive: true, }); - nodeFs.writeFileSync(opts.metricOutFile, "// metric.d.ts\n"); + nodeFs.writeFileSync(opts.metricOutFile, "// metric-views.d.ts\n"); nodeFs.writeFileSync(opts.metricMetadataOutFile, "{}\n"); // Annotate the array element types so the inferred return type is wide // enough for `mockResolvedValueOnce` overrides that populate `failures` @@ -57,8 +57,8 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = }; }, ), - METRIC_TYPES_FILE: "metric.d.ts", - METRIC_METADATA_FILE: "metrics.metadata.json", + METRIC_TYPES_FILE: "metric-views.d.ts", + METRIC_METADATA_FILE: "metric-views.metadata.json", })); // The library type-generator is an optional/ambient module; mock it so the @@ -195,13 +195,13 @@ describe("appkit mv sync", () => { tmpRoot, "shared", "appkit-types", - "metric.d.ts", + "metric-views.d.ts", ); const expectedMetadataOut = path.join( tmpRoot, "shared", "appkit-types", - "metrics.metadata.json", + "metric-views.metadata.json", ); // The appkit entry was called once with the resolved options. Cache is the @@ -254,12 +254,12 @@ describe("appkit mv sync", () => { expect(syncMetricViewsTypes).toHaveBeenCalledWith({ queryFolder: customConfigDir, warehouseId: "wh-123", - metricOutFile: path.join(tmpRoot, "build", "types", "metric.d.ts"), + metricOutFile: path.join(tmpRoot, "build", "types", "metric-views.d.ts"), metricMetadataOutFile: path.join( tmpRoot, "build", "types", - "metrics.metadata.json", + "metric-views.metadata.json", ), cache: undefined, }); @@ -287,8 +287,8 @@ describe("appkit mv sync", () => { expect(syncMetricViewsTypes).toHaveBeenCalledWith( expect.objectContaining({ queryFolder: path.dirname(absConfig), - metricOutFile: path.join(absOut, "metric.d.ts"), - metricMetadataOutFile: path.join(absOut, "metrics.metadata.json"), + metricOutFile: path.join(absOut, "metric-views.d.ts"), + metricMetadataOutFile: path.join(absOut, "metric-views.metadata.json"), }), ); }); @@ -513,13 +513,13 @@ describe("appkit mv sync", () => { tmpRoot, "shared", "appkit-types", - "metric.d.ts", + "metric-views.d.ts", ), metricMetadataOutFile: path.join( tmpRoot, "shared", "appkit-types", - "metrics.metadata.json", + "metric-views.metadata.json", ), schemas: [], failures: [ @@ -555,13 +555,13 @@ describe("appkit mv sync", () => { tmpRoot, "shared", "appkit-types", - "metric.d.ts", + "metric-views.d.ts", ), metricMetadataOutFile: path.join( tmpRoot, "shared", "appkit-types", - "metrics.metadata.json", + "metric-views.metadata.json", ), schemas: [ { @@ -614,13 +614,13 @@ describe("appkit mv sync", () => { tmpRoot, "shared", "appkit-types", - "metric.d.ts", + "metric-views.d.ts", ), metricMetadataOutFile: path.join( tmpRoot, "shared", "appkit-types", - "metrics.metadata.json", + "metric-views.metadata.json", ), cache: true, }); @@ -645,7 +645,7 @@ describe("appkit mv sync", () => { expect.objectContaining({ queryFolder: customConfigDir, warehouseId: "wh-interactive", - metricOutFile: path.join(tmpRoot, "alt", "out", "metric.d.ts"), + metricOutFile: path.join(tmpRoot, "alt", "out", "metric-views.d.ts"), }), ); }); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts index 282b2ab00..514ee12a7 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.ts @@ -31,7 +31,7 @@ export interface MetricViewsSyncOptions { * locked spec. */ metricViewsJsonPath?: string; - /** Output directory for metric.d.ts + metrics.metadata.json (default: /shared/appkit-types). */ + /** Output directory for metric-views.d.ts + metric-views.metadata.json (default: /shared/appkit-types). */ outputDir?: string; /** * Caching toggle. Commander's `--no-cache` sets this to `false` (and leaves it @@ -401,7 +401,7 @@ async function runMetricViewsSyncAction( export const metricViewsSyncCommand = new Command("sync") .description( - "Sync UC Metric View schemas: DESCRIBE every entry in metric-views.json, then emit metric.d.ts + metrics.metadata.json.", + "Sync UC Metric View schemas: DESCRIBE every entry in metric-views.json, then emit metric-views.d.ts + metric-views.metadata.json.", ) .option( "--warehouse-id ", @@ -413,7 +413,7 @@ export const metricViewsSyncCommand = new Command("sync") ) .option( "--output-dir ", - "Output directory for metric.d.ts and metrics.metadata.json (default: shared/appkit-types)", + "Output directory for metric-views.d.ts and metric-views.metadata.json (default: shared/appkit-types)", ) .option("--no-cache", "Disable the metric type-generation cache") .addHelpText( diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 1782a495b..23f80d534 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -63,8 +63,8 @@ declare module "@databricks/appkit/type-generator" { * Generate the metric-view type artifacts used by `appkit mv sync`. * * Reads `metric-views.json` from `queryFolder`, DESCRIBEs any metric views - * that are missing from the cache, then writes `metric.d.ts` and - * `metrics.metadata.json`. This only syncs metric-view types; analytics query + * that are missing from the cache, then writes `metric-views.d.ts` and + * `metric-views.metadata.json`. This only syncs metric-view types; analytics query * types are generated separately. * * The cache is enabled by default. Pass `cache: false` to force fresh From acf851bd14b213195c55f4eb80112d736888b5c3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 25 Jun 2026 13:52:58 +0200 Subject: [PATCH 05/13] chore: cleanup tsdoc comments --- packages/appkit/src/type-generator/index.ts | 167 ++++-------------- .../src/cli/commands/metric-views/index.ts | 7 +- .../cli/commands/metric-views/sync/sync.ts | 64 +------ 3 files changed, 44 insertions(+), 194 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index bd78cdf9a..118b50df7 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -46,7 +46,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 +78,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)}`, @@ -363,14 +362,10 @@ export async function generateFromEntryPoint(options: { } } - // One-time migration: remove old generated file and patch project configs await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); await migrateProjectConfig(projectRoot); - // Types are always written above — including `result: unknown` for any query - // that could not be described. Connectivity failures pass silently so a - // transient warehouse outage never blocks a build; genuine SQL errors and - // non-connectivity fatal request failures surface after the file write. + // 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); } @@ -387,13 +382,9 @@ export async function generateFromEntryPoint(options: { * report what happened and decide its exit code. */ export interface SyncMetricViewsTypesResult { - /** Absolute path the MetricRegistry `.d.ts` was written to (undefined when no config). */ metricOutFile?: string; - /** Absolute path the semantic-metadata JSON bundle was written to (undefined when no config). */ metricMetadataOutFile?: string; - /** Schemas emitted, one per configured metric key (empty when no config). */ schemas: MetricSchema[]; - /** Per-entry DESCRIBE failures surfaced by {@link syncMetrics}. */ failures: MetricSyncFailure[]; /** * `true` when no `metric-views.json` was found in the query folder, so nothing @@ -415,58 +406,23 @@ export interface SyncMetricViewsTypesResult { * CLI (default `"describe-now"` mode) and {@link generateFromEntryPoint}'s * metric section (which forwards its dev `"non-blocking"`/`"blocking"` mode). * - * It does the focused metric pipeline ONLY — it never describes analytics - * queries and never writes `analytics.d.ts` / `serving.d.ts`. The pipeline: - * read config ({@link readMetricConfig}) → resolve ({@link resolveMetricConfig}) - * → partition cache hits vs describe-needed → optional warehouse preflight / - * #406 status gate → describe ({@link syncMetrics} over - * {@link createWorkspaceDescribeFetcher}) → persist + prune the `metrics` - * cache section → merge → write `metric-views.d.ts` - * ({@link generateMetricTypeDeclarations}) and `metric-views.metadata.json` - * ({@link generateMetricsMetadataJson}). - * - * 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 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"` (default, the CLI): no preflight, no #406 status probe — - * DESCRIBE every key that isn't a clean cache hit. The hit predicate is - * STRICTER here: a degraded/sticky cached entry is NEVER served (it is - * re-described), so a focused `mv sync` always converges to correct types, - * and the sticky-degraded notice never fires (nothing degraded is served). - * - `"non-blocking"` (dev/Vite default): honor the #406 contract — one - * status-only probe, DESCRIBE only when the warehouse is already RUNNING, - * else emit degraded artifacts immediately. Degraded cache hits ARE served - * (last-known-good) and surfaced via the sticky-degraded notice. - * - `"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. + * - `"describe-now"` (default, the CLI): no preflight, no status probe — DESCRIBE every key that isn't a clean cache hit. The hit predicate is STRICTER here: a degraded/sticky cached entry is NEVER served (it is re-described), so a focused `mv sync` always converges to correct types, and the sticky-degraded notice never fires (nothing degraded is served). + * - `"non-blocking"` (dev/Vite default): one status-only probe, DESCRIBE only when the warehouse is already RUNNING, else emit degraded artifacts immediately. Degraded cache hits ARE served (last-known-good) and surfaced via the sticky-degraded notice. + * - `"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. + * 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.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.metricMetadataOutFile - output path for the semantic-metadata - * JSON bundle. - * @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.metricMetadataOutFile - output path for the semantic-metadata JSON bundle. + * @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; the CLI omits it (taking `"describe-now"`), - * {@link generateFromEntryPoint} forwards its own {@link PreflightMode}. + * @param options.mode - preflight/gate policy, default `"describe-now"`. See above; the CLI omits it (taking `"describe-now"`), {@link generateFromEntryPoint} forwards its own {@link PreflightMode}. */ export async function syncMetricViewsTypes(options: { queryFolder: string; @@ -502,13 +458,7 @@ export async function syncMetricViewsTypes(options: { const fatalErrors: Array<{ name: string; message: string }> = []; - // Load the shared typegen cache and copy its `metrics` section into a - // null-prototype map. Metric keys are user-controlled config and - // "__proto__"/"constructor" pass the metric key regex — a null prototype - // keeps a malicious/edge key from hitting an Object.prototype setter on write - // or resolving inherited names as phantom entries on read. With `noCache`, the - // section starts empty (every entry describe-needed) and is overwritten on - // save below. + // 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) { @@ -517,12 +467,9 @@ export async function syncMetricViewsTypes(options: { } } - // Dev modes (`non-blocking`/`blocking`) serve degraded cache hits as - // last-known-good — exactly like queries degrade to cached types — and - // surface them via the sticky-degraded notice. `describe-now` (the CLI) is an - // explicit "make my types correct now" action, so it NEVER serves a - // degraded/sticky entry: that entry is re-described instead, and no degraded - // hit is served, so the notice never fires. + // Dev modes (`non-blocking`/`blocking`) serve degraded cache hits as last-known-good — exactly like queries degrade to cached types — + // and surface them via the sticky-degraded notice. `describe-now` (the CLI) is an explicit "make my types correct now" action, + // so it NEVER serves a degraded/sticky entry: that entry is re-described instead, and no degraded hit is served, so the notice never fires. const serveDegraded = mode !== "describe-now"; // Partition BEFORE any gate/preflight decision: a hit (structurally valid @@ -563,22 +510,14 @@ export async function syncMetricViewsTypes(options: { ); } - // 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). - // 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). + // 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" && @@ -600,8 +539,7 @@ export async function syncMetricViewsTypes(options: { }); 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. + // DELETED/DELETING — the warehouse was deleted while we waited. preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; } } else if (decision === "waitThenProceed") { @@ -609,35 +547,25 @@ export async function syncMetricViewsTypes(options: { 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. + // Deleted mid-wait: fatal. 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. + // a not-ready / unreachable warehouse rather than throwing. if (!isConnectivityError(err)) { preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } } } - // Honor the non-blocking preflight contract (#406) for metric DESCRIBEs: a + // 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 — 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. `describe-now` and `blocking` both - // start `describeNow = true` (`mode !== "non-blocking"`), so this gate is - // skipped for them — `describe-now` describes directly, `blocking` already ran - // its preflight above. + // 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 || @@ -703,9 +631,7 @@ export async function syncMetricViewsTypes(options: { } // 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). + // 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)) @@ -720,11 +646,8 @@ export async function syncMetricViewsTypes(options: { } } 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. (Only reachable in - // `non-blocking` mode.) + // describe-needed key as a degraded schema 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( @@ -737,23 +660,13 @@ export async function syncMetricViewsTypes(options: { // 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`. In `describe-now` there is no preflight/gate, so - // `terminalSkip` is always false and this reduces to "retry a degraded outcome - // unless it was a deterministic DESCRIBE failure" — a deterministic failure - // won't loop forever, and the stricter hit rule re-describes it next run - // anyway. One save per pass; with `noCache` the section started empty, so it's - // overwritten. + // warehouse-down pass keeps last-known-good entries. 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]. + // syncMetrics 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] = { @@ -766,8 +679,7 @@ export async function syncMetricViewsTypes(options: { }; } - // Prune entries whose key is no longer configured so a removed metric doesn't - // haunt the cache file forever. + // 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)) { @@ -777,17 +689,13 @@ export async function syncMetricViewsTypes(options: { } } - // 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. With `noCache` the section started empty, so it's overwritten. + // Save when this pass produced outcomes, bypassed the cache, or pruned. if (describeNeeded.length > 0 || noCache || prunedCount > 0) { cache.metrics = mvCacheSection; await saveCache(cache); } - // Merge cached hits with fresh results back into config order (renderers sort - // internally where determinism matters). Every describe-needed entry yields - // exactly one schema above, so the final fallback is defensive only. + // Merge cached hits with fresh results back into config order. const describedByKey = new Map(); for (const schema of described) { describedByKey.set(schema.key, schema); @@ -795,11 +703,6 @@ export async function syncMetricViewsTypes(options: { const schemas = 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. logger.warn( "no schema resolved for metric key %s — emitting degraded types (should not happen)", entry.key, @@ -853,13 +756,9 @@ 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-views.d.ts"; /** * Default filename for the build-time semantic-metadata JSON bundle, sibling of diff --git a/packages/shared/src/cli/commands/metric-views/index.ts b/packages/shared/src/cli/commands/metric-views/index.ts index 7e03dd2fc..334176003 100644 --- a/packages/shared/src/cli/commands/metric-views/index.ts +++ b/packages/shared/src/cli/commands/metric-views/index.ts @@ -4,10 +4,9 @@ import { metricViewsSyncCommand } from "./sync/sync"; /** * Parent command for UC Metric View operations. * - * Phase 1 exposes a single subcommand (`sync`). Future subcommands - * (`list` / `validate` / `describe`) plug in here so users have one top-level - * surface for everything related to Metric Views. Sibling of `plugin`, - * `setup`, `generate-types`, `lint`, `docs`, `codemod`. + * Exposes a single subcommand (`sync`). + * Future subcommands (`list` / `validate` / `describe`) plug in here so users have one top-level surface for everything related to Metric Views. + * Sibling of `plugin`, `setup`, `generate-types`, `lint`, `docs`, `codemod`. */ export const metricViewsCommand = new Command("mv") .description("Metric-view management commands (UC Metric Views)") diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts index 514ee12a7..438bafeee 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.ts @@ -11,47 +11,21 @@ import { /** * Options parsed by commander for `appkit mv sync`. * - * Phase 3 locks the flag surface to exactly four options and adds the * interactive clack flow: * - `--warehouse-id` (+ `DATABRICKS_WAREHOUSE_ID` fallback) * - `--metric-views-json-path` (canonical config path) * - `--output-dir` (artifact output directory; replaces Phase 1's `--out-dir`) - * - `--no-cache` (commander negation → `cache === false` disables the - * metric type-generation cache) - * - * Phase 1's interim `--root-dir` is dropped: relative `--metric-views-json-path` - * / `--output-dir` resolve against `process.cwd()`, mirroring how - * `generate-types` anchors its defaults at the current directory. + * - `--no-cache` (commander negation → `cache === false` disables the metric type-generation cache) */ export interface MetricViewsSyncOptions { warehouseId?: string; - /** - * Path to metric-views.json. Default: - * `/config/queries/metric-views.json`. Canonical flag name in the - * locked spec. - */ metricViewsJsonPath?: string; - /** Output directory for metric-views.d.ts + metric-views.metadata.json (default: /shared/appkit-types). */ outputDir?: string; - /** - * Caching toggle. Commander's `--no-cache` sets this to `false` (and leaves it - * `true`/absent otherwise); `cache === false` is the single signal that - * disables the metric type-generation cache when forwarded to - * `syncMetricViewsTypes`. - */ cache?: boolean; } -/** Default filename for the metric source config (post-#433 name). */ const METRIC_VIEWS_CONFIG_FILE = "metric-views.json"; -/** - * Non-zero exit code for `appkit mv sync` failure modes. Every error mode - * exits with this same code and a distinct, recognizable message (the failure - * mode is identified by that message, not by a bespoke per-mode code — keeping - * the single Phase-1 exit mechanism). The dormant (no-config) and success cases - * take an early `return`, exiting 0 naturally. - */ const EXIT_FAILURE = 1; /** Resolved, absolute paths the sync run operates on. */ @@ -77,7 +51,6 @@ function resolvePaths(options: { }): ResolvedPaths { const cwd = process.cwd(); - // metric-views.json: --metric-views-json-path > /config/queries/metric-views.json. const explicitConfigPath = options.metricViewsJsonPath !== undefined; const configPath = options.metricViewsJsonPath ? path.isAbsolute(options.metricViewsJsonPath) @@ -85,8 +58,6 @@ function resolvePaths(options: { : path.resolve(cwd, options.metricViewsJsonPath) : path.join(cwd, "config", "queries", METRIC_VIEWS_CONFIG_FILE); - // Output paths under shared/appkit-types — matches how generate-types - // resolves its output directory. const outDir = options.outputDir ? path.isAbsolute(options.outputDir) ? options.outputDir @@ -105,7 +76,8 @@ function resolvePaths(options: { * The shared sync core for BOTH the interactive and non-interactive paths: * resolve paths → existence check (dormancy vs missing) → read + `JSON.parse` * → schema-validate → require warehouse → ONLY THEN dynamic-import appkit + - * `syncMetricViewsTypes`. Reaches the appkit metric-sync core through a dynamic + * `syncMetricViewsTypes`. + * Reaches the appkit metric-sync core through a dynamic * `import("@databricks/appkit/type-generator")` — the exact pattern * `generate-types.ts` uses — so the `shared` CLI package carries NO static * dependency on `@databricks/appkit` and compiles without it. @@ -141,13 +113,8 @@ async function runMetricViewsSync( return; } - // Existence is checked before anything else (including the warehouse-id - // requirement) so the dormancy invariant holds unconditionally: - // - DEFAULT path absent → additive path is dormant, exit 0. An opt-in - // project that never adopted metric views must NOT error, even without a - // warehouse configured. - // - EXPLICIT --metric-views-json-path absent → the user named a file that - // isn't there; that's a real error, exit non-zero. + // Check existence before requiring a warehouse. Missing default config means + // metric views are unused; a missing explicit path is an error. if (!fs.existsSync(configPath)) { if (explicitConfigPath) { console.error(`Error: metric-views.json not found at ${configPath}.`); @@ -160,8 +127,6 @@ async function runMetricViewsSync( return; } - // Read + parse the config before touching appkit. A malformed file is a - // user error with a precise location, not an appkit/warehouse failure. const rawConfig = fs.readFileSync(configPath, "utf-8"); let parsedConfig: unknown; try { @@ -173,10 +138,7 @@ async function runMetricViewsSync( return; } - // Schema-validate against the canonical metricSourceSchema (single source - // of truth) BEFORE the dynamic import. Bad FQN grammar, an unknown - // executor, an unrecognized key, or a bad metric key fail here with the - // `path: message` list — never as an opaque downstream error. + // Schema-validate against the canonical metricSourceSchema BEFORE the dynamic import. const validation = validateMetricViewsSource(parsedConfig); if (!validation.valid) { console.error(`Error: invalid ${configPath}:`); @@ -185,9 +147,7 @@ async function runMetricViewsSync( return; } - // The warehouse is only needed once we have a valid config to sync; require - // it here (after dormancy + validation) so a dormant/invalid project never - // trips on a missing warehouse. + // The warehouse is only needed once we have a valid config to sync; require it here (after dormancy + validation) so a dormant/invalid project never trips on a missing warehouse. const warehouseId = options.warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; if (!warehouseId) { @@ -214,8 +174,6 @@ async function runMetricViewsSync( metricOutFile, metricMetadataOutFile, warehouseId, - // `--no-cache` → cache === false disables the metric typegen cache; - // absent/true keeps the default (cache on). Forwarded verbatim. cache: options.cache, }); } catch (err) { @@ -319,23 +277,19 @@ async function runInteractive(): Promise { message: "SQL Warehouse ID", placeholder: process.env.DATABRICKS_WAREHOUSE_ID ? `${process.env.DATABRICKS_WAREHOUSE_ID} (from DATABRICKS_WAREHOUSE_ID)` - : "1234abcd5678efgh", - // Optional: a blank value defers to DATABRICKS_WAREHOUSE_ID (validated - // downstream by runMetricViewsSync, which errors if neither is set). + : "your-warehouse-id", }); if (isCancel(warehouseId)) return cancelled(); const metricViewsJsonPath = await text({ message: "Path to metric-views.json", placeholder: "config/queries/metric-views.json", - // Optional: blank uses the canonical default path. }); if (isCancel(metricViewsJsonPath)) return cancelled(); const outputDir = await text({ message: "Output directory for generated types", placeholder: "shared/appkit-types", - // Optional: blank uses the canonical default output dir. }); if (isCancel(outputDir)) return cancelled(); @@ -349,8 +303,6 @@ async function runInteractive(): Promise { warehouseId: trimmed(warehouseId), metricViewsJsonPath: trimmed(metricViewsJsonPath), outputDir: trimmed(outputDir), - // Interactive runs use the default cache behavior (cache on); --no-cache is - // a non-interactive flag. cache: true, }; From 189c382d494f9049df5940882658642637206b36 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 30 Jun 2026 11:20:28 +0200 Subject: [PATCH 06/13] chore(shared): address mv sync review findings - interactive mode no longer double-prints the success line - narrow appkit-missing detection so real sync errors surface verbatim - match the interactive outro to the run outcome (dormant != success) - exit 0 on interactive cancel (consistent with plugin create) - canonical command `metric-views` with `mv` alias - add `--wait` (blocking preflight) to fail instead of degrade in CI - document the ambient type-generator shim's intentional narrowing Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/cli/commands/metric-views/index.ts | 8 +- .../commands/metric-views/sync/sync.test.ts | 181 +++++++++++++++++- .../cli/commands/metric-views/sync/sync.ts | 127 +++++++++--- .../src/cli/commands/type-generator.d.ts | 22 +++ 4 files changed, 302 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/cli/commands/metric-views/index.ts b/packages/shared/src/cli/commands/metric-views/index.ts index 334176003..fd1613a57 100644 --- a/packages/shared/src/cli/commands/metric-views/index.ts +++ b/packages/shared/src/cli/commands/metric-views/index.ts @@ -8,12 +8,16 @@ import { metricViewsSyncCommand } from "./sync/sync"; * Future subcommands (`list` / `validate` / `describe`) plug in here so users have one top-level surface for everything related to Metric Views. * Sibling of `plugin`, `setup`, `generate-types`, `lint`, `docs`, `codemod`. */ -export const metricViewsCommand = new Command("mv") +export const metricViewsCommand = new Command("metric-views") + // `metric-views` is the canonical name shown in --help (full-word, consistent + // with `plugin` / `generate-types`); `mv` is the ergonomic shorthand alias. + .alias("mv") .description("Metric-view management commands (UC Metric Views)") .addCommand(metricViewsSyncCommand) .addHelpText( "after", ` Examples: - $ appkit mv sync --warehouse-id 1234abcd5678efgh`, + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh + $ appkit mv sync --warehouse-id 1234abcd5678efgh # 'mv' is a shorthand alias`, ); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts index 5108fc534..7ea2eb796 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts @@ -25,6 +25,7 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = metricOutFile: string; metricMetadataOutFile: string; cache?: boolean; + mode?: "describe-now" | "non-blocking" | "blocking"; }) => { const nodeFs = require("node:fs") as typeof import("node:fs"); const nodePath = require("node:path") as typeof import("node:path"); @@ -35,7 +36,7 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = nodeFs.writeFileSync(opts.metricMetadataOutFile, "{}\n"); // Annotate the array element types so the inferred return type is wide // enough for `mockResolvedValueOnce` overrides that populate `failures` - // (an empty literal would otherwise infer `never[]`). + // or `fatalErrors` (an empty literal would otherwise infer `never[]`). const schemas: Array<{ key: string; source: string; @@ -48,11 +49,13 @@ const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = reason: string; transient: boolean; }> = []; + const fatalErrors: Array<{ name: string; message: string }> = []; return { metricOutFile: opts.metricOutFile, metricMetadataOutFile: opts.metricMetadataOutFile, schemas, failures, + fatalErrors, noConfig: false, }; }, @@ -303,11 +306,17 @@ describe("appkit mv sync", () => { test("appkit absent: recognizable error message + non-zero exit", async () => { writeConfig(); - // Model the dynamic import failing as it does when @databricks/appkit - // isn't installed. - syncMetricViewsTypes.mockRejectedValueOnce( - new Error("Cannot find module '@databricks/appkit/type-generator'"), + // Model a module-resolution failure reaching the catch, as the dynamic + // `import("@databricks/appkit/type-generator")` throws when @databricks/appkit + // isn't installed: a structured ERR_MODULE_NOT_FOUND naming the package. Only + // THIS shape maps to the "not installed" guidance (see the next test). + const moduleNotFound = Object.assign( + new Error( + "Cannot find package '@databricks/appkit' imported from /app/cli.js", + ), + { code: "ERR_MODULE_NOT_FOUND" }, ); + syncMetricViewsTypes.mockRejectedValueOnce(moduleNotFound); const exitSpy = vi .spyOn(process, "exit") @@ -317,7 +326,33 @@ describe("appkit mv sync", () => { const errored = consoleError.mock.calls.flat().map(String).join("\n"); expect(errored).toContain( - "appkit mv sync is only available with @databricks/appkit installed", + "appkit metric-views sync is only available with @databricks/appkit installed", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + + test("a non-module sync error propagates verbatim (not misreported as missing appkit)", async () => { + writeConfig(); + // A real failure from syncMetricViewsTypes — e.g. an unreachable warehouse — + // carries its own message and no module-resolution code. It must surface + // verbatim with a non-zero exit, NOT be rewritten as "install appkit" just + // because some unrelated message might mention a module. + syncMetricViewsTypes.mockRejectedValueOnce( + new Error("Warehouse wh-123 is unreachable (timed out after 30s)"), + ); + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + + await runCli(["--warehouse-id", "wh-123"]); + + const errored = consoleError.mock.calls.flat().map(String).join("\n"); + expect(errored).toContain("Warehouse wh-123 is unreachable"); + expect(errored).not.toContain( + "only available with @databricks/appkit installed", ); expect(exitSpy).toHaveBeenCalledWith(1); @@ -530,6 +565,7 @@ describe("appkit mv sync", () => { transient: false, }, ], + fatalErrors: [], noConfig: false, }); @@ -572,6 +608,7 @@ describe("appkit mv sync", () => { }, ], failures: [], + fatalErrors: [], noConfig: false, }); @@ -587,6 +624,105 @@ describe("appkit mv sync", () => { expect(warned).toContain("Rerun"); }); + // --- --wait (blocking preflight): CI opt-in to fail instead of degrade ------ + + test("--wait forwards mode: 'blocking' to syncMetricViewsTypes", async () => { + writeConfig(); + + await runCli(["--warehouse-id", "wh-123", "--wait"]); + + expect(syncMetricViewsTypes).toHaveBeenCalledWith( + expect.objectContaining({ mode: "blocking" }), + ); + }); + + test("without --wait, mode stays describe-now (never blocking)", async () => { + writeConfig(); + + await runCli(["--warehouse-id", "wh-123"]); + + const call = syncMetricViewsTypes.mock.calls[0][0] as { mode?: string }; + // describe-now is appkit's default; the CLI omits the key entirely. + expect(call.mode).not.toBe("blocking"); + }); + + test("--wait + still-degraded result: hard failure (exit 1), not a warning", async () => { + writeConfig(); + // Even after waiting, a key came back degraded (e.g. a served degraded cache + // hit). Under --wait the CLI must fail rather than ship permissive types. + syncMetricViewsTypes.mockResolvedValueOnce({ + metricOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ), + metricMetadataOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.metadata.json", + ), + schemas: [ + { + key: "revenue", + source: "demo.sales.revenue", + lane: "sp", + degraded: true, + }, + ], + failures: [], + fatalErrors: [], + noConfig: false, + }); + + const exitSpy = await runCliCapturingExit([ + "--warehouse-id", + "wh-123", + "--wait", + ]); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errored = erroredText(); + expect(errored).toContain("could not be described even after waiting"); + expect(errored).toContain("revenue"); + }); + + test("--wait + fatal preflight (e.g. deleted warehouse): exit 1 with the reason", async () => { + writeConfig(); + // blocking mode reports a deleted/deleting warehouse via fatalErrors (the + // artifacts are still written); the CLI surfaces it and exits non-zero. + syncMetricViewsTypes.mockResolvedValueOnce({ + metricOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ), + metricMetadataOutFile: path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.metadata.json", + ), + schemas: [], + failures: [], + fatalErrors: [{ name: "wh-123", message: "Warehouse is DELETED" }], + noConfig: false, + }); + + const exitSpy = await runCliCapturingExit([ + "--warehouse-id", + "wh-123", + "--wait", + ]); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errored = erroredText(); + expect(errored).toContain("warehouse preflight failed"); + expect(errored).toContain("Warehouse is DELETED"); + }); + // --- Phase 3: interactive flow ---------------------------------------------- test("no flags → interactive: prompts fire and resolved values reach syncMetricViewsTypes", async () => { @@ -624,6 +760,14 @@ describe("appkit mv sync", () => { ), cache: true, }); + + // #1: in interactive mode the spinner carries the success line; the plain + // "Generated metric types" console.log must NOT also fire (no double print). + expect(clackMocks.spinnerStop).toHaveBeenCalledWith( + expect.stringContaining("Generated metric types"), + ); + const logged = consoleLog.mock.calls.flat().map(String).join("\n"); + expect(logged).not.toContain("Generated metric types"); }); test("interactive: a non-blank config path / output dir answer is honored", async () => { @@ -665,7 +809,22 @@ describe("appkit mv sync", () => { ); }); - test("interactive cancel (first prompt): graceful cancel + non-zero exit, no appkit call", async () => { + test("interactive + no metric-views.json: outro matches the no-op (not a false 'synced')", async () => { + // No writeConfig() → the default config path is absent (dormant). + clackMocks.textAnswers = ["wh-interactive", "", ""]; + + await runCli([]); + + expect(syncMetricViewsTypes).not.toHaveBeenCalled(); + // A dormant run must not claim success — the outro reflects the no-op. + expect(clackMocks.outro).toHaveBeenCalledTimes(1); + expect(clackMocks.outro).toHaveBeenCalledWith("Nothing to sync."); + expect(clackMocks.outro).not.toHaveBeenCalledWith("Metric types synced."); + const logged = consoleLog.mock.calls.flat().map(String).join("\n"); + expect(logged).toContain("Nothing to sync"); + }); + + test("interactive cancel (first prompt): graceful cancel + exit 0, no appkit call", async () => { writeConfig(); // First prompt returns the cancel symbol. clackMocks.textAnswers = [clackMocks.CANCEL]; @@ -677,11 +836,12 @@ describe("appkit mv sync", () => { await runCli([]); expect(clackMocks.cancel).toHaveBeenCalledTimes(1); - expect(exitSpy).toHaveBeenCalledWith(1); + // Cancel is graceful: exit 0, like every other interactive command. + expect(exitSpy).toHaveBeenCalledWith(0); expect(syncMetricViewsTypes).not.toHaveBeenCalled(); }); - test("interactive cancel (later prompt): graceful cancel + non-zero exit", async () => { + test("interactive cancel (later prompt): graceful cancel + exit 0", async () => { writeConfig(); // First answer ok, second prompt cancels. clackMocks.textAnswers = ["wh-1", clackMocks.CANCEL]; @@ -693,7 +853,8 @@ describe("appkit mv sync", () => { await runCli([]); expect(clackMocks.cancel).toHaveBeenCalledTimes(1); - expect(exitSpy).toHaveBeenCalledWith(1); + // Cancel is graceful: exit 0, like every other interactive command. + expect(exitSpy).toHaveBeenCalledWith(0); expect(syncMetricViewsTypes).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts index 438bafeee..991da7610 100644 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.ts +++ b/packages/shared/src/cli/commands/metric-views/sync/sync.ts @@ -9,18 +9,21 @@ import { } from "../validate-metric-views-source"; /** - * Options parsed by commander for `appkit mv sync`. + * Options parsed by commander for `appkit metric-views sync` (alias `mv`). * - * interactive clack flow: + * Flags: * - `--warehouse-id` (+ `DATABRICKS_WAREHOUSE_ID` fallback) * - `--metric-views-json-path` (canonical config path) * - `--output-dir` (artifact output directory; replaces Phase 1's `--out-dir`) + * - `--wait` (wait for the warehouse instead of emitting permissive types on a + * cold warehouse; exit non-zero if any view still can't be described) * - `--no-cache` (commander negation → `cache === false` disables the metric type-generation cache) */ export interface MetricViewsSyncOptions { warehouseId?: string; metricViewsJsonPath?: string; outputDir?: string; + wait?: boolean; cache?: boolean; } @@ -28,6 +31,14 @@ const METRIC_VIEWS_CONFIG_FILE = "metric-views.json"; const EXIT_FAILURE = 1; +/** + * What a sync run actually did, returned so the interactive caller can print an + * outro that matches the outcome (a dormant run must not claim "synced"). + * `undefined` means a hard-failure branch already called `process.exit` — it + * only "returns" under the test no-op exit spy. + */ +type SyncOutcome = "synced" | "degraded" | "noop"; + /** Resolved, absolute paths the sync run operates on. */ interface ResolvedPaths { /** Folder that holds metric-views.json (the appkit export reads from a folder). */ @@ -92,7 +103,7 @@ function resolvePaths(options: { async function runMetricViewsSync( options: MetricViewsSyncOptions, onProgress?: { start(): void; succeed(msg: string): void; fail(): void }, -): Promise { +): Promise { try { const { queryFolder, configPath, explicitConfigPath, outDir } = resolvePaths(options); @@ -124,7 +135,7 @@ async function runMetricViewsSync( console.log( `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, ); - return; + return "noop"; } const rawConfig = fs.readFileSync(configPath, "utf-8"); @@ -175,6 +186,10 @@ async function runMetricViewsSync( metricMetadataOutFile, warehouseId, cache: options.cache, + // `--wait` → blocking preflight (wait for / start the warehouse). The + // default omits `mode` so appkit uses "describe-now" (DESCRIBE now, + // degrade on a cold warehouse) — and the default call shape is unchanged. + ...(options.wait ? { mode: "blocking" as const } : {}), }); } catch (err) { onProgress?.fail(); @@ -188,6 +203,21 @@ async function runMetricViewsSync( console.log( `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, ); + return "noop"; + } + + // Fatal preflight errors only arise under `--wait` (blocking mode) — e.g. + // the warehouse is deleted/deleting. The artifacts are still written, but + // the run did not converge, so exit non-zero with the underlying reason. + if (result.fatalErrors.length > 0) { + onProgress?.fail(); + console.error( + "Error: could not sync metric views (warehouse preflight failed):", + ); + for (const fe of result.fatalErrors) { + console.error(` ${fe.name}: ${fe.message}`); + } + process.exit(EXIT_FAILURE); return; } @@ -217,26 +247,56 @@ async function runMetricViewsSync( .filter((schema) => schema.degraded) .map((schema) => schema.key); if (degradedKeys.length > 0) { + // `--wait` promised to wait for the warehouse and emit correct types, so a + // still-degraded result is a hard failure — never silently ship permissive + // types in CI. Without `--wait` (describe-now) the same state is a warning. + if (options.wait) { + onProgress?.fail(); + console.error( + `Error: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described even after waiting for the warehouse. Check the warehouse state and the metric view definitions, then rerun.`, + ); + process.exit(EXIT_FAILURE); + return; + } onProgress?.succeed( `Generated permissive metric types: ${metricOutFile}`, ); console.warn( - `Warning: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described — the warehouse wasn't ready, so permissive types were written. Rerun \`appkit mv sync\` once the warehouse is available.`, + `Warning: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described — the warehouse wasn't ready, so permissive types were written. Rerun \`appkit metric-views sync\` once the warehouse is available.`, ); - console.log(`Generated metric metadata: ${metricMetadataOutFile}`); - return; + // In interactive mode the spinner's success line is the user-facing + // output; emit the plain metadata path only on the flag-driven path. + if (!onProgress) { + console.log(`Generated metric metadata: ${metricMetadataOutFile}`); + } + return "degraded"; } onProgress?.succeed(`Generated metric types: ${metricOutFile}`); - console.log(`Generated metric types: ${metricOutFile}`); - console.log(`Generated metric metadata: ${metricMetadataOutFile}`); + // Avoid a double print in interactive mode: `onProgress.succeed` already + // stopped the spinner with the "Generated metric types" line. The plain logs + // are the flag-driven path's only output, so gate them on its absence. + if (!onProgress) { + console.log(`Generated metric types: ${metricOutFile}`); + console.log(`Generated metric metadata: ${metricMetadataOutFile}`); + } + return "synced"; } catch (error) { + // Only a genuinely missing/unresolvable @databricks/appkit maps to the + // "not installed" guidance. The dynamic import throws a structured + // ERR_MODULE_NOT_FOUND naming the package when appkit is absent; match THAT + // rather than any message containing "Cannot find module". Otherwise a real + // failure from syncMetricViewsTypes — an unreachable warehouse, an auth + // error, a bad FQN — whose message happens to mention a module would be + // misreported here, sending the user to reinstall a package that is present. + const err = error as { code?: unknown; message?: unknown }; if ( - error instanceof Error && - error.message.includes("Cannot find module") + err?.code === "ERR_MODULE_NOT_FOUND" && + typeof err.message === "string" && + err.message.includes("@databricks/appkit") ) { console.error( - "Error: appkit mv sync is only available with @databricks/appkit installed.", + "Error: appkit metric-views sync is only available with @databricks/appkit installed.", ); console.error("Please install @databricks/appkit to use this command."); process.exit(EXIT_FAILURE); @@ -264,13 +324,15 @@ async function runMetricViewsSync( async function runInteractive(): Promise { intro("Sync UC Metric View types"); - // A cancelled prompt (Ctrl-C) is a graceful, non-zero exit. The explicit - // `return` after `process.exit` keeps control flow correct under a no-op exit - // (tests) — without it, a cancelled flow would fall through to the next - // prompt and eventually run the sync. + // A cancelled prompt (Ctrl-C) is a graceful exit (code 0) — matching + // `plugin create` / `add-resource` and clack's own convention, so a wrapper + // script that treats non-zero as failure doesn't misread a deliberate cancel. + // The explicit `return` after `process.exit` keeps control flow correct under + // a no-op exit (tests) — without it, a cancelled flow would fall through to + // the next prompt and eventually run the sync. const cancelled = (): never => { cancel("Cancelled."); - process.exit(1); + process.exit(0); }; const warehouseId = await text({ @@ -307,13 +369,22 @@ async function runInteractive(): Promise { }; const s = spinner(); - await runMetricViewsSync(options, { + const outcome = await runMetricViewsSync(options, { start: () => s.start("Describing metric views…"), succeed: (msg) => s.stop(msg), fail: () => s.stop("Failed."), }); - outro("Metric types synced."); + // Close with an outro that matches what actually happened — a dormant run + // logged "Nothing to sync", so a blanket "Metric types synced." would + // contradict it. `undefined` means a hard-failure branch already exited. + if (outcome === "noop") { + outro("Nothing to sync."); + } else if (outcome === "degraded") { + outro("Metric types synced with warnings."); + } else if (outcome === "synced") { + outro("Metric types synced."); + } } /** @@ -338,6 +409,7 @@ async function runMetricViewsSyncAction( "warehouseId", "metricViewsJsonPath", "outputDir", + "wait", "cache", ] as const; const hasUserFlag = FLAG_OPTION_NAMES.some( @@ -367,18 +439,25 @@ export const metricViewsSyncCommand = new Command("sync") "--output-dir ", "Output directory for metric-views.d.ts and metric-views.metadata.json (default: shared/appkit-types)", ) + .option( + "--wait", + "Wait for the SQL warehouse to start instead of emitting permissive types on a cold warehouse; exit non-zero if any metric view still can't be described", + ) .option("--no-cache", "Disable the metric type-generation cache") .addHelpText( "after", ` Run with no flags for an interactive prompt; pass any flag for non-interactive mode. +'mv' is a shorthand alias, so 'appkit mv sync' is equivalent to 'appkit metric-views sync'. Examples: - $ appkit mv sync - $ appkit mv sync --warehouse-id 1234abcd5678efgh - $ appkit mv sync --warehouse-id 1234abcd5678efgh --metric-views-json-path config/queries/metric-views.json - $ appkit mv sync --warehouse-id 1234abcd5678efgh --output-dir shared/appkit-types - $ appkit mv sync --warehouse-id 1234abcd5678efgh --no-cache + $ appkit metric-views sync + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --metric-views-json-path config/queries/metric-views.json + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --output-dir shared/appkit-types + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --wait + $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --no-cache + $ appkit mv sync --warehouse-id 1234abcd5678efgh # 'mv' alias Environment variables: DATABRICKS_WAREHOUSE_ID SQL warehouse ID (used when --warehouse-id is omitted) diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 23f80d534..8d6bf7a94 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -1,3 +1,20 @@ +/** + * 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: array element types + * are widened to `unknown[]`, options the CLI never passes (e.g. `metricFetcher`) + * are omitted, and only the surface the CLI actually uses is declared. + * + * DRIFT WARNING: there is NO compile-time link to appkit's real types — if the + * real `generateFromEntryPoint` / `syncMetricViewsTypes` (or their result + * shapes) 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; @@ -76,6 +93,11 @@ declare module "@databricks/appkit/type-generator" { metricOutFile: string; metricMetadataOutFile: string; cache?: boolean; + // Preflight policy (mirrors the real export). The CLI omits it for the + // default `"describe-now"` (DESCRIBE now, degrade on a cold warehouse) and + // passes `"blocking"` under `--wait` (wait for / start the warehouse; only a + // deleted one is fatal). `"non-blocking"` is the dev/Vite path. + mode?: "describe-now" | "non-blocking" | "blocking"; }): Promise; export const METRIC_TYPES_FILE: string; From defe0f8842e066afabfa81051f1023bbe7cd9a79 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 2 Jul 2026 15:46:23 +0200 Subject: [PATCH 07/13] chore(types): version dev-playground metric-view artifacts; template ignores them dev-playground commits metric-views.d.ts + metric-views.metadata.json alongside its already-tracked analytics.d.ts (it has no build-time typegen to regenerate them), plus the source config metric-views.json. The scaffolding template gitignores the two generated files, since prebuild runs `generate-types --wait` and bakes fresh types into the bundle before deploy. Document the commit-vs-generate policy in type-generation.md. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../config/queries/metric-views.json | 11 ++ .../shared/appkit-types/metric-views.d.ts | 110 ++++++++++++++++++ .../appkit-types/metric-views.metadata.json | 63 ++++++++++ docs/docs/development/type-generation.md | 31 ++++- template/_gitignore | 4 + 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 apps/dev-playground/config/queries/metric-views.json create mode 100644 apps/dev-playground/shared/appkit-types/metric-views.d.ts create mode 100644 apps/dev-playground/shared/appkit-types/metric-views.metadata.json 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/apps/dev-playground/shared/appkit-types/metric-views.metadata.json b/apps/dev-playground/shared/appkit-types/metric-views.metadata.json new file mode 100644 index 000000000..8b8555814 --- /dev/null +++ b/apps/dev-playground/shared/appkit-types/metric-views.metadata.json @@ -0,0 +1,63 @@ +{ + "customers": { + "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": { + "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": [ + "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..157bb95a3 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 — when you adopt UC Metric Views — `metric-views.d.ts` plus its runtime metadata bundle `metric-views.metadata.json`. 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,35 @@ 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`. +### Should you commit generated types? + +Everything under `shared/appkit-types/` is a **build artifact**, not source. What you version is the input — your `config/queries/*.sql` files and `config/queries/metric-views.json` — while the generated `.d.ts` and `.metadata.json` files are reproduced from those inputs on every build. The app template gitignores them for this reason: `prebuild` runs `generate-types --wait` before `vite build`, so the accurate types are always regenerated (against a live warehouse) and baked into the bundle at build time. Because the bundle is what deploys, the source-tree copies never need to be committed, and committing them only risks a stale artifact drifting from the queries. The template's `_gitignore` already excludes `serving.d.ts`, `metric-views.d.ts`, and `metric-views.metadata.json` on this basis. + +The only input that must be committed is `config/queries/metric-views.json` itself — it declares which views exist and is the source the generated files are derived from. + +## 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 two more files into `shared/appkit-types/`: + +- `metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. +- `metric-views.metadata.json` — a build-time bundle of each view's measures and dimensions that the analytics plugin's metric-view path reads at runtime. + +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: diff --git a/template/_gitignore b/template/_gitignore index 23adbc24e..06a204739 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -11,3 +11,7 @@ playwright-report/ # Auto-generated types (endpoint-specific, varies per developer) shared/appkit-types/serving.d.ts + +# Metric-view types (regenerated at build by `generate-types --wait`) +shared/appkit-types/metric-views.d.ts +shared/appkit-types/metric-views.metadata.json From dc46470cc1442dcdcc6308eb07ab29fabaaa51e4 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 2 Jul 2026 16:15:08 +0200 Subject: [PATCH 08/13] refactor(shared): fold metric-view typegen into generate-types Remove the standalone `appkit metric-views`/`mv sync` command and emit metric-view types additively from `generate-types` instead: when config/queries/metric-views.json is present, generateFromEntryPoint now delegates to syncMetricViewsTypes and writes metric-views.d.ts + metric-views.metadata.json as siblings of the query out file, staying dormant otherwise. The CLI no longer calls syncMetricViewsTypes directly, so the shared type-generator.d.ts shim narrows to the generateFromEntryPoint / generateServingTypes surface, and the tsdown subpath-entry comment is updated to match. Drops the metric-views command tree and its registration. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 104 ++- .../src/type-generator/tests/index.test.ts | 90 ++ .../tests/sync-metric-views-types.test.ts | 6 +- packages/appkit/tsdown.config.ts | 12 +- .../src/cli/commands/generate-types.test.ts | 37 + .../shared/src/cli/commands/generate-types.ts | 16 + .../src/cli/commands/metric-views/index.ts | 23 - .../commands/metric-views/sync/sync.test.ts | 860 ------------------ .../cli/commands/metric-views/sync/sync.ts | 471 ---------- .../validate-metric-views-source.test.ts | 185 ---- .../validate-metric-views-source.ts | 77 -- .../src/cli/commands/type-generator.d.ts | 83 +- packages/shared/src/cli/index.ts | 2 - 13 files changed, 246 insertions(+), 1720 deletions(-) delete mode 100644 packages/shared/src/cli/commands/metric-views/index.ts delete mode 100644 packages/shared/src/cli/commands/metric-views/sync/sync.test.ts delete mode 100644 packages/shared/src/cli/commands/metric-views/sync/sync.ts delete mode 100644 packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts delete mode 100644 packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 118b50df7..b3c32331b 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -331,35 +331,84 @@ 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. 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 - // keep last-known-good cache serving + the sticky-degraded notice. 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 that flag. Fatal preflight errors come back in - // `fatalErrors` (empty except for a deleted/deleting warehouse in blocking - // mode) so the end-of-run throw below surfaces them after the writes, exactly - // as the inline block did. + // 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 keep last-known-good + // cache serving + the sticky-degraded notice. 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 mvFile = mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); const mvMetadataFile = mvMetadataOutFile ?? path.join(path.dirname(mvFile), METRIC_METADATA_FILE); - const mvResult = await syncMetricViewsTypes({ - queryFolder, - warehouseId, - metricOutFile: mvFile, - metricMetadataOutFile: mvMetadataFile, - cache: !noCache, - metricFetcher, - mode, - }); + + let mvResult: SyncMetricViewsTypesResult; + try { + mvResult = await syncMetricViewsTypes({ + queryFolder, + warehouseId, + metricOutFile: mvFile, + metricMetadataOutFile: mvMetadataFile, + 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, + ); + } + + // 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); } + + // 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}`, + }); + } + } } await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); @@ -395,21 +444,22 @@ export interface SyncMetricViewsTypesResult { * 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. The CLI never sets - * `mode`, so for `"describe-now"` this is always empty. + * 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. Backs BOTH the `appkit mv sync` - * CLI (default `"describe-now"` mode) and {@link generateFromEntryPoint}'s - * metric section (which forwards its dev `"non-blocking"`/`"blocking"` mode). + * 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"` (default, the CLI): no preflight, no status probe — DESCRIBE every key that isn't a clean cache hit. The hit predicate is STRICTER here: a degraded/sticky cached entry is NEVER served (it is re-described), so a focused `mv sync` always converges to correct types, and the sticky-degraded notice never fires (nothing degraded is served). + * - `"describe-now"` (the default for a direct call): no preflight, no status probe — DESCRIBE every key that isn't a clean cache hit. The hit predicate is STRICTER here: a degraded/sticky cached entry is NEVER served (it is re-described), so a direct describe-now call always converges to correct types, and the sticky-degraded notice never fires (nothing degraded is served). * - `"non-blocking"` (dev/Vite default): one status-only probe, DESCRIBE only when the warehouse is already RUNNING, else emit degraded artifacts immediately. Degraded cache hits ARE served (last-known-good) and surfaced via the sticky-degraded notice. * - `"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. * @@ -422,7 +472,7 @@ export interface SyncMetricViewsTypesResult { * @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; the CLI omits it (taking `"describe-now"`), {@link generateFromEntryPoint} forwards its own {@link PreflightMode}. + * @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; diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 230aeb93f..5d4d7d025 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -573,6 +573,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"); diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 3037f0036..ed8f097d7 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -6,8 +6,10 @@ import type { DescribeFetcher } from "../mv-registry/types"; import type { DatabricksStatementExecutionResponse } from "../types"; /** - * Unit tests for the metric-only `syncMetricViewsTypes` export that backs the - * `appkit mv sync` CLI. A mock {@link DescribeFetcher} is injected so the + * 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 * artifacts) runs without a warehouse, asserting BOTH artifacts land for a * mixed fixture (a service-principal metric + an OBO metric; measures + a diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 730757e71..b03ccabee 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -5,12 +5,12 @@ export default defineConfig([ publint: true, name: "@databricks/appkit", // `./type-generator` is a public subpath export consumed cross-package by the - // `appkit` CLI (`appkit mv sync` / `generate-types`) via a dynamic import - // Rolldown can't see. It must be its own entry so its declared public API - // (syncMetricViewsTypes + METRIC_TYPES_FILE / METRIC_METADATA_FILE, alongside - // generateFromEntryPoint / generateServingTypes) is 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. + // `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, diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index df65f047c..92dfb8477 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -50,6 +50,10 @@ const { vi.mock("@databricks/appkit/type-generator", () => ({ generateFromEntryPoint, generateServingTypes, + // The CLI joins these with the out file's directory to report the emitted + // metric artifacts; mirror the real exported constants. + METRIC_TYPES_FILE: "metric-views.d.ts", + METRIC_METADATA_FILE: "metric-views.metadata.json", })); // Mock the detached spawn so we can assert how the worker is launched without @@ -230,4 +234,37 @@ describe("generate-types foreground spawn orchestration", () => { expect(spawn).not.toHaveBeenCalled(); expect(acquireSpawnLock).not.toHaveBeenCalled(); }); + + test("reports metric artifacts when config/queries/metric-views.json exists", async () => { + // The metric path is additive: generateFromEntryPoint emits metric-views.d.ts + // + metric-views.metadata.json as siblings of the query out file whenever the + // config is present. The CLI announces them 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")}`, + ); + expect(logged).toContain( + `Generated metric metadata: ${path.join(path.dirname(outFile), "metric-views.metadata.json")}`, + ); + }); + + test("omits metric artifact lines 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:"); + expect(logged).not.toContain("Generated metric metadata:"); + }); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 03c1631a1..43aea5ecd 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -75,6 +75,22 @@ async function runGenerateTypes( mode, }); console.log(`Generated query types: ${resolvedOutFile}`); + + // generateFromEntryPoint also emits metric-view types additively when + // config/queries/metric-views.json exists (it stays dormant otherwise), + // writing them as siblings of the query out file. Mirror that to report + // the artifacts; 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)}`, + ); + console.log( + `Generated metric metadata: ${path.join(typesDir, typeGen.METRIC_METADATA_FILE)}`, + ); + } } } else { console.error( diff --git a/packages/shared/src/cli/commands/metric-views/index.ts b/packages/shared/src/cli/commands/metric-views/index.ts deleted file mode 100644 index fd1613a57..000000000 --- a/packages/shared/src/cli/commands/metric-views/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Command } from "commander"; -import { metricViewsSyncCommand } from "./sync/sync"; - -/** - * Parent command for UC Metric View operations. - * - * Exposes a single subcommand (`sync`). - * Future subcommands (`list` / `validate` / `describe`) plug in here so users have one top-level surface for everything related to Metric Views. - * Sibling of `plugin`, `setup`, `generate-types`, `lint`, `docs`, `codemod`. - */ -export const metricViewsCommand = new Command("metric-views") - // `metric-views` is the canonical name shown in --help (full-word, consistent - // with `plugin` / `generate-types`); `mv` is the ergonomic shorthand alias. - .alias("mv") - .description("Metric-view management commands (UC Metric Views)") - .addCommand(metricViewsSyncCommand) - .addHelpText( - "after", - ` -Examples: - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh - $ appkit mv sync --warehouse-id 1234abcd5678efgh # 'mv' is a shorthand alias`, - ); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts deleted file mode 100644 index 7ea2eb796..000000000 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.test.ts +++ /dev/null @@ -1,860 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - afterEach, - beforeEach, - describe, - expect, - type Mock, - test, - vi, -} from "vitest"; - -// --- Module mocks ----------------------------------------------------------- -// vi.mock factories are hoisted above the file, so the spies they return must be -// created in a hoisted block too. Mirrors generate-types.test.ts. -const { syncMetricViewsTypes, METRIC_TYPES_FILE, METRIC_METADATA_FILE } = - vi.hoisted(() => ({ - // The mock stands in for the appkit export: it WRITES both artifacts (so - // the test can assert they land in the temp dir) and reports its inputs. - syncMetricViewsTypes: vi.fn( - async (opts: { - queryFolder: string; - warehouseId: string; - metricOutFile: string; - metricMetadataOutFile: string; - cache?: boolean; - mode?: "describe-now" | "non-blocking" | "blocking"; - }) => { - const nodeFs = require("node:fs") as typeof import("node:fs"); - const nodePath = require("node:path") as typeof import("node:path"); - nodeFs.mkdirSync(nodePath.dirname(opts.metricOutFile), { - recursive: true, - }); - nodeFs.writeFileSync(opts.metricOutFile, "// metric-views.d.ts\n"); - nodeFs.writeFileSync(opts.metricMetadataOutFile, "{}\n"); - // Annotate the array element types so the inferred return type is wide - // enough for `mockResolvedValueOnce` overrides that populate `failures` - // or `fatalErrors` (an empty literal would otherwise infer `never[]`). - const schemas: Array<{ - key: string; - source: string; - lane: string; - degraded?: boolean; - }> = [{ key: "revenue", source: "demo.sales.revenue", lane: "sp" }]; - const failures: Array<{ - key: string; - source: string; - reason: string; - transient: boolean; - }> = []; - const fatalErrors: Array<{ name: string; message: string }> = []; - return { - metricOutFile: opts.metricOutFile, - metricMetadataOutFile: opts.metricMetadataOutFile, - schemas, - failures, - fatalErrors, - noConfig: false, - }; - }, - ), - METRIC_TYPES_FILE: "metric-views.d.ts", - METRIC_METADATA_FILE: "metric-views.metadata.json", - })); - -// The library type-generator is an optional/ambient module; mock it so the -// command's `await import("@databricks/appkit/type-generator")` resolves to -// spies and never touches a warehouse. -vi.mock("@databricks/appkit/type-generator", () => ({ - syncMetricViewsTypes, - METRIC_TYPES_FILE, - METRIC_METADATA_FILE, -})); - -// --- @clack/prompts mock ---------------------------------------------------- -// Drive the interactive path deterministically: each `text` prompt returns the -// next queued answer; `isCancel` recognizes the shared CANCEL symbol so a queued -// cancel triggers the graceful-exit branch. intro/outro/cancel/spinner are -// no-op spies (the spinner object exposes start/stop). -const clackMocks = vi.hoisted(() => { - const CANCEL = Symbol("clack:cancel"); - return { - CANCEL, - // Answers consumed in prompt order (warehouse id, config path, output dir). - textAnswers: [] as Array, - text: vi.fn(), - intro: vi.fn(), - outro: vi.fn(), - cancel: vi.fn(), - spinnerStart: vi.fn(), - spinnerStop: vi.fn(), - }; -}); - -vi.mock("@clack/prompts", () => ({ - intro: clackMocks.intro, - outro: clackMocks.outro, - cancel: clackMocks.cancel, - isCancel: (value: unknown) => value === clackMocks.CANCEL, - text: (...args: unknown[]) => { - clackMocks.text(...args); - return Promise.resolve( - clackMocks.textAnswers.length > 0 - ? clackMocks.textAnswers.shift() - : undefined, - ); - }, - spinner: () => ({ - start: clackMocks.spinnerStart, - stop: clackMocks.spinnerStop, - }), -})); - -import { metricViewsSyncCommand } from "./sync"; - -/** - * Drive the real commander command the way the bin does. `metricViewsSyncCommand` - * is a module-level singleton, so commander retains option values parsed by a - * previous call (absent options are NOT reset between `parseAsync` calls); - * clear that stored state first so each invocation parses from a clean slate. - * Resetting `_optionValueSources` to `{}` also clears the per-option `default` - * source bookkeeping, so a no-flag parse leaves every source `undefined` — the - * interactive-detection check keys on the `cli` source, so that reads correctly - * as "no user flag". - */ -async function runCli(args: string[]): Promise { - const cmd = metricViewsSyncCommand as unknown as { - _optionValues: Record; - _optionValueSources: Record; - }; - cmd._optionValues = {}; - cmd._optionValueSources = {}; - await metricViewsSyncCommand.parseAsync(args, { from: "user" }); -} - -describe("appkit mv sync", () => { - let tmpRoot: string; - let queryFolder: string; - let consoleLog: Mock; - let consoleError: Mock; - let consoleWarn: Mock; - let originalCwd: string; - const prevWarehouse = process.env.DATABRICKS_WAREHOUSE_ID; - - beforeEach(() => { - vi.clearAllMocks(); - clackMocks.textAnswers = []; - originalCwd = process.cwd(); - tmpRoot = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "metric-sync-cli-")), - ); - queryFolder = path.join(tmpRoot, "config", "queries"); - fs.mkdirSync(queryFolder, { recursive: true }); - delete process.env.DATABRICKS_WAREHOUSE_ID; - // `--root-dir` was dropped in Phase 3; the command resolves cwd-relative - // paths against process.cwd(), so anchor cwd at the temp root (mirrors - // promote.test.ts). - process.chdir(tmpRoot); - - consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}) as Mock; - consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => {}) as Mock; - consoleWarn = vi - .spyOn(console, "warn") - .mockImplementation(() => {}) as Mock; - }); - - afterEach(() => { - process.chdir(originalCwd); - vi.restoreAllMocks(); - fs.rmSync(tmpRoot, { recursive: true, force: true }); - if (prevWarehouse === undefined) { - delete process.env.DATABRICKS_WAREHOUSE_ID; - } else { - process.env.DATABRICKS_WAREHOUSE_ID = prevWarehouse; - } - }); - - const writeConfig = () => { - fs.writeFileSync( - path.join(queryFolder, "metric-views.json"), - JSON.stringify({ - metricViews: { revenue: { source: "demo.sales.revenue" } }, - }), - ); - }; - - // --- Non-interactive: flag parsing + option mapping ------------------------ - - test("calls the appkit entry with resolved paths and writes both artifacts", async () => { - writeConfig(); - - await runCli(["--warehouse-id", "wh-123"]); - - const expectedMetricOut = path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ); - const expectedMetadataOut = path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ); - - // The appkit entry was called once with the resolved options. Cache is the - // commander default; after the test harness reset it is undefined (no flag, - // source cleared) — i.e. caching stays ON downstream. - expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); - expect(syncMetricViewsTypes).toHaveBeenCalledWith({ - queryFolder, - warehouseId: "wh-123", - metricOutFile: expectedMetricOut, - metricMetadataOutFile: expectedMetadataOut, - cache: undefined, - }); - - // Both artifacts landed in the temp dir. - expect(fs.existsSync(expectedMetricOut)).toBe(true); - expect(fs.existsSync(expectedMetadataOut)).toBe(true); - }); - - test("falls back to DATABRICKS_WAREHOUSE_ID when --warehouse-id is omitted (and stays non-interactive via another flag)", async () => { - writeConfig(); - process.env.DATABRICKS_WAREHOUSE_ID = "wh-env"; - - // Pass --output-dir so the env var alone doesn't have to force - // non-interactive (it must not — see the dedicated interactive test). - await runCli(["--output-dir", "shared/appkit-types"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ warehouseId: "wh-env" }), - ); - }); - - test("honors --metric-views-json-path / --output-dir overrides for path resolution", async () => { - const customConfigDir = path.join(tmpRoot, "custom", "cfg"); - fs.mkdirSync(customConfigDir, { recursive: true }); - fs.writeFileSync( - path.join(customConfigDir, "metric-views.json"), - JSON.stringify({ metricViews: {} }), - ); - - await runCli([ - "--warehouse-id", - "wh-123", - "--metric-views-json-path", - "custom/cfg/metric-views.json", - "--output-dir", - "build/types", - ]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith({ - queryFolder: customConfigDir, - warehouseId: "wh-123", - metricOutFile: path.join(tmpRoot, "build", "types", "metric-views.d.ts"), - metricMetadataOutFile: path.join( - tmpRoot, - "build", - "types", - "metric-views.metadata.json", - ), - cache: undefined, - }); - }); - - test("absolute --metric-views-json-path / --output-dir are used as-is", async () => { - const absConfig = path.join( - tmpRoot, - "config", - "queries", - "metric-views.json", - ); - writeConfig(); - const absOut = path.join(tmpRoot, "abs-out"); - - await runCli([ - "--warehouse-id", - "wh-123", - "--metric-views-json-path", - absConfig, - "--output-dir", - absOut, - ]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ - queryFolder: path.dirname(absConfig), - metricOutFile: path.join(absOut, "metric-views.d.ts"), - metricMetadataOutFile: path.join(absOut, "metric-views.metadata.json"), - }), - ); - }); - - test("friendly message + no appkit call when metric-views.json is absent", async () => { - await runCli(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - const logged = consoleLog.mock.calls.flat().map(String).join("\n"); - expect(logged).toContain("Nothing to sync"); - }); - - test("appkit absent: recognizable error message + non-zero exit", async () => { - writeConfig(); - // Model a module-resolution failure reaching the catch, as the dynamic - // `import("@databricks/appkit/type-generator")` throws when @databricks/appkit - // isn't installed: a structured ERR_MODULE_NOT_FOUND naming the package. Only - // THIS shape maps to the "not installed" guidance (see the next test). - const moduleNotFound = Object.assign( - new Error( - "Cannot find package '@databricks/appkit' imported from /app/cli.js", - ), - { code: "ERR_MODULE_NOT_FOUND" }, - ); - syncMetricViewsTypes.mockRejectedValueOnce(moduleNotFound); - - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never); - - await runCli(["--warehouse-id", "wh-123"]); - - const errored = consoleError.mock.calls.flat().map(String).join("\n"); - expect(errored).toContain( - "appkit metric-views sync is only available with @databricks/appkit installed", - ); - expect(exitSpy).toHaveBeenCalledWith(1); - - exitSpy.mockRestore(); - }); - - test("a non-module sync error propagates verbatim (not misreported as missing appkit)", async () => { - writeConfig(); - // A real failure from syncMetricViewsTypes — e.g. an unreachable warehouse — - // carries its own message and no module-resolution code. It must surface - // verbatim with a non-zero exit, NOT be rewritten as "install appkit" just - // because some unrelated message might mention a module. - syncMetricViewsTypes.mockRejectedValueOnce( - new Error("Warehouse wh-123 is unreachable (timed out after 30s)"), - ); - - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never); - - await runCli(["--warehouse-id", "wh-123"]); - - const errored = consoleError.mock.calls.flat().map(String).join("\n"); - expect(errored).toContain("Warehouse wh-123 is unreachable"); - expect(errored).not.toContain( - "only available with @databricks/appkit installed", - ); - expect(exitSpy).toHaveBeenCalledWith(1); - - exitSpy.mockRestore(); - }); - - // --- --no-cache propagation ------------------------------------------------ - - test("--no-cache forwards cache: false to syncMetricViewsTypes", async () => { - writeConfig(); - - await runCli(["--warehouse-id", "wh-123", "--no-cache"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ cache: false }), - ); - }); - - test("without --no-cache, cache is not disabled (default ON downstream)", async () => { - writeConfig(); - - await runCli(["--warehouse-id", "wh-123"]); - - const call = syncMetricViewsTypes.mock.calls[0][0] as { cache?: boolean }; - // Either undefined (harness reset clears the default source) or true (live - // commander default) — the load-bearing invariant is that it is NOT false. - expect(call.cache).not.toBe(false); - }); - - // --- Phase 2: error taxonomy ------------------------------------------------ - // Every error mode exits non-zero (1) with a distinct, recognizable message. - // The command always `return`s right after `process.exit`, so a no-op exit - // spy lets execution stop cleanly and we assert the captured code + message. - - /** - * Drive the CLI with `process.exit` spied to a no-op (the command returns - * immediately after calling it), returning the spy so the test can assert the - * exit code and the captured stderr. - */ - async function runCliCapturingExit(args: string[]): Promise { - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never) as unknown as Mock; - await runCli(args); - return exitSpy; - } - - const erroredText = () => - consoleError.mock.calls.flat().map(String).join("\n"); - - const warnedText = () => consoleWarn.mock.calls.flat().map(String).join("\n"); - - test("explicit --metric-views-json-path to a missing file: non-zero + recognizable message", async () => { - const missing = path.join(tmpRoot, "nowhere", "metric-views.json"); - - const exitSpy = await runCliCapturingExit([ - "--warehouse-id", - "wh-123", - "--metric-views-json-path", - missing, - ]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(erroredText()).toContain("metric-views.json not found"); - }); - - test("explicit --metric-views-json-path with a non-metric-views.json basename: rejected before syncing", async () => { - // A valid config that EXISTS but is not named metric-views.json. The appkit - // reader resolves `/metric-views.json`, so without the basename guard - // the CLI would validate this file but sync a different (sibling/absent) one. - const customDir = path.join(tmpRoot, "custom"); - fs.mkdirSync(customDir, { recursive: true }); - fs.writeFileSync( - path.join(customDir, "my-config.json"), - JSON.stringify({ - metricViews: { revenue: { source: "demo.sales.revenue" } }, - }), - ); - - const exitSpy = await runCliCapturingExit([ - "--warehouse-id", - "wh-123", - "--metric-views-json-path", - "custom/my-config.json", - ]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(erroredText()).toContain( - "must point to a file named metric-views.json", - ); - }); - - test("malformed JSON: non-zero + 'not valid JSON' message, never imports appkit", async () => { - fs.writeFileSync( - path.join(queryFolder, "metric-views.json"), - "{ this is not json", - ); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(erroredText()).toContain("is not valid JSON"); - }); - - test("schema-invalid config (bad FQN): non-zero + path:message list, never imports appkit", async () => { - fs.writeFileSync( - path.join(queryFolder, "metric-views.json"), - // Two-part FQN — fails the three-part UC FQN grammar. - JSON.stringify({ metricViews: { revenue: { source: "main.cm" } } }), - ); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - const errored = erroredText(); - expect(errored).toContain("invalid"); - // CLI path of the failing field. - expect(errored).toContain("metricViews.revenue.source"); - }); - - test("schema-invalid config (unknown executor): non-zero + path:message list", async () => { - fs.writeFileSync( - path.join(queryFolder, "metric-views.json"), - JSON.stringify({ - metricViews: { revenue: { source: "main.a.cm", executor: "robot" } }, - }), - ); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(erroredText()).toContain("metricViews.revenue.executor"); - }); - - test("missing warehouse id (after a valid config): non-zero + recognizable message", async () => { - writeConfig(); - // No --warehouse-id and no DATABRICKS_WAREHOUSE_ID (cleared in beforeEach). - // Pass --output-dir so the run is non-interactive (no flag → interactive). - - const exitSpy = await runCliCapturingExit([ - "--output-dir", - "shared/appkit-types", - ]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(erroredText()).toContain("no warehouse ID"); - }); - - test("absent DEFAULT config with no warehouse id: still exits 0 (dormancy invariant)", async () => { - // No config file, no warehouse id — the additive path must stay dormant and - // NOT error on the missing warehouse. Pass a flag so this stays - // non-interactive (the dormancy decision is path-independent). - const exitSpy = await runCliCapturingExit([ - "--output-dir", - "shared/appkit-types", - ]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - // Dormancy takes the early-return path, so exit() is never called at all. - expect(exitSpy).not.toHaveBeenCalled(); - const logged = consoleLog.mock.calls.flat().map(String).join("\n"); - expect(logged).toContain("Nothing to sync"); - }); - - test("unreachable warehouse / auth failure (syncMetricViewsTypes throws): non-zero + message surfaced", async () => { - writeConfig(); - syncMetricViewsTypes.mockRejectedValueOnce( - new Error("warehouse wh-123 is unreachable: connection refused"), - ); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); - expect(exitSpy).toHaveBeenCalledWith(1); - // The action wrapper prints the thrown error's message verbatim. - expect(erroredText()).toContain( - "warehouse wh-123 is unreachable: connection refused", - ); - }); - - test("per-entry DESCRIBE failure (unreachable FQN): non-zero + lists the failed metric", async () => { - writeConfig(); - // The appkit export writes degraded types and returns `failures` rather - // than throwing for a missing/unreachable metric view FQN. - syncMetricViewsTypes.mockResolvedValueOnce({ - metricOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ), - metricMetadataOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ), - schemas: [], - failures: [ - { - key: "revenue", - source: "demo.sales.revenue", - reason: "TABLE_OR_VIEW_NOT_FOUND", - transient: false, - }, - ], - fatalErrors: [], - noConfig: false, - }); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); - expect(exitSpy).toHaveBeenCalledWith(1); - const errored = erroredText(); - expect(errored).toContain("could not be described"); - expect(errored).toContain("revenue"); - expect(errored).toContain("TABLE_OR_VIEW_NOT_FOUND"); - }); - - test("degraded-but-not-failed (warehouse not ready): warns and exits 0 with permissive types", async () => { - writeConfig(); - // A not-ready warehouse returns no schema for a key WITHOUT a hard failure: - // `syncMetricViewsTypes` writes permissive (degraded) types and reports them - // via `schemas[].degraded` with an empty `failures` list. Unlike a per-entry - // DESCRIBE failure, the CLI must treat this as a WARNING (exit 0), not a hard - // failure — the entries refresh on a rerun once the warehouse is available. - syncMetricViewsTypes.mockResolvedValueOnce({ - metricOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ), - metricMetadataOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ), - schemas: [ - { - key: "revenue", - source: "demo.sales.revenue", - lane: "sp", - degraded: true, - }, - ], - failures: [], - fatalErrors: [], - noConfig: false, - }); - - const exitSpy = await runCliCapturingExit(["--warehouse-id", "wh-123"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledTimes(1); - // Degraded is a warning, NOT a hard failure — the command exits 0. - expect(exitSpy).not.toHaveBeenCalled(); - const warned = warnedText(); - expect(warned).toContain("could not be described"); - expect(warned).toContain("revenue"); - expect(warned).toContain("permissive"); - expect(warned).toContain("Rerun"); - }); - - // --- --wait (blocking preflight): CI opt-in to fail instead of degrade ------ - - test("--wait forwards mode: 'blocking' to syncMetricViewsTypes", async () => { - writeConfig(); - - await runCli(["--warehouse-id", "wh-123", "--wait"]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ mode: "blocking" }), - ); - }); - - test("without --wait, mode stays describe-now (never blocking)", async () => { - writeConfig(); - - await runCli(["--warehouse-id", "wh-123"]); - - const call = syncMetricViewsTypes.mock.calls[0][0] as { mode?: string }; - // describe-now is appkit's default; the CLI omits the key entirely. - expect(call.mode).not.toBe("blocking"); - }); - - test("--wait + still-degraded result: hard failure (exit 1), not a warning", async () => { - writeConfig(); - // Even after waiting, a key came back degraded (e.g. a served degraded cache - // hit). Under --wait the CLI must fail rather than ship permissive types. - syncMetricViewsTypes.mockResolvedValueOnce({ - metricOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ), - metricMetadataOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ), - schemas: [ - { - key: "revenue", - source: "demo.sales.revenue", - lane: "sp", - degraded: true, - }, - ], - failures: [], - fatalErrors: [], - noConfig: false, - }); - - const exitSpy = await runCliCapturingExit([ - "--warehouse-id", - "wh-123", - "--wait", - ]); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errored = erroredText(); - expect(errored).toContain("could not be described even after waiting"); - expect(errored).toContain("revenue"); - }); - - test("--wait + fatal preflight (e.g. deleted warehouse): exit 1 with the reason", async () => { - writeConfig(); - // blocking mode reports a deleted/deleting warehouse via fatalErrors (the - // artifacts are still written); the CLI surfaces it and exits non-zero. - syncMetricViewsTypes.mockResolvedValueOnce({ - metricOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ), - metricMetadataOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ), - schemas: [], - failures: [], - fatalErrors: [{ name: "wh-123", message: "Warehouse is DELETED" }], - noConfig: false, - }); - - const exitSpy = await runCliCapturingExit([ - "--warehouse-id", - "wh-123", - "--wait", - ]); - - expect(exitSpy).toHaveBeenCalledWith(1); - const errored = erroredText(); - expect(errored).toContain("warehouse preflight failed"); - expect(errored).toContain("Warehouse is DELETED"); - }); - - // --- Phase 3: interactive flow ---------------------------------------------- - - test("no flags → interactive: prompts fire and resolved values reach syncMetricViewsTypes", async () => { - writeConfig(); - // Answers in prompt order: warehouse id, config path (blank → default), - // output dir (blank → default). - clackMocks.textAnswers = ["wh-interactive", "", ""]; - - await runCli([]); - - // intro/outro + three text prompts fired. - expect(clackMocks.intro).toHaveBeenCalledTimes(1); - expect(clackMocks.text).toHaveBeenCalledTimes(3); - expect(clackMocks.outro).toHaveBeenCalledTimes(1); - // Spinner wrapped the sync. - expect(clackMocks.spinnerStart).toHaveBeenCalledTimes(1); - expect(clackMocks.spinnerStop).toHaveBeenCalledTimes(1); - - // The interactive answer reached the appkit entry; blank path answers fell - // back to the canonical defaults (cwd-anchored). - expect(syncMetricViewsTypes).toHaveBeenCalledWith({ - queryFolder, - warehouseId: "wh-interactive", - metricOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.d.ts", - ), - metricMetadataOutFile: path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ), - cache: true, - }); - - // #1: in interactive mode the spinner carries the success line; the plain - // "Generated metric types" console.log must NOT also fire (no double print). - expect(clackMocks.spinnerStop).toHaveBeenCalledWith( - expect.stringContaining("Generated metric types"), - ); - const logged = consoleLog.mock.calls.flat().map(String).join("\n"); - expect(logged).not.toContain("Generated metric types"); - }); - - test("interactive: a non-blank config path / output dir answer is honored", async () => { - const customConfigDir = path.join(tmpRoot, "alt", "cfg"); - fs.mkdirSync(customConfigDir, { recursive: true }); - fs.writeFileSync( - path.join(customConfigDir, "metric-views.json"), - JSON.stringify({ metricViews: {} }), - ); - clackMocks.textAnswers = [ - "wh-interactive", - "alt/cfg/metric-views.json", - "alt/out", - ]; - - await runCli([]); - - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ - queryFolder: customConfigDir, - warehouseId: "wh-interactive", - metricOutFile: path.join(tmpRoot, "alt", "out", "metric-views.d.ts"), - }), - ); - }); - - test("interactive: env var alone does NOT force non-interactive (prompts still run)", async () => { - writeConfig(); - process.env.DATABRICKS_WAREHOUSE_ID = "wh-env"; - // Blank warehouse answer → falls back to the env var downstream. - clackMocks.textAnswers = ["", "", ""]; - - await runCli([]); - - expect(clackMocks.intro).toHaveBeenCalledTimes(1); - expect(clackMocks.text).toHaveBeenCalledTimes(3); - expect(syncMetricViewsTypes).toHaveBeenCalledWith( - expect.objectContaining({ warehouseId: "wh-env" }), - ); - }); - - test("interactive + no metric-views.json: outro matches the no-op (not a false 'synced')", async () => { - // No writeConfig() → the default config path is absent (dormant). - clackMocks.textAnswers = ["wh-interactive", "", ""]; - - await runCli([]); - - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - // A dormant run must not claim success — the outro reflects the no-op. - expect(clackMocks.outro).toHaveBeenCalledTimes(1); - expect(clackMocks.outro).toHaveBeenCalledWith("Nothing to sync."); - expect(clackMocks.outro).not.toHaveBeenCalledWith("Metric types synced."); - const logged = consoleLog.mock.calls.flat().map(String).join("\n"); - expect(logged).toContain("Nothing to sync"); - }); - - test("interactive cancel (first prompt): graceful cancel + exit 0, no appkit call", async () => { - writeConfig(); - // First prompt returns the cancel symbol. - clackMocks.textAnswers = [clackMocks.CANCEL]; - - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never) as unknown as Mock; - - await runCli([]); - - expect(clackMocks.cancel).toHaveBeenCalledTimes(1); - // Cancel is graceful: exit 0, like every other interactive command. - expect(exitSpy).toHaveBeenCalledWith(0); - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - }); - - test("interactive cancel (later prompt): graceful cancel + exit 0", async () => { - writeConfig(); - // First answer ok, second prompt cancels. - clackMocks.textAnswers = ["wh-1", clackMocks.CANCEL]; - - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never) as unknown as Mock; - - await runCli([]); - - expect(clackMocks.cancel).toHaveBeenCalledTimes(1); - // Cancel is graceful: exit 0, like every other interactive command. - expect(exitSpy).toHaveBeenCalledWith(0); - expect(syncMetricViewsTypes).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/shared/src/cli/commands/metric-views/sync/sync.ts b/packages/shared/src/cli/commands/metric-views/sync/sync.ts deleted file mode 100644 index 991da7610..000000000 --- a/packages/shared/src/cli/commands/metric-views/sync/sync.ts +++ /dev/null @@ -1,471 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import { cancel, intro, isCancel, outro, spinner, text } from "@clack/prompts"; -import { Command } from "commander"; -import { - formatMetricViewsSourceErrors, - validateMetricViewsSource, -} from "../validate-metric-views-source"; - -/** - * Options parsed by commander for `appkit metric-views sync` (alias `mv`). - * - * Flags: - * - `--warehouse-id` (+ `DATABRICKS_WAREHOUSE_ID` fallback) - * - `--metric-views-json-path` (canonical config path) - * - `--output-dir` (artifact output directory; replaces Phase 1's `--out-dir`) - * - `--wait` (wait for the warehouse instead of emitting permissive types on a - * cold warehouse; exit non-zero if any view still can't be described) - * - `--no-cache` (commander negation → `cache === false` disables the metric type-generation cache) - */ -export interface MetricViewsSyncOptions { - warehouseId?: string; - metricViewsJsonPath?: string; - outputDir?: string; - wait?: boolean; - cache?: boolean; -} - -const METRIC_VIEWS_CONFIG_FILE = "metric-views.json"; - -const EXIT_FAILURE = 1; - -/** - * What a sync run actually did, returned so the interactive caller can print an - * outro that matches the outcome (a dormant run must not claim "synced"). - * `undefined` means a hard-failure branch already called `process.exit` — it - * only "returns" under the test no-op exit spy. - */ -type SyncOutcome = "synced" | "degraded" | "noop"; - -/** Resolved, absolute paths the sync run operates on. */ -interface ResolvedPaths { - /** Folder that holds metric-views.json (the appkit export reads from a folder). */ - queryFolder: string; - /** Absolute path to metric-views.json. */ - configPath: string; - /** Whether the config path came from an explicit `--metric-views-json-path`. */ - explicitConfigPath: boolean; - /** Output directory for the generated artifacts. */ - outDir: string; -} - -/** - * Resolve config + output paths from the (interactive- or flag-supplied) - * options. Relative paths resolve against `process.cwd()` (Phase 1's - * `--root-dir` was dropped in Phase 3). - */ -function resolvePaths(options: { - metricViewsJsonPath?: string; - outputDir?: string; -}): ResolvedPaths { - const cwd = process.cwd(); - - const explicitConfigPath = options.metricViewsJsonPath !== undefined; - const configPath = options.metricViewsJsonPath - ? path.isAbsolute(options.metricViewsJsonPath) - ? options.metricViewsJsonPath - : path.resolve(cwd, options.metricViewsJsonPath) - : path.join(cwd, "config", "queries", METRIC_VIEWS_CONFIG_FILE); - - const outDir = options.outputDir - ? path.isAbsolute(options.outputDir) - ? options.outputDir - : path.resolve(cwd, options.outputDir) - : path.join(cwd, "shared", "appkit-types"); - - return { - queryFolder: path.dirname(configPath), - configPath, - explicitConfigPath, - outDir, - }; -} - -/** - * The shared sync core for BOTH the interactive and non-interactive paths: - * resolve paths → existence check (dormancy vs missing) → read + `JSON.parse` - * → schema-validate → require warehouse → ONLY THEN dynamic-import appkit + - * `syncMetricViewsTypes`. - * Reaches the appkit metric-sync core through a dynamic - * `import("@databricks/appkit/type-generator")` — the exact pattern - * `generate-types.ts` uses — so the `shared` CLI package carries NO static - * dependency on `@databricks/appkit` and compiles without it. - * - * Validation runs entirely before the dynamic import, so a misconfigured - * `metric-views.json` fails fast with a precise message and never touches a - * warehouse (or even requires appkit to be installed). - * - * `onProgress` lets the interactive path drive a clack spinner around the - * appkit call; the non-interactive path passes nothing (plain console logs). - */ -async function runMetricViewsSync( - options: MetricViewsSyncOptions, - onProgress?: { start(): void; succeed(msg: string): void; fail(): void }, -): Promise { - try { - const { queryFolder, configPath, explicitConfigPath, outDir } = - resolvePaths(options); - - // `--metric-views-json-path` selects WHICH metric-views.json to sync, but the - // appkit reader resolves `/metric-views.json` from the folder, so - // a differently-named file would be validated here yet never synced (appkit - // would read a sibling metric-views.json, or none). Reject the mismatch - // explicitly instead of silently validating one file and syncing another. - if ( - explicitConfigPath && - path.basename(configPath) !== METRIC_VIEWS_CONFIG_FILE - ) { - console.error( - `Error: --metric-views-json-path must point to a file named ${METRIC_VIEWS_CONFIG_FILE} (got "${path.basename(configPath)}").`, - ); - process.exit(EXIT_FAILURE); - return; - } - - // Check existence before requiring a warehouse. Missing default config means - // metric views are unused; a missing explicit path is an error. - if (!fs.existsSync(configPath)) { - if (explicitConfigPath) { - console.error(`Error: metric-views.json not found at ${configPath}.`); - process.exit(EXIT_FAILURE); - return; - } - console.log( - `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, - ); - return "noop"; - } - - const rawConfig = fs.readFileSync(configPath, "utf-8"); - let parsedConfig: unknown; - try { - parsedConfig = JSON.parse(rawConfig); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - console.error(`Error: ${configPath} is not valid JSON: ${reason}`); - process.exit(EXIT_FAILURE); - return; - } - - // Schema-validate against the canonical metricSourceSchema BEFORE the dynamic import. - const validation = validateMetricViewsSource(parsedConfig); - if (!validation.valid) { - console.error(`Error: invalid ${configPath}:`); - console.error(formatMetricViewsSourceErrors(validation.errors)); - process.exit(EXIT_FAILURE); - return; - } - - // The warehouse is only needed once we have a valid config to sync; require it here (after dormancy + validation) so a dormant/invalid project never trips on a missing warehouse. - const warehouseId = - options.warehouseId || process.env.DATABRICKS_WAREHOUSE_ID; - if (!warehouseId) { - console.error( - "Error: no warehouse ID. Pass --warehouse-id or set DATABRICKS_WAREHOUSE_ID.", - ); - process.exit(EXIT_FAILURE); - return; - } - - const typeGen = await import("@databricks/appkit/type-generator"); - - const metricOutFile = path.join(outDir, typeGen.METRIC_TYPES_FILE); - const metricMetadataOutFile = path.join( - outDir, - typeGen.METRIC_METADATA_FILE, - ); - - onProgress?.start(); - let result: Awaited>; - try { - result = await typeGen.syncMetricViewsTypes({ - queryFolder, - metricOutFile, - metricMetadataOutFile, - warehouseId, - cache: options.cache, - // `--wait` → blocking preflight (wait for / start the warehouse). The - // default omits `mode` so appkit uses "describe-now" (DESCRIBE now, - // degrade on a cold warehouse) — and the default call shape is unchanged. - ...(options.wait ? { mode: "blocking" as const } : {}), - }); - } catch (err) { - onProgress?.fail(); - throw err; - } - - if (result.noConfig) { - // Defensive: the existence check above already handled the dormant case, - // but syncMetricViewsTypes re-checks the folder, so honor its verdict too. - onProgress?.fail(); - console.log( - `No ${METRIC_VIEWS_CONFIG_FILE} found at ${configPath}. Nothing to sync.`, - ); - return "noop"; - } - - // Fatal preflight errors only arise under `--wait` (blocking mode) — e.g. - // the warehouse is deleted/deleting. The artifacts are still written, but - // the run did not converge, so exit non-zero with the underlying reason. - if (result.fatalErrors.length > 0) { - onProgress?.fail(); - console.error( - "Error: could not sync metric views (warehouse preflight failed):", - ); - for (const fe of result.fatalErrors) { - console.error(` ${fe.name}: ${fe.message}`); - } - process.exit(EXIT_FAILURE); - return; - } - - // Per-entry DESCRIBE failures (missing/unreachable FQN, type errors against - // a reachable warehouse) come back in `failures` rather than thrown — the - // appkit export writes permissive/degraded types and returns. Surface them - // as a hard failure so a misconfigured FQN does not silently ship. - if (result.failures.length > 0) { - onProgress?.fail(); - console.error( - `Error: ${result.failures.length} metric view(s) could not be described:`, - ); - for (const failure of result.failures) { - console.error( - ` ${failure.key} (${failure.source}): ${failure.reason}`, - ); - } - process.exit(EXIT_FAILURE); - return; - } - - // Degraded-but-not-failed (e.g. a not-ready warehouse returned no schema for - // some keys): the permissive types ARE written, so unlike `result.failures` - // above this is a warning — not a hard failure — and the command still exits - // 0. The degraded entries refresh on a rerun once the warehouse is available. - const degradedKeys = result.schemas - .filter((schema) => schema.degraded) - .map((schema) => schema.key); - if (degradedKeys.length > 0) { - // `--wait` promised to wait for the warehouse and emit correct types, so a - // still-degraded result is a hard failure — never silently ship permissive - // types in CI. Without `--wait` (describe-now) the same state is a warning. - if (options.wait) { - onProgress?.fail(); - console.error( - `Error: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described even after waiting for the warehouse. Check the warehouse state and the metric view definitions, then rerun.`, - ); - process.exit(EXIT_FAILURE); - return; - } - onProgress?.succeed( - `Generated permissive metric types: ${metricOutFile}`, - ); - console.warn( - `Warning: ${degradedKeys.length} metric view(s) (${degradedKeys.join(", ")}) could not be described — the warehouse wasn't ready, so permissive types were written. Rerun \`appkit metric-views sync\` once the warehouse is available.`, - ); - // In interactive mode the spinner's success line is the user-facing - // output; emit the plain metadata path only on the flag-driven path. - if (!onProgress) { - console.log(`Generated metric metadata: ${metricMetadataOutFile}`); - } - return "degraded"; - } - - onProgress?.succeed(`Generated metric types: ${metricOutFile}`); - // Avoid a double print in interactive mode: `onProgress.succeed` already - // stopped the spinner with the "Generated metric types" line. The plain logs - // are the flag-driven path's only output, so gate them on its absence. - if (!onProgress) { - console.log(`Generated metric types: ${metricOutFile}`); - console.log(`Generated metric metadata: ${metricMetadataOutFile}`); - } - return "synced"; - } catch (error) { - // Only a genuinely missing/unresolvable @databricks/appkit maps to the - // "not installed" guidance. The dynamic import throws a structured - // ERR_MODULE_NOT_FOUND naming the package when appkit is absent; match THAT - // rather than any message containing "Cannot find module". Otherwise a real - // failure from syncMetricViewsTypes — an unreachable warehouse, an auth - // error, a bad FQN — whose message happens to mention a module would be - // misreported here, sending the user to reinstall a package that is present. - const err = error as { code?: unknown; message?: unknown }; - if ( - err?.code === "ERR_MODULE_NOT_FOUND" && - typeof err.message === "string" && - err.message.includes("@databricks/appkit") - ) { - console.error( - "Error: appkit metric-views sync is only available with @databricks/appkit installed.", - ); - console.error("Please install @databricks/appkit to use this command."); - process.exit(EXIT_FAILURE); - return; - } - // Errors thrown by syncMetricViewsTypes — an unreachable warehouse, an auth - // failure, or a fatal DESCRIBE setup problem (TypegenFatalError) — carry - // their own recognizable message. Re-throw so the action wrapper prints it - // and exits non-zero, keeping the message verbatim. - throw error; - } -} - -/** - * Interactive flow (mirrors `plugin create`'s `runInteractive`): `intro` → - * `text` prompts (warehouse id, config path, output dir, each guarded by - * `isCancel`) → `spinner` around the sync → `outro`. Each prompt's value is - * folded back into {@link MetricViewsSyncOptions} and handed to {@link runMetricViewsSync} - * — the SAME validation + taxonomy + appkit call the flag path uses. - * - * Empty answers fall through as `undefined`, so the warehouse prompt's blank - * input still lets the `DATABRICKS_WAREHOUSE_ID` fallback apply, and blank - * path prompts use the canonical defaults. - */ -async function runInteractive(): Promise { - intro("Sync UC Metric View types"); - - // A cancelled prompt (Ctrl-C) is a graceful exit (code 0) — matching - // `plugin create` / `add-resource` and clack's own convention, so a wrapper - // script that treats non-zero as failure doesn't misread a deliberate cancel. - // The explicit `return` after `process.exit` keeps control flow correct under - // a no-op exit (tests) — without it, a cancelled flow would fall through to - // the next prompt and eventually run the sync. - const cancelled = (): never => { - cancel("Cancelled."); - process.exit(0); - }; - - const warehouseId = await text({ - message: "SQL Warehouse ID", - placeholder: process.env.DATABRICKS_WAREHOUSE_ID - ? `${process.env.DATABRICKS_WAREHOUSE_ID} (from DATABRICKS_WAREHOUSE_ID)` - : "your-warehouse-id", - }); - if (isCancel(warehouseId)) return cancelled(); - - const metricViewsJsonPath = await text({ - message: "Path to metric-views.json", - placeholder: "config/queries/metric-views.json", - }); - if (isCancel(metricViewsJsonPath)) return cancelled(); - - const outputDir = await text({ - message: "Output directory for generated types", - placeholder: "shared/appkit-types", - }); - if (isCancel(outputDir)) return cancelled(); - - const trimmed = (value: string | symbol): string | undefined => { - if (typeof value !== "string") return undefined; - const t = value.trim(); - return t.length > 0 ? t : undefined; - }; - - const options: MetricViewsSyncOptions = { - warehouseId: trimmed(warehouseId), - metricViewsJsonPath: trimmed(metricViewsJsonPath), - outputDir: trimmed(outputDir), - cache: true, - }; - - const s = spinner(); - const outcome = await runMetricViewsSync(options, { - start: () => s.start("Describing metric views…"), - succeed: (msg) => s.stop(msg), - fail: () => s.stop("Failed."), - }); - - // Close with an outro that matches what actually happened — a dormant run - // logged "Nothing to sync", so a blanket "Metric types synced." would - // contradict it. `undefined` means a hard-failure branch already exited. - if (outcome === "noop") { - outro("Nothing to sync."); - } else if (outcome === "degraded") { - outro("Metric types synced with warnings."); - } else if (outcome === "synced") { - outro("Metric types synced."); - } -} - -/** - * Entry point for the `metric sync` action. Detection mirrors `plugin create`'s - * interactive-vs-non-interactive split, but keys on commander's per-option value - * SOURCE (`getOptionValueSource(name) === "cli"`) rather than presence: - * - NO user-provided flag (every option's source is `default`/absent) → - * interactive prompts. - * - ANY user-provided flag → the flag-driven (non-interactive) path. - * - * Keying on the `cli` source (not value presence) is deliberate: a - * `DATABRICKS_WAREHOUSE_ID` env default does NOT populate any CLI option, so it - * never forces non-interactive; and `--no-cache`'s default (`cache: true`, - * source `default`) is correctly ignored, while an explicit `--no-cache` - * (source `cli`) does select non-interactive. - */ -async function runMetricViewsSyncAction( - options: MetricViewsSyncOptions, - command: Command, -): Promise { - const FLAG_OPTION_NAMES = [ - "warehouseId", - "metricViewsJsonPath", - "outputDir", - "wait", - "cache", - ] as const; - const hasUserFlag = FLAG_OPTION_NAMES.some( - (name) => command.getOptionValueSource(name) === "cli", - ); - - if (hasUserFlag) { - await runMetricViewsSync(options); - } else { - await runInteractive(); - } -} - -export const metricViewsSyncCommand = new Command("sync") - .description( - "Sync UC Metric View schemas: DESCRIBE every entry in metric-views.json, then emit metric-views.d.ts + metric-views.metadata.json.", - ) - .option( - "--warehouse-id ", - "Databricks SQL Warehouse ID (overrides DATABRICKS_WAREHOUSE_ID env var)", - ) - .option( - "--metric-views-json-path ", - "Path to metric-views.json (default: config/queries/metric-views.json)", - ) - .option( - "--output-dir ", - "Output directory for metric-views.d.ts and metric-views.metadata.json (default: shared/appkit-types)", - ) - .option( - "--wait", - "Wait for the SQL warehouse to start instead of emitting permissive types on a cold warehouse; exit non-zero if any metric view still can't be described", - ) - .option("--no-cache", "Disable the metric type-generation cache") - .addHelpText( - "after", - ` -Run with no flags for an interactive prompt; pass any flag for non-interactive mode. -'mv' is a shorthand alias, so 'appkit mv sync' is equivalent to 'appkit metric-views sync'. - -Examples: - $ appkit metric-views sync - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --metric-views-json-path config/queries/metric-views.json - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --output-dir shared/appkit-types - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --wait - $ appkit metric-views sync --warehouse-id 1234abcd5678efgh --no-cache - $ appkit mv sync --warehouse-id 1234abcd5678efgh # 'mv' alias - -Environment variables: - DATABRICKS_WAREHOUSE_ID SQL warehouse ID (used when --warehouse-id is omitted) - DATABRICKS_HOST Databricks workspace URL (consumed by the SDK)`, - ) - .action((opts: MetricViewsSyncOptions, command: Command) => - runMetricViewsSyncAction(opts, command).catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(EXIT_FAILURE); - }), - ); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts deleted file mode 100644 index f6b88c9d4..000000000 --- a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - formatMetricViewsPath, - formatMetricViewsSourceErrors, - validateMetricViewsSource, -} from "./validate-metric-views-source"; - -/** - * Fixtures are derived from the ACTUAL canonical schema - * (`packages/shared/src/schemas/metric-source.ts`) and the UC FQN grammar - * (`packages/shared/src/schemas/metric-fqn.ts`): - * - FQN: exactly three dot-separated segments; each segment may contain any - * character EXCEPT ASCII control chars (U+0000-U+001F), space (U+0020), - * forward slash, period, or DELETE (U+007F). Non-ASCII letters and hyphens - * are explicitly legal. - * - executor: enum "app_service_principal" (default) | "user". - * - metric key (record key): /^[a-zA-Z_][a-zA-Z0-9_]*$/. - * - root + entry objects are .strict() — unknown keys are rejected. - */ - -describe("validateMetricViewsSource", () => { - describe("accepts", () => { - test("a valid three-part FQN with the default executor", () => { - const result = validateMetricViewsSource({ - metricViews: { - revenue: { source: "main.analytics.customer_metrics" }, - }, - }); - expect(result.valid).toBe(true); - }); - - test('executor: "user"', () => { - const result = validateMetricViewsSource({ - metricViews: { - revenue: { source: "main.analytics.cm", executor: "user" }, - }, - }); - expect(result.valid).toBe(true); - }); - - test('executor: "app_service_principal"', () => { - const result = validateMetricViewsSource({ - metricViews: { - revenue: { - source: "main.analytics.cm", - executor: "app_service_principal", - }, - }, - }); - expect(result.valid).toBe(true); - }); - - test("FQN segments with non-ASCII letters and hyphens (UC delimited-identifier grammar)", () => { - const result = validateMetricViewsSource({ - metricViews: { - // café (combining acute), prod-data (hyphen), métricas (non-ASCII). - rev: { source: "café.prod-data.métricas" }, - }, - }); - expect(result.valid).toBe(true); - }); - - test("a $schema key alongside metricViews", () => { - const result = validateMetricViewsSource({ - $schema: - "https://databricks.github.io/appkit/schemas/metric-source.schema.json", - metricViews: { revenue: { source: "main.a.cm" } }, - }); - expect(result.valid).toBe(true); - }); - - test("an empty metricViews map", () => { - const result = validateMetricViewsSource({ metricViews: {} }); - expect(result.valid).toBe(true); - }); - - test("a completely empty object (metricViews is optional)", () => { - const result = validateMetricViewsSource({}); - expect(result.valid).toBe(true); - }); - }); - - describe("rejects", () => { - test("an FQN segment containing a space", () => { - const result = validateMetricViewsSource({ - metricViews: { rev: { source: "main.bad name.cm" } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].path).toBe("metricViews.rev.source"); - }); - - test("an FQN segment containing a forward slash", () => { - const result = validateMetricViewsSource({ - metricViews: { rev: { source: "main.a/b.cm" } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - expect(result.errors[0].path).toBe("metricViews.rev.source"); - }); - - test("a two-part FQN (a literal dot inside what should be one segment)", () => { - const result = validateMetricViewsSource({ - metricViews: { rev: { source: "main.cm" } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - expect(result.errors[0].path).toBe("metricViews.rev.source"); - }); - - test("an unknown executor value", () => { - const result = validateMetricViewsSource({ - metricViews: { rev: { source: "main.a.cm", executor: "robot" } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - expect(result.errors[0].path).toBe("metricViews.rev.executor"); - }); - - test("an unknown entry key (entries are .strict())", () => { - const result = validateMetricViewsSource({ - metricViews: { rev: { source: "main.a.cm", ttl: 5 } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - // Unrecognized-keys issues attach to the containing object. - expect(result.errors[0].path).toBe("metricViews.rev"); - expect(result.errors[0].message.toLowerCase()).toContain("ttl"); - }); - - test("a metric key that is not a valid identifier", () => { - const result = validateMetricViewsSource({ - metricViews: { "1bad": { source: "main.a.cm" } }, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - expect(result.errors[0].path).toBe("metricViews.1bad"); - }); - - test("an unknown top-level key (root is .strict())", () => { - const result = validateMetricViewsSource({ - metricViews: {}, - unexpected: true, - }); - expect(result.valid).toBe(false); - if (result.valid) throw new Error("expected invalid"); - // Root-level issues render as "(root)". - expect(result.errors[0].path).toBe("(root)"); - }); - }); -}); - -describe("formatMetricViewsPath", () => { - test("empty path renders as (root)", () => { - expect(formatMetricViewsPath([])).toBe("(root)"); - expect(formatMetricViewsPath(undefined)).toBe("(root)"); - }); - - test("nested object keys join with dots", () => { - expect(formatMetricViewsPath(["metricViews", "revenue", "source"])).toBe( - "metricViews.revenue.source", - ); - }); - - test("numeric segments render as bracket indices", () => { - expect(formatMetricViewsPath(["a", 0, "b"])).toBe("a[0].b"); - }); -}); - -describe("formatMetricViewsSourceErrors", () => { - test("renders each issue as an indented `path: message` line", () => { - const out = formatMetricViewsSourceErrors([ - { path: "metricViews.revenue.source", message: "Invalid string" }, - { path: "(root)", message: 'Unrecognized key: "foo"' }, - ]); - expect(out).toBe( - ' metricViews.revenue.source: Invalid string\n (root): Unrecognized key: "foo"', - ); - }); - - test("an empty issue list renders as an empty string", () => { - expect(formatMetricViewsSourceErrors([])).toBe(""); - }); -}); diff --git a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts b/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts deleted file mode 100644 index 5ff0f0ae3..000000000 --- a/packages/shared/src/cli/commands/metric-views/validate-metric-views-source.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - type MetricSource, - metricSourceSchema, -} from "../../../schemas/metric-source"; - -/** - * A single metric-source validation issue. `path` is formatted for direct CLI - * output (e.g. `metricViews.revenue.source`), and `message` is the schema's own - * diagnostic. Mirrors the `SemanticIssue` shape used by the plugin-manifest - * validator so CLI output stays uniform across commands. - */ -export interface MetricViewsSourceIssue { - path: string; - message: string; -} - -/** Result of {@link validateMetricViewsSource}. */ -export type ValidateMetricViewsSourceResult = - | { valid: true; source: MetricSource } - | { valid: false; errors: MetricViewsSourceIssue[] }; - -/** - * Format a Zod issue path (array of object keys / array indices) as a CLI path - * like `metricViews.revenue.source`. Numeric segments render as `[n]`; an empty - * path (a root-level issue, e.g. an unrecognized top-level key) renders as `(root)`. - */ -export function formatMetricViewsPath( - path: ReadonlyArray | undefined, -): string { - if (!path || path.length === 0) return "(root)"; - - let out = ""; - for (const key of path) { - if (typeof key === "number") { - out += `[${key}]`; - } else { - const str = String(key); - out += out.length === 0 ? str : `.${str}`; - } - } - return out.length === 0 ? "(root)" : out; -} - -/** - * Validate a parsed `metric-views.json` object against the canonical - * {@link metricSourceSchema}. The schema is the single source of truth (it also - * backs the generated JSON schema and the type-generator runtime); this helper - * only adapts its `safeParse` result into a CLI-friendly issue list. - * - * On success the original input is returned (typed as {@link MetricSource}), - * not a re-emitted copy. - */ -export function validateMetricViewsSource( - obj: unknown, -): ValidateMetricViewsSourceResult { - const result = metricSourceSchema.safeParse(obj); - if (result.success) { - return { valid: true, source: result.data }; - } - const errors = result.error.issues.map((issue) => ({ - path: formatMetricViewsPath(issue.path as ReadonlyArray), - message: issue.message, - })); - return { valid: false, errors }; -} - -/** - * Format metric-source validation issues for CLI output. Each issue renders on - * its own line indented by two spaces as ` : `. Mirrors - * `formatValidationErrors` in the plugin-manifest validator so the two commands - * present schema errors identically. - */ -export function formatMetricViewsSourceErrors( - issues: MetricViewsSourceIssue[], -): string { - return issues.map((issue) => ` ${issue.path}: ${issue.message}`).join("\n"); -} diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 8d6bf7a94..319e44872 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -6,13 +6,17 @@ * `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: array element types - * are widened to `unknown[]`, options the CLI never passes (e.g. `metricFetcher`) - * are omitted, and only the surface the CLI actually uses is declared. + * 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 constants the CLI uses to report the emitted paths. 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` / `syncMetricViewsTypes` (or their result - * shapes) change, this declaration will NOT fail to compile and must be + * 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" { @@ -21,10 +25,11 @@ declare module "@databricks/appkit/type-generator" { 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; @@ -42,64 +47,8 @@ declare module "@databricks/appkit/type-generator" { noCache?: boolean; }): Promise; - type MetricLane = "sp" | "obo"; - - /** Per-metric schema captured by {@link syncMetricViewsTypes}. */ - interface MetricSchema { - key: string; - source: string; - lane: MetricLane; - measures: unknown[]; - dimensions: unknown[]; - degraded?: boolean; - } - - /** One per-entry DESCRIBE failure surfaced by the metric sync. */ - interface MetricSyncFailure { - key: string; - source: string; - reason: string; - transient: boolean; - } - - /** Result of {@link syncMetricViewsTypes}. */ - interface SyncMetricViewsTypesResult { - metricOutFile?: string; - metricMetadataOutFile?: string; - schemas: MetricSchema[]; - failures: MetricSyncFailure[]; - // `true` when no metric-views.json was found — nothing was synced. - noConfig: boolean; - // Per-key fatal preflight errors. Always empty for `mv sync` (the CLI uses - // the default `describe-now` mode); populated only by the dev/Vite blocking - // path. Declared to match the real export's result contract. - fatalErrors: Array<{ name: string; message: string }>; - } - - /** - * Generate the metric-view type artifacts used by `appkit mv sync`. - * - * Reads `metric-views.json` from `queryFolder`, DESCRIBEs any metric views - * that are missing from the cache, then writes `metric-views.d.ts` and - * `metric-views.metadata.json`. This only syncs metric-view types; analytics query - * types are generated separately. - * - * The cache is enabled by default. Pass `cache: false` to force fresh - * DESCRIBE results, matching the CLI's `--no-cache` flag. - */ - export function syncMetricViewsTypes(options: { - queryFolder: string; - warehouseId: string; - metricOutFile: string; - metricMetadataOutFile: string; - cache?: boolean; - // Preflight policy (mirrors the real export). The CLI omits it for the - // default `"describe-now"` (DESCRIBE now, degrade on a cold warehouse) and - // passes `"blocking"` under `--wait` (wait for / start the warehouse; only a - // deleted one is fatal). `"non-blocking"` is the dev/Vite path. - mode?: "describe-now" | "non-blocking" | "blocking"; - }): Promise; - + // Metric artifact filenames (written as siblings of the query out file). The + // CLI joins these with the out file's directory to report the emitted paths. export const METRIC_TYPES_FILE: string; export const METRIC_METADATA_FILE: string; } diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 764c90c1a..aa60157c8 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -8,7 +8,6 @@ import { codemodCommand } from "./commands/codemod/index.js"; import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; -import { metricViewsCommand } from "./commands/metric-views/index.js"; import { pluginCommand } from "./commands/plugin/index.js"; import { setupCommand } from "./commands/setup.js"; @@ -29,6 +28,5 @@ cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); cmd.addCommand(pluginCommand); cmd.addCommand(codemodCommand); -cmd.addCommand(metricViewsCommand); await cmd.parseAsync(); From 1f7ffef4423cb5546ede4ec49cbbb18f25857317 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 2 Jul 2026 16:38:09 +0200 Subject: [PATCH 09/13] chore(playground): gitignore generated metric-view types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev-playground gitignores metric-views.d.ts + metric-views.metadata.json alongside its existing serving.d.ts — all are regenerated on `pnpm dev` by AppKit's dev-server appKitTypesPlugin (generateFromEntryPoint emits the metric-view pair additively when metric-views.json is present), so the generated artifacts don't need to be versioned. Only the source config metric-views.json stays tracked. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/.gitignore | 6 +- .../shared/appkit-types/metric-views.d.ts | 110 ------------------ .../appkit-types/metric-views.metadata.json | 63 ---------- 3 files changed, 4 insertions(+), 175 deletions(-) delete mode 100644 apps/dev-playground/shared/appkit-types/metric-views.d.ts delete mode 100644 apps/dev-playground/shared/appkit-types/metric-views.metadata.json diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 1f4745f52..6f4e2911f 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -2,5 +2,7 @@ test-results/ playwright-report/ -# Auto-generated types (endpoint-specific, varies per developer) -shared/appkit-types/serving.d.ts \ No newline at end of file +# Auto-generated types (regenerated on `pnpm dev` by appKitTypesPlugin) +shared/appkit-types/serving.d.ts +shared/appkit-types/metric-views.d.ts +shared/appkit-types/metric-views.metadata.json \ No newline at end of file diff --git a/apps/dev-playground/shared/appkit-types/metric-views.d.ts b/apps/dev-playground/shared/appkit-types/metric-views.d.ts deleted file mode 100644 index e0ada3749..000000000 --- a/apps/dev-playground/shared/appkit-types/metric-views.d.ts +++ /dev/null @@ -1,110 +0,0 @@ -// 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/apps/dev-playground/shared/appkit-types/metric-views.metadata.json b/apps/dev-playground/shared/appkit-types/metric-views.metadata.json deleted file mode 100644 index 8b8555814..000000000 --- a/apps/dev-playground/shared/appkit-types/metric-views.metadata.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "customers": { - "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": { - "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": [ - "day", - "hour", - "minute", - "month", - "quarter", - "week", - "year" - ] - } - } - } -} From 1e009ee430c26faeb1762ee5b1af6a5b24ca7091 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 2 Jul 2026 19:07:09 +0200 Subject: [PATCH 10/13] refactor(appkit): emit only metric-views.d.ts, drop the runtime metadata bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metric-view typegen shipped two artifacts: metric-views.d.ts (the MetricRegistry augmentation) and metric-views.metadata.json (a runtime Record bundle). The JSON bundle is reader-less on the current stack — nothing consumes it at runtime (its documented consumer registerMetricsMetadata() is only prose in a doc comment), and the entire runtime metric path — route, SQL builder, validator, useMetricView hook — lands in PR2/PR5, neither on main yet. It shipped dormant in #433. Strip it clean rather than pre-position an artifact PR2 may not want in this shape. The single-source end-state (types + runtime validation from one generated Zod/Standard-Schema module) is deferred to the runtime PRs, where the consumer contract is real and can drive the artifact's shape. - delete mv-registry/metadata.ts (buildMetricsMetadataBundle, generateMetricsMetadataJson) - remove mvMetadataOutFile/metricMetadataOutFile wiring from index.ts and vite-plugin.ts, the METRIC_METADATA_FILE const + registerMetricsMetadata doc prose, the ambient-shim mirror, and the CLI's metadata report line - drop the now-unused export on compareKeys (metadata.ts was its only external consumer; config.ts still uses it internally) - keep metric-views.d.ts unchanged, including its type-level metadata block (SQL type/display_name/format/time_grain) — the sole carrier of that data now - re-point metadata-bundle test assertions at the .d.ts where a type-level equivalent exists; drop the rest - docs + both gitignores (template, dev-playground) drop the .metadata.json line Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/.gitignore | 3 +- docs/docs/development/type-generation.md | 9 +- packages/appkit/src/type-generator/index.ts | 35 +- .../src/type-generator/mv-registry/config.ts | 10 +- .../type-generator/mv-registry/metadata.ts | 72 ---- .../__snapshots__/mv-registry.test.ts.snap | 55 --- .../src/type-generator/tests/index.test.ts | 73 ++-- .../type-generator/tests/mv-registry.test.ts | 330 +----------------- .../tests/sync-metric-views-types.test.ts | 48 +-- .../type-generator/tests/vite-plugin.test.ts | 16 +- .../appkit/src/type-generator/vite-plugin.ts | 23 +- .../src/cli/commands/generate-types.test.ts | 17 +- .../shared/src/cli/commands/generate-types.ts | 14 +- .../src/cli/commands/type-generator.d.ts | 7 +- template/_gitignore | 1 - 15 files changed, 71 insertions(+), 642 deletions(-) delete mode 100644 packages/appkit/src/type-generator/mv-registry/metadata.ts diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 6f4e2911f..6ef8f3f20 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -4,5 +4,4 @@ playwright-report/ # Auto-generated types (regenerated on `pnpm dev` by appKitTypesPlugin) shared/appkit-types/serving.d.ts -shared/appkit-types/metric-views.d.ts -shared/appkit-types/metric-views.metadata.json \ No newline at end of file +shared/appkit-types/metric-views.d.ts \ No newline at end of file diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 157bb95a3..84e912b3c 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -10,7 +10,7 @@ AppKit can automatically generate TypeScript types for your SQL queries, providi Generate type-safe TypeScript declarations for query keys, parameters, and result rows. -All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and — when you adopt UC Metric Views — `metric-views.d.ts` plus its runtime metadata bundle `metric-views.metadata.json`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The `.d.ts` files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and — when you adopt UC Metric Views — `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` @@ -86,16 +86,15 @@ In blocking mode the generator starts a stopped warehouse, waits (bounded) for i ### Should you commit generated types? -Everything under `shared/appkit-types/` is a **build artifact**, not source. What you version is the input — your `config/queries/*.sql` files and `config/queries/metric-views.json` — while the generated `.d.ts` and `.metadata.json` files are reproduced from those inputs on every build. The app template gitignores them for this reason: `prebuild` runs `generate-types --wait` before `vite build`, so the accurate types are always regenerated (against a live warehouse) and baked into the bundle at build time. Because the bundle is what deploys, the source-tree copies never need to be committed, and committing them only risks a stale artifact drifting from the queries. The template's `_gitignore` already excludes `serving.d.ts`, `metric-views.d.ts`, and `metric-views.metadata.json` on this basis. +Everything under `shared/appkit-types/` is a **build artifact**, not source. What you version is the input — your `config/queries/*.sql` files and `config/queries/metric-views.json` — while the generated `.d.ts` files are reproduced from those inputs on every build. The app template gitignores them for this reason: `prebuild` runs `generate-types --wait` before `vite build`, so the accurate types are always regenerated (against a live warehouse) and baked into the bundle at build time. Because the bundle is what deploys, the source-tree copies never need to be committed, and committing them only risks a stale artifact drifting from the queries. The template's `_gitignore` already excludes `serving.d.ts` and `metric-views.d.ts` on this basis. The only input that must be committed is `config/queries/metric-views.json` itself — it declares which views exist and is the source the generated files are derived from. ## 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 two more files into `shared/appkit-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. -- `metric-views.metadata.json` — a build-time bundle of each view's measures and dimensions that the analytics plugin's metric-view path reads at runtime. +- `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. diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index b3c32331b..aaff2a75a 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 { @@ -278,10 +277,6 @@ async function probeWarehouseState( * @param options.mvOutFile - optional output file for the MetricRegistry * 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 (`metric-views.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 @@ -295,7 +290,6 @@ export async function generateFromEntryPoint(options: { noCache?: boolean; mode?: PreflightMode; mvOutFile?: string; - mvMetadataOutFile?: string; metricFetcher?: DescribeFetcher; }) { const { @@ -305,7 +299,6 @@ export async function generateFromEntryPoint(options: { noCache, mode = "non-blocking", mvOutFile, - mvMetadataOutFile, metricFetcher, } = options; const projectRoot = resolveProjectRoot(outFile); @@ -360,9 +353,6 @@ export async function generateFromEntryPoint(options: { if (queryFolder) { const mvFile = mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); - const mvMetadataFile = - mvMetadataOutFile ?? - path.join(path.dirname(mvFile), METRIC_METADATA_FILE); let mvResult: SyncMetricViewsTypesResult; try { @@ -370,7 +360,6 @@ export async function generateFromEntryPoint(options: { queryFolder, warehouseId, metricOutFile: mvFile, - metricMetadataOutFile: mvMetadataFile, cache: !noCache, metricFetcher, mode, @@ -432,7 +421,6 @@ export async function generateFromEntryPoint(options: { */ export interface SyncMetricViewsTypesResult { metricOutFile?: string; - metricMetadataOutFile?: string; schemas: MetricSchema[]; failures: MetricSyncFailure[]; /** @@ -468,7 +456,6 @@ export interface SyncMetricViewsTypesResult { * @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.metricMetadataOutFile - output path for the semantic-metadata JSON bundle. * @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). @@ -478,7 +465,6 @@ export async function syncMetricViewsTypes(options: { queryFolder: string; warehouseId: string; metricOutFile: string; - metricMetadataOutFile: string; cache?: boolean; metricFetcher?: DescribeFetcher; mode?: "describe-now" | "non-blocking" | "blocking"; @@ -487,7 +473,6 @@ export async function syncMetricViewsTypes(options: { queryFolder, warehouseId, metricOutFile, - metricMetadataOutFile, cache: cacheEnabled, metricFetcher, mode = "describe-now", @@ -767,22 +752,14 @@ export async function syncMetricViewsTypes(options: { "utf-8", ); - await fs.mkdir(path.dirname(metricMetadataOutFile), { recursive: true }); - await fs.writeFile( - metricMetadataOutFile, - generateMetricsMetadataJson(schemas), - "utf-8", - ); - logger.debug( - "Wrote MetricRegistry augmentation + metadata bundle for %d metric(s)%s", + "Wrote MetricRegistry augmentation for %d metric(s)%s", schemas.length, failures.length > 0 ? ` (${failures.length} failure(s))` : "", ); return { metricOutFile, - metricMetadataOutFile, schemas, failures, fatalErrors, @@ -810,13 +787,3 @@ export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; export const METRIC_TYPES_FILE = "metric-views.d.ts"; -/** - * 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 = "metric-views.metadata.json"; 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/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 5d4d7d025..b096de7b4 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`. + // Default: the metric .d.ts is a sibling of `outFile`. const metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); - const metadataFile = path.join( - metricsDir, - "generated", - "metric-views.metadata.json", - ); 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-views.d.ts and metric-views.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 ── @@ -741,8 +725,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: {} }); // D′: the fatal skip is terminal — a deleted warehouse can never serve // these keys, so the degraded entries are pinned sticky (retry: false) @@ -798,8 +780,6 @@ 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", ); @@ -856,8 +836,6 @@ 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", ); @@ -914,8 +892,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: {} }); // D′: terminal skip — sticky, like the decision-time fatal. const metrics = JSON.parse(mocks.cacheFile.contents ?? "{}").metrics; @@ -992,7 +968,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 () => { @@ -1040,8 +1019,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 () => { @@ -1076,8 +1056,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", ); @@ -1089,11 +1067,6 @@ describe("generateFromEntryPoint — metric cache section", () => { const queryFolder = path.join(cacheTestDir, "queries"); const outFile = path.join(cacheTestDir, "generated", "analytics.d.ts"); const metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); - const metadataFile = path.join( - cacheTestDir, - "generated", - "metric-views.metadata.json", - ); const describeResponseFor = ( measure: string, @@ -1156,7 +1129,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( @@ -1166,11 +1139,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(); @@ -1180,7 +1151,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 () => { @@ -1277,18 +1247,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); }); 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 ae074014c..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 metric-views.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-views.d.ts and metric-views.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 index ed8f097d7..dfc650dcc 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -11,10 +11,10 @@ import type { DatabricksStatementExecutionResponse } from "../types"; * callable in its default `describe-now` mode). A mock {@link DescribeFetcher} is * injected so the * pipeline (read config → resolve → [cache partition] → syncMetrics → write - * artifacts) runs without a warehouse, asserting BOTH artifacts land 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`). + * 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 @@ -94,7 +94,6 @@ describe("syncMetricViewsTypes", () => { let tmpRoot: string; let queryFolder: string; let metricOutFile: string; - let metricMetadataOutFile: string; // A spy fetcher so cache tests can assert which FQNs were (re)described. const fetcher = vi.fn(async (fqn) => { @@ -131,32 +130,24 @@ describe("syncMetricViewsTypes", () => { "appkit-types", "metric-views.d.ts", ); - metricMetadataOutFile = path.join( - tmpRoot, - "shared", - "appkit-types", - "metric-views.metadata.json", - ); }); afterEach(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); - test("writes BOTH artifacts for a mixed SP + OBO fixture", async () => { + test("writes the MetricRegistry augmentation for a mixed SP + OBO fixture", async () => { writeMixedConfig(); const result = await syncMetricViewsTypes({ queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); - // Both artifacts exist on disk. + // The .d.ts exists on disk. expect(fs.existsSync(metricOutFile)).toBe(true); - expect(fs.existsSync(metricMetadataOutFile)).toBe(true); // Result reports both keys, no failures, config present. expect(result.noConfig).toBe(false); @@ -166,7 +157,6 @@ describe("syncMetricViewsTypes", () => { "revenue", ]); expect(result.metricOutFile).toBe(metricOutFile); - expect(result.metricMetadataOutFile).toBe(metricMetadataOutFile); // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- const declarations = fs.readFileSync(metricOutFile, "utf-8"); @@ -182,19 +172,9 @@ describe("syncMetricViewsTypes", () => { expect(declarations).toContain('lane: "sp"'); // The TIMESTAMP dimension carries inferred time grains in its @timeGrain tag. expect(declarations).toContain("@timeGrain"); - - // --- metric-views.metadata.json: per-metric semantic bundle --- - const bundle = JSON.parse(fs.readFileSync(metricMetadataOutFile, "utf-8")); - // SP metric: currency format spec is preserved on the measure. - expect(bundle.revenue.measures.total_revenue.type).toBe("DECIMAL(38,2)"); - expect(bundle.revenue.measures.total_revenue.format).toBe("$#,##0.00"); - expect(bundle.revenue.dimensions.region.type).toBe("STRING"); - // OBO metric: time-typed dimension carries its inferred time_grain set. - expect(bundle.churn.measures.churn_rate.type).toBe("DOUBLE"); - expect(bundle.churn.dimensions.event_time.type).toBe("TIMESTAMP"); - expect(bundle.churn.dimensions.event_time.time_grain).toEqual( - expect.arrayContaining(["day", "hour", "minute", "month", "year"]), - ); + // 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 () => { @@ -202,7 +182,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); @@ -210,7 +189,6 @@ describe("syncMetricViewsTypes", () => { expect(result.schemas).toEqual([]); expect(result.failures).toEqual([]); expect(fs.existsSync(metricOutFile)).toBe(false); - expect(fs.existsSync(metricMetadataOutFile)).toBe(false); }); // --- cache behavior (default ON) ------------------------------------------- @@ -223,7 +201,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); expect(fetcher).toHaveBeenCalledTimes(2); @@ -236,7 +213,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); expect(fetcher).not.toHaveBeenCalled(); @@ -259,7 +235,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); expect(fetcher).toHaveBeenCalledTimes(2); @@ -271,7 +246,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, cache: false, metricFetcher: fetcher, }); @@ -295,7 +269,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); expect(first.failures).toHaveLength(1); @@ -309,7 +282,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); expect(fetcher).toHaveBeenCalledTimes(1); @@ -326,7 +298,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); const afterFirst = JSON.parse(mocks.cacheFile.contents ?? "{}"); @@ -347,7 +318,6 @@ describe("syncMetricViewsTypes", () => { queryFolder, warehouseId: "wh-1", metricOutFile, - metricMetadataOutFile, metricFetcher: fetcher, }); 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 b93a96181..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-views.d.ts", - mvMetadataOutFile: "custom/types/metric-views.metadata.json", }); getHook( plugin, @@ -402,10 +400,6 @@ describe("appKitTypesPlugin — metric option plumbing", () => { process.cwd(), "custom/types/metric-views.d.ts", ), - mvMetadataOutFile: path.resolve( - process.cwd(), - "custom/types/metric-views.metadata.json", - ), }), ); }); 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/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 92dfb8477..a8f2153a0 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -50,10 +50,9 @@ const { vi.mock("@databricks/appkit/type-generator", () => ({ generateFromEntryPoint, generateServingTypes, - // The CLI joins these with the out file's directory to report the emitted - // metric artifacts; mirror the real exported constants. + // 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", - METRIC_METADATA_FILE: "metric-views.metadata.json", })); // Mock the detached spawn so we can assert how the worker is launched without @@ -235,10 +234,10 @@ describe("generate-types foreground spawn orchestration", () => { expect(acquireSpawnLock).not.toHaveBeenCalled(); }); - test("reports metric artifacts when config/queries/metric-views.json exists", async () => { + test("reports the metric artifact when config/queries/metric-views.json exists", async () => { // The metric path is additive: generateFromEntryPoint emits metric-views.d.ts - // + metric-views.metadata.json as siblings of the query out file whenever the - // config is present. The CLI announces them off the same dormancy signal. + // 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"), @@ -252,12 +251,9 @@ describe("generate-types foreground spawn orchestration", () => { expect(logged).toContain( `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.d.ts")}`, ); - expect(logged).toContain( - `Generated metric metadata: ${path.join(path.dirname(outFile), "metric-views.metadata.json")}`, - ); }); - test("omits metric artifact lines when metric-views.json is absent (dormant)", async () => { + 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"]); @@ -265,6 +261,5 @@ describe("generate-types foreground spawn orchestration", () => { const logged = consoleLog.mock.calls.flat().map(String).join("\n"); expect(logged).toContain("Generated query types:"); expect(logged).not.toContain("Generated metric types:"); - expect(logged).not.toContain("Generated metric metadata:"); }); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 43aea5ecd..1d1060384 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -76,20 +76,18 @@ async function runGenerateTypes( }); console.log(`Generated query types: ${resolvedOutFile}`); - // generateFromEntryPoint also emits metric-view types additively when - // config/queries/metric-views.json exists (it stays dormant otherwise), - // writing them as siblings of the query out file. Mirror that to report - // the artifacts; a degraded/failed view is warned (default non-blocking) - // or has already thrown (--wait) inside the call above. + // 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)}`, ); - console.log( - `Generated metric metadata: ${path.join(typesDir, typeGen.METRIC_METADATA_FILE)}`, - ); } } } else { diff --git a/packages/shared/src/cli/commands/type-generator.d.ts b/packages/shared/src/cli/commands/type-generator.d.ts index 319e44872..49391c3aa 100644 --- a/packages/shared/src/cli/commands/type-generator.d.ts +++ b/packages/shared/src/cli/commands/type-generator.d.ts @@ -10,7 +10,7 @@ * 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 constants the CLI uses to report the emitted paths. Metric-view types + * 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. * @@ -47,8 +47,7 @@ declare module "@databricks/appkit/type-generator" { noCache?: boolean; }): Promise; - // Metric artifact filenames (written as siblings of the query out file). The - // CLI joins these with the out file's directory to report the emitted paths. + // 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; - export const METRIC_METADATA_FILE: string; } diff --git a/template/_gitignore b/template/_gitignore index 06a204739..aff51d9e4 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -14,4 +14,3 @@ shared/appkit-types/serving.d.ts # Metric-view types (regenerated at build by `generate-types --wait`) shared/appkit-types/metric-views.d.ts -shared/appkit-types/metric-views.metadata.json From 5e045e6b7550db0d27e6d21d48bd570f04ecb3fd Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 2 Jul 2026 19:23:07 +0200 Subject: [PATCH 11/13] chore(playground): track generated metric-views.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the earlier flip that gitignored it. Track it alongside the already-tracked analytics.d.ts so the metric-view types are visible in-repo; serving.d.ts stays ignored (per-developer, endpoint-specific). The .metadata.json line was already removed in the strip (cf8009f8). The template still gitignores the .d.ts — scaffolded apps regenerate it at build via prebuild --wait. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/.gitignore | 3 +- .../shared/appkit-types/metric-views.d.ts | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 apps/dev-playground/shared/appkit-types/metric-views.d.ts diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 6ef8f3f20..d79530cf5 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -3,5 +3,4 @@ test-results/ playwright-report/ # Auto-generated types (regenerated on `pnpm dev` by appKitTypesPlugin) -shared/appkit-types/serving.d.ts -shared/appkit-types/metric-views.d.ts \ No newline at end of file +shared/appkit-types/serving.d.ts \ No newline at end of file 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"]; + }; + }; + }; + }; + } +} From 55e9e8fe6439fdd3d04d72d8970d4897522fd324 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 3 Jul 2026 09:04:00 +0200 Subject: [PATCH 12/13] fix(appkit): never cache a degraded metric-view describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A degraded metric-view outcome (DESCRIBE skipped behind a not-running or deleted warehouse, unanswered, or a per-key failure) was written to the typegen cache with a `retry` flag. A deterministic failure — a genuinely bad `source` — was pinned `retry: false` (sticky), and sticky degraded entries were served as cache hits even under `--wait`. So a misconfigured view, once cached sticky by any non-blocking pass, made `--wait`/CI green-build with permissive types instead of failing — contradicting the stated contract that `--wait` must not ship permissive types for a misconfigured view. Mirror the query path, which caches a describe result and "Never persists `result: unknown`": cache ONLY a successful (non-degraded) describe. A degraded outcome is still rendered into the emitted `.d.ts` (so the file always exists and tsc never breaks), but is never cached — any stale entry for the key is deleted. The key stays uncached, so the next eligible pass simply re-describes it. This makes the invariant "the cache holds only good schemas" hold after every pass, so no sticky degraded entry can be served and `--wait` re-describes a still-bad source every run (failing the build) instead of serving it green. DELETED-warehouse handling is unchanged where it matters: the blocking preflight still throws TypegenFatalError; non-blocking still degrades and stays resilient per the non-blocking typegen contract — it just no longer pins a sticky entry. - index.ts: cache only non-degraded describes; delete any stale entry on degrade; drop the derived `retry` (write constant false, vestigial for on-disk shape compat, mirroring the query path) - remove now-dead machinery: terminalSkip, stickyDegradedHits, the sticky-hit notice, and the serveDegraded cache-hit special-case - simplify the hit predicate to "revivable + hash match + not degraded" (the degraded guard also rejects a stale degraded entry left by an older writer) - cache.ts: rewrite the MetricCacheEntry doc to the new contract - tests: assert the new invariant (degraded ⇒ uncached ⇒ re-described); add a regression test that `--wait` over a still-bad source fails the build rather than serving a cached degrade Found by Isaac Review (multi-model debate, correctness consensus). Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/cache.ts | 24 +- packages/appkit/src/type-generator/index.ts | 114 +++++---- .../src/type-generator/tests/index.test.ts | 216 +++++++++--------- 3 files changed, 175 insertions(+), 179 deletions(-) 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 aaff2a75a..10ea7d7f8 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -235,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, @@ -327,8 +330,8 @@ export async function generateFromEntryPoint(options: { // 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 keep last-known-good - // cache serving + the sticky-degraded notice. The unified fn returns early + // #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. // @@ -447,8 +450,10 @@ export interface SyncMetricViewsTypesResult { * 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. The hit predicate is STRICTER here: a degraded/sticky cached entry is NEVER served (it is re-described), so a direct describe-now call always converges to correct types, and the sticky-degraded notice never fires (nothing degraded is served). - * - `"non-blocking"` (dev/Vite default): one status-only probe, DESCRIBE only when the warehouse is already RUNNING, else emit degraded artifacts immediately. Degraded cache hits ARE served (last-known-good) and surfaced via the sticky-degraded notice. + * - `"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. @@ -502,49 +507,31 @@ export async function syncMetricViewsTypes(options: { } } - // Dev modes (`non-blocking`/`blocking`) serve degraded cache hits as last-known-good — exactly like queries degrade to cached types — - // and surface them via the sticky-degraded notice. `describe-now` (the CLI) is an explicit "make my types correct now" action, - // so it NEVER serves a degraded/sticky entry: that entry is re-described instead, and no degraded hit is served, so the notice never fires. - const serveDegraded = mode !== "describe-now"; - - // Partition BEFORE any gate/preflight decision: a hit (structurally valid - // entry, hash match, not retry-flagged, and — unless serving degraded — not - // degraded) is served from cache no matter what the warehouse is doing. Only - // the remainder (new, edited, retry-flagged, unrevivable, or — in - // `describe-now` — degraded entries) is eligible for DESCRIBE, so a - // fully-warm pass makes zero warehouse calls and constructs zero clients. + // 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 = []; - // Degraded cached schemas pinned `retry: false` that are SERVED as hits are - // sticky failures: they serve their permissive schema, but are collected here - // for the single notice below so the misconfiguration isn't silently hidden. - // (Empty in `describe-now`, which never serves a degraded hit.) - 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 && - (serveDegraded || prior.schema.degraded !== true) + prior.schema.degraded !== true ) { hitSchemas.set(entry.key, prior.schema); - if (prior.schema.degraded === true) { - stickyDegradedHits.push(entry.key); - } } else { describeNeeded.push(entry); } } - 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(", "), - ); - } - let mvClient: WorkspaceClient | undefined; const getMvClient = (): WorkspaceClient => { mvClient ??= new WorkspaceClient({}); @@ -620,19 +607,13 @@ export async function syncMetricViewsTypes(options: { 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. Never set in - // `describe-now` (no preflight/gate runs there). - 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 caller surfaces them after the writes. Terminal, so these - // entries are pinned sticky. + // 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); - terminalSkip = true; for (const entry of describeNeeded) { fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); } @@ -681,10 +662,14 @@ export async function syncMetricViewsTypes(options: { } } 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. - // A transient state refreshes on a later RUNNING pass; a DELETED/DELETING probe is terminal, so those keys are pinned sticky below. + // 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); - 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, @@ -696,21 +681,32 @@ export async function syncMetricViewsTypes(options: { // 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. - const failureByKey = new Map(); - for (const failure of failures) { - failureByKey.set(failure.key, failure); - } + // + // 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]; - const failure = failureByKey.get(entry.key); + if (described[i].degraded === true) { + delete mvCacheSection[entry.key]; + continue; + } mvCacheSection[entry.key] = { hash: metricCacheHash(entry.source, entry.lane), schema: described[i], - retry: - described[i].degraded === true && - !terminalSkip && - (failure === undefined || failure.transient === true), + // 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, }; } diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index b096de7b4..c469bb115 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -726,13 +726,11 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('"revenue"'); expect(declarations).toContain("measureKeys: string"); - // 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 () => { @@ -784,10 +782,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { "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 () => { @@ -840,11 +838,11 @@ describe("generateFromEntryPoint — metric-view emission", () => { "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]>([ @@ -854,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); @@ -893,10 +891,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('"revenue"'); expect(declarations).toContain("measureKeys: string"); - // 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(); }, ); @@ -1196,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({ @@ -1206,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( @@ -1228,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'); @@ -1385,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" }, @@ -1439,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({ @@ -1514,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 () => { @@ -1576,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({ @@ -1586,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( @@ -1617,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({ @@ -1627,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"); From 8d70d57d2214fc8b8e4482e7f6b671006b6a7f8c Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 3 Jul 2026 09:39:05 +0200 Subject: [PATCH 13/13] chore: update docs and gitignore --- docs/docs/development/type-generation.md | 15 ++++++--------- template/_gitignore | 2 -- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 84e912b3c..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 concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and — when you adopt UC Metric Views — `metric-views.d.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The `.d.ts` files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.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,12 +84,6 @@ 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`. -### Should you commit generated types? - -Everything under `shared/appkit-types/` is a **build artifact**, not source. What you version is the input — your `config/queries/*.sql` files and `config/queries/metric-views.json` — while the generated `.d.ts` files are reproduced from those inputs on every build. The app template gitignores them for this reason: `prebuild` runs `generate-types --wait` before `vite build`, so the accurate types are always regenerated (against a live warehouse) and baked into the bundle at build time. Because the bundle is what deploys, the source-tree copies never need to be committed, and committing them only risks a stale artifact drifting from the queries. The template's `_gitignore` already excludes `serving.d.ts` and `metric-views.d.ts` on this basis. - -The only input that must be committed is `config/queries/metric-views.json` itself — it declares which views exist and is the source the generated files are derived from. - ## 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/`: @@ -105,7 +99,10 @@ If `metric-views.json` is absent the metric path stays dormant (nothing is emitt "$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" } + "customers": { + "source": "catalog.schema.customer_metrics", + "executor": "user" + } } } ``` @@ -149,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/template/_gitignore b/template/_gitignore index aff51d9e4..29895e7c0 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -12,5 +12,3 @@ playwright-report/ # Auto-generated types (endpoint-specific, varies per developer) shared/appkit-types/serving.d.ts -# Metric-view types (regenerated at build by `generate-types --wait`) -shared/appkit-types/metric-views.d.ts