From 2a705805cc39d27e36c4e961395c14bb77d62676 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 09:33:46 +0200 Subject: [PATCH 01/28] feat(analytics): freeze metric-view runtime contracts (PR5 phase 0) Freeze the three shared seams the metric-view hook/server/generator phases compile against: - S1: MetricColumnMeta + MetricViewsMetadata value types in packages/shared - S2: optional per-column metadata on the SSE result message + makeResultMessage - S3: base MetricRegistry, MetricKey, Infer* helpers, MetricFilter mirrors, and UseMetricViewOptions/UseMetricViewResult in appkit-ui hook types Types only (plus a makeResultMessage passthrough); existing /query callers are unchanged since metadata is optional. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit-ui/src/react/hooks/index.ts | 11 ++ packages/appkit-ui/src/react/hooks/types.ts | 126 ++++++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/metric-metadata.ts | 18 +++ packages/shared/src/sse/analytics.ts | 20 +++- 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/metric-metadata.ts diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 63b639761..fedb1a37c 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -7,11 +7,20 @@ export { } from "../resource-status-indicator"; export type { AnalyticsFormat, + InferDimensionKeys, + InferMeasureKeys, + InferMetricRow, InferResultByFormat, InferRowType, InferServingChunk, InferServingRequest, InferServingResponse, + InferTimeGrains, + MetricFilter, + MetricFilterOperatorName, + MetricKey, + MetricPredicate, + MetricRegistry, PluginRegistry, QueryRegistry, ServingAlias, @@ -19,6 +28,8 @@ export type { TypedArrowTable, UseAnalyticsQueryOptions, UseAnalyticsQueryResult, + UseMetricViewOptions, + UseMetricViewResult, WarehouseState, WarehouseStatus, } from "./types"; diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index aa0df8905..3406eb213 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -1,4 +1,5 @@ import type { Table } from "apache-arrow"; +import type { MetricColumnMeta } from "shared"; // ============================================================================ // Data Format Types @@ -247,3 +248,128 @@ export type InferServingRequest = ? Req : Record : Record; + +// ============================================================================ +// Metric View Registry +// ============================================================================ + +/** + * Metric view registry for type-safe metric keys, measure/dimension names, + * time grains, and row shapes. Extend this interface via module augmentation + * to get autocomplete for `useMetricView`: + * + * @example + * ```typescript + * // Auto-generated (generated metric-views.ts) + * declare module "@databricks/appkit-ui/react" { + * interface MetricRegistry { + * orders: { + * measureKeys: "revenue" | "order_count"; + * dimensionKeys: "region" | "order_date"; + * timeGrains: "day" | "month"; + * measures: { revenue: number; order_count: number }; + * dimensions: { region: string; order_date: string }; + * }; + * } + * } + * ``` + */ +// biome-ignore lint/suspicious/noEmptyInterface: intentionally empty — populated via module augmentation (generated metric-views.ts) +export interface MetricRegistry {} + +/** Resolves to registry keys if populated, otherwise string */ +export type MetricKey = AugmentedRegistry extends never + ? string + : AugmentedRegistry; + +/** Infers measure key names from the registry when K is a known key */ +export type InferMeasureKeys = K extends AugmentedRegistry + ? MetricRegistry[K] extends { measureKeys: infer M } + ? M + : string + : string; + +/** Infers dimension key names from the registry when K is a known key */ +export type InferDimensionKeys = K extends AugmentedRegistry + ? MetricRegistry[K] extends { dimensionKeys: infer D } + ? D + : string + : string; + +/** Infers time-grain names from the registry when K is a known key */ +export type InferTimeGrains = K extends AugmentedRegistry + ? MetricRegistry[K] extends { timeGrains: infer G } + ? G + : string + : string; + +/** + * Infers the row shape (measures + dimensions) from the registry when K is a + * known key, otherwise a total `Record`. Never resolves to + * `never` — always assignable to `Record`. + */ +export type InferMetricRow = K extends AugmentedRegistry + ? MetricRegistry[K] extends { + measures: infer Meas; + dimensions: infer Dim; + } + ? Meas & Dim + : Record + : Record; + +// ──────────────────────────────────────────────────────────────────────────── +// Metric filter vocabulary. +// +// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot +// depend on appkit, so this mirrors the twelve-operator filter grammar by hand. +// ──────────────────────────────────────────────────────────────────────────── + +/** v1 filter operator vocabulary — exactly twelve names. */ +export type MetricFilterOperatorName = + | "equals" + | "notEquals" + | "in" + | "notIn" + | "gt" + | "gte" + | "lt" + | "lte" + | "contains" + | "notContains" + | "set" + | "notSet"; + +/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */ +export interface MetricPredicate { + member: string; + operator: MetricFilterOperatorName; + values?: ReadonlyArray; +} + +/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */ +export type MetricFilter = + | MetricPredicate + | { and: ReadonlyArray } + | { or: ReadonlyArray }; + +/** Options for configuring a `useMetricView` query. */ +export interface UseMetricViewOptions { + measures: ReadonlyArray>; + dimensions?: ReadonlyArray>; + filter?: MetricFilter; + timeGrain?: InferTimeGrains; + timeDimension?: InferDimensionKeys; + limit?: number; + autoStart?: boolean; +} + +/** Result state returned by `useMetricView`. */ +export interface UseMetricViewResult[]> { + data: T | null; + loading: boolean; + error: string | null; + /** Structured upstream error code, mirroring useAnalyticsQuery. */ + errorCode: string | null; + /** Per-column display metadata for the queried columns, carried in the SSE result payload. `undefined` when the server injected no metadata (dormant / unknown key). */ + metadata: Record | undefined; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d036e0dbd..4b7c08ba1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent"; export * from "./cache"; export * from "./execute"; export * from "./genie"; +export * from "./metric-metadata"; export * from "./plugin"; export * from "./sql"; export * from "./sse/analytics"; diff --git a/packages/shared/src/metric-metadata.ts b/packages/shared/src/metric-metadata.ts new file mode 100644 index 000000000..b58a08fdc --- /dev/null +++ b/packages/shared/src/metric-metadata.ts @@ -0,0 +1,18 @@ +/** Per-column display metadata for a UC Metric View column, sourced from the + * YAML 1.1 display_name/format attributes + SQL type. Loose enough that an + * `as const` generated literal assigns to it. */ +export interface MetricColumnMeta { + type: string; + display_name?: string; + format?: string; + description?: string; +} +/** Build-time-generated metadata for every registered metric view, keyed by + * metric key. Injected into the analytics plugin via `analytics({ metricViewsMetadata })`. */ +export type MetricViewsMetadata = Record< + string, + { + measures: Record; + dimensions: Record; + } +>; diff --git a/packages/shared/src/sse/analytics.ts b/packages/shared/src/sse/analytics.ts index 41022672c..5ae9fcfc3 100644 --- a/packages/shared/src/sse/analytics.ts +++ b/packages/shared/src/sse/analytics.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { MetricColumnMeta } from "../metric-metadata"; /** * Wire protocol for analytics SSE messages emitted by `/api/analytics/query`. @@ -37,15 +38,23 @@ export const AnalyticsResultMessage = z.object({ // `unknown` so we don't bake the SDK's detailed shape into the contract. status: z.unknown().optional(), statement_id: z.string().optional(), + // Per-column display metadata for a metric-view result (display_name / + // format / type). Kept loose (`z.record(z.string(), z.unknown())`) for the + // same "keep client validation cheap" reason as `data` — the server + // constructs it via the typed builder, so the per-column shape is enforced + // at the source. Absent for plain `/query` results. + metadata: z.record(z.string(), z.unknown()).optional(), }); /** * TS-level shape of a successful row-shaped result message. * * **Kept in sync by hand** with `AnalyticsResultMessage` above. The Zod - * schema is intentionally loose (`z.array(z.unknown())`) to keep client + * schema is intentionally loose (`z.array(z.unknown())` for `data`, + * `z.record(z.string(), z.unknown())` for `metadata`) to keep client * validation cheap; this interface narrows `data` to - * `Record[]` so consumers don't have to cast at every + * `Record[]` and `metadata` to + * `Record` so consumers don't have to cast at every * call site. If you add a field to the Zod schema, add it here too. */ export interface AnalyticsResultMessage { @@ -53,6 +62,7 @@ export interface AnalyticsResultMessage { data?: Record[]; status?: unknown; statement_id?: string; + metadata?: Record; } /** @@ -72,7 +82,11 @@ export type AnalyticsSseMessage = z.infer; export function makeResultMessage( data: Record[] | undefined, - extras: { status?: unknown; statement_id?: string } = {}, + extras: { + status?: unknown; + statement_id?: string; + metadata?: Record; + } = {}, ): AnalyticsResultMessage { return { type: "result", data, ...extras }; } From 9102d122822921d2f1e5aa6115c11c2db6471fcd Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 10:36:06 +0200 Subject: [PATCH 02/28] feat(appkit): metric-view hook, formatters, generator const + payload metadata (PR5 1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the four parallel PR5 slices against the frozen phase-0 contracts: - Generator (type-generator): emit metric-views.ts (was .d.ts) carrying both the erasable declare-module MetricRegistry augmentation and a runtime `export const metricViewsMetadata = {...} as const`. Header is a type-only import (no runtime side-effect import on the Node server). Rename propagated through METRIC_TYPES_FILE, mvOutFile, vite-plugin, CLI announce, and tests; generated .ts added to Biome ignore. - Server (analytics plugin): accept an injected `metricViewsMetadata` config and stamp the responding metric's per-column slice (scoped to the requested measures/dimensions) into the SSE result payload. Metadata is response decoration — it never enters composeMetricCacheKey and never alters SQL. - Hook (appkit-ui): `useMetricView(key, opts)` mirroring useAnalyticsQuery (SSE, abort-on-arg-change, autoStart), returning { data, loading, error, errorCode, metadata }. - Formatters (appkit-ui js): pure, React-free, tree-shakeable formatValue / formatLabel / toD3Format taking the format spec / column metadata as args. Also fix a pre-existing latent port collision: analytics.integration.test.ts and server.integration.test.ts both hardcoded port 9879; under the added metric-test weight they could bind concurrently in the shared vitest worker pool, so an analytics request hit the server-plugin app and 404'd. Switch the analytics integration test to an OS-assigned ephemeral port (port: 0), matching the files plugin integration test. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- biome.json | 4 +- .../appkit-ui/src/js/format/format.test.ts | 96 +++++ packages/appkit-ui/src/js/format/format.ts | 162 ++++++++ packages/appkit-ui/src/js/format/index.ts | 1 + packages/appkit-ui/src/js/index.ts | 1 + .../hooks/__tests__/use-metric-view.test.ts | 365 ++++++++++++++++++ packages/appkit-ui/src/react/hooks/index.ts | 1 + .../src/react/hooks/use-metric-view.ts | 245 ++++++++++++ .../appkit/src/plugins/analytics/analytics.ts | 26 +- .../appkit/src/plugins/analytics/mv/index.ts | 1 + .../src/plugins/analytics/mv/metadata.ts | 55 +++ .../tests/analytics.integration.test.ts | 32 +- .../plugins/analytics/tests/metric.test.ts | 268 +++++++++++++ .../appkit/src/plugins/analytics/types.ts | 16 +- packages/appkit/src/type-generator/index.ts | 8 +- .../mv-registry/render-types.ts | 79 +++- .../__snapshots__/mv-registry.test.ts.snap | 58 ++- .../src/type-generator/tests/index.test.ts | 20 +- .../type-generator/tests/mv-registry.test.ts | 112 +++++- .../tests/sync-metric-views-types.test.ts | 18 +- .../type-generator/tests/vite-plugin.test.ts | 7 +- .../appkit/src/type-generator/vite-plugin.ts | 6 +- .../src/cli/commands/generate-types.test.ts | 4 +- .../shared/src/cli/commands/generate-types.ts | 2 +- 24 files changed, 1548 insertions(+), 39 deletions(-) create mode 100644 packages/appkit-ui/src/js/format/format.test.ts create mode 100644 packages/appkit-ui/src/js/format/format.ts create mode 100644 packages/appkit-ui/src/js/format/index.ts create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-metric-view.ts create mode 100644 packages/appkit/src/plugins/analytics/mv/metadata.ts diff --git a/biome.json b/biome.json index 24ddeb018..f0082d50e 100644 --- a/biome.json +++ b/biome.json @@ -21,7 +21,9 @@ "!**/*.gen.css", "!**/*.gen.ts", "!**/typedoc-sidebar.ts", - "!**/template" + "!**/template", + "!**/metric-views.ts", + "!**/metric-views.d.ts" ] }, "formatter": { diff --git a/packages/appkit-ui/src/js/format/format.test.ts b/packages/appkit-ui/src/js/format/format.test.ts new file mode 100644 index 000000000..18ba4ddfe --- /dev/null +++ b/packages/appkit-ui/src/js/format/format.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vitest"; +import { formatLabel, formatValue, toD3Format } from "./format"; + +describe("js/format formatValue", () => { + test("currency spec formats with prefix, grouping and 2 decimals", () => { + expect(formatValue(1234.5, "$#,##0.00")).toBe("$1,234.50"); + }); + + test("currency spec handles negatives with sign before the symbol", () => { + expect(formatValue(-1234.5, "$#,##0.00")).toBe("-$1,234.50"); + }); + + test("integer spec groups thousands with no decimals", () => { + expect(formatValue(1234567, "#,##0")).toBe("1,234,567"); + }); + + test("decimal grouping spec keeps N decimals", () => { + expect(formatValue(1234.5, "#,##0.00")).toBe("1,234.50"); + }); + + test("percent spec multiplies by 100 and appends %", () => { + expect(formatValue(0.1234, "0.0%")).toBe("12.3%"); + }); + + test("integer percent spec has no decimals", () => { + expect(formatValue(0.5, "0%")).toBe("50%"); + }); + + test("accepts numeric strings", () => { + expect(formatValue("1234.5", "$#,##0.00")).toBe("$1,234.50"); + }); + + test("accepts bigint values", () => { + expect(formatValue(1234567n, "#,##0")).toBe("1,234,567"); + }); + + test("no format falls back to toLocaleString for numbers", () => { + expect(formatValue(1234.5)).toBe((1234.5).toLocaleString()); + }); + + test("no format passes through strings", () => { + expect(formatValue("hello")).toBe("hello"); + }); + + test("null and undefined become empty string", () => { + expect(formatValue(null)).toBe(""); + expect(formatValue(undefined)).toBe(""); + expect(formatValue(null, "$#,##0.00")).toBe(""); + }); + + test("non-numeric value with numeric spec falls back to String()", () => { + expect(formatValue("N/A", "#,##0")).toBe("N/A"); + }); +}); + +describe("js/format formatLabel", () => { + test("display_name wins over the raw name", () => { + const meta = { type: "double", display_name: "Avg LTV" }; + expect(formatLabel("avg_ltv", meta)).toBe("Avg LTV"); + }); + + test("humanizes snake_case when no display_name", () => { + expect(formatLabel("avg_ltv")).toBe("Avg Ltv"); + }); + + test("humanizes camelCase", () => { + expect(formatLabel("totalSpend")).toBe("Total Spend"); + }); + + test("humanizes ALL_CAPS", () => { + expect(formatLabel("TOTAL_SPEND")).toBe("Total Spend"); + }); + + test("columnMeta without display_name falls back to humanize", () => { + expect(formatLabel("user_name", { type: "string" })).toBe("User Name"); + }); +}); + +describe("js/format toD3Format", () => { + test("maps the common numeric specs", () => { + expect(toD3Format("$#,##0.00")).toBe("$,.2f"); + expect(toD3Format("#,##0")).toBe(",.0f"); + expect(toD3Format("#,##0.00")).toBe(",.2f"); + expect(toD3Format("0.0%")).toBe(".1%"); + }); + + test("no spec returns undefined", () => { + expect(toD3Format()).toBeUndefined(); + expect(toD3Format("")).toBeUndefined(); + }); + + test("unrecognized specs return undefined", () => { + expect(toD3Format("yyyy-MM-dd")).toBeUndefined(); + expect(toD3Format("abc")).toBeUndefined(); + }); +}); diff --git a/packages/appkit-ui/src/js/format/format.ts b/packages/appkit-ui/src/js/format/format.ts new file mode 100644 index 000000000..3fa98f56c --- /dev/null +++ b/packages/appkit-ui/src/js/format/format.ts @@ -0,0 +1,162 @@ +import type { MetricColumnMeta } from "shared"; + +// ============================================================================ +// Pure Format Utilities +// ============================================================================ +// Library-agnostic, tree-shakeable helpers for turning raw metric values and +// column metadata into display strings. These take the UC/YAML format spec (or +// MetricColumnMeta) as ARGUMENTS — no React, no chart-lib coupling, no bundled +// artifact — so they can be used from any surface (tables, tooltips, charts). + +/** + * Counts the number of fractional digits declared by a numeric format spec. + * E.g. "#,##0.00" -> 2, "#,##0" -> 0, "0.0%" -> 1. + */ +function countDecimals(format: string): number { + const dotIndex = format.indexOf("."); + if (dotIndex === -1) return 0; + const frac = format.slice(dotIndex + 1); + const match = frac.match(/^[0#]+/); + return match ? match[0].length : 0; +} + +/** + * Best-effort coercion of an arbitrary value to a finite number. Handles the + * common wire shapes (number, bigint, numeric string). Returns null when the + * value cannot be meaningfully treated as a number. + */ +function coerceNumber(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + if (value.trim() === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** Format a number with fixed decimals + optional thousands grouping. */ +function formatNumber( + value: number, + decimals: number, + grouping: boolean, +): string { + return value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }); +} + +/** + * Format a raw value using a UC/YAML printf-style format spec. + * + * Recognizes the common spreadsheet-style specs: + * - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50") + * - thousands grouping + N decimals, e.g. `"#,##0"` (1234567 -> "1,234,567") + * or `"#,##0.00"` (1234.5 -> "1,234.50") + * - percent, e.g. `"0.0%"` (0.1234 -> "12.3%") — the value is multiplied by 100 + * + * No format spec -> sensible default: numbers via `toLocaleString`, everything + * else via `String()`. `null`/`undefined` -> `""`. Unrecognized specs fall back + * to a best-effort result (the number grouped, or `String(value)`). + */ +export function formatValue(value: unknown, format?: string): string { + if (value === null || value === undefined) return ""; + + if (!format) { + if (typeof value === "number") { + return Number.isFinite(value) ? value.toLocaleString() : String(value); + } + if (typeof value === "bigint") return value.toLocaleString(); + return String(value); + } + + const num = coerceNumber(value); + // Non-numeric value with a numeric-ish spec: nothing sensible to format. + if (num === null) return String(value); + + const isPercent = format.includes("%"); + const isCurrency = format.includes("$"); + const grouping = format.includes(","); + const decimals = countDecimals(format); + + if (isPercent) { + return `${formatNumber(num * 100, decimals, grouping)}%`; + } + + if (isCurrency) { + const sign = num < 0 ? "-" : ""; + return `${sign}$${formatNumber(Math.abs(num), decimals, grouping)}`; + } + + return formatNumber(num, decimals, grouping); +} + +/** + * Turns a raw column name into a human-readable label. + * Handles camelCase, snake_case, acronyms, and ALL_CAPS. + * E.g., "totalSpend" -> "Total Spend", "avg_ltv" -> "Avg Ltv". + */ +function humanize(name: string): string { + return ( + name + // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl -> HTTP Url) + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + // Handle lowercase followed by uppercase (e.g., totalSpend -> total Spend) + .replace(/([a-z])([A-Z])/g, "$1 $2") + // Replace underscores with spaces + .replace(/_/g, " ") + // Collapse multiple spaces into one + .replace(/\s+/g, " ") + // Normalize to title case + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()) + .trim() + ); +} + +/** + * Human label for a column: prefers `columnMeta.display_name`, else humanizes + * the raw column name (camelCase / snake_case / CAPS -> Title Case). + */ +export function formatLabel( + name: string, + columnMeta?: MetricColumnMeta, +): string { + if (columnMeta?.display_name) return columnMeta.display_name; + return humanize(name); +} + +/** + * Maps a UC/spreadsheet-style format spec to a + * [d3-format](https://d3js.org/d3-format) specifier string, for charts that + * consume d3 format strings. + * + * Best-effort mapping for the common specs: + * - `"$#,##0.00"` -> `"$,.2f"` + * - `"#,##0"` -> `",.0f"` + * - `"#,##0.00"` -> `",.2f"` + * - `"0.0%"` -> `".1%"` + * + * No spec, or a spec that is not a recognizable numeric pattern -> `undefined`. + */ +export function toD3Format(format?: string): string | undefined { + if (!format) return undefined; + + // Only map specs built purely from numeric-format characters; anything else + // (date patterns, free text, ...) is left unrecognized. + if (format.replace(/[#0,.$%\s]/g, "") !== "") return undefined; + if (!/[0#]/.test(format)) return undefined; + + const group = format.includes(",") ? "," : ""; + const decimals = countDecimals(format); + + if (format.includes("%")) { + return `${group}.${decimals}%`; + } + + const prefix = format.includes("$") ? "$" : ""; + return `${prefix}${group}.${decimals}f`; +} diff --git a/packages/appkit-ui/src/js/format/index.ts b/packages/appkit-ui/src/js/format/index.ts new file mode 100644 index 000000000..c89fec47d --- /dev/null +++ b/packages/appkit-ui/src/js/format/index.ts @@ -0,0 +1 @@ +export * from "./format"; diff --git a/packages/appkit-ui/src/js/index.ts b/packages/appkit-ui/src/js/index.ts index f49cde96e..2a9deaf43 100644 --- a/packages/appkit-ui/src/js/index.ts +++ b/packages/appkit-ui/src/js/index.ts @@ -12,4 +12,5 @@ export { export * from "./arrow"; export * from "./config"; export * from "./constants"; +export * from "./format"; export * from "./sse"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts new file mode 100644 index 000000000..0b09dfafc --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -0,0 +1,365 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let lastConnectArgs: any = null; +let capturedCallbacks: { + onMessage?: (msg: { data: string }) => void; + onError?: (err: Error) => void; + signal?: AbortSignal; +} = {}; + +// Mock connectSSE so the hook does not attempt a real network request. +// Capture both the full args (used by the payload/refetch tests) and the +// individual callbacks/signal (used by the result/error and late-envelope +// tests). The hook ignores the return value. +const mockConnectSSE = vi.fn((args: any): unknown => { + lastConnectArgs = args; + capturedCallbacks = { + onMessage: args?.onMessage, + onError: args?.onError, + signal: args?.signal, + }; + return () => {}; +}); + +vi.mock("@/js", () => ({ + connectSSE: (...args: unknown[]) => mockConnectSSE(...(args as [any])), + ArrowClient: {}, +})); + +vi.mock("../use-query-hmr", () => ({ + useQueryHMR: vi.fn(), +})); + +import { useMetricView } from "../use-metric-view"; + +function markAborted() { + const sig = capturedCallbacks.signal; + if (!sig) throw new Error("signal not captured yet"); + Object.defineProperty(sig, "aborted", { value: true, configurable: true }); +} + +describe("useMetricView", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastConnectArgs = null; + capturedCallbacks = {}; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("POSTs the metric route with only the defined body fields on mount", () => { + renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + limit: 100, + }), + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + expect(String(lastConnectArgs.url)).toContain( + "/api/analytics/metric/orders", + ); + // Only defined fields are serialized — undefined filter/timeGrain/ + // timeDimension are omitted from the body. + expect(JSON.parse(lastConnectArgs.payload)).toEqual({ + measures: ["revenue"], + dimensions: ["region"], + limit: 100, + }); + }); + + test("surfaces a type:result payload as data and reads its per-column metadata", async () => { + const { result } = renderHook(() => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + }), + ); + + const metadata = { + revenue: { type: "DECIMAL", display_name: "Revenue", format: "currency" }, + region: { type: "STRING", display_name: "Region" }, + }; + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 100, region: "EMEA" }], + metadata, + }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 100, region: "EMEA" }]); + }); + expect(result.current.metadata).toEqual(metadata); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("leaves metadata undefined when the result payload omits it", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 1 }] }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + expect(result.current.metadata).toBeUndefined(); + }); + + test("normalizes an empty result message (no data field) to []", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ data: JSON.stringify({ type: "result" }) }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([]); + }); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("ignores warehouse_status events without leaving the loading state", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + expect(result.current.loading).toBe(true); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + }); + }); + + // The metric result shape does not expose warehouseStatus — the event is a + // no-op that keeps the hook loading until the result arrives. + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + test("a server error event exposes both the message and the structured errorCode", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "error", + error: "Metric view is not defined", + code: "UPSTREAM_ERROR", + errorCode: "UNKNOWN_METRIC_KEY", + }), + }); + }); + + await waitFor(() => { + expect(result.current.error).toBe("Metric view is not defined"); + }); + expect(result.current.errorCode).toBe("UNKNOWN_METRIC_KEY"); + expect(result.current.loading).toBe(false); + + errorSpy.mockRestore(); + }); + + test("a malformed (non-JSON) SSE payload clears loading and surfaces an error", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ data: "not-json{" }); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.error).toBe("Unable to load data, please try again"); + expect(result.current.data).toBeNull(); + + warnSpy.mockRestore(); + }); + + test("maps an onError network failure to a user-facing message", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onError(new Error("Failed to fetch")); + }); + + await waitFor(() => { + expect(result.current.error).toBe( + "Network error. Please check your connection.", + ); + }); + expect(result.current.loading).toBe(false); + + errorSpy.mockRestore(); + }); + + test("does not refetch when the options are structurally equal across renders", () => { + const { rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["region"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ region: "EMEA" }); + rerender({ region: "EMEA" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + }); + + test("refetches and aborts the prior stream when a measure changes", () => { + const { rerender } = renderHook( + ({ measure }: { measure: string }) => + useMetricView("orders", { measures: [measure] }), + { initialProps: { measure: "revenue" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + const firstSignal = mockConnectSSE.mock.calls[0][0].signal as AbortSignal; + expect(firstSignal.aborted).toBe(false); + + rerender({ measure: "order_count" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + // The prior request's controller was aborted before the new one started. + expect(firstSignal.aborted).toBe(true); + expect(JSON.parse(mockConnectSSE.mock.calls[1][0].payload)).toEqual({ + measures: ["order_count"], + }); + }); + + test("refetches when the filter changes", () => { + const { rerender } = renderHook( + ({ region }: { region: string }) => + useMetricView("orders", { + measures: ["revenue"], + filter: { member: "region", operator: "equals", values: [region] }, + }), + { initialProps: { region: "EMEA" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ region: "APAC" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("refetches when the timeGrain changes", () => { + const { rerender } = renderHook( + ({ grain }: { grain: string }) => + useMetricView("orders", { + measures: ["revenue"], + dimensions: ["order_date"], + timeDimension: "order_date", + timeGrain: grain, + }), + { initialProps: { grain: "day" } }, + ); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + rerender({ grain: "month" }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("does not issue a request when autoStart is false", () => { + renderHook(() => + useMetricView("orders", { measures: ["revenue"], autoStart: false }), + ); + + expect(mockConnectSSE).not.toHaveBeenCalled(); + }); + + test("throws when the metric key is empty", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => + renderHook(() => useMetricView("", { measures: ["revenue"] })), + ).toThrow(/must be a non-empty string/); + + errorSpy.mockRestore(); + }); + + describe("aborted controller", () => { + test("ignores a late result envelope after the controller was aborted", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + await waitFor(() => expect(capturedCallbacks.signal).toBeDefined()); + + markAborted(); + + act(() => { + capturedCallbacks.onMessage?.({ + data: JSON.stringify({ type: "result", data: [{ revenue: 99 }] }), + }); + }); + + expect(result.current.data).toBeNull(); + }); + + test("ignores a late error envelope after the controller was aborted", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + await waitFor(() => expect(capturedCallbacks.signal).toBeDefined()); + + markAborted(); + + act(() => { + capturedCallbacks.onMessage?.({ + data: JSON.stringify({ + type: "error", + error: "The operation was aborted.", + code: "UPSTREAM_ERROR", + }), + }); + }); + + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index fedb1a37c..56fbb2a4f 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -45,6 +45,7 @@ export { type UseChartDataResult, useChartData, } from "./use-chart-data"; +export { useMetricView } from "./use-metric-view"; export { useIsMobile } from "./use-mobile"; export { usePluginClientConfig } from "./use-plugin-config"; export { diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts new file mode 100644 index 000000000..c7dd54d26 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MetricColumnMeta } from "shared"; +import { connectSSE } from "@/js"; +import type { + InferMetricRow, + MetricKey, + UseMetricViewOptions, + UseMetricViewResult, +} from "./types"; +import { useQueryHMR } from "./use-query-hmr"; + +function getDevMode(): string { + const dev = new URL(window.location.href).searchParams.get("dev"); + return dev ? `?dev=${dev}` : ""; +} + +const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; + +/** Map a fetch/SSE transport error to a user-facing message. */ +function userFacingFetchError(error: unknown): string { + if (error instanceof Error) { + if (error.name === "AbortError") { + return "Request timed out, please try again"; + } + if (error.message.includes("Failed to fetch")) { + return "Network error. Please check your connection."; + } + } + return GENERIC_LOAD_ERROR; +} + +interface MetricSseContext { + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setErrorCode: (code: string | null) => void; + setData: (data: Record[] | null) => void; + setMetadata: (metadata: Record | undefined) => void; +} + +function handleMetricSseMessage( + parsed: Record, + ctx: MetricSseContext, +): void { + // Warehouse-readiness progress. The metric result type does NOT expose + // warehouseStatus (Phase 0 contract), so these events keep the hook in its + // loading state without surfacing anything to the caller. + if (parsed.type === "warehouse_status") { + return; + } + + // JSON result. The SSE wire schema is intentionally loose (`data` is an + // optional array of unknown values), so a structural check is enough here — + // no need to ship a schema validator (zod, ~60 KB gz) to the browser just + // to read our own same-origin server's messages. Missing or non-array + // `data` normalizes to [] so `undefined` never bleeds into the hook's + // `T | null` state. `metadata` (per-column display metadata scoped to the + // queried columns) is surfaced as-is, or `undefined` when the server + // injected none (dormant / unknown key). + if (parsed.type === "result") { + ctx.setLoading(false); + ctx.setData(Array.isArray(parsed.data) ? parsed.data : []); + ctx.setMetadata( + parsed.metadata as Record | undefined, + ); + return; + } + + if (parsed.type === "error" || parsed.error || parsed.code) { + const errorMsg = + (parsed.error as string | undefined) || + (parsed.message as string | undefined) || + "Unable to execute metric query"; + ctx.setLoading(false); + ctx.setError(errorMsg); + // Propagate the upstream structured code so UI consumers can branch on a + // stable identifier instead of parsing the human-readable message. + if (typeof parsed.errorCode === "string") { + ctx.setErrorCode(parsed.errorCode); + } + if (parsed.code) { + console.error( + `[useMetricView] Code: ${parsed.code}, Message: ${errorMsg}`, + ); + } + return; + } + + // Not a warehouse-status, result, or error event — surface a generic error + // rather than silently dropping an unrecognized payload. + console.error("[useMetricView] Unrecognized SSE payload", parsed); + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); +} + +/** + * Subscribe to a Unity Catalog metric view and return its latest result. + * POSTs the structured `{ measures, dimensions, filter, timeGrain, + * timeDimension, limit }` body to `POST /api/analytics/metric/:key` and + * streams the row result back over SSE (with warehouse-readiness progress), + * mirroring {@link useAnalyticsQuery}'s JSON_ARRAY path. + * + * The measure/dimension names, time grain, and row shape are inferred from the + * `MetricRegistry` module augmentation when `key` is a known metric key. + * + * @param key - Metric view identifier + * @param options - Measures (required) plus optional dimensions, filter, + * timeGrain/timeDimension, limit, and autoStart + * @returns Metric result state with typed rows and per-column display metadata + * + * @example + * ```typescript + * const { data, metadata } = useMetricView("orders", { + * measures: ["revenue"], + * dimensions: ["region"], + * filter: { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + * }); + * // data: Array<{ revenue: number; region: string }> | null + * ``` + */ +export function useMetricView( + key: K, + options: UseMetricViewOptions, +): UseMetricViewResult[]> { + const autoStart = options?.autoStart ?? true; + + const devMode = getDevMode(); + const urlSuffix = `/api/analytics/metric/${encodeURIComponent(key)}${devMode}`; + + type Rows = InferMetricRow[]; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + const [metadata, setMetadata] = useState< + Record | undefined + >(undefined); + const abortControllerRef = useRef(null); + + if (!key || key.trim().length === 0) { + throw new Error("useMetricView: 'key' must be a non-empty string."); + } + + // Serialize the request body from only the defined fields. A JSON string is + // a primitive, so a structurally-equal body across renders stays + // referentially stable for the `start` callback's dependency check even + // though the caller passes fresh `measures`/`filter` object literals each + // render — no manual deep-equality/ref juggling required. + const payload = useMemo(() => { + const body: { + measures: ReadonlyArray; + dimensions?: ReadonlyArray; + filter?: unknown; + timeGrain?: unknown; + timeDimension?: unknown; + limit?: number; + } = { measures: options.measures }; + if (options.dimensions !== undefined) body.dimensions = options.dimensions; + if (options.filter !== undefined) body.filter = options.filter; + if (options.timeGrain !== undefined) body.timeGrain = options.timeGrain; + if (options.timeDimension !== undefined) + body.timeDimension = options.timeDimension; + if (options.limit !== undefined) body.limit = options.limit; + return JSON.stringify(body); + }, [ + options.measures, + options.dimensions, + options.filter, + options.timeGrain, + options.timeDimension, + options.limit, + ]); + + const start = useCallback(() => { + abortControllerRef.current?.abort(); + + setLoading(true); + setError(null); + setErrorCode(null); + setData(null); + setMetadata(undefined); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + const sseContext: MetricSseContext = { + setLoading, + setError, + setErrorCode, + setData: (rows) => setData(rows as Rows | null), + setMetadata, + }; + + connectSSE({ + url: urlSuffix, + payload, + signal: abortController.signal, + onMessage: async (message) => { + // Drop late envelopes from a stream whose controller was already + // aborted (React StrictMode unmount→remount). Mirrors onError below. + if (abortController.signal.aborted) return; + try { + const parsed = JSON.parse(message.data) as Record; + handleMetricSseMessage(parsed, sseContext); + } catch (error) { + // A `JSON.parse` failure (or any other thrown error inside the SSE + // message handler) must not strand the hook in `loading=true` with + // no error surfaced — the UI would spin forever. Clear loading, + // report a user-facing error, and abort the stream so a broken + // upstream doesn't re-fire the same failure on every frame. + console.warn("[useMetricView] Malformed message received", error); + setLoading(false); + setError(GENERIC_LOAD_ERROR); + abortController.abort(); + } + }, + onError: (error) => { + if (abortController.signal.aborted) return; + setLoading(false); + + if (error instanceof Error) { + console.error("[useMetricView] Error", { + key, + error: error.message, + stack: error.stack, + }); + } + setError(userFacingFetchError(error)); + }, + }); + }, [key, payload, urlSuffix]); + + useEffect(() => { + if (autoStart) { + start(); + } + + return () => { + abortControllerRef.current?.abort(); + }; + }, [start, autoStart]); + + useQueryHMR(key, start); + + return { data, loading, error, errorCode, metadata }; +} diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 362a74536..0882a3705 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -3,6 +3,7 @@ import { type AgentToolDefinition, type AnalyticsSseMessage, type IAppRouter, + type MetricColumnMeta, makeResultMessage, type PluginExecuteConfig, type SQLTypeMarker, @@ -34,6 +35,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "./metric"; import { QueryProcessor } from "./query"; @@ -556,6 +558,19 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw err; } + // Per-column metadata slice for the responding metric, scoped to the + // requested measures/dimensions. Pure response DECORATION: computed once + // from the injected config value, threaded into the `result` message below, + // and deliberately NOT part of the cache key or the SQL. Absent config → + // `undefined` → the `result` message omits the field (envelope-identical to + // `/query`). + const metadata = selectMetricMetadata( + this.config.metricViewsMetadata, + key, + request.measures, + request.dimensions, + ); + // Cache key. Composed over the canonicalized args (sorted measures/ // dimensions, stable-sorted predicates, grain, timeDimension, limit) plus // the `executorKey` — `"sp"` shares the cache across all users, a per-user @@ -652,12 +667,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { ); // Reuse the query route's JSON delivery: INLINE JSON_ARRAY with // an ARROW_STREAM-inline fallback, returning plain rows in a - // `result` message — byte-identical envelope to `/query`. + // `result` message — byte-identical envelope to `/query`, plus the + // metric's per-column `metadata` slice (omitted when absent). return await self._executeJsonArrayPath( executor, statement, processedParams, sig, + metadata, ); } catch (err) { originalError = err; @@ -707,6 +724,11 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`, * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result` * message. External links are never used for the JSON fallback. + * + * `metadata` (metric route only) is the pre-computed per-column slice stamped + * into the `result` message; it is pure response decoration (never affects + * the SQL or the cache key). `undefined` → the field is omitted, keeping the + * envelope byte-identical to a plain `/query` result. */ private async _executeJsonArrayPath( executor: AnalyticsPlugin, @@ -715,6 +737,7 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { | Record | undefined, signal?: AbortSignal, + metadata?: Record, ): Promise { const result = await deliverJsonResult( executor, @@ -725,6 +748,7 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { return makeResultMessage(result.data, { status: result.status, statement_id: result.statement_id, + metadata, }); } diff --git a/packages/appkit/src/plugins/analytics/mv/index.ts b/packages/appkit/src/plugins/analytics/mv/index.ts index 17c97793e..beb12a4b9 100644 --- a/packages/appkit/src/plugins/analytics/mv/index.ts +++ b/packages/appkit/src/plugins/analytics/mv/index.ts @@ -1,4 +1,5 @@ export { composeMetricCacheKey, deriveMetricExecutorKey } from "./cache"; export { buildMetricSql } from "./formatters"; +export { selectMetricMetadata } from "./metadata"; export { loadMetricRegistry } from "./registry"; export { validateMetricRequest } from "./schemas"; diff --git a/packages/appkit/src/plugins/analytics/mv/metadata.ts b/packages/appkit/src/plugins/analytics/mv/metadata.ts new file mode 100644 index 000000000..359d122d9 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/mv/metadata.ts @@ -0,0 +1,55 @@ +import type { MetricColumnMeta, MetricViewsMetadata } from "shared"; + +/** + * Compute the per-column metadata slice for a metric response, scoped to the + * columns the request actually asked for. + * + * `all` is the build-generated {@link MetricViewsMetadata} the app injects via + * `analytics({ metricViewsMetadata })` — a per-metric map of `measures` / + * `dimensions` to their {@link MetricColumnMeta}. This flattens the requested + * measures and dimensions for `key` into a single `Record` for + * the SSE `result` message, so the client can label/format only the columns it + * queried. + * + * This is pure **response decoration**: it never touches the cache key or the + * SQL, and reads only from the injected value (never disk / DESCRIBE at runtime). + * + * Returns `undefined` (rather than an empty object) when there is nothing to + * stamp — so the caller can omit the field entirely and the message stays + * byte-identical to a plain `/query` result: + * - `all` is absent (no metadata injected), or + * - `key` is not an own property of `all` (unknown metric; uses + * {@link Object.hasOwn} so a prototype member like `toString` never + * resolves to a bogus entry), or + * - none of the requested columns are present in the metadata (fully + * degraded / unknown columns). + * + * Requested columns that are absent from the metadata are simply omitted — a + * degraded/unknown column produces no entry rather than a placeholder. + */ +export function selectMetricMetadata( + all: MetricViewsMetadata | undefined, + key: string, + measures: string[], + dimensions: string[] | undefined, +): Record | undefined { + if (!all || !Object.hasOwn(all, key)) { + return undefined; + } + + const entry = all[key]; + const slice: Record = {}; + + for (const measure of measures) { + if (Object.hasOwn(entry.measures, measure)) { + slice[measure] = entry.measures[measure]; + } + } + for (const dimension of dimensions ?? []) { + if (Object.hasOwn(entry.dimensions, dimension)) { + slice[dimension] = entry.dimensions[dimension]; + } + } + + return Object.keys(slice).length > 0 ? slice : undefined; +} diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts index 5c08b8d43..bb50bbc89 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts @@ -25,12 +25,34 @@ import { analytics } from "../index"; const getAppQuerySpy = vi.spyOn(AppManager.prototype, "getAppQuery"); +/** + * Wait for the supplied server to finish binding, then return the OS-assigned + * port. Required when the test passes `port: 0` to `serverPlugin` — + * `app.server.start()` returns as soon as `listen()` is invoked but before the + * bind completes, so `server.address()` returns `null` until the `listening` + * event fires. + */ +async function getListeningPort(server: Server): Promise { + const addr = server.address(); + if (addr && typeof addr === "object" && typeof addr.port === "number") { + return addr.port; + } + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", (err) => reject(err)); + }); + const ready = server.address(); + if (!ready || typeof ready !== "object") { + throw new Error("Server is listening but address() returned null"); + } + return ready.port; +} + describe("Analytics Plugin Integration", () => { let server: Server; let baseUrl: string; let serviceContextMock: Awaited>; let mockClient: ReturnType; - const TEST_PORT = 9879; beforeAll(async () => { setupDatabricksEnv(); @@ -43,8 +65,11 @@ describe("Analytics Plugin Integration", () => { const app = await createApp({ plugins: [ + // port: 0 → OS assigns an ephemeral port. Avoids EADDRINUSE / cross-test + // route bleed when another integration test (e.g. server.integration) + // holds a fixed port concurrently in the shared vitest worker pool. serverPlugin({ - port: TEST_PORT, + port: 0, host: "127.0.0.1", }), analytics({}), @@ -52,7 +77,8 @@ describe("Analytics Plugin Integration", () => { }); server = app.server.getServer(); - baseUrl = `http://127.0.0.1:${TEST_PORT}`; + const port = await getListeningPort(server); + baseUrl = `http://127.0.0.1:${port}`; }); afterAll(async () => { diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 2fe78927e..40bbf58e5 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -8,6 +8,7 @@ import { mockServiceContext, setupDatabricksEnv, } from "@tools/test-helpers"; +import type { MetricViewsMetadata } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AppManager } from "../../../app"; import { ServiceContext } from "../../../context/service-context"; @@ -18,6 +19,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "../metric"; import type { @@ -588,6 +590,186 @@ describe("analytics metric route (Phase 1)", () => { expect(mockRes.status).toHaveBeenCalledWith(400); }); + + // ── Metadata stamping (Phase 2). The injected `metricViewsMetadata` is + // sliced to the requested columns and stamped into the `result` message; it + // is pure decoration (no SQL / cache-key effect). See `selectMetricMetadata` + // below for the unit-level scoping tests. + const REVENUE_METADATA: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + }; + + /** Extract the parsed `result` SSE payload from the mock response writes. */ + function readResultPayload(mockRes: ReturnType) { + const dataLine = (mockRes.write as any).mock.calls + .map((call: any[]) => call[0] as string) + .find( + (s: string) => + s.startsWith("data: ") && s.includes('"type":"result"'), + ); + if (!dataLine) return undefined; + return JSON.parse(dataLine.slice("data: ".length).trim()); + } + + test("stamps the per-column metadata slice into the result message", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234, region: "EMEA" }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"], dimensions: ["region"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // Only the requested columns are present — `mrr`/`segment` are omitted. + expect(payload.metadata).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits the metadata field entirely when no metadata is injected (envelope parity with /query)", async () => { + const plugin = pluginForDir( + config, // no metricViewsMetadata + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // The `result` message is byte-identical to a plain `/query` result: the + // `metadata` key is absent, not present-but-undefined. + expect(payload).toBeDefined(); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + expect(payload.data).toEqual([{ arr: 1234 }]); + }); + + test("omits metadata when only degraded/unknown columns are requested", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ unknown_measure: 1 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["unknown_measure"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + }); + + test("metadata presence does NOT change the SQL or the cache key", async () => { + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + // Capture the composed cache key the inner `execute` hands to the shared + // CacheManager mock — the same key whether or not metadata is injected. + const cacheKeyFor = async (mvMeta?: MetricViewsMetadata) => { + mockCacheInstance.getOrExecute.mockClear(); + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + createMockResponse(), + ); + // First getOrExecute call is the SQL execution's cache interceptor. + const call = mockCacheInstance.getOrExecute.mock.calls[0]; + return { cacheKey: call[0], userKey: call[2] }; + }; + + const withMeta = await cacheKeyFor(REVENUE_METADATA); + const withoutMeta = await cacheKeyFor(undefined); + + expect(withMeta.cacheKey).toEqual(withoutMeta.cacheKey); + expect(withMeta.userKey).toEqual(withoutMeta.userKey); + // And the SQL is unchanged (measures/dimensions only). + expect(executeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + statement: + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL", + }), + expect.any(AbortSignal), + ); + }); }); // ── 503-vs-404 latching + dormancy. @@ -2232,3 +2414,89 @@ describe("metric route — lane dispatch (Phase 3)", () => { expect(executeMock).not.toHaveBeenCalled(); }); }); + +// ── Phase 2: metadata slicing. `selectMetricMetadata` flattens the injected +// per-metric metadata down to only the requested columns for the SSE `result` +// message. It is pure and total; the invariants below are what keep the stamp +// scoped, degrade-safe, and prototype-safe. +describe("selectMetricMetadata", () => { + const all: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + orders: { + measures: { cnt: { type: "bigint" } }, + dimensions: {}, + }, + }; + + test("returns only the requested measures and dimensions (flat slice)", () => { + expect(selectMetricMetadata(all, "revenue", ["arr"], ["region"])).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits requested columns absent from the metadata (degraded/unknown cols)", () => { + // `mrr` is known; `ebitda` and `country` are not → dropped, not placeheld. + expect( + selectMetricMetadata(all, "revenue", ["mrr", "ebitda"], ["country"]), + ).toEqual({ + mrr: { type: "decimal", display_name: "MRR" }, + }); + }); + + test("undefined when no metadata is injected (all absent)", () => { + expect( + selectMetricMetadata(undefined, "revenue", ["arr"], ["region"]), + ).toBeUndefined(); + }); + + test("undefined for an unknown metric key", () => { + expect( + selectMetricMetadata(all, "nope", ["arr"], undefined), + ).toBeUndefined(); + }); + + test("undefined when none of the requested columns are present (empty slice)", () => { + expect( + selectMetricMetadata(all, "revenue", ["unknown"], ["also_unknown"]), + ).toBeUndefined(); + }); + + test("undefined when dimensions is undefined and no measures match", () => { + expect( + selectMetricMetadata(all, "orders", ["missing"], undefined), + ).toBeUndefined(); + }); + + test("handles undefined dimensions (measures only)", () => { + expect(selectMetricMetadata(all, "orders", ["cnt"], undefined)).toEqual({ + cnt: { type: "bigint" }, + }); + }); + + test.each(["__proto__", "constructor", "toString", "hasOwnProperty"])( + "inherited Object.prototype key %j → undefined (own-property lookup)", + (dangerousKey) => { + expect( + selectMetricMetadata(all, dangerousKey, ["arr"], undefined), + ).toBeUndefined(); + }, + ); + + test("does not resolve a requested column to an inherited prototype member", () => { + // `toString` is an inherited member of the measures object, not an own + // entry — it must not leak into the slice as a bogus function value. + expect( + selectMetricMetadata(all, "revenue", ["toString"], ["hasOwnProperty"]), + ).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index 83e4f737c..d6a080681 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,7 +1,21 @@ -import type { BasePluginConfig } from "shared"; +import type { BasePluginConfig, MetricViewsMetadata } from "shared"; export interface IAnalyticsConfig extends BasePluginConfig { timeout?: number; + /** + * Build-generated per-metric column metadata (`display_name` / `format` / + * `type` / `description`), keyed by metric key. The app injects the constant + * emitted by the metric-views type generator via + * `analytics({ metricViewsMetadata })`. + * + * The metric route stamps the slice of this scoped to a request's requested + * measures/dimensions into the SSE `result` message. It is **response + * decoration only**: it never enters the cache key and never changes the SQL. + * Absent → the `result` message carries no `metadata` field and the route + * behaves exactly as before. Never read from disk / `DESCRIBE` at runtime — + * it comes only from this injected value. + */ + metricViewsMetadata?: MetricViewsMetadata; /** * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL * warehouse to reach RUNNING before failing the request. Defaults to 5 min. diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 237125482..ddbb6cea2 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -297,7 +297,7 @@ async function probeWarehouseState( * `metric-views` directory of `queryFolder` (so query-only callers keep * working); when neither is given, the metric path is skipped. * @param options.mvOutFile - optional output file for the MetricRegistry - * augmentation. Defaults to a sibling `metric-views.d.ts` file under the same + * augmentation. Defaults to a sibling `metric-views.ts` file under the same * directory as `outFile`. Skipped entirely if `definitions.json` is absent. * @param options.metricFetcher - optional DescribeFetcher used by * {@link syncMetrics} (tests inject a mock; production lazily builds a @@ -458,7 +458,9 @@ export interface SyncMetricViewsTypesResult { * * @param options.metricViewsFolder - folder that holds `definitions.json` (`/config/metric-views`). * @param options.warehouseId - SQL warehouse used for `DESCRIBE TABLE EXTENDED`. - * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`. + * @param options.metricOutFile - output path for the MetricRegistry `.ts` (the + * generated source carries both the `declare module` augmentation and the + * runtime `metricViewsMetadata` const). * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). * @param options.metricFetcher - optional injected {@link DescribeFetcher} * @param options.mode - preflight/gate policy, default `"describe-now"`. @@ -752,4 +754,4 @@ export type { export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; -export const METRIC_TYPES_FILE = "metric-views.d.ts"; +export const METRIC_TYPES_FILE = "metric-views.ts"; diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index f70e584c8..dc0c81195 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -155,6 +155,70 @@ ${inner}; }`; } +// Render one column's runtime metadata object literal — the value-side twin of +// a `renderMetadataMap` entry. Sources the SAME per-column fields +// (type/display_name/format/description) but omits `time_grain` (not part of +// MetricColumnMeta). Strings go through JSON.stringify so quotes/backticks in +// display_name/description stay escape-safe. +function renderMetadataValueField(col: MetricColumnMetadata): string { + const fields: string[] = [`type: ${JSON.stringify(col.type)}`]; + if (col.displayName) { + fields.push(`display_name: ${JSON.stringify(col.displayName)}`); + } + if (col.format) { + fields.push(`format: ${JSON.stringify(col.format)}`); + } + if (col.description) { + fields.push(`description: ${JSON.stringify(col.description)}`); + } + return `{ ${fields.join(", ")} }`; +} + +// Render the runtime value map (measures or dimensions) for one metric — an +// object literal keyed by column name. Empty → `{}` (the value twin of the +// type-level `Record`, which is a type-only construct). +function renderMetadataValueMap( + cols: MetricColumnMetadata[], + indent: string, +): string { + if (cols.length === 0) return "{}"; + const inner = cols + .map( + (col) => + `${indent} ${JSON.stringify(col.name)}: ${renderMetadataValueField(col)}`, + ) + .join(",\n"); + return `{ +${inner}, +${indent}}`; +} + +// Render the runtime `metricViewsMetadata` const — a value twin of the +// type-level `metadata` blocks, conforming to MetricViewsMetadata from +// "shared". Emitted `as const`. Iterates `schemas` in the SAME order as the +// type augmentation. A degraded schema (empty measure/dimension arrays) +// contributes empty `measures: {}` / `dimensions: {}` maps, consistent with +// its degraded type block. +function renderMetricViewsMetadata(schemas: MetricSchema[]): string { + if (schemas.length === 0) { + return "export const metricViewsMetadata = {} as const;\n"; + } + const entries = schemas + .map((schema) => { + const measures = renderMetadataValueMap(schema.measures, " "); + const dimensions = renderMetadataValueMap(schema.dimensions, " "); + return ` ${JSON.stringify(schema.key)}: { + measures: ${measures}, + dimensions: ${dimensions}, + }`; + }) + .join(",\n"); + return `export const metricViewsMetadata = { +${entries}, +} as const; +`; +} + // Render the augmentation block for the appkit-ui MetricRegistry interface. function renderMetricRegistry(schemas: MetricSchema[]): string { if (schemas.length === 0) { @@ -172,12 +236,21 @@ ${entries}; `; } -// Build the full metric-views.d.ts file from a list of metric schemas. +// Build the full metric-views.ts file from a list of metric schemas. +// +// This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the +// erasable `declare module` type augmentation AND a runtime value export +// (`metricViewsMetadata`). It must therefore never emit a runtime side-effect +// import — a bare `import "@databricks/appkit-ui/react"` would execute the +// client package entry on the Node server. The header is a type-only +// `import type {} from "..."`, which (a) compiles to zero runtime code and +// (b) anchors the module so the global `declare module` augmentation resolves. export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { return `// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; -${renderMetricRegistry(schemas)}`; +import type {} from "@databricks/appkit-ui/react"; +${renderMetricRegistry(schemas)} +${renderMetricViewsMetadata(schemas)}`; } diff --git a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap index 6970320c2..7d1992ec9 100644 --- a/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap +++ b/packages/appkit/src/type-generator/tests/__snapshots__/mv-registry.test.ts.snap @@ -3,7 +3,7 @@ exports[`generateMetricTypeDeclarations — snapshot > emits TimeGrain union for a metric view with time-typed + regular dimensions 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "revenue": { @@ -48,13 +48,26 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "revenue": { + measures: { + "arr": { type: "DECIMAL(38,2)", description: "Annual recurring revenue" }, + }, + dimensions: { + "created_at": { type: "TIMESTAMP" }, + "region": { type: "STRING" }, + "segment": { type: "STRING" }, + }, + }, +} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits a stable MetricRegistry augmentation for a mixed sp + obo input 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "customer_metrics": { @@ -138,23 +151,47 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "customer_metrics": { + measures: { + "churn_rate": { type: "DOUBLE", display_name: "Churn Rate", format: "0.0%" }, + }, + dimensions: { + "csm_email": { type: "STRING" }, + "billing_date": { type: "DATE" }, + }, + }, + "revenue": { + measures: { + "arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00", description: "Annual recurring revenue" }, + "mrr": { type: "DECIMAL(38,2)", description: "Monthly recurring revenue" }, + }, + dimensions: { + "region": { type: "STRING" }, + "created_at": { type: "TIMESTAMP" }, + }, + }, +} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits an empty MetricRegistry interface when no metrics are registered 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry {} } + +export const metricViewsMetadata = {} as const; " `; exports[`generateMetricTypeDeclarations — snapshot > emits permissive types for a degraded entry and accurate empty unions for a confirmed-empty entry 1`] = ` "// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */ @@ -195,5 +232,18 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "cold_metric": { + measures: {}, + dimensions: {}, + }, + "dims_only": { + measures: {}, + dimensions: { + "region": { type: "STRING" }, + }, + }, +} as const; " `; diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index ae3ae46f5..67ea1868e 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -359,8 +359,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // not passed explicitly, so these tests only pass `queryFolder` below. const metricViewsFolder = path.join(metricsDir, "metric-views"); const outFile = path.join(metricsDir, "generated", "analytics.d.ts"); - // Default: the metric .d.ts is a sibling of `outFile`. - const metricFile = path.join(metricsDir, "generated", "metric-views.d.ts"); + // Default: the metric .ts is a sibling of `outFile`. + const metricFile = path.join(metricsDir, "generated", "metric-views.ts"); const describeResponse: DatabricksStatementExecutionResponse = { statement_id: "stmt-mock", @@ -409,7 +409,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { fs.rmSync(metricsDir, { recursive: true, force: true }); }); - test("writes metric-views.d.ts when definitions.json exists", async () => { + test("writes metric-views.ts when definitions.json exists", async () => { writeMetricConfig(); await expect( @@ -426,9 +426,19 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain('"revenue"'); expect(declarations).toContain('"total_revenue": number'); expect(declarations).toContain('"region": string'); - // Semantic metadata (SQL type) rides in the .d.ts type-level `metadata` + // Semantic metadata (SQL type) rides in the type-level `metadata` // block — the sole carrier now that the JSON bundle is gone. expect(declarations).toContain('"DECIMAL(38,2)"'); + // The generated file is a real `.ts`, so it also carries the runtime + // `metricViewsMetadata` const (value twin of the type-level metadata). + expect(declarations).toContain("export const metricViewsMetadata"); + expect(declarations).toContain("as const"); + // ...and NEVER a runtime side-effect import that would execute the client + // package entry on the Node server — only a zero-runtime type-only import. + expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); + expect(declarations).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); }); test("emits no metric artifacts and no errors when definitions.json is absent", async () => { @@ -1156,7 +1166,7 @@ describe("generateFromEntryPoint — metric cache section", () => { // derives it from queryFolder when not passed explicitly. const metricViewsFolder = path.join(cacheTestDir, "metric-views"); const outFile = path.join(cacheTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join(cacheTestDir, "generated", "metric-views.d.ts"); + const metricFile = path.join(cacheTestDir, "generated", "metric-views.ts"); const describeResponseFor = ( measure: string, diff --git a/packages/appkit/src/type-generator/tests/mv-registry.test.ts b/packages/appkit/src/type-generator/tests/mv-registry.test.ts index 6fa77693e..b2027070e 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -1623,6 +1623,112 @@ describe("generateMetricTypeDeclarations — snapshot", () => { }); }); +// ── PR5 Phase 1: the emitted file is a real `.ts` carrying BOTH the (erasable) +// `declare module` type augmentation AND a runtime `metricViewsMetadata` value. +// It must never emit a runtime side-effect import (that would execute the client +// package entry on the Node server) — only a zero-runtime type-only import. +describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { + test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { + const resolution = resolveMetricConfig({ + metricViews: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // Type half: the augmentation is still present, unchanged in shape. + expect(output).toContain('declare module "@databricks/appkit-ui/react"'); + expect(output).toContain("interface MetricRegistry"); + // Value half: a runtime const conforming to MetricViewsMetadata, `as const`. + expect(output).toContain("export const metricViewsMetadata = {"); + expect(output).toContain("} as const;"); + // The measure/dimension maps carry the SAME per-column fields as the type + // block (type/display_name/format), keyed by column name. + expect(output).toContain( + '"arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00" }', + ); + expect(output).toContain('"region": { type: "STRING" }'); + }); + + test("uses a zero-runtime type-only import, never a side-effect import", () => { + const output = generateMetricTypeDeclarations([]); + // A bare `import "..."` in a `.ts` would EXECUTE the client entry on the + // Node server — it must never be emitted. + expect(output).not.toContain('import "@databricks/appkit-ui/react"'); + expect(output).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); + }); + + test("emits an empty metricViewsMetadata for no registered metrics", () => { + const output = generateMetricTypeDeclarations([]); + expect(output).toContain("export const metricViewsMetadata = {} as const;"); + // Empty type augmentation stays too. + expect(output).toContain("interface MetricRegistry {}"); + }); + + test("a degraded schema contributes empty measures/dimensions value maps", async () => { + const resolution = resolveMetricConfig({ + metricViews: { cold: { source: "appkit_demo.public.cold" } }, + }); + // Non-terminal DESCRIBE → degraded schema (empty column arrays). + const fetcher = + async (): Promise => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + // Value side of a degraded entry: empty maps, consistent with its + // `Record` metadata type block. + expect(output).toContain(`"cold": { + measures: {}, + dimensions: {}, + }`); + }); + + test("escapes quotes/backticks in display_name and description via JSON.stringify", async () => { + const resolution = resolveMetricConfig({ + metricViews: { revenue: { source: "appkit_demo.public.revenue" } }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + // A double quote AND a backtick — both must survive into a valid + // TS string literal in the runtime const. + display_name: 'Net "ARR" `growth`', + comment: 'Revenue with a " quote', + }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // JSON.stringify escapes the embedded double quotes; the backtick rides + // through unescaped inside a double-quoted literal (valid TS). + expect(output).toContain('display_name: "Net \\"ARR\\" `growth`"'); + expect(output).toContain('description: "Revenue with a \\" quote"'); + }); +}); + // ── Phase 5: semantic-metadata extraction (display_name + format) ───────── describe("extractMetricColumns — Phase 5 semantic metadata", () => { test("captures display_name from a measure column", () => { @@ -1946,12 +2052,12 @@ describe("extractMetricColumns — Phase 5 semantic metadata", () => { }); }); -// ── Key-order determinism: the .d.ts emitter sorts metric keys with a +// ── Key-order determinism: the emitter sorts metric keys with a // locale-independent (code-unit) comparator. localeCompare-style collation // would interleave mixed-case keys ("ARPU", "churn", "Revenue") and could vary // by machine/locale, drifting the emitted augmentation between builds. describe("artifact key-order determinism", () => { - test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.d.ts", async () => { + test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.ts", async () => { const resolution = resolveMetricConfig({ metricViews: { Revenue: { source: "a.b.r" }, @@ -1973,7 +2079,7 @@ describe("artifact key-order determinism", () => { }); const { schemas } = await syncMetrics(resolution, fetcher); - // Entry keys in the .d.ts appear as ` "": {` lines (4-space + // Entry keys in the augmentation appear as ` "": {` lines (4-space // indent — metadata column maps sit deeper and don't match). const declarations = generateMetricTypeDeclarations(schemas); const dtsKeys = [...declarations.matchAll(/^ {4}"([^"]+)": \{$/gm)].map( diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 337892aa8..718a9fc3f 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -128,7 +128,7 @@ describe("syncMetricViewsTypes", () => { tmpRoot, "shared", "appkit-types", - "metric-views.d.ts", + "metric-views.ts", ); }); @@ -146,7 +146,7 @@ describe("syncMetricViewsTypes", () => { metricFetcher: fetcher, }); - // The .d.ts exists on disk. + // The generated .ts exists on disk. expect(fs.existsSync(metricOutFile)).toBe(true); // Result reports both keys, no failures, config present. @@ -158,7 +158,7 @@ describe("syncMetricViewsTypes", () => { ]); expect(result.metricOutFile).toBe(metricOutFile); - // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- + // --- metric-views.ts: MetricRegistry augmentation for both metrics --- const declarations = fs.readFileSync(metricOutFile, "utf-8"); expect(declarations).toContain("interface MetricRegistry"); expect(declarations).toContain('"revenue"'); @@ -172,9 +172,17 @@ describe("syncMetricViewsTypes", () => { expect(declarations).toContain('lane: "sp"'); // The TIMESTAMP dimension carries inferred time grains in its @timeGrain tag. expect(declarations).toContain("@timeGrain"); - // The semantic metadata (format spec, SQL type) rides in the .d.ts's - // type-level `metadata` block — the sole carrier now the JSON is gone. + // The semantic metadata (format spec, SQL type) rides in the type-level + // `metadata` block — the sole carrier now the JSON is gone. expect(declarations).toContain('"$#,##0.00"'); + // The file is a real `.ts`: it also carries the runtime `metricViewsMetadata` + // const, and never a runtime side-effect import (only a type-only one). + expect(declarations).toContain("export const metricViewsMetadata"); + expect(declarations).toContain("as const"); + expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); + expect(declarations).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); }); test("returns noConfig and writes nothing when definitions.json is absent", async () => { 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 214dc9a31..7510dd8f6 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -387,7 +387,7 @@ describe("appKitTypesPlugin — metric option plumbing", () => { test("a custom mvOutFile reaches generateFromEntryPoint", async () => { const plugin = appKitTypesPlugin({ - mvOutFile: "custom/types/metric-views.d.ts", + mvOutFile: "custom/types/metric-views.ts", }); getHook( plugin, @@ -400,10 +400,7 @@ describe("appKitTypesPlugin — metric option plumbing", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ - mvOutFile: path.resolve( - process.cwd(), - "custom/types/metric-views.d.ts", - ), + mvOutFile: path.resolve(process.cwd(), "custom/types/metric-views.ts"), }), ); }); diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 1bcb08ae5..6880152fb 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -35,8 +35,10 @@ interface AppKitTypesPluginOptions { /* Path to the output d.ts file (relative to client folder). */ outFile?: string; /** - * Path to the metric registry d.ts file (relative to client folder). - * Defaults to a sibling of `outFile`, computed by the generator. + * Path to the metric registry `.ts` file (relative to client folder). + * Defaults to a sibling of `outFile`, computed by the generator. The + * generated source carries both the `declare module` augmentation and the + * runtime `metricViewsMetadata` const, so it is a real `.ts`, not a `.d.ts`. */ mvOutFile?: string; /** diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 30255cc87..1c2f5c855 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -235,7 +235,7 @@ describe("generate-types foreground spawn orchestration", () => { }); test("reports the metric artifact when config/metric-views/definitions.json exists", async () => { - // The metric path is additive: generateFromEntryPoint emits metric-views.d.ts + // The metric path is additive: generateFromEntryPoint emits metric-views.ts // as a sibling of the query out file whenever the config is present. The CLI // announces it off the same dormancy signal. const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); @@ -249,7 +249,7 @@ describe("generate-types foreground spawn orchestration", () => { const logged = consoleLog.mock.calls.flat().map(String); expect(logged).toContain(`Generated query types: ${outFile}`); expect(logged).toContain( - `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.d.ts")}`, + `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.ts")}`, ); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 03ab43c0d..f38f323a2 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -95,7 +95,7 @@ async function runGenerateTypes( if (fs.existsSync(metricConfig)) { const typesDir = path.dirname(resolvedOutFile); console.log( - `Generated metric types: ${path.join(typesDir, "metric-views.d.ts")}`, + `Generated metric types: ${path.join(typesDir, "metric-views.ts")}`, ); } } From 948307b03747279f9ce53854bca553866c372e6c Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 11:02:46 +0200 Subject: [PATCH 03/28] feat(playground): wire metric-view runtime end-to-end + docs (PR5 phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the metric-view runtime in dev-playground and document it: - Regenerate the generated artifact as shared/appkit-types/metric-views.ts (delete the legacy .d.ts). Verified byte-for-byte identical to a live `generate-types` DESCRIBE against a real UC Metric View (warehouse dd43ee29fedd958d, dogfood): display_name/format/description genuinely flow from the UC YAML through typegen into the runtime metricViewsMetadata const. - Inject the const server-side: analytics({ metricViewsMetadata }). - Add a /metric-views demo route calling useMetricView("revenue", …) with timeGrain/timeDimension, rendering a chart + table whose labels and value formats come from the payload metadata (never hand-typed), degrading gracefully when metadata is absent. - Docs: extend plugins/analytics.md with the useMetricView + format-utility + metricViewsMetadata injection story (Plotly + ECharts examples), and fix the stale metric-views.d.ts references in development/type-generation.md. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/client/src/lib/nav.ts | 8 + .../client/src/routeTree.gen.ts | 21 ++ .../client/src/routes/metric-views.route.tsx | 128 +++++++++++ apps/dev-playground/server/index.ts | 8 +- .../{metric-views.d.ts => metric-views.ts} | 30 ++- docs/docs/development/type-generation.md | 6 +- docs/docs/plugins/analytics.md | 198 ++++++++++++++++++ 7 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 apps/dev-playground/client/src/routes/metric-views.route.tsx rename apps/dev-playground/shared/appkit-types/{metric-views.d.ts => metric-views.ts} (71%) diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index 00f70dfec..ba05b5b63 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -13,6 +13,7 @@ import { SearchIcon, ServerIcon, ShieldIcon, + SigmaIcon, Wand2Icon, ZapIcon, } from "lucide-react"; @@ -64,6 +65,13 @@ export const NAV_GROUPS: ReadonlyArray = [ "Same dashboard — served over Apache Arrow streaming for zero-copy speed.", icon: ZapIcon, }, + { + to: "/metric-views", + label: "Metric Views", + description: + "Measure a governed UC metric view with useMetricView — labels and formats from injected metadata.", + icon: SigmaIcon, + }, { to: "/lakebase", label: "Lakebase", diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 450287592..59f9b3602 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SmartDashboardRouteRouteImport } from './routes/smart-dashboar import { Route as ServingRouteRouteImport } from './routes/serving.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' import { Route as PolicyMatrixRouteRouteImport } from './routes/policy-matrix.route' +import { Route as MetricViewsRouteRouteImport } from './routes/metric-views.route' import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as JobsRouteRouteImport } from './routes/jobs.route' import { Route as GenieRouteRouteImport } from './routes/genie.route' @@ -74,6 +75,11 @@ const PolicyMatrixRouteRoute = PolicyMatrixRouteRouteImport.update({ path: '/policy-matrix', getParentRoute: () => rootRouteImport, } as any) +const MetricViewsRouteRoute = MetricViewsRouteRouteImport.update({ + id: '/metric-views', + path: '/metric-views', + getParentRoute: () => rootRouteImport, +} as any) const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ id: '/lakebase', path: '/lakebase', @@ -136,6 +142,7 @@ export interface FileRoutesByFullPath { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -157,6 +164,7 @@ export interface FileRoutesByTo { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -179,6 +187,7 @@ export interface FileRoutesById { '/genie': typeof GenieRouteRoute '/jobs': typeof JobsRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/metric-views': typeof MetricViewsRouteRoute '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute @@ -202,6 +211,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -223,6 +233,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -244,6 +255,7 @@ export interface FileRouteTypes { | '/genie' | '/jobs' | '/lakebase' + | '/metric-views' | '/policy-matrix' | '/reconnect' | '/serving' @@ -266,6 +278,7 @@ export interface RootRouteChildren { GenieRouteRoute: typeof GenieRouteRoute JobsRouteRoute: typeof JobsRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute + MetricViewsRouteRoute: typeof MetricViewsRouteRoute PolicyMatrixRouteRoute: typeof PolicyMatrixRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute ServingRouteRoute: typeof ServingRouteRoute @@ -342,6 +355,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PolicyMatrixRouteRouteImport parentRoute: typeof rootRouteImport } + '/metric-views': { + id: '/metric-views' + path: '/metric-views' + fullPath: '/metric-views' + preLoaderRoute: typeof MetricViewsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/lakebase': { id: '/lakebase' path: '/lakebase' @@ -426,6 +446,7 @@ const rootRouteChildren: RootRouteChildren = { GenieRouteRoute: GenieRouteRoute, JobsRouteRoute: JobsRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, + MetricViewsRouteRoute: MetricViewsRouteRoute, PolicyMatrixRouteRoute: PolicyMatrixRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, ServingRouteRoute: ServingRouteRoute, diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx new file mode 100644 index 000000000..41e5d6937 --- /dev/null +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -0,0 +1,128 @@ +import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + LineChart, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useMetricView, +} from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { Header } from "@/components/layout/header"; + +export const Route = createFileRoute("/metric-views")({ + component: MetricViewsRoute, +}); + +// Columns we ask the metric view for. Declared at module scope so their +// array identities stay stable across renders — `useMetricView` serializes +// the request body, so this also keeps the SSE subscription from re-firing. +const MEASURES = ["arr", "mrr"] as const; +const DIMENSIONS = ["created_at"] as const; + +function MetricViewsRoute() { + // Measure the `revenue` metric view: annual + monthly recurring revenue, + // bucketed by month over the `created_at` time dimension. Measure / + // dimension names, the time grain, and the row shape are all inferred from + // the generated `MetricRegistry` augmentation (shared/appkit-types/metric-views.ts). + const { data, loading, error, metadata } = useMetricView("revenue", { + measures: MEASURES, + dimensions: DIMENSIONS, + timeGrain: "month", + timeDimension: "created_at", + }); + + // The columns we rendered, in display order. `metadata` is the + // payload-carried, client-agnostic per-column display metadata the server + // stamped onto the SSE result from `analytics({ metricViewsMetadata })`. + const columns = ["created_at", ...MEASURES] as const; + + return ( +
+
+
+ + + + Recurring revenue by month + + revenue · measures {MEASURES.join(", ")} · grouped by month + + + + {loading && } + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && (!data || data.length === 0) && ( +
+ No results for this metric view. +
+ )} + + {!loading && !error && data && data.length > 0 && ( + <> + + +
+ + + + {columns.map((col) => ( + + {/* Human label from metadata.display_name, + else a humanized fallback. */} + {formatLabel(col, metadata?.[col])} + + ))} + + + + {data.map((row, i) => ( + + {columns.map((col) => ( + + {/* Format string comes from metadata, never + hand-typed. When `metadata` is undefined + (server injected none / unknown key), + `metadata?.[col]?.format` is undefined and + formatValue degrades to a sensible default. */} + {formatValue(row[col], metadata?.[col]?.format)} + + ))} + + ))} + +
+
+ + )} +
+
+
+
+ ); +} diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 1027928bb..97794372d 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -20,6 +20,12 @@ import { } from "@databricks/appkit/beta"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { z } from "zod"; +// Build-generated per-metric column metadata (display_name / format / type / +// description), emitted by the metric-views type generator alongside the +// MetricRegistry augmentation. Injecting it into `analytics({ metricViewsMetadata })` +// lets the metric route stamp per-column display metadata into the SSE result +// so the client can label/format columns without hard-coding format strings. +import { metricViewsMetadata } from "../shared/appkit-types/metric-views"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -377,7 +383,7 @@ createApp({ server(), reconnect(), telemetryExamples(), - analytics({}), + analytics({ metricViewsMetadata }), genie({ spaces: { demo: process.env.DATABRICKS_GENIE_SPACE_ID ?? "placeholder" }, }), diff --git a/apps/dev-playground/shared/appkit-types/metric-views.d.ts b/apps/dev-playground/shared/appkit-types/metric-views.ts similarity index 71% rename from apps/dev-playground/shared/appkit-types/metric-views.d.ts rename to apps/dev-playground/shared/appkit-types/metric-views.ts index 1c7fb87a9..4a9990148 100644 --- a/apps/dev-playground/shared/appkit-types/metric-views.d.ts +++ b/apps/dev-playground/shared/appkit-types/metric-views.ts @@ -1,6 +1,6 @@ // Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; +import type {} from "@databricks/appkit-ui/react"; declare module "@databricks/appkit-ui/react" { interface MetricRegistry { "customers": { @@ -127,3 +127,31 @@ declare module "@databricks/appkit-ui/react" { }; } } + +export const metricViewsMetadata = { + "customers": { + measures: { + "active_accounts": { type: "bigint", display_name: "Active Accounts", format: "#,##0" }, + "churn_rate": { type: "decimal", display_name: "Churn Rate" }, + "avg_ltv": { type: "double", display_name: "Average LTV", format: "$#,##0.00" }, + }, + dimensions: { + "segment": { type: "string", display_name: "Customer Segment" }, + "region": { type: "string", display_name: "Region" }, + "csm_email": { type: "string", display_name: "CSM Email" }, + }, + }, + "revenue": { + measures: { + "mrr": { type: "double", display_name: "Monthly Recurring Revenue", format: "$#,##0.00" }, + "arr": { type: "double", display_name: "Annual Recurring Revenue", format: "$#,##0.00", description: "Annualized contract value across all active subscriptions" }, + "new_arr": { type: "double", display_name: "New ARR", format: "$#,##0.00" }, + "churned_arr": { type: "double", display_name: "Churned ARR", format: "$#,##0.00" }, + }, + dimensions: { + "region": { type: "string", display_name: "Region" }, + "segment": { type: "string", display_name: "Customer Segment" }, + "created_at": { type: "timestamp_ltz", display_name: "Subscription Start" }, + }, + }, +} as const; diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 94199cd6e..0fd84bca1 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -10,7 +10,7 @@ AppKit can automatically generate TypeScript types for your SQL queries, providi Generate type-safe TypeScript declarations for query keys, parameters, and result rows. -All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.d.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The `.d.ts` files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per concern: `analytics.d.ts` (SQL query types), `serving.d.ts` (model-serving endpoint types), and `metric-views.ts`. A single command (and the Vite plugin) produces them all in one pass; see [Metric-view types](#metric-view-types). The declaration files use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. (`metric-views.ts` is a real source file rather than a `.d.ts` because it *also* carries a runtime `metricViewsMetadata` constant alongside the augmentation — see [Metric-view types](#metric-view-types).) TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. ## Vite plugin: `appKitTypesPlugin` @@ -106,9 +106,9 @@ This imposes a rollout ordering: **produce and commit `.appkit/` while the wareh ## Metric-view types -`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes `metric-views.d.ts` into `shared/appkit-types/`: +`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](../plugins/analytics.md) and writes `metric-views.ts` into `shared/appkit-types/`: -- `metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. +- `metric-views.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. The same file also exports a runtime `metricViewsMetadata` constant (the same metadata as a value, not just types) — inject it via `analytics({ metricViewsMetadata })` so the metric route can carry per-column display metadata in its response payload. The type augmentation erases at build; the constant is a normal named export and is tree-shaken away when unused. See [the analytics plugin's metric-view docs](../plugins/analytics.md) for the hook + format-utility wiring. If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` that same situation fails the build so CI never ships incomplete metric types. A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index a2ca889a2..2bf5988e3 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -493,3 +493,201 @@ const { data } = useAnalyticsQuery("users", params); // Bad - creates a new object every render, causing infinite refetches const { data } = useAnalyticsQuery("users", { status: sql.string("active") }); ``` + +### useMetricView + +React hook that measures a [metric view](#metric-views) over SSE — the client twin of `POST /api/analytics/metric/:key`. Instead of writing SQL, you pass the measures, dimensions, and filter as a structured request; the hook streams back the typed rows plus per-column display metadata. + +```ts +import { useMetricView } from "@databricks/appkit-ui/react"; + +const { data, loading, error, errorCode, metadata } = useMetricView("revenue", { + measures: ["arr", "mrr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", +}); +``` + +When `"revenue"` is a key in the generated `MetricRegistry` (see [Metric-view types](../development/type-generation.md#metric-view-types)), the measure/dimension names, the allowed `timeGrain` values, and the row shape are all inferred — passing an unknown measure is a type error, and `data` is typed as `Array<{ arr: number; mrr: number; created_at: string }> | null`. + +**Options:** + +| Option | Type | Required | Description | +| --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `measures` | `string[]` | yes | Measures to aggregate. Inferred from `MetricRegistry[key].measureKeys` for a known key. | +| `dimensions` | `string[]` | no | Dimensions to group by. Inferred from `measureKeys` / `dimensionKeys`. | +| `filter` | `MetricFilter` | no | Recursive predicate tree (same grammar as the route — see [Filters](#filters)). | +| `timeGrain` | `string` | no | Bucket a time dimension (`day`, `month`, …). Requires `timeDimension`. Inferred `timeGrains`. | +| `timeDimension` | `string` | no | The single dimension `timeGrain` buckets. Must be one of `dimensions`. | +| `limit` | `number` | no | Positive integer row cap. | +| `autoStart` | `boolean` | no | Start the query on mount. Default `true`. | + +**Return type:** + +```ts +{ + data: T | null; // typed rows (measures & dimensions), or null before the first result + loading: boolean; // true while the metric query is executing + error: string | null; // sanitized human-readable message, or null on success + errorCode: string | null; // stable upstream code (branch on this, not the message) + metadata: Record | undefined; // per-column display metadata (see below) +} +``` + +Like `useAnalyticsQuery`, the option object is serialized internally, so object/array literals passed fresh each render stay referentially stable — you do **not** need to `useMemo` the options. (Hoisting `measures`/`dimensions` to module scope or memoizing is still fine, and keeps the arrays type-narrowed to their literal tuple.) + +`metadata` is the per-column display metadata for **only the columns you queried**, scoped and carried in the SSE `result` payload. It is `undefined` when the server injected no metadata (the metric key is unknown, or `analytics({ metricViewsMetadata })` was not wired) — so always treat it as optional. + +### Metadata injection + +The metric route can stamp per-column display metadata (`display_name`, `format`, `type`, `description`) onto each `result` message. This metadata is **build-generated** by the metric-view type generator, which emits it as a runtime constant alongside the `MetricRegistry` type augmentation. Wire it into the plugin with a single import: + +```ts +// server/index.ts +import { analytics, createApp, server } from "@databricks/appkit"; +// Generated by the metric-view type generator (same file as the MetricRegistry +// augmentation). Path is your app's generated-types dir. +import { metricViewsMetadata } from "../shared/appkit-types/metric-views"; + +createApp({ + plugins: [ + server(), + analytics({ metricViewsMetadata }), + // … + ], +}); +``` + +This is **pure response decoration**: the injected metadata never enters the cache key and never changes the SQL. With it wired, every metric `result` message carries a `metadata` field scoped to the requested columns; without it, the message is byte-identical to a plain `/query` result and the hook's `metadata` is `undefined`. Because the metadata rides on the payload, the client never has to import the generated file or hard-code a format string — it is **payload-carried and client-agnostic**. + +### Format utilities + +`@databricks/appkit-ui/js` ships small, pure, tree-shakeable formatters that turn raw values + the metadata above into display strings. They take the format spec (or `MetricColumnMeta`) as **arguments** — no React, no chart-library coupling — so they work in tables, tooltips, and chart configs alike. + +| Function | Purpose | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `formatValue(value, format?)` | Format a raw value with a UC/spreadsheet format spec (`"$#,##0.00"`, `"#,##0"`, `"0.0%"`). No spec → sensible default. | +| `formatLabel(name, columnMeta?)` | Human label for a column: prefers `columnMeta.display_name`, else humanizes the raw name. | +| `toD3Format(format?)` | Map a UC format spec to a [d3-format](https://d3js.org/d3-format) specifier (for charts that consume d3 strings). | + +The golden rule: **source the format from `metadata`, never hand-type it.** When `metadata` is `undefined`, `metadata?.[col]?.format` is `undefined` and `formatValue` degrades gracefully to a default: + +```tsx +import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenueTable() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr", "mrr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + }); + const columns = ["created_at", "arr", "mrr"] as const; + + return ( + + + + {columns.map((col) => ( + // Header text from display_name (or a humanized fallback). + + ))} + + + + {data?.map((row, i) => ( + + {columns.map((col) => ( + // Format string comes from metadata, never hand-typed. + + ))} + + ))} + +
{formatLabel(col, metadata?.[col])}
{formatValue(row[col], metadata?.[col]?.format)}
+ ); +} +``` + +#### Feeding the format into charts + +Because `metadata[col].format` is just a string on the payload, the same spec drives axis ticks and tooltips in any chart library. + +**Plotly** — pass the spec straight through as a d3 `tickformat` / `hovertemplate` (Plotly axes speak d3-format): + +```tsx +import Plot from "react-plotly.js"; +import { toD3Format } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenuePlot() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + }); + const arrFormat = toD3Format(metadata?.arr?.format); // "$#,##0.00" → "$,.2f" + + return ( + r.created_at) ?? [], + y: data?.map((r) => r.arr) ?? [], + name: metadata?.arr?.display_name ?? "arr", + }, + ]} + layout={{ + yaxis: { tickformat: arrFormat }, + hoverlabel: { namelength: -1 }, + }} + /> + ); +} +``` + +**ECharts** — use the format spec inside `axisLabel.formatter` / `tooltip.formatter` via `formatValue`: + +```tsx +import ReactECharts from "echarts-for-react"; +import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { useMetricView } from "@databricks/appkit-ui/react"; + +function RevenueECharts() { + const { data, metadata } = useMetricView("revenue", { + measures: ["arr"], + dimensions: ["created_at"], + timeGrain: "month", + timeDimension: "created_at", + }); + const arrFormat = metadata?.arr?.format; + + const option = { + xAxis: { type: "category", data: data?.map((r) => r.created_at) ?? [] }, + yAxis: { + type: "value", + axisLabel: { formatter: (v: number) => formatValue(v, arrFormat) }, + }, + tooltip: { + trigger: "axis", + valueFormatter: (v: number) => formatValue(v, arrFormat), + }, + series: [ + { + name: formatLabel("arr", metadata?.arr), + type: "line", + data: data?.map((r) => r.arr) ?? [], + }, + ], + }; + + return ; +} +``` + +In both cases the format string originates from the server-injected `metadata` and is never written into the component — swapping the YAML `format` attribute on the metric view re-flows every axis, tooltip, and table cell without a client change. From 941a73356a1db683de67a277c6f71e2fa869f8a3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 14:39:22 +0200 Subject: [PATCH 04/28] chore: metrics --- .../client/src/routes/metric-views.route.tsx | 474 +++++++++++++++--- .../hooks/__tests__/use-metric-view.test.ts | 90 +++- .../src/react/hooks/use-metric-view.ts | 64 ++- 3 files changed, 560 insertions(+), 68 deletions(-) diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index 41e5d6937..5faef401b 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -1,11 +1,22 @@ import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; import { + Badge, + BarChart, + Button, Card, CardContent, CardDescription, CardHeader, CardTitle, + DonutChart, LineChart, + type MetricFilter, + type MetricPredicate, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Skeleton, Table, TableBody, @@ -16,110 +27,461 @@ import { useMetricView, } from "@databricks/appkit-ui/react"; import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useMemo, useState } from "react"; import { Header } from "@/components/layout/header"; export const Route = createFileRoute("/metric-views")({ component: MetricViewsRoute, }); -// Columns we ask the metric view for. Declared at module scope so their -// array identities stay stable across renders — `useMetricView` serializes -// the request body, so this also keeps the SSE subscription from re-firing. -const MEASURES = ["arr", "mrr"] as const; -const DIMENSIONS = ["created_at"] as const; +// Columns each visual asks the `revenue` metric view for. Declared at module +// scope so their array identities stay stable across renders — `useMetricView` +// serializes the request body, so this also keeps each SSE subscription from +// re-firing on unrelated state changes. Measure / dimension names and the row +// shape are inferred from the generated `MetricRegistry` augmentation +// (shared/appkit-types/metric-views.ts). +const REGION_DIM = ["region"] as const; +const SEGMENT_DIM = ["segment"] as const; +const TIME_DIM = ["created_at"] as const; +const ARR_MEASURE = ["arr"] as const; +const TREND_MEASURES = ["arr", "mrr"] as const; +const TABLE_MEASURES = ["arr", "mrr", "new_arr", "churned_arr"] as const; +const TABLE_COLUMNS = ["region", ...TABLE_MEASURES] as const; + +// The dimensions the page lets you slice by. Both the filter bar (dropdowns) +// and the detail table (row click) write selections keyed by these names, and +// every visual composes them into a `MetricFilter` the same way — so a future +// chart-click cross-filter drops into the same `selection` state unchanged. +const FILTER_DIMENSIONS = ["region", "segment"] as const; +type FilterDimension = (typeof FILTER_DIMENSIONS)[number]; +type Selection = Partial>; + +// Radix `Select` forbids an empty-string item value, so an explicit sentinel +// stands in for the "no filter on this dimension" choice. +const ALL = "__all__"; + +/** + * Compose the active selection into a `MetricFilter`, optionally excluding one + * dimension. Excluding a visual's own grouping dimension is what makes this a + * *cross*-filter rather than a global filter: the by-region chart keeps every + * region visible when a region is selected (so you can pick another), while the + * charts grouped by *other* dimensions narrow to that region. + */ +function buildFilter( + selection: Selection, + exclude?: FilterDimension, +): MetricFilter | undefined { + const predicates: MetricPredicate[] = []; + for (const dimension of FILTER_DIMENSIONS) { + const value = selection[dimension]; + if (dimension === exclude || value === undefined) continue; + predicates.push({ member: dimension, operator: "equals", values: [value] }); + } + if (predicates.length === 0) return undefined; + if (predicates.length === 1) return predicates[0]; + return { and: predicates }; +} + +/** + * Loading / error / empty state shared by every visual card. Returns `null` + * once data has rows so the caller renders the visual. + * + * `data === null` means the query hasn't produced a result yet — on first mount + * `useMetricView` is `loading=false, data=null` for a frame before its effect + * fires `start()`. Treating that as the skeleton state (not "empty") avoids + * flashing "No results" before the query has even run. Error is checked first + * so a failed query still surfaces its message rather than a skeleton. + */ +function VisualStatus({ + loading, + error, + data, +}: { + loading: boolean; + error: string | null; + data: readonly unknown[] | null; +}) { + if (error) + return ( +
+ {error} +
+ ); + if (loading || data === null) return ; + if (data.length === 0) + return ( +
+ No results for this selection. +
+ ); + return null; +} function MetricViewsRoute() { - // Measure the `revenue` metric view: annual + monthly recurring revenue, - // bucketed by month over the `created_at` time dimension. Measure / - // dimension names, the time grain, and the row shape are all inferred from - // the generated `MetricRegistry` augmentation (shared/appkit-types/metric-views.ts). - const { data, loading, error, metadata } = useMetricView("revenue", { - measures: MEASURES, - dimensions: DIMENSIONS, + // The single source of cross-filter truth. Every visual derives its query + // filter from this map, and every control (dropdowns, table rows) writes + // back into it — so all visuals stay coordinated through one piece of state. + const [selection, setSelection] = useState({}); + + const setDimension = useCallback( + (dimension: FilterDimension, value: string | undefined) => { + setSelection((previous) => { + const next = { ...previous }; + if (value === undefined) delete next[dimension]; + else next[dimension] = value; + return next; + }); + }, + [], + ); + + const clearAll = useCallback(() => setSelection({}), []); + + // One filter per visual, each excluding its own grouping dimension so the + // facet you're slicing on stays fully visible (see buildFilter). + const regionFilter = useMemo( + () => buildFilter(selection, "region"), + [selection], + ); + const segmentFilter = useMemo( + () => buildFilter(selection, "segment"), + [selection], + ); + // The trend and table group by created_at / region respectively; neither is a + // filterable dimension in its own right for the trend, so it applies the full + // selection. The table groups by region, so it excludes region (same filter + // as the region bar). + const trendFilter = useMemo(() => buildFilter(selection), [selection]); + + // Revenue by region — the filter excludes `region`, so this always lists + // every region available under the current segment selection. Doubles as the + // domain for the Region dropdown and the detail table below. + const region = useMetricView("revenue", { + measures: ARR_MEASURE, + dimensions: REGION_DIM, + filter: regionFilter, + }); + + // Revenue by segment — excludes `segment`, so every segment stays visible and + // this also feeds the Segment dropdown's options. + const segment = useMetricView("revenue", { + measures: ARR_MEASURE, + dimensions: SEGMENT_DIM, + filter: segmentFilter, + }); + + // ARR + MRR over time — the hero trend. Applies the full selection, so + // picking a region and/or segment visibly reshapes the line. + const trend = useMetricView("revenue", { + measures: TREND_MEASURES, + dimensions: TIME_DIM, timeGrain: "month", timeDimension: "created_at", + filter: trendFilter, + }); + + // Detail table, grouped by region. Same filter as the region bar (excludes + // region) so clicking a row narrows the other visuals without hiding the row + // you just clicked. This is the table cross-filter. + const table = useMetricView("revenue", { + measures: TABLE_MEASURES, + dimensions: REGION_DIM, + filter: regionFilter, }); - // The columns we rendered, in display order. `metadata` is the - // payload-carried, client-agnostic per-column display metadata the server - // stamped onto the SSE result from `analytics({ metricViewsMetadata })`. - const columns = ["created_at", ...MEASURES] as const; + // Dropdown option domains, derived from the region/segment breakdowns. Because + // each breakdown excludes its own dimension's filter, the options reflect + // what's actually available under the *other* active filter. + const regionOptions = useMemo( + () => + Array.from( + new Set((region.data ?? []).map((row) => String(row.region))), + ).sort(), + [region.data], + ); + const segmentOptions = useMemo( + () => + Array.from( + new Set((segment.data ?? []).map((row) => String(row.segment))), + ).sort(), + [segment.data], + ); + + const activeDimensions = FILTER_DIMENSIONS.filter( + (dimension) => selection[dimension] !== undefined, + ); return (
- + {/* Filter bar (A): dropdowns write into the shared selection. The Region + value is bound to selection.region, so it also reflects a table-row + click below. */} + - Recurring revenue by month + Filters - revenue · measures {MEASURES.join(", ")} · grouped by month + Slice every visual on this page by region and segment. - - {loading && } + +
+ - {error && ( -
- {error} -
- )} + +
- {!loading && !error && (!data || data.length === 0) && ( -
- No results for this metric view. + {/* Active-filter chips — click to remove one, or clear all. */} + {activeDimensions.length > 0 && ( +
+ {activeDimensions.map((dimension) => ( + // `asChild` renders the Badge as a real + + ))} +
)} + + + +
+ {/* Revenue by region */} + + + ARR by region + + revenue · arr · grouped by region + + + + + {!region.loading && + !region.error && + region.data && + region.data.length > 0 && ( + + )} + + - {!loading && !error && data && data.length > 0 && ( - <> + {/* Revenue by segment */} + + + ARR by segment + + revenue · arr · grouped by segment + + + + + {!segment.loading && + !segment.error && + segment.data && + segment.data.length > 0 && ( + + )} + + +
+ + {/* Hero trend — reshapes as filters narrow. */} + + + Recurring revenue over time + + revenue · measures {TREND_MEASURES.join(", ")} · grouped by month + + + + + {!trend.loading && + !trend.error && + trend.data && + trend.data.length > 0 && ( + )} + + + {/* Detail table (C): click a row to cross-filter by that region. */} + + + Revenue detail by region + + Click a row to filter every visual by that region — click again + (or a chip above) to clear. + + + + + {!table.loading && + !table.error && + table.data && + table.data.length > 0 && (
- {columns.map((col) => ( - - {/* Human label from metadata.display_name, - else a humanized fallback. */} - {formatLabel(col, metadata?.[col])} + {TABLE_COLUMNS.map((column) => ( + + {formatLabel(column, table.metadata?.[column])} ))} - {data.map((row, i) => ( - - {columns.map((col) => ( - - {/* Format string comes from metadata, never - hand-typed. When `metadata` is undefined - (server injected none / unknown key), - `metadata?.[col]?.format` is undefined and - formatValue degrades to a sensible default. */} - {formatValue(row[col], metadata?.[col]?.format)} - - ))} - - ))} + {/* One row per region — region is the GROUP BY key, so + it's unique per row and safe as the React key. */} + {table.data.map((row) => { + const rowRegion = String(row.region); + const isSelected = selection.region === rowRegion; + const toggle = () => + setDimension( + "region", + isSelected ? undefined : rowRegion, + ); + return ( + // The keeps its native `row` role (no role + // override — that would break table semantics for + // screen readers); its onClick is a mouse-only + // convenience. The real keyboard-accessible control is + // the button in the region cell below. + + {TABLE_COLUMNS.map((column) => + column === "region" ? ( + + + + ) : ( + + {formatValue( + row[column], + table.metadata?.[column]?.format, + )} + + ), + )} + + ); + })}
- - )} + )}
diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts index 0b09dfafc..580ec97b4 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -31,6 +31,20 @@ vi.mock("../use-query-hmr", () => ({ useQueryHMR: vi.fn(), })); +// Mock the warehouse-status publisher so we can observe the publish-only +// side-channel (useMetricView surfaces warehouse readiness ONLY by publishing +// to the ResourceStatusProvider — it never adds a field to its result). The +// two spies are stable across renders, mirroring the real hook's useCallback +// contract, so start()'s identity doesn't churn. +const mockPublishWarehouseStatus = vi.fn(); +const mockUnpublishWarehouseStatus = vi.fn(); +vi.mock("../use-analytics-warehouse-status", () => ({ + useAnalyticsWarehousePublisher: () => ({ + publish: mockPublishWarehouseStatus, + unpublish: mockUnpublishWarehouseStatus, + }), +})); + import { useMetricView } from "../use-metric-view"; function markAborted() { @@ -44,6 +58,8 @@ describe("useMetricView", () => { vi.clearAllMocks(); lastConnectArgs = null; capturedCallbacks = {}; + mockPublishWarehouseStatus.mockClear(); + mockUnpublishWarehouseStatus.mockClear(); }); afterEach(() => { @@ -136,29 +152,89 @@ describe("useMetricView", () => { expect(result.current.error).toBeNull(); }); - test("ignores warehouse_status events without leaving the loading state", async () => { + test("publishes warehouse_status to the resource provider without exposing it on the result", async () => { const { result } = renderHook(() => useMetricView("orders", { measures: ["revenue"] }), ); expect(result.current.loading).toBe(true); + // start() registers the slot with a null status (see the publish-only + // side-channel) before any event arrives. + expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(null); + const status = { state: "STARTING", elapsedMs: 1200 }; act(() => { lastConnectArgs.onMessage({ - data: JSON.stringify({ - type: "warehouse_status", - status: { state: "STARTING", elapsedMs: 1200 }, - }), + data: JSON.stringify({ type: "warehouse_status", status }), }); }); - // The metric result shape does not expose warehouseStatus — the event is a - // no-op that keeps the hook loading until the result arrives. + // The event is published to the shared provider (driving a global + // "warehouse starting…" indicator) but the metric result shape does NOT + // expose warehouseStatus (Phase 0 contract) and the hook stays loading. + expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(status); + expect(mockUnpublishWarehouseStatus).not.toHaveBeenCalled(); + expect(result.current).not.toHaveProperty("warehouseStatus"); expect(result.current.loading).toBe(true); expect(result.current.data).toBeNull(); expect(result.current.error).toBeNull(); }); + test("unpublishes warehouse status once the result arrives", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 500 }, + }), + }); + }); + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 1 }] }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + // The indicator must clear once the warehouse is ready and rows land. + expect(mockUnpublishWarehouseStatus).toHaveBeenCalled(); + }); + + test("a malformed warehouse_status event errors and unpublishes rather than publishing", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + // Baseline publish(null) from start(); a malformed event must not publish + // a status on top of it. + const publishCallsBefore = mockPublishWarehouseStatus.mock.calls.length; + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "warehouse_status" }), + }); + }); + + await waitFor(() => { + expect(result.current.error).toBe( + "Unable to load data, please try again", + ); + }); + expect(result.current.loading).toBe(false); + expect(mockPublishWarehouseStatus.mock.calls.length).toBe( + publishCallsBefore, + ); + expect(mockUnpublishWarehouseStatus).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + test("a server error event exposes both the message and the structured errorCode", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts index c7dd54d26..ff4cce3bd 100644 --- a/packages/appkit-ui/src/react/hooks/use-metric-view.ts +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import type { MetricColumnMeta } from "shared"; import { connectSSE } from "@/js"; import type { @@ -6,7 +13,9 @@ import type { MetricKey, UseMetricViewOptions, UseMetricViewResult, + WarehouseStatus, } from "./types"; +import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status"; import { useQueryHMR } from "./use-query-hmr"; function getDevMode(): string { @@ -35,6 +44,16 @@ interface MetricSseContext { setErrorCode: (code: string | null) => void; setData: (data: Record[] | null) => void; setMetadata: (metadata: Record | undefined) => void; + publishWarehouseStatus: (status: WarehouseStatus | null) => void; + unpublishWarehouseStatus: () => void; +} + +function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { + return ( + typeof value === "object" && + value !== null && + typeof (value as WarehouseStatus).state === "string" + ); } function handleMetricSseMessage( @@ -42,9 +61,20 @@ function handleMetricSseMessage( ctx: MetricSseContext, ): void { // Warehouse-readiness progress. The metric result type does NOT expose - // warehouseStatus (Phase 0 contract), so these events keep the hook in its - // loading state without surfacing anything to the caller. + // warehouseStatus (Phase 0 contract), so we keep the hook in its loading + // state (no caller-facing field) but publish the status to the shared + // ResourceStatusProvider — the same side-channel `useAnalyticsQuery` uses to + // drive a global "warehouse starting…" indicator during a cold start. This + // is a publish-only path: it never mutates UseMetricViewResult. if (parsed.type === "warehouse_status") { + if (!isWarehouseStatusPayload(parsed.status)) { + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); + ctx.unpublishWarehouseStatus(); + console.error("[useMetricView] Malformed warehouse_status event", parsed); + return; + } + ctx.publishWarehouseStatus(parsed.status); return; } @@ -62,6 +92,7 @@ function handleMetricSseMessage( ctx.setMetadata( parsed.metadata as Record | undefined, ); + ctx.unpublishWarehouseStatus(); return; } @@ -72,6 +103,7 @@ function handleMetricSseMessage( "Unable to execute metric query"; ctx.setLoading(false); ctx.setError(errorMsg); + ctx.unpublishWarehouseStatus(); // Propagate the upstream structured code so UI consumers can branch on a // stable identifier instead of parsing the human-readable message. if (typeof parsed.errorCode === "string") { @@ -90,6 +122,7 @@ function handleMetricSseMessage( console.error("[useMetricView] Unrecognized SSE payload", parsed); ctx.setLoading(false); ctx.setError(GENERIC_LOAD_ERROR); + ctx.unpublishWarehouseStatus(); } /** @@ -136,6 +169,13 @@ export function useMetricView( >(undefined); const abortControllerRef = useRef(null); + // Warehouse-readiness status + const publisherId = useId(); + const { + publish: publishWarehouseStatus, + unpublish: unpublishWarehouseStatus, + } = useAnalyticsWarehousePublisher(publisherId, key); + if (!key || key.trim().length === 0) { throw new Error("useMetricView: 'key' must be a non-empty string."); } @@ -178,6 +218,9 @@ export function useMetricView( setErrorCode(null); setData(null); setMetadata(undefined); + // Register this hook's slot (null = registered, not contributing) so a + // re-query clears any stale warehouse status from the prior run. + publishWarehouseStatus(null); const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -188,6 +231,8 @@ export function useMetricView( setErrorCode, setData: (rows) => setData(rows as Rows | null), setMetadata, + publishWarehouseStatus, + unpublishWarehouseStatus, }; connectSSE({ @@ -210,12 +255,14 @@ export function useMetricView( console.warn("[useMetricView] Malformed message received", error); setLoading(false); setError(GENERIC_LOAD_ERROR); + unpublishWarehouseStatus(); abortController.abort(); } }, onError: (error) => { if (abortController.signal.aborted) return; setLoading(false); + unpublishWarehouseStatus(); if (error instanceof Error) { console.error("[useMetricView] Error", { @@ -227,7 +274,13 @@ export function useMetricView( setError(userFacingFetchError(error)); }, }); - }, [key, payload, urlSuffix]); + }, [ + key, + payload, + urlSuffix, + publishWarehouseStatus, + unpublishWarehouseStatus, + ]); useEffect(() => { if (autoStart) { @@ -236,8 +289,9 @@ export function useMetricView( return () => { abortControllerRef.current?.abort(); + unpublishWarehouseStatus(); }; - }, [start, autoStart]); + }, [start, autoStart, unpublishWarehouseStatus]); useQueryHMR(key, start); From a8c6251ea3cbcc9ce09005c949e52a98e2e9e71c Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 15:38:54 +0200 Subject: [PATCH 05/28] refactor(appkit-ui): extract MetricFilter vocabulary + toMetricFilter builder to /js Move the twelve-operator MetricFilter grammar out of react/hooks/types.ts into a canonical, framework-agnostic js/metric-filter/ module and add a toMetricFilter builder that compiles a { dimension -> value(s) } shorthand into a MetricFilter (scalar -> equals, array -> in, omit undefined/empty). react/hooks/types.ts now re-exports the types so the /react public surface and UseMetricViewOptions.filter are unchanged. Wire the dev-playground metric-views route's buildFilter onto toMetricFilter, keeping only the app-specific cross-filter facet-exclusion local. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../client/src/routes/metric-views.route.tsx | 21 ++-- packages/appkit-ui/src/js/index.ts | 1 + .../src/js/metric-filter/index.test.ts | 72 ++++++++++++++ .../appkit-ui/src/js/metric-filter/index.ts | 99 +++++++++++++++++++ packages/appkit-ui/src/react/hooks/types.ts | 45 +++------ 5 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 packages/appkit-ui/src/js/metric-filter/index.test.ts create mode 100644 packages/appkit-ui/src/js/metric-filter/index.ts diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index 5faef401b..996066895 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -1,4 +1,9 @@ -import { formatLabel, formatValue } from "@databricks/appkit-ui/js"; +import { + formatLabel, + formatValue, + type MetricFilter, + toMetricFilter, +} from "@databricks/appkit-ui/js"; import { Badge, BarChart, @@ -10,8 +15,6 @@ import { CardTitle, DonutChart, LineChart, - type MetricFilter, - type MetricPredicate, Select, SelectContent, SelectItem, @@ -66,20 +69,22 @@ const ALL = "__all__"; * *cross*-filter rather than a global filter: the by-region chart keeps every * region visible when a region is selected (so you can pick another), while the * charts grouped by *other* dimensions narrow to that region. + * + * The map-to-`MetricFilter` compilation itself is the SDK's `toMetricFilter` + * (from `@databricks/appkit-ui/js`) — this wrapper only adds the cross-filter + * facet-exclusion, which is app-specific and stays local. */ function buildFilter( selection: Selection, exclude?: FilterDimension, ): MetricFilter | undefined { - const predicates: MetricPredicate[] = []; + const shorthand: Record = {}; for (const dimension of FILTER_DIMENSIONS) { const value = selection[dimension]; if (dimension === exclude || value === undefined) continue; - predicates.push({ member: dimension, operator: "equals", values: [value] }); + shorthand[dimension] = value; } - if (predicates.length === 0) return undefined; - if (predicates.length === 1) return predicates[0]; - return { and: predicates }; + return toMetricFilter(shorthand); } /** diff --git a/packages/appkit-ui/src/js/index.ts b/packages/appkit-ui/src/js/index.ts index 2a9deaf43..86447be01 100644 --- a/packages/appkit-ui/src/js/index.ts +++ b/packages/appkit-ui/src/js/index.ts @@ -13,4 +13,5 @@ export * from "./arrow"; export * from "./config"; export * from "./constants"; export * from "./format"; +export * from "./metric-filter"; export * from "./sse"; diff --git a/packages/appkit-ui/src/js/metric-filter/index.test.ts b/packages/appkit-ui/src/js/metric-filter/index.test.ts new file mode 100644 index 000000000..0bd8cadfc --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { type MetricFilter, toMetricFilter } from "./index"; + +describe("toMetricFilter", () => { + test("returns undefined for an empty selection", () => { + expect(toMetricFilter({})).toBeUndefined(); + }); + + test("omits members with undefined values", () => { + expect(toMetricFilter({ region: undefined })).toBeUndefined(); + expect(toMetricFilter({ region: undefined, segment: "SMB" })).toEqual({ + member: "segment", + operator: "equals", + values: ["SMB"], + }); + }); + + test("omits members with empty-array values", () => { + expect(toMetricFilter({ region: [] })).toBeUndefined(); + }); + + test("compiles a single scalar member to a bare equals predicate", () => { + expect(toMetricFilter({ region: "EMEA" })).toEqual({ + member: "region", + operator: "equals", + values: ["EMEA"], + }); + }); + + test("compiles a numeric scalar to an equals predicate", () => { + expect(toMetricFilter({ tier: 2 })).toEqual({ + member: "tier", + operator: "equals", + values: [2], + }); + }); + + test("compiles an array member to an in predicate", () => { + expect(toMetricFilter({ region: ["EMEA", "APAC"] })).toEqual({ + member: "region", + operator: "in", + values: ["EMEA", "APAC"], + }); + }); + + test("AND-groups multiple members, mixing equals and in", () => { + expect( + toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" }), + ).toEqual({ + and: [ + { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + { member: "segment", operator: "equals", values: ["SMB"] }, + ], + }); + }); + + test("copies array values rather than aliasing the caller's array", () => { + const values = ["EMEA", "APAC"]; + const filter = toMetricFilter({ region: values }); + // A single member compiles to a bare predicate (has `values`), not a group. + if (!filter || !("values" in filter)) { + throw new Error("expected a leaf predicate with values"); + } + expect(filter.values).toEqual(values); + expect(filter.values).not.toBe(values); + }); + + test("produces a MetricFilter assignable to the exported type", () => { + const filter: MetricFilter | undefined = toMetricFilter({ region: "EMEA" }); + expect(filter).toBeDefined(); + }); +}); diff --git a/packages/appkit-ui/src/js/metric-filter/index.ts b/packages/appkit-ui/src/js/metric-filter/index.ts new file mode 100644 index 000000000..c24029ca4 --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.ts @@ -0,0 +1,99 @@ +// ──────────────────────────────────────────────────────────────────────────── +// Metric filter vocabulary + builder. +// +// Pure, framework-agnostic. Lives on the `/js` axis because a `MetricFilter` is +// plain data — a Node script, an SSR pass, or a test can build one without React +// in the graph. The React `useMetricView` hook re-exports these types from +// `@databricks/appkit-ui/react` so its public surface is unchanged. +// +// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot +// depend on appkit, so this mirrors the twelve-operator filter grammar by hand. +// ──────────────────────────────────────────────────────────────────────────── + +/** v1 filter operator vocabulary — exactly twelve names. */ +export type MetricFilterOperatorName = + | "equals" + | "notEquals" + | "in" + | "notIn" + | "gt" + | "gte" + | "lt" + | "lte" + | "contains" + | "notContains" + | "set" + | "notSet"; + +/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */ +export interface MetricPredicate { + member: string; + operator: MetricFilterOperatorName; + values?: ReadonlyArray; +} + +/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */ +export type MetricFilter = + | MetricPredicate + | { and: ReadonlyArray } + | { or: ReadonlyArray }; + +/** + * Shorthand map of `dimension -> selected value(s)` that {@link toMetricFilter} + * compiles into a {@link MetricFilter}. A member is dropped when its value is + * `undefined` or an empty array, so a partially-filled filter-bar selection maps + * straight to "no predicate for that dimension". + */ +export type MetricFilterShorthand = Record< + string, + string | number | ReadonlyArray | undefined +>; + +/** + * Compile a `{ dimension -> value(s) }` shorthand into a {@link MetricFilter} — + * the equality/membership case a filter bar, dropdown set, or clicked data point + * produces. Scalar values become an `equals` predicate; array values become an + * `in` predicate. Members with `undefined` or empty-array values are omitted. + * + * Returns a bare {@link MetricPredicate} for a single member, an `and` group for + * several, and `undefined` when nothing is selected (so the caller can pass it + * straight to `useMetricView`'s optional `filter`, which omits the field when + * `undefined`). For operators beyond equality/membership (ranges, `contains`, + * `set`), build the {@link MetricFilter} tree directly. + * + * @example + * ```typescript + * toMetricFilter({ region: "EMEA" }); + * // → { member: "region", operator: "equals", values: ["EMEA"] } + * + * toMetricFilter({ region: ["EMEA", "APAC"], segment: "SMB" }); + * // → { and: [ + * // { member: "region", operator: "in", values: ["EMEA", "APAC"] }, + * // { member: "segment", operator: "equals", values: ["SMB"] }, + * // ] } + * + * toMetricFilter({ region: undefined }); // → undefined + * ``` + */ +export function toMetricFilter( + selection: MetricFilterShorthand, +): MetricFilter | undefined { + const predicates: MetricPredicate[] = []; + for (const member of Object.keys(selection)) { + const value = selection[member]; + if (value === undefined) continue; + if (Array.isArray(value)) { + if (value.length === 0) continue; + predicates.push({ member, operator: "in", values: [...value] }); + } else { + predicates.push({ + member, + operator: "equals", + values: [value as string | number], + }); + } + } + if (predicates.length === 0) return undefined; + if (predicates.length === 1) return predicates[0]; + return { and: predicates }; +} diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index 3406eb213..a01207fe5 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -317,40 +317,17 @@ export type InferMetricRow = K extends AugmentedRegistry : Record : Record; -// ──────────────────────────────────────────────────────────────────────────── -// Metric filter vocabulary. -// -// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot -// depend on appkit, so this mirrors the twelve-operator filter grammar by hand. -// ──────────────────────────────────────────────────────────────────────────── - -/** v1 filter operator vocabulary — exactly twelve names. */ -export type MetricFilterOperatorName = - | "equals" - | "notEquals" - | "in" - | "notIn" - | "gt" - | "gte" - | "lt" - | "lte" - | "contains" - | "notContains" - | "set" - | "notSet"; - -/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */ -export interface MetricPredicate { - member: string; - operator: MetricFilterOperatorName; - values?: ReadonlyArray; -} - -/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */ -export type MetricFilter = - | MetricPredicate - | { and: ReadonlyArray } - | { or: ReadonlyArray }; +// The metric-filter vocabulary is pure data (no React), so it lives canonically +// on the `/js` axis. Re-export it here so the `/react` public surface — and +// `UseMetricViewOptions.filter` below — is unchanged. The runtime builder +// `toMetricFilter` is available from `@databricks/appkit-ui/js`. +export type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "@/js"; + +import type { MetricFilter } from "@/js"; /** Options for configuring a `useMetricView` query. */ export interface UseMetricViewOptions { From c3b8273b1f24d4fcabd1b905c75ec3197f46ca8f Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 16:31:59 +0200 Subject: [PATCH 06/28] feat(appkit-ui): add onDataClick + selected props to charts (phases 1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two public chart props, inherited by every chart type via the factory: - onDataClick?(datum: ChartClickDatum): fire-and-forget click callback. base.tsx builds a memoized internal echarts onEvents={{ click }} only when the handler is set (no idle listener), mapping raw params via the pure mapToDatum. Pointer-only (canvas) — documented to require a keyboard-accessible equivalent. - selected?: string | string[]: controlled, name-based visual emphasis. base.tsx runs the pure applySelectionEmphasis transform over the built option so matching bar/pie-donut categories stay prominent and the rest dim; no-op when unset. ChartClickDatum is the only new public (barrel) symbol; mapToDatum, applySelectionEmphasis and SelectionEmphasisOptions are internal. echarts types stay out of the public API (datum.raw is unknown). Phases 1 and 2 are committed together so the producer helpers have their consumer (satisfies knip). Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit-ui/src/react/charts/base.tsx | 44 ++++- packages/appkit-ui/src/react/charts/index.ts | 1 + .../appkit-ui/src/react/charts/options.ts | 178 ++++++++++++++++++ packages/appkit-ui/src/react/charts/types.ts | 50 +++++ packages/appkit-ui/src/react/charts/utils.ts | 52 +++++ 5 files changed, 323 insertions(+), 2 deletions(-) diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 54c473114..1803af8d7 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -29,6 +29,7 @@ import ReactEChartsCore from "echarts-for-react/esm/core"; import { useCallback, useMemo, useRef } from "react"; import { normalizeChartData, normalizeHeatmapData } from "./normalize"; import { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, @@ -38,11 +39,13 @@ import { } from "./options"; import { useChartUITokens, useThemeColors } from "./theme"; import type { + ChartClickDatum, ChartColorPalette, ChartData, ChartType, Orientation, } from "./types"; +import { mapToDatum } from "./utils"; // ============================================================================ // ECharts Registration (modular imports for tree-shaking) @@ -168,6 +171,23 @@ export interface BaseChartProps { options?: Record; /** Additional CSS classes */ className?: string; + /** + * Fired when a data element (bar, slice, point) is clicked. Fire-and-forget: + * the return value is ignored (async handlers are fine — the chart never awaits). + * The handler receives a normalized {@link ChartClickDatum}. + * + * Pointer-only: charts render to , so this does not fire for keyboard + * users. Provide a keyboard-accessible equivalent (e.g. a table row action) for + * the same action. + */ + onDataClick?: (datum: ChartClickDatum) => void; + /** + * Controlled selection by category name. Matching data element(s) render at full + * prominence while the rest are dimmed. Drive it from your own state to reflect a + * cross-filter or selection. Categorical charts (bar, pie/donut) show emphasis; + * other chart types ignore it. + */ + selected?: string | string[]; } // ============================================================================ @@ -202,6 +222,8 @@ export function BaseChart({ max, options: customOptions, className, + onDataClick, + selected, }: BaseChartProps) { // Determine the appropriate color palette based on chart type const resolvedPalette = colorPalette ?? getDefaultPalette(chartType); @@ -329,8 +351,10 @@ export function BaseChart({ }); } - // Merge custom options - return customOptions ? { ...opt, ...customOptions } : opt; + // Merge custom options, then apply declarative selection emphasis. When + // `selected` is undefined/empty, applySelectionEmphasis is a no-op. + const merged = customOptions ? { ...opt, ...customOptions } : opt; + return applySelectionEmphasis(merged, selected); }, [ normalized, colors, @@ -350,8 +374,23 @@ export function BaseChart({ min, max, customOptions, + selected, ]); + // Build the ECharts event map only when a click handler is provided. Memoized + // on `onDataClick` so the object identity is stable across renders — + // echarts-for-react re-subscribes whenever `onEvents` identity changes, so an + // unstable object would thrash listeners. When no handler is set, this is + // `undefined` and no idle click listener is attached. `onEvents` is an internal + // implementation detail and is intentionally not a public prop. + const onEvents = useMemo( + () => + onDataClick + ? { click: (params: unknown) => onDataClick(mapToDatum(params)) } + : undefined, + [onDataClick], + ); + if (!option) { return (
@@ -370,6 +409,7 @@ export function BaseChart({ opts={{ renderer: "canvas" }} notMerge={false} lazyUpdate={true} + onEvents={onEvents} /> ); } diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index f5e374e8d..33387dfca 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -108,6 +108,7 @@ export type { BarChartSpecificProps, // Base props ChartBaseProps, + ChartClickDatum, ChartColorPalette, ChartData, ChartType, diff --git a/packages/appkit-ui/src/react/charts/options.ts b/packages/appkit-ui/src/react/charts/options.ts index e50711c83..564c83671 100644 --- a/packages/appkit-ui/src/react/charts/options.ts +++ b/packages/appkit-ui/src/react/charts/options.ts @@ -391,3 +391,181 @@ export function buildCartesianOption( })), }; } + +// ============================================================================ +// Selection Emphasis (declarative cross-filter highlighting) +// ============================================================================ + +/** + * Opacity applied to data elements that are NOT part of the current selection. + * Kept local to the option builder since it only describes selection styling and + * is not a themeable UI token. + */ +const DIMMED_OPACITY = 0.3; + +/** Opacity applied to selected (emphasized) data elements. */ +const SELECTED_OPACITY = 1; + +/** Options controlling {@link applySelectionEmphasis}. */ +interface SelectionEmphasisOptions { + /** Opacity for dimmed (non-selected) elements. @default 0.3 */ + dimmedOpacity?: number; + /** Opacity for emphasized (selected) elements. @default 1 */ + selectedOpacity?: number; +} + +/** + * Normalizes the `selected` input into a lookup set of category names. + * Returns `null` when there is nothing selected (undefined, or an empty + * string/array), which callers treat as "no emphasis". + */ +function toSelectionSet( + selected: string | string[] | undefined, +): Set | null { + if (selected == null) return null; + const names = Array.isArray(selected) ? selected : [selected]; + const set = new Set(names.map((name) => String(name))); + return set.size > 0 ? set : null; +} + +/** + * Finds the category-axis label array (`xAxis`/`yAxis` with `type: "category"`), + * used to map a bar datum's position to its category name. Returns `null` when + * no category axis is present (e.g. time-series or value axes). + */ +function categoryNamesFromAxes( + option: Record, +): (string | number)[] | null { + for (const axisKey of ["xAxis", "yAxis"] as const) { + const axis = option[axisKey]; + if (axis !== null && typeof axis === "object" && !Array.isArray(axis)) { + const a = axis as Record; + if (a.type === "category" && Array.isArray(a.data)) { + return a.data as (string | number)[]; + } + } + } + return null; +} + +/** + * Returns a copy of a single data item with its `itemStyle.opacity` set. + * Object data items (e.g. pie `{ name, value }`) are spread and their existing + * `itemStyle` preserved; primitive data items (e.g. raw bar values) are wrapped + * into `{ value, itemStyle }` — the equivalent ECharts data-item form. The + * per-datum `itemStyle` merges over the series-level `itemStyle` in ECharts, so + * styling such as bar `borderRadius` is retained. + */ +function withDatumOpacity(datum: unknown, opacity: number): unknown { + if (datum !== null && typeof datum === "object" && !Array.isArray(datum)) { + const d = datum as Record; + const prev = + d.itemStyle !== null && + typeof d.itemStyle === "object" && + !Array.isArray(d.itemStyle) + ? (d.itemStyle as Record) + : {}; + return { ...d, itemStyle: { ...prev, opacity } }; + } + return { value: datum as number | string, itemStyle: { opacity } }; +} + +/** + * Applies per-datum opacity to a single series based on the selection set. + * Only categorical series carry a resolvable category name: + * - `pie` — the name is read from each datum's `name` field. + * - `bar` — the name is read from the category axis at the datum's index. + * All other series types (line, area, scatter, radar, heatmap) are returned + * unchanged, as is any series lacking a resolvable category name. + */ +function emphasizeSeries( + series: unknown, + selected: Set, + dimmedOpacity: number, + selectedOpacity: number, + categoryNames: (string | number)[] | null, +): unknown { + if (series === null || typeof series !== "object" || Array.isArray(series)) { + return series; + } + const s = series as Record; + if (!Array.isArray(s.data)) return series; + + let nameAt: (datum: unknown, index: number) => string | undefined; + if (s.type === "pie") { + nameAt = (datum) => + datum !== null && typeof datum === "object" && "name" in datum + ? String((datum as Record).name) + : undefined; + } else if (s.type === "bar") { + // Bar data items are raw values; the category name lives on the category axis. + if (!categoryNames) return series; + nameAt = (_datum, index) => + categoryNames[index] !== undefined + ? String(categoryNames[index]) + : undefined; + } else { + return series; + } + + const data = (s.data as unknown[]).map((datum, index) => { + const name = nameAt(datum, index); + if (name === undefined) return datum; + const opacity = selected.has(name) ? selectedOpacity : dimmedOpacity; + return withDatumOpacity(datum, opacity); + }); + + return { ...s, data }; +} + +/** + * Pure, declarative selection-emphasis transform for a built ECharts `option`. + * + * Given one or more selected category names, returns a new `option` in which the + * matching data element(s) render at full prominence while the rest are dimmed + * via `itemStyle.opacity`. It is a **no-op** (returns the input unchanged) when + * `selected` is `undefined` or empty. + * + * This function never touches an ECharts instance or calls `dispatchAction` — it + * only shapes the option object, so it can be composed into the option-building + * pipeline. It meaningfully affects the categorical chart types (`bar`, `pie`, + * `donut`) where a data point maps to a category name; other chart types are + * left untouched. + * + * @typeParam T - The option object type (typically `Record`). + * @param option - The ECharts option produced by one of the `build*Option` helpers. + * @param selected - The selected category name(s); `undefined`/empty means no emphasis. + * @param opts - Optional opacity overrides. See {@link SelectionEmphasisOptions}. + * @returns A new option with emphasis applied, or the original `option` when there is no selection. + */ +export function applySelectionEmphasis( + option: T, + selected: string | string[] | undefined, + opts: SelectionEmphasisOptions = {}, +): T { + const selectedSet = toSelectionSet(selected); + // No selection → identity: no emphasis, no dimming. + if (!selectedSet) return option; + + if (option === null || typeof option !== "object" || Array.isArray(option)) { + return option; + } + const opt = option as Record; + if (!Array.isArray(opt.series)) return option; + + const dimmedOpacity = opts.dimmedOpacity ?? DIMMED_OPACITY; + const selectedOpacity = opts.selectedOpacity ?? SELECTED_OPACITY; + const categoryNames = categoryNamesFromAxes(opt); + + const series = (opt.series as unknown[]).map((s) => + emphasizeSeries( + s, + selectedSet, + dimmedOpacity, + selectedOpacity, + categoryNames, + ), + ); + + return { ...opt, series } as T; +} diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index fba131ec8..685487076 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -89,6 +89,56 @@ export interface ChartBaseProps { /** Additional ECharts options to merge */ options?: Record; + + /** + * Fired when a data element (bar, slice, point) is clicked. Fire-and-forget: + * the return value is ignored (async handlers are fine — the chart never awaits). + * The handler receives a normalized {@link ChartClickDatum}. + * + * Pointer-only: charts render to , so this does not fire for keyboard + * users. Provide a keyboard-accessible equivalent (e.g. a table row action) for + * the same action. + */ + onDataClick?: (datum: ChartClickDatum) => void; + + /** + * Controlled selection by category name. Matching data element(s) render at full + * prominence while the rest are dimmed. Drive it from your own state to reflect a + * cross-filter or selection. Categorical charts (bar, pie/donut) show emphasis; + * other chart types ignore it. + */ + selected?: string | string[]; +} + +// ============================================================================ +// Interaction / Click Events +// ============================================================================ + +/** + * A normalized description of a clicked chart element. + * + * This is the public, ECharts-free shape emitted by chart click handlers. It is + * the single boundary that keeps ECharts event types out of appkit-ui's public + * API — consumers should read the strongly-typed fields below and reach for + * {@link ChartClickDatum.raw} only when they knowingly opt into unsupported + * internals. + * + * In the common cross-filter case, {@link ChartClickDatum.name} carries the + * dimension value of the clicked element. + */ +export interface ChartClickDatum { + /** Category label of the clicked element — the dimension value in the common cross-filter case. */ + name: string; + /** The datum's value. */ + value: number | string | null; + /** Series label, when present. */ + seriesName?: string; + /** Index of the datum within its series. */ + dataIndex: number; + /** Which series was clicked. */ + seriesIndex: number; + /** Untouched ECharts event params. Typed `unknown` — cast at your own risk; not part of the supported surface. */ + raw: unknown; } // ============================================================================ diff --git a/packages/appkit-ui/src/react/charts/utils.ts b/packages/appkit-ui/src/react/charts/utils.ts index cdd5c07a3..c4c587210 100644 --- a/packages/appkit-ui/src/react/charts/utils.ts +++ b/packages/appkit-ui/src/react/charts/utils.ts @@ -1,3 +1,5 @@ +import type { ChartClickDatum } from "./types"; + // ============================================================================ // Chart Utility Functions // ============================================================================ @@ -125,6 +127,56 @@ export function sortNumericAscending( return { xData: sortedXData, yDataMap: sortedYDataMap }; } +/** + * Maps a raw ECharts click-event `params` object into a public + * {@link ChartClickDatum}. + * + * This is the single boundary that keeps ECharts types out of appkit-ui's + * public API: the input is typed `unknown` (echarts-for-react passes the event + * payload loosely) and every field is read defensively via a narrowed local + * cast rather than by importing an ECharts type such as `CallbackDataParams` or + * `ECElementEvent`. + * + * Field handling: + * - `name` → coerced to a string, falling back to `""` when missing. + * - `value` → passed through when it is a `number` or `string`; arrays, + * objects, and missing values become `null`. + * - `seriesName` → kept when it is a string, otherwise left `undefined`. + * - `dataIndex` / `seriesIndex` → kept when numeric, otherwise `-1`. + * - `raw` → the entire original `params` object, untouched. + * + * @param params - The raw ECharts click-event payload (untyped at our boundary). + * @returns A normalized, ECharts-free {@link ChartClickDatum}. + */ +export function mapToDatum(params: unknown): ChartClickDatum { + const p = ( + params !== null && typeof params === "object" ? params : {} + ) as Record; + + const name = typeof p.name === "string" ? p.name : ""; + + const rawValue = p.value; + const value = + typeof rawValue === "number" || typeof rawValue === "string" + ? rawValue + : null; + + const seriesName = + typeof p.seriesName === "string" ? p.seriesName : undefined; + + const dataIndex = typeof p.dataIndex === "number" ? p.dataIndex : -1; + const seriesIndex = typeof p.seriesIndex === "number" ? p.seriesIndex : -1; + + return { + name, + value, + seriesName, + dataIndex, + seriesIndex, + raw: params, + }; +} + /** * Sorts time-series data in ascending chronological order. */ From 8dd47864ec435880088308a58adcdd887a4e39e7 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 16:37:29 +0200 Subject: [PATCH 07/28] feat(playground): chart-click cross-filter on metric-views region + segment charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the new appkit-ui chart props into the metric-views demo: the region BarChart and segment DonutChart get onDataClick={(d) => setDimension(dim, d.name)} — reusing the same setDimension the table row-click uses — and selected={selection[dim]} so the clicked category is emphasized. LineChart, Table, and the existing (keyboard-accessible) table row-click are unchanged. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/client/src/routes/metric-views.route.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index 996066895..ab5ebbd09 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -330,6 +330,8 @@ function MetricViewsRoute() { yKey="arr" height={280} title="Annual recurring revenue by region" + onDataClick={(d) => setDimension("region", d.name)} + selected={selection.region} /> )} @@ -361,6 +363,8 @@ function MetricViewsRoute() { innerRadius={55} showLegend title="Annual recurring revenue by segment" + onDataClick={(d) => setDimension("segment", d.name)} + selected={selection.segment} /> )} From dbef8f8406639dcf79110f7c9e0812b7f594c0fe Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Jul 2026 22:22:08 +0200 Subject: [PATCH 08/28] feat: export notify for write-back feat --- .../client/src/routes/metric-views.route.tsx | 104 +++++++++++++++++- .../react/charts/__tests__/options.test.ts | 60 ++++++++++ packages/appkit-ui/src/react/charts/base.tsx | 13 +++ .../appkit-ui/src/react/charts/options.ts | 30 ++++- packages/appkit-ui/src/react/ui/index.ts | 1 + packages/appkit-ui/src/react/ui/notify.ts | 42 +++++++ 6 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 packages/appkit-ui/src/react/ui/notify.ts diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index ab5ebbd09..f824a3e3b 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -9,12 +9,14 @@ import { BarChart, Button, Card, + CardAction, CardContent, CardDescription, CardHeader, CardTitle, DonutChart, LineChart, + notify, Select, SelectContent, SelectItem, @@ -30,6 +32,7 @@ import { useMetricView, } from "@databricks/appkit-ui/react"; import { createFileRoute } from "@tanstack/react-router"; +import { FilterIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { Header } from "@/components/layout/header"; @@ -87,6 +90,47 @@ function buildFilter( return toMetricFilter(shorthand); } +/** + * The dimensions actually shaping a card's data, given the shared selection and + * the card's own excluded dimension. Mirrors `buildFilter`'s facet-exclusion so + * the badge tells the truth per-card: the ARR-by-region card excludes `region`, + * so it never claims a region filter it deliberately ignores. + */ +function appliedDimensions( + selection: Selection, + exclude?: FilterDimension, +): FilterDimension[] { + return FILTER_DIMENSIONS.filter( + (dimension) => dimension !== exclude && selection[dimension] !== undefined, + ); +} + +/** + * Header badge that makes a card explicit about which filters shaped its data. + * Renders nothing when the card is unfiltered, so an unsliced card stays clean. + * Placed in `CardAction` (top-right of the header) via the caller. + */ +function FilterBadge({ + selection, + exclude, +}: { + selection: Selection; + exclude?: FilterDimension; +}) { + const applied = appliedDimensions(selection, exclude); + if (applied.length === 0) return null; + return ( + + + {applied + .map( + (dimension) => `${formatLabel(dimension)}: ${selection[dimension]}`, + ) + .join(" · ")} + + ); +} + /** * Loading / error / empty state shared by every visual card. Returns `null` * once data has rows so the caller renders the visual. @@ -313,6 +357,11 @@ function MetricViewsRoute() { revenue · arr · grouped by region + + {/* Excludes `region` — same facet-exclusion as this card's + filter, so it never claims the region slice it ignores. */} + + setDimension("region", d.name)} selected={selection.region} /> @@ -344,6 +392,9 @@ function MetricViewsRoute() { revenue · arr · grouped by segment + + + setDimension("segment", d.name)} selected={selection.segment} /> @@ -378,6 +428,11 @@ function MetricViewsRoute() { revenue · measures {TREND_MEASURES.join(", ")} · grouped by month + + {/* No `exclude` — the trend groups by time, so it applies the + full selection (both region and segment narrow it). */} + + { + // Resolve the measure by series INDEX — yKey order === + // TREND_MEASURES order, and sorting only reorders points + // WITHIN a series, not the series array. (Parsing the "Arr" + // label wouldn't round-trip to keys like "new_arr", and + // indexing trend.data by dataIndex is wrong because the + // time series is sorted before rendering.) + const measureKey = TREND_MEASURES[d.seriesIndex]; + if (!measureKey) return; + const meta = trend.metadata?.[measureKey]; + const label = meta?.display_name ?? measureKey; + // A time-series point is a [epochMs, value] tuple on + // ECharts' params.value (d.value is null for tuples); read + // it off the raw params so both the date and amount survive. + const tuple = (d.raw as { value?: unknown } | undefined) + ?.value; + const [ts, amount] = Array.isArray(tuple) + ? tuple + : [undefined, undefined]; + const month = + typeof ts === "number" + ? new Date(ts).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + }) + : String(d.name || ""); + const formatted = formatValue(amount, meta?.format); + notify.message( + `Write back: ${label} · ${month} · ${formatted}`, + { description: "Point selected for write-back (demo)." }, + ); + }} /> )} @@ -409,6 +502,11 @@ function MetricViewsRoute() { Click a row to filter every visual by that region — click again (or a chip above) to clear. + + {/* Grouped by region, so it excludes `region` (same as the region + bar) — a segment filter still narrows it. */} + + { expect(opt.series[0].smooth).toBe(false); expect(opt.series[0].showSymbol).toBe(false); }); + + test("applies symbolSize to line series (not just scatter)", () => { + const ctx = createBaseContext(); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "line", + isTimeSeries: false, + stacked: false, + smooth: true, + showSymbol: true, + symbolSize: 14, + }), + ); + + expect(opt.series[0].symbolSize).toBe(14); + }); + + test("sets triggerLineEvent only when interactive", () => { + const ctx = createBaseContext(); + const base = { + ...ctx, + chartType: "line" as const, + isTimeSeries: false, + stacked: false, + smooth: true, + showSymbol: true, + symbolSize: 8, + }; + + // Non-interactive line: no triggerLineEvent. + expect( + asOption(buildCartesianOption(base)).series[0].triggerLineEvent, + ).toBeUndefined(); + + // Interactive line: whole stroke is clickable. + expect( + asOption(buildCartesianOption({ ...base, interactive: true })).series[0] + .triggerLineEvent, + ).toBe(true); + }); + + test("does not set triggerLineEvent on a bar series even when interactive", () => { + const ctx = createBaseContext(); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + interactive: true, + }), + ); + + expect(opt.series[0].triggerLineEvent).toBeUndefined(); + }); }); describe("area chart", () => { diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 1803af8d7..20919f2fc 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -232,6 +232,15 @@ export function BaseChart({ const ui = useChartUITokens(); + // Only the *presence* of a click handler shapes the option (it flips + // `triggerLineEvent`/`symbolSize` on line/area). Depend on this boolean, not + // the handler reference — consumers pass an inline `onDataClick`, whose + // identity changes every render, so depending on the reference would rebuild + // the whole option object on every parent re-render (e.g. each SSE tick). The + // `onEvents` memo below still depends on `onDataClick` itself — it needs the + // real function. + const interactive = !!onDataClick; + // Store ECharts instance directly to avoid stale ref issues on unmount const echartsInstanceRef = useRef(null); @@ -348,6 +357,9 @@ export function BaseChart({ smooth, showSymbol, symbolSize, + // A click handler turns on `triggerLineEvent` for line/area so the + // whole stroke is clickable, not just symbols. + interactive, }); } @@ -375,6 +387,7 @@ export function BaseChart({ max, customOptions, selected, + interactive, ]); // Build the ECharts event map only when a click handler is provided. Memoized diff --git a/packages/appkit-ui/src/react/charts/options.ts b/packages/appkit-ui/src/react/charts/options.ts index 564c83671..8b4f99475 100644 --- a/packages/appkit-ui/src/react/charts/options.ts +++ b/packages/appkit-ui/src/react/charts/options.ts @@ -29,6 +29,12 @@ export interface CartesianContext extends OptionBuilderContext { smooth: boolean; showSymbol: boolean; symbolSize: number; + /** + * Whether a click handler is attached. When true, line/area series set + * `triggerLineEvent` so a click anywhere on the stroke fires (not just on a + * symbol) — otherwise clicking a thin line is nearly impossible to land. + */ + interactive?: boolean; } // ============================================================================ @@ -331,11 +337,19 @@ export function buildCartesianOption( ctx: CartesianContext, ): Record { const ui = ctx.ui ?? FALLBACK_UI_TOKENS; - const { chartType, isTimeSeries, stacked, smooth, showSymbol, symbolSize } = - ctx; + const { + chartType, + isTimeSeries, + stacked, + smooth, + showSymbol, + symbolSize, + interactive, + } = ctx; const hasMultipleSeries = ctx.yFields.length > 1; const seriesType = chartType === "area" ? "line" : chartType; const isScatter = chartType === "scatter"; + const isLineLike = chartType === "line" || chartType === "area"; return { ...buildBaseOption(ctx), @@ -378,11 +392,15 @@ export function buildCartesianOption( : isTimeSeries ? createTimeSeriesData(ctx.xData, ctx.yDataMap[key]) : ctx.yDataMap[key], - smooth: chartType === "line" || chartType === "area" ? smooth : undefined, - showSymbol: - chartType === "line" || chartType === "area" ? showSymbol : undefined, + smooth: isLineLike ? smooth : undefined, + showSymbol: isLineLike ? showSymbol : undefined, symbol: isScatter ? "circle" : undefined, - symbolSize: isScatter ? symbolSize : undefined, + // Symbol size now applies to line/area too (previously scatter-only), so + // an interactive line can present a clickable point, not just a hairline. + symbolSize: isScatter || isLineLike ? symbolSize : undefined, + // Fire click events along the whole line stroke, not only on symbols, + // when the chart is interactive. No effect on non-line series. + triggerLineEvent: isLineLike && interactive ? true : undefined, areaStyle: chartType === "area" ? { opacity: 0.3 } : undefined, stack: stacked && chartType === "area" ? "total" : undefined, itemStyle: diff --git a/packages/appkit-ui/src/react/ui/index.ts b/packages/appkit-ui/src/react/ui/index.ts index b73d1b1ac..736810dfc 100644 --- a/packages/appkit-ui/src/react/ui/index.ts +++ b/packages/appkit-ui/src/react/ui/index.ts @@ -41,6 +41,7 @@ export * from "./sheet"; export * from "./sidebar"; export * from "./skeleton"; export * from "./slider"; +export * from "./notify"; export * from "./sonner"; export * from "./spinner"; export * from "./switch"; diff --git a/packages/appkit-ui/src/react/ui/notify.ts b/packages/appkit-ui/src/react/ui/notify.ts new file mode 100644 index 000000000..27c28cce3 --- /dev/null +++ b/packages/appkit-ui/src/react/ui/notify.ts @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { toast } from "sonner"; + +/** Options for a {@link notify} toast — a curated subset of sonner's surface. */ +export interface NotifyOptions { + /** Secondary line under the title. */ + description?: ReactNode; + /** Auto-dismiss delay in ms. Omit for sonner's default; `Infinity` to make it sticky. */ + duration?: number; +} + +/** + * Fire a transient toast through the app's mounted `` — the same + * sonner surface `ResourceStatusIndicator` renders warehouse-readiness into. + * + * This is a curated wrapper so app code never imports sonner directly: it + * exposes only a title + `{ description, duration }`, not sonner's full option + * bag. Requires a `` (or ``) mounted in + * the tree; without one the call is a no-op. + * + * @example + * ```tsx + * notify.message("Write back: Arr · Apr 2026 · $8,100,000"); + * notify.success("Saved", { description: "Row written back to the source." }); + * ``` + */ +export const notify = { + /** Neutral message toast. */ + message: (title: ReactNode, options?: NotifyOptions) => + toast(title, options), + /** Informational toast. */ + info: (title: ReactNode, options?: NotifyOptions) => toast.info(title, options), + /** Success toast. */ + success: (title: ReactNode, options?: NotifyOptions) => + toast.success(title, options), + /** Warning toast. */ + warning: (title: ReactNode, options?: NotifyOptions) => + toast.warning(title, options), + /** Error toast. */ + error: (title: ReactNode, options?: NotifyOptions) => + toast.error(title, options), +}; From b9be4f10832927240d88f2f2af5522ff051c91cb Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 29 Jul 2026 15:04:07 +0200 Subject: [PATCH 09/28] fix: sound metric-view row/time typing, currency + cache correctness, review cleanup Address adversarial-review findings on the useMetricView / metric-route branch: - Type soundness: infer rows from the selected measure/dimension tuples (PickMetricRow) and correlate timeDimension/timeGrain to temporal dims only. - Formatting: preserve every currency symbol the generator emits end-to-end and keep bigint precision (no Number() rounding). - Cache correctness: stamp fresh per-column metadata AFTER the cached execute() so a cache hit never serves stale labels/formats after a redeploy. - Charts: guard selected="" as a no-op, split [x,y] click tuples into x/y, and memoize onEvents on handler presence (no listener thrash per SSE tick). - Typegen: sweep a stale sibling metric-views.d.ts on upgrade and reject a .d.ts mvOutFile. - Drop the unused public notify export and the fake "Write back" demo; remove the dead autoStart option; align AnalyticsStreamMessage; tighten the biome ignore; add tests + comment cleanup. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../client/src/routes/metric-views.route.tsx | 48 +---- biome.json | 3 +- docs/docs/plugins/analytics.md | 2 +- .../appkit-ui/src/js/format/format.test.ts | 51 +++++ packages/appkit-ui/src/js/format/format.ts | 74 +++++-- .../react/charts/__tests__/options.test.ts | 192 ++++++++++++++++++ .../src/react/charts/__tests__/utils.test.ts | 65 ++++++ packages/appkit-ui/src/react/charts/base.tsx | 50 +++-- packages/appkit-ui/src/react/charts/index.ts | 1 + .../appkit-ui/src/react/charts/options.ts | 23 ++- packages/appkit-ui/src/react/charts/types.ts | 17 +- packages/appkit-ui/src/react/charts/utils.ts | 67 +++--- .../hooks/__tests__/use-metric-view.test.ts | 60 +++++- packages/appkit-ui/src/react/hooks/index.ts | 3 + packages/appkit-ui/src/react/hooks/types.ts | 111 +++++++++- .../src/react/hooks/use-metric-view.ts | 87 +++++--- packages/appkit-ui/src/react/ui/index.ts | 1 - packages/appkit-ui/src/react/ui/notify.ts | 42 ---- .../appkit/src/plugins/analytics/analytics.ts | 30 ++- .../plugins/analytics/tests/metric.test.ts | 117 ++++++++--- .../appkit/src/plugins/analytics/types.ts | 28 ++- packages/appkit/src/type-generator/index.ts | 16 ++ .../src/type-generator/tests/index.test.ts | 5 +- .../type-generator/tests/mv-registry.test.ts | 57 +++--- .../tests/sync-metric-views-types.test.ts | 31 +++ .../type-generator/tests/vite-plugin.test.ts | 13 ++ .../appkit/src/type-generator/vite-plugin.ts | 11 + 27 files changed, 927 insertions(+), 278 deletions(-) delete mode 100644 packages/appkit-ui/src/react/ui/notify.ts diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index f824a3e3b..5b3a3d62d 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -16,7 +16,6 @@ import { CardTitle, DonutChart, LineChart, - notify, Select, SelectContent, SelectItem, @@ -54,10 +53,10 @@ const TREND_MEASURES = ["arr", "mrr"] as const; const TABLE_MEASURES = ["arr", "mrr", "new_arr", "churned_arr"] as const; const TABLE_COLUMNS = ["region", ...TABLE_MEASURES] as const; -// The dimensions the page lets you slice by. Both the filter bar (dropdowns) -// and the detail table (row click) write selections keyed by these names, and -// every visual composes them into a `MetricFilter` the same way — so a future -// chart-click cross-filter drops into the same `selection` state unchanged. +// The dimensions the page lets you slice by. The filter-bar dropdowns, the +// detail-table row click, AND the chart clicks (region bar / segment donut) +// all write selections keyed by these names, and every visual composes them +// into a `MetricFilter` the same way — one shared `selection` state drives them. const FILTER_DIMENSIONS = ["region", "segment"] as const; type FilterDimension = (typeof FILTER_DIMENSIONS)[number]; type Selection = Partial>; @@ -450,45 +449,6 @@ function MetricViewsRoute() { yKey={[...TREND_MEASURES]} height={320} showLegend - // Render point symbols + trigger clicks along the whole line - // (SDK's triggerLineEvent, on because onDataClick is set) so - // the hairline isn't the only hit target. - showSymbol - // Clicking a point fires a transient "write-back" toast (the - // "Apps = action layer" gesture) through the same Toaster the - // warehouse-status indicator uses. - onDataClick={(d) => { - // Resolve the measure by series INDEX — yKey order === - // TREND_MEASURES order, and sorting only reorders points - // WITHIN a series, not the series array. (Parsing the "Arr" - // label wouldn't round-trip to keys like "new_arr", and - // indexing trend.data by dataIndex is wrong because the - // time series is sorted before rendering.) - const measureKey = TREND_MEASURES[d.seriesIndex]; - if (!measureKey) return; - const meta = trend.metadata?.[measureKey]; - const label = meta?.display_name ?? measureKey; - // A time-series point is a [epochMs, value] tuple on - // ECharts' params.value (d.value is null for tuples); read - // it off the raw params so both the date and amount survive. - const tuple = (d.raw as { value?: unknown } | undefined) - ?.value; - const [ts, amount] = Array.isArray(tuple) - ? tuple - : [undefined, undefined]; - const month = - typeof ts === "number" - ? new Date(ts).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - }) - : String(d.name || ""); - const formatted = formatValue(amount, meta?.format); - notify.message( - `Write back: ${label} · ${month} · ${formatted}`, - { description: "Point selected for write-back (demo)." }, - ); - }} /> )} diff --git a/biome.json b/biome.json index f0082d50e..5b4ad9118 100644 --- a/biome.json +++ b/biome.json @@ -22,8 +22,7 @@ "!**/*.gen.ts", "!**/typedoc-sidebar.ts", "!**/template", - "!**/metric-views.ts", - "!**/metric-views.d.ts" + "!**/appkit-types/metric-views.ts" ] }, "formatter": { diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 2bf5988e3..26e2b6cd0 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -535,7 +535,7 @@ When `"revenue"` is a key in the generated `MetricRegistry` (see [Metric-view ty } ``` -Like `useAnalyticsQuery`, the option object is serialized internally, so object/array literals passed fresh each render stay referentially stable — you do **not** need to `useMemo` the options. (Hoisting `measures`/`dimensions` to module scope or memoizing is still fine, and keeps the arrays type-narrowed to their literal tuple.) +Like `useAnalyticsQuery`, the option object is serialized (`JSON.stringify`) internally, so object/array literals passed fresh each render do **not** trigger a refetch as long as they serialize to the same string — you do **not** need to `useMemo` the options. (This is same-serialization, not deep structural equality: reordering keys within `filter` changes the string and does re-query. Hoisting `measures`/`dimensions` to module scope or memoizing is still fine, and keeps the arrays type-narrowed to their literal tuple.) `metadata` is the per-column display metadata for **only the columns you queried**, scoped and carried in the SSE `result` payload. It is `undefined` when the server injected no metadata (the metric key is unknown, or `analytics({ metricViewsMetadata })` was not wired) — so always treat it as optional. diff --git a/packages/appkit-ui/src/js/format/format.test.ts b/packages/appkit-ui/src/js/format/format.test.ts index 18ba4ddfe..4a2cbb0f2 100644 --- a/packages/appkit-ui/src/js/format/format.test.ts +++ b/packages/appkit-ui/src/js/format/format.test.ts @@ -34,6 +34,20 @@ describe("js/format formatValue", () => { expect(formatValue(1234567n, "#,##0")).toBe("1,234,567"); }); + test("preserves bigint precision beyond 2^53 (no Number() rounding)", () => { + // 9_007_199_254_740_993n = 2^53 + 1, which is NOT representable as a JS + // number — Number(bigint) would round it to 9_007_199_254_740_992. + expect(formatValue(9_007_199_254_740_993n, "#,##0")).toBe( + "9,007,199,254,740,993", + ); + expect(formatValue(9_007_199_254_740_993n, "$#,##0")).toBe( + "$9,007,199,254,740,993", + ); + expect(formatValue(-9_007_199_254_740_993n, "$#,##0")).toBe( + "-$9,007,199,254,740,993", + ); + }); + test("no format falls back to toLocaleString for numbers", () => { expect(formatValue(1234.5)).toBe((1234.5).toLocaleString()); }); @@ -51,6 +65,28 @@ describe("js/format formatValue", () => { test("non-numeric value with numeric spec falls back to String()", () => { expect(formatValue("N/A", "#,##0")).toBe("N/A"); }); + + // End-to-end over the currency symbols the metric-view generator emits + // (mv-registry/describe.ts CURRENCY_SYMBOLS + the unknown-code fallback). + // Each spec here is exactly what the generator produces for that symbol. + describe("preserves every currency symbol the generator emits", () => { + test.each([ + ["$#,##0.00", 1234.5, "$1,234.50"], // USD + ["€#,##0.00", 1234.5, "€1,234.50"], // EUR + ["£#,##0.00", 1234.5, "£1,234.50"], // GBP + ["¥#,##0", 1234, "¥1,234"], // JPY / CNY + ["₹#,##0.00", 1234.5, "₹1,234.50"], // INR + ["R$#,##0.00", 1234.5, "R$1,234.50"], // BRL (multi-char symbol) + ["XYZ #,##0.00", 1234.5, "XYZ 1,234.50"], // unknown ISO code + space + ])("formatValue(%s) preserves the symbol", (spec, value, expected) => { + expect(formatValue(value, spec)).toBe(expected); + }); + + test("negative currency keeps the sign before the symbol for every prefix", () => { + expect(formatValue(-1234.5, "€#,##0.00")).toBe("-€1,234.50"); + expect(formatValue(-1234.5, "R$#,##0.00")).toBe("-R$1,234.50"); + }); + }); }); describe("js/format formatLabel", () => { @@ -93,4 +129,19 @@ describe("js/format toD3Format", () => { expect(toD3Format("yyyy-MM-dd")).toBeUndefined(); expect(toD3Format("abc")).toBeUndefined(); }); + + // Every currency spec the generator emits maps to d3's `$` currency type + // (the actual glyph is supplied by the consuming d3 locale, not the + // specifier) — none of them are dropped as unrecognized. + test.each([ + ["$#,##0.00", "$,.2f"], + ["€#,##0.00", "$,.2f"], + ["£#,##0.00", "$,.2f"], + ["¥#,##0", "$,.0f"], + ["₹#,##0.00", "$,.2f"], + ["R$#,##0.00", "$,.2f"], + ["XYZ #,##0.00", "$,.2f"], + ])("maps currency spec %s to a $-typed d3 specifier", (spec, expected) => { + expect(toD3Format(spec)).toBe(expected); + }); }); diff --git a/packages/appkit-ui/src/js/format/format.ts b/packages/appkit-ui/src/js/format/format.ts index 3fa98f56c..434f080cc 100644 --- a/packages/appkit-ui/src/js/format/format.ts +++ b/packages/appkit-ui/src/js/format/format.ts @@ -49,11 +49,24 @@ function formatNumber( }); } +/** + * The currency symbol a spec carries — everything before the first digit + * placeholder (`#`/`0`). The metric-view generator emits `$`, `€`, `£`, `¥`, + * `₹`, `R$`, or an unknown ISO code + space (e.g. `"XYZ "`); this recovers any + * of them verbatim. Returns `""` for a bare numeric spec (`"#,##0"`) or a + * percent spec (`"0.0%"`), neither of which has a leading symbol. + */ +function currencyPrefix(format: string): string { + const match = format.match(/^[^#0]+/); + return match ? match[0] : ""; +} + /** * Format a raw value using a UC/YAML printf-style format spec. * * Recognizes the common spreadsheet-style specs: - * - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50") + * - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50"); the prefix is + * emitted verbatim, so `"€#,##0"`, `"R$#,##0.00"`, etc. survive end-to-end * - thousands grouping + N decimals, e.g. `"#,##0"` (1234567 -> "1,234,567") * or `"#,##0.00"` (1234.5 -> "1,234.50") * - percent, e.g. `"0.0%"` (0.1234 -> "12.3%") — the value is multiplied by 100 @@ -73,22 +86,42 @@ export function formatValue(value: unknown, format?: string): string { return String(value); } - const num = coerceNumber(value); - // Non-numeric value with a numeric-ish spec: nothing sensible to format. - if (num === null) return String(value); - const isPercent = format.includes("%"); - const isCurrency = format.includes("$"); const grouping = format.includes(","); const decimals = countDecimals(format); + // Any leading symbol (before the first digit placeholder) is a currency + // prefix — emit it verbatim so non-USD symbols the generator produces are + // preserved instead of collapsing to "$". + const prefix = currencyPrefix(format); + + // bigint fast path. A bigint is an exact integer, so `BigInt.toLocaleString` + // formats it losslessly — `Number(bigint)` would corrupt values beyond ±2^53 + // (int64 counts / cents). The percent path multiplies by 100 (float math a + // large bigint can't survive), so refuse it rather than emit a wrong number. + if (typeof value === "bigint") { + if (isPercent) return String(value); + const sign = value < 0n ? "-" : ""; + // `Intl.NumberFormat` accepts a bigint directly and formats it exactly (no + // float coercion), unlike `Number(value)`. + const body = new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }).format(value < 0n ? -value : value); + return `${sign}${prefix}${body}`; + } + + const num = coerceNumber(value); + // Non-numeric value with a numeric-ish spec: nothing sensible to format. + if (num === null) return String(value); if (isPercent) { return `${formatNumber(num * 100, decimals, grouping)}%`; } - if (isCurrency) { + if (prefix) { const sign = num < 0 ? "-" : ""; - return `${sign}$${formatNumber(Math.abs(num), decimals, grouping)}`; + return `${sign}${prefix}${formatNumber(Math.abs(num), decimals, grouping)}`; } return formatNumber(num, decimals, grouping); @@ -136,19 +169,25 @@ export function formatLabel( * * Best-effort mapping for the common specs: * - `"$#,##0.00"` -> `"$,.2f"` - * - `"#,##0"` -> `",.0f"` - * - `"#,##0.00"` -> `",.2f"` - * - `"0.0%"` -> `".1%"` + * - `"€#,##0.00"` -> `"$,.2f"` (currency), `"#,##0"` -> `",.0f"`, `"0.0%"` -> `".1%"` + * + * A d3 specifier's currency marker is the single `$` symbol; the actual glyph + * ($, €, R$, …) is supplied by the d3 *locale*, not the specifier string — so a + * non-USD currency spec still maps to the `$` currency type here (it is not + * rejected as unrecognized), and the caller's d3 locale renders the right glyph. * * No spec, or a spec that is not a recognizable numeric pattern -> `undefined`. */ export function toD3Format(format?: string): string | undefined { if (!format) return undefined; - // Only map specs built purely from numeric-format characters; anything else - // (date patterns, free text, ...) is left unrecognized. - if (format.replace(/[#0,.$%\s]/g, "") !== "") return undefined; - if (!/[0#]/.test(format)) return undefined; + // Strip any leading currency prefix first, then require the remainder to be + // built purely from numeric-format characters; anything else (date patterns, + // free text, ...) is left unrecognized. + const prefix = currencyPrefix(format); + const numeric = format.slice(prefix.length); + if (numeric.replace(/[#0,.%\s]/g, "") !== "") return undefined; + if (!/[0#]/.test(numeric)) return undefined; const group = format.includes(",") ? "," : ""; const decimals = countDecimals(format); @@ -157,6 +196,7 @@ export function toD3Format(format?: string): string | undefined { return `${group}.${decimals}%`; } - const prefix = format.includes("$") ? "$" : ""; - return `${prefix}${group}.${decimals}f`; + // `$` is d3's currency marker (glyph comes from the locale); emit it for any + // currency prefix, USD or otherwise. + return `${prefix ? "$" : ""}${group}.${decimals}f`; } diff --git a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts index 3e2883c91..e11ffba8f 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { FALLBACK_UI_TOKENS } from "../constants"; import { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, @@ -988,3 +989,194 @@ describe("tooltip theming", () => { expect(opt.tooltip?.formatter?.({ data: [0, 0, 10] })).toBe("A, Mon: 10"); }); }); + +// ============================================================================ +// applySelectionEmphasis — the cross-filter highlight transform +// ============================================================================ + +describe("applySelectionEmphasis", () => { + // Minimal helpers to read opacity off a transformed datum, tolerating both the + // object form ({ value, itemStyle }) and the wrapped-primitive form. + const opacityOf = (datum: unknown): number | undefined => + (datum as { itemStyle?: { opacity?: number } })?.itemStyle?.opacity; + + const barOption = (categories: (string | number)[], values: number[]) => ({ + xAxis: { type: "category", data: categories }, + yAxis: { type: "value" }, + series: [{ type: "bar", data: values }], + }); + + describe("no-op cases (identity)", () => { + test("undefined selection returns the input unchanged (same reference)", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, undefined)).toBe(opt); + }); + + test("empty-string selection is a no-op — does NOT dim everything (guards #4)", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + // The bug being guarded: "" would match no category and dim all bars. + expect(applySelectionEmphasis(opt, "")).toBe(opt); + }); + + test("empty-array selection is a no-op", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, [])).toBe(opt); + }); + + test("an array of only empty strings is a no-op", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + expect(applySelectionEmphasis(opt, ["", ""])).toBe(opt); + }); + + test("option without a series array is returned unchanged", () => { + const opt = { xAxis: { type: "category", data: ["A"] } }; + expect(applySelectionEmphasis(opt, "A")).toBe(opt); + }); + }); + + describe("bar series (category axis)", () => { + test("dims non-selected categories and keeps the selected one at full opacity", () => { + const opt = barOption(["EMEA", "APAC", "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, "APAC")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); // EMEA dimmed + expect(opacityOf(data[1])).toBe(1); // APAC selected + expect(opacityOf(data[2])).toBe(0.3); // AMER dimmed + }); + + test("a mixed array selection ignores the dead empty-string member", () => { + const opt = barOption(["EMEA", "APAC", "AMER"], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, ["EMEA", "", "AMER"])); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(1); // EMEA selected + expect(opacityOf(data[1])).toBe(0.3); // APAC dimmed + expect(opacityOf(data[2])).toBe(1); // AMER selected + }); + + test("preserves the series-level itemStyle (bar borderRadius) via a real builder", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC"], + yDataMap: { value: [10, 20] }, + }); + const built = buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + const out = asOption(applySelectionEmphasis(built, "EMEA")); + + // The per-datum itemStyle carries opacity but the bar's borderRadius is + // set at the series level and must survive (per-datum merges OVER series). + expect(opacityOf(out.series[0].data[0])).toBe(1); + expect(opacityOf(out.series[0].data[1])).toBe(0.3); + expect(out.series[0].itemStyle?.borderRadius).toEqual([4, 4, 0, 0]); + }); + + test("matches numeric category names by their string form", () => { + const opt = barOption([2024, 2025, 2026], [10, 20, 30]); + const out = asOption(applySelectionEmphasis(opt, "2025")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); + expect(opacityOf(data[1])).toBe(1); + expect(opacityOf(data[2])).toBe(0.3); + }); + + test("respects custom opacity overrides", () => { + const opt = barOption(["EMEA", "APAC"], [10, 20]); + const out = asOption( + applySelectionEmphasis(opt, "EMEA", { + dimmedOpacity: 0.1, + selectedOpacity: 0.9, + }), + ); + expect(opacityOf(out.series[0].data[0])).toBe(0.9); + expect(opacityOf(out.series[0].data[1])).toBe(0.1); + }); + + test("horizontal bars read categories from the yAxis", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC", "AMER"], + yDataMap: { value: [10, 20, 30] }, + }); + const built = buildHorizontalBarOption(ctx, false); + const out = asOption(applySelectionEmphasis(built, "APAC")); + + const data = out.series[0].data; + expect(opacityOf(data[0])).toBe(0.3); + expect(opacityOf(data[1])).toBe(1); + expect(opacityOf(data[2])).toBe(0.3); + }); + }); + + describe("pie series (name-keyed data)", () => { + test("dims non-selected slices, reading the name off each datum", () => { + const ctx = createBaseContext({ + xData: ["EMEA", "APAC", "AMER"], + yDataMap: { value: [10, 20, 30] }, + yFields: ["value"], + }); + const built = buildPieOption(ctx, "pie", 0, true, "outside"); + const out = asOption(applySelectionEmphasis(built, "AMER")); + + const data = out.series[0].data as Array<{ + name: string; + itemStyle?: { opacity?: number }; + }>; + // Object data items are spread — name/value survive alongside opacity. + expect(data[0]).toMatchObject({ name: "EMEA" }); + expect(data[0].itemStyle?.opacity).toBe(0.3); + expect(data[2].itemStyle?.opacity).toBe(1); + }); + }); + + describe("non-categorical series are passed through untouched", () => { + test("line series (no category name per datum) is unchanged", () => { + const opt = { + xAxis: { type: "category", data: ["A", "B"] }, + yAxis: { type: "value" }, + series: [{ type: "line", data: [10, 20] }], + }; + const out = asOption(applySelectionEmphasis(opt, "A")); + // Line data is left as raw values (no itemStyle wrapping). + expect(out.series[0].data).toEqual([10, 20]); + }); + + test("scatter series is unchanged", () => { + const opt = { + xAxis: { type: "value" }, + yAxis: { type: "value" }, + series: [ + { + type: "scatter", + data: [ + [1, 2], + [3, 4], + ], + }, + ], + }; + const out = asOption(applySelectionEmphasis(opt, "anything")); + expect(out.series[0].data).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + test("bar with no category axis (e.g. value/value) is left unchanged", () => { + const opt = { + xAxis: { type: "value" }, + yAxis: { type: "value" }, + series: [{ type: "bar", data: [10, 20] }], + }; + const out = asOption(applySelectionEmphasis(opt, "A")); + expect(out.series[0].data).toEqual([10, 20]); + }); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts index 728494111..c3001b02b 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts @@ -3,6 +3,7 @@ import { createTimeSeriesData, escapeHtml, formatLabel, + mapToDatum, sortTimeSeriesAscending, toChartArray, toChartValue, @@ -334,3 +335,67 @@ describe("createTimeSeriesData", () => { ]); }); }); + +describe("mapToDatum", () => { + test("normalizes a scalar (bar/pie) click: name + value, no x/y", () => { + const d = mapToDatum({ + name: "EMEA", + value: 42, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d).toMatchObject({ + name: "EMEA", + value: 42, + seriesName: "ARR", + dataIndex: 1, + seriesIndex: 0, + }); + expect(d.x).toBeUndefined(); + expect(d.y).toBeUndefined(); + }); + + test("splits an [x, y] tuple point into x/y and surfaces y as value", () => { + // A time-series point: value is [epochMs, amount]. Previously value became + // null and callers had to re-parse `raw`. + const d = mapToDatum({ + value: [1704067200000, 8_100_000], + seriesName: "ARR", + seriesIndex: 0, + dataIndex: 3, + }); + expect(d.x).toBe(1704067200000); + expect(d.y).toBe(8_100_000); + expect(d.value).toBe(8_100_000); + // No explicit name → the x component's string form fills in. + expect(d.name).toBe("1704067200000"); + }); + + test("keeps an explicit name even for a tuple datum", () => { + const d = mapToDatum({ name: "Apr 2026", value: [1704067200000, 5] }); + expect(d.name).toBe("Apr 2026"); + expect(d.x).toBe(1704067200000); + expect(d.y).toBe(5); + }); + + test("missing name and non-tuple value falls back to empty string / null", () => { + const d = mapToDatum({ seriesIndex: 0 }); + expect(d.name).toBe(""); + expect(d.value).toBeNull(); + expect(d.dataIndex).toBe(-1); + expect(d.seriesIndex).toBe(0); + }); + + test("preserves the raw params untouched", () => { + const params = { name: "X", value: 1, extra: { deep: true } }; + expect(mapToDatum(params).raw).toBe(params); + }); + + test("tolerates a non-object payload", () => { + const d = mapToDatum(null); + expect(d.name).toBe(""); + expect(d.value).toBeNull(); + expect(d.raw).toBeNull(); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 20919f2fc..41324d9b2 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -233,14 +233,18 @@ export function BaseChart({ const ui = useChartUITokens(); // Only the *presence* of a click handler shapes the option (it flips - // `triggerLineEvent`/`symbolSize` on line/area). Depend on this boolean, not - // the handler reference — consumers pass an inline `onDataClick`, whose - // identity changes every render, so depending on the reference would rebuild - // the whole option object on every parent re-render (e.g. each SSE tick). The - // `onEvents` memo below still depends on `onDataClick` itself — it needs the - // real function. + // `triggerLineEvent`/`symbolSize` on line/area) AND the `onEvents` map below. + // Depend on this boolean, not the handler reference — consumers pass an inline + // `onDataClick`, whose identity changes every render, so depending on the + // reference would rebuild the whole option object AND re-subscribe the ECharts + // click listener on every parent re-render (e.g. each SSE tick). const interactive = !!onDataClick; + // Keep the latest handler in a ref so `onEvents` can call the current + // `onDataClick` without listing it as a dependency (see `onEvents` below). + const onDataClickRef = useRef(onDataClick); + onDataClickRef.current = onDataClick; + // Store ECharts instance directly to avoid stale ref issues on unmount const echartsInstanceRef = useRef(null); @@ -391,17 +395,35 @@ export function BaseChart({ ]); // Build the ECharts event map only when a click handler is provided. Memoized - // on `onDataClick` so the object identity is stable across renders — - // echarts-for-react re-subscribes whenever `onEvents` identity changes, so an - // unstable object would thrash listeners. When no handler is set, this is - // `undefined` and no idle click listener is attached. `onEvents` is an internal - // implementation detail and is intentionally not a public prop. + // on the `interactive` boolean (handler PRESENCE), NOT on `onDataClick`'s + // identity: consumers pass an inline arrow whose identity changes every render, + // and echarts-for-react re-subscribes whenever `onEvents` identity changes — so + // keying on the reference would tear down and re-attach the click listener on + // every parent re-render (e.g. each SSE tick). The handler is invoked through + // `onDataClickRef` so it always calls the latest closure. When no handler is + // set this is `undefined` and no idle click listener is attached. `onEvents` + // is an internal implementation detail, intentionally not a public prop. const onEvents = useMemo( () => - onDataClick - ? { click: (params: unknown) => onDataClick(mapToDatum(params)) } + interactive + ? { + click: (params: unknown) => { + // Fire-and-forget: the datum callback may be async, and a rejected + // promise must not surface as an unhandled rejection (the docs + // promise async handlers are fine). Swallow rejections here. + const result = onDataClickRef.current?.( + mapToDatum(params), + ) as void | Promise; + if ( + result && + typeof (result as Promise).then === "function" + ) { + (result as Promise).catch(() => {}); + } + }, + } : undefined, - [onDataClick], + [interactive], ); if (!option) { diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index 33387dfca..55d067989 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -85,6 +85,7 @@ export { // ============================================================================ export { + applySelectionEmphasis, buildCartesianOption, buildHeatmapOption, buildHorizontalBarOption, diff --git a/packages/appkit-ui/src/react/charts/options.ts b/packages/appkit-ui/src/react/charts/options.ts index 8b4f99475..7a7a5016b 100644 --- a/packages/appkit-ui/src/react/charts/options.ts +++ b/packages/appkit-ui/src/react/charts/options.ts @@ -306,6 +306,9 @@ export function buildHeatmapOption( top: "center", textStyle: { color: ui.axisTitle }, inRange: { + // A visualMap gradient needs at least two stops; with a single-color + // palette, ramp from a light grey to that color instead of passing a + // one-entry array (which ECharts renders as a flat, unreadable scale). color: ctx.colors.length >= 2 ? ctx.colors : ["#f0f0f0", ctx.colors[0]], }, }, @@ -395,8 +398,8 @@ export function buildCartesianOption( smooth: isLineLike ? smooth : undefined, showSymbol: isLineLike ? showSymbol : undefined, symbol: isScatter ? "circle" : undefined, - // Symbol size now applies to line/area too (previously scatter-only), so - // an interactive line can present a clickable point, not just a hairline. + // Symbol size applies to line/area as well as scatter, so an interactive + // line can present a clickable point, not just a hairline. symbolSize: isScatter || isLineLike ? symbolSize : undefined, // Fire click events along the whole line stroke, not only on symbols, // when the chart is interactive. No effect on non-line series. @@ -436,13 +439,21 @@ interface SelectionEmphasisOptions { * Normalizes the `selected` input into a lookup set of category names. * Returns `null` when there is nothing selected (undefined, or an empty * string/array), which callers treat as "no emphasis". + * + * Falsy entries (`""`, and after stringify anything empty) are dropped BEFORE + * the size check: an empty-string selection would otherwise survive as + * `Set{""}`, match no category, and dim every element — the opposite of the + * "empty = no-op" contract. A mixed array like `["EMEA","","APAC"]` likewise + * sheds its dead `""` member. */ function toSelectionSet( selected: string | string[] | undefined, ): Set | null { if (selected == null) return null; const names = Array.isArray(selected) ? selected : [selected]; - const set = new Set(names.map((name) => String(name))); + const set = new Set( + names.map((name) => String(name)).filter((name) => name !== ""), + ); return set.size > 0 ? set : null; } @@ -450,6 +461,11 @@ function toSelectionSet( * Finds the category-axis label array (`xAxis`/`yAxis` with `type: "category"`), * used to map a bar datum's position to its category name. Returns `null` when * no category axis is present (e.g. time-series or value axes). + * + * Assumes exactly one category axis (the first of x/y wins) — true for the + * builders here: vertical bars carry a category `xAxis` + value `yAxis`, + * horizontal bars the reverse. A chart with two category axes is not a shape + * these builders produce. */ function categoryNamesFromAxes( option: Record, @@ -562,7 +578,6 @@ export function applySelectionEmphasis( opts: SelectionEmphasisOptions = {}, ): T { const selectedSet = toSelectionSet(selected); - // No selection → identity: no emphasis, no dimming. if (!selectedSet) return option; if (option === null || typeof option !== "object" || Array.isArray(option)) { diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 685487076..07b8a6f13 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -129,8 +129,23 @@ export interface ChartBaseProps { export interface ChartClickDatum { /** Category label of the clicked element — the dimension value in the common cross-filter case. */ name: string; - /** The datum's value. */ + /** + * The datum's scalar value. For `[x, y]` tuple points (time-series / scatter) + * this is the y-component (see {@link ChartClickDatum.x} / {@link ChartClickDatum.y}); + * `null` when there is no scalar value to surface. + */ value: number | string | null; + /** + * The x-component of an `[x, y]` tuple datum (e.g. the timestamp of a + * time-series point, or the x of a scatter point). `undefined` for scalar + * (bar/pie) data that carries no separate x. + */ + x?: number | string; + /** + * The y-component of an `[x, y]` tuple datum. Mirrors {@link ChartClickDatum.value} + * for tuple points; `undefined` for scalar data. + */ + y?: number | string; /** Series label, when present. */ seriesName?: string; /** Index of the datum within its series. */ diff --git a/packages/appkit-ui/src/react/charts/utils.ts b/packages/appkit-ui/src/react/charts/utils.ts index c4c587210..cdf0d4160 100644 --- a/packages/appkit-ui/src/react/charts/utils.ts +++ b/packages/appkit-ui/src/react/charts/utils.ts @@ -33,29 +33,12 @@ export function toChartArray(data: unknown[]): (string | number)[] { return data.map(toChartValue); } -/** - * Formats a field name into a human-readable label. - * Handles camelCase, snake_case, acronyms, and ALL_CAPS. - * E.g., "totalSpend" -> "Total Spend", "user_name" -> "User Name", - * "userID" -> "User Id", "TOTAL_SPEND" -> "Total Spend" - */ -export function formatLabel(field: string): string { - return ( - field - // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl → HTTP Url) - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - // Handle lowercase followed by uppercase (e.g., totalSpend → total Spend) - .replace(/([a-z])([A-Z])/g, "$1 $2") - // Replace underscores with spaces - .replace(/_/g, " ") - // Collapse multiple spaces into one - .replace(/\s+/g, " ") - // Normalize to title case - .toLowerCase() - .replace(/\b\w/g, (l) => l.toUpperCase()) - .trim() - ); -} +// `formatLabel` lives canonically on the `/js` axis (it also accepts a +// `MetricColumnMeta` to prefer a `display_name`). Re-export the superset so the +// `/react` surface has a single humanize implementation — a `/react` consumer +// and a `/js` consumer get identical behavior. Chart internals call it with +// just a field name, which the superset handles. +export { formatLabel } from "@/js"; /** * Escapes HTML special characters to prevent XSS. @@ -138,9 +121,15 @@ export function sortNumericAscending( * `ECElementEvent`. * * Field handling: - * - `name` → coerced to a string, falling back to `""` when missing. - * - `value` → passed through when it is a `number` or `string`; arrays, - * objects, and missing values become `null`. + * - `name` → coerced to a string, falling back to `""` when missing. For a + * tuple datum whose name is absent, the x-component's string form is used so + * callers still get a meaningful label. + * - `value` → for a scalar datum, passed through when a `number`/`string` (else + * `null`); for an `[x, y]` tuple datum (time-series / scatter), the + * y-component. + * - `x` / `y` → the components of an `[x, y]` tuple datum; `undefined` for + * scalar data. Lets callers read the timestamp + amount of a clicked + * time-series point without reaching into `raw`. * - `seriesName` → kept when it is a string, otherwise left `undefined`. * - `dataIndex` / `seriesIndex` → kept when numeric, otherwise `-1`. * - `raw` → the entire original `params` object, untouched. @@ -153,13 +142,27 @@ export function mapToDatum(params: unknown): ChartClickDatum { params !== null && typeof params === "object" ? params : {} ) as Record; - const name = typeof p.name === "string" ? p.name : ""; + const isScalar = (v: unknown): v is number | string => + typeof v === "number" || typeof v === "string"; const rawValue = p.value; - const value = - typeof rawValue === "number" || typeof rawValue === "string" - ? rawValue - : null; + + // `[x, y]` tuple datum (time-series / scatter): split the components out so + // callers don't have to re-parse `raw`. Only the first two scalar entries are + // read; anything else falls through to the scalar path. + let x: number | string | undefined; + let y: number | string | undefined; + if (Array.isArray(rawValue)) { + if (isScalar(rawValue[0])) x = rawValue[0]; + if (isScalar(rawValue[1])) y = rawValue[1]; + } + + const value = isScalar(rawValue) ? rawValue : (y ?? null); + + // Prefer the datum's own name; for a tuple point without one, fall back to + // the x-component's string form (e.g. a timestamp) rather than "". + const name = + typeof p.name === "string" ? p.name : x !== undefined ? String(x) : ""; const seriesName = typeof p.seriesName === "string" ? p.seriesName : undefined; @@ -170,6 +173,8 @@ export function mapToDatum(params: unknown): ChartClickDatum { return { name, value, + x, + y, seriesName, dataIndex, seriesIndex, diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts index 580ec97b4..d7a1f65e9 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -136,6 +136,58 @@ describe("useMetricView", () => { expect(result.current.metadata).toBeUndefined(); }); + test("treats a non-object metadata (null/array) as absent", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ revenue: 1 }], + // Malformed wire value — must not be surfaced as a metadata map. + metadata: ["not", "an", "object"], + }), + }); + }); + + await waitFor(() => { + expect(result.current.data).toEqual([{ revenue: 1 }]); + }); + expect(result.current.metadata).toBeUndefined(); + }); + + test("a successful result after a transient error clears the stale error", async () => { + const { result } = renderHook(() => + useMetricView("orders", { measures: ["revenue"] }), + ); + + // First: an error envelope sets error + errorCode. + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "error", + error: "boom", + errorCode: "UPSTREAM_ERROR", + }), + }); + }); + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.errorCode).toBe("UPSTREAM_ERROR"); + + // Then: a successful result must clear both, so error-first consumers show + // the fresh data instead of the stale error. + act(() => { + lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ revenue: 7 }] }), + }); + }); + await waitFor(() => expect(result.current.data).toEqual([{ revenue: 7 }])); + expect(result.current.error).toBeNull(); + expect(result.current.errorCode).toBeNull(); + }); + test("normalizes an empty result message (no data field) to []", async () => { const { result } = renderHook(() => useMetricView("orders", { measures: ["revenue"] }), @@ -379,14 +431,6 @@ describe("useMetricView", () => { expect(mockConnectSSE).toHaveBeenCalledTimes(2); }); - test("does not issue a request when autoStart is false", () => { - renderHook(() => - useMetricView("orders", { measures: ["revenue"], autoStart: false }), - ); - - expect(mockConnectSSE).not.toHaveBeenCalled(); - }); - test("throws when the metric key is empty", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 56fbb2a4f..f5e111d2d 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -7,6 +7,7 @@ export { } from "../resource-status-indicator"; export type { AnalyticsFormat, + GrainsForSelectedTimeDims, InferDimensionKeys, InferMeasureKeys, InferMetricRow, @@ -15,12 +16,14 @@ export type { InferServingChunk, InferServingRequest, InferServingResponse, + InferTimeDimensionKeys, InferTimeGrains, MetricFilter, MetricFilterOperatorName, MetricKey, MetricPredicate, MetricRegistry, + PickMetricRow, PluginRegistry, QueryRegistry, ServingAlias, diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index a01207fe5..9fa0aa919 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -304,9 +304,13 @@ export type InferTimeGrains = K extends AugmentedRegistry : string; /** - * Infers the row shape (measures + dimensions) from the registry when K is a - * known key, otherwise a total `Record`. Never resolves to - * `never` — always assignable to `Record`. + * Infers the full row shape (every measure + dimension) from the registry when + * K is a known key, otherwise a total `Record`. Never resolves + * to `never` — always assignable to `Record`. + * + * This types EVERY column as present. `useMetricView` instead returns + * {@link PickMetricRow}, which narrows to only the selected measures/dimensions; + * this remains exported as a convenience for the "all columns" case. */ export type InferMetricRow = K extends AugmentedRegistry ? MetricRegistry[K] extends { @@ -317,6 +321,78 @@ export type InferMetricRow = K extends AugmentedRegistry : Record : Record; +/** + * The row shape for a query that selected exactly the measures in `M` and the + * dimensions in `D` — a `Pick` over the registry's measure/dimension shapes + * rather than the whole metric. This is what keeps `data` honest: a query + * selecting `["arr"] + ["region"]` types `row.arr`/`row.region` but not the + * unselected `mrr`/`segment`. + * + * When `M`/`D` are the wide default (caller passed a non-literal array, or K is + * an unknown/degraded key), this degrades to the full row / a total + * `Record` — it never resolves to `never`. + */ +export type PickMetricRow< + K, + M extends ReadonlyArray, + D extends ReadonlyArray, +> = K extends AugmentedRegistry + ? MetricRegistry[K] extends { + measures: infer Meas; + dimensions: infer Dim; + } + ? Pick> & + Pick> + : Record + : Record; + +/** The per-dimension metadata map for K (carries `time_grain` on temporal dims). */ +type MetricDimensionMeta = K extends AugmentedRegistry + ? MetricRegistry[K] extends { metadata: { dimensions: infer DM } } + ? DM + : never + : never; + +/** + * The dimension keys of K that are TEMPORAL — i.e. carry a `time_grain` tuple in + * the generated metadata. Only these can be a `timeDimension`; grouping a + * non-temporal dimension by a grain (`date_trunc` over a string) is nonsense. + * Degrades to `string` for an unknown key. + */ +export type InferTimeDimensionKeys = + K extends AugmentedRegistry + ? { + [P in keyof MetricDimensionMeta]: MetricDimensionMeta[P] extends { + time_grain: unknown; + } + ? P + : never; + }[keyof MetricDimensionMeta] + : string; + +/** + * The valid grains for the SELECTED temporal dimensions `D` of K — the union of + * each selected temporal dimension's `time_grain` tuple. In practice grains are + * type-driven (all `timestamp` dims share one set, all `date` dims another), so + * the union is exactly the grains applicable to the query. Falls back to the + * metric's whole `timeGrains` union (or `string`) for an unknown/degraded key. + */ +export type GrainsForSelectedTimeDims< + K, + D extends ReadonlyArray, +> = K extends AugmentedRegistry + ? { + [P in Extract< + D[number], + keyof MetricDimensionMeta + >]: MetricDimensionMeta[P] extends { + time_grain: infer G extends readonly unknown[]; + } + ? G[number] + : never; + }[Extract>] + : string; + // The metric-filter vocabulary is pure data (no React), so it lives canonically // on the `/js` axis. Re-export it here so the `/react` public surface — and // `UseMetricViewOptions.filter` below — is unchanged. The runtime builder @@ -329,15 +405,30 @@ export type { import type { MetricFilter } from "@/js"; -/** Options for configuring a `useMetricView` query. */ -export interface UseMetricViewOptions { - measures: ReadonlyArray>; - dimensions?: ReadonlyArray>; +/** + * Options for configuring a `useMetricView` query. + * + * Generic over the selected measure tuple `M` and dimension tuple `D` so the + * returned row shape ({@link PickMetricRow}) narrows to exactly the columns the + * query asked for. `timeDimension` must be a SELECTED, TEMPORAL dimension, and + * `timeGrain` is correlated to the grains valid for those dimensions — so + * bucketing a non-temporal dimension is a type error. + */ +export interface UseMetricViewOptions< + K extends MetricKey = MetricKey, + M extends ReadonlyArray> = ReadonlyArray< + InferMeasureKeys + >, + D extends ReadonlyArray> = ReadonlyArray< + InferDimensionKeys + >, +> { + measures: M; + dimensions?: D; filter?: MetricFilter; - timeGrain?: InferTimeGrains; - timeDimension?: InferDimensionKeys; + timeDimension?: Extract>; + timeGrain?: GrainsForSelectedTimeDims; limit?: number; - autoStart?: boolean; } /** Result state returned by `useMetricView`. */ diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts index ff4cce3bd..d77a9b37b 100644 --- a/packages/appkit-ui/src/react/hooks/use-metric-view.ts +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -9,8 +9,10 @@ import { import type { MetricColumnMeta } from "shared"; import { connectSSE } from "@/js"; import type { - InferMetricRow, + InferDimensionKeys, + InferMeasureKeys, MetricKey, + PickMetricRow, UseMetricViewOptions, UseMetricViewResult, WarehouseStatus, @@ -56,16 +58,31 @@ function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { ); } +/** + * Narrow the wire `metadata` field to a per-column map. The value is only a + * meaningful metadata map when it is a plain object; a `null`, array, or scalar + * is treated as absent (`undefined`). Per-column shapes are not validated — + * the server constructs them via the typed builder. + */ +function asMetricMetadata( + value: unknown, +): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + function handleMetricSseMessage( parsed: Record, ctx: MetricSseContext, ): void { // Warehouse-readiness progress. The metric result type does NOT expose - // warehouseStatus (Phase 0 contract), so we keep the hook in its loading - // state (no caller-facing field) but publish the status to the shared - // ResourceStatusProvider — the same side-channel `useAnalyticsQuery` uses to - // drive a global "warehouse starting…" indicator during a cold start. This - // is a publish-only path: it never mutates UseMetricViewResult. + // warehouseStatus, so we keep the hook in its loading state (no caller-facing + // field) but publish the status to the shared ResourceStatusProvider — the + // same side-channel `useAnalyticsQuery` uses to drive a global "warehouse + // starting…" indicator during a cold start. This is a publish-only path: it + // never mutates UseMetricViewResult. if (parsed.type === "warehouse_status") { if (!isWarehouseStatusPayload(parsed.status)) { ctx.setLoading(false); @@ -79,19 +96,21 @@ function handleMetricSseMessage( } // JSON result. The SSE wire schema is intentionally loose (`data` is an - // optional array of unknown values), so a structural check is enough here — - // no need to ship a schema validator (zod, ~60 KB gz) to the browser just - // to read our own same-origin server's messages. Missing or non-array - // `data` normalizes to [] so `undefined` never bleeds into the hook's - // `T | null` state. `metadata` (per-column display metadata scoped to the - // queried columns) is surfaced as-is, or `undefined` when the server - // injected none (dormant / unknown key). + // optional array of unknown values), so a shallow structural check is enough + // here rather than a full schema validator. Missing or non-array `data` + // normalizes to [] so `undefined` never bleeds into the hook's `T | null` + // state. `metadata` (per-column display metadata scoped to the queried + // columns) is surfaced as-is only when it is a plain object; anything else + // (null / array / scalar) is treated as absent. It is `undefined` when the + // server injected none (dormant / unknown key). if (parsed.type === "result") { ctx.setLoading(false); + // A successful result supersedes any error from a prior (retried) attempt — + // clear it so error-first consumers don't hide valid data. + ctx.setError(null); + ctx.setErrorCode(null); ctx.setData(Array.isArray(parsed.data) ? parsed.data : []); - ctx.setMetadata( - parsed.metadata as Record | undefined, - ); + ctx.setMetadata(asMetricMetadata(parsed.metadata)); ctx.unpublishWarehouseStatus(); return; } @@ -133,11 +152,13 @@ function handleMetricSseMessage( * mirroring {@link useAnalyticsQuery}'s JSON_ARRAY path. * * The measure/dimension names, time grain, and row shape are inferred from the - * `MetricRegistry` module augmentation when `key` is a known metric key. + * `MetricRegistry` module augmentation when `key` is a known metric key. The + * returned rows are narrowed to exactly the SELECTED measures/dimensions (not + * every column the metric exposes). * * @param key - Metric view identifier * @param options - Measures (required) plus optional dimensions, filter, - * timeGrain/timeDimension, limit, and autoStart + * timeGrain/timeDimension, and limit * @returns Metric result state with typed rows and per-column display metadata * * @example @@ -150,16 +171,22 @@ function handleMetricSseMessage( * // data: Array<{ revenue: number; region: string }> | null * ``` */ -export function useMetricView( +export function useMetricView< + K extends MetricKey = MetricKey, + const M extends ReadonlyArray> = ReadonlyArray< + InferMeasureKeys + >, + const D extends ReadonlyArray> = ReadonlyArray< + InferDimensionKeys + >, +>( key: K, - options: UseMetricViewOptions, -): UseMetricViewResult[]> { - const autoStart = options?.autoStart ?? true; - + options: UseMetricViewOptions, +): UseMetricViewResult[]> { const devMode = getDevMode(); const urlSuffix = `/api/analytics/metric/${encodeURIComponent(key)}${devMode}`; - type Rows = InferMetricRow[]; + type Rows = PickMetricRow[]; const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -181,10 +208,12 @@ export function useMetricView( } // Serialize the request body from only the defined fields. A JSON string is - // a primitive, so a structurally-equal body across renders stays + // a primitive, so a body that serializes identically across renders stays // referentially stable for the `start` callback's dependency check even // though the caller passes fresh `measures`/`filter` object literals each - // render — no manual deep-equality/ref juggling required. + // render — no manual deep-equality/ref juggling required. (Reordering keys + // changes the serialization and does re-fire, but the field order here is + // fixed and the caller does not control it.) const payload = useMemo(() => { const body: { measures: ReadonlyArray; @@ -283,15 +312,13 @@ export function useMetricView( ]); useEffect(() => { - if (autoStart) { - start(); - } + start(); return () => { abortControllerRef.current?.abort(); unpublishWarehouseStatus(); }; - }, [start, autoStart, unpublishWarehouseStatus]); + }, [start, unpublishWarehouseStatus]); useQueryHMR(key, start); diff --git a/packages/appkit-ui/src/react/ui/index.ts b/packages/appkit-ui/src/react/ui/index.ts index 736810dfc..b73d1b1ac 100644 --- a/packages/appkit-ui/src/react/ui/index.ts +++ b/packages/appkit-ui/src/react/ui/index.ts @@ -41,7 +41,6 @@ export * from "./sheet"; export * from "./sidebar"; export * from "./skeleton"; export * from "./slider"; -export * from "./notify"; export * from "./sonner"; export * from "./spinner"; export * from "./switch"; diff --git a/packages/appkit-ui/src/react/ui/notify.ts b/packages/appkit-ui/src/react/ui/notify.ts deleted file mode 100644 index 27c28cce3..000000000 --- a/packages/appkit-ui/src/react/ui/notify.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ReactNode } from "react"; -import { toast } from "sonner"; - -/** Options for a {@link notify} toast — a curated subset of sonner's surface. */ -export interface NotifyOptions { - /** Secondary line under the title. */ - description?: ReactNode; - /** Auto-dismiss delay in ms. Omit for sonner's default; `Infinity` to make it sticky. */ - duration?: number; -} - -/** - * Fire a transient toast through the app's mounted `` — the same - * sonner surface `ResourceStatusIndicator` renders warehouse-readiness into. - * - * This is a curated wrapper so app code never imports sonner directly: it - * exposes only a title + `{ description, duration }`, not sonner's full option - * bag. Requires a `` (or ``) mounted in - * the tree; without one the call is a no-op. - * - * @example - * ```tsx - * notify.message("Write back: Arr · Apr 2026 · $8,100,000"); - * notify.success("Saved", { description: "Row written back to the source." }); - * ``` - */ -export const notify = { - /** Neutral message toast. */ - message: (title: ReactNode, options?: NotifyOptions) => - toast(title, options), - /** Informational toast. */ - info: (title: ReactNode, options?: NotifyOptions) => toast.info(title, options), - /** Success toast. */ - success: (title: ReactNode, options?: NotifyOptions) => - toast.success(title, options), - /** Warning toast. */ - warning: (title: ReactNode, options?: NotifyOptions) => - toast.warning(title, options), - /** Error toast. */ - error: (title: ReactNode, options?: NotifyOptions) => - toast.error(title, options), -}; diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 0882a3705..3b60c3f31 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -3,7 +3,6 @@ import { type AgentToolDefinition, type AnalyticsSseMessage, type IAppRouter, - type MetricColumnMeta, makeResultMessage, type PluginExecuteConfig, type SQLTypeMarker, @@ -667,14 +666,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { ); // Reuse the query route's JSON delivery: INLINE JSON_ARRAY with // an ARROW_STREAM-inline fallback, returning plain rows in a - // `result` message — byte-identical envelope to `/query`, plus the - // metric's per-column `metadata` slice (omitted when absent). + // `result` message — byte-identical envelope to `/query`. The + // per-column `metadata` is stamped AFTER this cached call returns + // (see below), never baked into the cached message. return await self._executeJsonArrayPath( executor, statement, processedParams, sig, - metadata, ); } catch (err) { originalError = err; @@ -712,7 +711,17 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { throw ExecutionError.statementFailed(inner); } - yield sqlResult.data as AnalyticsStreamMessage; + // Stamp the FRESH per-column metadata onto the (possibly cached) result + // message. The cache key excludes metadata and the cached message never + // carries it, so a cache HIT after a redeploy that changed a column's + // display_name/format serves the current metadata, not a stale copy. + // `undefined` leaves the field absent — envelope-identical to `/query`. + const resultMessage = sqlResult.data as AnalyticsSseMessage; + yield ( + metadata !== undefined + ? { ...resultMessage, metadata } + : resultMessage + ) as AnalyticsStreamMessage; }, streamExecutionSettings, executorKey, @@ -725,10 +734,11 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result` * message. External links are never used for the JSON fallback. * - * `metadata` (metric route only) is the pre-computed per-column slice stamped - * into the `result` message; it is pure response decoration (never affects - * the SQL or the cache key). `undefined` → the field is omitted, keeping the - * envelope byte-identical to a plain `/query` result. + * This returns the bare `result` message (rows + status/statement_id) and is + * cached by the caller. The metric route's per-column `metadata` is stamped + * onto the message AFTER the cached call returns (so a cache hit never serves + * stale metadata), never inside here — keeping the cached payload + * metadata-free and byte-identical to a plain `/query` result. */ private async _executeJsonArrayPath( executor: AnalyticsPlugin, @@ -737,7 +747,6 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { | Record | undefined, signal?: AbortSignal, - metadata?: Record, ): Promise { const result = await deliverJsonResult( executor, @@ -748,7 +757,6 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { return makeResultMessage(result.data, { status: result.status, statement_id: result.statement_id, - metadata, }); } diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 40bbf58e5..4b5a2818c 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -136,7 +136,7 @@ function writeRegistry( writeFileSync(path.join(dir, "definitions.json"), body); } -describe("analytics metric route (Phase 1)", () => { +describe("analytics metric route", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -296,8 +296,8 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimensions + GROUP BY ALL. Bare dimensions here; date_trunc - // grain application (via timeDimension) is covered in its own block below. + // ── dimensions + GROUP BY ALL. Bare dimensions here; date_trunc grain + // application (via timeDimension) is covered in its own block below. describe("buildMetricSql dimensions + GROUP BY", () => { const registration: MetricRegistration = { key: "revenue", @@ -368,7 +368,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2a: timeGrain + timeDimension → date_trunc on the named column. + // ── timeGrain + timeDimension → date_trunc on the named column. // The grain is a grammar-gated single-quoted literal; the column keeps its // plain alias; other dimensions render bare; GROUP BY ALL is present. describe("buildMetricSql timeGrain + timeDimension (date_trunc)", () => { @@ -422,7 +422,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimension identifier safety. A dimension is backtick-quoted at + // ── dimension identifier safety. A dimension is backtick-quoted at // interpolation, so an injection-shaped name is neutralized (inert quoted // column), and only an unquotable (control-char) name throws. describe("buildMetricSql dimension identifier safety (quoting)", () => { @@ -463,7 +463,7 @@ describe("analytics metric route (Phase 1)", () => { }); // ── Envelope parity — streams warehouse_status* then a `result` message, - // byte-identical to the /query route's JSON SSE path. + // the same event shape as the /query route's JSON SSE path. describe("_handleMetricRoute SSE envelope", () => { test("streams warehouse_status then a result message with aliased rows", async () => { const plugin = pluginForDir( @@ -591,10 +591,10 @@ describe("analytics metric route (Phase 1)", () => { expect(mockRes.status).toHaveBeenCalledWith(400); }); - // ── Metadata stamping (Phase 2). The injected `metricViewsMetadata` is - // sliced to the requested columns and stamped into the `result` message; it - // is pure decoration (no SQL / cache-key effect). See `selectMetricMetadata` - // below for the unit-level scoping tests. + // ── Metadata stamping. The injected `metricViewsMetadata` is sliced to the + // requested columns and stamped into the `result` message; it is pure + // decoration (no SQL / cache-key effect). See `selectMetricMetadata` below + // for the unit-level scoping tests. const REVENUE_METADATA: MetricViewsMetadata = { revenue: { measures: { @@ -683,8 +683,8 @@ describe("analytics metric route (Phase 1)", () => { ); const payload = readResultPayload(mockRes); - // The `result` message is byte-identical to a plain `/query` result: the - // `metadata` key is absent, not present-but-undefined. + // Envelope parity with a plain `/query` result: the `metadata` key is + // absent, not present-but-undefined. expect(payload).toBeDefined(); expect(Object.hasOwn(payload, "metadata")).toBe(false); expect(payload.data).toEqual([{ arr: 1234 }]); @@ -770,6 +770,73 @@ describe("analytics metric route (Phase 1)", () => { expect.any(AbortSignal), ); }); + + test("cache HIT serves the FRESH metadata, not the metadata baked in at cache-fill time", async () => { + // Regression: metadata was formerly stamped INSIDE the cached execute(), + // so a cache hit replayed the OLD labels/formats even after a redeploy + // changed them. The cache key excludes metadata, so the SQL result is a + // hit across the two runs below; only the injected metadata differs. + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + const runWithMetadata = async (mvMeta: MetricViewsMetadata) => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + mockRes, + ); + return readResultPayload(mockRes); + }; + + // First run fills the cache with the OLD labels. + const oldMeta: MetricViewsMetadata = { + revenue: { + measures: { arr: { type: "decimal", display_name: "ARR (old)" } }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const first = await runWithMetadata(oldMeta); + expect(first.metadata.arr.display_name).toBe("ARR (old)"); + + // Second run: same body → SQL cache HIT (executeStatement not called + // again), but the app now injects NEW labels. The response must reflect + // the fresh metadata, not the stale copy from the cached message. + executeMock.mockClear(); + const newMeta: MetricViewsMetadata = { + revenue: { + measures: { + arr: { + type: "decimal", + display_name: "ARR (new)", + format: "$#,##0", + }, + }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const second = await runWithMetadata(newMeta); + + expect(executeMock).not.toHaveBeenCalled(); // SQL served from cache + expect(second.metadata.arr.display_name).toBe("ARR (new)"); + expect(second.metadata.arr.format).toBe("$#,##0"); + }); }); // ── 503-vs-404 latching + dormancy. @@ -989,9 +1056,9 @@ describe("analytics metric route (Phase 1)", () => { }); // ── loadMetricRegistry: config parse against the landed metricSourceSchema. -// The loader reads the config file THROUGH an `AppManager` (Phase 2), so each -// test points an `AppManager` at its temp dir instead of passing a bare -// directory string. The loader is stateless — it reads + parses on every call +// The loader reads the config file THROUGH an `AppManager`, so each test points +// an `AppManager` at its temp dir instead of passing a bare directory string. +// The loader is stateless — it reads + parses on every call // (no memoization), so there is no cache to reset between tests. describe("loadMetricRegistry", () => { let dir: string; @@ -1124,9 +1191,9 @@ describe("loadMetricRegistry", () => { }); }); -// ── Phase 2: the structured filter engine (translator + validator). -// Registry-free: names are grammar-gated, values are parameterized. No -// allowlist, no op⇄dimension-type check. +// ── The structured filter engine (translator + validator). Registry-free: +// names are grammar-gated, values are parameterized. No allowlist, no +// op⇄dimension-type check. describe("metric — filter translator", () => { const registration: MetricRegistration = { key: "revenue", @@ -1844,8 +1911,8 @@ describe("metric — filter translator", () => { }); // The warehouse-authoritative unknown-name parity test (sanitized - // clientMessage/errorCode envelope) lands here because the Phase 1 harness - // can drive the metric route end-to-end and assert on the SSE error bytes. + // clientMessage/errorCode envelope) lands here because this harness can drive + // the metric route end-to-end and assert on the SSE error bytes. describe("warehouse-authoritative unknown-name parity", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -1927,7 +1994,7 @@ describe("metric — filter translator", () => { }); }); -// ── Phase 3: cache-key composition. `composeMetricCacheKey` produces the +// ── cache-key composition. `composeMetricCacheKey` produces the // array `CacheManager.generateKey` concatenates + sha256s; the invariants // below are what make the cache both correct (semantically equal calls collapse) // and safe (distinct args / executors never collide). @@ -2167,7 +2234,7 @@ describe("composeMetricCacheKey", () => { }); }); -// ── Phase 3: executor-key isolation. The key is what scopes the cache — `"sp"` +// ── executor-key isolation. The key is what scopes the cache — `"sp"` // shares it across all callers, a per-user hash isolates OBO callers. The raw // identity must never enter the key verbatim (privacy: cache keys are logged // and persisted). @@ -2240,12 +2307,12 @@ describe("deriveMetricExecutorKey", () => { }); }); -// ── Phase 3: lane dispatch at the handler level. The lane comes from the +// ── lane dispatch at the handler level. The lane comes from the // registration (the entry's `executor` in definitions.json), NOT the URL: // OBO-lane routes through `asUser(req)`, SP-lane through the default executor. // A missing/whitespace OBO identity must land on the canonical 401 envelope, // never an out-of-envelope 500. -describe("metric route — lane dispatch (Phase 3)", () => { +describe("metric route — lane dispatch", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -2415,7 +2482,7 @@ describe("metric route — lane dispatch (Phase 3)", () => { }); }); -// ── Phase 2: metadata slicing. `selectMetricMetadata` flattens the injected +// ── metadata slicing. `selectMetricMetadata` flattens the injected // per-metric metadata down to only the requested columns for the SSE `result` // message. It is pure and total; the invariants below are what keep the stamp // scoped, degrade-safe, and prototype-safe. diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index d6a080681..c23c01029 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,4 +1,8 @@ -import type { BasePluginConfig, MetricViewsMetadata } from "shared"; +import type { + BasePluginConfig, + MetricColumnMeta, + MetricViewsMetadata, +} from "shared"; export interface IAnalyticsConfig extends BasePluginConfig { timeout?: number; @@ -68,20 +72,32 @@ export interface WarehouseStatus { } /** - * Discriminated union of every SSE message shape emitted by - * `POST /api/analytics/query/:query_key`. Useful for typing the client-side + * Discriminated union of every SSE message shape emitted by the analytics + * routes (`POST /api/analytics/query/:query_key` and + * `POST /api/analytics/metric/:key`). Useful for typing the client-side * `onMessage` handler (and is the source of truth re-mirrored in * `appkit-ui` since that package can't depend on `appkit`). + * + * The `result` message carries an optional `metadata` map (per-column display + * metadata) — present on the metric route, absent on plain `/query`. The + * `error` message carries an optional structured `errorCode` (a stable upstream + * identifier) alongside the legacy `code`. */ export type AnalyticsStreamMessage = | { type: "warehouse_status"; status: WarehouseStatus } - | { type: "result"; data: unknown[] } + | { + type: "result"; + data?: unknown[]; + status?: unknown; + statement_id?: string; + metadata?: Record; + } | { type: "arrow"; statement_id: string; status: { state: string }; } - | { type: "error"; error: string; code?: string }; + | { type: "error"; error: string; code?: string; errorCode?: string }; /** * Supported response formats for analytics queries. @@ -137,7 +153,7 @@ export interface AnalyticsQueryResponse { * - `"sp"` ← `executor: "app_service_principal"` — queried as the app * service principal (cache shared across all users). * - `"obo"` ← `executor: "user"` — queried on-behalf-of the requesting - * user (per-user cache). OBO dispatch is wired in a later phase. + * user (per-user cache) via `asUser(req)`. */ export type MetricLane = "sp" | "obo"; diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index ddbb6cea2..89ae305c9 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -720,6 +720,22 @@ export async function syncMetricViewsTypes(options: { "utf-8", ); + // Sweep a stale sibling `metric-views.d.ts` from a pre-`.ts` version. Older + // typegen emitted an ambient `.d.ts`; the current output is a real `.ts` at + // `metricOutFile`. Left behind, the old sibling would duplicate the + // `declare module` augmentation and re-introduce the bare side-effect import + // the new header deliberately drops. Best-effort: only removed when the new + // file is itself a `.ts` (never delete the file we just wrote), ENOENT-safe. + if (metricOutFile.endsWith(".ts") && !metricOutFile.endsWith(".d.ts")) { + const staleDts = `${metricOutFile.slice(0, -".ts".length)}.d.ts`; + try { + await fs.unlink(staleDts); + logger.debug("Removed stale generated types at %s", staleDts); + } catch { + // No stale sibling — nothing to clean up. + } + } + logger.debug( "Wrote MetricRegistry augmentation for %d metric(s)%s", schemas.length, diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 67ea1868e..f6f1ab01b 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -541,7 +541,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(declarations).toContain("timeGrains: string"); }); - // ── Non-blocking warehouse gate: metric DESCRIBEs honor the #406 contract ── + // ── Non-blocking warehouse gate: metric DESCRIBEs are skipped when the + // warehouse isn't running (degraded types still emitted) ── test("non-blocking + warehouse not running: skips all DESCRIBEs but still emits degraded artifacts", async () => { fs.writeFileSync( @@ -931,7 +932,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { mocks.waitUntilRunning.mock.calls[0][2].treatStoppedAsTransient, ).toBeUndefined(); // The DESCRIBE batch still ran (fall-through), and its non-terminal answer - // degraded the key per Phase 1 semantics. + // degraded the key. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); expect(fs.readFileSync(metricFile, "utf-8")).toContain( "measureKeys: string", 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 b2027070e..b796a7203 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -227,12 +227,12 @@ describe("resolveMetricConfig", () => { }); }); -// ── Phase 2: UC-accurate FQN naming validation. The source FQN is validated -// against UC_FQN_PATTERN (single-sourced from the zod-free +// ── UC-accurate FQN naming validation. The source FQN is validated against +// UC_FQN_PATTERN (single-sourced from the zod-free // packages/shared/src/schemas/metric-fqn.ts, shared with the canonical Zod -// schema). The old hand-rolled segment charset [a-zA-Z0-9_-] was flagged in -// PR #433 review (pkosiec) as "more restrictive than UC"; these tests pin the -// arity/dot/charset rules and the now-accepted UC-legal characters. +// schema). A hand-rolled segment charset [a-zA-Z0-9_-] would be more +// restrictive than UC; these tests pin the arity/dot/charset rules and the +// UC-legal characters that must be accepted. describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { const sourceOf = (source: string) => ({ metricViews: { revenue: { source } }, @@ -289,8 +289,8 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { ).toThrowError(/the schema part .* contains a character/); }); - // ── Regression: UC-legal characters the OLD [a-zA-Z0-9_-] regex rejected - // now PASS. PR #433 review (pkosiec): "more restrictive than UC". ─────── + // ── UC-legal characters a narrow [a-zA-Z0-9_-] regex would reject must be + // accepted (hyphens, mixed case, non-ASCII). ─────────────────────────── test("accepts hyphens, mixed case, and non-ASCII names UC permits", () => { for (const source of [ "prod-data.analytics.revenue", @@ -364,10 +364,9 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { }); }); -// ── Input caps (inline-only at v1): the canonical Zod schema has no caps -// yet — aligning it is a PR4 rider, so these fixtures deliberately do NOT -// run through metricSourceSchema (they'd pass it) and stay out of the -// parity suite below. +// ── Input caps (inline-only at v1): the canonical Zod schema does not yet +// carry these caps, so these fixtures deliberately do NOT run through +// metricSourceSchema (they'd pass it) and stay out of the parity suite below. describe("resolveMetricConfig — input caps", () => { const manyViews = (count: number) => Object.fromEntries( @@ -426,10 +425,10 @@ describe("resolveMetricConfig — input caps", () => { // inline; this block is the drift alarm for them. TEST-ONLY import of the Zod schema. // // Caps divergence: the inline validator enforces v1 input caps (≤200 entries, -// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not -// carry yet — aligning the Zod schema is a PR4 rider. Cap fixtures therefore -// live in the dedicated caps suite above and are asserted on the inline side -// only; do NOT add them here expecting metricSourceSchema to reject them. +// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not carry +// yet. Cap fixtures therefore live in the dedicated caps suite above and are +// asserted on the inline side only; do NOT add them here expecting +// metricSourceSchema to reject them. describe("resolveMetricConfig — parity with shared metricSourceSchema", () => { const accepts: Array<{ name: string; config: Record }> = [ { @@ -775,11 +774,11 @@ describe("createWorkspaceDescribeFetcher", () => { }); test("a backtick-bearing FQN is now accepted and safely quoted (UC permits it, quoting doubles it)", async () => { - // Under the old hand-rolled segment charset ([a-zA-Z0-9_-]) a backtick was - // rejected outright. UC actually permits a backtick inside a quoted name, - // and quoteFqnForSql (Phase 1) makes it injection-safe by doubling it. So - // naming validation now accepts it and the statement quotes it as a single - // identifier rather than refusing the FQN. + // A narrow segment charset ([a-zA-Z0-9_-]) would reject a backtick outright. + // UC actually permits a backtick inside a quoted name, and quoteFqnForSql + // makes it injection-safe by doubling it. So naming validation accepts it + // and the statement quotes it as a single identifier rather than refusing + // the FQN. const { client, statements } = stubClient(); const fetcher = createWorkspaceDescribeFetcher(client, "wh-1"); @@ -843,7 +842,7 @@ describe("extractMetricColumns", () => { expect(extractMetricColumns({ unrelated: true })).toEqual([]); }); - // ── Phase 2: time-typed dimensions ──────────────────────────────────── + // ── time-typed dimensions ────────────────────────────────────────────── test("infers all 7 standard grains for a TIMESTAMP dimension", () => { const cols = extractMetricColumns({ columns: [ @@ -1408,7 +1407,7 @@ describe("syncMetrics — bounded-concurrency scheduling", () => { expect(schemas.map((s) => s.key)).toEqual(keys); // Rejected entries land in `failures` (stable entry order) AND are - // degraded — the Phase-1 matrix, unchanged by chunking. + // degraded — the failure matrix is unchanged by chunking. expect(failures.map((f) => f.key)).toEqual(["m02", "m06", "m11"]); for (const failure of failures) { expect(failure.source).toBe(`demo.public.${failure.key}`); @@ -1583,7 +1582,7 @@ describe("generateMetricTypeDeclarations — snapshot", () => { expect(output).toContain("measures: Record"); }); - // ── Phase 2: time-typed dim + multiple non-time dims fixture ───────── + // ── time-typed dim + multiple non-time dims fixture ────────────────── test("emits TimeGrain union for a metric view with time-typed + regular dimensions", async () => { const resolution = resolveMetricConfig({ metricViews: { @@ -1623,9 +1622,9 @@ describe("generateMetricTypeDeclarations — snapshot", () => { }); }); -// ── PR5 Phase 1: the emitted file is a real `.ts` carrying BOTH the (erasable) -// `declare module` type augmentation AND a runtime `metricViewsMetadata` value. -// It must never emit a runtime side-effect import (that would execute the client +// ── The emitted file is a real `.ts` carrying BOTH the (erasable) `declare +// module` type augmentation AND a runtime `metricViewsMetadata` value. It must +// never emit a runtime side-effect import (that would execute the client // package entry on the Node server) — only a zero-runtime type-only import. describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { @@ -1729,8 +1728,8 @@ describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", }); }); -// ── Phase 5: semantic-metadata extraction (display_name + format) ───────── -describe("extractMetricColumns — Phase 5 semantic metadata", () => { +// ── semantic-metadata extraction (display_name + format) ────────────────── +describe("extractMetricColumns — semantic metadata", () => { test("captures display_name from a measure column", () => { const cols = extractMetricColumns({ columns: [ @@ -2090,7 +2089,7 @@ describe("artifact key-order determinism", () => { }); }); -// ── Phase 2: syncMetrics propagates timeGrains end-to-end ──────────────── +// ── syncMetrics propagates timeGrains end-to-end ───────────────────────── describe("syncMetrics — time-typed dimension propagation", () => { test("propagates inferred grains onto the resulting MetricSchema", async () => { const resolution = resolveMetricConfig({ diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 718a9fc3f..d6d4154ce 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -185,6 +185,37 @@ describe("syncMetricViewsTypes", () => { ); }); + test("removes a stale sibling metric-views.d.ts left by a pre-.ts version on upgrade", async () => { + writeMixedConfig(); + + // Simulate an app upgraded from a version that emitted an ambient + // `metric-views.d.ts`. Left in place beside the new `.ts`, it would + // duplicate the `declare module` augmentation and re-introduce the bare + // side-effect import the new header drops. + const staleDts = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); + fs.mkdirSync(path.dirname(staleDts), { recursive: true }); + fs.writeFileSync( + staleDts, + '// old\nimport "@databricks/appkit-ui/react";\n', + ); + + await syncMetricViewsTypes({ + metricViewsFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + // The new .ts is written and the stale .d.ts sibling is swept. + expect(fs.existsSync(metricOutFile)).toBe(true); + expect(fs.existsSync(staleDts)).toBe(false); + }); + test("returns noConfig and writes nothing when definitions.json is absent", async () => { const result = await syncMetricViewsTypes({ metricViewsFolder, 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 7510dd8f6..7bbe6d8b5 100644 --- a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts @@ -404,6 +404,19 @@ describe("appKitTypesPlugin — metric option plumbing", () => { }), ); }); + + test("rejects a .d.ts custom mvOutFile up front (it would emit a runtime const into an ambient decl → TS1039)", () => { + const plugin = appKitTypesPlugin({ + mvOutFile: "custom/types/metric-views.d.ts", + }); + const configResolved = getHook( + plugin, + "configResolved", + ); + expect(() => + configResolved({ root: path.join(process.cwd(), "client") }), + ).toThrow(/must be a \.ts file, not a \.d\.ts/); + }); }); describe("appKitTypesPlugin — background warehouse watch", () => { diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 6880152fb..a0fe6d7bc 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -332,6 +332,17 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // final path is identical (the default outFile above lives in // shared//), and a customized outFile now keeps its metric // sibling next to it instead of pinning it under shared/. + // + // Reject a `.d.ts` metric out-path up front: the metric file is a real + // `.ts` source carrying a runtime `const` (metricViewsMetadata), which is + // illegal inside an ambient declaration file (TS1039). Fail fast with a + // clear message rather than emitting a file that won't compile. + if (options?.mvOutFile?.endsWith(".d.ts")) { + throw new Error( + `appKitAnalyticsTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + + "The metric-views file carries a runtime const, which cannot live in an ambient .d.ts.", + ); + } mvOutFile = options?.mvOutFile !== undefined ? path.resolve(projectRoot, options.mvOutFile) From d8eac282c222e6c5aee3af97678652fc61c01af2 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 29 Jul 2026 18:14:47 +0200 Subject: [PATCH 10/28] chore: move files and update comments --- packages/appkit-ui/src/js/format/format.ts | 202 ------------------ .../format/{format.test.ts => index.test.ts} | 2 +- packages/appkit-ui/src/js/format/index.ts | 195 ++++++++++++++++- .../appkit-ui/src/js/metric-filter/index.ts | 6 - .../src/react/hooks/use-metric-view.ts | 3 - 5 files changed, 195 insertions(+), 213 deletions(-) delete mode 100644 packages/appkit-ui/src/js/format/format.ts rename packages/appkit-ui/src/js/format/{format.test.ts => index.test.ts} (98%) diff --git a/packages/appkit-ui/src/js/format/format.ts b/packages/appkit-ui/src/js/format/format.ts deleted file mode 100644 index 434f080cc..000000000 --- a/packages/appkit-ui/src/js/format/format.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { MetricColumnMeta } from "shared"; - -// ============================================================================ -// Pure Format Utilities -// ============================================================================ -// Library-agnostic, tree-shakeable helpers for turning raw metric values and -// column metadata into display strings. These take the UC/YAML format spec (or -// MetricColumnMeta) as ARGUMENTS — no React, no chart-lib coupling, no bundled -// artifact — so they can be used from any surface (tables, tooltips, charts). - -/** - * Counts the number of fractional digits declared by a numeric format spec. - * E.g. "#,##0.00" -> 2, "#,##0" -> 0, "0.0%" -> 1. - */ -function countDecimals(format: string): number { - const dotIndex = format.indexOf("."); - if (dotIndex === -1) return 0; - const frac = format.slice(dotIndex + 1); - const match = frac.match(/^[0#]+/); - return match ? match[0].length : 0; -} - -/** - * Best-effort coercion of an arbitrary value to a finite number. Handles the - * common wire shapes (number, bigint, numeric string). Returns null when the - * value cannot be meaningfully treated as a number. - */ -function coerceNumber(value: unknown): number | null { - if (typeof value === "number") return Number.isFinite(value) ? value : null; - if (typeof value === "bigint") return Number(value); - if (typeof value === "string") { - if (value.trim() === "") return null; - const n = Number(value); - return Number.isFinite(n) ? n : null; - } - return null; -} - -/** Format a number with fixed decimals + optional thousands grouping. */ -function formatNumber( - value: number, - decimals: number, - grouping: boolean, -): string { - return value.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - useGrouping: grouping, - }); -} - -/** - * The currency symbol a spec carries — everything before the first digit - * placeholder (`#`/`0`). The metric-view generator emits `$`, `€`, `£`, `¥`, - * `₹`, `R$`, or an unknown ISO code + space (e.g. `"XYZ "`); this recovers any - * of them verbatim. Returns `""` for a bare numeric spec (`"#,##0"`) or a - * percent spec (`"0.0%"`), neither of which has a leading symbol. - */ -function currencyPrefix(format: string): string { - const match = format.match(/^[^#0]+/); - return match ? match[0] : ""; -} - -/** - * Format a raw value using a UC/YAML printf-style format spec. - * - * Recognizes the common spreadsheet-style specs: - * - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50"); the prefix is - * emitted verbatim, so `"€#,##0"`, `"R$#,##0.00"`, etc. survive end-to-end - * - thousands grouping + N decimals, e.g. `"#,##0"` (1234567 -> "1,234,567") - * or `"#,##0.00"` (1234.5 -> "1,234.50") - * - percent, e.g. `"0.0%"` (0.1234 -> "12.3%") — the value is multiplied by 100 - * - * No format spec -> sensible default: numbers via `toLocaleString`, everything - * else via `String()`. `null`/`undefined` -> `""`. Unrecognized specs fall back - * to a best-effort result (the number grouped, or `String(value)`). - */ -export function formatValue(value: unknown, format?: string): string { - if (value === null || value === undefined) return ""; - - if (!format) { - if (typeof value === "number") { - return Number.isFinite(value) ? value.toLocaleString() : String(value); - } - if (typeof value === "bigint") return value.toLocaleString(); - return String(value); - } - - const isPercent = format.includes("%"); - const grouping = format.includes(","); - const decimals = countDecimals(format); - // Any leading symbol (before the first digit placeholder) is a currency - // prefix — emit it verbatim so non-USD symbols the generator produces are - // preserved instead of collapsing to "$". - const prefix = currencyPrefix(format); - - // bigint fast path. A bigint is an exact integer, so `BigInt.toLocaleString` - // formats it losslessly — `Number(bigint)` would corrupt values beyond ±2^53 - // (int64 counts / cents). The percent path multiplies by 100 (float math a - // large bigint can't survive), so refuse it rather than emit a wrong number. - if (typeof value === "bigint") { - if (isPercent) return String(value); - const sign = value < 0n ? "-" : ""; - // `Intl.NumberFormat` accepts a bigint directly and formats it exactly (no - // float coercion), unlike `Number(value)`. - const body = new Intl.NumberFormat("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - useGrouping: grouping, - }).format(value < 0n ? -value : value); - return `${sign}${prefix}${body}`; - } - - const num = coerceNumber(value); - // Non-numeric value with a numeric-ish spec: nothing sensible to format. - if (num === null) return String(value); - - if (isPercent) { - return `${formatNumber(num * 100, decimals, grouping)}%`; - } - - if (prefix) { - const sign = num < 0 ? "-" : ""; - return `${sign}${prefix}${formatNumber(Math.abs(num), decimals, grouping)}`; - } - - return formatNumber(num, decimals, grouping); -} - -/** - * Turns a raw column name into a human-readable label. - * Handles camelCase, snake_case, acronyms, and ALL_CAPS. - * E.g., "totalSpend" -> "Total Spend", "avg_ltv" -> "Avg Ltv". - */ -function humanize(name: string): string { - return ( - name - // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl -> HTTP Url) - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - // Handle lowercase followed by uppercase (e.g., totalSpend -> total Spend) - .replace(/([a-z])([A-Z])/g, "$1 $2") - // Replace underscores with spaces - .replace(/_/g, " ") - // Collapse multiple spaces into one - .replace(/\s+/g, " ") - // Normalize to title case - .toLowerCase() - .replace(/\b\w/g, (l) => l.toUpperCase()) - .trim() - ); -} - -/** - * Human label for a column: prefers `columnMeta.display_name`, else humanizes - * the raw column name (camelCase / snake_case / CAPS -> Title Case). - */ -export function formatLabel( - name: string, - columnMeta?: MetricColumnMeta, -): string { - if (columnMeta?.display_name) return columnMeta.display_name; - return humanize(name); -} - -/** - * Maps a UC/spreadsheet-style format spec to a - * [d3-format](https://d3js.org/d3-format) specifier string, for charts that - * consume d3 format strings. - * - * Best-effort mapping for the common specs: - * - `"$#,##0.00"` -> `"$,.2f"` - * - `"€#,##0.00"` -> `"$,.2f"` (currency), `"#,##0"` -> `",.0f"`, `"0.0%"` -> `".1%"` - * - * A d3 specifier's currency marker is the single `$` symbol; the actual glyph - * ($, €, R$, …) is supplied by the d3 *locale*, not the specifier string — so a - * non-USD currency spec still maps to the `$` currency type here (it is not - * rejected as unrecognized), and the caller's d3 locale renders the right glyph. - * - * No spec, or a spec that is not a recognizable numeric pattern -> `undefined`. - */ -export function toD3Format(format?: string): string | undefined { - if (!format) return undefined; - - // Strip any leading currency prefix first, then require the remainder to be - // built purely from numeric-format characters; anything else (date patterns, - // free text, ...) is left unrecognized. - const prefix = currencyPrefix(format); - const numeric = format.slice(prefix.length); - if (numeric.replace(/[#0,.%\s]/g, "") !== "") return undefined; - if (!/[0#]/.test(numeric)) return undefined; - - const group = format.includes(",") ? "," : ""; - const decimals = countDecimals(format); - - if (format.includes("%")) { - return `${group}.${decimals}%`; - } - - // `$` is d3's currency marker (glyph comes from the locale); emit it for any - // currency prefix, USD or otherwise. - return `${prefix ? "$" : ""}${group}.${decimals}f`; -} diff --git a/packages/appkit-ui/src/js/format/format.test.ts b/packages/appkit-ui/src/js/format/index.test.ts similarity index 98% rename from packages/appkit-ui/src/js/format/format.test.ts rename to packages/appkit-ui/src/js/format/index.test.ts index 4a2cbb0f2..1a1ab9050 100644 --- a/packages/appkit-ui/src/js/format/format.test.ts +++ b/packages/appkit-ui/src/js/format/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { formatLabel, formatValue, toD3Format } from "./format"; +import { formatLabel, formatValue, toD3Format } from "./index"; describe("js/format formatValue", () => { test("currency spec formats with prefix, grouping and 2 decimals", () => { diff --git a/packages/appkit-ui/src/js/format/index.ts b/packages/appkit-ui/src/js/format/index.ts index c89fec47d..747f13fc5 100644 --- a/packages/appkit-ui/src/js/format/index.ts +++ b/packages/appkit-ui/src/js/format/index.ts @@ -1 +1,194 @@ -export * from "./format"; +import type { MetricColumnMeta } from "shared"; + +/** + * Counts the number of fractional digits declared by a numeric format spec. + * E.g. "#,##0.00" -> 2, "#,##0" -> 0, "0.0%" -> 1. + */ +function countDecimals(format: string): number { + const dotIndex = format.indexOf("."); + if (dotIndex === -1) return 0; + const frac = format.slice(dotIndex + 1); + const match = frac.match(/^[0#]+/); + return match ? match[0].length : 0; +} + +/** + * Best-effort coercion of an arbitrary value to a finite number. Handles the + * common wire shapes (number, bigint, numeric string). Returns null when the + * value cannot be meaningfully treated as a number. + */ +function coerceNumber(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + if (value.trim() === "") return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** Format a number with fixed decimals + optional thousands grouping. */ +function formatNumber( + value: number, + decimals: number, + grouping: boolean, +): string { + return value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }); +} + +/** + * The currency symbol a spec carries — everything before the first digit + * placeholder (`#`/`0`). The metric-view generator emits `$`, `€`, `£`, `¥`, + * `₹`, `R$`, or an unknown ISO code + space (e.g. `"XYZ "`); this recovers any + * of them verbatim. Returns `""` for a bare numeric spec (`"#,##0"`) or a + * percent spec (`"0.0%"`), neither of which has a leading symbol. + */ +function currencyPrefix(format: string): string { + const match = format.match(/^[^#0]+/); + return match ? match[0] : ""; +} + +/** + * Format a raw value using a UC/YAML printf-style format spec. + * + * Recognizes the common spreadsheet-style specs: + * - currency prefix, e.g. `"$#,##0.00"` (1234.5 -> "$1,234.50"); the prefix is + * emitted verbatim, so `"€#,##0"`, `"R$#,##0.00"`, etc. survive end-to-end + * - thousands grouping + N decimals, e.g. `"#,##0"` (1234567 -> "1,234,567") + * or `"#,##0.00"` (1234.5 -> "1,234.50") + * - percent, e.g. `"0.0%"` (0.1234 -> "12.3%") — the value is multiplied by 100 + * + * No format spec -> sensible default: numbers via `toLocaleString`, everything + * else via `String()`. `null`/`undefined` -> `""`. Unrecognized specs fall back + * to a best-effort result (the number grouped, or `String(value)`). + */ +export function formatValue(value: unknown, format?: string): string { + if (value === null || value === undefined) return ""; + + if (!format) { + if (typeof value === "number") { + return Number.isFinite(value) ? value.toLocaleString() : String(value); + } + if (typeof value === "bigint") return value.toLocaleString(); + return String(value); + } + + const isPercent = format.includes("%"); + const grouping = format.includes(","); + const decimals = countDecimals(format); + // Any leading symbol (before the first digit placeholder) is a currency + // prefix — emit it verbatim so non-USD symbols the generator produces are + // preserved instead of collapsing to "$". + const prefix = currencyPrefix(format); + + // bigint fast path. A bigint is an exact integer, so `BigInt.toLocaleString` + // formats it losslessly — `Number(bigint)` would corrupt values beyond ±2^53 + // (int64 counts / cents). The percent path multiplies by 100 (float math a + // large bigint can't survive), so refuse it rather than emit a wrong number. + if (typeof value === "bigint") { + if (isPercent) return String(value); + const sign = value < 0n ? "-" : ""; + // `Intl.NumberFormat` accepts a bigint directly and formats it exactly (no + // float coercion), unlike `Number(value)`. + const body = new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }).format(value < 0n ? -value : value); + return `${sign}${prefix}${body}`; + } + + const num = coerceNumber(value); + // Non-numeric value with a numeric-ish spec: nothing sensible to format. + if (num === null) return String(value); + + if (isPercent) { + return `${formatNumber(num * 100, decimals, grouping)}%`; + } + + if (prefix) { + const sign = num < 0 ? "-" : ""; + return `${sign}${prefix}${formatNumber(Math.abs(num), decimals, grouping)}`; + } + + return formatNumber(num, decimals, grouping); +} + +/** + * Turns a raw column name into a human-readable label. + * Handles camelCase, snake_case, acronyms, and ALL_CAPS. + * E.g., "totalSpend" -> "Total Spend", "avg_ltv" -> "Avg Ltv". + */ +function humanize(name: string): string { + return ( + name + // Handle consecutive uppercase followed by lowercase (e.g., HTTPUrl -> HTTP Url) + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + // Handle lowercase followed by uppercase (e.g., totalSpend -> total Spend) + .replace(/([a-z])([A-Z])/g, "$1 $2") + // Replace underscores with spaces + .replace(/_/g, " ") + // Collapse multiple spaces into one + .replace(/\s+/g, " ") + // Normalize to title case + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()) + .trim() + ); +} + +/** + * Human label for a column: prefers `columnMeta.display_name`, else humanizes + * the raw column name (camelCase / snake_case / CAPS -> Title Case). + */ +export function formatLabel( + name: string, + columnMeta?: MetricColumnMeta, +): string { + if (columnMeta?.display_name) return columnMeta.display_name; + return humanize(name); +} + +/** + * Maps a UC/spreadsheet-style format spec to a + * [d3-format](https://d3js.org/d3-format) specifier string, for charts that + * consume d3 format strings. + * + * Best-effort mapping for the common specs: + * - `"$#,##0.00"` -> `"$,.2f"` + * - `"€#,##0.00"` -> `"$,.2f"` (currency), `"#,##0"` -> `",.0f"`, `"0.0%"` -> `".1%"` + * + * A d3 specifier's currency marker is the single `$` symbol; the actual glyph + * ($, €, R$, …) is supplied by the d3 *locale*, not the specifier string — so a + * non-USD currency spec still maps to the `$` currency type here (it is not + * rejected as unrecognized), and the caller's d3 locale renders the right glyph. + * + * No spec, or a spec that is not a recognizable numeric pattern -> `undefined`. + */ +export function toD3Format(format?: string): string | undefined { + if (!format) return undefined; + + // Strip any leading currency prefix first, then require the remainder to be + // built purely from numeric-format characters; anything else (date patterns, + // free text, ...) is left unrecognized. + const prefix = currencyPrefix(format); + const numeric = format.slice(prefix.length); + if (numeric.replace(/[#0,.%\s]/g, "") !== "") return undefined; + if (!/[0#]/.test(numeric)) return undefined; + + const group = format.includes(",") ? "," : ""; + const decimals = countDecimals(format); + + if (format.includes("%")) { + return `${group}.${decimals}%`; + } + + // `$` is d3's currency marker (glyph comes from the locale); emit it for any + // currency prefix, USD or otherwise. + return `${prefix ? "$" : ""}${group}.${decimals}f`; +} diff --git a/packages/appkit-ui/src/js/metric-filter/index.ts b/packages/appkit-ui/src/js/metric-filter/index.ts index c24029ca4..96409ee3c 100644 --- a/packages/appkit-ui/src/js/metric-filter/index.ts +++ b/packages/appkit-ui/src/js/metric-filter/index.ts @@ -1,16 +1,10 @@ // ──────────────────────────────────────────────────────────────────────────── // Metric filter vocabulary + builder. // -// Pure, framework-agnostic. Lives on the `/js` axis because a `MetricFilter` is -// plain data — a Node script, an SSR pass, or a test can build one without React -// in the graph. The React `useMetricView` hook re-exports these types from -// `@databricks/appkit-ui/react` so its public surface is unchanged. -// // **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot // depend on appkit, so this mirrors the twelve-operator filter grammar by hand. // ──────────────────────────────────────────────────────────────────────────── -/** v1 filter operator vocabulary — exactly twelve names. */ export type MetricFilterOperatorName = | "equals" | "notEquals" diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts index d77a9b37b..963310eae 100644 --- a/packages/appkit-ui/src/react/hooks/use-metric-view.ts +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -27,7 +27,6 @@ function getDevMode(): string { const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; -/** Map a fetch/SSE transport error to a user-facing message. */ function userFacingFetchError(error: unknown): string { if (error instanceof Error) { if (error.name === "AbortError") { @@ -136,8 +135,6 @@ function handleMetricSseMessage( return; } - // Not a warehouse-status, result, or error event — surface a generic error - // rather than silently dropping an unrecognized payload. console.error("[useMetricView] Unrecognized SSE payload", parsed); ctx.setLoading(false); ctx.setError(GENERIC_LOAD_ERROR); From 488175b2f8c521a8a8d5563dc7fc14b0142e5619 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 30 Jul 2026 10:45:53 +0200 Subject: [PATCH 11/28] docs: trim redundant comments and convert render-types doc to JSDoc Drop comments that restated adjacent JSDoc/functions: - base.tsx: the inline-handler re-subscribe rationale lived in both the `interactive` and `onEvents` comments; keep it once at `onEvents` (where the subscription happens) and point the `interactive` note at it. - use-metric-view.ts: result-branch comment re-explained metadata narrowing already documented on `asMetricMetadata`; defer to that doc. - js/format/index.ts: call-site comment restated `currencyPrefix`'s own JSDoc. - render-types.ts: convert `generateMetricTypeDeclarations`'s // block to /** */ so the exported function's rationale surfaces on IDE hover. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit-ui/src/js/format/index.ts | 3 --- packages/appkit-ui/src/react/charts/base.tsx | 9 ++++----- .../src/react/hooks/use-metric-view.ts | 5 +---- .../mv-registry/render-types.ts | 20 ++++++++++--------- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/appkit-ui/src/js/format/index.ts b/packages/appkit-ui/src/js/format/index.ts index 747f13fc5..d1311bc33 100644 --- a/packages/appkit-ui/src/js/format/index.ts +++ b/packages/appkit-ui/src/js/format/index.ts @@ -81,9 +81,6 @@ export function formatValue(value: unknown, format?: string): string { const isPercent = format.includes("%"); const grouping = format.includes(","); const decimals = countDecimals(format); - // Any leading symbol (before the first digit placeholder) is a currency - // prefix — emit it verbatim so non-USD symbols the generator produces are - // preserved instead of collapsing to "$". const prefix = currencyPrefix(format); // bigint fast path. A bigint is an exact integer, so `BigInt.toLocaleString` diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 41324d9b2..ae7a90f88 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -233,11 +233,10 @@ export function BaseChart({ const ui = useChartUITokens(); // Only the *presence* of a click handler shapes the option (it flips - // `triggerLineEvent`/`symbolSize` on line/area) AND the `onEvents` map below. - // Depend on this boolean, not the handler reference — consumers pass an inline - // `onDataClick`, whose identity changes every render, so depending on the - // reference would rebuild the whole option object AND re-subscribe the ECharts - // click listener on every parent re-render (e.g. each SSE tick). + // `triggerLineEvent`/`symbolSize` on line/area) AND gates the `onEvents` map + // below. Depend on this boolean, not the handler reference, so an inline + // `onDataClick` (new identity every render) doesn't rebuild the option object + // each render — see `onEvents` for the matching subscription rationale. const interactive = !!onDataClick; // Keep the latest handler in a ref so `onEvents` can call the current diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts index 963310eae..b2e21bacd 100644 --- a/packages/appkit-ui/src/react/hooks/use-metric-view.ts +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -98,10 +98,7 @@ function handleMetricSseMessage( // optional array of unknown values), so a shallow structural check is enough // here rather than a full schema validator. Missing or non-array `data` // normalizes to [] so `undefined` never bleeds into the hook's `T | null` - // state. `metadata` (per-column display metadata scoped to the queried - // columns) is surfaced as-is only when it is a plain object; anything else - // (null / array / scalar) is treated as absent. It is `undefined` when the - // server injected none (dormant / unknown key). + // state. `metadata` is narrowed by `asMetricMetadata` (see its doc). if (parsed.type === "result") { ctx.setLoading(false); // A successful result supersedes any error from a prior (retried) attempt — diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index dc0c81195..a3fb2977c 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -236,15 +236,17 @@ ${entries}; `; } -// Build the full metric-views.ts file from a list of metric schemas. -// -// This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the -// erasable `declare module` type augmentation AND a runtime value export -// (`metricViewsMetadata`). It must therefore never emit a runtime side-effect -// import — a bare `import "@databricks/appkit-ui/react"` would execute the -// client package entry on the Node server. The header is a type-only -// `import type {} from "..."`, which (a) compiles to zero runtime code and -// (b) anchors the module so the global `declare module` augmentation resolves. +/** + * Build the full metric-views.ts file from a list of metric schemas. + * + * This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the + * erasable `declare module` type augmentation AND a runtime value export + * (`metricViewsMetadata`). It must therefore never emit a runtime side-effect + * import — a bare `import "@databricks/appkit-ui/react"` would execute the + * client package entry on the Node server. The header is a type-only + * `import type {} from "..."`, which (a) compiles to zero runtime code and + * (b) anchors the module so the global `declare module` augmentation resolves. + */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { From cfd233a7bd4a0e7f805d8264b3f92a7b5ee35a53 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 30 Jul 2026 10:48:23 +0200 Subject: [PATCH 12/28] docs: drop non-existent autoStart option from useMetricView table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useMetricView never implemented autoStart — the row was copied from useAnalyticsQuery's options table. The hook's effect calls start() unconditionally and UseMetricViewOptions has no such field. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/docs/plugins/analytics.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 26e2b6cd0..ea301b920 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -521,7 +521,6 @@ When `"revenue"` is a key in the generated `MetricRegistry` (see [Metric-view ty | `timeGrain` | `string` | no | Bucket a time dimension (`day`, `month`, …). Requires `timeDimension`. Inferred `timeGrains`. | | `timeDimension` | `string` | no | The single dimension `timeGrain` buckets. Must be one of `dimensions`. | | `limit` | `number` | no | Positive integer row cap. | -| `autoStart` | `boolean` | no | Start the query on mount. Default `true`. | **Return type:** From 5c8114ab72158d90421dd3812104eb5947f4ad3b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 30 Jul 2026 13:17:05 +0200 Subject: [PATCH 13/28] refactor(analytics): consolidate shared logic - Centralize analytics SSE parsing across React hooks - Share metric filter types and runtime operator vocabulary - Reuse metadata and label formatters; refresh size baseline Signed-off-by: Atila Fassina --- bundle-size-baseline.json | 126 +++++----- .../src/js/metric-filter/index.test.ts | 20 +- .../appkit-ui/src/js/metric-filter/index.ts | 38 +-- .../hooks/__tests__/analytics-sse.test.ts | 208 +++++++++++++++++ .../src/react/hooks/analytics-sse.ts | 216 ++++++++++++++++++ .../src/react/hooks/use-analytics-query.ts | 171 ++------------ .../src/react/hooks/use-metric-view.ts | 168 +++----------- .../appkit-ui/src/react/lib/format.test.ts | 16 ++ packages/appkit-ui/src/react/lib/format.ts | 9 +- .../src/plugins/analytics/mv/constants.ts | 54 ++--- .../src/plugins/analytics/tests/types.test.ts | 19 ++ .../appkit/src/plugins/analytics/types.ts | 49 +--- .../mv-registry/render-types.ts | 62 ++--- packages/shared/src/index.ts | 1 + packages/shared/src/metric-filter.ts | 84 +++++++ 15 files changed, 748 insertions(+), 493 deletions(-) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/analytics-sse.ts create mode 100644 packages/appkit-ui/src/react/lib/format.test.ts create mode 100644 packages/appkit/src/plugins/analytics/tests/types.test.ts create mode 100644 packages/shared/src/metric-filter.ts diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 2780cebb5..65494ae5c 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 781571, - "unpacked": 2737743 + "packed": 822780, + "unpacked": 2869800 }, "dist": { "total": { - "raw": 2724086, - "gzip": 913249 + "raw": 2856143, + "gzip": 958494 }, "js": { - "raw": 809876, - "gzip": 282994 + "raw": 844411, + "gzip": 295130 }, "types": { - "raw": 291641, - "gzip": 99520 + "raw": 311200, + "gzip": 106583 }, "maps": { - "raw": 1611774, - "gzip": 526917 + "raw": 1689737, + "gzip": 552963 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 551 + "fileCount": 563 }, "entries": [ { "id": ".", - "gzip": 90672, + "gzip": 91159, "composition": { - "initialGzip": 88098, + "initialGzip": 88585, "lazyGzip": 2574, - "totalGzip": 90672, - "own": 288090, + "totalGzip": 91159, + "own": 289429, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 84000, + "gzip": 84487, "kind": "initial" }, { @@ -64,27 +64,42 @@ }, { "id": "./beta", - "gzip": 40758, + "gzip": 45645, "composition": { - "initialGzip": 40527, - "lazyGzip": 231, - "totalGzip": 40758, - "own": 121992, + "initialGzip": 45216, + "lazyGzip": 429, + "totalGzip": 45645, + "own": 131711, "nodeModules": null, "chunks": [ { "label": "beta.js", - "gzip": 31149, + "gzip": 29230, + "kind": "initial" + }, + { + "label": "stream-manager.js", + "gzip": 5948, + "kind": "initial" + }, + { + "label": "wide-event-emitter.js", + "gzip": 3239, "kind": "initial" }, { "label": "databricks.js", - "gzip": 5928, + "gzip": 3107, + "kind": "initial" + }, + { + "label": "configuration.js", + "gzip": 2104, "kind": "initial" }, { "label": "service-context.js", - "gzip": 3230, + "gzip": 1368, "kind": "initial" }, { @@ -92,14 +107,19 @@ "gzip": 220, "kind": "initial" }, + { + "label": "supervisor-api.js", + "gzip": 184, + "kind": "lazy" + }, { "label": "databricks.js", - "gzip": 128, + "gzip": 132, "kind": "lazy" }, { "label": "index.js", - "gzip": 103, + "gzip": 113, "kind": "lazy" } ] @@ -107,17 +127,17 @@ }, { "id": "./type-generator", - "gzip": 19143, + "gzip": 19377, "composition": { - "initialGzip": 19143, + "initialGzip": 19377, "lazyGzip": 0, - "totalGzip": 19143, - "own": 55109, + "totalGzip": 19377, + "own": 55765, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 19143, + "gzip": 19377, "kind": "initial" } ] @@ -128,25 +148,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 312421, - "unpacked": 1300636 + "packed": 342438, + "unpacked": 1390991 }, "dist": { "total": { - "raw": 1296690, - "gzip": 431573 + "raw": 1387045, + "gzip": 465995 }, "js": { - "raw": 367812, - "gzip": 122278 + "raw": 390404, + "gzip": 131301 }, "types": { - "raw": 210200, - "gzip": 76204 + "raw": 230599, + "gzip": 83923 }, "maps": { - "raw": 701818, - "gzip": 229745 + "raw": 749182, + "gzip": 247425 }, "css": { "raw": 16860, @@ -156,22 +176,22 @@ "raw": 0, "gzip": 0 }, - "fileCount": 472 + "fileCount": 490 }, "entries": [ { "id": "./js", - "gzip": 4254, + "gzip": 4914, "composition": { - "initialGzip": 4410, + "initialGzip": 5069, "lazyGzip": 50587, - "totalGzip": 54997, - "own": 11865, + "totalGzip": 55656, + "own": 13629, "nodeModules": 213288, "chunks": [ { "label": "index.js", - "gzip": 4290, + "gzip": 4949, "kind": "initial" }, { @@ -207,17 +227,17 @@ }, { "id": "./react", - "gzip": 47419, + "gzip": 48938, "composition": { - "initialGzip": 439507, + "initialGzip": 440934, "lazyGzip": 49772, - "totalGzip": 489279, - "own": 171753, - "nodeModules": 1403070, + "totalGzip": 490706, + "own": 176021, + "nodeModules": 1403082, "chunks": [ { "label": "index.js", - "gzip": 437357, + "gzip": 438784, "kind": "initial" }, { diff --git a/packages/appkit-ui/src/js/metric-filter/index.test.ts b/packages/appkit-ui/src/js/metric-filter/index.test.ts index 0bd8cadfc..508da2ab9 100644 --- a/packages/appkit-ui/src/js/metric-filter/index.test.ts +++ b/packages/appkit-ui/src/js/metric-filter/index.test.ts @@ -1,7 +1,23 @@ -import { describe, expect, test } from "vitest"; -import { type MetricFilter, toMetricFilter } from "./index"; +import type { + MetricFilter as SharedMetricFilter, + MetricFilterOperatorName as SharedMetricFilterOperatorName, + MetricPredicate as SharedMetricPredicate, +} from "shared"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + type MetricFilter, + type MetricFilterOperatorName, + type MetricPredicate, + toMetricFilter, +} from "./index"; describe("toMetricFilter", () => { + test("re-exports the shared metric-filter AST types", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + test("returns undefined for an empty selection", () => { expect(toMetricFilter({})).toBeUndefined(); }); diff --git a/packages/appkit-ui/src/js/metric-filter/index.ts b/packages/appkit-ui/src/js/metric-filter/index.ts index 96409ee3c..0371173b0 100644 --- a/packages/appkit-ui/src/js/metric-filter/index.ts +++ b/packages/appkit-ui/src/js/metric-filter/index.ts @@ -1,36 +1,10 @@ -// ──────────────────────────────────────────────────────────────────────────── -// Metric filter vocabulary + builder. -// -// **Kept in sync with appkit `plugins/analytics/types.ts`** — appkit-ui cannot -// depend on appkit, so this mirrors the twelve-operator filter grammar by hand. -// ──────────────────────────────────────────────────────────────────────────── +import type { MetricFilter, MetricPredicate } from "shared"; -export type MetricFilterOperatorName = - | "equals" - | "notEquals" - | "in" - | "notIn" - | "gt" - | "gte" - | "lt" - | "lte" - | "contains" - | "notContains" - | "set" - | "notSet"; - -/** A single filter predicate — the leaf node of the recursive {@link MetricFilter} tree. */ -export interface MetricPredicate { - member: string; - operator: MetricFilterOperatorName; - values?: ReadonlyArray; -} - -/** Recursive filter expression: a leaf {@link MetricPredicate} or an `and`/`or` group. */ -export type MetricFilter = - | MetricPredicate - | { and: ReadonlyArray } - | { or: ReadonlyArray }; +export type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "shared"; /** * Shorthand map of `dimension -> selected value(s)` that {@link toMetricFilter} diff --git a/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts new file mode 100644 index 000000000..6f1757f5e --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/analytics-sse.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test, vi } from "vitest"; +import { + type AnalyticsSseHandlerContext, + GENERIC_LOAD_ERROR, + handleAnalyticsSseError, + handleAnalyticsSseMessage, + parseAnalyticsSseMessage, + userFacingFetchError, +} from "../analytics-sse"; + +function createContext(overrides: Partial = {}) { + const controller = new AbortController(); + const abort = vi.fn(() => controller.abort()); + const context: AnalyticsSseHandlerContext = { + source: "useAnalyticsQuery", + resource: { queryKey: "orders" }, + defaultExecutionError: "Unable to execute query", + unpublishOnMalformedMessage: false, + signal: controller.signal, + abort, + setLoading: vi.fn(), + setError: vi.fn(), + setErrorCode: vi.fn(), + onWarehouseStatus: vi.fn(), + onResult: vi.fn(), + unpublishWarehouseStatus: vi.fn(), + ...overrides, + }; + return { abort, context, controller }; +} + +describe("analytics SSE parsing", () => { + test("classifies warehouse status, normalized results, and structured errors", () => { + expect( + parseAnalyticsSseMessage( + JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 1200 }, + }), + "fallback", + ), + ).toEqual({ + kind: "warehouse-status", + status: { state: "STARTING", elapsedMs: 1200 }, + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "result", metadata: { amount: {} } }), + "fallback", + ), + ).toEqual({ + kind: "result", + data: [], + payload: { type: "result", metadata: { amount: {} } }, + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ + type: "error", + message: "Query failed", + code: "UPSTREAM_ERROR", + errorCode: "STATEMENT_FAILED", + }), + "fallback", + ), + ).toEqual({ + kind: "error", + message: "Query failed", + code: "UPSTREAM_ERROR", + errorCode: "STATEMENT_FAILED", + }); + }); + + test("classifies malformed warehouse status and unknown payloads as invalid", () => { + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "warehouse_status" }), + "fallback", + ), + ).toMatchObject({ + kind: "invalid", + reason: "malformed-warehouse-status", + }); + + expect( + parseAnalyticsSseMessage( + JSON.stringify({ type: "heartbeat" }), + "fallback", + ), + ).toMatchObject({ kind: "invalid", reason: "unrecognized" }); + }); +}); + +describe("analytics SSE handling", () => { + test("applies common success state and delegates result-specific fields", async () => { + const { context } = createContext(); + + await handleAnalyticsSseMessage( + JSON.stringify({ + type: "result", + data: [{ amount: 42 }], + metadata: { amount: { type: "LONG" } }, + }), + context, + ); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.onResult).toHaveBeenCalledWith({ + kind: "result", + data: [{ amount: 42 }], + payload: { + type: "result", + data: [{ amount: 42 }], + metadata: { amount: { type: "LONG" } }, + }, + }); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + expect(context.setError).not.toHaveBeenCalled(); + }); + + test("surfaces server errors and their structured code", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { abort, context } = createContext(); + + await handleAnalyticsSseMessage( + JSON.stringify({ + type: "error", + error: "Server is at capacity", + code: "UPSTREAM_ERROR", + errorCode: "WAREHOUSE_CAPACITY", + }), + context, + ); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith("Server is at capacity"); + expect(context.setErrorCode).toHaveBeenCalledWith("WAREHOUSE_CAPACITY"); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + expect(abort).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "[useAnalyticsQuery] Code: UPSTREAM_ERROR, Message: Server is at capacity", + ); + errorSpy.mockRestore(); + }); + + test("terminates malformed streams with the generic user-facing error", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { abort, context, controller } = createContext(); + + await handleAnalyticsSseMessage("not-json{", context); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith(GENERIC_LOAD_ERROR); + expect(context.unpublishWarehouseStatus).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledOnce(); + expect(controller.signal.aborted).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + "[useAnalyticsQuery] Malformed message received", + expect.any(SyntaxError), + ); + warnSpy.mockRestore(); + }); + + test("retains metric-view warehouse cleanup for malformed streams", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { context } = createContext({ + source: "useMetricView", + unpublishOnMalformedMessage: true, + }); + + await handleAnalyticsSseMessage("not-json{", context); + + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + warnSpy.mockRestore(); + }); + + test("maps transport failures and ignores errors after abort", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { context, controller } = createContext(); + + handleAnalyticsSseError(new Error("Failed to fetch"), context); + + expect(context.setLoading).toHaveBeenCalledWith(false); + expect(context.setError).toHaveBeenCalledWith( + "Network error. Please check your connection.", + ); + expect(context.unpublishWarehouseStatus).toHaveBeenCalledOnce(); + + vi.mocked(context.setError).mockClear(); + controller.abort(); + handleAnalyticsSseError(new Error("late failure"), context); + expect(context.setError).not.toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); +}); + +test("maps timeout and unknown failures to the existing user-facing messages", () => { + const timeout = new Error("aborted"); + timeout.name = "AbortError"; + + expect(userFacingFetchError(timeout)).toBe( + "Request timed out, please try again", + ); + expect(userFacingFetchError(new Error("other"))).toBe(GENERIC_LOAD_ERROR); +}); diff --git a/packages/appkit-ui/src/react/hooks/analytics-sse.ts b/packages/appkit-ui/src/react/hooks/analytics-sse.ts new file mode 100644 index 000000000..608adb450 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/analytics-sse.ts @@ -0,0 +1,216 @@ +import type { WarehouseStatus } from "./types"; + +export const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; + +export function getDevMode(): string { + const dev = new URL(window.location.href).searchParams.get("dev"); + return dev ? `?dev=${dev}` : ""; +} + +/** Map a fetch/SSE transport error to a user-facing message. */ +export function userFacingFetchError(error: unknown): string { + if (error instanceof Error) { + if (error.name === "AbortError") { + return "Request timed out, please try again"; + } + if (error.message.includes("Failed to fetch")) { + return "Network error. Please check your connection."; + } + } + return GENERIC_LOAD_ERROR; +} + +interface WarehouseStatusMessage { + kind: "warehouse-status"; + status: WarehouseStatus; +} + +export interface AnalyticsSseResultMessage { + kind: "result"; + data: unknown[]; + payload: Record; +} + +interface AnalyticsSseErrorMessage { + kind: "error"; + message: string; + errorCode: string | null; + code: unknown; +} + +interface InvalidAnalyticsSseMessage { + kind: "invalid"; + reason: "malformed-warehouse-status" | "unrecognized"; + payload: unknown; +} + +type AnalyticsSseMessage = + | WarehouseStatusMessage + | AnalyticsSseResultMessage + | AnalyticsSseErrorMessage + | InvalidAnalyticsSseMessage; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { + return ( + typeof value === "object" && + value !== null && + typeof (value as WarehouseStatus).state === "string" + ); +} + +/** + * Parse and classify the deliberately loose analytics SSE wire format. + * Result rows normalize to an empty array so hook state remains `T | null`. + */ +export function parseAnalyticsSseMessage( + data: string, + defaultExecutionError: string, +): AnalyticsSseMessage { + const parsed: unknown = JSON.parse(data); + + if (!isRecord(parsed)) { + return { kind: "invalid", reason: "unrecognized", payload: parsed }; + } + + if (parsed.type === "warehouse_status") { + if (!isWarehouseStatusPayload(parsed.status)) { + return { + kind: "invalid", + reason: "malformed-warehouse-status", + payload: parsed, + }; + } + return { kind: "warehouse-status", status: parsed.status }; + } + + if (parsed.type === "result") { + return { + kind: "result", + data: Array.isArray(parsed.data) ? parsed.data : [], + payload: parsed, + }; + } + + if (parsed.type === "error" || parsed.error || parsed.code) { + const message = + (typeof parsed.error === "string" && parsed.error) || + (typeof parsed.message === "string" && parsed.message) || + defaultExecutionError; + return { + kind: "error", + message, + errorCode: typeof parsed.errorCode === "string" ? parsed.errorCode : null, + code: parsed.code, + }; + } + + return { kind: "invalid", reason: "unrecognized", payload: parsed }; +} + +export interface AnalyticsSseHandlerContext { + source: "useAnalyticsQuery" | "useMetricView"; + resource: Record; + defaultExecutionError: string; + unpublishOnMalformedMessage: boolean; + signal: AbortSignal; + abort: () => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setErrorCode: (code: string | null) => void; + onWarehouseStatus: (status: WarehouseStatus) => void; + onResult: (message: AnalyticsSseResultMessage) => void; + unpublishWarehouseStatus: () => void; +} + +function failWithGenericError(ctx: AnalyticsSseHandlerContext): void { + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); + ctx.unpublishWarehouseStatus(); +} + +/** + * Apply the state transitions shared by analytics-query and metric-view SSE + * messages while delegating their distinct result/status state to callbacks. + */ +export async function handleAnalyticsSseMessage( + data: string, + ctx: AnalyticsSseHandlerContext, +): Promise { + if (ctx.signal.aborted) return; + + try { + const message = parseAnalyticsSseMessage(data, ctx.defaultExecutionError); + + if (message.kind === "warehouse-status") { + ctx.onWarehouseStatus(message.status); + return; + } + + if (message.kind === "result") { + ctx.setLoading(false); + ctx.onResult(message); + ctx.unpublishWarehouseStatus(); + return; + } + + if (message.kind === "error") { + ctx.setLoading(false); + ctx.setError(message.message); + ctx.unpublishWarehouseStatus(); + if (message.errorCode !== null) { + ctx.setErrorCode(message.errorCode); + } + if (message.code) { + console.error( + `[${ctx.source}] Code: ${String(message.code)}, Message: ${message.message}`, + ); + } + return; + } + + if (message.reason === "malformed-warehouse-status") { + console.error( + `[${ctx.source}] Malformed warehouse_status event`, + message.payload, + ); + } else { + console.error( + `[${ctx.source}] Unrecognized SSE payload`, + message.payload, + ); + } + failWithGenericError(ctx); + } catch (error) { + console.warn(`[${ctx.source}] Malformed message received`, error); + ctx.setLoading(false); + ctx.setError(GENERIC_LOAD_ERROR); + if (ctx.unpublishOnMalformedMessage) { + ctx.unpublishWarehouseStatus(); + } + ctx.abort(); + } +} + +/** Apply the shared terminal state for an SSE connection failure. */ +export function handleAnalyticsSseError( + error: unknown, + ctx: AnalyticsSseHandlerContext, +): void { + if (ctx.signal.aborted) return; + + ctx.setLoading(false); + ctx.unpublishWarehouseStatus(); + + if (error instanceof Error) { + console.error(`[${ctx.source}] Error`, { + ...ctx.resource, + error: error.message, + stack: error.stack, + }); + } + ctx.setError(userFacingFetchError(error)); +} diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts index 1c63ffe14..93b3dba36 100644 --- a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts @@ -7,6 +7,14 @@ import { useState, } from "react"; import { ArrowClient, connectSSE } from "@/js"; +import { + type AnalyticsSseHandlerContext, + GENERIC_LOAD_ERROR, + getDevMode, + handleAnalyticsSseError, + handleAnalyticsSseMessage, + userFacingFetchError, +} from "./analytics-sse"; import type { AnalyticsFormat, InferParams, @@ -56,112 +64,6 @@ function useStableParams(value: T): T { return ref.current; } -function getDevMode(): string { - const dev = new URL(window.location.href).searchParams.get("dev"); - return dev ? `?dev=${dev}` : ""; -} - -const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; - -/** Map a fetch/SSE transport error to a user-facing message. */ -function userFacingFetchError(error: unknown): string { - if (error instanceof Error) { - if (error.name === "AbortError") { - return "Request timed out, please try again"; - } - if (error.message.includes("Failed to fetch")) { - return "Network error. Please check your connection."; - } - } - return GENERIC_LOAD_ERROR; -} - -interface AnalyticsQuerySseContext { - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setErrorCode: (code: string | null) => void; - setData: (data: ResultType | null) => void; - setWarehouseStatus: (status: WarehouseStatus | null) => void; - publishWarehouseStatus: (status: WarehouseStatus | null) => void; - unpublishWarehouseStatus: () => void; -} - -function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { - return ( - typeof value === "object" && - value !== null && - typeof (value as WarehouseStatus).state === "string" - ); -} - -async function handleAnalyticsSseMessage( - parsed: Record, - ctx: AnalyticsQuerySseContext, -): Promise { - if (parsed.type === "warehouse_status") { - if (!isWarehouseStatusPayload(parsed.status)) { - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); - console.error( - "[useAnalyticsQuery] Malformed warehouse_status event", - parsed, - ); - return; - } - ctx.setWarehouseStatus(parsed.status); - ctx.publishWarehouseStatus(parsed.status); - return; - } - - // JSON result. The SSE wire schema is intentionally loose (`data` is an - // optional array of unknown values), so a structural check is enough here — - // no need to ship a schema validator (zod, ~60 KB gz) to the browser just - // to read our own same-origin server's messages. Missing or non-array - // `data` normalizes to [] so `undefined` never bleeds into the hook's - // `T | null` state. - if (parsed.type === "result") { - ctx.setLoading(false); - ctx.setData((Array.isArray(parsed.data) ? parsed.data : []) as ResultType); - ctx.unpublishWarehouseStatus(); - return; - } - - // NOTE: ARROW_STREAM no longer flows over SSE — the server streams the - // raw Arrow IPC bytes back as the query response body, handled by - // `fetchArrowDirect` instead of this SSE handler. - - if (parsed.type === "error" || parsed.error || parsed.code) { - const errorMsg = - (parsed.error as string | undefined) || - (parsed.message as string | undefined) || - "Unable to execute query"; - ctx.setLoading(false); - ctx.setError(errorMsg); - ctx.unpublishWarehouseStatus(); - // Propagate the upstream structured code so UI consumers can branch on - // a stable identifier (e.g. format-switch on - // RESULT_TOO_LARGE_FOR_JSON_FALLBACK or ARROW_DELIVERY_UNSUPPORTED) - // instead of parsing the human-readable message. - if (typeof parsed.errorCode === "string") { - ctx.setErrorCode(parsed.errorCode); - } - if (parsed.code) { - console.error( - `[useAnalyticsQuery] Code: ${parsed.code}, Message: ${errorMsg}`, - ); - } - return; - } - - // Not a warehouse-status, result, or error event — surface a generic error - // rather than silently dropping an unrecognized payload. - console.error("[useAnalyticsQuery] Unrecognized SSE payload", parsed); - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); -} - interface ArrowDirectContext { url: string; payload: string; @@ -392,13 +294,21 @@ export function useAnalyticsQuery< return; } - const sseContext: AnalyticsQuerySseContext = { + const sseContext: AnalyticsSseHandlerContext = { + source: "useAnalyticsQuery", + resource: { queryKey }, + defaultExecutionError: "Unable to execute query", + unpublishOnMalformedMessage: false, + signal: abortController.signal, + abort: () => abortController.abort(), setLoading, setError, setErrorCode, - setData, - setWarehouseStatus, - publishWarehouseStatus, + onWarehouseStatus: (status) => { + setWarehouseStatus(status); + publishWarehouseStatus(status); + }, + onResult: (message) => setData(message.data as ResultType), unpublishWarehouseStatus, }; @@ -406,44 +316,9 @@ export function useAnalyticsQuery< url: urlSuffix, payload, signal: abortController.signal, - onMessage: async (message) => { - // Drop late envelopes from a stream whose controller was already - // aborted (React StrictMode unmount→remount). Mirrors onError below. - if (abortController.signal.aborted) return; - try { - const parsed = JSON.parse(message.data) as Record; - await handleAnalyticsSseMessage(parsed, sseContext); - } catch (error) { - // A `JSON.parse` failure (or any other thrown error inside the - // SSE message handler) used to leave the hook permanently in - // `loading=true` with no error surfaced — the UI would just - // spin forever. Clear loading and report a user-facing error - // so the consumer can render a retry affordance. - // - // We also abort the SSE connection: if the upstream is - // emitting un-parseable frames, leaving the stream open just - // re-fires the same failure on the next message. Closing - // forces the consumer into a clean retry path. - console.warn("[useAnalyticsQuery] Malformed message received", error); - setLoading(false); - setError(GENERIC_LOAD_ERROR); - abortController.abort(); - } - }, - onError: (error) => { - if (abortController.signal.aborted) return; - setLoading(false); - unpublishWarehouseStatus(); - - if (error instanceof Error) { - console.error("[useAnalyticsQuery] Error", { - queryKey, - error: error.message, - stack: error.stack, - }); - } - setError(userFacingFetchError(error)); - }, + onMessage: (message) => + handleAnalyticsSseMessage(message.data, sseContext), + onError: (error) => handleAnalyticsSseError(error, sseContext), }); }, [ queryKey, diff --git a/packages/appkit-ui/src/react/hooks/use-metric-view.ts b/packages/appkit-ui/src/react/hooks/use-metric-view.ts index b2e21bacd..0e1c77fa3 100644 --- a/packages/appkit-ui/src/react/hooks/use-metric-view.ts +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -8,6 +8,12 @@ import { } from "react"; import type { MetricColumnMeta } from "shared"; import { connectSSE } from "@/js"; +import { + type AnalyticsSseHandlerContext, + getDevMode, + handleAnalyticsSseError, + handleAnalyticsSseMessage, +} from "./analytics-sse"; import type { InferDimensionKeys, InferMeasureKeys, @@ -15,48 +21,10 @@ import type { PickMetricRow, UseMetricViewOptions, UseMetricViewResult, - WarehouseStatus, } from "./types"; import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status"; import { useQueryHMR } from "./use-query-hmr"; -function getDevMode(): string { - const dev = new URL(window.location.href).searchParams.get("dev"); - return dev ? `?dev=${dev}` : ""; -} - -const GENERIC_LOAD_ERROR = "Unable to load data, please try again"; - -function userFacingFetchError(error: unknown): string { - if (error instanceof Error) { - if (error.name === "AbortError") { - return "Request timed out, please try again"; - } - if (error.message.includes("Failed to fetch")) { - return "Network error. Please check your connection."; - } - } - return GENERIC_LOAD_ERROR; -} - -interface MetricSseContext { - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setErrorCode: (code: string | null) => void; - setData: (data: Record[] | null) => void; - setMetadata: (metadata: Record | undefined) => void; - publishWarehouseStatus: (status: WarehouseStatus | null) => void; - unpublishWarehouseStatus: () => void; -} - -function isWarehouseStatusPayload(value: unknown): value is WarehouseStatus { - return ( - typeof value === "object" && - value !== null && - typeof (value as WarehouseStatus).state === "string" - ); -} - /** * Narrow the wire `metadata` field to a per-column map. The value is only a * meaningful metadata map when it is a plain object; a `null`, array, or scalar @@ -72,72 +40,6 @@ function asMetricMetadata( return undefined; } -function handleMetricSseMessage( - parsed: Record, - ctx: MetricSseContext, -): void { - // Warehouse-readiness progress. The metric result type does NOT expose - // warehouseStatus, so we keep the hook in its loading state (no caller-facing - // field) but publish the status to the shared ResourceStatusProvider — the - // same side-channel `useAnalyticsQuery` uses to drive a global "warehouse - // starting…" indicator during a cold start. This is a publish-only path: it - // never mutates UseMetricViewResult. - if (parsed.type === "warehouse_status") { - if (!isWarehouseStatusPayload(parsed.status)) { - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); - console.error("[useMetricView] Malformed warehouse_status event", parsed); - return; - } - ctx.publishWarehouseStatus(parsed.status); - return; - } - - // JSON result. The SSE wire schema is intentionally loose (`data` is an - // optional array of unknown values), so a shallow structural check is enough - // here rather than a full schema validator. Missing or non-array `data` - // normalizes to [] so `undefined` never bleeds into the hook's `T | null` - // state. `metadata` is narrowed by `asMetricMetadata` (see its doc). - if (parsed.type === "result") { - ctx.setLoading(false); - // A successful result supersedes any error from a prior (retried) attempt — - // clear it so error-first consumers don't hide valid data. - ctx.setError(null); - ctx.setErrorCode(null); - ctx.setData(Array.isArray(parsed.data) ? parsed.data : []); - ctx.setMetadata(asMetricMetadata(parsed.metadata)); - ctx.unpublishWarehouseStatus(); - return; - } - - if (parsed.type === "error" || parsed.error || parsed.code) { - const errorMsg = - (parsed.error as string | undefined) || - (parsed.message as string | undefined) || - "Unable to execute metric query"; - ctx.setLoading(false); - ctx.setError(errorMsg); - ctx.unpublishWarehouseStatus(); - // Propagate the upstream structured code so UI consumers can branch on a - // stable identifier instead of parsing the human-readable message. - if (typeof parsed.errorCode === "string") { - ctx.setErrorCode(parsed.errorCode); - } - if (parsed.code) { - console.error( - `[useMetricView] Code: ${parsed.code}, Message: ${errorMsg}`, - ); - } - return; - } - - console.error("[useMetricView] Unrecognized SSE payload", parsed); - ctx.setLoading(false); - ctx.setError(GENERIC_LOAD_ERROR); - ctx.unpublishWarehouseStatus(); -} - /** * Subscribe to a Unity Catalog metric view and return its latest result. * POSTs the structured `{ measures, dimensions, filter, timeGrain, @@ -248,13 +150,26 @@ export function useMetricView< const abortController = new AbortController(); abortControllerRef.current = abortController; - const sseContext: MetricSseContext = { + const sseContext: AnalyticsSseHandlerContext = { + source: "useMetricView", + resource: { key }, + defaultExecutionError: "Unable to execute metric query", + unpublishOnMalformedMessage: true, + signal: abortController.signal, + abort: () => abortController.abort(), setLoading, setError, setErrorCode, - setData: (rows) => setData(rows as Rows | null), - setMetadata, - publishWarehouseStatus, + // Metric results expose warehouse readiness only through the shared + // resource-status publisher, not through UseMetricViewResult. + onWarehouseStatus: publishWarehouseStatus, + onResult: (message) => { + // A successful result supersedes a prior retried error. + setError(null); + setErrorCode(null); + setData(message.data as Rows); + setMetadata(asMetricMetadata(message.payload.metadata)); + }, unpublishWarehouseStatus, }; @@ -262,40 +177,9 @@ export function useMetricView< url: urlSuffix, payload, signal: abortController.signal, - onMessage: async (message) => { - // Drop late envelopes from a stream whose controller was already - // aborted (React StrictMode unmount→remount). Mirrors onError below. - if (abortController.signal.aborted) return; - try { - const parsed = JSON.parse(message.data) as Record; - handleMetricSseMessage(parsed, sseContext); - } catch (error) { - // A `JSON.parse` failure (or any other thrown error inside the SSE - // message handler) must not strand the hook in `loading=true` with - // no error surfaced — the UI would spin forever. Clear loading, - // report a user-facing error, and abort the stream so a broken - // upstream doesn't re-fire the same failure on every frame. - console.warn("[useMetricView] Malformed message received", error); - setLoading(false); - setError(GENERIC_LOAD_ERROR); - unpublishWarehouseStatus(); - abortController.abort(); - } - }, - onError: (error) => { - if (abortController.signal.aborted) return; - setLoading(false); - unpublishWarehouseStatus(); - - if (error instanceof Error) { - console.error("[useMetricView] Error", { - key, - error: error.message, - stack: error.stack, - }); - } - setError(userFacingFetchError(error)); - }, + onMessage: (message) => + handleAnalyticsSseMessage(message.data, sseContext), + onError: (error) => handleAnalyticsSseError(error, sseContext), }); }, [ key, diff --git a/packages/appkit-ui/src/react/lib/format.test.ts b/packages/appkit-ui/src/react/lib/format.test.ts new file mode 100644 index 000000000..cff9dc0ca --- /dev/null +++ b/packages/appkit-ui/src/react/lib/format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { formatFieldLabel } from "./format"; + +describe("formatFieldLabel", () => { + test.each([ + ["totalCost", "Total Cost"], + ["user_name", "User Name"], + ["userID", "User Id"], + ["getHTTPUrl", "Get Http Url"], + ["TOTAL_SPEND", "Total Spend"], + ["", ""], + ['', ''], + ])("formats %j as %j", (field, expected) => { + expect(formatFieldLabel(field)).toBe(expected); + }); +}); diff --git a/packages/appkit-ui/src/react/lib/format.ts b/packages/appkit-ui/src/react/lib/format.ts index 3dceed51f..4fa51416a 100644 --- a/packages/appkit-ui/src/react/lib/format.ts +++ b/packages/appkit-ui/src/react/lib/format.ts @@ -1,3 +1,5 @@ +import { formatLabel } from "../../js/format"; + /** * Formats numeric values based on field name context * @param value - The numeric value to format @@ -46,12 +48,7 @@ export function formatChartValue(value: number, fieldName: string): string { * formatFieldLabel("revenue") // "Revenue" */ export function formatFieldLabel(field: string): string { - const safe = field.replace(/[^a-zA-Z0-9_-]/g, ""); - return safe - .replace(/([A-Z])/g, " $1") - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()) - .trim(); + return formatLabel(field); } /** diff --git a/packages/appkit/src/plugins/analytics/mv/constants.ts b/packages/appkit/src/plugins/analytics/mv/constants.ts index f62214d19..815d0631f 100644 --- a/packages/appkit/src/plugins/analytics/mv/constants.ts +++ b/packages/appkit/src/plugins/analytics/mv/constants.ts @@ -1,11 +1,23 @@ import { METRIC_CONFIG_FILE } from "../../../../../shared/src/schemas/metric-fqn"; -import type { MetricFilterOperatorName, MetricLane } from "../types"; +import type { MetricLane } from "../types"; // Re-exported from the shared zod-free module (single source of truth for the // `definitions.json` basename) so analytics-local callers keep importing it // from this barrel. export { METRIC_CONFIG_FILE }; +// The filter-operator vocabulary lives canonically in the shared zod-free +// module (single source of truth for both the runtime tuple and the derived +// type union). Re-exported here so analytics-local callers (`schemas.ts`, +// `formatters.ts`) keep importing operators + subsets from this barrel. +export { + LIST_VALUE_OPERATORS, + METRIC_FILTER_OPERATORS, + NULL_OPERATORS, + SINGLE_VALUE_OPERATORS, + STRING_OPERATORS, +} from "shared"; + /** * Measure, dimension, and filter-member names are **column identifiers**: they * are validated by the shared {@link isValidColumnName} (rejects only control @@ -41,35 +53,6 @@ export const METRIC_LIMIT_MAX = 100_000; */ export const METRIC_FILTER_GROUP_MAX = 100; -/** Operators that require at least one value. */ -export const LIST_VALUE_OPERATORS = new Set([ - "in", - "notIn", -]); - -/** Operators that reject `values` entirely. */ -export const NULL_OPERATORS = new Set([ - "set", - "notSet", -]); - -/** Operators that emit `LIKE` / `NOT LIKE` and require a string value. */ -export const STRING_OPERATORS = new Set([ - "contains", - "notContains", -]); - -/** Operators that require exactly one value. */ -export const SINGLE_VALUE_OPERATORS = new Set([ - "equals", - "notEquals", - "gt", - "gte", - "lt", - "lte", - ...STRING_OPERATORS, -]); - /** * Map an entry's declared `executor` to the internal execution lane: * - `"user"` → `"obo"` (per-user cache, on-behalf-of) @@ -80,14 +63,3 @@ export function laneFromExecutor( ): MetricLane { return executor === "user" ? "obo" : "sp"; } - -/** - * The exact twelve filter operators allowed at v1. The runtime tuple is the - * server-side source of truth; the client-side type union - * `MetricFilterOperatorName` mirrors these names statically. - */ -export const METRIC_FILTER_OPERATORS = [ - ...SINGLE_VALUE_OPERATORS, - ...LIST_VALUE_OPERATORS, - ...NULL_OPERATORS, -] as const satisfies readonly MetricFilterOperatorName[]; diff --git a/packages/appkit/src/plugins/analytics/tests/types.test.ts b/packages/appkit/src/plugins/analytics/tests/types.test.ts new file mode 100644 index 000000000..4f7289892 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/tests/types.test.ts @@ -0,0 +1,19 @@ +import type { + MetricFilter as SharedMetricFilter, + MetricFilterOperatorName as SharedMetricFilterOperatorName, + MetricPredicate as SharedMetricPredicate, +} from "shared"; +import { describe, expectTypeOf, test } from "vitest"; +import type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "../types"; + +describe("analytics metric-filter types", () => { + test("re-exports the shared AST types", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/appkit/src/plugins/analytics/types.ts b/packages/appkit/src/plugins/analytics/types.ts index c23c01029..d1c45c3ce 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,9 +1,16 @@ import type { BasePluginConfig, MetricColumnMeta, + MetricFilter, MetricViewsMetadata, } from "shared"; +export type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "shared"; + export interface IAnalyticsConfig extends BasePluginConfig { timeout?: number; /** @@ -172,48 +179,6 @@ export interface MetricRegistration { lane: MetricLane; } -/** - * v1 filter operator vocabulary — exactly twelve names. The runtime tuple - * `METRIC_FILTER_OPERATORS` (next to the validator in `metric.ts`) is the - * server-side source of truth; this union mirrors it statically. - */ -export type MetricFilterOperatorName = - | "equals" - | "notEquals" - | "in" - | "notIn" - | "gt" - | "gte" - | "lt" - | "lte" - | "contains" - | "notContains" - | "set" - | "notSet"; - -/** - * A single filter predicate — the leaf node of the recursive - * {@link MetricFilter} tree. `member` is a dimension name (grammar-gated, not - * allowlisted); `values` is bound through parameterized `:f_` bind vars - * and never interpolated into the SQL string. - */ -export interface MetricPredicate { - member: string; - operator: MetricFilterOperatorName; - values?: ReadonlyArray; -} - -/** - * Recursive filter expression for the metric-view request body: a leaf - * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. The - * shape is intentionally non-generic server-side — per-metric narrowing (if - * any) lives client-side. - */ -export type MetricFilter = - | MetricPredicate - | { and: ReadonlyArray } - | { or: ReadonlyArray }; - /** * Validated request body for `POST /api/analytics/metric/:key`. * diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index a3fb2977c..c1614c229 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -116,6 +116,35 @@ function renderDegradedMetricEntry(schema: MetricSchema): string { }`; } +type RenderedMetadataField = readonly [name: string, value: string]; + +// Build the canonical rendered fields shared by type-level and runtime +// metadata. `time_grain` is type-only and is included only when requested. +function metadataFields( + col: MetricColumnMetadata, + includeTimeGrain = false, +): RenderedMetadataField[] { + const fields: RenderedMetadataField[] = [["type", JSON.stringify(col.type)]]; + const optionalFields = [ + ["display_name", col.displayName], + ["format", col.format], + ["description", col.description], + ] as const; + + for (const [name, value] of optionalFields) { + if (value) { + fields.push([name, JSON.stringify(value)]); + } + } + + if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { + const grainTuple = col.timeGrains.map((g) => JSON.stringify(g)).join(", "); + fields.push(["time_grain", `readonly [${grainTuple}]`]); + } + + return fields; +} + // Render the type-level shape of a column's semantic-metadata map // for the `metadata` field of a MetricRegistry entry. function renderMetadataMap( @@ -127,23 +156,9 @@ function renderMetadataMap( const inner = cols .map((col) => { - const fields: string[] = [`type: ${JSON.stringify(col.type)}`]; - if (col.displayName) { - fields.push(`display_name: ${JSON.stringify(col.displayName)}`); - } - if (col.format) { - fields.push(`format: ${JSON.stringify(col.format)}`); - } - if (col.description) { - fields.push(`description: ${JSON.stringify(col.description)}`); - } - if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { - const grainTuple = col.timeGrains - .map((g) => JSON.stringify(g)) - .join(", "); - fields.push(`time_grain: readonly [${grainTuple}]`); - } - const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(";\n"); + const fieldsBlock = metadataFields(col, includeTimeGrain) + .map(([name, value]) => `${indent} ${name}: ${value}`) + .join(";\n"); return `${indent}${JSON.stringify(col.name)}: { ${fieldsBlock}; ${indent}}`; @@ -161,16 +176,9 @@ ${inner}; // MetricColumnMeta). Strings go through JSON.stringify so quotes/backticks in // display_name/description stay escape-safe. function renderMetadataValueField(col: MetricColumnMetadata): string { - const fields: string[] = [`type: ${JSON.stringify(col.type)}`]; - if (col.displayName) { - fields.push(`display_name: ${JSON.stringify(col.displayName)}`); - } - if (col.format) { - fields.push(`format: ${JSON.stringify(col.format)}`); - } - if (col.description) { - fields.push(`description: ${JSON.stringify(col.description)}`); - } + const fields = metadataFields(col).map( + ([name, value]) => `${name}: ${value}`, + ); return `{ ${fields.join(", ")} }`; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4b7c08ba1..e1dfb7ac6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent"; export * from "./cache"; export * from "./execute"; export * from "./genie"; +export * from "./metric-filter"; export * from "./metric-metadata"; export * from "./plugin"; export * from "./sql"; diff --git a/packages/shared/src/metric-filter.ts b/packages/shared/src/metric-filter.ts new file mode 100644 index 000000000..6f20f9558 --- /dev/null +++ b/packages/shared/src/metric-filter.ts @@ -0,0 +1,84 @@ +// Metric-filter vocabulary — the single source of truth for the v1 filter +// grammar, shared by the appkit analytics runtime (validator + SQL renderer) +// and the appkit-ui client (which imports the types only). This module is +// zod-free so any consumer can import it without pulling zod into its graph, +// mirroring the sibling `metric-metadata.ts` contract. + +/** + * The exact twelve filter operators allowed at v1. This runtime tuple is the + * canonical source: {@link MetricFilterOperatorName} is derived from it, so the + * type union and the runtime list can never drift apart. + */ +export const METRIC_FILTER_OPERATORS = [ + "equals", + "notEquals", + "gt", + "gte", + "lt", + "lte", + "contains", + "notContains", + "in", + "notIn", + "set", + "notSet", +] as const; + +/** + * v1 filter operator vocabulary — exactly twelve names, derived from the + * {@link METRIC_FILTER_OPERATORS} tuple so the union stays in lockstep with the + * runtime list the validator checks against. + */ +export type MetricFilterOperatorName = (typeof METRIC_FILTER_OPERATORS)[number]; + +/** Operators that require at least one value. */ +export const LIST_VALUE_OPERATORS = new Set([ + "in", + "notIn", +]); + +/** Operators that reject `values` entirely. */ +export const NULL_OPERATORS = new Set([ + "set", + "notSet", +]); + +/** Operators that emit `LIKE` / `NOT LIKE` and require a string value. */ +export const STRING_OPERATORS = new Set([ + "contains", + "notContains", +]); + +/** Operators that require exactly one value. */ +export const SINGLE_VALUE_OPERATORS = new Set([ + "equals", + "notEquals", + "gt", + "gte", + "lt", + "lte", + ...STRING_OPERATORS, +]); + +/** + * A single filter predicate — the leaf node of the recursive + * {@link MetricFilter} tree. `member` is a dimension name (grammar-gated, not + * allowlisted); `values` is bound through parameterized `:f_` bind vars + * and never interpolated into the SQL string. + */ +export interface MetricPredicate { + member: string; + operator: MetricFilterOperatorName; + values?: ReadonlyArray; +} + +/** + * Recursive filter expression for the metric-view request body: a leaf + * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. The + * shape is intentionally non-generic server-side — per-metric narrowing (if + * any) lives client-side. + */ +export type MetricFilter = + | MetricPredicate + | { and: ReadonlyArray } + | { or: ReadonlyArray }; From 04c5aa71a8ed62661004a3e1279bb03edee84923 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 30 Jul 2026 14:34:18 +0200 Subject: [PATCH 14/28] docs: link Plotly/ECharts to their OSS docs; trim chart-datum comment - analytics.md: make the Plotly and ECharts chart-library references links to plotly.com/javascript and echarts.apache.org; minor wording tidy (hardcode). - charts/types.ts: trim the ChartClickDatum doc comment. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/docs/plugins/analytics.md | 6 +++--- packages/appkit-ui/src/react/charts/types.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index ea301b920..666643c44 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -558,7 +558,7 @@ createApp({ }); ``` -This is **pure response decoration**: the injected metadata never enters the cache key and never changes the SQL. With it wired, every metric `result` message carries a `metadata` field scoped to the requested columns; without it, the message is byte-identical to a plain `/query` result and the hook's `metadata` is `undefined`. Because the metadata rides on the payload, the client never has to import the generated file or hard-code a format string — it is **payload-carried and client-agnostic**. +This is **pure response decoration**: the injected metadata never enters the cache key and never changes the SQL. With it wired, every metric `result` message carries a `metadata` field scoped to the requested columns; without it, the message is byte-identical to a plain `/query` result and the hook's `metadata` is `undefined`. Because the metadata rides on the payload, the client never has to import the generated file or hardcode a format string — it is **payload-carried and client-agnostic**. ### Format utilities @@ -614,7 +614,7 @@ function RevenueTable() { Because `metadata[col].format` is just a string on the payload, the same spec drives axis ticks and tooltips in any chart library. -**Plotly** — pass the spec straight through as a d3 `tickformat` / `hovertemplate` (Plotly axes speak d3-format): +**[Plotly](https://plotly.com/javascript/)** — pass the spec straight through as a d3 `tickformat` / `hovertemplate` (Plotly axes speak d3-format): ```tsx import Plot from "react-plotly.js"; @@ -650,7 +650,7 @@ function RevenuePlot() { } ``` -**ECharts** — use the format spec inside `axisLabel.formatter` / `tooltip.formatter` via `formatValue`: +**[ECharts](https://echarts.apache.org/)** — use the format spec inside `axisLabel.formatter` / `tooltip.formatter` via `formatValue`: ```tsx import ReactECharts from "echarts-for-react"; diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 07b8a6f13..e0a1b25ac 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -117,9 +117,7 @@ export interface ChartBaseProps { /** * A normalized description of a clicked chart element. * - * This is the public, ECharts-free shape emitted by chart click handlers. It is - * the single boundary that keeps ECharts event types out of appkit-ui's public - * API — consumers should read the strongly-typed fields below and reach for + * Consumers should read the strongly-typed fields below and reach for * {@link ChartClickDatum.raw} only when they knowingly opt into unsupported * internals. * From ac24bc96808fa892b4670de99d82d8bb2a689830 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 09:56:11 +0200 Subject: [PATCH 15/28] docs: trim leftover phase labels and history narration from comments Co-authored-by: Isaac Signed-off-by: Atila Fassina --- apps/dev-playground/client/src/routes/metric-views.route.tsx | 4 ++-- packages/appkit-ui/src/react/charts/__tests__/utils.test.ts | 3 +-- packages/appkit-ui/src/react/charts/base.tsx | 4 ++-- .../src/react/hooks/__tests__/use-metric-view.test.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/dev-playground/client/src/routes/metric-views.route.tsx b/apps/dev-playground/client/src/routes/metric-views.route.tsx index 5b3a3d62d..626096b8e 100644 --- a/apps/dev-playground/client/src/routes/metric-views.route.tsx +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -268,7 +268,7 @@ function MetricViewsRoute() { tooltip="Each visual POSTs {measures, dimensions, filter} to /api/analytics/metric/revenue. Selections compose into a MetricFilter the server renders into a parameterized WHERE — the client never builds SQL." /> - {/* Filter bar (A): dropdowns write into the shared selection. The Region + {/* Filter bar: dropdowns write into the shared selection. The Region value is bound to selection.region, so it also reflects a table-row click below. */} @@ -454,7 +454,7 @@ function MetricViewsRoute() { - {/* Detail table (C): click a row to cross-filter by that region. */} + {/* Detail table: click a row to cross-filter by that region. */} Revenue detail by region diff --git a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts index c3001b02b..0e17c207d 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/utils.test.ts @@ -357,8 +357,7 @@ describe("mapToDatum", () => { }); test("splits an [x, y] tuple point into x/y and surfaces y as value", () => { - // A time-series point: value is [epochMs, amount]. Previously value became - // null and callers had to re-parse `raw`. + // A time-series point: value is [epochMs, amount]. const d = mapToDatum({ value: [1704067200000, 8_100_000], seriesName: "ARR", diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index ae7a90f88..4e817842e 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -408,8 +408,8 @@ export function BaseChart({ ? { click: (params: unknown) => { // Fire-and-forget: the datum callback may be async, and a rejected - // promise must not surface as an unhandled rejection (the docs - // promise async handlers are fine). Swallow rejections here. + // promise must not surface as an unhandled rejection. Swallow + // rejections here. const result = onDataClickRef.current?.( mapToDatum(params), ) as void | Promise; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts index d7a1f65e9..7f5585f20 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -223,7 +223,7 @@ describe("useMetricView", () => { // The event is published to the shared provider (driving a global // "warehouse starting…" indicator) but the metric result shape does NOT - // expose warehouseStatus (Phase 0 contract) and the hook stays loading. + // expose warehouseStatus and the hook stays loading. expect(mockPublishWarehouseStatus).toHaveBeenCalledWith(status); expect(mockUnpublishWarehouseStatus).not.toHaveBeenCalled(); expect(result.current).not.toHaveProperty("warehouseStatus"); From 4e1c4e34c0aa7c4a93a246c78ff52f9792cfe9f1 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 17:07:09 +0200 Subject: [PATCH 16/28] fix(appkit): --wait typegen never overwrites committed types on degrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of typegen-ci-resilient-describe. In blocking (`--wait`) mode the type generator now suppresses the `.d.ts` write on ANY degraded result (query `result: unknown` or degraded metric), leaving the committed types untouched as the CI fallback of record, then throws as before. The prior path wrote degraded (`unknown`) declarations first and threw after, which clobbered good committed types on a fresh CI checkout — including via the auth/timeout/bad-id/DELETED fatal-degrade path. Non-blocking mode is unchanged (still writes degraded types for the detached worker to refresh). Tests inverted to assert no-write-on-degrade while preserving throw + behavioral assertions; adds coverage for the query-side fatal-degrade clobber-prevention case. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 68 +++- .../src/type-generator/tests/index.test.ts | 347 ++++++++++++++++-- 2 files changed, 380 insertions(+), 35 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 2ce7f611d..4a06062c0 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -57,6 +57,24 @@ function plural(count: number, singular: string, pluralForm = `${singular}s`) { return count === 1 ? singular : pluralForm; } +/** + * Detects if a query schema has degraded to `result: unknown`. + * A degraded query cannot be distinguished from a successful one that simply + * has no result columns (both emit `result: unknown`), so we conservatively + * treat any `result: unknown` as potentially degraded for write-suppression + * purposes in blocking mode. + */ +function isQueryDegraded(schema: QuerySchema): boolean { + return schema.type.includes("result: unknown"); +} + +/** + * Detects if any metric schemas are degraded (marked with `degraded: true`). + */ +function hasAnyDegradedMetrics(schemas: MetricSchema[]): boolean { + return schemas.some((s) => s.degraded === true); +} + function formatFailureRows( label: string, queries: TypegenFailure[], @@ -331,8 +349,19 @@ export async function generateFromEntryPoint(options: { const typeDeclarations = generateTypeDeclarations(queryRegistry); - await fs.mkdir(path.dirname(outFile), { recursive: true }); - await fs.writeFile(outFile, typeDeclarations, "utf-8"); + // In blocking mode, suppress writes when any query is degraded AND there are + // no syntax/fatal errors to preserve committed .d.ts files as the fallback of + // record. Degraded writes still happen when there are preflight errors (which + // write before throwing). Non-blocking mode always writes. The throw still + // fires at the end if there are errors — this just prevents overwriting + // committed good types with degraded ones from pure connectivity failures. + const hasAnyDegradedQuery = queryRegistry.some(isQueryDegraded); + const shouldWriteQueries = mode !== "blocking" || !hasAnyDegradedQuery; + + if (shouldWriteQueries) { + await fs.mkdir(path.dirname(outFile), { recursive: true }); + await fs.writeFile(outFile, typeDeclarations, "utf-8"); + } // Metric-view types: emit whenever a metric-views folder is resolved (gated // on the metric config's own dir, NOT the queries folder — an app can declare @@ -351,6 +380,10 @@ export async function generateFromEntryPoint(options: { cache: !noCache, metricFetcher, mode, + // In blocking mode, only suppress writes for pure degradation (no failures). + // If there are preflight fatals or sync failures, degraded artifacts are + // still written before the throw. + suppressDegradedWrite: mode === "blocking", }); } catch (configError) { // syncMetricViewsTypes only throws for a malformed definitions.json — re-throw as a message-only TypegenFatalError. @@ -433,7 +466,11 @@ export interface SyncMetricViewsTypesResult { * @param options.metricOutFile - output path for the MetricRegistry `.d.ts`. * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). * @param options.metricFetcher - optional injected {@link DescribeFetcher} - * @param options.mode - preflight/gate policy, default `"describe-now"`. + * @param options.mode - preflight/gate policy, default `"describe-now"`. When set to `"blocking"`, + * metric-view .d.ts writes are suppressed if any metric is degraded (to preserve committed files). + * @param options.suppressDegradedWrite - when true (only in `mode === "blocking"` context), skip + * the metricOutFile write if any metric schema has `degraded === true`. Used to prevent + * overwriting committed .d.ts files with degraded types in blocking mode. */ export async function syncMetricViewsTypes(options: { metricViewsFolder: string; @@ -442,6 +479,7 @@ export async function syncMetricViewsTypes(options: { cache?: boolean; metricFetcher?: DescribeFetcher; mode?: "describe-now" | "non-blocking" | "blocking"; + suppressDegradedWrite?: boolean; }): Promise { const { metricViewsFolder, @@ -450,6 +488,7 @@ export async function syncMetricViewsTypes(options: { cache: cacheEnabled, metricFetcher, mode = "describe-now", + suppressDegradedWrite, } = options; // Only `cache === false` disables caching; `undefined`/`true` keep it on. @@ -683,12 +722,23 @@ export async function syncMetricViewsTypes(options: { return emptyMetricSchema(entry); }); - await fs.mkdir(path.dirname(metricOutFile), { recursive: true }); - await fs.writeFile( - metricOutFile, - generateMetricTypeDeclarations(schemas), - "utf-8", - ); + // In blocking mode, suppress writes when any metric is degraded AND there are + // no failures to preserve committed .d.ts files as the fallback of record. + // Degraded writes still happen when there are preflight fatals or sync failures + // (which throw after writing). Non-blocking mode always writes. This just + // prevents overwriting committed good types with degraded ones from pure + // warehouse-not-ready scenarios. + const shouldWriteMetrics = + !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); + + if (shouldWriteMetrics) { + await fs.mkdir(path.dirname(metricOutFile), { recursive: true }); + await fs.writeFile( + metricOutFile, + generateMetricTypeDeclarations(schemas), + "utf-8", + ); + } logger.debug( "Wrote MetricRegistry augmentation for %d metric(s)%s", diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index efcc60f44..b85f26fb2 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -587,11 +587,11 @@ describe("generateFromEntryPoint — metric-view emission", () => { 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); + // Phase 1: the degraded metric write is suppressed in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); }); - test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { + test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate, Phase 1 suppresses write", async () => { writeMetricConfig(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -601,6 +601,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // 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). + // Per Phase 1 anti-clobber: degraded artifacts are NOT written in blocking + // mode when there are no failures (to preserve committed good types). await expect( generateFromEntryPoint({ outFile, @@ -616,10 +618,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { 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"); + // Phase 1: degraded artifacts are suppressed, not written (to preserve committed types). + expect(fs.existsSync(metricFile)).toBe(false); } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -698,7 +698,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }); - test("blocking + DELETED: fails through the query path's fatal pathway (TypegenFatalError after artifacts are written)", async () => { + test("blocking + DELETED: fails through the query path's fatal pathway (TypegenFatalError, committed types untouched)", async () => { writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("DELETED"); @@ -726,10 +726,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Write-first semantics match query fatals: degraded artifacts exist. - const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"revenue"'); - expect(declarations).toContain("measureKeys: string"); + // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // 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 @@ -738,7 +736,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(metrics.revenue).toBeUndefined(); }); - test("blocking + preflight wait rejects with a timeout: fatal after artifacts (no silent stall)", async () => { + test("blocking + preflight wait rejects with a timeout: fatal, committed types untouched (no silent stall)", async () => { // A timed-out wait is deterministic, not a connectivity blip: surface it as // fatal rather than falling through to DESCRIBE a not-ready warehouse — the // ~5-min stall that still "succeeds". (Hybrid: warehouse-level → fatal.) @@ -782,10 +780,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect.objectContaining({ maxMs: 300_000 }), ); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // ... but degraded artifacts are still written before the throw. - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - "measureKeys: string", - ); + // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — the key stays uncached for the next // pass to re-probe. @@ -793,10 +789,12 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(metrics.revenue).toBeUndefined(); }); - test("blocking + preflight wait resolves non-RUNNING (STOPPED): degrades, does not throw", async () => { + test("blocking + preflight wait resolves non-RUNNING (STOPPED): degrades, does not throw, Phase 1 suppresses write", async () => { // A non-RUNNING *resolve* (not a throw) for a startable state is soft: fall // through to DESCRIBE, which degrades on the still-cold warehouse. Only a // DELETED/DELETING resolve (or a thrown deterministic error) is fatal. + // Per Phase 1 anti-clobber: degraded artifacts are NOT written in blocking + // mode when there are no failures (to preserve committed good types). writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockResolvedValue("STOPPED"); @@ -839,9 +837,8 @@ 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); - expect(fs.readFileSync(metricFile, "utf-8")).toContain( - "measureKeys: string", - ); + // Phase 1: degraded artifacts are suppressed, not written (to preserve committed types). + expect(fs.existsSync(metricFile)).toBe(false); // 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 @@ -857,7 +854,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, degraded outcome not cached", + "blocking + warehouse deleted mid-wait (probe read %s): fatal, committed types untouched, degraded outcome not cached", async (probedState, startsWarehouse) => { writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue(probedState); @@ -891,10 +888,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // The DESCRIBE batch is skipped — nothing can answer it. expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Degraded artifacts are still written before the throw. - const declarations = fs.readFileSync(metricFile, "utf-8"); - expect(declarations).toContain('"revenue"'); - expect(declarations).toContain("measureKeys: string"); + // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — no sticky entry to serve later. const metrics = @@ -1795,3 +1790,303 @@ describe("generateFromEntryPoint — metric cache section", () => { }, ); }); + +// ── Phase 1: Write suppression for blocking mode with degraded types ── +describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", () => { + const antiClobberDir = path.join(__dirname, "__output_anti_clobber__"); + const queryFolder = path.join(antiClobberDir, "queries"); + const metricViewsFolder = path.join(antiClobberDir, "metric-views"); + const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); + const metricFile = path.join(antiClobberDir, "generated", "metric-views.d.ts"); + + const degradedQuerySchema = (name: string) => ({ + name, + type: `{ name: "${name}"; parameters: Record; result: unknown; }`, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + fs.rmSync(antiClobberDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.mkdirSync(metricViewsFolder, { recursive: true }); + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + }); + }); + + afterAll(() => { + fs.rmSync(antiClobberDir, { recursive: true, force: true }); + }); + + test("blocking mode + degraded query (no errors): no write to outFile (queries .d.ts)", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + // Pre-write a "good" committed file so we can verify it's NOT overwritten + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committedContent = + "// Committed good types\nexport const GOOD_VERSION = true;"; + fs.writeFileSync(outFile, committedContent, "utf-8"); + + // Run in blocking mode with degraded query and NO errors + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // The committed file must NOT be overwritten with degraded types + const finalContent = fs.readFileSync(outFile, "utf-8"); + expect(finalContent).toBe(committedContent); + expect(finalContent).not.toContain("offline_query"); + }); + + test("blocking mode + non-degraded query: writes to outFile normally", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "good_query", + type: `{ name: "good_query"; parameters: Record; result: Array<{ id: number; }> }`, + }, + ], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // File should be written with good types + const content = fs.readFileSync(outFile, "utf-8"); + expect(content).toContain("interface QueryRegistry"); + expect(content).toContain("good_query"); + }); + + test("non-blocking mode + degraded query: writes to outFile anyway", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "non-blocking", + }), + ).resolves.toBeUndefined(); + + // In non-blocking mode, the file is written even with degraded types + const content = fs.readFileSync(outFile, "utf-8"); + expect(content).toContain("interface QueryRegistry"); + expect(content).toContain("offline_query"); + }); + + test("blocking mode + degraded metric (no failures): no write to metric-views.d.ts", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + // Pre-write a "good" committed metric file + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + const committedMetricContent = + "// Committed good metric types\nexport const GOOD_METRIC = true;"; + fs.writeFileSync(metricFile, committedMetricContent, "utf-8"); + + // Inject a fetcher that returns PENDING (non-terminal, triggers degradation with NO failures) + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + metricFetcher: async () => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }), + }), + ).resolves.toBeUndefined(); + + // The committed metric file must NOT be overwritten with degraded types + const finalMetricContent = fs.readFileSync(metricFile, "utf-8"); + expect(finalMetricContent).toBe(committedMetricContent); + expect(finalMetricContent).not.toContain("revenue"); + }); + + test("blocking mode + degraded query WITH syntax errors: no write to outFile, still throws", async () => { + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("bad_query")], + syntaxErrors: [{ name: "bad_query", message: "Table not found" }], + fatalErrors: [], + }); + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenSyntaxError); + // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking mode + non-degraded metric: writes to metric-views.d.ts normally", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + const describeResponse: DatabricksStatementExecutionResponse = { + statement_id: "stmt-mock", + status: { state: "SUCCEEDED" }, + result: { + data_array: [ + [ + JSON.stringify({ + columns: [ + { + name: "total_revenue", + type: "DECIMAL(38,2)", + is_measure: true, + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }), + ], + ], + }, + }; + + mocks.getWarehouseState.mockResolvedValue("RUNNING"); + mocks.executeStatement.mockResolvedValue(describeResponse); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // File should be written with good types + const content = fs.readFileSync(metricFile, "utf-8"); + expect(content).toContain("interface MetricRegistry"); + expect(content).toContain("revenue"); + expect(content).toContain('"total_revenue": number'); + }); + + test("non-blocking mode + degraded metric: writes to metric-views.d.ts anyway", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + mocks.getWarehouseState.mockResolvedValue("STOPPED"); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "non-blocking", + }), + ).resolves.toBeUndefined(); + + // In non-blocking mode, the file is written even with degraded types + const content = fs.readFileSync(metricFile, "utf-8"); + expect(content).toContain("interface MetricRegistry"); + expect(content).toContain("revenue"); + expect(content).toContain("measureKeys: string"); // Permissive degraded type + }); + + test("blocking mode + degraded query (no syntax/fatal errors): resolves without write", async () => { + // When a query is degraded but there are no syntax or fatal errors, + // the function resolves normally. In blocking mode, the degraded write + // is suppressed, so outFile is not written. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedQuerySchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + }); + + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + // The file was not written because the query was degraded in blocking mode + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking mode + degraded query WITH fatal errors (auth/bad-id): no write to outFile, still throws TypegenFatalError", async () => { + // This test covers the fresh-CI-checkout fatal-degrade clobber-prevention case: + // a degraded schema result with fatal errors (not syntax errors) should NOT write + // artifacts in blocking mode, yet should still throw TypegenFatalError. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "bad_query", + type: `{ name: "bad_query"; parameters: Record; result: unknown; }`, + }, + ], + syntaxErrors: [], + fatalErrors: [{ name: "bad_query", message: "warehouse wh-1: auth failed" }], + }); + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + expect(fs.existsSync(outFile)).toBe(false); + }); +}); From 3de8a85573eef909ead919b7eff648a64cf01ea9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 17:11:58 +0200 Subject: [PATCH 17/28] feat(appkit): add classifyBlockingFailure two-bucket taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of typegen-ci-resilient-describe. Adds a pure classifyBlockingFailure(error) → "deterministic" | "environmental" to type-generator/errors.ts, building on the existing getErrorStatus and isConnectivityError helpers. Deterministic (build must crash regardless of committed types): HTTP 404 (bad warehouse id) and 400 (malformed request), checked first and walked through cause/AggregateError chains. Environmental (has-types gate applies later): 401/403 auth, connectivity, DELETED/DELETING, wait-timeout, and any unrecognized failure (the default). The auth status set is a one-line change point for the auth-owning team. No behavior change to isConnectivityError. Adds tests/errors.test.ts. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/errors.ts | 54 ++++ .../src/type-generator/tests/errors.test.ts | 239 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 packages/appkit/src/type-generator/tests/errors.test.ts diff --git a/packages/appkit/src/type-generator/errors.ts b/packages/appkit/src/type-generator/errors.ts index 97c19d121..61409ed42 100644 --- a/packages/appkit/src/type-generator/errors.ts +++ b/packages/appkit/src/type-generator/errors.ts @@ -139,3 +139,57 @@ export function isConnectivityError(error: unknown): boolean { return false; } + +const AUTH_ERROR_STATUSES = new Set([401, 403]); + +/** + * Classifies a thrown failure into one of two buckets: deterministic failures + * that must be surfaced (bad warehouse id, malformed request) or environmental + * issues (connectivity, auth, deleted warehouse, timeouts) that the has-types + * gate will handle later. + * + * Returns: + * - "deterministic": HTTP 404 (bad warehouse id) or 400 (malformed request). + * The build must fail. + * - "environmental": Everything else — auth (401/403), connectivity errors, + * warehouse state changes (DELETED/DELETING), wait-for-RUNNING timeouts, + * unrecognized failures. Default = environmental. + * + * Walks `cause`/`AggregateError` chains when checking for deterministic status, + * so a wrapped 404 is still recognized as deterministic. + */ +export function classifyBlockingFailure( + error: unknown, +): "deterministic" | "environmental" { + // Deterministic: check first so they're never swallowed by environmental rules. + // Walk the error chain to find any deterministic status. + const seen = new Set(); + const stack = [error]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + + const status = getErrorStatus(current); + if (status === 404 || status === 400) { + return "deterministic"; + } + + stack.push(...getErrorChildren(current)); + } + + // Environmental: auth, connectivity, unrecognized, default. + const topLevelStatus = getErrorStatus(error); + if (topLevelStatus !== undefined && AUTH_ERROR_STATUSES.has(topLevelStatus)) { + return "environmental"; + } + + if (isConnectivityError(error)) { + return "environmental"; + } + + // Default: any unrecognized failure or no status (DELETED/DELETING messages, + // timeout messages, plain Error objects) → environmental. + return "environmental"; +} diff --git a/packages/appkit/src/type-generator/tests/errors.test.ts b/packages/appkit/src/type-generator/tests/errors.test.ts new file mode 100644 index 000000000..a29c7f7e8 --- /dev/null +++ b/packages/appkit/src/type-generator/tests/errors.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { classifyBlockingFailure } from "../errors"; + +describe("classifyBlockingFailure", () => { + describe("deterministic failures", () => { + it("classifies HTTP 400 as deterministic", () => { + const error = new Error("Bad request"); + (error as any).status = 400; + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 as deterministic", () => { + const error = new Error("Not found"); + (error as any).status = 404; + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 from response.status as deterministic", () => { + const error = new Error("Not found"); + (error as any).response = { status: 404 }; + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies HTTP 404 from statusCode as deterministic", () => { + const error = new Error("Not found"); + (error as any).statusCode = 404; + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + }); + + describe("environmental failures - auth", () => { + it("classifies HTTP 401 as environmental", () => { + const error = new Error("Unauthorized"); + (error as any).status = 401; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 403 as environmental", () => { + const error = new Error("Forbidden"); + (error as any).status = 403; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - other HTTP statuses", () => { + it("classifies HTTP 500 as environmental (not in deterministic set)", () => { + const error = new Error("Internal server error"); + (error as any).status = 500; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 502 as environmental (via connectivity)", () => { + const error = new Error("Bad gateway"); + (error as any).status = 502; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 503 as environmental (via connectivity)", () => { + const error = new Error("Service unavailable"); + (error as any).status = 503; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies HTTP 504 as environmental (via connectivity)", () => { + const error = new Error("Gateway timeout"); + (error as any).status = 504; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - connectivity codes", () => { + it("classifies ECONNREFUSED as environmental", () => { + const error = new Error("Connection refused"); + (error as any).code = "ECONNREFUSED"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ENOTFOUND as environmental", () => { + const error = new Error("ENOTFOUND"); + (error as any).code = "ENOTFOUND"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ETIMEDOUT as environmental", () => { + const error = new Error("Timed out"); + (error as any).code = "ETIMEDOUT"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies ECONNRESET as environmental", () => { + const error = new Error("Connection reset"); + (error as any).code = "ECONNRESET"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies UND_ERR_* codes as environmental", () => { + const error = new Error("undici error"); + (error as any).code = "UND_ERR_ABORTED"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - TLS codes", () => { + it("classifies CERT_HAS_EXPIRED as environmental", () => { + const error = new Error("Certificate has expired"); + (error as any).code = "CERT_HAS_EXPIRED"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies DEPTH_ZERO_SELF_SIGNED_CERT as environmental", () => { + const error = new Error("Self signed cert"); + (error as any).code = "DEPTH_ZERO_SELF_SIGNED_CERT"; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - connectivity messages", () => { + it("classifies connection refused message as environmental", () => { + const error = new Error("connection refused"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies socket hang up message as environmental", () => { + const error = new Error("socket hang up"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies network error message as environmental", () => { + const error = new Error("network error"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies certificate has expired message as environmental", () => { + const error = new Error("certificate has expired"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - warehouse state messages", () => { + it("classifies DELETED warehouse error as environmental", () => { + const error = new Error("warehouse has been DELETED"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies DELETING warehouse error as environmental", () => { + const error = new Error("warehouse is DELETING"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - timeout messages", () => { + it("classifies wait-for-RUNNING timeout as environmental", () => { + const error = new Error( + "warehouse did not reach RUNNING within 300000ms", + ); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + }); + + describe("environmental failures - unrecognized errors", () => { + it("classifies plain Error with no status as environmental (default)", () => { + const error = new Error("boom"); + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies plain object error as environmental", () => { + const error = { message: "something went wrong" }; + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies null as environmental", () => { + expect(classifyBlockingFailure(null)).toBe("environmental"); + }); + + it("classifies undefined as environmental", () => { + expect(classifyBlockingFailure(undefined)).toBe("environmental"); + }); + }); + + describe("wrapped errors", () => { + it("classifies deterministic status (404) nested under .cause as deterministic", () => { + const causedError = new Error("Not found"); + (causedError as any).status = 404; + + const error = new Error("Outer error"); + (error as any).cause = causedError; + + expect(classifyBlockingFailure(error)).toBe("deterministic"); + }); + + it("classifies connectivity code nested under .cause as environmental", () => { + const causedError = new Error("Connection refused"); + (causedError as any).code = "ECONNREFUSED"; + + const error = new Error("Outer error"); + (error as any).cause = causedError; + + expect(classifyBlockingFailure(error)).toBe("environmental"); + }); + + it("classifies AggregateError with 404 as deterministic", () => { + const statusError = new Error("Not found"); + (statusError as any).status = 404; + + const aggregateError = new AggregateError( + [statusError], + "Multiple errors", + ); + + expect(classifyBlockingFailure(aggregateError)).toBe("deterministic"); + }); + }); + + describe("purity", () => { + it("returns the same classification when called twice with the same input", () => { + const error = new Error("Not found"); + (error as any).status = 404; + + const result1 = classifyBlockingFailure(error); + const result2 = classifyBlockingFailure(error); + + expect(result1).toBe(result2); + expect(result1).toBe("deterministic"); + }); + + it("returns the same classification for equivalent errors", () => { + const error1 = new Error("Connection refused"); + (error1 as any).code = "ECONNREFUSED"; + + const error2 = new Error("Connection refused"); + (error2 as any).code = "ECONNREFUSED"; + + expect(classifyBlockingFailure(error1)).toBe( + classifyBlockingFailure(error2), + ); + expect(classifyBlockingFailure(error1)).toBe("environmental"); + }); + }); +}); From 5711770c7a7db65282c0070d4c74851b29de7fbd Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 18:07:38 +0200 Subject: [PATCH 18/28] feat(appkit): has-types gate for environmental typegen failures in --wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of typegen-ci-resilient-describe (join point). Wires the classifyBlockingFailure taxonomy into the reordered blocking write path so `--wait` degrades gracefully on environmental failures instead of always crashing: - Deterministic failures (SQL syntax, HTTP 404/400) still crash the build. - Environmental failures (401/403 auth, connectivity, DELETED/DELETING, wait-timeout, unrecognized) now flow through a has-types gate: if committed analytics/metric-views .d.ts exist, skip the (already-suppressed) write, emit one loud greppable stderr warning naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) + warehouse id, and exit 0 using the committed types as the fallback of record. If no committed types exist, crash with a generic 'run generate-types --wait locally' remedy. Serving types are excluded from the gate (gitignored, degrade independently). Non-blocking mode is unchanged. Threads deterministic-vs-environmental and a coarse cause label out of the query + metric preflights. Adds gate-matrix coverage: environmental+present (per cause) → warning+exit0, environmental+ absent → crash, deterministic (404/400/syntax) → crash regardless of types, partial presence, serving-exclusion, and CI-safe (ANSI-free) warning output. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 180 ++++++- .../src/type-generator/query-registry.ts | 56 +- .../tests/generate-queries.test.ts | 35 +- .../src/type-generator/tests/index.test.ts | 490 ++++++++++++++++-- packages/appkit/src/type-generator/types.ts | 15 +- 5 files changed, 685 insertions(+), 91 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 4a06062c0..957569b00 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { WorkspaceClient } from "@databricks/sdk-experimental"; @@ -11,7 +12,11 @@ import { metricCacheHash, saveCache, } from "./cache"; -import { getErrorDiagnostic, isConnectivityError } from "./errors"; +import { + classifyBlockingFailure, + getErrorDiagnostic, + isConnectivityError, +} from "./errors"; import { migrateProjectConfig, removeOldGeneratedTypes, @@ -44,6 +49,26 @@ dotenv.config(); const logger = createLogger("type-generator"); +/** + * Classify an environmental failure into one of three coarse cause labels + * for the warning message. + */ +function classifyEnvironmentalCause( + error: unknown, +): "auth" | "unreachable" | "unavailable" { + if (isConnectivityError(error)) return "unreachable"; + // Check for auth status (401/403) + if (typeof error === "object" && error !== null) { + const err = error as Record; + const status = err.status ?? err.statusCode; + if (typeof status === "number" && (status === 401 || status === 403)) { + return "auth"; + } + } + // Default for other environmental failures (DELETED/DELETING, timeouts, etc.) + return "unavailable"; +} + /** * 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) @@ -51,12 +76,47 @@ const logger = createLogger("type-generator"); */ const MV_PREFLIGHT_WAIT_MAX_MS = 300_000; +/** + * Generate a loud warning message for environmental failures with committed types present. + * The message includes a coarse cause label and the warehouse ID. + * @param cause - coarse cause label: "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other) + * @param warehouseId - the warehouse ID + */ +function determineWarningMessage( + cause: "auth" | "unreachable" | "unavailable" = "unavailable", + warehouseId: string, +): string { + const causeLabel = + cause === "auth" + ? "auth blocked" + : cause === "unreachable" + ? "warehouse unreachable" + : "warehouse unavailable"; + // Use a stable prefix for greppability and CI log matching. + return `AppKit typegen: using committed types — warehouse ${warehouseId} ${causeLabel}; please check warehouse status and retry`; +} + type TypegenFailure = QuerySyntaxError | QueryFatalError; function plural(count: number, singular: string, pluralForm = `${singular}s`) { return count === 1 ? singular : pluralForm; } +/** + * Check if committed type artifacts exist (at least one of the requested surfaces). + * Serving types are excluded (gitignored, never part of the gate). + * Returns true if either the analytics or metric-views committed .d.ts file exists. + */ +function hasCommittedTypes( + analyticsOutFile: string, + metricViewsOutFile: string | undefined, +): boolean { + const hasAnalytics = existsSync(analyticsOutFile); + const hasMetrics = + metricViewsOutFile !== undefined && existsSync(metricViewsOutFile); + return hasAnalytics || hasMetrics; +} + /** * Detects if a query schema has degraded to `result: unknown`. * A degraded query cannot be distinguished from a successful one that simply @@ -336,7 +396,13 @@ export async function generateFromEntryPoint(options: { let queryRegistry: QuerySchema[] = []; let syntaxErrors: QuerySyntaxError[] = []; + // Deterministic fatal errors only (404/400). let fatalErrors: QueryFatalError[] = []; + // Track whether an environmental failure occurred in blocking mode. + let hadEnvironmentalFailure = false; + // Track the coarse cause of the environmental failure for the warning message. + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; + if (queryFolder) { const result = await generateQueriesFromDescribe(queryFolder, warehouseId, { noCache, @@ -345,6 +411,10 @@ export async function generateFromEntryPoint(options: { queryRegistry = result.schemas; syntaxErrors = result.syntaxErrors ?? []; fatalErrors = result.fatalErrors ?? []; + hadEnvironmentalFailure = + hadEnvironmentalFailure || (result.hadEnvironmentalFailure ?? false); + environmentalCause = + environmentalCause ?? result.environmentalCause ?? undefined; } const typeDeclarations = generateTypeDeclarations(queryRegistry); @@ -400,10 +470,17 @@ export async function generateFromEntryPoint(options: { // Deleted/deleting-warehouse fatal preflight (blocking mode only); // empty (no-op) when definitions.json is absent or in non-blocking mode. + // Only deterministic fatals are recorded in fatalErrors. for (const fe of mvResult.fatalErrors) { fatalErrors.push(fe); } + // Thread through the environmental failure flag and cause. + hadEnvironmentalFailure = + hadEnvironmentalFailure || (mvResult.hadEnvironmentalFailure ?? false); + environmentalCause = + environmentalCause ?? mvResult.environmentalCause ?? undefined; + // 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. if (mode === "blocking") { @@ -419,7 +496,10 @@ export async function generateFromEntryPoint(options: { await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); await migrateProjectConfig(projectRoot); - // Types are always written above — including `result: unknown` for any Metric View that could not be described. + // Phase 3: Unified terminal decision combining deterministic & environmental failures + // with a has-types gate in blocking mode. + + // Deterministic failures (SQL syntax errors or 404/400 HTTP) always crash regardless of mode. if (syntaxErrors.length > 0) { throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors); } @@ -427,6 +507,35 @@ export async function generateFromEntryPoint(options: { throw new TypegenFatalError(fatalErrors, warehouseId); } + // Environmental failures (in blocking mode) trigger the has-types gate. + if (mode === "blocking" && hadEnvironmentalFailure) { + // Determine resolved metric-views file for the has-types check. + const resolvedMvFile = + options.mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); + + const hasTypes = hasCommittedTypes(outFile, resolvedMvFile); + + if (hasTypes) { + // Committed types present: emit loud warning and exit 0. + const warningMessage = determineWarningMessage( + environmentalCause ?? "unavailable", + warehouseId, + ); + logger.warn(warningMessage); + } else { + // No committed types: crash with a generic message. + throw new TypegenFatalError( + [ + { + name: "type-generator", + message: `Warehouse ${warehouseId} could not be reached and no committed types exist. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated .d.ts files.`, + }, + ], + warehouseId, + ); + } + } + logger.debug("Type generation complete!"); } @@ -450,8 +559,23 @@ export interface SyncMetricViewsTypesResult { * artifacts are still written; {@link generateFromEntryPoint} surfaces these * by throwing {@link TypegenFatalError} after the writes. A `"describe-now"` * run sets no blocking preflight, so for that mode this is always empty. + * ONLY contains deterministic failures (404/400). */ fatalErrors: Array<{ name: string; message: string }>; + /** + * `true` when an environmental failure occurred in blocking mode (auth, connectivity, + * DELETED/DELETING, wait-timeout, or other unrecognized failures). Used by + * {@link generateFromEntryPoint} to decide whether to apply the has-types gate. + * Does not directly cause a throw — the gate decides that. Always false in + * non-blocking or describe-now mode. + */ + hadEnvironmentalFailure?: boolean; + /** + * Coarse cause label for the environmental failure, one of "auth" (401/403), + * "unreachable" (connectivity), or "unavailable" (other). Only set when + * hadEnvironmentalFailure is true; used by the warning message. + */ + environmentalCause?: "auth" | "unreachable" | "unavailable"; } /** @@ -549,6 +673,8 @@ export async function syncMetricViewsTypes(options: { // 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; + let hadEnvironmentalFailure = false; + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; if ( mode === "blocking" && metricFetcher === undefined && @@ -559,6 +685,9 @@ export async function syncMetricViewsTypes(options: { const decision = decidePreflight(state, mode); if (decision === "fatal") { preflightFatalMessage = `warehouse ${warehouseId} is ${state}`; + // State-based DELETED/DELETING is environmental, not deterministic. + hadEnvironmentalFailure = true; + environmentalCause = "unavailable"; } else if (decision === "startWaitProceed") { // treatStoppedAsTransient rides out the stale pre-start STOPPED/STOPPING // reading, same as the query preflight. @@ -571,21 +700,35 @@ export async function syncMetricViewsTypes(options: { // With treatStoppedAsTransient, a non-RUNNING resolve is exactly // DELETED/DELETING — the warehouse was deleted while we waited. preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; + hadEnvironmentalFailure = true; } } 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. + // Deleted mid-wait: fatal. Environmental (state-based). preflightFatalMessage = `warehouse ${warehouseId} is ${settled}`; + hadEnvironmentalFailure = true; } } } catch (err) { // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs degrade // a not-ready / unreachable warehouse rather than throwing. if (!isConnectivityError(err)) { - preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + // Classify: deterministic (404/400) or environmental (auth, etc). + const classification = classifyBlockingFailure(err); + if (classification === "deterministic") { + // Keep as fatal preflight for deterministic errors (404/400). + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + } else { + // Environmental: set preflightFatalMessage so DESCRIBE is skipped, but + // mark hadEnvironmentalFailure so the gate handles it later (not added + // to fatalErrors). + preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + hadEnvironmentalFailure = true; + environmentalCause = classifyEnvironmentalCause(err); + } } } } @@ -607,14 +750,21 @@ export async function syncMetricViewsTypes(options: { let described: MetricSchema[]; let failures: MetricSyncFailure[] = []; if (preflightFatalMessage !== undefined) { - // Fatal preflight (deleted/deleting warehouse): fail like the query path — + // Fatal preflight (deleted/deleting warehouse or deterministic error): // skip DESCRIBE, emit degraded schemas so both artifacts are still written, // and record one fatal error per describe-needed key (cache hits are - // unaffected). The caller surfaces them after the writes. The degraded - // schemas are not cached (see the write block), so a later pass re-probes. + // unaffected) ONLY if it's a deterministic error. Environmental failures + // degrade silently. The degraded schemas are not cached (see the write + // block), so a later pass re-probes. described = describeNeeded.map(emptyMetricSchema); - for (const entry of describeNeeded) { - fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); + if (!hadEnvironmentalFailure) { + // Only deterministic fatals (404/400) record errors. + for (const entry of describeNeeded) { + fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); + } + } else { + // Environmental failure: don't record in fatalErrors, let the has-types + // gate handle it later. } } else if (describeNeeded.length === 0) { // Nothing left to describe — every configured key was a cache hit. @@ -658,11 +808,13 @@ export async function syncMetricViewsTypes(options: { degradedKeys.length, degradedKeys.join(", "), ); + hadEnvironmentalFailure = true; // Mark as environmental if any metric degraded. + environmentalCause = environmentalCause ?? "unavailable"; } } else { // Un-probed DESCRIBEs deliberately skipped, not failures: emit each // describe-needed key as a degraded schema so both artifacts exist; cache - // hits keep serving last-known-good. + // hits keep serving last-known-good. This is an environmental failure path. described = describeNeeded.map(emptyMetricSchema); logger.info( "Warehouse %s is not running — wrote degraded metric types (permissive) for %d metric view(s) (%s); they will refresh once the warehouse is available.", @@ -670,6 +822,8 @@ export async function syncMetricViewsTypes(options: { describeNeeded.length, describeNeeded.map((e) => e.key).join(", "), ); + hadEnvironmentalFailure = true; // Mark as environmental when not describing. + environmentalCause = environmentalCause ?? "unavailable"; } // Cache only successful schema results for describe-needed keys; remove stale cache for degraded ones. @@ -752,6 +906,12 @@ export async function syncMetricViewsTypes(options: { failures, fatalErrors, noConfig: false, + hadEnvironmentalFailure: + mode === "blocking" ? hadEnvironmentalFailure : undefined, + environmentalCause: + mode === "blocking" && hadEnvironmentalFailure + ? environmentalCause + : undefined, }; } diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index fb239487a..65091a990 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -5,7 +5,11 @@ import { tableFromIPC } from "apache-arrow"; import pc from "picocolors"; import { createLogger } from "../logging/logger"; import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache"; -import { getErrorDiagnostic, isConnectivityError } from "./errors"; +import { + classifyBlockingFailure, + getErrorDiagnostic, + isConnectivityError, +} from "./errors"; import { decidePreflight, type PreflightMode } from "./preflight"; import { Spinner } from "./spinner"; import { type DescribeFormatMemo, describeAdaptive } from "./statement-result"; @@ -675,7 +679,12 @@ export async function generateQueriesFromDescribe( // Genuine SQL errors (reachable warehouse). Connectivity failures are NOT // recorded here — they degrade silently so a transient outage isn't fatal. const syntaxErrors: QuerySyntaxError[] = []; + // Deterministic fatal errors only (404/400). Environmental failures are + // tracked separately below. const fatalErrors: QueryFatalError[] = []; + // Track whether an environmental failure occurred in blocking mode (for the + // has-types gate in generateFromEntryPoint). + let hadEnvironmentalFailure = false; if (uncachedQueries.length > 0) { // One-time warehouse preflight (before issuing any DESCRIBE). A single @@ -685,6 +694,8 @@ export async function generateQueriesFromDescribe( // not-ready warehouse degrades exactly like a per-query outage. let decision: ReturnType = "proceed"; let fatalMessage = ""; + // Track whether a non-connectivity environmental failure occurred (for Phase 3). + let isEnvironmental = false; if (mode === "non-blocking") { // `non-blocking` never describes and must make ZERO warehouse round-trips: // skip the probe entirely (no getWarehouseState) and go straight to @@ -697,7 +708,9 @@ export async function generateQueriesFromDescribe( const state = await getWarehouseState(client, warehouseId); decision = decidePreflight(state, mode); if (decision === "fatal") { + // DELETED/DELETING is state-based and environmental. fatalMessage = `warehouse ${warehouseId} is ${state}`; + isEnvironmental = true; } if (decision === "startWaitProceed") { // Stopped/stopping warehouse: nudge it out of the stopped state, then @@ -714,6 +727,7 @@ export async function generateQueriesFromDescribe( } else { decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; + isEnvironmental = true; // DELETED/DELETING or timeout is environmental } } if (decision === "waitThenProceed") { @@ -725,6 +739,7 @@ export async function generateQueriesFromDescribe( } else { decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; + isEnvironmental = true; // DELETED/DELETING or timeout is environmental } } } catch (err) { @@ -733,18 +748,41 @@ export async function generateQueriesFromDescribe( // per-query connectivity failure — never fail a build on a blip. decision = "degradeAll"; } else { - // Auth, bad warehouse id, malformed config, or a timed-out wait: fatal. - decision = "fatal"; - fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + // Classify the exception: deterministic (404/400) or environmental (auth, etc). + const classification = classifyBlockingFailure(err); + if (classification === "deterministic") { + // Build-failing deterministic error (bad warehouse id, malformed request). + decision = "fatal"; + fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + isEnvironmental = false; + } else { + // Environmental: auth, timeouts, unrecognized, etc. Degrade for the + // has-types gate to handle later. + decision = "degradeAll"; + isEnvironmental = true; + fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; + } } } } + // Track environmental failures in blocking mode for Phase 3 gate. + if ( + mode === "blocking" && + ((decision === "degradeAll" && isEnvironmental) || + (decision === "fatal" && isEnvironmental)) + ) { + hadEnvironmentalFailure = true; + } + if (decision !== "proceed") { // degradeAll or fatal: skip DESCRIBE entirely. Every uncached query gets a // degraded schema (reused cache or `unknown`); fatal additionally records - // a fatalError per query so the caller fails the build after writing. - const kind = decision === "fatal" ? "fatal" : "connectivity"; + // a fatalError per query so the caller fails the build after writing. Only + // deterministic fatals are recorded; environmental degradations go silent + // so the has-types gate can decide. + const kind = + decision === "fatal" && !isEnvironmental ? "fatal" : "connectivity"; for (const { index, queryName, sql, sqlHash } of uncachedQueries) { freshResults.push({ index, @@ -753,7 +791,9 @@ export async function generateQueriesFromDescribe( type: degradedType(cache, queryName, sql, sqlHash), }, }); - if (decision === "fatal") { + if (decision === "fatal" && !isEnvironmental) { + // Only deterministic fatals record an error; environmental failures + // degrade silently for the has-types gate. fatalErrors.push({ name: queryName, message: fatalMessage }); logEntries.push({ queryName, @@ -1039,7 +1079,7 @@ export async function generateQueriesFromDescribe( .sort((a, b) => a.index - b.index) .map((r) => r.schema); - return { schemas, syntaxErrors, fatalErrors }; + return { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure }; } /** diff --git a/packages/appkit/src/type-generator/tests/generate-queries.test.ts b/packages/appkit/src/type-generator/tests/generate-queries.test.ts index 22a054f82..6724df7e2 100644 --- a/packages/appkit/src/type-generator/tests/generate-queries.test.ts +++ b/packages/appkit/src/type-generator/tests/generate-queries.test.ts @@ -774,44 +774,46 @@ describe("generateQueriesFromDescribe", () => { }); test.each(["DELETED", "DELETING"] as const)( - "%s + blocking mode — fatal per query after schemas are written, never describes", + "%s + blocking mode — environmental, degrades silently, hadEnvironmentalFailure set for gate", async (state) => { + // Phase 3: DELETED/DELETING are environmental (state-based fatals). + // They degrade silently (fatalErrors empty) but set hadEnvironmentalFailure + // for the entry point's has-types gate to handle. mocks.readdir.mockResolvedValue(["a.sql", "b.sql"]); mocks.readFile .mockResolvedValueOnce("SELECT id FROM a") .mockResolvedValueOnce("SELECT id FROM b"); mocks.getWarehouse.mockReturnValue({ state }); - const { schemas, syntaxErrors, fatalErrors } = + const { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure } = await generateQueriesFromDescribe("/queries", "wh-123", { mode: "blocking", }); - // A deleted/deleting warehouse is the only fatal case: never started, - // never described; one fatal entry per uncached query. + // Phase 3: environmental failures degrade, not fatal at query level. expect(mocks.startWarehouse).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(fatalErrors).toEqual([ - { name: "a", message: `warehouse wh-123 is ${state}` }, - { name: "b", message: `warehouse wh-123 is ${state}` }, - ]); + expect(fatalErrors).toEqual([]); // Phase 3: environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // Phase 3: track for gate expect(syntaxErrors).toEqual([]); // Schemas are still produced (degraded) so the .d.ts is written before - // generateFromEntryPoint throws on the recorded fatalErrors. + // generateFromEntryPoint uses the gate to decide throw/warn. expect(schemas).toHaveLength(2); expect(schemas[0].type).toContain("result: unknown"); expect(schemas[1].type).toContain("result: unknown"); }, ); - test("STOPPED + blocking — start succeeds but warehouse never reaches RUNNING is fatal", async () => { + test("STOPPED + blocking — start succeeds but warehouse never reaches RUNNING is environmental, degrades silently", async () => { + // Phase 3: wait timeout (non-RUNNING resolve) is environmental (state-based fatal). + // It degrades silently (fatalErrors empty) but sets hadEnvironmentalFailure for the gate. vi.useFakeTimers(); try { mocks.readdir.mockResolvedValue(["a.sql"]); mocks.readFile.mockResolvedValue("SELECT id FROM a"); // Preflight sees STOPPED → start fires, but the warehouse then reports // DELETED (a genuinely terminal state even with treatStoppedAsTransient). - // The wait resolves non-RUNNING → fatal; schemas still written. + // The wait resolves non-RUNNING → environmental; schemas still written. mocks.getWarehouse .mockReturnValueOnce({ state: "STOPPED" }) .mockReturnValue({ state: "DELETED" }); @@ -820,17 +822,14 @@ describe("generateQueriesFromDescribe", () => { mode: "blocking", }); await vi.runAllTimersAsync(); - const { schemas, syntaxErrors, fatalErrors } = await promise; + const { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure } = + await promise; expect(mocks.startWarehouse).toHaveBeenCalledTimes(1); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(syntaxErrors).toEqual([]); - expect(fatalErrors).toEqual([ - { - name: "a", - message: "warehouse wh-123 did not reach RUNNING (now DELETED)", - }, - ]); + expect(fatalErrors).toEqual([]); // Phase 3: environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // Phase 3: track for gate expect(schemas[0].type).toContain("result: unknown"); } finally { vi.useRealTimers(); diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index b85f26fb2..3341ae510 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -102,6 +102,12 @@ const { hashSQL } = await import("../cache"); const outputDir = path.join(__dirname, "__output__"); +// Strip ANSI SGR escape sequences so warning/error messages assert as plain +// text (and match CI logs). The ESC byte is built via String.fromCharCode so +// no control character appears in a regex literal (Biome noControlCharactersInRegex). +const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const stripAnsi = (s: string): string => s.replace(ANSI_SGR, ""); + describe("generateFromEntryPoint", () => { beforeAll(() => { // Create output directory once before all tests @@ -698,35 +704,33 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }); - test("blocking + DELETED: fails through the query path's fatal pathway (TypegenFatalError, committed types untouched)", async () => { + test("blocking + DELETED: environmental failure with committed types → no throw, warning emitted", async () => { + // Phase 3: DELETED is environmental. Since the query path writes analytics.d.ts + // (even with empty registry), committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("DELETED"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const error = await generateFromEntryPoint({ - outFile, - queryFolder, - warehouseId: "wh-1", - mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }); - // Identical surfacing to a query-path fatal preflight: same error class, - // same per-name fatal entries, same message template. - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual([ - { name: "revenue", message: "warehouse wh-1 is DELETED" }, - ]); + // Phase 3: environmental failure with committed types → no throw. + // The generator returns normally (exit 0). + } finally { + warnSpy.mockRestore(); + } // A deleted warehouse is never started, waited on, or described. expect(mocks.startWarehouse).not.toHaveBeenCalled(); expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is NEVER cached (mirrors the query path): the key is @@ -736,10 +740,9 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(metrics.revenue).toBeUndefined(); }); - test("blocking + preflight wait rejects with a timeout: fatal, committed types untouched (no silent stall)", async () => { - // A timed-out wait is deterministic, not a connectivity blip: surface it as - // fatal rather than falling through to DESCRIBE a not-ready warehouse — the - // ~5-min stall that still "succeeds". (Hybrid: warehouse-level → fatal.) + test("blocking + preflight wait rejects with a timeout: environmental failure with committed types → no throw, warning emitted", async () => { + // Phase 3: timeout is environmental. Since the query path writes analytics.d.ts, + // committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockRejectedValue( @@ -751,21 +754,14 @@ describe("generateFromEntryPoint — metric-view emission", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const error = await generateFromEntryPoint({ + await generateFromEntryPoint({ outFile, queryFolder, warehouseId: "wh-1", mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual( - [expect.objectContaining({ name: "revenue" })], - ); + }); + + // Phase 3: environmental failure with committed types → no throw. } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -780,7 +776,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect.objectContaining({ maxMs: 300_000 }), ); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — the key stays uncached for the next @@ -854,8 +850,10 @@ 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, committed types untouched, degraded outcome not cached", + "blocking + warehouse deleted mid-wait (probe read %s): environmental failure with committed types → no throw, warning emitted", async (probedState, startsWarehouse) => { + // Phase 3: DELETED mid-wait is environmental. Since the query path writes + // analytics.d.ts, committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue(probedState); mocks.startWarehouse.mockResolvedValue(undefined); @@ -863,24 +861,14 @@ describe("generateFromEntryPoint — metric-view emission", () => { // RESOLVES (does not throw) with the terminal state. mocks.waitUntilRunning.mockResolvedValue("DELETED"); - const error = await generateFromEntryPoint({ + await generateFromEntryPoint({ outFile, queryFolder, warehouseId: "wh-1", mode: "blocking", - }).then( - () => { - throw new Error("expected generateFromEntryPoint to reject"); - }, - (err: unknown) => err, - ); + }); - // Same fatal pathway as the decision-time DELETED: per-key entries - // with the query path's message template, thrown after the writes. - expect(error).toBeInstanceOf(TypegenFatalError); - expect((error as InstanceType).queries).toEqual( - [{ name: "revenue", message: "warehouse wh-1 is DELETED" }], - ); + // Phase 3: environmental failure with committed types → no throw. expect(mocks.startWarehouse).toHaveBeenCalledTimes( startsWarehouse ? 1 : 0, @@ -888,7 +876,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // The DESCRIBE batch is skipped — nothing can answer it. expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — no sticky entry to serve later. @@ -1797,7 +1785,11 @@ describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", ( const queryFolder = path.join(antiClobberDir, "queries"); const metricViewsFolder = path.join(antiClobberDir, "metric-views"); const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); - const metricFile = path.join(antiClobberDir, "generated", "metric-views.d.ts"); + const metricFile = path.join( + antiClobberDir, + "generated", + "metric-views.d.ts", + ); const degradedQuerySchema = (name: string) => ({ name, @@ -2068,7 +2060,9 @@ describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", ( }, ], syntaxErrors: [], - fatalErrors: [{ name: "bad_query", message: "warehouse wh-1: auth failed" }], + fatalErrors: [ + { name: "bad_query", message: "warehouse wh-1: auth failed" }, + ], }); fs.mkdirSync(path.dirname(outFile), { recursive: true }); @@ -2090,3 +2084,395 @@ describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", ( expect(fs.existsSync(outFile)).toBe(false); }); }); + +describe("generateFromEntryPoint — Phase 3: warning message with cause labels", () => { + const warningTestDir = path.join(__dirname, "__output_warning__"); + const queryFolder = path.join(warningTestDir, "queries"); + const metricViewsFolder = path.join(warningTestDir, "metric-views"); + const outFile = path.join(warningTestDir, "generated", "analytics.d.ts"); + const metricFile = path.join( + warningTestDir, + "generated", + "metric-views.d.ts", + ); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + fs.rmSync(warningTestDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.mkdirSync(metricViewsFolder, { recursive: true }); + // Pre-create committed types files so the gate triggers + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, "// committed types\n", "utf-8"); + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + }); + }); + + afterAll(() => { + fs.rmSync(warningTestDir, { recursive: true, force: true }); + }); + + test("warning: environmental failure with committed types → warning contains warehouse id and cause label (unavailable)", async () => { + // DELETED warehouse is classified as "unavailable" + mocks.getWarehouseState.mockResolvedValue("DELETED"); + // Mock the query path to return degraded queries so it triggers environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [ + { + name: "q", + type: '{ name: "q"; parameters: Record; result: unknown; }', + }, + ], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-abc123", + mode: "blocking", + }); + + // Find the typegen warning call (skip other loggers) + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + // Strip ANSI codes for clean assertion + const cleanWarnings = stripAnsi(warnings); + + // Must contain stable prefix, warehouse ID, and the unavailable label + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-abc123"); + expect(cleanWarnings).toContain("warehouse unavailable"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warning: environmental failure (auth) with committed types → warning contains 'auth blocked' label", async () => { + // Query path returns auth failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "auth", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-auth", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-auth"); + expect(cleanWarnings).toContain("auth blocked"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warning: environmental failure (connectivity) with committed types → warning contains 'warehouse unreachable' label", async () => { + // Query path returns connectivity failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unreachable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-net", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-net"); + expect(cleanWarnings).toContain("warehouse unreachable"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("crash: deterministic error (404 bad warehouse id) STILL crashes even with committed types present", async () => { + // A 404 is deterministic, not environmental — committed types cannot save a deterministic error + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [{ name: "test", message: "warehouse not found (404)" }], + hadEnvironmentalFailure: false, + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-missing", + mode: "blocking", + }).then( + () => { + throw new Error("expected generateFromEntryPoint to reject"); + }, + (err: unknown) => err, + ); + + // Deterministic errors throw TypegenFatalError even with committed types + expect(error).toBeInstanceOf(TypegenFatalError); + // No warning — this is a deterministic failure + const typegenWarns = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + expect(typegenWarns.length).toBe(0); + } finally { + warnSpy.mockRestore(); + } + }); + + test("partial presence: only analytics.d.ts exists (metric absent) + environmental → warning emitted (partial presence counts)", async () => { + // Keep analytics.d.ts but remove metric file + expect(fs.existsSync(outFile)).toBe(true); + fs.rmSync(metricFile, { force: true }); + + // Query path returns environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-partial", + mode: "blocking", + }); + + // Warning emitted because at least one committed type exists (analytics.d.ts) + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + } finally { + warnSpy.mockRestore(); + } + }); + + test("CI=true: warning output is ANSI-free (plain text for log parsing)", async () => { + process.env.CI = "true"; + // Query path returns environmental failure + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-ci", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + // Verify no ANSI escape codes: stripping SGR sequences leaves it unchanged. + expect(stripAnsi(warnings)).toBe(warnings); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("wh-ci"); + expect(warnings).toContain("warehouse unavailable"); + } finally { + delete process.env.CI; + warnSpy.mockRestore(); + } + }); + + test("metric path: environmental failure + committed analytics exists → metric warning uses correct cause label", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + + // Pre-create metric committed types + fs.writeFileSync(metricFile, "// committed metric types\n", "utf-8"); + + // Metric preflight reports auth failure (environmental, not deterministic 404/400) + mocks.getWarehouseState.mockRejectedValue( + Object.assign( + new Error("PERMISSION_DENIED: cannot read warehouse wh-1"), + { status: 403 }, + ), + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-metric-auth", + mode: "blocking", + }); + + const warnCalls = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")); + + expect(warnCalls.length).toBeGreaterThan(0); + const warnings = warnCalls.join("\n"); + const cleanWarnings = stripAnsi(warnings); + + // The metric path's auth error should bubble up and generate the warning + expect(cleanWarnings).toContain("AppKit typegen: using committed types"); + expect(cleanWarnings).toContain("wh-metric-auth"); + expect(cleanWarnings).toContain("auth blocked"); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe("generateFromEntryPoint — Phase 3: has-types gate crash (no committed types)", () => { + const gateDir = path.join(__dirname, "__output_gate_crash__"); + const queryFolder = path.join(gateDir, "queries"); + const outFile = path.join(gateDir, "generated", "analytics.d.ts"); + + const degradedSchema = (name: string) => ({ + name, + type: `{ name: "${name}"; parameters: Record; result: unknown; }`, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.cacheFile.contents = undefined; + // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. + fs.rmSync(gateDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + // A degraded query in blocking mode → write suppressed (Phase 1) → nothing on disk. + mocks.generateQueriesFromDescribe.mockResolvedValue({ + schemas: [degradedSchema("offline_query")], + syntaxErrors: [], + fatalErrors: [], + hadEnvironmentalFailure: true, + environmentalCause: "unavailable", + }); + }); + + afterAll(() => { + fs.rmSync(gateDir, { recursive: true, force: true }); + }); + + test("blocking + environmental failure + NO committed types → crash with run-locally remedy", async () => { + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-nogate", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // Core safety path: no committed .d.ts to fall back on → build must fail. + expect(err).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((err as Error).message); + expect(message).toContain("generate-types --wait"); + expect(message).toContain("wh-nogate"); + // Phase 1 suppressed the degraded write, so nothing was written this run either. + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("blocking + environmental failure + only serving.d.ts present → still crashes (serving excluded from gate)", async () => { + // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.d.ts stay absent. + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync( + path.join(path.dirname(outFile), "serving.d.ts"), + "// committed serving types\n", + "utf-8", + ); + + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-serving", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // serving.d.ts presence must NOT satisfy the has-types gate. + expect(err).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((err as Error).message); + expect(message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + }); +}); diff --git a/packages/appkit/src/type-generator/types.ts b/packages/appkit/src/type-generator/types.ts index e947bb530..257379337 100644 --- a/packages/appkit/src/type-generator/types.ts +++ b/packages/appkit/src/type-generator/types.ts @@ -138,12 +138,21 @@ export interface QueryFatalError { * warehouse (genuine SQL errors). Connectivity failures are deliberately NOT * included: they degrade silently (reuse last-known-good type or emit * `unknown`) so a transient outage never fails a build. - * @property fatalErrors - non-SQL fatal describe request failures. These still - * produce `result: unknown` schemas so callers can write declarations before - * surfacing the error. + * @property fatalErrors - deterministic non-SQL fatal describe request failures + * (404/400). These still produce `result: unknown` schemas so callers can write + * declarations before surfacing the error. + * @property hadEnvironmentalFailure - `true` when an environmental failure occurred + * in blocking mode (auth, connectivity, timeouts, or other unrecognized failures). + * Used by {@link generateFromEntryPoint} to decide whether to apply the has-types + * gate. Always false in non-blocking mode. + * @property environmentalCause - coarse cause label for the environmental failure, + * one of "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other). + * Only set when hadEnvironmentalFailure is true; used by the warning message. */ export interface QueryGenerationResult { schemas: QuerySchema[]; syntaxErrors: QuerySyntaxError[]; fatalErrors: QueryFatalError[]; + hadEnvironmentalFailure?: boolean; + environmentalCause?: "auth" | "unreachable" | "unavailable"; } From 157f055307c4201d6b0d7994bd0fe7a2cb50a941 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 18:11:54 +0200 Subject: [PATCH 19/28] docs: document CI-resilient --wait typegen behavior Phase 4 of typegen-ci-resilient-describe. Updates the type-generation docs to describe the committed-types fallback and two-bucket failure taxonomy for blocking (`--wait`) builds: committed .d.ts as the fallback of record, --wait never overwriting good types with degraded ones, deterministic failures (SQL syntax / 404 / 400) crashing vs. environmental failures (auth / connectivity / deleted / timeout) gating on committed-type presence, the loud stderr warning, and the run-locally remedy for a first build with no committed types. Notes the metric-views-only edge case (empty analytics.d.ts satisfies the gate). Refreshes the metric-view section to reference the same taxonomy instead of the old always-fail framing. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/docs/development/type-generation.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 04b2091c6..bbce8d855 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -82,7 +82,22 @@ Pass `--wait` for CI and production builds, where accurate types must be present 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`. +#### CI resilience: committed types as fallback + +In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed `.d.ts` files** (`shared/appkit-types/analytics.d.ts`, `metric-views.d.ts`) as the fallback when the warehouse is unreachable. These committed files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. + +The generator **never overwrites committed types with degraded (`result: unknown`) types** — it writes real types, or it does not write at all. + +A **two-bucket failure taxonomy** determines whether the build crashes or falls back to committed types: + +- **Deterministic failures (always crash):** SQL syntax errors in your queries (genuine DESCRIBE failure against a reachable warehouse), HTTP 404 (bad or unknown warehouse ID), HTTP 400 (malformed request). These are developer or configuration errors that committed types must not hide. +- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If committed types exist, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If no committed types exist, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the `.d.ts` files. + +The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types. + +**Note:** If your app declares only metric views and no `config/queries/`, the first build still writes an empty `analytics.d.ts`, which counts as "committed types present" for the gate. An environmental failure will then fall back and warn rather than crash, even on a first build — an accepted v1 simplification. + +The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. ## Metric-view types @@ -90,7 +105,7 @@ In blocking mode the generator starts a stopped warehouse, waits (bounded) for i - `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 `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` that same situation fails the build so CI never ships incomplete metric types. A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. +If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. `definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`): From 936f7b4728b62e697faa559bdaf112458a231e38 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 3 Aug 2026 18:47:53 +0200 Subject: [PATCH 20/28] chore: remove implementation-phase narration and slop from typegen changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wash of the typegen-ci-resilient-describe branch: strips loop-process "Phase N" labels from comments, test names, and describe titles (keeping the semantic text), removes an unnecessary comment / empty else-block / useless default parameter, rewrites two stale+duplicated write-suppression comments to match the actual behavior, and converts errors.test.ts's `(error as any)` casts to the sibling `Object.assign(new Error(...), { ... })` idiom. Comments, names, and test-setup style only — no logic or assertion changes (537 tests still pass). Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 31 ++--- .../src/type-generator/query-registry.ts | 5 +- .../src/type-generator/tests/errors.test.ts | 118 +++++++++--------- .../tests/generate-queries.test.ts | 21 ++-- .../src/type-generator/tests/index.test.ts | 54 ++++---- 5 files changed, 113 insertions(+), 116 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 957569b00..354cfcd77 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -57,7 +57,6 @@ function classifyEnvironmentalCause( error: unknown, ): "auth" | "unreachable" | "unavailable" { if (isConnectivityError(error)) return "unreachable"; - // Check for auth status (401/403) if (typeof error === "object" && error !== null) { const err = error as Record; const status = err.status ?? err.statusCode; @@ -83,7 +82,7 @@ const MV_PREFLIGHT_WAIT_MAX_MS = 300_000; * @param warehouseId - the warehouse ID */ function determineWarningMessage( - cause: "auth" | "unreachable" | "unavailable" = "unavailable", + cause: "auth" | "unreachable" | "unavailable", warehouseId: string, ): string { const causeLabel = @@ -419,12 +418,9 @@ export async function generateFromEntryPoint(options: { const typeDeclarations = generateTypeDeclarations(queryRegistry); - // In blocking mode, suppress writes when any query is degraded AND there are - // no syntax/fatal errors to preserve committed .d.ts files as the fallback of - // record. Degraded writes still happen when there are preflight errors (which - // write before throwing). Non-blocking mode always writes. The throw still - // fires at the end if there are errors — this just prevents overwriting - // committed good types with degraded ones from pure connectivity failures. + // In blocking mode, never overwrite committed types with a degraded result: + // if any query degraded to `result: unknown`, skip the write and leave the + // committed .d.ts as the fallback of record. Non-blocking mode always writes. const hasAnyDegradedQuery = queryRegistry.some(isQueryDegraded); const shouldWriteQueries = mode !== "blocking" || !hasAnyDegradedQuery; @@ -496,8 +492,8 @@ export async function generateFromEntryPoint(options: { await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); await migrateProjectConfig(projectRoot); - // Phase 3: Unified terminal decision combining deterministic & environmental failures - // with a has-types gate in blocking mode. + // Unified terminal decision: deterministic failures crash; environmental + // failures fall to the has-types gate in blocking mode. // Deterministic failures (SQL syntax errors or 404/400 HTTP) always crash regardless of mode. if (syntaxErrors.length > 0) { @@ -757,14 +753,12 @@ export async function syncMetricViewsTypes(options: { // degrade silently. The degraded schemas are not cached (see the write // block), so a later pass re-probes. described = describeNeeded.map(emptyMetricSchema); + // Only deterministic fatals (404/400) record errors; environmental failures + // degrade silently for the has-types gate to handle. if (!hadEnvironmentalFailure) { - // Only deterministic fatals (404/400) record errors. for (const entry of describeNeeded) { fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); } - } else { - // Environmental failure: don't record in fatalErrors, let the has-types - // gate handle it later. } } else if (describeNeeded.length === 0) { // Nothing left to describe — every configured key was a cache hit. @@ -876,12 +870,9 @@ export async function syncMetricViewsTypes(options: { return emptyMetricSchema(entry); }); - // In blocking mode, suppress writes when any metric is degraded AND there are - // no failures to preserve committed .d.ts files as the fallback of record. - // Degraded writes still happen when there are preflight fatals or sync failures - // (which throw after writing). Non-blocking mode always writes. This just - // prevents overwriting committed good types with degraded ones from pure - // warehouse-not-ready scenarios. + // Same anti-clobber rule as the query path: when suppressDegradedWrite is set + // (blocking mode), skip the write if any metric degraded, preserving the + // committed metric-views.d.ts. Non-blocking mode always writes. const shouldWriteMetrics = !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 65091a990..b0f8501d3 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -694,7 +694,8 @@ export async function generateQueriesFromDescribe( // not-ready warehouse degrades exactly like a per-query outage. let decision: ReturnType = "proceed"; let fatalMessage = ""; - // Track whether a non-connectivity environmental failure occurred (for Phase 3). + // Track whether a non-connectivity environmental failure occurred so the + // caller's has-types gate can decide crash-vs-fall-back. let isEnvironmental = false; if (mode === "non-blocking") { // `non-blocking` never describes and must make ZERO warehouse round-trips: @@ -766,7 +767,7 @@ export async function generateQueriesFromDescribe( } } - // Track environmental failures in blocking mode for Phase 3 gate. + // Record blocking-mode environmental failures for the has-types gate. if ( mode === "blocking" && ((decision === "degradeAll" && isEnvironmental) || diff --git a/packages/appkit/src/type-generator/tests/errors.test.ts b/packages/appkit/src/type-generator/tests/errors.test.ts index a29c7f7e8..12f314398 100644 --- a/packages/appkit/src/type-generator/tests/errors.test.ts +++ b/packages/appkit/src/type-generator/tests/errors.test.ts @@ -4,112 +4,117 @@ import { classifyBlockingFailure } from "../errors"; describe("classifyBlockingFailure", () => { describe("deterministic failures", () => { it("classifies HTTP 400 as deterministic", () => { - const error = new Error("Bad request"); - (error as any).status = 400; + const error = Object.assign(new Error("Bad request"), { status: 400 }); expect(classifyBlockingFailure(error)).toBe("deterministic"); }); it("classifies HTTP 404 as deterministic", () => { - const error = new Error("Not found"); - (error as any).status = 404; + const error = Object.assign(new Error("Not found"), { status: 404 }); expect(classifyBlockingFailure(error)).toBe("deterministic"); }); it("classifies HTTP 404 from response.status as deterministic", () => { - const error = new Error("Not found"); - (error as any).response = { status: 404 }; + const error = Object.assign(new Error("Not found"), { + response: { status: 404 }, + }); expect(classifyBlockingFailure(error)).toBe("deterministic"); }); it("classifies HTTP 404 from statusCode as deterministic", () => { - const error = new Error("Not found"); - (error as any).statusCode = 404; + const error = Object.assign(new Error("Not found"), { statusCode: 404 }); expect(classifyBlockingFailure(error)).toBe("deterministic"); }); }); describe("environmental failures - auth", () => { it("classifies HTTP 401 as environmental", () => { - const error = new Error("Unauthorized"); - (error as any).status = 401; + const error = Object.assign(new Error("Unauthorized"), { status: 401 }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies HTTP 403 as environmental", () => { - const error = new Error("Forbidden"); - (error as any).status = 403; + const error = Object.assign(new Error("Forbidden"), { status: 403 }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); }); describe("environmental failures - other HTTP statuses", () => { it("classifies HTTP 500 as environmental (not in deterministic set)", () => { - const error = new Error("Internal server error"); - (error as any).status = 500; + const error = Object.assign(new Error("Internal server error"), { + status: 500, + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies HTTP 502 as environmental (via connectivity)", () => { - const error = new Error("Bad gateway"); - (error as any).status = 502; + const error = Object.assign(new Error("Bad gateway"), { status: 502 }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies HTTP 503 as environmental (via connectivity)", () => { - const error = new Error("Service unavailable"); - (error as any).status = 503; + const error = Object.assign(new Error("Service unavailable"), { + status: 503, + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies HTTP 504 as environmental (via connectivity)", () => { - const error = new Error("Gateway timeout"); - (error as any).status = 504; + const error = Object.assign(new Error("Gateway timeout"), { + status: 504, + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); }); describe("environmental failures - connectivity codes", () => { it("classifies ECONNREFUSED as environmental", () => { - const error = new Error("Connection refused"); - (error as any).code = "ECONNREFUSED"; + const error = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies ENOTFOUND as environmental", () => { - const error = new Error("ENOTFOUND"); - (error as any).code = "ENOTFOUND"; + const error = Object.assign(new Error("ENOTFOUND"), { + code: "ENOTFOUND", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies ETIMEDOUT as environmental", () => { - const error = new Error("Timed out"); - (error as any).code = "ETIMEDOUT"; + const error = Object.assign(new Error("Timed out"), { + code: "ETIMEDOUT", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies ECONNRESET as environmental", () => { - const error = new Error("Connection reset"); - (error as any).code = "ECONNRESET"; + const error = Object.assign(new Error("Connection reset"), { + code: "ECONNRESET", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies UND_ERR_* codes as environmental", () => { - const error = new Error("undici error"); - (error as any).code = "UND_ERR_ABORTED"; + const error = Object.assign(new Error("undici error"), { + code: "UND_ERR_ABORTED", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); }); describe("environmental failures - TLS codes", () => { it("classifies CERT_HAS_EXPIRED as environmental", () => { - const error = new Error("Certificate has expired"); - (error as any).code = "CERT_HAS_EXPIRED"; + const error = Object.assign(new Error("Certificate has expired"), { + code: "CERT_HAS_EXPIRED", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies DEPTH_ZERO_SELF_SIGNED_CERT as environmental", () => { - const error = new Error("Self signed cert"); - (error as any).code = "DEPTH_ZERO_SELF_SIGNED_CERT"; + const error = Object.assign(new Error("Self signed cert"), { + code: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); }); @@ -179,42 +184,40 @@ describe("classifyBlockingFailure", () => { describe("wrapped errors", () => { it("classifies deterministic status (404) nested under .cause as deterministic", () => { - const causedError = new Error("Not found"); - (causedError as any).status = 404; - - const error = new Error("Outer error"); - (error as any).cause = causedError; - + const causedError = Object.assign(new Error("Not found"), { + status: 404, + }); + const error = Object.assign(new Error("Outer error"), { + cause: causedError, + }); expect(classifyBlockingFailure(error)).toBe("deterministic"); }); it("classifies connectivity code nested under .cause as environmental", () => { - const causedError = new Error("Connection refused"); - (causedError as any).code = "ECONNREFUSED"; - - const error = new Error("Outer error"); - (error as any).cause = causedError; - + const causedError = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + const error = Object.assign(new Error("Outer error"), { + cause: causedError, + }); expect(classifyBlockingFailure(error)).toBe("environmental"); }); it("classifies AggregateError with 404 as deterministic", () => { - const statusError = new Error("Not found"); - (statusError as any).status = 404; - + const statusError = Object.assign(new Error("Not found"), { + status: 404, + }); const aggregateError = new AggregateError( [statusError], "Multiple errors", ); - expect(classifyBlockingFailure(aggregateError)).toBe("deterministic"); }); }); describe("purity", () => { it("returns the same classification when called twice with the same input", () => { - const error = new Error("Not found"); - (error as any).status = 404; + const error = Object.assign(new Error("Not found"), { status: 404 }); const result1 = classifyBlockingFailure(error); const result2 = classifyBlockingFailure(error); @@ -224,11 +227,12 @@ describe("classifyBlockingFailure", () => { }); it("returns the same classification for equivalent errors", () => { - const error1 = new Error("Connection refused"); - (error1 as any).code = "ECONNREFUSED"; - - const error2 = new Error("Connection refused"); - (error2 as any).code = "ECONNREFUSED"; + const error1 = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); + const error2 = Object.assign(new Error("Connection refused"), { + code: "ECONNREFUSED", + }); expect(classifyBlockingFailure(error1)).toBe( classifyBlockingFailure(error2), diff --git a/packages/appkit/src/type-generator/tests/generate-queries.test.ts b/packages/appkit/src/type-generator/tests/generate-queries.test.ts index 6724df7e2..60edcdcb8 100644 --- a/packages/appkit/src/type-generator/tests/generate-queries.test.ts +++ b/packages/appkit/src/type-generator/tests/generate-queries.test.ts @@ -776,9 +776,9 @@ describe("generateQueriesFromDescribe", () => { test.each(["DELETED", "DELETING"] as const)( "%s + blocking mode — environmental, degrades silently, hadEnvironmentalFailure set for gate", async (state) => { - // Phase 3: DELETED/DELETING are environmental (state-based fatals). - // They degrade silently (fatalErrors empty) but set hadEnvironmentalFailure - // for the entry point's has-types gate to handle. + // DELETED/DELETING are environmental (state-based fatals). They degrade + // silently (fatalErrors empty) but set hadEnvironmentalFailure for the + // entry point's has-types gate to handle. mocks.readdir.mockResolvedValue(["a.sql", "b.sql"]); mocks.readFile .mockResolvedValueOnce("SELECT id FROM a") @@ -790,11 +790,11 @@ describe("generateQueriesFromDescribe", () => { mode: "blocking", }); - // Phase 3: environmental failures degrade, not fatal at query level. + // Environmental failures degrade, not fatal at query level. expect(mocks.startWarehouse).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - expect(fatalErrors).toEqual([]); // Phase 3: environmental, not fatal - expect(hadEnvironmentalFailure).toBe(true); // Phase 3: track for gate + expect(fatalErrors).toEqual([]); // environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // tracked for the gate expect(syntaxErrors).toEqual([]); // Schemas are still produced (degraded) so the .d.ts is written before // generateFromEntryPoint uses the gate to decide throw/warn. @@ -805,8 +805,9 @@ describe("generateQueriesFromDescribe", () => { ); test("STOPPED + blocking — start succeeds but warehouse never reaches RUNNING is environmental, degrades silently", async () => { - // Phase 3: wait timeout (non-RUNNING resolve) is environmental (state-based fatal). - // It degrades silently (fatalErrors empty) but sets hadEnvironmentalFailure for the gate. + // A wait timeout (non-RUNNING resolve) is environmental (state-based + // fatal). It degrades silently (fatalErrors empty) but sets + // hadEnvironmentalFailure for the gate. vi.useFakeTimers(); try { mocks.readdir.mockResolvedValue(["a.sql"]); @@ -828,8 +829,8 @@ describe("generateQueriesFromDescribe", () => { expect(mocks.startWarehouse).toHaveBeenCalledTimes(1); expect(mocks.executeStatement).not.toHaveBeenCalled(); expect(syntaxErrors).toEqual([]); - expect(fatalErrors).toEqual([]); // Phase 3: environmental, not fatal - expect(hadEnvironmentalFailure).toBe(true); // Phase 3: track for gate + expect(fatalErrors).toEqual([]); // environmental, not fatal + expect(hadEnvironmentalFailure).toBe(true); // tracked for the gate expect(schemas[0].type).toContain("result: unknown"); } finally { vi.useRealTimers(); diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 3341ae510..3732e7b97 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -593,11 +593,11 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect((error as Error).message).toContain("revenue"); expect((error as Error).message).toContain("DESCRIBE exploded"); - // Phase 1: the degraded metric write is suppressed in blocking mode (committed types preserved). + // The degraded metric write is suppressed in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); }); - test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate, Phase 1 suppresses write", async () => { + 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(() => {}); @@ -607,8 +607,8 @@ describe("generateFromEntryPoint — metric-view emission", () => { // 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). - // Per Phase 1 anti-clobber: degraded artifacts are NOT written in blocking - // mode when there are no failures (to preserve committed good types). + // Degraded artifacts are NOT written in blocking mode when there are no failures + // (to preserve committed good types). await expect( generateFromEntryPoint({ outFile, @@ -624,7 +624,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { const warned = warnSpy.mock.calls.flat().map(String).join("\n"); expect(warned).not.toContain("metric sync failed"); - // Phase 1: degraded artifacts are suppressed, not written (to preserve committed types). + // Degraded artifacts are suppressed, not written (to preserve committed types). expect(fs.existsSync(metricFile)).toBe(false); } finally { warnSpy.mockRestore(); @@ -705,7 +705,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + DELETED: environmental failure with committed types → no throw, warning emitted", async () => { - // Phase 3: DELETED is environmental. Since the query path writes analytics.d.ts + // DELETED is environmental. Since the query path writes analytics.d.ts // (even with empty registry), committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("DELETED"); @@ -719,7 +719,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { mode: "blocking", }); - // Phase 3: environmental failure with committed types → no throw. + // Environmental failure with committed types → no throw. // The generator returns normally (exit 0). } finally { warnSpy.mockRestore(); @@ -730,7 +730,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.waitUntilRunning).not.toHaveBeenCalled(); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is NEVER cached (mirrors the query path): the key is @@ -741,7 +741,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + preflight wait rejects with a timeout: environmental failure with committed types → no throw, warning emitted", async () => { - // Phase 3: timeout is environmental. Since the query path writes analytics.d.ts, + // Timeout is environmental. Since the query path writes analytics.d.ts, // committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); @@ -761,7 +761,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { mode: "blocking", }); - // Phase 3: environmental failure with committed types → no throw. + // Environmental failure with committed types → no throw. } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -776,7 +776,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect.objectContaining({ maxMs: 300_000 }), ); expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — the key stays uncached for the next @@ -785,12 +785,12 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(metrics.revenue).toBeUndefined(); }); - test("blocking + preflight wait resolves non-RUNNING (STOPPED): degrades, does not throw, Phase 1 suppresses write", async () => { + test("blocking + preflight wait resolves non-RUNNING (STOPPED): degrades, does not throw", async () => { // A non-RUNNING *resolve* (not a throw) for a startable state is soft: fall // through to DESCRIBE, which degrades on the still-cold warehouse. Only a // DELETED/DELETING resolve (or a thrown deterministic error) is fatal. - // Per Phase 1 anti-clobber: degraded artifacts are NOT written in blocking - // mode when there are no failures (to preserve committed good types). + // Degraded artifacts are NOT written in blocking mode when there are no failures + // (to preserve committed good types). writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockResolvedValue("STOPPED"); @@ -831,9 +831,9 @@ describe("generateFromEntryPoint — metric-view emission", () => { mocks.waitUntilRunning.mock.calls[0][2].treatStoppedAsTransient, ).toBeUndefined(); // The DESCRIBE batch still ran (fall-through), and its non-terminal answer - // degraded the key per Phase 1 semantics. + // degraded the key. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); - // Phase 1: degraded artifacts are suppressed, not written (to preserve committed types). + // Degraded artifacts are suppressed, not written (to preserve committed types). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached; the key stays uncached and the next @@ -852,7 +852,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ])( "blocking + warehouse deleted mid-wait (probe read %s): environmental failure with committed types → no throw, warning emitted", async (probedState, startsWarehouse) => { - // Phase 3: DELETED mid-wait is environmental. Since the query path writes + // DELETED mid-wait is environmental. Since the query path writes // analytics.d.ts, committed types exist, so emit warning + return 0. writeMetricConfig(); mocks.getWarehouseState.mockResolvedValue(probedState); @@ -868,7 +868,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { mode: "blocking", }); - // Phase 3: environmental failure with committed types → no throw. + // Environmental failure with committed types → no throw. expect(mocks.startWarehouse).toHaveBeenCalledTimes( startsWarehouse ? 1 : 0, @@ -876,7 +876,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // The DESCRIBE batch is skipped — nothing can answer it. expect(mocks.executeStatement).not.toHaveBeenCalled(); - // Phase 1: degraded metric artifacts are NOT written in blocking mode (committed types preserved). + // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(metricFile)).toBe(false); // The degraded outcome is not cached — no sticky entry to serve later. @@ -1779,8 +1779,8 @@ describe("generateFromEntryPoint — metric cache section", () => { ); }); -// ── Phase 1: Write suppression for blocking mode with degraded types ── -describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", () => { +// ── Write suppression for blocking mode with degraded types ── +describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { const antiClobberDir = path.join(__dirname, "__output_anti_clobber__"); const queryFolder = path.join(antiClobberDir, "queries"); const metricViewsFolder = path.join(antiClobberDir, "metric-views"); @@ -1947,7 +1947,7 @@ describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", ( ); expect(error).toBeInstanceOf(TypegenSyntaxError); - // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + // Degraded artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(outFile)).toBe(false); }); @@ -2080,12 +2080,12 @@ describe("generateFromEntryPoint — Phase 1: anti-clobber for blocking mode", ( ); expect(error).toBeInstanceOf(TypegenFatalError); - // Phase 1: degraded artifacts are NOT written in blocking mode (committed types preserved). + // Degraded artifacts are NOT written in blocking mode (committed types preserved). expect(fs.existsSync(outFile)).toBe(false); }); }); -describe("generateFromEntryPoint — Phase 3: warning message with cause labels", () => { +describe("generateFromEntryPoint — warning message with cause labels", () => { const warningTestDir = path.join(__dirname, "__output_warning__"); const queryFolder = path.join(warningTestDir, "queries"); const metricViewsFolder = path.join(warningTestDir, "metric-views"); @@ -2400,7 +2400,7 @@ describe("generateFromEntryPoint — Phase 3: warning message with cause labels" }); }); -describe("generateFromEntryPoint — Phase 3: has-types gate crash (no committed types)", () => { +describe("generateFromEntryPoint — has-types gate crash (no committed types)", () => { const gateDir = path.join(__dirname, "__output_gate_crash__"); const queryFolder = path.join(gateDir, "queries"); const outFile = path.join(gateDir, "generated", "analytics.d.ts"); @@ -2416,7 +2416,7 @@ describe("generateFromEntryPoint — Phase 3: has-types gate crash (no committed // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. fs.rmSync(gateDir, { recursive: true, force: true }); fs.mkdirSync(queryFolder, { recursive: true }); - // A degraded query in blocking mode → write suppressed (Phase 1) → nothing on disk. + // A degraded query in blocking mode → write suppressed → nothing on disk. mocks.generateQueriesFromDescribe.mockResolvedValue({ schemas: [degradedSchema("offline_query")], syntaxErrors: [], @@ -2446,7 +2446,7 @@ describe("generateFromEntryPoint — Phase 3: has-types gate crash (no committed const message = stripAnsi((err as Error).message); expect(message).toContain("generate-types --wait"); expect(message).toContain("wh-nogate"); - // Phase 1 suppressed the degraded write, so nothing was written this run either. + // The degraded write was suppressed, so nothing was written this run either. expect(fs.existsSync(outFile)).toBe(false); }); From bd5335042d318dfaf2a4446d4c8c501fa528f819 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 10:21:58 +0200 Subject: [PATCH 21/28] chore: regenerate bundle-size baseline against merged tree The merge took main's baseline to resolve the conflict; this remeasures against the post-merge build so the numbers reflect the actual tree. `size:compare` now reports no change. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- bundle-size-baseline.json | 86 +++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index e719b6a89..cdca23920 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 816319, - "unpacked": 2850839 + "packed": 823639, + "unpacked": 2872404 }, "dist": { "total": { - "raw": 2837182, - "gzip": 951166 + "raw": 2858747, + "gzip": 959693 }, "js": { - "raw": 839388, - "gzip": 293125 + "raw": 844853, + "gzip": 295360 }, "types": { - "raw": 308962, - "gzip": 105526 + "raw": 311200, + "gzip": 106574 }, "maps": { - "raw": 1678037, - "gzip": 548697 + "raw": 1691899, + "gzip": 553941 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 559 + "fileCount": 565 }, "entries": [ { "id": ".", - "gzip": 90682, + "gzip": 91172, "composition": { - "initialGzip": 88108, + "initialGzip": 88598, "lazyGzip": 2574, - "totalGzip": 90682, - "own": 288132, + "totalGzip": 91172, + "own": 289507, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 84010, + "gzip": 84500, "kind": "initial" }, { @@ -127,17 +127,17 @@ }, { "id": "./type-generator", - "gzip": 19143, + "gzip": 19377, "composition": { - "initialGzip": 19143, + "initialGzip": 19377, "lazyGzip": 0, - "totalGzip": 19143, - "own": 55109, + "totalGzip": 19377, + "own": 55765, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 19143, + "gzip": 19377, "kind": "initial" } ] @@ -148,25 +148,25 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 312521, - "unpacked": 1301416 + "packed": 342822, + "unpacked": 1392263 }, "dist": { "total": { - "raw": 1297470, - "gzip": 431699 + "raw": 1388317, + "gzip": 466417 }, "js": { - "raw": 368202, - "gzip": 122338 + "raw": 390794, + "gzip": 131361 }, "types": { - "raw": 210200, - "gzip": 76204 + "raw": 231270, + "gzip": 84276 }, "maps": { - "raw": 702208, - "gzip": 229811 + "raw": 749393, + "gzip": 247434 }, "css": { "raw": 16860, @@ -176,22 +176,22 @@ "raw": 0, "gzip": 0 }, - "fileCount": 472 + "fileCount": 490 }, "entries": [ { "id": "./js", - "gzip": 4254, + "gzip": 4914, "composition": { - "initialGzip": 4410, + "initialGzip": 5069, "lazyGzip": 50587, - "totalGzip": 54997, - "own": 11865, + "totalGzip": 55656, + "own": 13629, "nodeModules": 213288, "chunks": [ { "label": "index.js", - "gzip": 4290, + "gzip": 4949, "kind": "initial" }, { @@ -227,17 +227,17 @@ }, { "id": "./react", - "gzip": 47453, + "gzip": 48968, "composition": { - "initialGzip": 439562, + "initialGzip": 440989, "lazyGzip": 49772, - "totalGzip": 489334, - "own": 172143, - "nodeModules": 1403070, + "totalGzip": 490761, + "own": 176411, + "nodeModules": 1403082, "chunks": [ { "label": "index.js", - "gzip": 437412, + "gzip": 438839, "kind": "initial" }, { From eda5378abdac781bc66ff1c1d179d9f670acecc7 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 10:27:28 +0200 Subject: [PATCH 22/28] fix(appkit): recognize wrapped and response-carried auth status in cause labels `classifyEnvironmentalCause` read only `err.status`/`err.statusCode` on the top-level error, while `errors.ts` already resolved `response.status` and walked `cause`/`AggregateError` chains. A 403 reported under `response` or wrapped in a cause chain was therefore labeled "warehouse unavailable" instead of "auth blocked", pointing CI at the wrong remedy. Move the helper next to `classifyBlockingFailure` in errors.ts so both classifiers share one status-extraction path, and reuse the existing chain walk. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/errors.ts | 34 ++++++++++++ packages/appkit/src/type-generator/index.ts | 20 +------ .../src/type-generator/tests/errors.test.ts | 52 ++++++++++++++++++- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/appkit/src/type-generator/errors.ts b/packages/appkit/src/type-generator/errors.ts index 61409ed42..8a074795c 100644 --- a/packages/appkit/src/type-generator/errors.ts +++ b/packages/appkit/src/type-generator/errors.ts @@ -193,3 +193,37 @@ export function classifyBlockingFailure( // timeout messages, plain Error objects) → environmental. return "environmental"; } + +/** + * Coarse cause label for an environmental failure, used by the `--wait` + * committed-types warning so the log says *why* generation fell back. + * + * Returns: + * - "unreachable": transport/connectivity failure (see {@link isConnectivityError}). + * - "auth": HTTP 401/403, including a status carried on `response.status` or + * wrapped in a `cause`/`AggregateError` chain. + * - "unavailable": everything else (DELETED/DELETING, wait timeouts, degraded + * DESCRIBEs). + */ +export function classifyEnvironmentalCause( + error: unknown, +): "auth" | "unreachable" | "unavailable" { + if (isConnectivityError(error)) return "unreachable"; + + // Walk the error chain so a wrapped 401/403 is still labeled as auth. + const seen = new Set(); + const stack = [error]; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined || seen.has(current)) continue; + seen.add(current); + + const status = getErrorStatus(current); + if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) return "auth"; + + stack.push(...getErrorChildren(current)); + } + + return "unavailable"; +} diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 354cfcd77..bb7fd62a3 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -14,6 +14,7 @@ import { } from "./cache"; import { classifyBlockingFailure, + classifyEnvironmentalCause, getErrorDiagnostic, isConnectivityError, } from "./errors"; @@ -49,25 +50,6 @@ dotenv.config(); const logger = createLogger("type-generator"); -/** - * Classify an environmental failure into one of three coarse cause labels - * for the warning message. - */ -function classifyEnvironmentalCause( - error: unknown, -): "auth" | "unreachable" | "unavailable" { - if (isConnectivityError(error)) return "unreachable"; - if (typeof error === "object" && error !== null) { - const err = error as Record; - const status = err.status ?? err.statusCode; - if (typeof status === "number" && (status === 401 || status === 403)) { - return "auth"; - } - } - // Default for other environmental failures (DELETED/DELETING, timeouts, etc.) - return "unavailable"; -} - /** * 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) diff --git a/packages/appkit/src/type-generator/tests/errors.test.ts b/packages/appkit/src/type-generator/tests/errors.test.ts index 12f314398..a978cde0d 100644 --- a/packages/appkit/src/type-generator/tests/errors.test.ts +++ b/packages/appkit/src/type-generator/tests/errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyBlockingFailure } from "../errors"; +import { classifyBlockingFailure, classifyEnvironmentalCause } from "../errors"; describe("classifyBlockingFailure", () => { describe("deterministic failures", () => { @@ -241,3 +241,53 @@ describe("classifyBlockingFailure", () => { }); }); }); + +describe("classifyEnvironmentalCause", () => { + it("labels connectivity failures as unreachable", () => { + const error = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + expect(classifyEnvironmentalCause(error)).toBe("unreachable"); + }); + + it.each([401, 403])("labels HTTP %i as auth", (status) => { + const error = Object.assign(new Error("Denied"), { status }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels auth status carried on statusCode", () => { + const error = Object.assign(new Error("Denied"), { statusCode: 403 }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels auth status carried on response.status", () => { + const error = Object.assign(new Error("Denied"), { + response: { status: 401 }, + }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("labels an auth status wrapped in a cause chain", () => { + const error = new Error("Request failed", { + cause: Object.assign(new Error("Denied"), { status: 403 }), + }); + expect(classifyEnvironmentalCause(error)).toBe("auth"); + }); + + it("prefers unreachable when a failure is both connectivity and status-bearing", () => { + // 503 is connectivity; the label should describe the transport problem. + const error = Object.assign(new Error("Service unavailable"), { + status: 503, + }); + expect(classifyEnvironmentalCause(error)).toBe("unreachable"); + }); + + it.each([ + ["a warehouse state message", new Error("warehouse wh-1 is DELETED")], + ["a plain error", new Error("something went wrong")], + ["a non-auth status", Object.assign(new Error("teapot"), { status: 418 })], + ["a non-object", "just a string"], + ])("labels %s as unavailable", (_name, error) => { + expect(classifyEnvironmentalCause(error)).toBe("unavailable"); + }); +}); From 330515a8cbd4b0f0e1120df873675a7bc58a9dd3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 10:27:49 +0200 Subject: [PATCH 23/28] fix(appkit): treat unreachable warehouses as environmental in --wait typegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocking-mode preflight set `decision = "degradeAll"` for connectivity failures but left `isEnvironmental` false, so `hadEnvironmentalFailure` never flipped and the has-types gate never ran for an unreachable warehouse. On a fresh CI checkout that meant: queries degrade to `result: unknown`, write suppression skips `analytics.d.ts` entirely, no fatal errors are recorded, and the run exits 0 having written no types — the build then fails later somewhere less legible. Flag connectivity failures (preflight and per-query DESCRIBE) as environmental so the gate decides: warn and fall back when committed types exist, crash with the run-locally remedy when they don't. This cannot turn a passing build red — with committed types the outcome is unchanged apart from the warning now being emitted. Also return `environmentalCause` from `generateQueriesFromDescribe`, which `QueryGenerationResult` already declared and the metric path already set. Without it every query-path environmental failure fell back to the default "warehouse unavailable" label, and the "warehouse unreachable" label was unreachable in practice. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/type-generator/query-registry.ts | 38 +++++++-- .../tests/generate-queries.test.ts | 78 ++++++++++++++++++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index b0f8501d3..d055659ec 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -7,6 +7,7 @@ import { createLogger } from "../logging/logger"; import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache"; import { classifyBlockingFailure, + classifyEnvironmentalCause, getErrorDiagnostic, isConnectivityError, } from "./errors"; @@ -683,8 +684,10 @@ export async function generateQueriesFromDescribe( // tracked separately below. const fatalErrors: QueryFatalError[] = []; // Track whether an environmental failure occurred in blocking mode (for the - // has-types gate in generateFromEntryPoint). + // has-types gate in generateFromEntryPoint), plus its coarse cause so the + // gate's warning can say why generation fell back to committed types. let hadEnvironmentalFailure = false; + let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; if (uncachedQueries.length > 0) { // One-time warehouse preflight (before issuing any DESCRIBE). A single @@ -694,8 +697,8 @@ export async function generateQueriesFromDescribe( // not-ready warehouse degrades exactly like a per-query outage. let decision: ReturnType = "proceed"; let fatalMessage = ""; - // Track whether a non-connectivity environmental failure occurred so the - // caller's has-types gate can decide crash-vs-fall-back. + // Track whether an environmental failure occurred so the caller's has-types + // gate can decide crash-vs-fall-back. let isEnvironmental = false; if (mode === "non-blocking") { // `non-blocking` never describes and must make ZERO warehouse round-trips: @@ -712,6 +715,7 @@ export async function generateQueriesFromDescribe( // DELETED/DELETING is state-based and environmental. fatalMessage = `warehouse ${warehouseId} is ${state}`; isEnvironmental = true; + environmentalCause = "unavailable"; } if (decision === "startWaitProceed") { // Stopped/stopping warehouse: nudge it out of the stopped state, then @@ -729,6 +733,7 @@ export async function generateQueriesFromDescribe( decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; isEnvironmental = true; // DELETED/DELETING or timeout is environmental + environmentalCause = "unavailable"; } } if (decision === "waitThenProceed") { @@ -741,13 +746,18 @@ export async function generateQueriesFromDescribe( decision = "fatal"; fatalMessage = `warehouse ${warehouseId} did not reach RUNNING (now ${final})`; isEnvironmental = true; // DELETED/DELETING or timeout is environmental + environmentalCause = "unavailable"; } } } catch (err) { if (isConnectivityError(err)) { - // Warehouse unreachable (transient outage): degrade silently like a - // per-query connectivity failure — never fail a build on a blip. + // Warehouse unreachable (transient outage): degrade rather than fail — + // never fail a build on a blip. Still environmental, so the caller's + // has-types gate decides warn-and-fall-back (committed types present) + // vs crash (fresh checkout with nothing to fall back to). decision = "degradeAll"; + isEnvironmental = true; + environmentalCause = "unreachable"; } else { // Classify the exception: deterministic (404/400) or environmental (auth, etc). const classification = classifyBlockingFailure(err); @@ -761,6 +771,7 @@ export async function generateQueriesFromDescribe( // has-types gate to handle later. decision = "degradeAll"; isEnvironmental = true; + environmentalCause = classifyEnvironmentalCause(err); fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } } @@ -969,6 +980,13 @@ export async function generateQueriesFromDescribe( continue; } + // Environmental for the same reason as the preflight connectivity + // branch above, so the has-types gate still sees it. + if (mode === "blocking") { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unreachable"; + } + logger.warn( "DESCRIBE unreachable for %s: %s — %s", queryName, @@ -1080,7 +1098,15 @@ export async function generateQueriesFromDescribe( .sort((a, b) => a.index - b.index) .map((r) => r.schema); - return { schemas, syntaxErrors, fatalErrors, hadEnvironmentalFailure }; + return { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause: hadEnvironmentalFailure + ? environmentalCause + : undefined, + }; } /** diff --git a/packages/appkit/src/type-generator/tests/generate-queries.test.ts b/packages/appkit/src/type-generator/tests/generate-queries.test.ts index 60edcdcb8..5a4dd90ab 100644 --- a/packages/appkit/src/type-generator/tests/generate-queries.test.ts +++ b/packages/appkit/src/type-generator/tests/generate-queries.test.ts @@ -891,7 +891,7 @@ describe("generateQueriesFromDescribe", () => { } }); - test("preflight connectivity error — degradeAll, never describes", async () => { + test("preflight connectivity error — degradeAll, never describes, flagged environmental for the gate", async () => { mocks.readdir.mockResolvedValue(["a.sql"]); mocks.readFile.mockResolvedValue("SELECT id FROM a"); mocks.getWarehouse.mockImplementation(() => { @@ -901,16 +901,88 @@ describe("generateQueriesFromDescribe", () => { ); }); - const { schemas, syntaxErrors, fatalErrors } = + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "blocking", + }); + + // Without the environmental flag a fresh checkout would exit 0 having + // written no types at all: degraded queries suppress the write, and + // nothing else fails the run. + expect(mocks.executeStatement).not.toHaveBeenCalled(); + expect(fatalErrors).toEqual([]); + expect(syntaxErrors).toEqual([]); + expect(schemas[0].type).toContain("result: unknown"); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unreachable"); + }); + + test("preflight auth error — environmentalCause is auth, including on response.status", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + // Status carried on `response.status` rather than `status` — some HTTP + // clients report it there, and it must still label as auth. + mocks.getWarehouse.mockImplementation(() => { + throw Object.assign(new Error("PERMISSION_DENIED"), { + response: { status: 403 }, + }); + }); + + const { fatalErrors, hadEnvironmentalFailure, environmentalCause } = await generateQueriesFromDescribe("/queries", "wh-123", { mode: "blocking", }); - // Unreachable warehouse degrades silently — even in blocking mode. expect(mocks.executeStatement).not.toHaveBeenCalled(); + expect(fatalErrors).toEqual([]); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("auth"); + }); + + test("per-query DESCRIBE connectivity failure is flagged environmental for the gate", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + mocks.getWarehouse.mockReturnValue({ state: "RUNNING" }); + mocks.executeStatement.mockRejectedValue( + Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + ); + + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "blocking", + }); + expect(fatalErrors).toEqual([]); expect(syntaxErrors).toEqual([]); expect(schemas[0].type).toContain("result: unknown"); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unreachable"); + }); + + test("non-blocking mode never reports an environmental cause", async () => { + mocks.readdir.mockResolvedValue(["a.sql"]); + mocks.readFile.mockResolvedValue("SELECT id FROM a"); + + const { hadEnvironmentalFailure, environmentalCause } = + await generateQueriesFromDescribe("/queries", "wh-123", { + mode: "non-blocking", + }); + + // The gate is blocking-only; non-blocking degrades without signaling. + expect(hadEnvironmentalFailure).toBe(false); + expect(environmentalCause).toBeUndefined(); }); test("RUNNING preflight — describes normally", async () => { From 8796c45892e9007bed8f130e3b58fa5ad632c979 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 10:28:21 +0200 Subject: [PATCH 24/28] chore(appkit): correct write-suppression comments and drop test env mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments claimed blocking mode suppresses writes only for pure degradation and that degraded artifacts are still written before a throw. `suppressDegradedWrite: mode === "blocking"` is unconditional, so any degradation suppresses the write including on runs that then throw. The behavior is what the PR intends; the comments described the old shape. The ANSI-free warning test set `process.env.CI` and deleted it in `finally`, clobbering a pre-existing value for later tests. Nothing under `src/type-generator` reads `CI`, so the assignment never affected the assertion — it only risked perturbing third-party color detection, which is exactly what this test checks. Drop it rather than stub it. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 13 +++++++------ .../appkit/src/type-generator/tests/index.test.ts | 4 +--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index bb7fd62a3..3e872c849 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -428,9 +428,9 @@ export async function generateFromEntryPoint(options: { cache: !noCache, metricFetcher, mode, - // In blocking mode, only suppress writes for pure degradation (no failures). - // If there are preflight fatals or sync failures, degraded artifacts are - // still written before the throw. + // In blocking mode, never overwrite committed metric types with a + // degraded result — including on runs that go on to throw, so a failing + // build leaves the committed .d.ts intact. Non-blocking always writes. suppressDegradedWrite: mode === "blocking", }); } catch (configError) { @@ -533,9 +533,10 @@ export interface SyncMetricViewsTypesResult { noConfig: boolean; /** * Per-key fatal preflight errors (empty except in the `blocking`-mode - * deleted/deleting-warehouse and deterministic-preflight-failure cases). The - * artifacts are still written; {@link generateFromEntryPoint} surfaces these - * by throwing {@link TypegenFatalError} after the writes. A `"describe-now"` + * deleted/deleting-warehouse and deterministic-preflight-failure cases). + * {@link generateFromEntryPoint} surfaces these by throwing + * {@link TypegenFatalError}; when the run also degraded, `suppressDegradedWrite` + * means no artifact was written and the committed types stand. A `"describe-now"` * run sets no blocking preflight, so for that mode this is always empty. * ONLY contains deterministic failures (404/400). */ diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 3732e7b97..d9c4a9ecd 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -2313,8 +2313,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); - test("CI=true: warning output is ANSI-free (plain text for log parsing)", async () => { - process.env.CI = "true"; + test("warning output is ANSI-free (plain text for CI log parsing)", async () => { // Query path returns environmental failure mocks.generateQueriesFromDescribe.mockResolvedValue({ schemas: [], @@ -2347,7 +2346,6 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { expect(warnings).toContain("wh-ci"); expect(warnings).toContain("warehouse unavailable"); } finally { - delete process.env.CI; warnSpy.mockRestore(); } }); From b9711b65bd8797ae2d7808e1a5dfb0fae2963dc1 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 11:18:57 +0200 Subject: [PATCH 25/28] test(appkit): drive the unreachable-warehouse gate through the real query path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate tests in index.test.ts mock `generateQueriesFromDescribe`, so they supply the `hadEnvironmentalFailure: true` they assert on. They pass whether or not the query path actually reports it — which is how the connectivity bug survived review: the preflight returned false and no test joined the two halves. Mock only the SDK boundary so the real query path classifies the failure and the real gate decides. Covers the fresh-checkout crash (previously exit 0 with no types written), the committed-types warn-and-preserve path with the "warehouse unreachable" label, and non-blocking staying silent. Verified these fail when the isEnvironmental assignment is reverted. Co-authored-by: Isaac --- .../tests/unreachable-warehouse-gate.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts new file mode 100644 index 000000000..c8467c0ee --- /dev/null +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * End-to-end coverage for the `--wait` has-types gate when the warehouse is + * unreachable. + * + * The sibling `index.test.ts` mocks `generateQueriesFromDescribe`, so its gate + * tests hand the entry point a `hadEnvironmentalFailure: true` they wrote + * themselves — they would still pass if the query path never set that flag. + * That is exactly how the original bug survived: the query path reported + * `false` for a connectivity failure and no test joined the two halves. + * + * Here only the SDK boundary is mocked. The real query path classifies the + * failure and the real gate decides, so a regression in either half fails a + * test. + */ + +const mocks = vi.hoisted(() => ({ + getWarehouse: vi.fn(), + executeStatement: vi.fn(), +})); + +vi.mock("@databricks/sdk-experimental", () => ({ + WorkspaceClient: vi.fn(() => ({ + statementExecution: { executeStatement: mocks.executeStatement }, + warehouses: { get: mocks.getWarehouse, start: vi.fn() }, + })), +})); + +// Keep the on-disk typegen cache out of play: a reused cached type would mask +// the degrade this test depends on. +vi.mock("../cache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCache: vi.fn(async () => ({ + version: actual.CACHE_VERSION, + queries: {}, + })), + saveCache: vi.fn(), + }; +}); + +const { generateFromEntryPoint, TypegenFatalError } = await import("../index"); + +const testDir = path.join(__dirname, "__output_unreachable_gate__"); +const queryFolder = path.join(testDir, "queries"); +const outFile = path.join(testDir, "generated", "analytics.d.ts"); + +/** DNS-style transport failure: what a CI runner without warehouse egress sees. */ +function unreachableError() { + return Object.assign(new Error("getaddrinfo ENOTFOUND x.databricks.com"), { + code: "ENOTFOUND", + }); +} + +describe("--wait gate: unreachable warehouse (real query path)", () => { + beforeEach(() => { + vi.clearAllMocks(); + fs.rmSync(testDir, { recursive: true, force: true }); + fs.mkdirSync(queryFolder, { recursive: true }); + fs.writeFileSync( + path.join(queryFolder, "users.sql"), + "SELECT id FROM users", + "utf-8", + ); + mocks.getWarehouse.mockRejectedValue(unreachableError()); + }); + + afterAll(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + test("no committed types → crashes with the run-locally remedy instead of exiting 0", async () => { + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + // The regression this guards: the run used to resolve, write nothing, and + // exit 0 — leaving the build to fail later with no usable diagnostic. + expect(err).toBeInstanceOf(TypegenFatalError); + expect((err as Error).message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + // Preflight failed, so no DESCRIBE was attempted. + expect(mocks.executeStatement).not.toHaveBeenCalled(); + }); + + test("committed types present → warns 'warehouse unreachable' and keeps them", async () => { + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committed = "// committed types\n"; + fs.writeFileSync(outFile, committed, "utf-8"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "blocking", + }); + + const warnings = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("AppKit typegen")) + .join("\n"); + + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("wh-unreachable"); + // The label the query path now supplies; it was unreachable in practice + // while connectivity failures reported no cause at all. + expect(warnings).toContain("warehouse unreachable"); + // Anti-clobber: the degraded result must not overwrite what was committed. + expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } + }); + + test("non-blocking mode stays silent and writes degraded types", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // Must not throw: the non-blocking default never fails on warehouse state. + await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-unreachable", + mode: "non-blocking", + }); + + const gateWarnings = warnSpy.mock.calls + .flat() + .map(String) + .filter((s) => s.includes("using committed types")); + + expect(gateWarnings).toEqual([]); + // Degraded types are written here — the gate is blocking-only. + expect(fs.existsSync(outFile)).toBe(true); + expect(fs.readFileSync(outFile, "utf-8")).toContain("result: unknown"); + } finally { + warnSpy.mockRestore(); + } + }); +}); From e5b269dc39dce84bd07a216b958a65a91f330343 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 12:01:36 +0200 Subject: [PATCH 26/28] test(appkit): mock the workspace-client wrapper in the unreachable-warehouse gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new gate test replaced `@databricks/sdk-experimental` with a bare factory mock. After #475 the type-generator reaches the SDK through `../../workspace-client`, whose `legacy.ts` destructures `ConfigError`, `Context` and `TimeUnits` off that module at import time — so the factory-only mock starved module init and the suite failed to collect. Mock the wrapper instead, spreading `importOriginal` so the re-exported SDK values survive. This matches the sibling type-generator tests and keeps the test's intent: the wrapper is now the client boundary, so the real query path still classifies the failure and the real gate still decides. Verified by re-injecting the original bug (`isEnvironmental = false` on the connectivity branch) and confirming both gate tests fail. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../tests/unreachable-warehouse-gate.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts index c8467c0ee..1334268fc 100644 --- a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -12,7 +12,7 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; * That is exactly how the original bug survived: the query path reported * `false` for a connectivity failure and no test joined the two halves. * - * Here only the SDK boundary is mocked. The real query path classifies the + * Here only the client boundary is mocked. The real query path classifies the * failure and the real gate decides, so a regression in either half fails a * test. */ @@ -22,12 +22,21 @@ const mocks = vi.hoisted(() => ({ executeStatement: vi.fn(), })); -vi.mock("@databricks/sdk-experimental", () => ({ - WorkspaceClient: vi.fn(() => ({ - statementExecution: { executeStatement: mocks.executeStatement }, - warehouses: { get: mocks.getWarehouse, start: vi.fn() }, - })), -})); +// Stub the wrapper, not `@databricks/sdk-experimental` underneath it: the +// wrapper re-exports SDK values (`ConfigError`, `Context`, `Time`, `TimeUnits`) +// that a bare SDK factory mock would drop, breaking module init. Spreading +// `importOriginal` keeps those intact while swapping only the factory. +vi.mock("../../workspace-client", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createWorkspaceClient: () => ({ + statementExecution: { executeStatement: mocks.executeStatement }, + warehouses: { get: mocks.getWarehouse, start: vi.fn() }, + }), + }; +}); // Keep the on-disk typegen cache out of play: a reused cached type would mask // the degrade this test depends on. From e3171a7355a722eb3f27e2fe16fc7f200378ee77 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 12:57:05 +0200 Subject: [PATCH 27/28] fix: preserve typegen fallback for warehouse outages Signed-off-by: Atila Fassina --- packages/appkit/src/type-generator/index.ts | 15 ++++- .../src/type-generator/query-registry.ts | 4 ++ .../tests/generate-queries.test.ts | 13 +++-- .../src/type-generator/tests/index.test.ts | 33 +++++++++++ .../tests/unreachable-warehouse-gate.test.ts | 58 ++++++++++++++++++- 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 7fab90d85..142a09fbf 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -462,10 +462,13 @@ export async function generateFromEntryPoint(options: { environmentalCause = environmentalCause ?? mvResult.environmentalCause ?? undefined; - // 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. + // Blocking (`--wait` / prod Vite) escalates only deterministic per-key + // DESCRIBE failures. Transient connectivity failures are already recorded + // as environmental by syncMetricViewsTypes and fall through to the + // committed-types gate below. if (mode === "blocking") { for (const failure of mvResult.failures) { + if (failure.transient) continue; fatalErrors.push({ name: failure.key, message: `metric view ${failure.key} (${failure.source}) could not be described: ${failure.reason}`, @@ -775,6 +778,14 @@ export async function syncMetricViewsTypes(options: { } } + // A rejected DESCRIBE with a connectivity signal is expected to recover on + // a later pass. In blocking mode, route it through the same committed-types + // gate as preflight outages instead of treating it as a configuration error. + if (mode === "blocking" && failures.some((failure) => failure.transient)) { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unreachable"; + } + // Degraded-but-not-failed keys: the warehouse answered with a non-terminal // state (stopped / cold-starting), so their schemas are unknown. const failedKeys = new Set(failures.map((f) => f.key)); diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 86289c51e..040486fac 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -944,6 +944,10 @@ export async function generateQueriesFromDescribe( // status === "unavailable": non-terminal DESCRIBE (warehouse // stopped/cold-starting/busy). Degrade like a transient outage: // tag OFFLINE, count as degraded, never cache. + if (mode === "blocking") { + hadEnvironmentalFailure = true; + environmentalCause = environmentalCause ?? "unavailable"; + } logEntries.push({ queryName, status: "MISS", diff --git a/packages/appkit/src/type-generator/tests/generate-queries.test.ts b/packages/appkit/src/type-generator/tests/generate-queries.test.ts index f0ddeab95..0b19d7a8e 100644 --- a/packages/appkit/src/type-generator/tests/generate-queries.test.ts +++ b/packages/appkit/src/type-generator/tests/generate-queries.test.ts @@ -624,16 +624,21 @@ describe("generateQueriesFromDescribe", () => { status: { state: "PENDING" }, }); - const { schemas, syntaxErrors, fatalErrors } = await describeQueries( - "/queries", - "wh-123", - ); + const { + schemas, + syntaxErrors, + fatalErrors, + hadEnvironmentalFailure, + environmentalCause, + } = await describeQueries("/queries", "wh-123"); expect(schemas).toHaveLength(1); expect(schemas[0].name).toBe("users"); expect(schemas[0].type).toContain("result: unknown"); expect(syntaxErrors).toEqual([]); expect(fatalErrors).toEqual([]); + expect(hadEnvironmentalFailure).toBe(true); + expect(environmentalCause).toBe("unavailable"); // a non-ready warehouse must never persist `result: unknown` expect(lastSavedQueries()).not.toHaveProperty("users"); }); diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 0d3d505d5..816208689 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -602,6 +602,39 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(fs.existsSync(metricFile)).toBe(false); }); + test("blocking + transient metric DESCRIBE failure: warns and preserves committed metric types", async () => { + writeMetricConfig(); + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + const committed = "// committed metric types\n"; + fs.writeFileSync(metricFile, committed, "utf-8"); + + const unreachable = Object.assign( + new Error("connect ECONNREFUSED 10.0.0.1:443"), + { code: "ECONNREFUSED" }, + ); + mocks.getWarehouseState.mockRejectedValue(unreachable); + mocks.executeStatement.mockRejectedValue(unreachable); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-1", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + const warnings = warnSpy.mock.calls.flat().map(String).join("\n"); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("warehouse unreachable"); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } + }); + test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { writeMetricConfig(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts index 1334268fc..0a4dde781 100644 --- a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -3,8 +3,8 @@ import path from "node:path"; import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; /** - * End-to-end coverage for the `--wait` has-types gate when the warehouse is - * unreachable. + * End-to-end coverage for the `--wait` has-types gate when query DESCRIBE + * cannot produce a schema for environmental reasons. * * The sibling `index.test.ts` mocks `generateQueriesFromDescribe`, so its gate * tests hand the entry point a `hadEnvironmentalFailure: true` they wrote @@ -65,7 +65,7 @@ function unreachableError() { }); } -describe("--wait gate: unreachable warehouse (real query path)", () => { +describe("--wait gate: environmental query failures (real query path)", () => { beforeEach(() => { vi.clearAllMocks(); fs.rmSync(testDir, { recursive: true, force: true }); @@ -134,6 +134,58 @@ describe("--wait gate: unreachable warehouse (real query path)", () => { } }); + test("non-terminal DESCRIBE + no committed types → crashes instead of silently exiting 0", async () => { + mocks.getWarehouse.mockResolvedValue({ state: "RUNNING" }); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-pending", + status: { state: "PENDING" }, + }); + + const err = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-scaling", + mode: "blocking", + }).then( + () => undefined, + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(TypegenFatalError); + expect((err as Error).message).toContain("generate-types --wait"); + expect(fs.existsSync(outFile)).toBe(false); + }); + + test("non-terminal DESCRIBE + committed types → warns unavailable and keeps them", async () => { + mocks.getWarehouse.mockResolvedValue({ state: "RUNNING" }); + mocks.executeStatement.mockResolvedValue({ + statement_id: "stmt-pending", + status: { state: "RUNNING" }, + }); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const committed = "// committed types\n"; + fs.writeFileSync(outFile, committed, "utf-8"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-scaling", + mode: "blocking", + }), + ).resolves.toBeUndefined(); + + const warnings = warnSpy.mock.calls.flat().map(String).join("\n"); + expect(warnings).toContain("AppKit typegen: using committed types"); + expect(warnings).toContain("warehouse unavailable"); + expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); + } finally { + warnSpy.mockRestore(); + } + }); + test("non-blocking mode stays silent and writes degraded types", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { From b75c7449b0dfcdd5b091c660acecfabc6603a0af Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 4 Aug 2026 17:06:15 +0200 Subject: [PATCH 28/28] fix: require complete committed types for fallback Signed-off-by: Atila Fassina --- bundle-size-baseline.json | 46 ++++---- docs/docs/development/type-generation.md | 8 +- packages/appkit/src/type-generator/errors.ts | 14 --- packages/appkit/src/type-generator/index.ts | 64 +++++----- .../src/type-generator/query-registry.ts | 13 +-- .../src/type-generator/tests/index.test.ts | 109 +++++++++++++----- .../tests/unreachable-warehouse-gate.test.ts | 23 +--- 7 files changed, 137 insertions(+), 140 deletions(-) diff --git a/bundle-size-baseline.json b/bundle-size-baseline.json index 2baf34119..980282f5d 100644 --- a/bundle-size-baseline.json +++ b/bundle-size-baseline.json @@ -3,25 +3,25 @@ { "name": "@databricks/appkit", "tarball": { - "packed": 821911, - "unpacked": 2868818 + "packed": 836260, + "unpacked": 2918382 }, "dist": { "total": { - "raw": 2855161, - "gzip": 958614 + "raw": 2904725, + "gzip": 974154 }, "js": { - "raw": 843740, - "gzip": 294783 + "raw": 857783, + "gzip": 299070 }, "types": { - "raw": 313465, - "gzip": 107586 + "raw": 315703, + "gzip": 108634 }, "maps": { - "raw": 1687161, - "gzip": 552427 + "raw": 1720444, + "gzip": 562632 }, "css": { "raw": 0, @@ -31,22 +31,22 @@ "raw": 10795, "gzip": 3818 }, - "fileCount": 575 + "fileCount": 581 }, "entries": [ { "id": ".", - "gzip": 90768, + "gzip": 91943, "composition": { - "initialGzip": 88194, + "initialGzip": 89369, "lazyGzip": 2574, - "totalGzip": 90768, - "own": 288255, + "totalGzip": 91943, + "own": 291965, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 84096, + "gzip": 85271, "kind": "initial" }, { @@ -132,17 +132,17 @@ }, { "id": "./type-generator", - "gzip": 19370, + "gzip": 20418, "composition": { - "initialGzip": 19370, + "initialGzip": 20418, "lazyGzip": 0, - "totalGzip": 19370, - "own": 55763, + "totalGzip": 20418, + "own": 58727, "nodeModules": null, "chunks": [ { "label": "index.js", - "gzip": 19370, + "gzip": 20418, "kind": "initial" } ] @@ -153,8 +153,8 @@ { "name": "@databricks/appkit-ui", "tarball": { - "packed": 312522, - "unpacked": 1301416 + "packed": 342823, + "unpacked": 1392263 }, "dist": { "total": { diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 6a5b0d49f..b7727ecef 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -84,18 +84,18 @@ npx @databricks/appkit generate-types --wait #### CI resilience: committed types as fallback -In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed `.d.ts` files** (`shared/appkit-types/analytics.d.ts`, `metric-views.d.ts`) as the fallback when the warehouse is unreachable. These committed files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. +In blocking mode (`--wait`), the generator attempts to fetch real types from your warehouse, but delegates to **committed type files** (`shared/appkit-types/analytics.d.ts` and, when Metric Views are configured, `shared/appkit-types/metric-views.ts`) as the fallback when the warehouse is unreachable. These generated files should be part of your repository. On a fresh CI checkout, every build attempts to DESCRIBE against the warehouse; the committed types are used only when that cannot complete. The generator **never overwrites committed types with degraded (`result: unknown`) types** — it writes real types, or it does not write at all. A **two-bucket failure taxonomy** determines whether the build crashes or falls back to committed types: - **Deterministic failures (always crash):** SQL syntax errors in your queries (genuine DESCRIBE failure against a reachable warehouse), HTTP 404 (bad or unknown warehouse ID), HTTP 400 (malformed request). These are developer or configuration errors that committed types must not hide. -- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If committed types exist, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If no committed types exist, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the `.d.ts` files. +- **Environmental failures (gate on committed types):** Authentication failures (401/403), network unreachability, warehouse unavailability (cold, deleting, or deleted), wait timeout on `RUNNING`, or any unrecognized failure. If every type file required by the app exists, the build **keeps them, emits a loud warning to stderr, and succeeds (exit 0)**. If a required file is missing, the build **crashes** with a message instructing you to run `npx @databricks/appkit generate-types --wait` locally (against a reachable warehouse) and commit the generated type files. The loud warning is a single greppable stderr line naming the coarse cause (auth blocked / warehouse unreachable / warehouse unavailable) and the warehouse ID, so CI logs surface that the build fell back to committed types. -**Note:** If your app declares only metric views and no `config/queries/`, the first build still writes an empty `analytics.d.ts`, which counts as "committed types present" for the gate. An environmental failure will then fall back and warn rather than crash, even on a first build — an accepted v1 simplification. +For a Metric Views app, `metric-views.ts` must already exist before an environmental failure can fall back successfully. Unlike a declaration-only artifact, this file also exports the runtime `metricViewsMetadata` value consumed by the server, so `analytics.d.ts` alone cannot satisfy the gate. The app template wires this up for you: `postinstall` and `predev` run the non-blocking default, while `prebuild` runs `--wait`. @@ -105,7 +105,7 @@ The app template wires this up for you: `postinstall` and `predev` run the non-b - `metric-views.ts` — augments the `MetricRegistry` interface so `useMetricView('', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level. The same file also exports a runtime `metricViewsMetadata` constant (the same metadata as a value, not just types) — inject it via `analytics({ metricViewsMetadata })` so the metric route can carry per-column display metadata in its response payload. The type augmentation erases at build; the constant is a normal named export and is tree-shaken away when unused. See [the analytics plugin's metric-view docs](../plugins/analytics.md) for the hook + format-utility wiring. -If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.d.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. +If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` metric views obey the [two-bucket taxonomy](#ci-resilience-committed-types-as-fallback) (environmental failures gate to committed `metric-views.ts` + warn; deterministic failures like malformed definitions crash the build). A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode. `definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`): diff --git a/packages/appkit/src/type-generator/errors.ts b/packages/appkit/src/type-generator/errors.ts index 8a074795c..a6053f576 100644 --- a/packages/appkit/src/type-generator/errors.ts +++ b/packages/appkit/src/type-generator/errors.ts @@ -161,8 +161,6 @@ const AUTH_ERROR_STATUSES = new Set([401, 403]); export function classifyBlockingFailure( error: unknown, ): "deterministic" | "environmental" { - // Deterministic: check first so they're never swallowed by environmental rules. - // Walk the error chain to find any deterministic status. const seen = new Set(); const stack = [error]; @@ -179,18 +177,6 @@ export function classifyBlockingFailure( stack.push(...getErrorChildren(current)); } - // Environmental: auth, connectivity, unrecognized, default. - const topLevelStatus = getErrorStatus(error); - if (topLevelStatus !== undefined && AUTH_ERROR_STATUSES.has(topLevelStatus)) { - return "environmental"; - } - - if (isConnectivityError(error)) { - return "environmental"; - } - - // Default: any unrecognized failure or no status (DELETED/DELETING messages, - // timeout messages, plain Error objects) → environmental. return "environmental"; } diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 0a6e60b44..4f7a058d7 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -60,10 +60,6 @@ const logger = createLogger("type-generator"); */ const MV_PREFLIGHT_WAIT_MAX_MS = 300_000; -/** - * Generate a loud warning message for environmental failures with committed types present. - * @param cause - coarse cause label: "auth" (401/403), "unreachable" (connectivity), or "unavailable" (other) - */ function determineWarningMessage( cause: "auth" | "unreachable" | "unavailable", warehouseId: string, @@ -85,18 +81,20 @@ function plural(count: number, singular: string, pluralForm = `${singular}s`) { } /** - * Check if committed type artifacts exist (at least one of the requested surfaces). - * Serving types are excluded (gitignored, never part of the gate). - * Returns true if either the analytics or metric-views committed .d.ts file exists. + * Check that every type artifact required by this run exists. + * Serving types are excluded (gitignored, never part of the gate). When metric + * views are configured, their `.ts` artifact is required in addition to the + * analytics declarations because it also provides runtime metadata exports. */ -function hasCommittedTypes( +function hasRequiredCommittedTypes( analyticsOutFile: string, - metricViewsOutFile: string | undefined, + requiredMetricViewsOutFile: string | undefined, ): boolean { const hasAnalytics = existsSync(analyticsOutFile); - const hasMetrics = - metricViewsOutFile !== undefined && existsSync(metricViewsOutFile); - return hasAnalytics || hasMetrics; + const hasRequiredMetrics = + requiredMetricViewsOutFile === undefined || + existsSync(requiredMetricViewsOutFile); + return hasAnalytics && hasRequiredMetrics; } function isQueryDegraded(schema: QuerySchema): boolean { @@ -374,6 +372,8 @@ export async function generateFromEntryPoint(options: { let hadEnvironmentalFailure = false; // Track the coarse cause of the environmental failure for the warning message. let environmentalCause: "auth" | "unreachable" | "unavailable" | undefined; + // Set when definitions.json makes metric-views.ts a required build input. + let metricTypesRequired = false; if (queryFolder) { const result = await generateQueriesFromDescribe(queryFolder, warehouseId, { @@ -428,7 +428,7 @@ export async function generateFromEntryPoint(options: { mode, // In blocking mode, never overwrite committed metric types with a // degraded result — including on runs that go on to throw, so a failing - // build leaves the committed .d.ts intact. Non-blocking always writes. + // build leaves the committed type file intact. Non-blocking always writes. suppressDegradedWrite: mode === "blocking", }); } catch (configError) { @@ -444,6 +444,8 @@ export async function generateFromEntryPoint(options: { ); } + metricTypesRequired = !mvResult.noConfig; + // Deleted/deleting-warehouse fatal preflight (blocking mode only); // empty (no-op) when definitions.json is absent or in non-blocking mode. // Only deterministic fatals are recorded in fatalErrors. @@ -489,7 +491,10 @@ export async function generateFromEntryPoint(options: { const resolvedMvFile = options.mvOutFile ?? path.join(path.dirname(outFile), METRIC_TYPES_FILE); - const hasTypes = hasCommittedTypes(outFile, resolvedMvFile); + const hasTypes = hasRequiredCommittedTypes( + outFile, + metricTypesRequired ? resolvedMvFile : undefined, + ); if (hasTypes) { // Committed types present: emit loud warning and exit 0. @@ -499,12 +504,12 @@ export async function generateFromEntryPoint(options: { ); logger.warn(warningMessage); } else { - // No committed types: crash with a generic message. + // A required committed type file is missing: crash with a generic message. throw new TypegenFatalError( [ { name: "type-generator", - message: `Warehouse ${warehouseId} could not be reached and no committed types exist. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated .d.ts files.`, + message: `Warehouse ${warehouseId} could not be reached and required committed type files are missing. Run 'npx @databricks/appkit generate-types --wait' locally and commit the generated type files.`, }, ], warehouseId, @@ -570,10 +575,10 @@ export interface SyncMetricViewsTypesResult { * @param options.cache - cache toggle, default ON. Only `cache === false` disables it (so `undefined`/`true` keep caching). * @param options.metricFetcher - optional injected {@link DescribeFetcher} * @param options.mode - preflight/gate policy, default `"describe-now"`. When set to `"blocking"`, - * metric-view .d.ts writes are suppressed if any metric is degraded (to preserve committed files). + * metric-view `.ts` writes are suppressed if any metric is degraded (to preserve committed files). * @param options.suppressDegradedWrite - when true (only in `mode === "blocking"` context), skip * the metricOutFile write if any metric schema has `degraded === true`. Used to prevent - * overwriting committed .d.ts files with degraded types in blocking mode. + * overwriting committed type files with degraded types in blocking mode. */ export async function syncMetricViewsTypes(options: { metricViewsFolder: string; @@ -695,15 +700,12 @@ export async function syncMetricViewsTypes(options: { // Connectivity blip: fall through to syncMetrics, whose DESCRIBEs degrade // a not-ready / unreachable warehouse rather than throwing. if (!isConnectivityError(err)) { - // Classify: deterministic (404/400) or environmental (auth, etc). + // Deterministic failures become fatal errors; environmental failures + // degrade for the committed-types gate. const classification = classifyBlockingFailure(err); if (classification === "deterministic") { - // Keep as fatal preflight for deterministic errors (404/400). preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } else { - // Environmental: set preflightFatalMessage so DESCRIBE is skipped, but - // mark hadEnvironmentalFailure so the gate handles it later (not added - // to fatalErrors). preflightFatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; hadEnvironmentalFailure = true; environmentalCause = classifyEnvironmentalCause(err); @@ -729,15 +731,9 @@ export async function syncMetricViewsTypes(options: { let described: MetricSchema[]; let failures: MetricSyncFailure[] = []; if (preflightFatalMessage !== undefined) { - // Fatal preflight (deleted/deleting warehouse or deterministic error): - // skip DESCRIBE, emit degraded schemas so both artifacts are still written, - // and record one fatal error per describe-needed key (cache hits are - // unaffected) ONLY if it's a deterministic error. Environmental failures - // degrade silently. The degraded schemas are not cached (see the write - // block), so a later pass re-probes. + // Environmental failures degrade for the committed-types gate; deterministic + // failures record one fatal error per key. Degraded schemas are not cached. described = describeNeeded.map(emptyMetricSchema); - // Only deterministic fatals (404/400) record errors; environmental failures - // degrade silently for the has-types gate to handle. if (!hadEnvironmentalFailure) { for (const entry of describeNeeded) { fatalErrors.push({ name: entry.key, message: preflightFatalMessage }); @@ -797,9 +793,7 @@ export async function syncMetricViewsTypes(options: { environmentalCause = environmentalCause ?? "unavailable"; } } else { - // Un-probed DESCRIBEs deliberately skipped, not failures: emit each - // describe-needed key as a degraded schema so both artifacts exist; cache - // hits keep serving last-known-good. This is an environmental failure path. + // Un-probed DESCRIBEs emit degraded schemas; cache hits remain last-known-good. described = describeNeeded.map(emptyMetricSchema); logger.info( "Warehouse %s is not running — wrote degraded metric types (permissive) for %d metric view(s) (%s); they will refresh once the warehouse is available.", @@ -863,7 +857,7 @@ export async function syncMetricViewsTypes(options: { // Same anti-clobber rule as the query path: when suppressDegradedWrite is set // (blocking mode), skip the write if any metric degraded, preserving the - // committed metric-views.d.ts. Non-blocking mode always writes. + // committed metric-views.ts. Non-blocking mode always writes. const shouldWriteMetrics = !suppressDegradedWrite || !hasAnyDegradedMetrics(schemas); diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 720c61443..108957915 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -762,31 +762,20 @@ export async function generateQueriesFromDescribe( isEnvironmental = true; environmentalCause = "unreachable"; } else { - // Classify the exception: deterministic (404/400) or environmental (auth, etc). const classification = classifyBlockingFailure(err); if (classification === "deterministic") { - // Build-failing deterministic error (bad warehouse id, malformed request). decision = "fatal"; fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; - isEnvironmental = false; } else { - // Environmental: auth, timeouts, unrecognized, etc. Degrade for the - // has-types gate to handle later. decision = "degradeAll"; isEnvironmental = true; environmentalCause = classifyEnvironmentalCause(err); - fatalMessage = `warehouse ${warehouseId}: ${getErrorDiagnostic(err)}`; } } } } - // Record blocking-mode environmental failures for the has-types gate. - if ( - mode === "blocking" && - ((decision === "degradeAll" && isEnvironmental) || - (decision === "fatal" && isEnvironmental)) - ) { + if (mode === "blocking" && decision !== "proceed" && isEnvironmental) { hadEnvironmentalFailure = true; } diff --git a/packages/appkit/src/type-generator/tests/index.test.ts b/packages/appkit/src/type-generator/tests/index.test.ts index 252a9979c..4f6aaf709 100644 --- a/packages/appkit/src/type-generator/tests/index.test.ts +++ b/packages/appkit/src/type-generator/tests/index.test.ts @@ -325,6 +325,13 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); }; + const committedMetricTypes = + "// committed metric types\nexport const metricViewsMetadata = {};\n"; + const writeCommittedMetricTypes = () => { + fs.mkdirSync(path.dirname(metricFile), { recursive: true }); + fs.writeFileSync(metricFile, committedMetricTypes, "utf-8"); + }; + beforeEach(() => { vi.clearAllMocks(); mocks.cacheFile.contents = undefined; @@ -649,6 +656,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { test("blocking + a non-terminal DESCRIBE (warehouse not ready): degrades, does NOT escalate", async () => { writeMetricConfig(); + writeCommittedMetricTypes(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -675,7 +683,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { const warned = warnSpy.mock.calls.flat().map(String).join("\n"); expect(warned).not.toContain("metric sync failed"); // Degraded artifacts are suppressed, not written (to preserve committed types). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); } finally { warnSpy.mockRestore(); logSpy.mockRestore(); @@ -755,9 +763,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + DELETED: environmental failure with committed types → no throw, warning emitted", async () => { - // DELETED is environmental. Since the query path writes analytics.d.ts - // (even with empty registry), committed types exist, so emit warning + return 0. + // DELETED is environmental. Both required committed artifacts exist, so + // emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("DELETED"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -781,7 +790,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is NEVER cached (mirrors the query path): the key is // left uncached so a later pass re-probes, and no stale/sticky entry can be @@ -791,9 +800,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { }); test("blocking + preflight wait rejects with a timeout: environmental failure with committed types → no throw, warning emitted", async () => { - // Timeout is environmental. Since the query path writes analytics.d.ts, - // committed types exist, so emit warning + return 0. + // Timeout is environmental. Both required committed artifacts exist, so + // emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockRejectedValue( new Error( @@ -827,7 +837,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { ); expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached — the key stays uncached for the next // pass to re-probe. @@ -842,6 +852,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // Degraded artifacts are NOT written in blocking mode when there are no failures // (to preserve committed good types). writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue("STARTING"); mocks.waitUntilRunning.mockResolvedValue("STOPPED"); // The fall-through DESCRIBE hits a still-cold warehouse: non-terminal @@ -884,7 +895,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { // degraded the key. expect(mocks.executeStatement).toHaveBeenCalledTimes(1); // Degraded artifacts are suppressed, not written (to preserve committed types). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached; the key stays uncached and the next // describe-capable pass re-probes it (convergence via re-describe, not via a @@ -902,9 +913,10 @@ describe("generateFromEntryPoint — metric-view emission", () => { ])( "blocking + warehouse deleted mid-wait (probe read %s): environmental failure with committed types → no throw, warning emitted", async (probedState, startsWarehouse) => { - // DELETED mid-wait is environmental. Since the query path writes - // analytics.d.ts, committed types exist, so emit warning + return 0. + // DELETED mid-wait is environmental. Both required committed artifacts + // exist, so emit a warning and return 0. writeMetricConfig(); + writeCommittedMetricTypes(); mocks.getWarehouseState.mockResolvedValue(probedState); mocks.startWarehouse.mockResolvedValue(undefined); // The warehouse was deleted while the preflight waited: the wait @@ -927,7 +939,7 @@ describe("generateFromEntryPoint — metric-view emission", () => { expect(mocks.executeStatement).not.toHaveBeenCalled(); // Degraded metric artifacts are NOT written in blocking mode (committed types preserved). - expect(fs.existsSync(metricFile)).toBe(false); + expect(fs.readFileSync(metricFile, "utf-8")).toBe(committedMetricTypes); // The degraded outcome is not cached — no sticky entry to serve later. const metrics = @@ -1835,11 +1847,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { const queryFolder = path.join(antiClobberDir, "queries"); const metricViewsFolder = path.join(antiClobberDir, "metric-views"); const outFile = path.join(antiClobberDir, "generated", "analytics.d.ts"); - const metricFile = path.join( - antiClobberDir, - "generated", - "metric-views.d.ts", - ); + const metricFile = path.join(antiClobberDir, "generated", "metric-views.ts"); const degradedQuerySchema = (name: string) => ({ name, @@ -1942,7 +1950,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(content).toContain("offline_query"); }); - test("blocking mode + degraded metric (no failures): no write to metric-views.d.ts", async () => { + test("blocking mode + degraded metric (no failures): no write to metric-views.ts", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2002,7 +2010,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(fs.existsSync(outFile)).toBe(false); }); - test("blocking mode + non-degraded metric: writes to metric-views.d.ts normally", async () => { + test("blocking mode + non-degraded metric: writes to metric-views.ts normally", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2050,7 +2058,7 @@ describe("generateFromEntryPoint — anti-clobber for blocking mode", () => { expect(content).toContain('"total_revenue": number'); }); - test("non-blocking mode + degraded metric: writes to metric-views.d.ts anyway", async () => { + test("non-blocking mode + degraded metric: writes to metric-views.ts anyway", async () => { fs.writeFileSync( path.join(metricViewsFolder, "definitions.json"), JSON.stringify({ @@ -2142,11 +2150,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { const queryFolder = path.join(warningTestDir, "queries"); const metricViewsFolder = path.join(warningTestDir, "metric-views"); const outFile = path.join(warningTestDir, "generated", "analytics.d.ts"); - const metricFile = path.join( - warningTestDir, - "generated", - "metric-views.d.ts", - ); + const metricFile = path.join(warningTestDir, "generated", "metric-views.ts"); beforeEach(() => { vi.clearAllMocks(); @@ -2326,8 +2330,8 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); - test("partial presence: only analytics.d.ts exists (metric absent) + environmental → warning emitted (partial presence counts)", async () => { - // Keep analytics.d.ts but remove metric file + test("analytics.d.ts alone satisfies the gate when metric views are not configured", async () => { + // Keep analytics.d.ts but remove the unneeded metric file. expect(fs.existsSync(outFile)).toBe(true); fs.rmSync(metricFile, { force: true }); @@ -2350,7 +2354,7 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { mode: "blocking", }); - // Warning emitted because at least one committed type exists (analytics.d.ts) + // No metric config exists, so analytics.d.ts is the only required file. const warnCalls = warnSpy.mock.calls .flat() .map(String) @@ -2365,6 +2369,51 @@ describe("generateFromEntryPoint — warning message with cause labels", () => { } }); + test("metric config + missing metric-views.ts + environmental failure → crash", async () => { + fs.writeFileSync( + path.join(metricViewsFolder, "definitions.json"), + JSON.stringify({ + metricViews: { revenue: { source: "demo.sales.revenue" } }, + }), + ); + expect(fs.existsSync(outFile)).toBe(true); + expect(fs.existsSync(metricFile)).toBe(false); + + mocks.getWarehouseState.mockRejectedValue( + Object.assign(new Error("PERMISSION_DENIED: cannot read warehouse"), { + status: 403, + }), + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const error = await generateFromEntryPoint({ + outFile, + queryFolder, + warehouseId: "wh-metric-missing", + mode: "blocking", + }).then( + () => undefined, + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(TypegenFatalError); + const message = stripAnsi((error as Error).message); + expect(message).toContain("required committed type files are missing"); + expect(message).toContain("commit the generated type files"); + expect( + warnSpy.mock.calls + .flat() + .map(String) + .some((value) => + value.includes("AppKit typegen: using committed types"), + ), + ).toBe(false); + } finally { + warnSpy.mockRestore(); + } + }); + test("warning output is ANSI-free (plain text for CI log parsing)", async () => { // Query path returns environmental failure mocks.generateQueriesFromDescribe.mockResolvedValue({ @@ -2464,7 +2513,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", beforeEach(() => { vi.clearAllMocks(); mocks.cacheFile.contents = undefined; - // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.d.ts. + // Clean slate: no generated/ dir, so no committed analytics.d.ts / metric-views.ts. fs.rmSync(gateDir, { recursive: true, force: true }); fs.mkdirSync(queryFolder, { recursive: true }); // A degraded query in blocking mode → write suppressed → nothing on disk. @@ -2492,7 +2541,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", (e: unknown) => e, ); - // Core safety path: no committed .d.ts to fall back on → build must fail. + // Core safety path: no committed type file to fall back on → build must fail. expect(err).toBeInstanceOf(TypegenFatalError); const message = stripAnsi((err as Error).message); expect(message).toContain("generate-types --wait"); @@ -2502,7 +2551,7 @@ describe("generateFromEntryPoint — has-types gate crash (no committed types)", }); test("blocking + environmental failure + only serving.d.ts present → still crashes (serving excluded from gate)", async () => { - // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.d.ts stay absent. + // Pre-create ONLY a serving.d.ts sibling. analytics.d.ts / metric-views.ts stay absent. fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync( path.join(path.dirname(outFile), "serving.d.ts"), diff --git a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts index 0a4dde781..f2c542ebc 100644 --- a/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts +++ b/packages/appkit/src/type-generator/tests/unreachable-warehouse-gate.test.ts @@ -2,20 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; -/** - * End-to-end coverage for the `--wait` has-types gate when query DESCRIBE - * cannot produce a schema for environmental reasons. - * - * The sibling `index.test.ts` mocks `generateQueriesFromDescribe`, so its gate - * tests hand the entry point a `hadEnvironmentalFailure: true` they wrote - * themselves — they would still pass if the query path never set that flag. - * That is exactly how the original bug survived: the query path reported - * `false` for a connectivity failure and no test joined the two halves. - * - * Here only the client boundary is mocked. The real query path classifies the - * failure and the real gate decides, so a regression in either half fails a - * test. - */ +/** Exercises the blocking fallback gate through the real query path. */ const mocks = vi.hoisted(() => ({ getWarehouse: vi.fn(), @@ -93,12 +80,9 @@ describe("--wait gate: environmental query failures (real query path)", () => { (e: unknown) => e, ); - // The regression this guards: the run used to resolve, write nothing, and - // exit 0 — leaving the build to fail later with no usable diagnostic. expect(err).toBeInstanceOf(TypegenFatalError); expect((err as Error).message).toContain("generate-types --wait"); expect(fs.existsSync(outFile)).toBe(false); - // Preflight failed, so no DESCRIBE was attempted. expect(mocks.executeStatement).not.toHaveBeenCalled(); }); @@ -124,10 +108,7 @@ describe("--wait gate: environmental query failures (real query path)", () => { expect(warnings).toContain("AppKit typegen: using committed types"); expect(warnings).toContain("wh-unreachable"); - // The label the query path now supplies; it was unreachable in practice - // while connectivity failures reported no cause at all. expect(warnings).toContain("warehouse unreachable"); - // Anti-clobber: the degraded result must not overwrite what was committed. expect(fs.readFileSync(outFile, "utf-8")).toBe(committed); } finally { warnSpy.mockRestore(); @@ -189,7 +170,6 @@ describe("--wait gate: environmental query failures (real query path)", () => { test("non-blocking mode stays silent and writes degraded types", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - // Must not throw: the non-blocking default never fails on warehouse state. await generateFromEntryPoint({ outFile, queryFolder, @@ -203,7 +183,6 @@ describe("--wait gate: environmental query failures (real query path)", () => { .filter((s) => s.includes("using committed types")); expect(gateWarnings).toEqual([]); - // Degraded types are written here — the gate is blocking-only. expect(fs.existsSync(outFile)).toBe(true); expect(fs.readFileSync(outFile, "utf-8")).toContain("result: unknown"); } finally {