From a559e99a63fbda81756924034258503df8457b00 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 9 Jun 2026 15:36:02 +0200 Subject: [PATCH 1/7] chore(shared): add metric.json schema (Zod source + generated JSON Schema) Author the metric.json config contract as Zod in packages/shared/src/schemas/metric-source.ts (single source of truth) and generate the published JSON Schema via tools/generate-json-schema.ts into docs/static/schemas/metric-source.schema.json. metric.json declares the Unity Catalog Metric View sources (sp/obo lanes) that opt an app into the analytics metric-view path. Reconciles PR1 of the metric-views stack onto main's Zod-first schema convention; #341 authored this JSON-first with a separate generated type. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/static/schemas/metric-source.schema.json | 66 ++++++++++++++ packages/shared/src/schemas/metric-source.ts | 87 +++++++++++++++++++ tools/generate-json-schema.ts | 13 +++ 3 files changed, 166 insertions(+) create mode 100644 docs/static/schemas/metric-source.schema.json create mode 100644 packages/shared/src/schemas/metric-source.ts diff --git a/docs/static/schemas/metric-source.schema.json b/docs/static/schemas/metric-source.schema.json new file mode 100644 index 00000000..c8d350ce --- /dev/null +++ b/docs/static/schemas/metric-source.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + "title": "AppKit Metric Source Configuration", + "type": "object", + "properties": { + "$schema": { + "description": "Reference to the JSON Schema for validation", + "type": "string" + }, + "sp": { + "description": "Metric views queried as the service principal. Cache scope is shared across all users.", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key." + }, + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "type": "string", + "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$", + "description": "Three-part Unity Catalog FQN of the metric view: ..", + "examples": [ + "appkit_demo.public.revenue_metrics", + "main.analytics.customer_metrics" + ] + } + }, + "required": ["source"], + "additionalProperties": false, + "description": "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." + } + }, + "obo": { + "description": "Metric views queried as the requesting user (on-behalf-of). Cache scope is per-user.", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key." + }, + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "type": "string", + "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$", + "description": "Three-part Unity Catalog FQN of the metric view: ..", + "examples": [ + "appkit_demo.public.revenue_metrics", + "main.analytics.customer_metrics" + ] + } + }, + "required": ["source"], + "additionalProperties": false, + "description": "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." + } + } + }, + "additionalProperties": false, + "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under sp/obo binds a metric key to a UC metric view FQN. Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." +} diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts new file mode 100644 index 00000000..132c1a65 --- /dev/null +++ b/packages/shared/src/schemas/metric-source.ts @@ -0,0 +1,87 @@ +/** + * Zod-authoring module for the AppKit metric-source schema. + * + * Single source of truth for `metric.json` — the opt-in config that activates + * the analytics plugin's metric-view path. The JSON Schema artifact published + * at the docs URL is emitted from this schema via + * `tools/generate-json-schema.ts` and lives only in `docs/static/schemas/` + * (no package-internal copies). + * + * `metric.json` declares Unity Catalog Metric View sources. Each entry under + * `sp`/`obo` binds a metric key to a UC metric view FQN: + * - `sp` entries are queried as the service principal (cache scope shared + * across all users). + * - `obo` entries are queried as the requesting user (on-behalf-of; cache + * scope per-user). + * + * Entries are objects (rather than bare strings) at v1 so future per-entry + * options (cacheTtl, defaultFilter, allowlists) can ship as additive + * properties without a breaking change. + */ + +import { z } from "zod"; + +// ── Metric key ─────────────────────────────────────────────────────────── + +export const metricKeySchema = z + .string() + .regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/) + .describe( + "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key.", + ); + +// ── Metric entry (single source declaration) ───────────────────────────── + +export const metricEntrySchema = z + .object({ + source: z + .string() + .regex( + /^[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$/, + ) + .describe( + "Three-part Unity Catalog FQN of the metric view: ..", + ) + .meta({ + examples: [ + "appkit_demo.public.revenue_metrics", + "main.analytics.customer_metrics", + ], + }), + }) + .strict() + .describe( + "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.", + ); + +// ── Metric source config (root) ────────────────────────────────────────── + +export const metricSourceSchema = z + .object({ + $schema: z + .string() + .optional() + .describe("Reference to the JSON Schema for validation"), + sp: z + .record(metricKeySchema, metricEntrySchema) + .optional() + .describe( + "Metric views queried as the service principal. Cache scope is shared across all users.", + ), + obo: z + .record(metricKeySchema, metricEntrySchema) + .optional() + .describe( + "Metric views queried as the requesting user (on-behalf-of). Cache scope is per-user.", + ), + }) + .strict() + .describe( + "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under sp/obo binds a metric key to a UC metric view FQN. Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", + ); + +// ── Inferred types ─────────────────────────────────────────────────────── + +export type MetricKey = z.infer; +export type MetricEntry = z.infer; +export type MetricSource = z.infer; diff --git a/tools/generate-json-schema.ts b/tools/generate-json-schema.ts index 367103e2..25cd7519 100644 --- a/tools/generate-json-schema.ts +++ b/tools/generate-json-schema.ts @@ -14,6 +14,7 @@ import { pluginManifestSchema, templatePluginsManifestSchema, } from "../packages/shared/src/schemas/manifest"; +import { metricSourceSchema } from "../packages/shared/src/schemas/metric-source"; import { formatWithBiome } from "./format-with-biome"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -28,11 +29,17 @@ const TEMPLATE_OUT_PATH = path.join( DOCS_SCHEMAS_DIR, "template-plugins.schema.json", ); +const METRIC_SOURCE_OUT_PATH = path.join( + DOCS_SCHEMAS_DIR, + "metric-source.schema.json", +); const PLUGIN_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; const TEMPLATE_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/template-plugins.schema.json"; +const METRIC_SOURCE_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/metric-source.schema.json"; function emit( schema: z.ZodType, @@ -91,9 +98,15 @@ async function main(): Promise { TEMPLATE_SCHEMA_ID, "AppKit Template Plugins Manifest", ); + const metricSourceJson = emit( + metricSourceSchema, + METRIC_SOURCE_SCHEMA_ID, + "AppKit Metric Source Configuration", + ); writeJson(PLUGIN_OUT_PATH, pluginJson); writeJson(TEMPLATE_OUT_PATH, templateJson); + writeJson(METRIC_SOURCE_OUT_PATH, metricSourceJson); } main().catch((err) => { From 2e038a2756aab559c251d684bbc6d4db6c2ce62c Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 9 Jun 2026 15:38:19 +0200 Subject: [PATCH 2/7] test(shared): add metric.json schema validation tests Validate metricSourceSchema directly via safeParse (no Ajv): accepts sp-only / mixed sp+obo / empty configs; rejects bare-string entries, missing source, unknown entry and top-level fields, invalid metric keys (leading digit, hyphen), and malformed source FQNs. Ports the #341 case set to main's Zod-first validation idiom. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../shared/src/schemas/metric-source.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/shared/src/schemas/metric-source.test.ts diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts new file mode 100644 index 00000000..cbefdd22 --- /dev/null +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; +import { metricSourceSchema } from "./metric-source"; + +describe("metricSourceSchema", () => { + test("accepts a valid SP-only configuration", () => { + const config = { + $schema: + "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + sp: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + obo: {}, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(true); + }); + + test("accepts mixed sp + obo lanes", () => { + const config = { + sp: { revenue: { source: "demo.public.revenue" } }, + obo: { customer: { source: "demo.public.customer_metrics" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(true); + }); + + test("accepts an empty configuration", () => { + expect(metricSourceSchema.safeParse({}).success).toBe(true); + expect(metricSourceSchema.safeParse({ sp: {}, obo: {} }).success).toBe( + true, + ); + }); + + test("accepts metric keys with underscores", () => { + const config = { + sp: { customer_metrics: { source: "demo.public.customer_metrics" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(true); + }); + + test("rejects a bare-string entry (must be an object)", () => { + const config = { + sp: { revenue: "demo.public.revenue" }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects an entry without source", () => { + const config = { + sp: { revenue: {} }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects unknown fields on entries", () => { + const config = { + sp: { + revenue: { + source: "a.b.c", + ttl: 5, // future option, not in v1 + }, + }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects unknown top-level keys", () => { + expect(metricSourceSchema.safeParse({ foo: 1 }).success).toBe(false); + expect( + metricSourceSchema.safeParse({ sp: {}, obo: {}, unknown: {} }).success, + ).toBe(false); + }); + + test("rejects metric keys that start with a digit", () => { + const config = { + sp: { "1bad": { source: "a.b.c" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects metric keys containing a hyphen", () => { + const config = { + sp: { "bad-key": { source: "a.b.c" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects a non-three-part FQN", () => { + const cases = [ + "revenue", // single token + "demo.revenue", // two parts + "four.parts.really.bad", + ".starts.with.dot", + "ends.with.dot.", + ]; + for (const source of cases) { + const config = { sp: { revenue: { source } } }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + } + }); +}); From fa05d05cc47eac6b7add62814deb035d6e2ea192 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 9 Jun 2026 16:22:30 +0200 Subject: [PATCH 3/7] docs(shared): tidy metric-source schema comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the module header, move the object-entry rationale to a @note on metricEntrySchema, and drop the section banners. Comment-only — the Zod schema, describe() strings, and generated JSON are unchanged. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/shared/src/schemas/metric-source.ts | 30 +++++++------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts index 132c1a65..10471c95 100644 --- a/packages/shared/src/schemas/metric-source.ts +++ b/packages/shared/src/schemas/metric-source.ts @@ -1,28 +1,19 @@ /** - * Zod-authoring module for the AppKit metric-source schema. + * AppKit metric-source schema. * - * Single source of truth for `metric.json` — the opt-in config that activates - * the analytics plugin's metric-view path. The JSON Schema artifact published - * at the docs URL is emitted from this schema via - * `tools/generate-json-schema.ts` and lives only in `docs/static/schemas/` - * (no package-internal copies). + * Single source of truth for `metric.json` + * the config that activates the Analytics' metric-view path. * - * `metric.json` declares Unity Catalog Metric View sources. Each entry under - * `sp`/`obo` binds a metric key to a UC metric view FQN: + * `metric.json` declares UC Metric Views. + * Each entry under `sp`/`obo` binds a metric key to a UC metric view FQN: * - `sp` entries are queried as the service principal (cache scope shared * across all users). * - `obo` entries are queried as the requesting user (on-behalf-of; cache * scope per-user). - * - * Entries are objects (rather than bare strings) at v1 so future per-entry - * options (cacheTtl, defaultFilter, allowlists) can ship as additive - * properties without a breaking change. */ import { z } from "zod"; -// ── Metric key ─────────────────────────────────────────────────────────── - export const metricKeySchema = z .string() .regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/) @@ -30,8 +21,11 @@ export const metricKeySchema = z "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key.", ); -// ── Metric entry (single source declaration) ───────────────────────────── - +/** + * @note Entries are objects (rather than bare strings) at v1 so future per-entry + * options (cacheTtl, defaultFilter, allowlists) can ship as additive + * properties without a breaking change. + */ export const metricEntrySchema = z .object({ source: z @@ -54,8 +48,6 @@ export const metricEntrySchema = z "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.", ); -// ── Metric source config (root) ────────────────────────────────────────── - export const metricSourceSchema = z .object({ $schema: z @@ -80,8 +72,6 @@ export const metricSourceSchema = z "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under sp/obo binds a metric key to a UC metric view FQN. Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", ); -// ── Inferred types ─────────────────────────────────────────────────────── - export type MetricKey = z.infer; export type MetricEntry = z.infer; export type MetricSource = z.infer; From 1e141cd3d7686a1b7acc4230ba9e534d395fd8a9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 10 Jun 2026 11:44:15 +0200 Subject: [PATCH 4/7] test(shared): drop obo from the SP-only schema case The SP-only case carried an explicit obo: {} (ported verbatim from the reference); empty-lane coverage already lives in the empty-configuration case. Review feedback on #429. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/shared/src/schemas/metric-source.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts index cbefdd22..893477e9 100644 --- a/packages/shared/src/schemas/metric-source.test.ts +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -9,7 +9,6 @@ describe("metricSourceSchema", () => { sp: { revenue: { source: "appkit_demo.public.revenue_metrics" }, }, - obo: {}, }; expect(metricSourceSchema.safeParse(config).success).toBe(true); }); From 917242a8014dc602030288ed000de7e358fdc8a0 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 10 Jun 2026 15:33:23 +0200 Subject: [PATCH 5/7] chore(shared): reshape metric.json to entity-first metrics map with executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sp/obo lane sections with a single 'metrics' map; the execution principal moves into each entry as 'executor' ("service_principal" | "user"), defaulting to service_principal — consistent with plain .sql queries executing as SP. Entity-first also makes metric keys unique by construction: the same key can no longer be declared in two lanes, so the cross-lane duplicate rule (previously unexpressible in the schema and enforced post-parse) becomes unrepresentable. Review feedback from calvarjorge on #429. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/static/schemas/metric-source.schema.json | 38 +++--------- .../shared/src/schemas/metric-source.test.ts | 62 +++++++++++++------ packages/shared/src/schemas/metric-source.ts | 41 +++++++----- 3 files changed, 77 insertions(+), 64 deletions(-) diff --git a/docs/static/schemas/metric-source.schema.json b/docs/static/schemas/metric-source.schema.json index c8d350ce..96c64b95 100644 --- a/docs/static/schemas/metric-source.schema.json +++ b/docs/static/schemas/metric-source.schema.json @@ -8,8 +8,8 @@ "description": "Reference to the JSON Schema for validation", "type": "string" }, - "sp": { - "description": "Metric views queried as the service principal. Cache scope is shared across all users.", + "metrics": { + "description": "Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.", "type": "object", "propertyNames": { "type": "string", @@ -27,40 +27,20 @@ "appkit_demo.public.revenue_metrics", "main.analytics.customer_metrics" ] - } - }, - "required": ["source"], - "additionalProperties": false, - "description": "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." - } - }, - "obo": { - "description": "Metric views queried as the requesting user (on-behalf-of). Cache scope is per-user.", - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "description": "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key." - }, - "additionalProperties": { - "type": "object", - "properties": { - "source": { + }, + "executor": { + "default": "service_principal", "type": "string", - "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$", - "description": "Three-part Unity Catalog FQN of the metric view: ..", - "examples": [ - "appkit_demo.public.revenue_metrics", - "main.analytics.customer_metrics" - ] + "enum": ["service_principal", "user"], + "description": "Who the metric view is queried as. 'service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache." } }, "required": ["source"], "additionalProperties": false, - "description": "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." + "description": "A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." } } }, "additionalProperties": false, - "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under sp/obo binds a metric key to a UC metric view FQN. Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." + "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." } diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts index 893477e9..a5b7ca3f 100644 --- a/packages/shared/src/schemas/metric-source.test.ts +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -2,56 +2,82 @@ import { describe, expect, test } from "vitest"; import { metricSourceSchema } from "./metric-source"; describe("metricSourceSchema", () => { - test("accepts a valid SP-only configuration", () => { + test("accepts a minimal configuration and defaults executor to service_principal", () => { const config = { $schema: "https://databricks.github.io/appkit/schemas/metric-source.schema.json", - sp: { + metrics: { revenue: { source: "appkit_demo.public.revenue_metrics" }, }, }; - expect(metricSourceSchema.safeParse(config).success).toBe(true); + const result = metricSourceSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.metrics?.revenue.executor).toBe("service_principal"); }); - test("accepts mixed sp + obo lanes", () => { + test("accepts explicit executor values", () => { const config = { - sp: { revenue: { source: "demo.public.revenue" } }, - obo: { customer: { source: "demo.public.customer_metrics" } }, + metrics: { + revenue: { + source: "demo.public.revenue", + executor: "service_principal", + }, + my_orders: { source: "main.sales.orders_by_user", executor: "user" }, + }, }; - expect(metricSourceSchema.safeParse(config).success).toBe(true); + const result = metricSourceSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.metrics?.my_orders.executor).toBe("user"); }); test("accepts an empty configuration", () => { expect(metricSourceSchema.safeParse({}).success).toBe(true); - expect(metricSourceSchema.safeParse({ sp: {}, obo: {} }).success).toBe( - true, - ); + expect(metricSourceSchema.safeParse({ metrics: {} }).success).toBe(true); }); test("accepts metric keys with underscores", () => { const config = { - sp: { customer_metrics: { source: "demo.public.customer_metrics" } }, + metrics: { + customer_metrics: { source: "demo.public.customer_metrics" }, + }, }; expect(metricSourceSchema.safeParse(config).success).toBe(true); }); + test("rejects the legacy sp/obo lane shape", () => { + const config = { + sp: { revenue: { source: "demo.public.revenue" } }, + obo: { my_orders: { source: "main.sales.orders_by_user" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects invalid executor values", () => { + for (const executor of ["sp", "obo", "app_service_principal", "USER"]) { + const config = { + metrics: { revenue: { source: "a.b.c", executor } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + } + }); + test("rejects a bare-string entry (must be an object)", () => { const config = { - sp: { revenue: "demo.public.revenue" }, + metrics: { revenue: "demo.public.revenue" }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects an entry without source", () => { const config = { - sp: { revenue: {} }, + metrics: { revenue: {} }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects unknown fields on entries", () => { const config = { - sp: { + metrics: { revenue: { source: "a.b.c", ttl: 5, // future option, not in v1 @@ -64,20 +90,20 @@ describe("metricSourceSchema", () => { test("rejects unknown top-level keys", () => { expect(metricSourceSchema.safeParse({ foo: 1 }).success).toBe(false); expect( - metricSourceSchema.safeParse({ sp: {}, obo: {}, unknown: {} }).success, + metricSourceSchema.safeParse({ metrics: {}, unknown: {} }).success, ).toBe(false); }); test("rejects metric keys that start with a digit", () => { const config = { - sp: { "1bad": { source: "a.b.c" } }, + metrics: { "1bad": { source: "a.b.c" } }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects metric keys containing a hyphen", () => { const config = { - sp: { "bad-key": { source: "a.b.c" } }, + metrics: { "bad-key": { source: "a.b.c" } }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); @@ -91,7 +117,7 @@ describe("metricSourceSchema", () => { "ends.with.dot.", ]; for (const source of cases) { - const config = { sp: { revenue: { source } } }; + const config = { metrics: { revenue: { source } } }; expect(metricSourceSchema.safeParse(config).success).toBe(false); } }); diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts index 10471c95..9bbc3608 100644 --- a/packages/shared/src/schemas/metric-source.ts +++ b/packages/shared/src/schemas/metric-source.ts @@ -4,12 +4,17 @@ * Single source of truth for `metric.json` * the config that activates the Analytics' metric-view path. * - * `metric.json` declares UC Metric Views. - * Each entry under `sp`/`obo` binds a metric key to a UC metric view FQN: - * - `sp` entries are queried as the service principal (cache scope shared - * across all users). - * - `obo` entries are queried as the requesting user (on-behalf-of; cache - * scope per-user). + * `metric.json` declares UC Metric Views under a single `metrics` map. + * Each entry binds a metric key to a UC metric view FQN plus the executor + * the query runs as: + * - `executor: "service_principal"` (default) — queried as the app service + * principal (cache scope shared across all users). + * - `executor: "user"` — queried as the requesting user (on-behalf-of; + * cache scope per-user). + * + * A single map (rather than per-executor sections) makes metric keys unique + * by construction — the same key cannot be declared twice with different + * executors. */ import { z } from "zod"; @@ -21,10 +26,16 @@ export const metricKeySchema = z "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key.", ); +export const metricExecutorSchema = z + .enum(["service_principal", "user"]) + .describe( + "Who the metric view is queried as. 'service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.", + ); + /** * @note Entries are objects (rather than bare strings) at v1 so future per-entry * options (cacheTtl, defaultFilter, allowlists) can ship as additive - * properties without a breaking change. + * properties without a breaking change. `executor` is the first such option. */ export const metricEntrySchema = z .object({ @@ -42,10 +53,11 @@ export const metricEntrySchema = z "main.analytics.customer_metrics", ], }), + executor: metricExecutorSchema.default("service_principal"), }) .strict() .describe( - "A single metric view source declaration. v1 only accepts the 'source' field; future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.", + "A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.", ); export const metricSourceSchema = z @@ -54,24 +66,19 @@ export const metricSourceSchema = z .string() .optional() .describe("Reference to the JSON Schema for validation"), - sp: z - .record(metricKeySchema, metricEntrySchema) - .optional() - .describe( - "Metric views queried as the service principal. Cache scope is shared across all users.", - ), - obo: z + metrics: z .record(metricKeySchema, metricEntrySchema) .optional() .describe( - "Metric views queried as the requesting user (on-behalf-of). Cache scope is per-user.", + "Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.", ), }) .strict() .describe( - "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under sp/obo binds a metric key to a UC metric view FQN. Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", + "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", ); export type MetricKey = z.infer; +export type MetricExecutor = z.infer; export type MetricEntry = z.infer; export type MetricSource = z.infer; From 5fc4a0079c005ef4bbbf709fa562853c7e7f93b3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 10 Jun 2026 15:46:01 +0200 Subject: [PATCH 6/7] chore(shared): rename executor value service_principal to app_service_principal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More specific about whose service principal executes the query — the app's. Bare service_principal is now a rejected value. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/static/schemas/metric-source.schema.json | 8 ++++---- packages/shared/src/schemas/metric-source.test.ts | 10 ++++++---- packages/shared/src/schemas/metric-source.ts | 10 +++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/static/schemas/metric-source.schema.json b/docs/static/schemas/metric-source.schema.json index 96c64b95..e11c2926 100644 --- a/docs/static/schemas/metric-source.schema.json +++ b/docs/static/schemas/metric-source.schema.json @@ -29,10 +29,10 @@ ] }, "executor": { - "default": "service_principal", + "default": "app_service_principal", "type": "string", - "enum": ["service_principal", "user"], - "description": "Who the metric view is queried as. 'service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache." + "enum": ["app_service_principal", "user"], + "description": "Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache." } }, "required": ["source"], @@ -42,5 +42,5 @@ } }, "additionalProperties": false, - "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." + "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." } diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts index a5b7ca3f..5974d486 100644 --- a/packages/shared/src/schemas/metric-source.test.ts +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { metricSourceSchema } from "./metric-source"; describe("metricSourceSchema", () => { - test("accepts a minimal configuration and defaults executor to service_principal", () => { + test("accepts a minimal configuration and defaults executor to app_service_principal", () => { const config = { $schema: "https://databricks.github.io/appkit/schemas/metric-source.schema.json", @@ -12,7 +12,9 @@ describe("metricSourceSchema", () => { }; const result = metricSourceSchema.safeParse(config); expect(result.success).toBe(true); - expect(result.data?.metrics?.revenue.executor).toBe("service_principal"); + expect(result.data?.metrics?.revenue.executor).toBe( + "app_service_principal", + ); }); test("accepts explicit executor values", () => { @@ -20,7 +22,7 @@ describe("metricSourceSchema", () => { metrics: { revenue: { source: "demo.public.revenue", - executor: "service_principal", + executor: "app_service_principal", }, my_orders: { source: "main.sales.orders_by_user", executor: "user" }, }, @@ -53,7 +55,7 @@ describe("metricSourceSchema", () => { }); test("rejects invalid executor values", () => { - for (const executor of ["sp", "obo", "app_service_principal", "USER"]) { + for (const executor of ["sp", "obo", "service_principal", "USER"]) { const config = { metrics: { revenue: { source: "a.b.c", executor } }, }; diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts index 9bbc3608..3f9e0394 100644 --- a/packages/shared/src/schemas/metric-source.ts +++ b/packages/shared/src/schemas/metric-source.ts @@ -7,7 +7,7 @@ * `metric.json` declares UC Metric Views under a single `metrics` map. * Each entry binds a metric key to a UC metric view FQN plus the executor * the query runs as: - * - `executor: "service_principal"` (default) — queried as the app service + * - `executor: "app_service_principal"` (default) — queried as the app service * principal (cache scope shared across all users). * - `executor: "user"` — queried as the requesting user (on-behalf-of; * cache scope per-user). @@ -27,9 +27,9 @@ export const metricKeySchema = z ); export const metricExecutorSchema = z - .enum(["service_principal", "user"]) + .enum(["app_service_principal", "user"]) .describe( - "Who the metric view is queried as. 'service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.", + "Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.", ); /** @@ -53,7 +53,7 @@ export const metricEntrySchema = z "main.analytics.customer_metrics", ], }), - executor: metricExecutorSchema.default("service_principal"), + executor: metricExecutorSchema.default("app_service_principal"), }) .strict() .describe( @@ -75,7 +75,7 @@ export const metricSourceSchema = z }) .strict() .describe( - "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", + "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", ); export type MetricKey = z.infer; From aa5d7fbcee6442085017dcbf1c554be0a2fa4468 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 10 Jun 2026 16:11:06 +0200 Subject: [PATCH 7/7] chore(shared): rename metric.json root key metrics to metricViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entries are UC metric views, not generic metrics — the key now says so. camelCase per the repo's authored-config-key convention (manifest keys like displayName/dependsOn; snake_case is reserved for values and fields mirroring Databricks APIs). Co-authored-by: Isaac Signed-off-by: Atila Fassina --- docs/static/schemas/metric-source.schema.json | 4 +-- .../shared/src/schemas/metric-source.test.ts | 30 ++++++++++--------- packages/shared/src/schemas/metric-source.ts | 6 ++-- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/static/schemas/metric-source.schema.json b/docs/static/schemas/metric-source.schema.json index e11c2926..43b8a932 100644 --- a/docs/static/schemas/metric-source.schema.json +++ b/docs/static/schemas/metric-source.schema.json @@ -8,7 +8,7 @@ "description": "Reference to the JSON Schema for validation", "type": "string" }, - "metrics": { + "metricViews": { "description": "Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.", "type": "object", "propertyNames": { @@ -42,5 +42,5 @@ } }, "additionalProperties": false, - "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." + "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." } diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts index 5974d486..4b805c25 100644 --- a/packages/shared/src/schemas/metric-source.test.ts +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -6,20 +6,20 @@ describe("metricSourceSchema", () => { const config = { $schema: "https://databricks.github.io/appkit/schemas/metric-source.schema.json", - metrics: { + metricViews: { revenue: { source: "appkit_demo.public.revenue_metrics" }, }, }; const result = metricSourceSchema.safeParse(config); expect(result.success).toBe(true); - expect(result.data?.metrics?.revenue.executor).toBe( + expect(result.data?.metricViews?.revenue.executor).toBe( "app_service_principal", ); }); test("accepts explicit executor values", () => { const config = { - metrics: { + metricViews: { revenue: { source: "demo.public.revenue", executor: "app_service_principal", @@ -29,17 +29,19 @@ describe("metricSourceSchema", () => { }; const result = metricSourceSchema.safeParse(config); expect(result.success).toBe(true); - expect(result.data?.metrics?.my_orders.executor).toBe("user"); + expect(result.data?.metricViews?.my_orders.executor).toBe("user"); }); test("accepts an empty configuration", () => { expect(metricSourceSchema.safeParse({}).success).toBe(true); - expect(metricSourceSchema.safeParse({ metrics: {} }).success).toBe(true); + expect(metricSourceSchema.safeParse({ metricViews: {} }).success).toBe( + true, + ); }); test("accepts metric keys with underscores", () => { const config = { - metrics: { + metricViews: { customer_metrics: { source: "demo.public.customer_metrics" }, }, }; @@ -57,7 +59,7 @@ describe("metricSourceSchema", () => { test("rejects invalid executor values", () => { for (const executor of ["sp", "obo", "service_principal", "USER"]) { const config = { - metrics: { revenue: { source: "a.b.c", executor } }, + metricViews: { revenue: { source: "a.b.c", executor } }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); } @@ -65,21 +67,21 @@ describe("metricSourceSchema", () => { test("rejects a bare-string entry (must be an object)", () => { const config = { - metrics: { revenue: "demo.public.revenue" }, + metricViews: { revenue: "demo.public.revenue" }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects an entry without source", () => { const config = { - metrics: { revenue: {} }, + metricViews: { revenue: {} }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects unknown fields on entries", () => { const config = { - metrics: { + metricViews: { revenue: { source: "a.b.c", ttl: 5, // future option, not in v1 @@ -92,20 +94,20 @@ describe("metricSourceSchema", () => { test("rejects unknown top-level keys", () => { expect(metricSourceSchema.safeParse({ foo: 1 }).success).toBe(false); expect( - metricSourceSchema.safeParse({ metrics: {}, unknown: {} }).success, + metricSourceSchema.safeParse({ metricViews: {}, unknown: {} }).success, ).toBe(false); }); test("rejects metric keys that start with a digit", () => { const config = { - metrics: { "1bad": { source: "a.b.c" } }, + metricViews: { "1bad": { source: "a.b.c" } }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); test("rejects metric keys containing a hyphen", () => { const config = { - metrics: { "bad-key": { source: "a.b.c" } }, + metricViews: { "bad-key": { source: "a.b.c" } }, }; expect(metricSourceSchema.safeParse(config).success).toBe(false); }); @@ -119,7 +121,7 @@ describe("metricSourceSchema", () => { "ends.with.dot.", ]; for (const source of cases) { - const config = { metrics: { revenue: { source } } }; + const config = { metricViews: { revenue: { source } } }; expect(metricSourceSchema.safeParse(config).success).toBe(false); } }); diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts index 3f9e0394..0e1170d7 100644 --- a/packages/shared/src/schemas/metric-source.ts +++ b/packages/shared/src/schemas/metric-source.ts @@ -4,7 +4,7 @@ * Single source of truth for `metric.json` * the config that activates the Analytics' metric-view path. * - * `metric.json` declares UC Metric Views under a single `metrics` map. + * `metric.json` declares UC Metric Views under a single `metricViews` map. * Each entry binds a metric key to a UC metric view FQN plus the executor * the query runs as: * - `executor: "app_service_principal"` (default) — queried as the app service @@ -66,7 +66,7 @@ export const metricSourceSchema = z .string() .optional() .describe("Reference to the JSON Schema for validation"), - metrics: z + metricViews: z .record(metricKeySchema, metricEntrySchema) .optional() .describe( @@ -75,7 +75,7 @@ export const metricSourceSchema = z }) .strict() .describe( - "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metrics' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", + "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", ); export type MetricKey = z.infer;