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..5b3a3d62d --- /dev/null +++ b/apps/dev-playground/client/src/routes/metric-views.route.tsx @@ -0,0 +1,557 @@ +import { + formatLabel, + formatValue, + type MetricFilter, + toMetricFilter, +} from "@databricks/appkit-ui/js"; +import { + Badge, + BarChart, + Button, + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, + DonutChart, + LineChart, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + 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"; + +export const Route = createFileRoute("/metric-views")({ + component: MetricViewsRoute, +}); + +// 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. 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>; + +// 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. + * + * 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 shorthand: Record = {}; + for (const dimension of FILTER_DIMENSIONS) { + const value = selection[dimension]; + if (dimension === exclude || value === undefined) continue; + shorthand[dimension] = value; + } + 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. + * + * `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() { + // 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, + }); + + // 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. */} + + + Filters + + Slice every visual on this page by region and segment. + + + +
+ + + +
+ + {/* 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 + + + {/* Excludes `region` — same facet-exclusion as this card's + filter, so it never claims the region slice it ignores. */} + + + + + + {!region.loading && + !region.error && + region.data && + region.data.length > 0 && ( + setDimension("region", d.name)} + selected={selection.region} + /> + )} + + + + {/* Revenue by segment */} + + + ARR by segment + + revenue · arr · grouped by segment + + + + + + + + {!segment.loading && + !segment.error && + segment.data && + segment.data.length > 0 && ( + setDimension("segment", d.name)} + selected={selection.segment} + /> + )} + + +
+ + {/* Hero trend — reshapes as filters narrow. */} + + + Recurring revenue over time + + 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). */} + + + + + + {!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. + + + {/* Grouped by region, so it excludes `region` (same as the region + bar) — a segment filter still narrows it. */} + + + + + + {!table.loading && + !table.error && + table.data && + table.data.length > 0 && ( +
+ + + + {TABLE_COLUMNS.map((column) => ( + + {formatLabel(column, table.metadata?.[column])} + + ))} + + + + {/* 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/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/biome.json b/biome.json index 24ddeb018..5b4ad9118 100644 --- a/biome.json +++ b/biome.json @@ -21,7 +21,8 @@ "!**/*.gen.css", "!**/*.gen.ts", "!**/typedoc-sidebar.ts", - "!**/template" + "!**/template", + "!**/appkit-types/metric-views.ts" ] }, "formatter": { 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/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..666643c44 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -493,3 +493,200 @@ 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. | + +**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 (`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. + +### 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 hardcode 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](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"; +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](https://echarts.apache.org/)** — 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. diff --git a/packages/appkit-ui/src/js/format/index.test.ts b/packages/appkit-ui/src/js/format/index.test.ts new file mode 100644 index 000000000..1a1ab9050 --- /dev/null +++ b/packages/appkit-ui/src/js/format/index.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { formatLabel, formatValue, toD3Format } from "./index"; + +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("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()); + }); + + 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"); + }); + + // 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", () => { + 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(); + }); + + // 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/index.ts b/packages/appkit-ui/src/js/format/index.ts new file mode 100644 index 000000000..d1311bc33 --- /dev/null +++ b/packages/appkit-ui/src/js/format/index.ts @@ -0,0 +1,191 @@ +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); + 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/index.ts b/packages/appkit-ui/src/js/index.ts index f49cde96e..86447be01 100644 --- a/packages/appkit-ui/src/js/index.ts +++ b/packages/appkit-ui/src/js/index.ts @@ -12,4 +12,6 @@ export { 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..508da2ab9 --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.test.ts @@ -0,0 +1,88 @@ +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(); + }); + + 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..0371173b0 --- /dev/null +++ b/packages/appkit-ui/src/js/metric-filter/index.ts @@ -0,0 +1,67 @@ +import type { MetricFilter, MetricPredicate } from "shared"; + +export type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} from "shared"; + +/** + * 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/charts/__tests__/options.test.ts b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts index 5a777fafd..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, @@ -28,6 +29,7 @@ interface EChartsOption { showSymbol?: boolean; symbol?: string; symbolSize?: number; + triggerLineEvent?: boolean; areaStyle?: { opacity: number }; stack?: string; itemStyle?: { borderRadius?: number[] }; @@ -200,6 +202,65 @@ describe("buildCartesianOption", () => { 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", () => { @@ -928,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 54c473114..ae7a90f88 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); @@ -210,6 +232,18 @@ export function BaseChart({ const ui = useChartUITokens(); + // Only the *presence* of a click handler shapes the option (it flips + // `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 + // `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); @@ -326,11 +360,16 @@ 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, }); } - // 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 +389,42 @@ export function BaseChart({ min, max, customOptions, + selected, + interactive, ]); + // Build the ECharts event map only when a click handler is provided. Memoized + // 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( + () => + 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, + [interactive], + ); + if (!option) { return (
@@ -370,6 +443,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..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, @@ -108,6 +109,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..7a7a5016b 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; } // ============================================================================ @@ -300,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]], }, }, @@ -331,11 +340,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 +395,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 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. + triggerLineEvent: isLineLike && interactive ? true : undefined, areaStyle: chartType === "area" ? { opacity: 0.3 } : undefined, stack: stacked && chartType === "area" ? "total" : undefined, itemStyle: @@ -391,3 +412,193 @@ 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". + * + * 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)).filter((name) => 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). + * + * 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, +): (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); + 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..e0a1b25ac 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -89,6 +89,69 @@ 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. + * + * 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 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. */ + 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..cdf0d4160 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 // ============================================================================ @@ -31,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. @@ -125,6 +110,78 @@ 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. 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. + * + * @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 isScalar = (v: unknown): v is number | string => + typeof v === "number" || typeof v === "string"; + + const rawValue = p.value; + + // `[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; + + const dataIndex = typeof p.dataIndex === "number" ? p.dataIndex : -1; + const seriesIndex = typeof p.seriesIndex === "number" ? p.seriesIndex : -1; + + return { + name, + value, + x, + y, + seriesName, + dataIndex, + seriesIndex, + raw: params, + }; +} + /** * Sorts time-series data in ascending chronological order. */ 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/__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..d7a1f65e9 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-metric-view.test.ts @@ -0,0 +1,485 @@ +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(), +})); + +// 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() { + 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 = {}; + mockPublishWarehouseStatus.mockClear(); + mockUnpublishWarehouseStatus.mockClear(); + }); + + 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("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"] }), + ); + + 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("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 }), + }); + }); + + // 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(() => {}); + + 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("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/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/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 63b639761..f5e111d2d 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -7,11 +7,23 @@ export { } from "../resource-status-indicator"; export type { AnalyticsFormat, + GrainsForSelectedTimeDims, + InferDimensionKeys, + InferMeasureKeys, + InferMetricRow, InferResultByFormat, InferRowType, InferServingChunk, InferServingRequest, InferServingResponse, + InferTimeDimensionKeys, + InferTimeGrains, + MetricFilter, + MetricFilterOperatorName, + MetricKey, + MetricPredicate, + MetricRegistry, + PickMetricRow, PluginRegistry, QueryRegistry, ServingAlias, @@ -19,6 +31,8 @@ export type { TypedArrowTable, UseAnalyticsQueryOptions, UseAnalyticsQueryResult, + UseMetricViewOptions, + UseMetricViewResult, WarehouseState, WarehouseStatus, } from "./types"; @@ -34,6 +48,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/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index aa0df8905..9fa0aa919 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,196 @@ 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 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 { + measures: infer Meas; + dimensions: infer Dim; + } + ? Meas & Dim + : 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 +// `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. + * + * 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; + timeDimension?: Extract>; + timeGrain?: GrainsForSelectedTimeDims; + limit?: number; +} + +/** 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/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 new file mode 100644 index 000000000..0e1c77fa3 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-metric-view.ts @@ -0,0 +1,204 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import type { MetricColumnMeta } from "shared"; +import { connectSSE } from "@/js"; +import { + type AnalyticsSseHandlerContext, + getDevMode, + handleAnalyticsSseError, + handleAnalyticsSseMessage, +} from "./analytics-sse"; +import type { + InferDimensionKeys, + InferMeasureKeys, + MetricKey, + PickMetricRow, + UseMetricViewOptions, + UseMetricViewResult, +} from "./types"; +import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status"; +import { useQueryHMR } from "./use-query-hmr"; + +/** + * 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; +} + +/** + * 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. 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, and limit + * @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< + K extends MetricKey = MetricKey, + const M extends ReadonlyArray> = ReadonlyArray< + InferMeasureKeys + >, + const D extends ReadonlyArray> = ReadonlyArray< + InferDimensionKeys + >, +>( + key: K, + options: UseMetricViewOptions, +): UseMetricViewResult[]> { + const devMode = getDevMode(); + const urlSuffix = `/api/analytics/metric/${encodeURIComponent(key)}${devMode}`; + + type Rows = PickMetricRow[]; + 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); + + // 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."); + } + + // Serialize the request body from only the defined fields. A JSON string is + // 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. (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; + 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); + // 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; + + const sseContext: AnalyticsSseHandlerContext = { + source: "useMetricView", + resource: { key }, + defaultExecutionError: "Unable to execute metric query", + unpublishOnMalformedMessage: true, + signal: abortController.signal, + abort: () => abortController.abort(), + setLoading, + setError, + setErrorCode, + // 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, + }; + + connectSSE({ + url: urlSuffix, + payload, + signal: abortController.signal, + onMessage: (message) => + handleAnalyticsSseMessage(message.data, sseContext), + onError: (error) => handleAnalyticsSseError(error, sseContext), + }); + }, [ + key, + payload, + urlSuffix, + publishWarehouseStatus, + unpublishWarehouseStatus, + ]); + + useEffect(() => { + start(); + + return () => { + abortControllerRef.current?.abort(); + unpublishWarehouseStatus(); + }; + }, [start, unpublishWarehouseStatus]); + + useQueryHMR(key, start); + + return { data, loading, error, errorCode, metadata }; +} 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/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 362a74536..3b60c3f31 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -34,6 +34,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "./metric"; import { QueryProcessor } from "./query"; @@ -556,6 +557,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,7 +666,9 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { ); // Reuse the query route's JSON delivery: INLINE JSON_ARRAY with // an ARROW_STREAM-inline fallback, returning plain rows in a - // `result` message — byte-identical envelope to `/query`. + // `result` message — byte-identical envelope to `/query`. The + // per-column `metadata` is stamped AFTER this cached call returns + // (see below), never baked into the cached message. return await self._executeJsonArrayPath( executor, statement, @@ -695,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, @@ -707,6 +733,12 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`, * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result` * message. External links are never used for the JSON fallback. + * + * This returns the bare `result` message (rows + status/statement_id) and is + * cached by the caller. The metric route's per-column `metadata` is stamped + * onto the message AFTER the cached call returns (so a cache hit never serves + * stale metadata), never inside here — keeping the cached payload + * metadata-free and byte-identical to a plain `/query` result. */ private async _executeJsonArrayPath( executor: AnalyticsPlugin, diff --git a/packages/appkit/src/plugins/analytics/mv/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/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..4b5a2818c 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -8,6 +8,7 @@ import { mockServiceContext, setupDatabricksEnv, } from "@tools/test-helpers"; +import type { MetricViewsMetadata } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AppManager } from "../../../app"; import { ServiceContext } from "../../../context/service-context"; @@ -18,6 +19,7 @@ import { composeMetricCacheKey, deriveMetricExecutorKey, loadMetricRegistry, + selectMetricMetadata, validateMetricRequest, } from "../metric"; import type { @@ -134,7 +136,7 @@ function writeRegistry( writeFileSync(path.join(dir, "definitions.json"), body); } -describe("analytics metric route (Phase 1)", () => { +describe("analytics metric route", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -294,8 +296,8 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimensions + GROUP BY ALL. Bare dimensions here; date_trunc - // grain application (via timeDimension) is covered in its own block below. + // ── dimensions + GROUP BY ALL. Bare dimensions here; date_trunc grain + // application (via timeDimension) is covered in its own block below. describe("buildMetricSql dimensions + GROUP BY", () => { const registration: MetricRegistration = { key: "revenue", @@ -366,7 +368,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2a: timeGrain + timeDimension → date_trunc on the named column. + // ── timeGrain + timeDimension → date_trunc on the named column. // The grain is a grammar-gated single-quoted literal; the column keeps its // plain alias; other dimensions render bare; GROUP BY ALL is present. describe("buildMetricSql timeGrain + timeDimension (date_trunc)", () => { @@ -420,7 +422,7 @@ describe("analytics metric route (Phase 1)", () => { }); }); - // ── Phase 2: dimension identifier safety. A dimension is backtick-quoted at + // ── dimension identifier safety. A dimension is backtick-quoted at // interpolation, so an injection-shaped name is neutralized (inert quoted // column), and only an unquotable (control-char) name throws. describe("buildMetricSql dimension identifier safety (quoting)", () => { @@ -461,7 +463,7 @@ describe("analytics metric route (Phase 1)", () => { }); // ── Envelope parity — streams warehouse_status* then a `result` message, - // byte-identical to the /query route's JSON SSE path. + // the same event shape as the /query route's JSON SSE path. describe("_handleMetricRoute SSE envelope", () => { test("streams warehouse_status then a result message with aliased rows", async () => { const plugin = pluginForDir( @@ -588,6 +590,253 @@ describe("analytics metric route (Phase 1)", () => { expect(mockRes.status).toHaveBeenCalledWith(400); }); + + // ── Metadata stamping. The injected `metricViewsMetadata` is sliced to the + // requested columns and stamped into the `result` message; it is pure + // decoration (no SQL / cache-key effect). See `selectMetricMetadata` below + // for the unit-level scoping tests. + const REVENUE_METADATA: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + }; + + /** Extract the parsed `result` SSE payload from the mock response writes. */ + function readResultPayload(mockRes: ReturnType) { + const dataLine = (mockRes.write as any).mock.calls + .map((call: any[]) => call[0] as string) + .find( + (s: string) => + s.startsWith("data: ") && s.includes('"type":"result"'), + ); + if (!dataLine) return undefined; + return JSON.parse(dataLine.slice("data: ".length).trim()); + } + + test("stamps the per-column metadata slice into the result message", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234, region: "EMEA" }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"], dimensions: ["region"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // Only the requested columns are present — `mrr`/`segment` are omitted. + expect(payload.metadata).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits the metadata field entirely when no metadata is injected (envelope parity with /query)", async () => { + const plugin = pluginForDir( + config, // no metricViewsMetadata + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1234 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + // Envelope parity with a plain `/query` result: the `metadata` key is + // absent, not present-but-undefined. + expect(payload).toBeDefined(); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + expect(payload.data).toEqual([{ arr: 1234 }]); + }); + + test("omits metadata when only degraded/unknown columns are requested", async () => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: REVENUE_METADATA }, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { data: [{ unknown_measure: 1 }] }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["unknown_measure"] }, + }), + mockRes, + ); + + const payload = readResultPayload(mockRes); + expect(Object.hasOwn(payload, "metadata")).toBe(false); + }); + + test("metadata presence does NOT change the SQL or the cache key", async () => { + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + // Capture the composed cache key the inner `execute` hands to the shared + // CacheManager mock — the same key whether or not metadata is injected. + const cacheKeyFor = async (mvMeta?: MetricViewsMetadata) => { + mockCacheInstance.getOrExecute.mockClear(); + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + createMockResponse(), + ); + // First getOrExecute call is the SQL execution's cache interceptor. + const call = mockCacheInstance.getOrExecute.mock.calls[0]; + return { cacheKey: call[0], userKey: call[2] }; + }; + + const withMeta = await cacheKeyFor(REVENUE_METADATA); + const withoutMeta = await cacheKeyFor(undefined); + + expect(withMeta.cacheKey).toEqual(withoutMeta.cacheKey); + expect(withMeta.userKey).toEqual(withoutMeta.userKey); + // And the SQL is unchanged (measures/dimensions only). + expect(executeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + statement: + "SELECT MEASURE(`arr`) AS `arr`, `region` FROM `cat`.`sch`.`revenue_metrics` GROUP BY ALL", + }), + expect.any(AbortSignal), + ); + }); + + test("cache HIT serves the FRESH metadata, not the metadata baked in at cache-fill time", async () => { + // Regression: metadata was formerly stamped INSIDE the cached execute(), + // so a cache hit replayed the OLD labels/formats even after a redeploy + // changed them. The cache key excludes metadata, so the SQL result is a + // hit across the two runs below; only the injected metadata differs. + const registry = { + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp" as const, + }, + }; + const body = { measures: ["arr"], dimensions: ["region"] }; + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ arr: 1, region: "EMEA" }] }, + }); + + const runWithMetadata = async (mvMeta: MetricViewsMetadata) => { + const plugin = pluginForDir( + { ...config, metricViewsMetadata: mvMeta }, + registryDir(registry), + ); + (plugin as any).SQLClient.executeStatement = executeMock; + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ params: { key: "revenue" }, body }), + mockRes, + ); + return readResultPayload(mockRes); + }; + + // First run fills the cache with the OLD labels. + const oldMeta: MetricViewsMetadata = { + revenue: { + measures: { arr: { type: "decimal", display_name: "ARR (old)" } }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const first = await runWithMetadata(oldMeta); + expect(first.metadata.arr.display_name).toBe("ARR (old)"); + + // Second run: same body → SQL cache HIT (executeStatement not called + // again), but the app now injects NEW labels. The response must reflect + // the fresh metadata, not the stale copy from the cached message. + executeMock.mockClear(); + const newMeta: MetricViewsMetadata = { + revenue: { + measures: { + arr: { + type: "decimal", + display_name: "ARR (new)", + format: "$#,##0", + }, + }, + dimensions: { region: { type: "string", display_name: "Region" } }, + }, + }; + const second = await runWithMetadata(newMeta); + + expect(executeMock).not.toHaveBeenCalled(); // SQL served from cache + expect(second.metadata.arr.display_name).toBe("ARR (new)"); + expect(second.metadata.arr.format).toBe("$#,##0"); + }); }); // ── 503-vs-404 latching + dormancy. @@ -807,9 +1056,9 @@ describe("analytics metric route (Phase 1)", () => { }); // ── loadMetricRegistry: config parse against the landed metricSourceSchema. -// The loader reads the config file THROUGH an `AppManager` (Phase 2), so each -// test points an `AppManager` at its temp dir instead of passing a bare -// directory string. The loader is stateless — it reads + parses on every call +// The loader reads the config file THROUGH an `AppManager`, so each test points +// an `AppManager` at its temp dir instead of passing a bare directory string. +// The loader is stateless — it reads + parses on every call // (no memoization), so there is no cache to reset between tests. describe("loadMetricRegistry", () => { let dir: string; @@ -942,9 +1191,9 @@ describe("loadMetricRegistry", () => { }); }); -// ── Phase 2: the structured filter engine (translator + validator). -// Registry-free: names are grammar-gated, values are parameterized. No -// allowlist, no op⇄dimension-type check. +// ── The structured filter engine (translator + validator). Registry-free: +// names are grammar-gated, values are parameterized. No allowlist, no +// op⇄dimension-type check. describe("metric — filter translator", () => { const registration: MetricRegistration = { key: "revenue", @@ -1662,8 +1911,8 @@ describe("metric — filter translator", () => { }); // The warehouse-authoritative unknown-name parity test (sanitized - // clientMessage/errorCode envelope) lands here because the Phase 1 harness - // can drive the metric route end-to-end and assert on the SSE error bytes. + // clientMessage/errorCode envelope) lands here because this harness can drive + // the metric route end-to-end and assert on the SSE error bytes. describe("warehouse-authoritative unknown-name parity", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -1745,7 +1994,7 @@ describe("metric — filter translator", () => { }); }); -// ── Phase 3: cache-key composition. `composeMetricCacheKey` produces the +// ── cache-key composition. `composeMetricCacheKey` produces the // array `CacheManager.generateKey` concatenates + sha256s; the invariants // below are what make the cache both correct (semantically equal calls collapse) // and safe (distinct args / executors never collide). @@ -1985,7 +2234,7 @@ describe("composeMetricCacheKey", () => { }); }); -// ── Phase 3: executor-key isolation. The key is what scopes the cache — `"sp"` +// ── executor-key isolation. The key is what scopes the cache — `"sp"` // shares it across all callers, a per-user hash isolates OBO callers. The raw // identity must never enter the key verbatim (privacy: cache keys are logged // and persisted). @@ -2058,12 +2307,12 @@ describe("deriveMetricExecutorKey", () => { }); }); -// ── Phase 3: lane dispatch at the handler level. The lane comes from the +// ── lane dispatch at the handler level. The lane comes from the // registration (the entry's `executor` in definitions.json), NOT the URL: // OBO-lane routes through `asUser(req)`, SP-lane through the default executor. // A missing/whitespace OBO identity must land on the canonical 401 envelope, // never an out-of-envelope 500. -describe("metric route — lane dispatch (Phase 3)", () => { +describe("metric route — lane dispatch", () => { let config: IAnalyticsConfig; let serviceContextMock: Awaited>; @@ -2232,3 +2481,89 @@ describe("metric route — lane dispatch (Phase 3)", () => { expect(executeMock).not.toHaveBeenCalled(); }); }); + +// ── metadata slicing. `selectMetricMetadata` flattens the injected +// per-metric metadata down to only the requested columns for the SSE `result` +// message. It is pure and total; the invariants below are what keep the stamp +// scoped, degrade-safe, and prototype-safe. +describe("selectMetricMetadata", () => { + const all: MetricViewsMetadata = { + revenue: { + measures: { + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + mrr: { type: "decimal", display_name: "MRR" }, + }, + dimensions: { + region: { type: "string", display_name: "Region" }, + segment: { type: "string" }, + }, + }, + orders: { + measures: { cnt: { type: "bigint" } }, + dimensions: {}, + }, + }; + + test("returns only the requested measures and dimensions (flat slice)", () => { + expect(selectMetricMetadata(all, "revenue", ["arr"], ["region"])).toEqual({ + arr: { type: "decimal", display_name: "ARR", format: "currency" }, + region: { type: "string", display_name: "Region" }, + }); + }); + + test("omits requested columns absent from the metadata (degraded/unknown cols)", () => { + // `mrr` is known; `ebitda` and `country` are not → dropped, not placeheld. + expect( + selectMetricMetadata(all, "revenue", ["mrr", "ebitda"], ["country"]), + ).toEqual({ + mrr: { type: "decimal", display_name: "MRR" }, + }); + }); + + test("undefined when no metadata is injected (all absent)", () => { + expect( + selectMetricMetadata(undefined, "revenue", ["arr"], ["region"]), + ).toBeUndefined(); + }); + + test("undefined for an unknown metric key", () => { + expect( + selectMetricMetadata(all, "nope", ["arr"], undefined), + ).toBeUndefined(); + }); + + test("undefined when none of the requested columns are present (empty slice)", () => { + expect( + selectMetricMetadata(all, "revenue", ["unknown"], ["also_unknown"]), + ).toBeUndefined(); + }); + + test("undefined when dimensions is undefined and no measures match", () => { + expect( + selectMetricMetadata(all, "orders", ["missing"], undefined), + ).toBeUndefined(); + }); + + test("handles undefined dimensions (measures only)", () => { + expect(selectMetricMetadata(all, "orders", ["cnt"], undefined)).toEqual({ + cnt: { type: "bigint" }, + }); + }); + + test.each(["__proto__", "constructor", "toString", "hasOwnProperty"])( + "inherited Object.prototype key %j → undefined (own-property lookup)", + (dangerousKey) => { + expect( + selectMetricMetadata(all, dangerousKey, ["arr"], undefined), + ).toBeUndefined(); + }, + ); + + test("does not resolve a requested column to an inherited prototype member", () => { + // `toString` is an inherited member of the measures object, not an own + // entry — it must not leak into the slice as a bogus function value. + expect( + selectMetricMetadata(all, "revenue", ["toString"], ["hasOwnProperty"]), + ).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/analytics/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 83e4f737c..d1c45c3ce 100644 --- a/packages/appkit/src/plugins/analytics/types.ts +++ b/packages/appkit/src/plugins/analytics/types.ts @@ -1,7 +1,32 @@ -import type { BasePluginConfig } from "shared"; +import type { + BasePluginConfig, + MetricColumnMeta, + MetricFilter, + MetricViewsMetadata, +} from "shared"; + +export type { + MetricFilter, + MetricFilterOperatorName, + MetricPredicate, +} 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. @@ -54,20 +79,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. @@ -123,7 +160,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"; @@ -142,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/index.ts b/packages/appkit/src/type-generator/index.ts index 237125482..89ae305c9 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"`. @@ -718,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, @@ -752,4 +770,4 @@ export type { export const TYPES_DIR = "appkit-types"; export const ANALYTICS_TYPES_FILE = "analytics.d.ts"; export const SERVING_TYPES_FILE = "serving.d.ts"; -export const METRIC_TYPES_FILE = "metric-views.d.ts"; +export const METRIC_TYPES_FILE = "metric-views.ts"; diff --git a/packages/appkit/src/type-generator/mv-registry/render-types.ts b/packages/appkit/src/type-generator/mv-registry/render-types.ts index f70e584c8..c1614c229 100644 --- a/packages/appkit/src/type-generator/mv-registry/render-types.ts +++ b/packages/appkit/src/type-generator/mv-registry/render-types.ts @@ -116,6 +116,35 @@ function renderDegradedMetricEntry(schema: MetricSchema): string { }`; } +type RenderedMetadataField = readonly [name: string, value: string]; + +// Build the canonical rendered fields shared by type-level and runtime +// metadata. `time_grain` is type-only and is included only when requested. +function metadataFields( + col: MetricColumnMetadata, + includeTimeGrain = false, +): RenderedMetadataField[] { + const fields: RenderedMetadataField[] = [["type", JSON.stringify(col.type)]]; + const optionalFields = [ + ["display_name", col.displayName], + ["format", col.format], + ["description", col.description], + ] as const; + + for (const [name, value] of optionalFields) { + if (value) { + fields.push([name, JSON.stringify(value)]); + } + } + + if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { + const grainTuple = col.timeGrains.map((g) => JSON.stringify(g)).join(", "); + fields.push(["time_grain", `readonly [${grainTuple}]`]); + } + + return fields; +} + // Render the type-level shape of a column's semantic-metadata map // for the `metadata` field of a MetricRegistry entry. function renderMetadataMap( @@ -127,23 +156,9 @@ function renderMetadataMap( const inner = cols .map((col) => { - const fields: string[] = [`type: ${JSON.stringify(col.type)}`]; - if (col.displayName) { - fields.push(`display_name: ${JSON.stringify(col.displayName)}`); - } - if (col.format) { - fields.push(`format: ${JSON.stringify(col.format)}`); - } - if (col.description) { - fields.push(`description: ${JSON.stringify(col.description)}`); - } - if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) { - const grainTuple = col.timeGrains - .map((g) => JSON.stringify(g)) - .join(", "); - fields.push(`time_grain: readonly [${grainTuple}]`); - } - const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(";\n"); + const fieldsBlock = metadataFields(col, includeTimeGrain) + .map(([name, value]) => `${indent} ${name}: ${value}`) + .join(";\n"); return `${indent}${JSON.stringify(col.name)}: { ${fieldsBlock}; ${indent}}`; @@ -155,6 +170,63 @@ ${inner}; }`; } +// Render one column's runtime metadata object literal — the value-side twin of +// a `renderMetadataMap` entry. Sources the SAME per-column fields +// (type/display_name/format/description) but omits `time_grain` (not part of +// MetricColumnMeta). Strings go through JSON.stringify so quotes/backticks in +// display_name/description stay escape-safe. +function renderMetadataValueField(col: MetricColumnMetadata): string { + const fields = metadataFields(col).map( + ([name, value]) => `${name}: ${value}`, + ); + return `{ ${fields.join(", ")} }`; +} + +// Render the runtime value map (measures or dimensions) for one metric — an +// object literal keyed by column name. Empty → `{}` (the value twin of the +// type-level `Record`, which is a type-only construct). +function renderMetadataValueMap( + cols: MetricColumnMetadata[], + indent: string, +): string { + if (cols.length === 0) return "{}"; + const inner = cols + .map( + (col) => + `${indent} ${JSON.stringify(col.name)}: ${renderMetadataValueField(col)}`, + ) + .join(",\n"); + return `{ +${inner}, +${indent}}`; +} + +// Render the runtime `metricViewsMetadata` const — a value twin of the +// type-level `metadata` blocks, conforming to MetricViewsMetadata from +// "shared". Emitted `as const`. Iterates `schemas` in the SAME order as the +// type augmentation. A degraded schema (empty measure/dimension arrays) +// contributes empty `measures: {}` / `dimensions: {}` maps, consistent with +// its degraded type block. +function renderMetricViewsMetadata(schemas: MetricSchema[]): string { + if (schemas.length === 0) { + return "export const metricViewsMetadata = {} as const;\n"; + } + const entries = schemas + .map((schema) => { + const measures = renderMetadataValueMap(schema.measures, " "); + const dimensions = renderMetadataValueMap(schema.dimensions, " "); + return ` ${JSON.stringify(schema.key)}: { + measures: ${measures}, + dimensions: ${dimensions}, + }`; + }) + .join(",\n"); + return `export const metricViewsMetadata = { +${entries}, +} as const; +`; +} + // Render the augmentation block for the appkit-ui MetricRegistry interface. function renderMetricRegistry(schemas: MetricSchema[]): string { if (schemas.length === 0) { @@ -172,12 +244,23 @@ ${entries}; `; } -// Build the full metric-views.d.ts file from a list of metric schemas. +/** + * Build the full metric-views.ts file from a list of metric schemas. + * + * This is a real `.ts` source file (not a `.d.ts`), so it carries BOTH the + * erasable `declare module` type augmentation AND a runtime value export + * (`metricViewsMetadata`). It must therefore never emit a runtime side-effect + * import — a bare `import "@databricks/appkit-ui/react"` would execute the + * client package entry on the Node server. The header is a type-only + * `import type {} from "..."`, which (a) compiles to zero runtime code and + * (b) anchors the module so the global `declare module` augmentation resolves. + */ export function generateMetricTypeDeclarations( schemas: MetricSchema[], ): string { return `// Auto-generated by AppKit - DO NOT EDIT // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build -import "@databricks/appkit-ui/react"; -${renderMetricRegistry(schemas)}`; +import type {} from "@databricks/appkit-ui/react"; +${renderMetricRegistry(schemas)} +${renderMetricViewsMetadata(schemas)}`; } diff --git a/packages/appkit/src/type-generator/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..f6f1ab01b 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 () => { @@ -531,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( @@ -921,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", @@ -1156,7 +1167,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..b796a7203 100644 --- a/packages/appkit/src/type-generator/tests/mv-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/mv-registry.test.ts @@ -227,12 +227,12 @@ describe("resolveMetricConfig", () => { }); }); -// ── Phase 2: UC-accurate FQN naming validation. The source FQN is validated -// against UC_FQN_PATTERN (single-sourced from the zod-free +// ── UC-accurate FQN naming validation. The source FQN is validated against +// UC_FQN_PATTERN (single-sourced from the zod-free // packages/shared/src/schemas/metric-fqn.ts, shared with the canonical Zod -// schema). The old hand-rolled segment charset [a-zA-Z0-9_-] was flagged in -// PR #433 review (pkosiec) as "more restrictive than UC"; these tests pin the -// arity/dot/charset rules and the now-accepted UC-legal characters. +// schema). A hand-rolled segment charset [a-zA-Z0-9_-] would be more +// restrictive than UC; these tests pin the arity/dot/charset rules and the +// UC-legal characters that must be accepted. describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { const sourceOf = (source: string) => ({ metricViews: { revenue: { source } }, @@ -289,8 +289,8 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { ).toThrowError(/the schema part .* contains a character/); }); - // ── Regression: UC-legal characters the OLD [a-zA-Z0-9_-] regex rejected - // now PASS. PR #433 review (pkosiec): "more restrictive than UC". ─────── + // ── UC-legal characters a narrow [a-zA-Z0-9_-] regex would reject must be + // accepted (hyphens, mixed case, non-ASCII). ─────────────────────────── test("accepts hyphens, mixed case, and non-ASCII names UC permits", () => { for (const source of [ "prod-data.analytics.revenue", @@ -364,10 +364,9 @@ describe("resolveMetricConfig — FQN naming (UC-accurate)", () => { }); }); -// ── Input caps (inline-only at v1): the canonical Zod schema has no caps -// yet — aligning it is a PR4 rider, so these fixtures deliberately do NOT -// run through metricSourceSchema (they'd pass it) and stay out of the -// parity suite below. +// ── Input caps (inline-only at v1): the canonical Zod schema does not yet +// carry these caps, so these fixtures deliberately do NOT run through +// metricSourceSchema (they'd pass it) and stay out of the parity suite below. describe("resolveMetricConfig — input caps", () => { const manyViews = (count: number) => Object.fromEntries( @@ -426,10 +425,10 @@ describe("resolveMetricConfig — input caps", () => { // inline; this block is the drift alarm for them. TEST-ONLY import of the Zod schema. // // Caps divergence: the inline validator enforces v1 input caps (≤200 entries, -// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not -// carry yet — aligning the Zod schema is a PR4 rider. Cap fixtures therefore -// live in the dedicated caps suite above and are asserted on the inline side -// only; do NOT add them here expecting metricSourceSchema to reject them. +// ≤255 per FQN segment, ≤767 full FQN) that the canonical schema does not carry +// yet. Cap fixtures therefore live in the dedicated caps suite above and are +// asserted on the inline side only; do NOT add them here expecting +// metricSourceSchema to reject them. describe("resolveMetricConfig — parity with shared metricSourceSchema", () => { const accepts: Array<{ name: string; config: Record }> = [ { @@ -775,11 +774,11 @@ describe("createWorkspaceDescribeFetcher", () => { }); test("a backtick-bearing FQN is now accepted and safely quoted (UC permits it, quoting doubles it)", async () => { - // Under the old hand-rolled segment charset ([a-zA-Z0-9_-]) a backtick was - // rejected outright. UC actually permits a backtick inside a quoted name, - // and quoteFqnForSql (Phase 1) makes it injection-safe by doubling it. So - // naming validation now accepts it and the statement quotes it as a single - // identifier rather than refusing the FQN. + // A narrow segment charset ([a-zA-Z0-9_-]) would reject a backtick outright. + // UC actually permits a backtick inside a quoted name, and quoteFqnForSql + // makes it injection-safe by doubling it. So naming validation accepts it + // and the statement quotes it as a single identifier rather than refusing + // the FQN. const { client, statements } = stubClient(); const fetcher = createWorkspaceDescribeFetcher(client, "wh-1"); @@ -843,7 +842,7 @@ describe("extractMetricColumns", () => { expect(extractMetricColumns({ unrelated: true })).toEqual([]); }); - // ── Phase 2: time-typed dimensions ──────────────────────────────────── + // ── time-typed dimensions ────────────────────────────────────────────── test("infers all 7 standard grains for a TIMESTAMP dimension", () => { const cols = extractMetricColumns({ columns: [ @@ -1408,7 +1407,7 @@ describe("syncMetrics — bounded-concurrency scheduling", () => { expect(schemas.map((s) => s.key)).toEqual(keys); // Rejected entries land in `failures` (stable entry order) AND are - // degraded — the Phase-1 matrix, unchanged by chunking. + // degraded — the failure matrix is unchanged by chunking. expect(failures.map((f) => f.key)).toEqual(["m02", "m06", "m11"]); for (const failure of failures) { expect(failure.source).toBe(`demo.public.${failure.key}`); @@ -1583,7 +1582,7 @@ describe("generateMetricTypeDeclarations — snapshot", () => { expect(output).toContain("measures: Record"); }); - // ── Phase 2: time-typed dim + multiple non-time dims fixture ───────── + // ── time-typed dim + multiple non-time dims fixture ────────────────── test("emits TimeGrain union for a metric view with time-typed + regular dimensions", async () => { const resolution = resolveMetricConfig({ metricViews: { @@ -1623,8 +1622,114 @@ describe("generateMetricTypeDeclarations — snapshot", () => { }); }); -// ── Phase 5: semantic-metadata extraction (display_name + format) ───────── -describe("extractMetricColumns — Phase 5 semantic metadata", () => { +// ── The emitted file is a real `.ts` carrying BOTH the (erasable) `declare +// module` type augmentation AND a runtime `metricViewsMetadata` value. It must +// never emit a runtime side-effect import (that would execute the client +// package entry on the Node server) — only a zero-runtime type-only import. +describe("generateMetricTypeDeclarations — runtime metricViewsMetadata value", () => { + test("emits both the declare-module augmentation and the metricViewsMetadata const", async () => { + const resolution = resolveMetricConfig({ + metricViews: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + display_name: "Annual Recurring Revenue", + format: "$#,##0.00", + }, + { name: "region", type: "STRING", is_measure: false }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // Type half: the augmentation is still present, unchanged in shape. + expect(output).toContain('declare module "@databricks/appkit-ui/react"'); + expect(output).toContain("interface MetricRegistry"); + // Value half: a runtime const conforming to MetricViewsMetadata, `as const`. + expect(output).toContain("export const metricViewsMetadata = {"); + expect(output).toContain("} as const;"); + // The measure/dimension maps carry the SAME per-column fields as the type + // block (type/display_name/format), keyed by column name. + expect(output).toContain( + '"arr": { type: "DECIMAL(38,2)", display_name: "Annual Recurring Revenue", format: "$#,##0.00" }', + ); + expect(output).toContain('"region": { type: "STRING" }'); + }); + + test("uses a zero-runtime type-only import, never a side-effect import", () => { + const output = generateMetricTypeDeclarations([]); + // A bare `import "..."` in a `.ts` would EXECUTE the client entry on the + // Node server — it must never be emitted. + expect(output).not.toContain('import "@databricks/appkit-ui/react"'); + expect(output).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); + }); + + test("emits an empty metricViewsMetadata for no registered metrics", () => { + const output = generateMetricTypeDeclarations([]); + expect(output).toContain("export const metricViewsMetadata = {} as const;"); + // Empty type augmentation stays too. + expect(output).toContain("interface MetricRegistry {}"); + }); + + test("a degraded schema contributes empty measures/dimensions value maps", async () => { + const resolution = resolveMetricConfig({ + metricViews: { cold: { source: "appkit_demo.public.cold" } }, + }); + // Non-terminal DESCRIBE → degraded schema (empty column arrays). + const fetcher = + async (): Promise => ({ + statement_id: "stmt-mock", + status: { state: "PENDING" }, + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + // Value side of a degraded entry: empty maps, consistent with its + // `Record` metadata type block. + expect(output).toContain(`"cold": { + measures: {}, + dimensions: {}, + }`); + }); + + test("escapes quotes/backticks in display_name and description via JSON.stringify", async () => { + const resolution = resolveMetricConfig({ + metricViews: { revenue: { source: "appkit_demo.public.revenue" } }, + }); + const fetcher = async () => + mockDescribeResponse({ + columns: [ + { + name: "arr", + type: "DECIMAL(38,2)", + is_measure: true, + // A double quote AND a backtick — both must survive into a valid + // TS string literal in the runtime const. + display_name: 'Net "ARR" `growth`', + comment: 'Revenue with a " quote', + }, + ], + }); + const { schemas } = await syncMetrics(resolution, fetcher); + const output = generateMetricTypeDeclarations(schemas); + + // JSON.stringify escapes the embedded double quotes; the backtick rides + // through unescaped inside a double-quoted literal (valid TS). + expect(output).toContain('display_name: "Net \\"ARR\\" `growth`"'); + expect(output).toContain('description: "Revenue with a \\" quote"'); + }); +}); + +// ── semantic-metadata extraction (display_name + format) ────────────────── +describe("extractMetricColumns — semantic metadata", () => { test("captures display_name from a measure column", () => { const cols = extractMetricColumns({ columns: [ @@ -1946,12 +2051,12 @@ describe("extractMetricColumns — Phase 5 semantic metadata", () => { }); }); -// ── Key-order determinism: the .d.ts emitter sorts metric keys with a +// ── Key-order determinism: the emitter sorts metric keys with a // locale-independent (code-unit) comparator. localeCompare-style collation // would interleave mixed-case keys ("ARPU", "churn", "Revenue") and could vary // by machine/locale, drifting the emitted augmentation between builds. describe("artifact key-order determinism", () => { - test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.d.ts", async () => { + test("mixed-case keys order code-unit (uppercase before lowercase) in metric-views.ts", async () => { const resolution = resolveMetricConfig({ metricViews: { Revenue: { source: "a.b.r" }, @@ -1973,7 +2078,7 @@ describe("artifact key-order determinism", () => { }); const { schemas } = await syncMetrics(resolution, fetcher); - // Entry keys in the .d.ts appear as ` "": {` lines (4-space + // Entry keys in the augmentation appear as ` "": {` lines (4-space // indent — metadata column maps sit deeper and don't match). const declarations = generateMetricTypeDeclarations(schemas); const dtsKeys = [...declarations.matchAll(/^ {4}"([^"]+)": \{$/gm)].map( @@ -1984,7 +2089,7 @@ describe("artifact key-order determinism", () => { }); }); -// ── Phase 2: syncMetrics propagates timeGrains end-to-end ──────────────── +// ── syncMetrics propagates timeGrains end-to-end ───────────────────────── describe("syncMetrics — time-typed dimension propagation", () => { test("propagates inferred grains onto the resulting MetricSchema", async () => { const resolution = resolveMetricConfig({ diff --git a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts index 337892aa8..d6d4154ce 100644 --- a/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts +++ b/packages/appkit/src/type-generator/tests/sync-metric-views-types.test.ts @@ -128,7 +128,7 @@ describe("syncMetricViewsTypes", () => { tmpRoot, "shared", "appkit-types", - "metric-views.d.ts", + "metric-views.ts", ); }); @@ -146,7 +146,7 @@ describe("syncMetricViewsTypes", () => { metricFetcher: fetcher, }); - // The .d.ts exists on disk. + // The generated .ts exists on disk. expect(fs.existsSync(metricOutFile)).toBe(true); // Result reports both keys, no failures, config present. @@ -158,7 +158,7 @@ describe("syncMetricViewsTypes", () => { ]); expect(result.metricOutFile).toBe(metricOutFile); - // --- metric-views.d.ts: MetricRegistry augmentation for both metrics --- + // --- metric-views.ts: MetricRegistry augmentation for both metrics --- const declarations = fs.readFileSync(metricOutFile, "utf-8"); expect(declarations).toContain("interface MetricRegistry"); expect(declarations).toContain('"revenue"'); @@ -172,9 +172,48 @@ describe("syncMetricViewsTypes", () => { expect(declarations).toContain('lane: "sp"'); // The TIMESTAMP dimension carries inferred time grains in its @timeGrain tag. expect(declarations).toContain("@timeGrain"); - // The semantic metadata (format spec, SQL type) rides in the .d.ts's - // type-level `metadata` block — the sole carrier now the JSON is gone. + // The semantic metadata (format spec, SQL type) rides in the type-level + // `metadata` block — the sole carrier now the JSON is gone. expect(declarations).toContain('"$#,##0.00"'); + // The file is a real `.ts`: it also carries the runtime `metricViewsMetadata` + // const, and never a runtime side-effect import (only a type-only one). + expect(declarations).toContain("export const metricViewsMetadata"); + expect(declarations).toContain("as const"); + expect(declarations).not.toContain('import "@databricks/appkit-ui/react"'); + expect(declarations).toContain( + 'import type {} from "@databricks/appkit-ui/react"', + ); + }); + + test("removes a stale sibling metric-views.d.ts left by a pre-.ts version on upgrade", async () => { + writeMixedConfig(); + + // Simulate an app upgraded from a version that emitted an ambient + // `metric-views.d.ts`. Left in place beside the new `.ts`, it would + // duplicate the `declare module` augmentation and re-introduce the bare + // side-effect import the new header drops. + const staleDts = path.join( + tmpRoot, + "shared", + "appkit-types", + "metric-views.d.ts", + ); + fs.mkdirSync(path.dirname(staleDts), { recursive: true }); + fs.writeFileSync( + staleDts, + '// old\nimport "@databricks/appkit-ui/react";\n', + ); + + await syncMetricViewsTypes({ + metricViewsFolder, + warehouseId: "wh-1", + metricOutFile, + metricFetcher: fetcher, + }); + + // The new .ts is written and the stale .d.ts sibling is swept. + expect(fs.existsSync(metricOutFile)).toBe(true); + expect(fs.existsSync(staleDts)).toBe(false); }); test("returns noConfig and writes nothing when definitions.json is absent", async () => { diff --git a/packages/appkit/src/type-generator/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/tests/vite-plugin.test.ts index 214dc9a31..7bbe6d8b5 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,13 +400,23 @@ describe("appKitTypesPlugin — metric option plumbing", () => { expect(mocks.generateFromEntryPoint).toHaveBeenCalledWith( expect.objectContaining({ - mvOutFile: path.resolve( - process.cwd(), - "custom/types/metric-views.d.ts", - ), + mvOutFile: path.resolve(process.cwd(), "custom/types/metric-views.ts"), }), ); }); + + test("rejects a .d.ts custom mvOutFile up front (it would emit a runtime const into an ambient decl → TS1039)", () => { + const plugin = appKitTypesPlugin({ + mvOutFile: "custom/types/metric-views.d.ts", + }); + const configResolved = getHook( + plugin, + "configResolved", + ); + expect(() => + configResolved({ root: path.join(process.cwd(), "client") }), + ).toThrow(/must be a \.ts file, not a \.d\.ts/); + }); }); describe("appKitTypesPlugin — background warehouse watch", () => { diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 1bcb08ae5..a0fe6d7bc 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -35,8 +35,10 @@ interface AppKitTypesPluginOptions { /* Path to the output d.ts file (relative to client folder). */ outFile?: string; /** - * Path to the metric registry d.ts file (relative to client folder). - * Defaults to a sibling of `outFile`, computed by the generator. + * Path to the metric registry `.ts` file (relative to client folder). + * Defaults to a sibling of `outFile`, computed by the generator. The + * generated source carries both the `declare module` augmentation and the + * runtime `metricViewsMetadata` const, so it is a real `.ts`, not a `.d.ts`. */ mvOutFile?: string; /** @@ -330,6 +332,17 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { // final path is identical (the default outFile above lives in // shared//), and a customized outFile now keeps its metric // sibling next to it instead of pinning it under shared/. + // + // Reject a `.d.ts` metric out-path up front: the metric file is a real + // `.ts` source carrying a runtime `const` (metricViewsMetadata), which is + // illegal inside an ambient declaration file (TS1039). Fail fast with a + // clear message rather than emitting a file that won't compile. + if (options?.mvOutFile?.endsWith(".d.ts")) { + throw new Error( + `appKitAnalyticsTypesPlugin: mvOutFile must be a .ts file, not a .d.ts (got "${options.mvOutFile}"). ` + + "The metric-views file carries a runtime const, which cannot live in an ambient .d.ts.", + ); + } mvOutFile = options?.mvOutFile !== undefined ? path.resolve(projectRoot, options.mvOutFile) diff --git a/packages/shared/src/cli/commands/generate-types.test.ts b/packages/shared/src/cli/commands/generate-types.test.ts index 30255cc87..1c2f5c855 100644 --- a/packages/shared/src/cli/commands/generate-types.test.ts +++ b/packages/shared/src/cli/commands/generate-types.test.ts @@ -235,7 +235,7 @@ describe("generate-types foreground spawn orchestration", () => { }); test("reports the metric artifact when config/metric-views/definitions.json exists", async () => { - // The metric path is additive: generateFromEntryPoint emits metric-views.d.ts + // The metric path is additive: generateFromEntryPoint emits metric-views.ts // as a sibling of the query out file whenever the config is present. The CLI // announces it off the same dormancy signal. const outFile = path.join(tmpRoot, "shared/appkit-types/analytics.d.ts"); @@ -249,7 +249,7 @@ describe("generate-types foreground spawn orchestration", () => { const logged = consoleLog.mock.calls.flat().map(String); expect(logged).toContain(`Generated query types: ${outFile}`); expect(logged).toContain( - `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.d.ts")}`, + `Generated metric types: ${path.join(path.dirname(outFile), "metric-views.ts")}`, ); }); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 03ab43c0d..f38f323a2 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -95,7 +95,7 @@ async function runGenerateTypes( if (fs.existsSync(metricConfig)) { const typesDir = path.dirname(resolvedOutFile); console.log( - `Generated metric types: ${path.join(typesDir, "metric-views.d.ts")}`, + `Generated metric types: ${path.join(typesDir, "metric-views.ts")}`, ); } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d036e0dbd..e1dfb7ac6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,8 @@ 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"; export * from "./sse/analytics"; 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 }; 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 }; }