@databricks/appkit 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/appkit/package.js +1 -1
  2. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  3. package/dist/plugins/analytics/analytics.js +9 -3
  4. package/dist/plugins/analytics/analytics.js.map +1 -1
  5. package/dist/plugins/analytics/metric.js +2 -1
  6. package/dist/plugins/analytics/mv/cache.js +2 -0
  7. package/dist/plugins/analytics/mv/cache.js.map +1 -1
  8. package/dist/plugins/analytics/mv/constants.js +40 -27
  9. package/dist/plugins/analytics/mv/constants.js.map +1 -1
  10. package/dist/plugins/analytics/mv/formatters.js +18 -2
  11. package/dist/plugins/analytics/mv/formatters.js.map +1 -1
  12. package/dist/plugins/analytics/mv/index.js +2 -1
  13. package/dist/plugins/analytics/mv/metadata.js +52 -10
  14. package/dist/plugins/analytics/mv/metadata.js.map +1 -1
  15. package/dist/plugins/analytics/mv/schemas.js +31 -1
  16. package/dist/plugins/analytics/mv/schemas.js.map +1 -1
  17. package/dist/plugins/analytics/types.d.ts +6 -5
  18. package/dist/plugins/analytics/types.d.ts.map +1 -1
  19. package/dist/plugins/analytics/types.js.map +1 -1
  20. package/dist/shared/src/schemas/manifest.d.ts +2 -2
  21. package/dist/shared/src/schemas/metric-metadata-bundle.js +24 -0
  22. package/dist/shared/src/schemas/metric-metadata-bundle.js.map +1 -0
  23. package/dist/shared/src/schemas/metric-source.js +1 -1
  24. package/dist/type-generator/index.js +10 -6
  25. package/dist/type-generator/index.js.map +1 -1
  26. package/dist/type-generator/mv-registry/render-types.js +35 -52
  27. package/dist/type-generator/mv-registry/render-types.js.map +1 -1
  28. package/dist/type-generator/vite-plugin.js +0 -1
  29. package/dist/type-generator/vite-plugin.js.map +1 -1
  30. package/docs/development/type-generation.md +7 -6
  31. package/docs/plugins/analytics.md +287 -12
  32. package/package.json +1 -1
  33. package/sbom.cdx.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"formatters.js","names":["sqlHelpers"],"sources":["../../../../src/plugins/analytics/mv/formatters.ts"],"sourcesContent":["import { type SQLTypeMarker, sql as sqlHelpers } from \"shared\";\nimport {\n isValidColumnName,\n isValidFqn,\n quoteFqnForSql,\n quoteIdentifier,\n} from \"../../../../../shared/src/schemas/metric-fqn\";\nimport type {\n IAnalyticsMetricRequest,\n MetricFilter,\n MetricFilterOperatorName,\n MetricPredicate,\n MetricRegistration,\n} from \"../types\";\nimport {\n METRIC_FILTER_MAX_DEPTH,\n METRIC_FILTER_OPERATORS,\n TIME_GRAIN_PATTERN,\n} from \"./constants\";\nimport type { FilterRenderState } from \"./types\";\n\nfunction quoteSafeFqn(fqn: string): string {\n if (!isValidFqn(fqn)) {\n throw new Error(\n `Refusing to build SQL: \"${fqn}\" is not a valid three-part UC FQN.`,\n );\n }\n return quoteFqnForSql(fqn);\n}\n\nexport function buildMetricSql(\n registration: MetricRegistration,\n request: IAnalyticsMetricRequest,\n): {\n statement: string;\n parameters: Record<string, SQLTypeMarker>;\n} {\n const quotedSource = quoteSafeFqn(registration.source);\n\n if (request.measures.length === 0) {\n throw new Error(\"buildMetricSql requires at least one measure.\");\n }\n\n for (const m of request.measures) {\n if (!isValidColumnName(m)) {\n throw new Error(\n `Refusing to build SQL: measure \"${m}\" is not a valid identifier.`,\n );\n }\n }\n\n const dimensions = request.dimensions ?? [];\n for (const d of dimensions) {\n if (!isValidColumnName(d)) {\n throw new Error(\n `Refusing to build SQL: dimension \"${d}\" is not a valid identifier.`,\n );\n }\n }\n\n const measureClauses = [...request.measures]\n .sort()\n .map((m) => `MEASURE(${quoteIdentifier(m)}) AS ${quoteIdentifier(m)}`);\n\n const dimensionClauses = [...dimensions]\n .sort()\n .map((d) =>\n renderDimensionClause(d, request.timeGrain, request.timeDimension),\n );\n\n const selectList = [...measureClauses, ...dimensionClauses].join(\", \");\n const groupByClause = dimensions.length > 0 ? \" GROUP BY ALL\" : \"\";\n\n const limitClause =\n typeof request.limit === \"number\" && request.limit > 0\n ? ` LIMIT ${Math.floor(request.limit)}`\n : \"\";\n\n const parameters: Record<string, SQLTypeMarker> = {};\n let whereClause = \"\";\n if (request.filter !== undefined) {\n const fragment = renderFilter(request.filter, parameters, {\n counter: 0,\n depth: 0,\n });\n if (fragment !== null && fragment.length > 0) {\n whereClause = ` WHERE ${fragment}`;\n }\n }\n\n const statement = `SELECT ${selectList} FROM ${quotedSource}${whereClause}${groupByClause}${limitClause}`;\n return { statement, parameters };\n}\n\nfunction renderFilter(\n node: MetricFilter,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string | null {\n if (node === null || typeof node !== \"object\") {\n throw new Error(\n \"Refusing to build SQL: filter node must be an object Predicate or { and } / { or } group.\",\n );\n }\n\n if (\"and\" in node || \"or\" in node) {\n const groupKey = \"and\" in node ? \"and\" : \"or\";\n if (state.depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n throw new Error(\n `Refusing to build SQL: filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}.`,\n );\n }\n\n const children = (\n node as { and?: ReadonlyArray<MetricFilter> } & {\n or?: ReadonlyArray<MetricFilter>;\n }\n )[groupKey];\n\n if (!Array.isArray(children) || children.length === 0) {\n // Empty group → the Boolean identity element for the operator, so the\n // result is correct in ANY position (including nested inside the other\n // operator). An empty OR is vacuously false (`1 = 0`); an empty AND is\n // vacuously true (`1 = 1`). Returning `null` for empty-AND (dropped by the\n // parent) would only be correct at the top level — nested in an OR it\n // would silently vanish and turn `TRUE OR P` into `P`, under-returning\n // rows. (The validator rejects empty groups outright; this is the\n // defense-in-depth fallback and must be semantically correct too.)\n return groupKey === \"or\" ? \"1 = 0\" : \"1 = 1\";\n }\n\n const sortedChildren = sortFilterChildren(children);\n\n const fragments: string[] = [];\n const childState: FilterRenderState = {\n counter: state.counter,\n depth: state.depth + 1,\n };\n for (const child of sortedChildren) {\n const rendered = renderFilter(child, params, childState);\n if (rendered != null && rendered.length > 0) {\n fragments.push(rendered);\n }\n }\n state.counter = childState.counter;\n\n if (fragments.length === 0) return null;\n if (fragments.length === 1) return fragments[0];\n const joiner = groupKey === \"and\" ? \" AND \" : \" OR \";\n return `(${fragments.join(joiner)})`;\n }\n\n const predicate = node as MetricPredicate;\n\n if (!isValidColumnName(predicate.member)) {\n throw new Error(\n `Refusing to build SQL: filter member \"${predicate.member}\" is not a valid identifier.`,\n );\n }\n if (\n !METRIC_FILTER_OPERATORS.includes(\n predicate.operator as MetricFilterOperatorName,\n )\n ) {\n throw new Error(\n `Refusing to build SQL: unknown filter operator \"${predicate.operator}\".`,\n );\n }\n\n return renderPredicate(predicate, params, state);\n}\n\nexport function sortFilterChildren(\n children: ReadonlyArray<MetricFilter>,\n): MetricFilter[] {\n const indexed = children.map((child, idx) => {\n let key: string;\n let isPredicate: boolean;\n if (\n child !== null &&\n typeof child === \"object\" &&\n !(\"and\" in child) &&\n !(\"or\" in child)\n ) {\n const p = child as MetricPredicate;\n key = JSON.stringify([p.member, p.operator]);\n isPredicate = true;\n } else {\n key = \"\";\n isPredicate = false;\n }\n return { child, idx, key, isPredicate };\n });\n\n indexed.sort((a, b) => {\n if (a.isPredicate && !b.isPredicate) return -1;\n if (!a.isPredicate && b.isPredicate) return 1;\n if (a.isPredicate && b.isPredicate) {\n if (a.key < b.key) return -1;\n if (a.key > b.key) return 1;\n }\n return a.idx - b.idx;\n });\n\n return indexed.map((entry) => entry.child);\n}\n\nfunction renderPredicate(\n predicate: MetricPredicate,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n const col = quoteIdentifier(predicate.member);\n const op = predicate.operator;\n const values = predicate.values ?? [];\n\n switch (op) {\n case \"equals\":\n return `${col} = ${bindValue(values[0], params, state)}`;\n case \"notEquals\":\n return `${col} <> ${bindValue(values[0], params, state)}`;\n case \"gt\":\n return `${col} > ${bindValue(values[0], params, state)}`;\n case \"gte\":\n return `${col} >= ${bindValue(values[0], params, state)}`;\n case \"lt\":\n return `${col} < ${bindValue(values[0], params, state)}`;\n case \"lte\":\n return `${col} <= ${bindValue(values[0], params, state)}`;\n case \"in\": {\n const placeholders = values.map((v) => bindValue(v, params, state));\n return `${col} IN (${placeholders.join(\", \")})`;\n }\n case \"notIn\": {\n const placeholders = values.map((v) => bindValue(v, params, state));\n return `${col} NOT IN (${placeholders.join(\", \")})`;\n }\n case \"contains\": {\n const raw = values[0];\n if (typeof raw !== \"string\") {\n throw new Error(\n `Refusing to build SQL: filter operator \"contains\" requires a string value (got ${typeof raw}).`,\n );\n }\n return `${col} LIKE ${bindLikeValue(raw, params, state)}`;\n }\n case \"notContains\": {\n const raw = values[0];\n if (typeof raw !== \"string\") {\n throw new Error(\n `Refusing to build SQL: filter operator \"notContains\" requires a string value (got ${typeof raw}).`,\n );\n }\n return `${col} NOT LIKE ${bindLikeValue(raw, params, state)}`;\n }\n case \"set\":\n return `${col} IS NOT NULL`;\n case \"notSet\":\n return `${col} IS NULL`;\n default: {\n const _exhaustive: never = op;\n throw new Error(\n `Refusing to build SQL: unhandled filter operator \"${_exhaustive as string}\".`,\n );\n }\n }\n}\n\nfunction bindValue(\n value: string | number | undefined,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n if (value === undefined) {\n throw new Error(\n \"Refusing to build SQL: filter predicate is missing a required value.\",\n );\n }\n const name = `f_${state.counter}`;\n state.counter += 1;\n if (typeof value === \"number\") {\n params[name] = sqlHelpers.number(value);\n } else if (typeof value === \"string\") {\n params[name] = sqlHelpers.string(value);\n } else {\n throw new Error(\n `Refusing to build SQL: filter value must be a string or number (got ${typeof value}).`,\n );\n }\n return `:${name}`;\n}\n\nfunction bindLikeValue(\n value: string,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n const name = `f_${state.counter}`;\n state.counter += 1;\n params[name] = sqlHelpers.string(`%${value}%`);\n return `:${name}`;\n}\n\nfunction renderDimensionClause(\n dim: string,\n timeGrain: string | undefined,\n timeDimension: string | undefined,\n): string {\n if (timeGrain != null && dim === timeDimension) {\n if (!isValidColumnName(dim)) {\n throw new Error(\n `Refusing to build SQL: timeDimension \"${dim}\" is not a valid identifier.`,\n );\n }\n if (!TIME_GRAIN_PATTERN.test(timeGrain)) {\n throw new Error(\n `Refusing to build SQL: timeGrain \"${timeGrain}\" is not a valid grain token.`,\n );\n }\n const quoted = quoteIdentifier(dim);\n return `date_trunc('${timeGrain}', ${quoted}) AS ${quoted}`;\n }\n return quoteIdentifier(dim);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"formatters.js","names":["sqlHelpers"],"sources":["../../../../src/plugins/analytics/mv/formatters.ts"],"sourcesContent":["import { type SQLTypeMarker, sql as sqlHelpers } from \"shared\";\nimport {\n isValidColumnName,\n isValidFqn,\n quoteFqnForSql,\n quoteIdentifier,\n} from \"../../../../../shared/src/schemas/metric-fqn\";\nimport type {\n IAnalyticsMetricRequest,\n MetricFilter,\n MetricFilterOperatorName,\n MetricPredicate,\n MetricRegistration,\n} from \"../types\";\nimport {\n METRIC_FILTER_MAX_DEPTH,\n METRIC_FILTER_OPERATORS,\n TIME_GRAIN_PATTERN,\n} from \"./constants\";\nimport type { FilterRenderState } from \"./types\";\n\nfunction quoteSafeFqn(fqn: string): string {\n if (!isValidFqn(fqn)) {\n throw new Error(\n `Refusing to build SQL: \"${fqn}\" is not a valid three-part UC FQN.`,\n );\n }\n return quoteFqnForSql(fqn);\n}\n\nexport function buildMetricSql(\n registration: MetricRegistration,\n request: IAnalyticsMetricRequest,\n): {\n statement: string;\n parameters: Record<string, SQLTypeMarker>;\n} {\n const quotedSource = quoteSafeFqn(registration.source);\n\n if (request.measures.length === 0) {\n throw new Error(\"buildMetricSql requires at least one measure.\");\n }\n\n for (const m of request.measures) {\n if (!isValidColumnName(m)) {\n throw new Error(\n `Refusing to build SQL: measure \"${m}\" is not a valid identifier.`,\n );\n }\n }\n\n const dimensions = request.dimensions ?? [];\n for (const d of dimensions) {\n if (!isValidColumnName(d)) {\n throw new Error(\n `Refusing to build SQL: dimension \"${d}\" is not a valid identifier.`,\n );\n }\n }\n\n const measureClauses = [...request.measures]\n .sort()\n .map((m) => `MEASURE(${quoteIdentifier(m)}) AS ${quoteIdentifier(m)}`);\n\n const sortedDimensions = [...dimensions].sort();\n const dimensionClauses = sortedDimensions.map((d) =>\n renderDimensionClause(d, request.timeGrain, request.timeDimension),\n );\n\n const selectList = [...measureClauses, ...dimensionClauses].join(\", \");\n const groupByClause = dimensions.length > 0 ? \" GROUP BY ALL\" : \"\";\n const orderByClause = renderOrderByClause(request, sortedDimensions);\n\n const limitClause =\n typeof request.limit === \"number\" && request.limit > 0\n ? ` LIMIT ${Math.floor(request.limit)}`\n : \"\";\n\n const parameters: Record<string, SQLTypeMarker> = {};\n let whereClause = \"\";\n if (request.filter !== undefined) {\n const fragment = renderFilter(request.filter, parameters, {\n counter: 0,\n depth: 0,\n });\n if (fragment !== null && fragment.length > 0) {\n whereClause = ` WHERE ${fragment}`;\n }\n }\n\n const statement = `SELECT ${selectList} FROM ${quotedSource}${whereClause}${groupByClause}${orderByClause}${limitClause}`;\n return { statement, parameters };\n}\n\nfunction renderFilter(\n node: MetricFilter,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string | null {\n if (node === null || typeof node !== \"object\") {\n throw new Error(\n \"Refusing to build SQL: filter node must be an object Predicate or { and } / { or } group.\",\n );\n }\n\n if (\"and\" in node || \"or\" in node) {\n const groupKey = \"and\" in node ? \"and\" : \"or\";\n if (state.depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n throw new Error(\n `Refusing to build SQL: filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}.`,\n );\n }\n\n const children = (\n node as { and?: ReadonlyArray<MetricFilter> } & {\n or?: ReadonlyArray<MetricFilter>;\n }\n )[groupKey];\n\n if (!Array.isArray(children) || children.length === 0) {\n // Empty group → the Boolean identity element for the operator, so the\n // result is correct in ANY position (including nested inside the other\n // operator). An empty OR is vacuously false (`1 = 0`); an empty AND is\n // vacuously true (`1 = 1`). Returning `null` for empty-AND (dropped by the\n // parent) would only be correct at the top level — nested in an OR it\n // would silently vanish and turn `TRUE OR P` into `P`, under-returning\n // rows. (The validator rejects empty groups outright; this is the\n // defense-in-depth fallback and must be semantically correct too.)\n return groupKey === \"or\" ? \"1 = 0\" : \"1 = 1\";\n }\n\n const sortedChildren = sortFilterChildren(children);\n\n const fragments: string[] = [];\n const childState: FilterRenderState = {\n counter: state.counter,\n depth: state.depth + 1,\n };\n for (const child of sortedChildren) {\n const rendered = renderFilter(child, params, childState);\n if (rendered != null && rendered.length > 0) {\n fragments.push(rendered);\n }\n }\n state.counter = childState.counter;\n\n if (fragments.length === 0) return null;\n if (fragments.length === 1) return fragments[0];\n const joiner = groupKey === \"and\" ? \" AND \" : \" OR \";\n return `(${fragments.join(joiner)})`;\n }\n\n const predicate = node as MetricPredicate;\n\n if (!isValidColumnName(predicate.member)) {\n throw new Error(\n `Refusing to build SQL: filter member \"${predicate.member}\" is not a valid identifier.`,\n );\n }\n if (\n !METRIC_FILTER_OPERATORS.includes(\n predicate.operator as MetricFilterOperatorName,\n )\n ) {\n throw new Error(\n `Refusing to build SQL: unknown filter operator \"${predicate.operator}\".`,\n );\n }\n\n return renderPredicate(predicate, params, state);\n}\n\nexport function sortFilterChildren(\n children: ReadonlyArray<MetricFilter>,\n): MetricFilter[] {\n const indexed = children.map((child, idx) => {\n let key: string;\n let isPredicate: boolean;\n if (\n child !== null &&\n typeof child === \"object\" &&\n !(\"and\" in child) &&\n !(\"or\" in child)\n ) {\n const p = child as MetricPredicate;\n key = JSON.stringify([p.member, p.operator]);\n isPredicate = true;\n } else {\n key = \"\";\n isPredicate = false;\n }\n return { child, idx, key, isPredicate };\n });\n\n indexed.sort((a, b) => {\n if (a.isPredicate && !b.isPredicate) return -1;\n if (!a.isPredicate && b.isPredicate) return 1;\n if (a.isPredicate && b.isPredicate) {\n if (a.key < b.key) return -1;\n if (a.key > b.key) return 1;\n }\n return a.idx - b.idx;\n });\n\n return indexed.map((entry) => entry.child);\n}\n\nfunction renderPredicate(\n predicate: MetricPredicate,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n const col = quoteIdentifier(predicate.member);\n const op = predicate.operator;\n const values = predicate.values ?? [];\n\n switch (op) {\n case \"equals\":\n return `${col} = ${bindValue(values[0], params, state)}`;\n case \"notEquals\":\n return `${col} <> ${bindValue(values[0], params, state)}`;\n case \"gt\":\n return `${col} > ${bindValue(values[0], params, state)}`;\n case \"gte\":\n return `${col} >= ${bindValue(values[0], params, state)}`;\n case \"lt\":\n return `${col} < ${bindValue(values[0], params, state)}`;\n case \"lte\":\n return `${col} <= ${bindValue(values[0], params, state)}`;\n case \"in\": {\n const placeholders = values.map((v) => bindValue(v, params, state));\n return `${col} IN (${placeholders.join(\", \")})`;\n }\n case \"notIn\": {\n const placeholders = values.map((v) => bindValue(v, params, state));\n return `${col} NOT IN (${placeholders.join(\", \")})`;\n }\n case \"contains\": {\n const raw = values[0];\n if (typeof raw !== \"string\") {\n throw new Error(\n `Refusing to build SQL: filter operator \"contains\" requires a string value (got ${typeof raw}).`,\n );\n }\n return `${col} LIKE ${bindLikeValue(raw, params, state)}`;\n }\n case \"notContains\": {\n const raw = values[0];\n if (typeof raw !== \"string\") {\n throw new Error(\n `Refusing to build SQL: filter operator \"notContains\" requires a string value (got ${typeof raw}).`,\n );\n }\n return `${col} NOT LIKE ${bindLikeValue(raw, params, state)}`;\n }\n case \"set\":\n return `${col} IS NOT NULL`;\n case \"notSet\":\n return `${col} IS NULL`;\n default: {\n const _exhaustive: never = op;\n throw new Error(\n `Refusing to build SQL: unhandled filter operator \"${_exhaustive as string}\".`,\n );\n }\n }\n}\n\nfunction bindValue(\n value: string | number | undefined,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n if (value === undefined) {\n throw new Error(\n \"Refusing to build SQL: filter predicate is missing a required value.\",\n );\n }\n const name = `f_${state.counter}`;\n state.counter += 1;\n if (typeof value === \"number\") {\n params[name] = sqlHelpers.number(value);\n } else if (typeof value === \"string\") {\n params[name] = sqlHelpers.string(value);\n } else {\n throw new Error(\n `Refusing to build SQL: filter value must be a string or number (got ${typeof value}).`,\n );\n }\n return `:${name}`;\n}\n\nfunction bindLikeValue(\n value: string,\n params: Record<string, SQLTypeMarker>,\n state: FilterRenderState,\n): string {\n const name = `f_${state.counter}`;\n state.counter += 1;\n params[name] = sqlHelpers.string(`%${value}%`);\n return `:${name}`;\n}\n\nfunction renderDimensionClause(\n dim: string,\n timeGrain: string | undefined,\n timeDimension: string | undefined,\n): string {\n if (timeGrain != null && dim === timeDimension) {\n if (!isValidColumnName(dim)) {\n throw new Error(\n `Refusing to build SQL: timeDimension \"${dim}\" is not a valid identifier.`,\n );\n }\n if (!TIME_GRAIN_PATTERN.test(timeGrain)) {\n throw new Error(\n `Refusing to build SQL: timeGrain \"${timeGrain}\" is not a valid grain token.`,\n );\n }\n const quoted = quoteIdentifier(dim);\n return `date_trunc('${timeGrain}', ${quoted}) AS ${quoted}`;\n }\n return quoteIdentifier(dim);\n}\n\nfunction renderOrderByClause(\n request: IAnalyticsMetricRequest,\n sortedDimensions: string[],\n): string {\n const keyList: string[] = [];\n\n if (request.orderBy != null && request.orderBy.length > 0) {\n for (const entry of request.orderBy) {\n if (!isValidColumnName(entry.field)) {\n throw new Error(\n `Refusing to build SQL: orderBy field \"${entry.field}\" is not a valid identifier.`,\n );\n }\n const direction = entry.direction === \"DESC\" ? \" DESC\" : \"\";\n keyList.push(`${quoteIdentifier(entry.field)}${direction}`);\n }\n }\n\n // Tie-breaker completion: when limit is set, append all dimensions not\n // already named in orderBy. Under GROUP BY ALL the full dimension tuple is\n // unique per row, so ordering by all dimensions gives a TOTAL order. A\n // partial ordering still leaves ties, and ties + LIMIT = non-determinism.\n if (typeof request.limit === \"number\" && request.limit > 0) {\n const orderByFields = new Set(request.orderBy?.map((e) => e.field) ?? []);\n for (const dim of sortedDimensions) {\n if (!orderByFields.has(dim)) {\n keyList.push(quoteIdentifier(dim));\n }\n }\n }\n\n // Return empty string when there is nothing to order by. This covers:\n // no orderBy + no limit; and no orderBy + limit but zero dimensions\n // (a pure aggregate returns exactly one row, ordering is pointless).\n if (keyList.length === 0) {\n return \"\";\n }\n\n return ` ORDER BY ${keyList.join(\", \")}`;\n}\n"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { buildMetricSql } from "./formatters.js";
2
2
  import { composeMetricCacheKey, deriveMetricExecutorKey } from "./cache.js";
3
- import { selectMetricMetadata } from "./metadata.js";
3
+ import { METRIC_METADATA_FILE } from "../../../shared/src/schemas/metric-metadata-bundle.js";
4
+ import { loadMetricMetadata, selectMetricMetadata } from "./metadata.js";
4
5
  import { loadMetricRegistry } from "./registry.js";
5
6
  import { validateMetricRequest } from "./schemas.js";
6
7
 
@@ -1,17 +1,59 @@
1
+ import { createLogger } from "../../../logging/logger.js";
2
+ import { METRIC_METADATA_BUNDLE_VERSION, METRIC_METADATA_FILE, metricMetadataBundleSchema } from "../../../shared/src/schemas/metric-metadata-bundle.js";
3
+
1
4
  //#region src/plugins/analytics/mv/metadata.ts
5
+ const logger = createLogger("analytics:metric-views");
6
+ /**
7
+ * Parsed-bundle cache, keyed on the raw file contents.
8
+ */
9
+ let parsedBundleCache = null;
2
10
  /**
3
- * Flatten the injected {@link MetricViewsMetadata} for `key` into a single
11
+ * The runtime twin of the generated `MetricRegistry` augmentation: the type
12
+ * generator emits both from one `DESCRIBE` pass, this side being JSON so the
13
+ * plugin can discover it instead of the app importing and injecting it.
14
+ *
15
+ * Read through {@link AppManager.readMetricViewsConfig} for the same reasons as
16
+ * {@link loadMetricRegistry} — dev-tunnel awareness and the traversal guard.
17
+ */
18
+ async function loadMetricMetadata(app, req, devFileReader) {
19
+ let raw;
20
+ try {
21
+ raw = await app.readMetricViewsConfig(METRIC_METADATA_FILE, req, devFileReader);
22
+ } catch (err) {
23
+ logger.warn("Failed to read %s: %s", METRIC_METADATA_FILE, err instanceof Error ? err.message : String(err));
24
+ return;
25
+ }
26
+ if (raw === null) return;
27
+ if (parsedBundleCache?.raw === raw) return parsedBundleCache.metadata;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ } catch (err) {
32
+ logger.warn("Ignoring malformed %s: %s", METRIC_METADATA_FILE, err instanceof Error ? err.message : String(err));
33
+ return;
34
+ }
35
+ const result = metricMetadataBundleSchema.safeParse(parsed);
36
+ if (!result.success) {
37
+ logger.warn("Ignoring invalid %s: %s", METRIC_METADATA_FILE, result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
38
+ return;
39
+ }
40
+ if (result.data.version !== METRIC_METADATA_BUNDLE_VERSION) {
41
+ logger.warn("Ignoring %s written for bundle version %d (this runtime reads version %d) — regenerate types", METRIC_METADATA_FILE, result.data.version, METRIC_METADATA_BUNDLE_VERSION);
42
+ return;
43
+ }
44
+ const metadata = Object.create(null);
45
+ for (const [key, entry] of Object.entries(result.data.metricViews)) metadata[key] = entry;
46
+ parsedBundleCache = {
47
+ raw,
48
+ metadata
49
+ };
50
+ return metadata;
51
+ }
52
+ /**
53
+ * Flatten the resolved {@link MetricViewsMetadata} for `key` into a single
4
54
  * `Record<column, meta>` covering only the requested measures and dimensions,
5
55
  * so the client can label/format just the columns it queried.
6
56
  *
7
- * Pure response decoration: it never touches the cache key or the SQL, and
8
- * reads only from the injected value (never disk / DESCRIBE at runtime).
9
- *
10
- * Lookups go through {@link Object.hasOwn}, so neither an inherited metric key
11
- * nor an inherited column name (`toString`, `__proto__`, …) can resolve to a
12
- * bogus entry. Requested columns absent from the metadata are omitted rather
13
- * than placeheld.
14
- *
15
57
  * Returns `undefined` rather than an empty object when there is nothing to
16
58
  * stamp, so the caller can omit the field and keep the message byte-identical
17
59
  * to a plain `/query` result.
@@ -26,5 +68,5 @@ function selectMetricMetadata(all, key, measures, dimensions) {
26
68
  }
27
69
 
28
70
  //#endregion
29
- export { selectMetricMetadata };
71
+ export { loadMetricMetadata, selectMetricMetadata };
30
72
  //# sourceMappingURL=metadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"metadata.js","names":[],"sources":["../../../../src/plugins/analytics/mv/metadata.ts"],"sourcesContent":["import type { MetricViewColumnDisplay, MetricViewsMetadata } from \"shared\";\n\n/**\n * Flatten the injected {@link MetricViewsMetadata} for `key` into a single\n * `Record<column, meta>` covering only the requested measures and dimensions,\n * so the client can label/format just the columns it queried.\n *\n * Pure response decoration: it never touches the cache key or the SQL, and\n * reads only from the injected value (never disk / DESCRIBE at runtime).\n *\n * Lookups go through {@link Object.hasOwn}, so neither an inherited metric key\n * nor an inherited column name (`toString`, `__proto__`, …) can resolve to a\n * bogus entry. Requested columns absent from the metadata are omitted rather\n * than placeheld.\n *\n * Returns `undefined` rather than an empty object when there is nothing to\n * stamp, so the caller can omit the field and keep the message byte-identical\n * to a plain `/query` result.\n */\nexport function selectMetricMetadata(\n all: MetricViewsMetadata | undefined,\n key: string,\n measures: string[],\n dimensions: string[] | undefined,\n): Record<string, MetricViewColumnDisplay> | undefined {\n if (!all || !Object.hasOwn(all, key)) {\n return undefined;\n }\n\n const entry = all[key];\n const slice: Record<string, MetricViewColumnDisplay> = {};\n\n for (const measure of measures) {\n if (Object.hasOwn(entry.measures, measure)) {\n slice[measure] = entry.measures[measure];\n }\n }\n for (const dimension of dimensions ?? []) {\n if (Object.hasOwn(entry.dimensions, dimension)) {\n slice[dimension] = entry.dimensions[dimension];\n }\n }\n\n return Object.keys(slice).length > 0 ? slice : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,SAAgB,qBACd,KACA,KACA,UACA,YACqD;AACrD,KAAI,CAAC,OAAO,CAAC,OAAO,OAAO,KAAK,IAAI,CAClC;CAGF,MAAM,QAAQ,IAAI;CAClB,MAAM,QAAiD,EAAE;AAEzD,MAAK,MAAM,WAAW,SACpB,KAAI,OAAO,OAAO,MAAM,UAAU,QAAQ,CACxC,OAAM,WAAW,MAAM,SAAS;AAGpC,MAAK,MAAM,aAAa,cAAc,EAAE,CACtC,KAAI,OAAO,OAAO,MAAM,YAAY,UAAU,CAC5C,OAAM,aAAa,MAAM,WAAW;AAIxC,QAAO,OAAO,KAAK,MAAM,CAAC,SAAS,IAAI,QAAQ"}
1
+ {"version":3,"file":"metadata.js","names":[],"sources":["../../../../src/plugins/analytics/mv/metadata.ts"],"sourcesContent":["import type { MetricViewColumnDisplay, MetricViewsMetadata } from \"shared\";\nimport {\n METRIC_METADATA_BUNDLE_VERSION,\n METRIC_METADATA_FILE,\n metricMetadataBundleSchema,\n} from \"../../../../../shared/src/schemas/metric-metadata-bundle\";\nimport type { AppManager, DevFileReader, RequestLike } from \"../../../app\";\nimport { createLogger } from \"../../../logging/logger\";\n\nconst logger = createLogger(\"analytics:metric-views\");\n\nexport { METRIC_METADATA_FILE };\n\n/**\n * Parsed-bundle cache, keyed on the raw file contents.\n */\nlet parsedBundleCache: { raw: string; metadata: MetricViewsMetadata } | null =\n null;\n\n/**\n * The runtime twin of the generated `MetricRegistry` augmentation: the type\n * generator emits both from one `DESCRIBE` pass, this side being JSON so the\n * plugin can discover it instead of the app importing and injecting it.\n *\n * Read through {@link AppManager.readMetricViewsConfig} for the same reasons as\n * {@link loadMetricRegistry} — dev-tunnel awareness and the traversal guard.\n */\nexport async function loadMetricMetadata(\n app: AppManager,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n): Promise<MetricViewsMetadata | undefined> {\n let raw: string | null;\n try {\n raw = await app.readMetricViewsConfig(\n METRIC_METADATA_FILE,\n req,\n devFileReader,\n );\n } catch (err) {\n logger.warn(\n \"Failed to read %s: %s\",\n METRIC_METADATA_FILE,\n err instanceof Error ? err.message : String(err),\n );\n return undefined;\n }\n\n // Absent file (ENOENT / dev-tunnel not-found) or a rejected traversal path.\n // Types were never generated, or generation predates the bundle → dormant.\n if (raw === null) {\n return undefined;\n }\n\n if (parsedBundleCache?.raw === raw) {\n return parsedBundleCache.metadata;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n logger.warn(\n \"Ignoring malformed %s: %s\",\n METRIC_METADATA_FILE,\n err instanceof Error ? err.message : String(err),\n );\n return undefined;\n }\n\n const result = metricMetadataBundleSchema.safeParse(parsed);\n if (!result.success) {\n logger.warn(\n \"Ignoring invalid %s: %s\",\n METRIC_METADATA_FILE,\n result.error.issues\n .map((i) => `${i.path.join(\".\")}: ${i.message}`)\n .join(\"; \"),\n );\n return undefined;\n }\n\n if (result.data.version !== METRIC_METADATA_BUNDLE_VERSION) {\n logger.warn(\n \"Ignoring %s written for bundle version %d (this runtime reads version %d) — regenerate types\",\n METRIC_METADATA_FILE,\n result.data.version,\n METRIC_METADATA_BUNDLE_VERSION,\n );\n return undefined;\n }\n\n // Null-prototype map for the same reason as the registry: a metric key that\n // collides with an inherited `Object.prototype` member must not resolve to a\n // truthy non-entry at the lookup site in `selectMetricMetadata`.\n const metadata: MetricViewsMetadata = Object.create(null);\n for (const [key, entry] of Object.entries(result.data.metricViews)) {\n metadata[key] = entry;\n }\n\n parsedBundleCache = { raw, metadata };\n return metadata;\n}\n\n/**\n * Flatten the resolved {@link MetricViewsMetadata} for `key` into a single\n * `Record<column, meta>` covering only the requested measures and dimensions,\n * so the client can label/format just the columns it queried.\n *\n * Returns `undefined` rather than an empty object when there is nothing to\n * stamp, so the caller can omit the field and keep the message byte-identical\n * to a plain `/query` result.\n */\nexport function selectMetricMetadata(\n all: MetricViewsMetadata | undefined,\n key: string,\n measures: string[],\n dimensions: string[] | undefined,\n): Record<string, MetricViewColumnDisplay> | undefined {\n if (!all || !Object.hasOwn(all, key)) {\n return undefined;\n }\n\n const entry = all[key];\n const slice: Record<string, MetricViewColumnDisplay> = {};\n\n for (const measure of measures) {\n if (Object.hasOwn(entry.measures, measure)) {\n slice[measure] = entry.measures[measure];\n }\n }\n for (const dimension of dimensions ?? []) {\n if (Object.hasOwn(entry.dimensions, dimension)) {\n slice[dimension] = entry.dimensions[dimension];\n }\n }\n\n return Object.keys(slice).length > 0 ? slice : undefined;\n}\n"],"mappings":";;;;AASA,MAAM,SAAS,aAAa,yBAAyB;;;;AAOrD,IAAI,oBACF;;;;;;;;;AAUF,eAAsB,mBACpB,KACA,KACA,eAC0C;CAC1C,IAAI;AACJ,KAAI;AACF,QAAM,MAAM,IAAI,sBACd,sBACA,KACA,cACD;UACM,KAAK;AACZ,SAAO,KACL,yBACA,sBACA,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,CACjD;AACD;;AAKF,KAAI,QAAQ,KACV;AAGF,KAAI,mBAAmB,QAAQ,IAC7B,QAAO,kBAAkB;CAG3B,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;UACjB,KAAK;AACZ,SAAO,KACL,6BACA,sBACA,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,CACjD;AACD;;CAGF,MAAM,SAAS,2BAA2B,UAAU,OAAO;AAC3D,KAAI,CAAC,OAAO,SAAS;AACnB,SAAO,KACL,2BACA,sBACA,OAAO,MAAM,OACV,KAAK,MAAM,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC,IAAI,EAAE,UAAU,CAC/C,KAAK,KAAK,CACd;AACD;;AAGF,KAAI,OAAO,KAAK,YAAY,gCAAgC;AAC1D,SAAO,KACL,gGACA,sBACA,OAAO,KAAK,SACZ,+BACD;AACD;;CAMF,MAAM,WAAgC,OAAO,OAAO,KAAK;AACzD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,KAAK,YAAY,CAChE,UAAS,OAAO;AAGlB,qBAAoB;EAAE;EAAK;EAAU;AACrC,QAAO;;;;;;;;;;;AAYT,SAAgB,qBACd,KACA,KACA,UACA,YACqD;AACrD,KAAI,CAAC,OAAO,CAAC,OAAO,OAAO,KAAK,IAAI,CAClC;CAGF,MAAM,QAAQ,IAAI;CAClB,MAAM,QAAiD,EAAE;AAEzD,MAAK,MAAM,WAAW,SACpB,KAAI,OAAO,OAAO,MAAM,UAAU,QAAQ,CACxC,OAAM,WAAW,MAAM,SAAS;AAGpC,MAAK,MAAM,aAAa,cAAc,EAAE,CACtC,KAAI,OAAO,OAAO,MAAM,YAAY,UAAU,CAC5C,OAAM,aAAa,MAAM,WAAW;AAIxC,QAAO,OAAO,KAAK,MAAM,CAAC,SAAS,IAAI,QAAQ"}
@@ -1,7 +1,7 @@
1
1
  import { ValidationError } from "../../../errors/validation.js";
2
2
  import "../../../errors/index.js";
3
3
  import { isValidColumnName } from "../../../shared/src/schemas/metric-fqn.js";
4
- import { LIST_VALUE_OPERATORS, METRIC_DIMENSIONS_MAX, METRIC_FILTER_GROUP_MAX, METRIC_FILTER_MAX_DEPTH, METRIC_FILTER_OPERATORS, METRIC_FILTER_VALUES_MAX, METRIC_LIMIT_MAX, METRIC_MEASURES_MAX, NULL_OPERATORS, SINGLE_VALUE_OPERATORS, STRING_OPERATORS, TIME_GRAIN_PATTERN } from "./constants.js";
4
+ import { LIST_VALUE_OPERATORS, METRIC_DIMENSIONS_MAX, METRIC_FILTER_GROUP_MAX, METRIC_FILTER_MAX_DEPTH, METRIC_FILTER_OPERATORS, METRIC_FILTER_VALUES_MAX, METRIC_LIMIT_MAX, METRIC_MEASURES_MAX, METRIC_ORDER_BY_MAX, METRIC_ORDER_DIRECTIONS, NULL_OPERATORS, SINGLE_VALUE_OPERATORS, STRING_OPERATORS, TIME_GRAIN_PATTERN } from "./constants.js";
5
5
  import { normalizeAnalyticsFormat } from "../types.js";
6
6
  import { z } from "zod";
7
7
 
@@ -24,6 +24,10 @@ const metricRequestSchema = z.object({
24
24
  filter: filterSchema.optional(),
25
25
  timeGrain: z.string().min(1, { message: "timeGrain cannot be empty" }).regex(TIME_GRAIN_PATTERN, { message: "timeGrain must match /^[a-z][a-z_]*$/" }).optional(),
26
26
  timeDimension: z.string().min(1, { message: "timeDimension cannot be empty" }).refine(isValidColumnName, { message: "timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)" }).optional(),
27
+ orderBy: z.array(z.object({
28
+ field: z.string().min(1, "orderBy field cannot be empty").refine(isValidColumnName, { message: "orderBy field contains a character that cannot be used in a SQL identifier (control character or newline)" }),
29
+ direction: z.enum(METRIC_ORDER_DIRECTIONS).optional()
30
+ }).strict()).min(1, "orderBy cannot be an empty array").max(METRIC_ORDER_BY_MAX, { message: `orderBy length exceeds the maximum of ${METRIC_ORDER_BY_MAX}` }).optional(),
27
31
  limit: z.number().int({ message: "limit must be an integer" }).positive({ message: "limit must be positive" }).max(METRIC_LIMIT_MAX, { message: `limit exceeds the maximum of ${METRIC_LIMIT_MAX}` }).optional(),
28
32
  format: z.enum([
29
33
  "JSON_ARRAY",
@@ -59,6 +63,32 @@ const metricRequestSchema = z.object({
59
63
  message: "timeDimension must be one of dimensions",
60
64
  path: ["timeDimension"]
61
65
  });
66
+ if (value.orderBy != null) {
67
+ const selectedNames = new Set([...value.measures, ...value.dimensions ?? []]);
68
+ for (let i = 0; i < value.orderBy.length; i++) if (!selectedNames.has(value.orderBy[i].field)) ctx.addIssue({
69
+ code: "custom",
70
+ message: "orderBy field must be one of measures or dimensions",
71
+ path: [
72
+ "orderBy",
73
+ i,
74
+ "field"
75
+ ]
76
+ });
77
+ const seenFields = /* @__PURE__ */ new Set();
78
+ let hasDuplicate = false;
79
+ for (const entry of value.orderBy) {
80
+ if (seenFields.has(entry.field)) {
81
+ hasDuplicate = true;
82
+ break;
83
+ }
84
+ seenFields.add(entry.field);
85
+ }
86
+ if (hasDuplicate) ctx.addIssue({
87
+ code: "custom",
88
+ message: "orderBy fields must be unique",
89
+ path: ["orderBy"]
90
+ });
91
+ }
62
92
  });
63
93
  function validateFilterTree(node, ctx, path, depth) {
64
94
  if (node === null || typeof node !== "object") {
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.js","names":[],"sources":["../../../../src/plugins/analytics/mv/schemas.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { isValidColumnName } from \"../../../../../shared/src/schemas/metric-fqn\";\nimport { ValidationError } from \"../../../errors\";\nimport type {\n IAnalyticsMetricRequest,\n MetricFilter,\n MetricFilterOperatorName,\n MetricPredicate,\n} from \"../types\";\nimport { normalizeAnalyticsFormat } from \"../types\";\nimport {\n LIST_VALUE_OPERATORS,\n METRIC_DIMENSIONS_MAX,\n METRIC_FILTER_GROUP_MAX,\n METRIC_FILTER_MAX_DEPTH,\n METRIC_FILTER_OPERATORS,\n METRIC_FILTER_VALUES_MAX,\n METRIC_LIMIT_MAX,\n METRIC_MEASURES_MAX,\n NULL_OPERATORS,\n SINGLE_VALUE_OPERATORS,\n STRING_OPERATORS,\n TIME_GRAIN_PATTERN,\n} from \"./constants\";\n\n/** A leaf predicate: `{ member, operator, values? }`, no extra keys. */\nconst filterPredicateSchema: z.ZodType<MetricPredicate> = z\n .object({\n member: z\n .string()\n .min(1, { message: \"filter predicate 'member' cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"filter predicate 'member' contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n operator: z.string().min(1, {\n message: \"filter predicate 'operator' cannot be empty\",\n }) as z.ZodType<MetricFilterOperatorName>,\n values: z\n .array(z.union([z.string(), z.number()]))\n .max(METRIC_FILTER_VALUES_MAX, {\n message: `filter predicate 'values' length exceeds the maximum of ${METRIC_FILTER_VALUES_MAX}`,\n })\n .optional(),\n })\n .strict();\n\n/** Recursive filter: a predicate leaf or an `{ and }` / `{ or }` group. */\nconst filterSchema: z.ZodType<MetricFilter> = z.lazy(() =>\n z.union([\n filterPredicateSchema,\n z\n .object({\n and: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'and' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n z\n .object({\n or: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'or' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n ]),\n);\n\nconst metricRequestSchema = z\n .object({\n measures: z\n .array(\n z\n .string()\n .min(1, \"measure name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"measure name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .min(1, \"at least one measure is required\")\n .max(METRIC_MEASURES_MAX, {\n message: `measures length exceeds the maximum of ${METRIC_MEASURES_MAX}`,\n }),\n dimensions: z\n .array(\n z\n .string()\n .min(1, \"dimension name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"dimension name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .max(METRIC_DIMENSIONS_MAX, {\n message: `dimensions length exceeds the maximum of ${METRIC_DIMENSIONS_MAX}`,\n })\n .optional(),\n filter: filterSchema.optional(),\n // Grammar-shaped bucketing grain, applied to `timeDimension` via\n // `date_trunc`. The token is validated for safety here; the grain literal\n // is interpolated (single-quoted, never a bind param) in\n // `renderDimensionClause`, so this pattern gate is the security boundary.\n timeGrain: z\n .string()\n .min(1, { message: \"timeGrain cannot be empty\" })\n .regex(TIME_GRAIN_PATTERN, {\n message: \"timeGrain must match /^[a-z][a-z_]*$/\",\n })\n .optional(),\n // The single dimension `timeGrain` applies to via `date_trunc`. A column\n // identifier (backtick-quoted at interpolation), so it accepts the full\n // delimited-identifier grammar. Cross-field rules in `superRefine`:\n // required when `timeGrain` is set, and must be one of `dimensions`.\n timeDimension: z\n .string()\n .min(1, { message: \"timeDimension cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)\",\n })\n .optional(),\n limit: z\n .number()\n .int({ message: \"limit must be an integer\" })\n .positive({ message: \"limit must be positive\" })\n .max(METRIC_LIMIT_MAX, {\n message: `limit exceeds the maximum of ${METRIC_LIMIT_MAX}`,\n })\n .optional(),\n format: z.enum([\"JSON_ARRAY\", \"ARROW_STREAM\", \"JSON\", \"ARROW\"]).optional(),\n })\n .strict()\n .superRefine((value, ctx) => {\n if (value.filter != null) {\n validateFilterTree(value.filter, ctx, [\"filter\"], 0);\n }\n\n const seen = new Set<string>();\n const collided = new Set<string>();\n for (const name of [...value.measures, ...(value.dimensions ?? [])]) {\n if (seen.has(name)) {\n collided.add(name);\n }\n seen.add(name);\n }\n if (collided.size > 0) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"measures and dimensions must be unique across both lists (a name cannot repeat, nor appear as both a measure and a dimension)\",\n path: [\"measures\"],\n });\n }\n\n if (\n value.format != null &&\n normalizeAnalyticsFormat(value.format) !== \"JSON_ARRAY\"\n ) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"format: only JSON_ARRAY is supported on the metric route at v1 (ARROW_STREAM is not yet implemented)\",\n path: [\"format\"],\n });\n }\n\n if (value.timeGrain != null && value.timeDimension == null) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension is required when timeGrain is set\",\n path: [\"timeDimension\"],\n });\n }\n if (\n value.timeDimension != null &&\n !(value.dimensions ?? []).includes(value.timeDimension)\n ) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension must be one of dimensions\",\n path: [\"timeDimension\"],\n });\n }\n }) as z.ZodType<IAnalyticsMetricRequest>;\n\nfunction validateFilterTree(\n node: MetricFilter,\n ctx: z.RefinementCtx,\n path: Array<string | number>,\n depth: number,\n): void {\n if (node === null || typeof node !== \"object\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: \"filter node must be a Predicate or { and } / { or } group\",\n });\n return;\n }\n\n if (\"and\" in node || \"or\" in node) {\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n });\n return;\n }\n\n const groupKey = \"and\" in node ? \"and\" : \"or\";\n const children = (\n node as { and?: ReadonlyArray<MetricFilter> } & {\n or?: ReadonlyArray<MetricFilter>;\n }\n )[groupKey];\n\n if (!Array.isArray(children)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter ${groupKey} group must be an array of predicates or nested groups`,\n });\n return;\n }\n\n if (children.length === 0) {\n // Reject empty groups of either kind. An empty `or` is vacuously false;\n // an empty `and` contributes no constraint and renders to no WHERE\n // clause — identical SQL to omitting `filter` entirely, but it would\n // canonicalize to a distinct cache key (`and()` vs `_`), needlessly\n // splitting the cache across semantically identical requests. Requiring\n // at least one child keeps request shape ↔ cache key one-to-one.\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter '${groupKey}' group must contain at least one predicate`,\n });\n return;\n }\n\n children.forEach((child, idx) => {\n validateFilterTree(child, ctx, [...path, groupKey, idx], depth + 1);\n });\n return;\n }\n\n const predicate = node as MetricPredicate;\n\n if (\n !METRIC_FILTER_OPERATORS.includes(\n predicate.operator as MetricFilterOperatorName,\n )\n ) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"operator\"],\n message: `filter operator \"${predicate.operator}\" is not one of: ${METRIC_FILTER_OPERATORS.join(\", \")}`,\n });\n return;\n }\n\n const op = predicate.operator;\n const values = predicate.values;\n const valuesLen = values?.length ?? 0;\n\n if (NULL_OPERATORS.has(op)) {\n if (values != null && valuesLen > 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" must not carry values`,\n });\n }\n } else if (SINGLE_VALUE_OPERATORS.has(op)) {\n if (valuesLen !== 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires exactly one value (got ${valuesLen})`,\n });\n }\n } else if (LIST_VALUE_OPERATORS.has(op)) {\n if (valuesLen < 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires at least one value`,\n });\n }\n }\n\n if (STRING_OPERATORS.has(op) && valuesLen > 0) {\n const v = predicate.values?.[0];\n if (typeof v !== \"string\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires a string value (got ${typeof v})`,\n });\n }\n }\n}\n\nfunction preCheckFilterDepth(filter: unknown): void {\n if (filter == null || typeof filter !== \"object\") return;\n const stack: Array<[unknown, number]> = [[filter, 0]];\n while (stack.length > 0) {\n const popped = stack.pop();\n if (popped === undefined) continue;\n const [node, depth] = popped;\n if (node == null || typeof node !== \"object\") continue;\n const obj = node as Record<string, unknown>;\n for (const groupKey of [\"and\", \"or\"] as const) {\n const children = obj[groupKey];\n if (!Array.isArray(children)) continue;\n if (children.length > METRIC_FILTER_GROUP_MAX) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter ${groupKey} group has ${children.length} children; the maximum is ${METRIC_FILTER_GROUP_MAX}`,\n },\n },\n );\n }\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n },\n },\n );\n }\n for (const child of children) {\n stack.push([child, depth + 1]);\n }\n }\n }\n}\n\nexport function validateMetricRequest(body: unknown): IAnalyticsMetricRequest {\n if (body != null && typeof body === \"object\") {\n preCheckFilterDepth((body as { filter?: unknown }).filter);\n }\n const result = metricRequestSchema.safeParse(body);\n if (!result.success) {\n const fieldPaths = result.error.issues\n .map((i) => i.path.join(\".\") || \"(root)\")\n .join(\", \");\n throw new ValidationError(\n fieldPaths.length > 0\n ? `Invalid metric request body (fields: ${fieldPaths})`\n : \"Invalid metric request body\",\n { context: { issues: result.error.issues } },\n );\n }\n return result.data;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"schemas.js","names":[],"sources":["../../../../src/plugins/analytics/mv/schemas.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { isValidColumnName } from \"../../../../../shared/src/schemas/metric-fqn\";\nimport { ValidationError } from \"../../../errors\";\nimport type {\n IAnalyticsMetricRequest,\n MetricFilter,\n MetricFilterOperatorName,\n MetricPredicate,\n} from \"../types\";\nimport { normalizeAnalyticsFormat } from \"../types\";\nimport {\n LIST_VALUE_OPERATORS,\n METRIC_DIMENSIONS_MAX,\n METRIC_FILTER_GROUP_MAX,\n METRIC_FILTER_MAX_DEPTH,\n METRIC_FILTER_OPERATORS,\n METRIC_FILTER_VALUES_MAX,\n METRIC_LIMIT_MAX,\n METRIC_MEASURES_MAX,\n METRIC_ORDER_BY_MAX,\n METRIC_ORDER_DIRECTIONS,\n NULL_OPERATORS,\n SINGLE_VALUE_OPERATORS,\n STRING_OPERATORS,\n TIME_GRAIN_PATTERN,\n} from \"./constants\";\n\n/** A leaf predicate: `{ member, operator, values? }`, no extra keys. */\nconst filterPredicateSchema: z.ZodType<MetricPredicate> = z\n .object({\n member: z\n .string()\n .min(1, { message: \"filter predicate 'member' cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"filter predicate 'member' contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n operator: z.string().min(1, {\n message: \"filter predicate 'operator' cannot be empty\",\n }) as z.ZodType<MetricFilterOperatorName>,\n values: z\n .array(z.union([z.string(), z.number()]))\n .max(METRIC_FILTER_VALUES_MAX, {\n message: `filter predicate 'values' length exceeds the maximum of ${METRIC_FILTER_VALUES_MAX}`,\n })\n .optional(),\n })\n .strict();\n\n/** Recursive filter: a predicate leaf or an `{ and }` / `{ or }` group. */\nconst filterSchema: z.ZodType<MetricFilter> = z.lazy(() =>\n z.union([\n filterPredicateSchema,\n z\n .object({\n and: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'and' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n z\n .object({\n or: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'or' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n ]),\n);\n\nconst metricRequestSchema = z\n .object({\n measures: z\n .array(\n z\n .string()\n .min(1, \"measure name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"measure name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .min(1, \"at least one measure is required\")\n .max(METRIC_MEASURES_MAX, {\n message: `measures length exceeds the maximum of ${METRIC_MEASURES_MAX}`,\n }),\n dimensions: z\n .array(\n z\n .string()\n .min(1, \"dimension name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"dimension name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .max(METRIC_DIMENSIONS_MAX, {\n message: `dimensions length exceeds the maximum of ${METRIC_DIMENSIONS_MAX}`,\n })\n .optional(),\n filter: filterSchema.optional(),\n // Grammar-shaped bucketing grain, applied to `timeDimension` via\n // `date_trunc`. The token is validated for safety here; the grain literal\n // is interpolated (single-quoted, never a bind param) in\n // `renderDimensionClause`, so this pattern gate is the security boundary.\n timeGrain: z\n .string()\n .min(1, { message: \"timeGrain cannot be empty\" })\n .regex(TIME_GRAIN_PATTERN, {\n message: \"timeGrain must match /^[a-z][a-z_]*$/\",\n })\n .optional(),\n // The single dimension `timeGrain` applies to via `date_trunc`. A column\n // identifier (backtick-quoted at interpolation), so it accepts the full\n // delimited-identifier grammar. Cross-field rules in `superRefine`:\n // required when `timeGrain` is set, and must be one of `dimensions`.\n timeDimension: z\n .string()\n .min(1, { message: \"timeDimension cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)\",\n })\n .optional(),\n orderBy: z\n .array(\n z\n .object({\n field: z\n .string()\n .min(1, \"orderBy field cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"orderBy field contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n direction: z.enum(METRIC_ORDER_DIRECTIONS).optional(),\n })\n .strict(),\n )\n .min(1, \"orderBy cannot be an empty array\")\n .max(METRIC_ORDER_BY_MAX, {\n message: `orderBy length exceeds the maximum of ${METRIC_ORDER_BY_MAX}`,\n })\n .optional(),\n limit: z\n .number()\n .int({ message: \"limit must be an integer\" })\n .positive({ message: \"limit must be positive\" })\n .max(METRIC_LIMIT_MAX, {\n message: `limit exceeds the maximum of ${METRIC_LIMIT_MAX}`,\n })\n .optional(),\n format: z.enum([\"JSON_ARRAY\", \"ARROW_STREAM\", \"JSON\", \"ARROW\"]).optional(),\n })\n .strict()\n .superRefine((value, ctx) => {\n if (value.filter != null) {\n validateFilterTree(value.filter, ctx, [\"filter\"], 0);\n }\n\n const seen = new Set<string>();\n const collided = new Set<string>();\n for (const name of [...value.measures, ...(value.dimensions ?? [])]) {\n if (seen.has(name)) {\n collided.add(name);\n }\n seen.add(name);\n }\n if (collided.size > 0) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"measures and dimensions must be unique across both lists (a name cannot repeat, nor appear as both a measure and a dimension)\",\n path: [\"measures\"],\n });\n }\n\n if (\n value.format != null &&\n normalizeAnalyticsFormat(value.format) !== \"JSON_ARRAY\"\n ) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"format: only JSON_ARRAY is supported on the metric route at v1 (ARROW_STREAM is not yet implemented)\",\n path: [\"format\"],\n });\n }\n\n if (value.timeGrain != null && value.timeDimension == null) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension is required when timeGrain is set\",\n path: [\"timeDimension\"],\n });\n }\n if (\n value.timeDimension != null &&\n !(value.dimensions ?? []).includes(value.timeDimension)\n ) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension must be one of dimensions\",\n path: [\"timeDimension\"],\n });\n }\n\n if (value.orderBy != null) {\n const selectedNames = new Set([\n ...value.measures,\n ...(value.dimensions ?? []),\n ]);\n\n for (let i = 0; i < value.orderBy.length; i++) {\n if (!selectedNames.has(value.orderBy[i].field)) {\n ctx.addIssue({\n code: \"custom\",\n message: \"orderBy field must be one of measures or dimensions\",\n path: [\"orderBy\", i, \"field\"],\n });\n }\n }\n\n const seenFields = new Set<string>();\n let hasDuplicate = false;\n for (const entry of value.orderBy) {\n if (seenFields.has(entry.field)) {\n hasDuplicate = true;\n break;\n }\n seenFields.add(entry.field);\n }\n if (hasDuplicate) {\n ctx.addIssue({\n code: \"custom\",\n message: \"orderBy fields must be unique\",\n path: [\"orderBy\"],\n });\n }\n }\n }) as z.ZodType<IAnalyticsMetricRequest>;\n\nfunction validateFilterTree(\n node: MetricFilter,\n ctx: z.RefinementCtx,\n path: Array<string | number>,\n depth: number,\n): void {\n if (node === null || typeof node !== \"object\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: \"filter node must be a Predicate or { and } / { or } group\",\n });\n return;\n }\n\n if (\"and\" in node || \"or\" in node) {\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n });\n return;\n }\n\n const groupKey = \"and\" in node ? \"and\" : \"or\";\n const children = (\n node as { and?: ReadonlyArray<MetricFilter> } & {\n or?: ReadonlyArray<MetricFilter>;\n }\n )[groupKey];\n\n if (!Array.isArray(children)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter ${groupKey} group must be an array of predicates or nested groups`,\n });\n return;\n }\n\n if (children.length === 0) {\n // Reject empty groups of either kind. An empty `or` is vacuously false;\n // an empty `and` contributes no constraint and renders to no WHERE\n // clause — identical SQL to omitting `filter` entirely, but it would\n // canonicalize to a distinct cache key (`and()` vs `_`), needlessly\n // splitting the cache across semantically identical requests. Requiring\n // at least one child keeps request shape ↔ cache key one-to-one.\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter '${groupKey}' group must contain at least one predicate`,\n });\n return;\n }\n\n children.forEach((child, idx) => {\n validateFilterTree(child, ctx, [...path, groupKey, idx], depth + 1);\n });\n return;\n }\n\n const predicate = node as MetricPredicate;\n\n if (\n !METRIC_FILTER_OPERATORS.includes(\n predicate.operator as MetricFilterOperatorName,\n )\n ) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"operator\"],\n message: `filter operator \"${predicate.operator}\" is not one of: ${METRIC_FILTER_OPERATORS.join(\", \")}`,\n });\n return;\n }\n\n const op = predicate.operator;\n const values = predicate.values;\n const valuesLen = values?.length ?? 0;\n\n if (NULL_OPERATORS.has(op)) {\n if (values != null && valuesLen > 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" must not carry values`,\n });\n }\n } else if (SINGLE_VALUE_OPERATORS.has(op)) {\n if (valuesLen !== 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires exactly one value (got ${valuesLen})`,\n });\n }\n } else if (LIST_VALUE_OPERATORS.has(op)) {\n if (valuesLen < 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires at least one value`,\n });\n }\n }\n\n if (STRING_OPERATORS.has(op) && valuesLen > 0) {\n const v = predicate.values?.[0];\n if (typeof v !== \"string\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires a string value (got ${typeof v})`,\n });\n }\n }\n}\n\nfunction preCheckFilterDepth(filter: unknown): void {\n if (filter == null || typeof filter !== \"object\") return;\n const stack: Array<[unknown, number]> = [[filter, 0]];\n while (stack.length > 0) {\n const popped = stack.pop();\n if (popped === undefined) continue;\n const [node, depth] = popped;\n if (node == null || typeof node !== \"object\") continue;\n const obj = node as Record<string, unknown>;\n for (const groupKey of [\"and\", \"or\"] as const) {\n const children = obj[groupKey];\n if (!Array.isArray(children)) continue;\n if (children.length > METRIC_FILTER_GROUP_MAX) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter ${groupKey} group has ${children.length} children; the maximum is ${METRIC_FILTER_GROUP_MAX}`,\n },\n },\n );\n }\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n },\n },\n );\n }\n for (const child of children) {\n stack.push([child, depth + 1]);\n }\n }\n }\n}\n\nexport function validateMetricRequest(body: unknown): IAnalyticsMetricRequest {\n if (body != null && typeof body === \"object\") {\n preCheckFilterDepth((body as { filter?: unknown }).filter);\n }\n const result = metricRequestSchema.safeParse(body);\n if (!result.success) {\n const fieldPaths = result.error.issues\n .map((i) => i.path.join(\".\") || \"(root)\")\n .join(\", \");\n throw new ValidationError(\n fieldPaths.length > 0\n ? `Invalid metric request body (fields: ${fieldPaths})`\n : \"Invalid metric request body\",\n { context: { issues: result.error.issues } },\n );\n }\n return result.data;\n}\n"],"mappings":";;;;;;;;;AA4BA,MAAM,wBAAoD,EACvD,OAAO;CACN,QAAQ,EACL,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,6CAA6C,CAAC,CAChE,OAAO,mBAAmB,EACzB,SACE,yHACH,CAAC;CACJ,UAAU,EAAE,QAAQ,CAAC,IAAI,GAAG,EAC1B,SAAS,+CACV,CAAC;CACF,QAAQ,EACL,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CACxC,IAAI,0BAA0B,EAC7B,SAAS,2DAA2D,4BACrE,CAAC,CACD,UAAU;CACd,CAAC,CACD,QAAQ;;AAGX,MAAM,eAAwC,EAAE,WAC9C,EAAE,MAAM;CACN;CACA,EACG,OAAO,EACN,KAAK,EAAE,MAAM,aAAa,CAAC,IAAI,yBAAyB,EACtD,SAAS,6CAA6C,wBAAwB,YAC/E,CAAC,EACH,CAAC,CACD,QAAQ;CACX,EACG,OAAO,EACN,IAAI,EAAE,MAAM,aAAa,CAAC,IAAI,yBAAyB,EACrD,SAAS,4CAA4C,wBAAwB,YAC9E,CAAC,EACH,CAAC,CACD,QAAQ;CACZ,CAAC,CACH;AAED,MAAM,sBAAsB,EACzB,OAAO;CACN,UAAU,EACP,MACC,EACG,QAAQ,CACR,IAAI,GAAG,+BAA+B,CACtC,OAAO,mBAAmB,EACzB,SACE,4GACH,CAAC,CACL,CACA,IAAI,GAAG,mCAAmC,CAC1C,IAAI,qBAAqB,EACxB,SAAS,0CAA0C,uBACpD,CAAC;CACJ,YAAY,EACT,MACC,EACG,QAAQ,CACR,IAAI,GAAG,iCAAiC,CACxC,OAAO,mBAAmB,EACzB,SACE,8GACH,CAAC,CACL,CACA,IAAI,uBAAuB,EAC1B,SAAS,4CAA4C,yBACtD,CAAC,CACD,UAAU;CACb,QAAQ,aAAa,UAAU;CAK/B,WAAW,EACR,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,6BAA6B,CAAC,CAChD,MAAM,oBAAoB,EACzB,SAAS,yCACV,CAAC,CACD,UAAU;CAKb,eAAe,EACZ,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,iCAAiC,CAAC,CACpD,OAAO,mBAAmB,EACzB,SACE,6GACH,CAAC,CACD,UAAU;CACb,SAAS,EACN,MACC,EACG,OAAO;EACN,OAAO,EACJ,QAAQ,CACR,IAAI,GAAG,gCAAgC,CACvC,OAAO,mBAAmB,EACzB,SACE,6GACH,CAAC;EACJ,WAAW,EAAE,KAAK,wBAAwB,CAAC,UAAU;EACtD,CAAC,CACD,QAAQ,CACZ,CACA,IAAI,GAAG,mCAAmC,CAC1C,IAAI,qBAAqB,EACxB,SAAS,yCAAyC,uBACnD,CAAC,CACD,UAAU;CACb,OAAO,EACJ,QAAQ,CACR,IAAI,EAAE,SAAS,4BAA4B,CAAC,CAC5C,SAAS,EAAE,SAAS,0BAA0B,CAAC,CAC/C,IAAI,kBAAkB,EACrB,SAAS,gCAAgC,oBAC1C,CAAC,CACD,UAAU;CACb,QAAQ,EAAE,KAAK;EAAC;EAAc;EAAgB;EAAQ;EAAQ,CAAC,CAAC,UAAU;CAC3E,CAAC,CACD,QAAQ,CACR,aAAa,OAAO,QAAQ;AAC3B,KAAI,MAAM,UAAU,KAClB,oBAAmB,MAAM,QAAQ,KAAK,CAAC,SAAS,EAAE,EAAE;CAGtD,MAAM,uBAAO,IAAI,KAAa;CAC9B,MAAM,2BAAW,IAAI,KAAa;AAClC,MAAK,MAAM,QAAQ,CAAC,GAAG,MAAM,UAAU,GAAI,MAAM,cAAc,EAAE,CAAE,EAAE;AACnE,MAAI,KAAK,IAAI,KAAK,CAChB,UAAS,IAAI,KAAK;AAEpB,OAAK,IAAI,KAAK;;AAEhB,KAAI,SAAS,OAAO,EAClB,KAAI,SAAS;EACX,MAAM;EACN,SACE;EACF,MAAM,CAAC,WAAW;EACnB,CAAC;AAGJ,KACE,MAAM,UAAU,QAChB,yBAAyB,MAAM,OAAO,KAAK,aAE3C,KAAI,SAAS;EACX,MAAM;EACN,SACE;EACF,MAAM,CAAC,SAAS;EACjB,CAAC;AAGJ,KAAI,MAAM,aAAa,QAAQ,MAAM,iBAAiB,KACpD,KAAI,SAAS;EACX,MAAM;EACN,SAAS;EACT,MAAM,CAAC,gBAAgB;EACxB,CAAC;AAEJ,KACE,MAAM,iBAAiB,QACvB,EAAE,MAAM,cAAc,EAAE,EAAE,SAAS,MAAM,cAAc,CAEvD,KAAI,SAAS;EACX,MAAM;EACN,SAAS;EACT,MAAM,CAAC,gBAAgB;EACxB,CAAC;AAGJ,KAAI,MAAM,WAAW,MAAM;EACzB,MAAM,gBAAgB,IAAI,IAAI,CAC5B,GAAG,MAAM,UACT,GAAI,MAAM,cAAc,EAAE,CAC3B,CAAC;AAEF,OAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,QAAQ,IACxC,KAAI,CAAC,cAAc,IAAI,MAAM,QAAQ,GAAG,MAAM,CAC5C,KAAI,SAAS;GACX,MAAM;GACN,SAAS;GACT,MAAM;IAAC;IAAW;IAAG;IAAQ;GAC9B,CAAC;EAIN,MAAM,6BAAa,IAAI,KAAa;EACpC,IAAI,eAAe;AACnB,OAAK,MAAM,SAAS,MAAM,SAAS;AACjC,OAAI,WAAW,IAAI,MAAM,MAAM,EAAE;AAC/B,mBAAe;AACf;;AAEF,cAAW,IAAI,MAAM,MAAM;;AAE7B,MAAI,aACF,KAAI,SAAS;GACX,MAAM;GACN,SAAS;GACT,MAAM,CAAC,UAAU;GAClB,CAAC;;EAGN;AAEJ,SAAS,mBACP,MACA,KACA,MACA,OACM;AACN,KAAI,SAAS,QAAQ,OAAO,SAAS,UAAU;AAC7C,MAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB;GACA,SAAS;GACV,CAAC;AACF;;AAGF,KAAI,SAAS,QAAQ,QAAQ,MAAM;AACjC,MAAI,QAAQ,IAAI,yBAAyB;AACvC,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB;IACA,SAAS,sDAAsD;IAChE,CAAC;AACF;;EAGF,MAAM,WAAW,SAAS,OAAO,QAAQ;EACzC,MAAM,WACJ,KAGA;AAEF,MAAI,CAAC,MAAM,QAAQ,SAAS,EAAE;AAC5B,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB,MAAM,CAAC,GAAG,MAAM,SAAS;IACzB,SAAS,UAAU,SAAS;IAC7B,CAAC;AACF;;AAGF,MAAI,SAAS,WAAW,GAAG;AAOzB,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB,MAAM,CAAC,GAAG,MAAM,SAAS;IACzB,SAAS,WAAW,SAAS;IAC9B,CAAC;AACF;;AAGF,WAAS,SAAS,OAAO,QAAQ;AAC/B,sBAAmB,OAAO,KAAK;IAAC,GAAG;IAAM;IAAU;IAAI,EAAE,QAAQ,EAAE;IACnE;AACF;;CAGF,MAAM,YAAY;AAElB,KACE,CAAC,wBAAwB,SACvB,UAAU,SACX,EACD;AACA,MAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,WAAW;GAC3B,SAAS,oBAAoB,UAAU,SAAS,mBAAmB,wBAAwB,KAAK,KAAK;GACtG,CAAC;AACF;;CAGF,MAAM,KAAK,UAAU;CACrB,MAAM,SAAS,UAAU;CACzB,MAAM,YAAY,QAAQ,UAAU;AAEpC,KAAI,eAAe,IAAI,GAAG,EACxB;MAAI,UAAU,QAAQ,YAAY,EAChC,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG;GACjC,CAAC;YAEK,uBAAuB,IAAI,GAAG,EACvC;MAAI,cAAc,EAChB,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG,oCAAoC,UAAU;GAC/E,CAAC;YAEK,qBAAqB,IAAI,GAAG,EACrC;MAAI,YAAY,EACd,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG;GACjC,CAAC;;AAIN,KAAI,iBAAiB,IAAI,GAAG,IAAI,YAAY,GAAG;EAC7C,MAAM,IAAI,UAAU,SAAS;AAC7B,MAAI,OAAO,MAAM,SACf,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG,iCAAiC,OAAO,EAAE;GAC3E,CAAC;;;AAKR,SAAS,oBAAoB,QAAuB;AAClD,KAAI,UAAU,QAAQ,OAAO,WAAW,SAAU;CAClD,MAAM,QAAkC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACrD,QAAO,MAAM,SAAS,GAAG;EACvB,MAAM,SAAS,MAAM,KAAK;AAC1B,MAAI,WAAW,OAAW;EAC1B,MAAM,CAAC,MAAM,SAAS;AACtB,MAAI,QAAQ,QAAQ,OAAO,SAAS,SAAU;EAC9C,MAAM,MAAM;AACZ,OAAK,MAAM,YAAY,CAAC,OAAO,KAAK,EAAW;GAC7C,MAAM,WAAW,IAAI;AACrB,OAAI,CAAC,MAAM,QAAQ,SAAS,CAAE;AAC9B,OAAI,SAAS,SAAS,wBACpB,OAAM,IAAI,gBACR,gDACA,EACE,SAAS,EACP,QAAQ,UAAU,SAAS,aAAa,SAAS,OAAO,4BAA4B,2BACrF,EACF,CACF;AAEH,OAAI,QAAQ,IAAI,wBACd,OAAM,IAAI,gBACR,gDACA,EACE,SAAS,EACP,QAAQ,sDAAsD,2BAC/D,EACF,CACF;AAEH,QAAK,MAAM,SAAS,SAClB,OAAM,KAAK,CAAC,OAAO,QAAQ,EAAE,CAAC;;;;AAMtC,SAAgB,sBAAsB,MAAwC;AAC5E,KAAI,QAAQ,QAAQ,OAAO,SAAS,SAClC,qBAAqB,KAA8B,OAAO;CAE5D,MAAM,SAAS,oBAAoB,UAAU,KAAK;AAClD,KAAI,CAAC,OAAO,SAAS;EACnB,MAAM,aAAa,OAAO,MAAM,OAC7B,KAAK,MAAM,EAAE,KAAK,KAAK,IAAI,IAAI,SAAS,CACxC,KAAK,KAAK;AACb,QAAM,IAAI,gBACR,WAAW,SAAS,IAChB,wCAAwC,WAAW,KACnD,+BACJ,EAAE,SAAS,EAAE,QAAQ,OAAO,MAAM,QAAQ,EAAE,CAC7C;;AAEH,QAAO,OAAO"}
@@ -6,9 +6,8 @@ import "../../shared/src/index.js";
6
6
  interface IAnalyticsConfig extends BasePluginConfig {
7
7
  timeout?: number;
8
8
  /**
9
- * Build-generated per-metric column metadata, keyed by metric key. The
10
- * metric route scopes this to the requested measures and dimensions before
11
- * attaching it to the SSE result.
9
+ * Per-column display metadata. The metric route stamps the slice scoped to a
10
+ * request's measures/dimensions into the SSE `result` message.
12
11
  */
13
12
  metricViewsMetadata?: MetricViewsMetadata;
14
13
  /**
@@ -22,14 +21,16 @@ interface IAnalyticsConfig extends BasePluginConfig {
22
21
  * controlled deployments where billable warehouse starts must not be
23
22
  * triggered by user requests; in that case `STOPPED` surfaces as a
24
23
  * `ConfigurationError`.
24
+ *
25
+ * @default true
25
26
  */
26
27
  autoStartWarehouse?: boolean;
27
28
  /**
28
29
  * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first
29
30
  * byte (warehouse readiness + execute + first chunk). Past this, a stuck or
30
31
  * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of
31
- * hanging until the client disconnects. Defaults to 2 min. Once the first
32
- * byte arrives the stream is not time-bounded.
32
+ * hanging until the client disconnects.
33
+ * Defaults to 2 min.
33
34
  */
34
35
  arrowFirstByteTimeoutMs?: number;
35
36
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"mappings":";;;;;UAMiB,gBAAA,SAAyB,gBAAA;EACxC,OAAA;;;AADF;;;EAOE,mBAAA,GAAsB,mBAAA;EAPkB;;;;EAYxC,yBAAA;EAQA;;;;;;;EAAA,kBAAA;;;;;;;;EAQA,uBAAA;AAAA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"mappings":";;;;;UAeiB,gBAAA,SAAyB,gBAAA;EACxC,OAAA;;AADF;;;EAME,mBAAA,GAAsB,mBAAA;EANkB;;;;EAWxC,yBAAA;EAUA;;;;;;;;;EAAA,kBAAA;;;;;;;;EAQA,uBAAA;AAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"sourcesContent":["import type {\n BasePluginConfig,\n MetricViewColumnDisplay,\n MetricViewsMetadata,\n} from \"shared\";\n\nexport interface IAnalyticsConfig extends BasePluginConfig {\n timeout?: number;\n /**\n * Build-generated per-metric column metadata, keyed by metric key. The\n * metric route scopes this to the requested measures and dimensions before\n * attaching it to the SSE result.\n */\n metricViewsMetadata?: MetricViewsMetadata;\n /**\n * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL\n * warehouse to reach RUNNING before failing the request. Defaults to 5 min.\n */\n warehouseStartupTimeoutMs?: number;\n /**\n * When `true` (default), a `STOPPED` SQL warehouse is auto-started on the\n * first analytics request that reaches it. Set to `false` for cost-\n * controlled deployments where billable warehouse starts must not be\n * triggered by user requests; in that case `STOPPED` surfaces as a\n * `ConfigurationError`.\n */\n autoStartWarehouse?: boolean;\n /**\n * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first\n * byte (warehouse readiness + execute + first chunk). Past this, a stuck or\n * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of\n * hanging until the client disconnects. Defaults to 2 min. Once the first\n * byte arrives the stream is not time-bounded.\n */\n arrowFirstByteTimeoutMs?: number;\n}\n\n/**\n * SQL warehouse lifecycle states surfaced by the analytics route.\n * Mirrors the states emitted by the Databricks SQL SDK (`sql.State`).\n */\nexport type WarehouseState =\n | \"RUNNING\"\n | \"STARTING\"\n | \"STOPPED\"\n | \"STOPPING\"\n | \"DELETED\"\n | \"DELETING\";\n\n/**\n * Snapshot of warehouse readiness streamed to the client over SSE before the\n * SQL result. Lets the UI render a \"warehouse starting…\" affordance instead\n * of a frozen spinner during cold starts.\n *\n * Note: the SDK's `health.summary` is intentionally NOT forwarded here. It's\n * free-form operator-oriented diagnostic text (cluster IDs, capacity-failure\n * reasons, internal RPC errors) that must not reach end users; it stays in\n * server-side telemetry only.\n */\nexport interface WarehouseStatus {\n state: WarehouseState;\n /** Milliseconds elapsed since the route began waiting for the warehouse. */\n elapsedMs: number;\n}\n\n/**\n * Discriminated union of every SSE message shape emitted by\n * `POST /api/analytics/query/:query_key`. Useful for typing the client-side\n * `onMessage` handler (and is the source of truth re-mirrored in\n * `appkit-ui` since that package can't depend on `appkit`).\n */\nexport type AnalyticsStreamMessage =\n | { type: \"warehouse_status\"; status: WarehouseStatus }\n | {\n type: \"result\";\n data?: unknown[];\n status?: unknown;\n statement_id?: string;\n metadata?: Record<string, MetricViewColumnDisplay>;\n }\n | {\n type: \"arrow\";\n statement_id: string;\n status: { state: string };\n }\n | { type: \"error\"; error: string; code?: string };\n\n/**\n * Supported response formats for analytics queries.\n *\n * \"JSON\" and \"ARROW\" are legacy aliases kept for backwards compatibility\n * with appkit/appkit-ui < 0.33.0 — safe to remove once no consumer is on\n * a pre-0.33.0 version. The route handler normalizes them to their\n * canonical equivalents before any downstream code reads the value.\n */\nexport type AnalyticsFormat =\n | \"JSON_ARRAY\"\n | \"ARROW_STREAM\"\n /** @deprecated Use \"JSON_ARRAY\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"JSON\"\n /** @deprecated Use \"ARROW_STREAM\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"ARROW\";\n\n/** Canonical (post-normalization) analytics format values. */\ntype CanonicalAnalyticsFormat = \"JSON_ARRAY\" | \"ARROW_STREAM\";\n\n/**\n * Map a (possibly legacy) AnalyticsFormat to its canonical form.\n * Legacy values come from appkit/appkit-ui < 0.33.0 and can be removed\n * along with the deprecated aliases once no such consumer remains.\n */\nexport function normalizeAnalyticsFormat(\n f: AnalyticsFormat,\n): CanonicalAnalyticsFormat {\n if (f === \"JSON\") return \"JSON_ARRAY\";\n if (f === \"ARROW\") return \"ARROW_STREAM\";\n return f;\n}\n\nexport interface IAnalyticsQueryRequest {\n parameters?: Record<string, any>;\n format?: AnalyticsFormat;\n}\n\nexport interface AnalyticsQueryResponse {\n chunk_index: number;\n row_offset: number;\n row_count: number;\n data: any[];\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Metric views — POST /api/analytics/metric/:key\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Execution lane for a registered metric view, derived from the entry's\n * `executor` in `definitions.json`:\n * - `\"sp\"` ← `executor: \"app_service_principal\"` — queried as the app\n * service principal (cache shared across all users).\n * - `\"obo\"` ← `executor: \"user\"` — queried on-behalf-of the requesting\n * user (per-user cache). OBO dispatch is wired in a later phase.\n */\nexport type MetricLane = \"sp\" | \"obo\";\n\n/**\n * A single registered metric view, loaded from `config/metric-views/definitions.json`.\n *\n * The registration carries only what the runtime needs to build and dispatch\n * SQL: the metric `key`, the three-part UC FQN `source`, and the `lane`. There\n * is intentionally NO build-time measure/dimension metadata here — the security\n * boundary is the grammar gate plus parameterized values, not a name allowlist,\n * so the runtime never enumerates known measures/dimensions.\n */\nexport interface MetricRegistration {\n key: string;\n source: string;\n lane: MetricLane;\n}\n\n/**\n * v1 filter operator vocabulary — exactly twelve names. The runtime tuple\n * `METRIC_FILTER_OPERATORS` (next to the validator in `metric.ts`) is the\n * server-side source of truth; this union mirrors it statically.\n */\nexport type MetricFilterOperatorName =\n | \"equals\"\n | \"notEquals\"\n | \"in\"\n | \"notIn\"\n | \"gt\"\n | \"gte\"\n | \"lt\"\n | \"lte\"\n | \"contains\"\n | \"notContains\"\n | \"set\"\n | \"notSet\";\n\n/**\n * A single filter predicate — the leaf node of the recursive\n * {@link MetricFilter} tree. `member` is a dimension name (grammar-gated, not\n * allowlisted); `values` is bound through parameterized `:f_<idx>` bind vars\n * and never interpolated into the SQL string.\n */\nexport interface MetricPredicate {\n member: string;\n operator: MetricFilterOperatorName;\n values?: ReadonlyArray<string | number>;\n}\n\n/**\n * Recursive filter expression for the metric-view request body: a leaf\n * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. The\n * shape is intentionally non-generic server-side — per-metric narrowing (if\n * any) lives client-side.\n */\nexport type MetricFilter =\n | MetricPredicate\n | { and: ReadonlyArray<MetricFilter> }\n | { or: ReadonlyArray<MetricFilter> };\n\n/**\n * Validated request body for `POST /api/analytics/metric/:key`.\n *\n * `measures` is required. `dimensions` drive `GROUP BY ALL`; `filter` is the\n * recursive structured predicate tree translated into a parameterized `WHERE`\n * clause. `timeGrain` buckets the single dimension named by `timeDimension`\n * via `date_trunc`; it requires `timeDimension`, and `timeDimension` must be\n * one of `dimensions` so it is selected and in `GROUP BY ALL`. Both tokens are\n * grammar-gated before they reach SQL.\n */\nexport interface IAnalyticsMetricRequest {\n measures: string[];\n dimensions?: string[];\n filter?: MetricFilter;\n timeGrain?: string;\n /**\n * The single dimension that `timeGrain` buckets via `date_trunc`. Must be\n * one of `dimensions` (so it is selected and in `GROUP BY ALL`) and is\n * required whenever `timeGrain` is set. Grammar-gated as a SQL identifier.\n */\n timeDimension?: string;\n limit?: number;\n format?: AnalyticsFormat;\n}\n"],"mappings":";;;;;;AA+GA,SAAgB,yBACd,GAC0B;AAC1B,KAAI,MAAM,OAAQ,QAAO;AACzB,KAAI,MAAM,QAAS,QAAO;AAC1B,QAAO"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"sourcesContent":["import type {\n BasePluginConfig,\n MetricFilter,\n MetricOrderBy,\n MetricViewColumnDisplay,\n MetricViewsMetadata,\n} from \"shared\";\n\nexport type {\n MetricFilter,\n MetricFilterOperatorName,\n MetricOrderBy,\n MetricPredicate,\n} from \"shared\";\n\nexport interface IAnalyticsConfig extends BasePluginConfig {\n timeout?: number;\n /**\n * Per-column display metadata. The metric route stamps the slice scoped to a\n * request's measures/dimensions into the SSE `result` message.\n */\n metricViewsMetadata?: MetricViewsMetadata;\n /**\n * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL\n * warehouse to reach RUNNING before failing the request. Defaults to 5 min.\n */\n warehouseStartupTimeoutMs?: number;\n /**\n * When `true` (default), a `STOPPED` SQL warehouse is auto-started on the\n * first analytics request that reaches it. Set to `false` for cost-\n * controlled deployments where billable warehouse starts must not be\n * triggered by user requests; in that case `STOPPED` surfaces as a\n * `ConfigurationError`.\n *\n * @default true\n */\n autoStartWarehouse?: boolean;\n /**\n * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first\n * byte (warehouse readiness + execute + first chunk). Past this, a stuck or\n * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of\n * hanging until the client disconnects.\n * Defaults to 2 min.\n */\n arrowFirstByteTimeoutMs?: number;\n}\n\nexport type WarehouseState =\n | \"RUNNING\"\n | \"STARTING\"\n | \"STOPPED\"\n | \"STOPPING\"\n | \"DELETED\"\n | \"DELETING\";\n\n/**\n * Snapshot of warehouse readiness streamed to the client over SSE before the\n * SQL result. Lets the UI render a \"warehouse starting…\" affordance instead\n * of a frozen spinner during cold starts.\n *\n * Note: the SDK's `health.summary` is intentionally NOT forwarded here. It's\n * free-form operator-oriented diagnostic text (cluster IDs, capacity-failure\n * reasons, internal RPC errors) that must not reach end users; it stays in\n * server-side telemetry only.\n */\nexport interface WarehouseStatus {\n state: WarehouseState;\n /** Milliseconds elapsed since the route began waiting for the warehouse. */\n elapsedMs: number;\n}\n\n/**\n * Discriminated union of every SSE message shape emitted by the analytics\n * routes (`POST /api/analytics/query/:query_key` and\n * `POST /api/analytics/metric/:key`).\n */\nexport type AnalyticsStreamMessage =\n | { type: \"warehouse_status\"; status: WarehouseStatus }\n | {\n type: \"result\";\n data?: unknown[];\n status?: unknown;\n statement_id?: string;\n metadata?: Record<string, MetricViewColumnDisplay>;\n }\n | {\n type: \"arrow\";\n statement_id: string;\n status: { state: string };\n }\n | { type: \"error\"; error: string; code?: string; errorCode?: string };\n\n/**\n * Supported response formats for analytics queries.\n *\n * \"JSON\" and \"ARROW\" are legacy aliases kept for backwards compatibility\n * with appkit/appkit-ui < 0.33.0 — safe to remove once no consumer is on\n * a pre-0.33.0 version. The route handler normalizes them to their\n * canonical equivalents before any downstream code reads the value.\n */\nexport type AnalyticsFormat =\n | \"JSON_ARRAY\"\n | \"ARROW_STREAM\"\n /** @deprecated Use \"JSON_ARRAY\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"JSON\"\n /** @deprecated Use \"ARROW_STREAM\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"ARROW\";\n\n/** Canonical (post-normalization) analytics format values. */\ntype CanonicalAnalyticsFormat = \"JSON_ARRAY\" | \"ARROW_STREAM\";\n\n/**\n * Map a (possibly legacy) AnalyticsFormat to its canonical form.\n * Legacy values come from appkit/appkit-ui < 0.33.0 and can be removed\n * along with the deprecated aliases once no such consumer remains.\n */\nexport function normalizeAnalyticsFormat(\n f: AnalyticsFormat,\n): CanonicalAnalyticsFormat {\n if (f === \"JSON\") return \"JSON_ARRAY\";\n if (f === \"ARROW\") return \"ARROW_STREAM\";\n return f;\n}\n\nexport interface IAnalyticsQueryRequest {\n parameters?: Record<string, any>;\n format?: AnalyticsFormat;\n}\n\nexport interface AnalyticsQueryResponse {\n chunk_index: number;\n row_offset: number;\n row_count: number;\n data: any[];\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Metric views — POST /api/analytics/metric/:key\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Execution lane for a registered metric view, derived from the entry's\n * `executor` in `definitions.json`:\n * - `\"sp\"` ← `executor: \"app_service_principal\"` — queried as the app\n * service principal (cache shared across all users).\n * - `\"obo\"` ← `executor: \"user\"` — queried on-behalf-of the requesting\n * user (per-user cache) via `asUser(req)`.\n */\nexport type MetricLane = \"sp\" | \"obo\";\n\n/**\n * A single registered metric view, loaded from `config/metric-views/definitions.json`.\n *\n * The registration carries only what the runtime needs to build and dispatch\n * SQL: the metric `key`, the three-part UC FQN `source`, and the `lane`. There\n * is intentionally NO build-time measure/dimension metadata here — the security\n * boundary is the grammar gate plus parameterized values, not a name allowlist,\n * so the runtime never enumerates known measures/dimensions.\n */\nexport interface MetricRegistration {\n key: string;\n source: string;\n lane: MetricLane;\n}\n\n/**\n * Validated request body for `POST /api/analytics/metric/:key`.\n *\n * `measures` is required. `dimensions` drive `GROUP BY ALL`; `filter` is the\n * recursive structured predicate tree translated into a parameterized `WHERE`\n * clause. `timeGrain` buckets the single dimension named by `timeDimension`\n * via `date_trunc`; it requires `timeDimension`, and `timeDimension` must be\n * one of `dimensions` so it is selected and in `GROUP BY ALL`. Both tokens are\n * grammar-gated before they reach SQL. `orderBy` controls row ordering; when\n * `limit` is set, any dimensions not named in `orderBy` are appended as\n * tie-breakers so the ordering is total and `LIMIT` is deterministic.\n */\nexport interface IAnalyticsMetricRequest {\n measures: string[];\n dimensions?: string[];\n filter?: MetricFilter;\n timeGrain?: string;\n /**\n * The single dimension that `timeGrain` buckets via `date_trunc`. Must be\n * one of `dimensions` (so it is selected and in `GROUP BY ALL`) and is\n * required whenever `timeGrain` is set. Grammar-gated as a SQL identifier.\n */\n timeDimension?: string;\n orderBy?: MetricOrderBy[];\n limit?: number;\n format?: AnalyticsFormat;\n}\n"],"mappings":";;;;;;AAoHA,SAAgB,yBACd,GAC0B;AAC1B,KAAI,MAAM,OAAQ,QAAO;AACzB,KAAI,MAAM,QAAS,QAAO;AAC1B,QAAO"}
@@ -162,9 +162,9 @@ declare const pluginManifestSchema: z.ZodObject<{
162
162
  type: z.ZodLiteral<"sql_warehouse">;
163
163
  }, z.core.$strict>, z.ZodObject<{
164
164
  permission: z.ZodEnum<{
165
- CAN_QUERY: "CAN_QUERY";
166
165
  CAN_VIEW: "CAN_VIEW";
167
166
  CAN_MANAGE: "CAN_MANAGE";
167
+ CAN_QUERY: "CAN_QUERY";
168
168
  }>;
169
169
  alias: z.ZodString;
170
170
  resourceKey: z.ZodString;
@@ -683,9 +683,9 @@ declare const pluginManifestSchema: z.ZodObject<{
683
683
  type: z.ZodLiteral<"sql_warehouse">;
684
684
  }, z.core.$strict>, z.ZodObject<{
685
685
  permission: z.ZodEnum<{
686
- CAN_QUERY: "CAN_QUERY";
687
686
  CAN_VIEW: "CAN_VIEW";
688
687
  CAN_MANAGE: "CAN_MANAGE";
688
+ CAN_QUERY: "CAN_QUERY";
689
689
  }>;
690
690
  alias: z.ZodString;
691
691
  resourceKey: z.ZodString;
@@ -0,0 +1,24 @@
1
+ import { metricKeySchema } from "./metric-source.js";
2
+ import { z } from "zod";
3
+
4
+ //#region ../shared/src/schemas/metric-metadata-bundle.ts
5
+ const METRIC_METADATA_BUNDLE_VERSION = 1;
6
+ const METRIC_METADATA_FILE = "metadata.generated.json";
7
+ const columnDisplaySchema = z.object({
8
+ type: z.string().describe("SQL type of the column as reported by DESCRIBE."),
9
+ display_name: z.string().optional().describe("Human label from the metric view's YAML `display_name`."),
10
+ format: z.string().optional().describe("Spark number-format spec from the metric view's YAML `format`, e.g. \"$#,##0.00\"."),
11
+ description: z.string().optional().describe("Column description from the metric view's YAML.")
12
+ }).describe("Display metadata for a single metric-view column.");
13
+ const metricEntryMetadataSchema = z.object({
14
+ measures: z.record(z.string(), columnDisplaySchema),
15
+ dimensions: z.record(z.string(), columnDisplaySchema)
16
+ }).describe("Per-column display metadata for one metric view.");
17
+ const metricMetadataBundleSchema = z.object({
18
+ version: z.number().int().describe("Bundle format version. Compared against METRIC_METADATA_BUNDLE_VERSION."),
19
+ metricViews: z.record(metricKeySchema, metricEntryMetadataSchema).describe("Per-column display metadata, keyed by metric key.")
20
+ }).describe("Schema for AppKit config/metric-views/metadata.generated.json — build-generated per-column display metadata for the analytics plugin's metric-view path. Generated; do not hand-edit.");
21
+
22
+ //#endregion
23
+ export { METRIC_METADATA_BUNDLE_VERSION, METRIC_METADATA_FILE, metricMetadataBundleSchema };
24
+ //# sourceMappingURL=metric-metadata-bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metric-metadata-bundle.js","names":[],"sources":["../../../../../shared/src/schemas/metric-metadata-bundle.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { metricKeySchema } from \"./metric-source\";\n\n// Bundle format version.\nexport const METRIC_METADATA_BUNDLE_VERSION = 1;\nexport const METRIC_METADATA_FILE = \"metadata.generated.json\";\n\nconst columnDisplaySchema = z\n .object({\n type: z\n .string()\n .describe(\"SQL type of the column as reported by DESCRIBE.\"),\n display_name: z\n .string()\n .optional()\n .describe(\"Human label from the metric view's YAML `display_name`.\"),\n format: z\n .string()\n .optional()\n .describe(\n 'Spark number-format spec from the metric view\\'s YAML `format`, e.g. \"$#,##0.00\".',\n ),\n description: z\n .string()\n .optional()\n .describe(\"Column description from the metric view's YAML.\"),\n })\n // Non-strict: a newer generator may add per-column fields, and an older\n // runtime should keep serving the fields it does understand rather than\n // rejecting the whole bundle over one unknown key.\n .describe(\"Display metadata for a single metric-view column.\");\n\nconst metricEntryMetadataSchema = z\n .object({\n measures: z.record(z.string(), columnDisplaySchema),\n dimensions: z.record(z.string(), columnDisplaySchema),\n })\n .describe(\"Per-column display metadata for one metric view.\");\n\nexport const metricMetadataBundleSchema = z\n .object({\n version: z\n .number()\n .int()\n .describe(\n \"Bundle format version. Compared against METRIC_METADATA_BUNDLE_VERSION.\",\n ),\n metricViews: z\n .record(metricKeySchema, metricEntryMetadataSchema)\n .describe(\"Per-column display metadata, keyed by metric key.\"),\n })\n .describe(\n \"Schema for AppKit config/metric-views/metadata.generated.json — build-generated per-column display metadata for the analytics plugin's metric-view path. Generated; do not hand-edit.\",\n );\n\nexport type MetricMetadataBundle = z.infer<typeof metricMetadataBundleSchema>;\n"],"mappings":";;;;AAIA,MAAa,iCAAiC;AAC9C,MAAa,uBAAuB;AAEpC,MAAM,sBAAsB,EACzB,OAAO;CACN,MAAM,EACH,QAAQ,CACR,SAAS,kDAAkD;CAC9D,cAAc,EACX,QAAQ,CACR,UAAU,CACV,SAAS,0DAA0D;CACtE,QAAQ,EACL,QAAQ,CACR,UAAU,CACV,SACC,qFACD;CACH,aAAa,EACV,QAAQ,CACR,UAAU,CACV,SAAS,kDAAkD;CAC/D,CAAC,CAID,SAAS,oDAAoD;AAEhE,MAAM,4BAA4B,EAC/B,OAAO;CACN,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,oBAAoB;CACnD,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,oBAAoB;CACtD,CAAC,CACD,SAAS,mDAAmD;AAE/D,MAAa,6BAA6B,EACvC,OAAO;CACN,SAAS,EACN,QAAQ,CACR,KAAK,CACL,SACC,0EACD;CACH,aAAa,EACV,OAAO,iBAAiB,0BAA0B,CAClD,SAAS,oDAAoD;CACjE,CAAC,CACD,SACC,wLACD"}
@@ -86,5 +86,5 @@ const metricSourceSchema = z.object({
86
86
  });
87
87
 
88
88
  //#endregion
89
- export { metricSourceSchema };
89
+ export { metricKeySchema, metricSourceSchema };
90
90
  //# sourceMappingURL=metric-source.js.map
@@ -1,12 +1,13 @@
1
1
  import { createLogger } from "../logging/logger.js";
2
2
  import { createWorkspaceClient } from "../workspace-client/factory.js";
3
3
  import "../workspace-client/index.js";
4
+ import { METRIC_METADATA_FILE } from "../shared/src/schemas/metric-metadata-bundle.js";
4
5
  import { isRevivableMetricCacheEntry, loadCache, metricCacheHash, saveCache } from "./cache.js";
5
6
  import { classifyBlockingFailure, classifyEnvironmentalCause, getErrorDiagnostic, isConnectivityError } from "./errors.js";
6
7
  import { migrateProjectConfig, removeOldGeneratedTypes, resolveProjectRoot } from "./migration.js";
7
8
  import { readMetricConfig, resolveMetricConfig } from "./mv-registry/config.js";
8
9
  import { createWorkspaceDescribeFetcher } from "./mv-registry/describe.js";
9
- import { generateMetricTypeDeclarations } from "./mv-registry/render-types.js";
10
+ import { buildMetricMetadataBundle, generateMetricTypeDeclarations } from "./mv-registry/render-types.js";
10
11
  import { emptyMetricSchema, syncMetrics } from "./mv-registry/sync.js";
11
12
  import { decidePreflight } from "./preflight.js";
12
13
  import { getWarehouseState, startWarehouse, waitUntilRunning } from "./warehouse-status.js";
@@ -440,12 +441,15 @@ async function syncMetricViewsTypes(options) {
440
441
  if (!suppressDegradedWrite || !hasAnyDegradedMetrics(schemas)) {
441
442
  await fs.mkdir(path.dirname(metricOutFile), { recursive: true });
442
443
  await fs.writeFile(metricOutFile, generateMetricTypeDeclarations(schemas), "utf-8");
444
+ const bundlePath = path.join(metricViewsFolder, METRIC_METADATA_FILE);
445
+ await fs.writeFile(bundlePath, `${JSON.stringify(buildMetricMetadataBundle(schemas), null, 2)}\n`, "utf-8");
446
+ logger.debug("Wrote metric metadata bundle to %s", bundlePath);
443
447
  }
444
- if (metricOutFile.endsWith(".ts") && !metricOutFile.endsWith(".d.ts") && existsSync(metricOutFile)) {
445
- const staleDts = `${metricOutFile.slice(0, -3)}.d.ts`;
448
+ if (metricOutFile.endsWith(".d.ts") && existsSync(metricOutFile)) {
449
+ const staleTs = `${metricOutFile.slice(0, -5)}.ts`;
446
450
  try {
447
- await fs.unlink(staleDts);
448
- logger.debug("Removed stale generated types at %s", staleDts);
451
+ await fs.unlink(staleTs);
452
+ logger.debug("Removed stale generated types at %s", staleTs);
449
453
  } catch {}
450
454
  }
451
455
  logger.debug("Wrote MetricRegistry augmentation for %d metric(s)%s", schemas.length, failures.length > 0 ? ` (${failures.length} failure(s))` : "");
@@ -463,7 +467,7 @@ const generateServingTypes = generateServingTypes$1;
463
467
  const TYPES_DIR = "appkit-types";
464
468
  const ANALYTICS_TYPES_FILE = "analytics.d.ts";
465
469
  const SERVING_TYPES_FILE = "serving.d.ts";
466
- const METRIC_TYPES_FILE = "metric-views.ts";
470
+ const METRIC_TYPES_FILE = "metric-views.d.ts";
467
471
 
468
472
  //#endregion
469
473
  export { ANALYTICS_TYPES_FILE, SERVING_TYPES_FILE, TYPES_DIR, TypegenFatalError, TypegenSyntaxError, generateFromEntryPoint, generateServingTypes };