@databricks/appkit 0.46.0 → 0.47.1
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.
- package/dist/app/index.d.ts +49 -2
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +87 -10
- package/dist/app/index.js.map +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cli/commands/generate-types.js +9 -4
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/cli/commands/lint.js +6 -5
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts +16 -0
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js +162 -1
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/analytics/metric.js +7 -0
- package/dist/plugins/analytics/mv/cache.js +51 -0
- package/dist/plugins/analytics/mv/cache.js.map +1 -0
- package/dist/plugins/analytics/mv/constants.js +72 -0
- package/dist/plugins/analytics/mv/constants.js.map +1 -0
- package/dist/plugins/analytics/mv/formatters.js +151 -0
- package/dist/plugins/analytics/mv/formatters.js.map +1 -0
- package/dist/plugins/analytics/mv/index.js +6 -0
- package/dist/plugins/analytics/mv/registry.js +55 -0
- package/dist/plugins/analytics/mv/registry.js.map +1 -0
- package/dist/plugins/analytics/mv/schemas.js +178 -0
- package/dist/plugins/analytics/mv/schemas.js.map +1 -0
- package/dist/plugins/analytics/types.js.map +1 -1
- package/dist/schemas/metric-fqn.js +14 -0
- package/dist/schemas/metric-fqn.js.map +1 -0
- package/dist/shared/src/schemas/metric-fqn.js +78 -46
- package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
- package/dist/shared/src/schemas/metric-source.js +90 -0
- package/dist/shared/src/schemas/metric-source.js.map +1 -0
- package/dist/type-generator/index.js +14 -7
- package/dist/type-generator/index.js.map +1 -1
- package/dist/type-generator/mv-registry/config.js +13 -31
- package/dist/type-generator/mv-registry/config.js.map +1 -1
- package/dist/type-generator/mv-registry/describe.js +1 -31
- package/dist/type-generator/mv-registry/describe.js.map +1 -1
- package/dist/type-generator/mv-registry/sync.js +1 -1
- package/dist/type-generator/mv-registry/sync.js.map +1 -1
- package/dist/type-generator/vite-plugin.d.ts +5 -1
- package/dist/type-generator/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/vite-plugin.js +21 -4
- package/dist/type-generator/vite-plugin.js.map +1 -1
- package/docs/development/type-generation.md +3 -3
- package/docs/plugins/analytics.md +172 -23
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe.js","names":[],"sources":["../../../src/type-generator/mv-registry/describe.ts"],"sourcesContent":["import type { WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport { type DescribeFormatMemo, describeAdaptive } from \"../statement-result\";\nimport type { DatabricksStatementExecutionResponse } from \"../types\";\nimport { isValidFqn } from \"./config\";\nimport type { DescribeFetcher, MetricColumnMetadata } from \"./types\";\n\n/**\n * Parse the JSON payload returned by DESCRIBE TABLE EXTENDED ... AS JSON.\n *\n * The Statement Execution API returns a single string cell — this normalizer\n * unwraps it. Handles both the production (real warehouse) shape and the\n * shape produced by mocked test responses.\n */\nexport function parseDescribeTableExtendedJson(\n response: DatabricksStatementExecutionResponse,\n): unknown {\n if (response.status?.state === \"FAILED\") {\n const msg = response.status.error?.message ?? \"DESCRIBE failed\";\n throw new Error(`DESCRIBE TABLE EXTENDED failed: ${msg}`);\n }\n\n const rows = response.result?.data_array ?? [];\n if (rows.length === 0) {\n throw new Error(\n \"DESCRIBE TABLE EXTENDED returned no rows. Verify the FQN points to a metric view.\",\n );\n }\n\n const cell = rows[0]?.[0];\n if (typeof cell !== \"string\") {\n throw new Error(\n \"DESCRIBE TABLE EXTENDED first cell was not a JSON string. Confirm the AS JSON suffix is supported.\",\n );\n }\n\n try {\n return JSON.parse(cell);\n } catch (err) {\n throw new Error(\n `Failed to parse DESCRIBE TABLE EXTENDED JSON: ${(err as Error).message}`,\n );\n }\n}\n\n/**\n * Pure function: turn the parsed DESCRIBE JSON into structured column metadata.\n *\n * Tolerant of multiple JSON shapes (the field may be `columns` or\n * `schema.fields`, type may be a string or `{ name }` object, the measure\n * marker may be `is_measure` or under `metadata.is_measure`).\n */\nexport function extractMetricColumns(parsed: unknown): MetricColumnMetadata[] {\n if (!parsed || typeof parsed !== \"object\") {\n return [];\n }\n\n const root = parsed as Record<string, unknown>;\n const columnsCandidate = (root.columns ??\n (root.schema && typeof root.schema === \"object\"\n ? (root.schema as Record<string, unknown>).fields\n : undefined)) as unknown;\n\n if (!Array.isArray(columnsCandidate)) {\n return [];\n }\n\n const columns: MetricColumnMetadata[] = [];\n for (const raw of columnsCandidate) {\n if (!raw || typeof raw !== \"object\") continue;\n const obj = raw as Record<string, unknown>;\n const name =\n typeof obj.name === \"string\"\n ? obj.name\n : typeof obj.column_name === \"string\"\n ? obj.column_name\n : undefined;\n if (!name) continue;\n\n const typeRaw = obj.type ?? obj.data_type ?? obj.type_name;\n let type = \"STRING\";\n if (typeof typeRaw === \"string\") {\n type = typeRaw;\n } else if (typeRaw && typeof typeRaw === \"object\") {\n const inner = (typeRaw as Record<string, unknown>).name;\n if (typeof inner === \"string\") type = inner;\n }\n\n let isMeasure = false;\n if (typeof obj.is_measure === \"boolean\") {\n isMeasure = obj.is_measure;\n } else if (\n obj.metadata &&\n typeof obj.metadata === \"object\" &&\n typeof (obj.metadata as Record<string, unknown>).is_measure === \"boolean\"\n ) {\n isMeasure = (obj.metadata as Record<string, unknown>)\n .is_measure as boolean;\n } else if (obj.kind === \"measure\" || obj.role === \"measure\") {\n isMeasure = true;\n }\n\n const description =\n typeof obj.comment === \"string\"\n ? obj.comment\n : typeof obj.description === \"string\"\n ? obj.description\n : undefined;\n\n const displayName = extractStringFromAny(obj, [\n \"display_name\",\n \"displayName\",\n ]);\n const format = extractFormatString(obj);\n\n // Time-grain inference is type-driven, not YAML-attribute-driven.\n const timeGrains = isMeasure ? undefined : inferTimeGrains(type);\n\n columns.push({\n name,\n type,\n isMeasure,\n description,\n ...(displayName ? { displayName } : {}),\n ...(format ? { format } : {}),\n ...(timeGrains ? { timeGrains } : {}),\n });\n }\n\n return columns;\n}\n\n/**\n * Read a non-empty string attribute from a DESCRIBE column entry, tolerating\n * the multiple shapes UC has shipped for this metadata over time.\n */\nfunction extractStringFromAny(\n obj: Record<string, unknown>,\n candidates: readonly string[],\n): string | undefined {\n for (const key of candidates) {\n const direct = obj[key];\n if (typeof direct === \"string\" && direct.trim().length > 0) {\n return direct;\n }\n const meta = obj.metadata;\n if (meta && typeof meta === \"object\" && !Array.isArray(meta)) {\n const nested = (meta as Record<string, unknown>)[key];\n if (typeof nested === \"string\" && nested.trim().length > 0) {\n return nested;\n }\n }\n }\n return undefined;\n}\n\n/**\n * Read the column's `format` attribute from a DESCRIBE entry and return a\n * printf-like format string suitable for `formatValue` and `toD3Format`.\n */\nfunction extractFormatString(obj: Record<string, unknown>): string | undefined {\n for (const key of [\"format\", \"format_spec\"]) {\n const direct = obj[key];\n const fromDirect = formatStringFromValue(direct);\n if (fromDirect) return fromDirect;\n\n const meta = obj.metadata;\n if (meta && typeof meta === \"object\" && !Array.isArray(meta)) {\n const nested = (meta as Record<string, unknown>)[key];\n const fromMeta = formatStringFromValue(nested);\n if (fromMeta) return fromMeta;\n }\n }\n return undefined;\n}\n\nfunction formatStringFromValue(value: unknown): string | undefined {\n if (typeof value === \"string\" && value.trim().length > 0) return value.trim();\n if (value && typeof value === \"object\" && !Array.isArray(value)) {\n return translateStructuredFormat(value as Record<string, unknown>);\n }\n return undefined;\n}\n\n/**\n * Translate the structured `format` object emitted by DESCRIBE TABLE EXTENDED\n * AS JSON into a printf-like format string.\n */\nfunction translateStructuredFormat(\n spec: Record<string, unknown>,\n): string | undefined {\n if (spec.currency && typeof spec.currency === \"object\") {\n return currencyFormatString(spec.currency as Record<string, unknown>);\n }\n if (spec.percent && typeof spec.percent === \"object\") {\n return percentFormatString(spec.percent as Record<string, unknown>);\n }\n if (spec.number && typeof spec.number === \"object\") {\n return numberFormatString(spec.number as Record<string, unknown>);\n }\n return undefined;\n}\n\nfunction currencyFormatString(c: Record<string, unknown>): string {\n const places = readDecimalPlaces(c) ?? 2;\n const codeRaw = c.currency_code;\n const code =\n typeof codeRaw === \"string\" && codeRaw.trim().length > 0\n ? codeRaw.toUpperCase()\n : \"USD\";\n const symbol = currencySymbol(code);\n return `${symbol}#,##0${fractionalSuffix(places)}`;\n}\n\nfunction percentFormatString(p: Record<string, unknown>): string {\n const places = readDecimalPlaces(p) ?? 0;\n return `0${fractionalSuffix(places)}%`;\n}\n\nfunction numberFormatString(n: Record<string, unknown>): string {\n const places = readDecimalPlaces(n) ?? 0;\n return `#,##0${fractionalSuffix(places)}`;\n}\n\nfunction fractionalSuffix(places: number): string {\n return places > 0 ? `.${\"0\".repeat(places)}` : \"\";\n}\n\n/**\n * Maximum decimal places honored from a format spec.\n */\nconst MAX_DECIMAL_PLACES = 100;\n\nfunction readDecimalPlaces(obj: Record<string, unknown>): number | undefined {\n const dp = obj.decimal_places;\n if (typeof dp === \"number\" && Number.isFinite(dp) && dp >= 0) {\n return Math.min(Math.floor(dp), MAX_DECIMAL_PLACES);\n }\n if (dp && typeof dp === \"object\" && !Array.isArray(dp)) {\n const places = (dp as Record<string, unknown>).places;\n if (typeof places === \"number\" && Number.isFinite(places) && places >= 0) {\n return Math.min(Math.floor(places), MAX_DECIMAL_PLACES);\n }\n }\n return undefined;\n}\n\n/**\n * Map ISO currency codes to their conventional prefix symbol. Unknown codes\n * fall back to the literal code + space.\n */\nconst CURRENCY_SYMBOLS: Record<string, string> = {\n USD: \"$\",\n EUR: \"€\",\n GBP: \"£\",\n JPY: \"¥\",\n CNY: \"¥\",\n INR: \"₹\",\n BRL: \"R$\",\n};\n\nfunction currencySymbol(code: string): string {\n return CURRENCY_SYMBOLS[code] ?? `${code} `;\n}\n\n/**\n * Infer the standard set of valid time grains for a dimension based on its\n * SQL data type.\n */\nfunction inferTimeGrains(type: string): string[] | undefined {\n const normalized = type\n .toLowerCase()\n .replace(/\\(.*\\)$/, \"\")\n .trim();\n if (\n normalized === \"timestamp\" ||\n normalized === \"timestamp_ltz\" ||\n normalized === \"timestamp_ntz\"\n ) {\n return [\"day\", \"hour\", \"minute\", \"month\", \"quarter\", \"week\", \"year\"];\n }\n if (normalized === \"date\") {\n return [\"day\", \"month\", \"quarter\", \"week\", \"year\"];\n }\n return undefined;\n}\n\n/**\n * Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL\n * statement.\n *\n * Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The\n * one character that can break out of a backtick-quoted identifier is the\n * backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick\n * inside a segment is doubled before the segment is wrapped. Control characters\n * and newlines have no valid escape inside a quoted identifier, so a segment\n * containing one is rejected outright.\n *\n * This is a pure, standalone escaper: it is intentionally independent of FQN\n * naming validation ({@link isValidFqn}). Naming validation decides whether an\n * FQN is an acceptable metric source; this function only guarantees that\n * whatever it is handed cannot break out of the quoted identifier it produces.\n *\n * An ordinary identifier is unchanged apart from the wrapping backticks:\n * `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.\n *\n * @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).\n * @returns The backtick-quoted, escaped identifier ready for interpolation.\n * @throws If any segment contains a control character or newline.\n */\nexport function quoteFqnForSql(fqn: string): string {\n // Reject anything that cannot be represented inside a backtick-quoted\n // identifier. \\p{Cc} is the Unicode \"control\" category, which covers C0\n // (incl. \\n, \\r, \\t), DEL, and C1 — i.e. every control character/newline.\n const CONTROL_OR_NEWLINE = /\\p{Cc}/u;\n return fqn\n .split(\".\")\n .map((segment) => {\n if (CONTROL_OR_NEWLINE.test(segment)) {\n throw new Error(\n `Cannot quote FQN segment \"${segment}\" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`,\n );\n }\n // Double every backtick — the only break-out from a backtick-quoted\n // identifier — then wrap the whole segment in backticks.\n return `\\`${segment.replace(/`/g, \"``\")}\\``;\n })\n .join(\".\");\n}\n\n/**\n * Build a DescribeFetcher from a real WorkspaceClient + warehouseId.\n */\nexport function createWorkspaceDescribeFetcher(\n client: WorkspaceClient,\n warehouseId: string,\n): DescribeFetcher {\n // One format probe per fetcher (= per typegen run): the first DESCRIBE\n // discovers the warehouse's working format, every later one reuses it.\n const describeFormat: DescribeFormatMemo = {};\n return async (fqn: string) => {\n /**\n * Defense-in-depth: every caller passes a source that already cleared\n * {@link resolveMetricConfig}, but this fetcher is an exported seam — re-check\n * before interpolating into SQL.\n */\n if (!isValidFqn(fqn)) {\n throw new Error(\n `Invalid metric source \"${fqn}\": expected a three-part UC FQN <catalog>.<schema>.<metric_view>.`,\n );\n }\n // Escape + quote every segment before interpolation. isValidFqn already\n // rejects backticks/control chars for metric sources, so this is\n // belt-and-suspenders for the SQL-injection seam — and keeps the quoting\n // independent of the naming rule.\n const quotedFqn = quoteFqnForSql(fqn);\n return describeAdaptive(\n client,\n `DESCRIBE TABLE EXTENDED ${quotedFqn} AS JSON`,\n warehouseId,\n describeFormat,\n );\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"describe.js","names":[],"sources":["../../../src/type-generator/mv-registry/describe.ts"],"sourcesContent":["import type { WorkspaceClient } from \"@databricks/sdk-experimental\";\n// Grammar + SQL-quoting for metric-view FQNs live together in the shared,\n// zod-free leaf so the type-generator and the analytics runtime validate and\n// escape against one source of truth (see the module doc in metric-fqn.ts).\nimport {\n isValidFqn,\n quoteFqnForSql,\n} from \"../../../../shared/src/schemas/metric-fqn\";\nimport { type DescribeFormatMemo, describeAdaptive } from \"../statement-result\";\nimport type { DatabricksStatementExecutionResponse } from \"../types\";\nimport type { DescribeFetcher, MetricColumnMetadata } from \"./types\";\n\n/**\n * Parse the JSON payload returned by DESCRIBE TABLE EXTENDED ... AS JSON.\n *\n * The Statement Execution API returns a single string cell — this normalizer\n * unwraps it. Handles both the production (real warehouse) shape and the\n * shape produced by mocked test responses.\n */\nexport function parseDescribeTableExtendedJson(\n response: DatabricksStatementExecutionResponse,\n): unknown {\n if (response.status?.state === \"FAILED\") {\n const msg = response.status.error?.message ?? \"DESCRIBE failed\";\n throw new Error(`DESCRIBE TABLE EXTENDED failed: ${msg}`);\n }\n\n const rows = response.result?.data_array ?? [];\n if (rows.length === 0) {\n throw new Error(\n \"DESCRIBE TABLE EXTENDED returned no rows. Verify the FQN points to a metric view.\",\n );\n }\n\n const cell = rows[0]?.[0];\n if (typeof cell !== \"string\") {\n throw new Error(\n \"DESCRIBE TABLE EXTENDED first cell was not a JSON string. Confirm the AS JSON suffix is supported.\",\n );\n }\n\n try {\n return JSON.parse(cell);\n } catch (err) {\n throw new Error(\n `Failed to parse DESCRIBE TABLE EXTENDED JSON: ${(err as Error).message}`,\n );\n }\n}\n\n/**\n * Pure function: turn the parsed DESCRIBE JSON into structured column metadata.\n *\n * Tolerant of multiple JSON shapes (the field may be `columns` or\n * `schema.fields`, type may be a string or `{ name }` object, the measure\n * marker may be `is_measure` or under `metadata.is_measure`).\n */\nexport function extractMetricColumns(parsed: unknown): MetricColumnMetadata[] {\n if (!parsed || typeof parsed !== \"object\") {\n return [];\n }\n\n const root = parsed as Record<string, unknown>;\n const columnsCandidate = (root.columns ??\n (root.schema && typeof root.schema === \"object\"\n ? (root.schema as Record<string, unknown>).fields\n : undefined)) as unknown;\n\n if (!Array.isArray(columnsCandidate)) {\n return [];\n }\n\n const columns: MetricColumnMetadata[] = [];\n for (const raw of columnsCandidate) {\n if (!raw || typeof raw !== \"object\") continue;\n const obj = raw as Record<string, unknown>;\n const name =\n typeof obj.name === \"string\"\n ? obj.name\n : typeof obj.column_name === \"string\"\n ? obj.column_name\n : undefined;\n if (!name) continue;\n\n const typeRaw = obj.type ?? obj.data_type ?? obj.type_name;\n let type = \"STRING\";\n if (typeof typeRaw === \"string\") {\n type = typeRaw;\n } else if (typeRaw && typeof typeRaw === \"object\") {\n const inner = (typeRaw as Record<string, unknown>).name;\n if (typeof inner === \"string\") type = inner;\n }\n\n let isMeasure = false;\n if (typeof obj.is_measure === \"boolean\") {\n isMeasure = obj.is_measure;\n } else if (\n obj.metadata &&\n typeof obj.metadata === \"object\" &&\n typeof (obj.metadata as Record<string, unknown>).is_measure === \"boolean\"\n ) {\n isMeasure = (obj.metadata as Record<string, unknown>)\n .is_measure as boolean;\n } else if (obj.kind === \"measure\" || obj.role === \"measure\") {\n isMeasure = true;\n }\n\n const description =\n typeof obj.comment === \"string\"\n ? obj.comment\n : typeof obj.description === \"string\"\n ? obj.description\n : undefined;\n\n const displayName = extractStringFromAny(obj, [\n \"display_name\",\n \"displayName\",\n ]);\n const format = extractFormatString(obj);\n\n // Time-grain inference is type-driven, not YAML-attribute-driven.\n const timeGrains = isMeasure ? undefined : inferTimeGrains(type);\n\n columns.push({\n name,\n type,\n isMeasure,\n description,\n ...(displayName ? { displayName } : {}),\n ...(format ? { format } : {}),\n ...(timeGrains ? { timeGrains } : {}),\n });\n }\n\n return columns;\n}\n\n/**\n * Read a non-empty string attribute from a DESCRIBE column entry, tolerating\n * the multiple shapes UC has shipped for this metadata over time.\n */\nfunction extractStringFromAny(\n obj: Record<string, unknown>,\n candidates: readonly string[],\n): string | undefined {\n for (const key of candidates) {\n const direct = obj[key];\n if (typeof direct === \"string\" && direct.trim().length > 0) {\n return direct;\n }\n const meta = obj.metadata;\n if (meta && typeof meta === \"object\" && !Array.isArray(meta)) {\n const nested = (meta as Record<string, unknown>)[key];\n if (typeof nested === \"string\" && nested.trim().length > 0) {\n return nested;\n }\n }\n }\n return undefined;\n}\n\n/**\n * Read the column's `format` attribute from a DESCRIBE entry and return a\n * printf-like format string suitable for `formatValue` and `toD3Format`.\n */\nfunction extractFormatString(obj: Record<string, unknown>): string | undefined {\n for (const key of [\"format\", \"format_spec\"]) {\n const direct = obj[key];\n const fromDirect = formatStringFromValue(direct);\n if (fromDirect) return fromDirect;\n\n const meta = obj.metadata;\n if (meta && typeof meta === \"object\" && !Array.isArray(meta)) {\n const nested = (meta as Record<string, unknown>)[key];\n const fromMeta = formatStringFromValue(nested);\n if (fromMeta) return fromMeta;\n }\n }\n return undefined;\n}\n\nfunction formatStringFromValue(value: unknown): string | undefined {\n if (typeof value === \"string\" && value.trim().length > 0) return value.trim();\n if (value && typeof value === \"object\" && !Array.isArray(value)) {\n return translateStructuredFormat(value as Record<string, unknown>);\n }\n return undefined;\n}\n\n/**\n * Translate the structured `format` object emitted by DESCRIBE TABLE EXTENDED\n * AS JSON into a printf-like format string.\n */\nfunction translateStructuredFormat(\n spec: Record<string, unknown>,\n): string | undefined {\n if (spec.currency && typeof spec.currency === \"object\") {\n return currencyFormatString(spec.currency as Record<string, unknown>);\n }\n if (spec.percent && typeof spec.percent === \"object\") {\n return percentFormatString(spec.percent as Record<string, unknown>);\n }\n if (spec.number && typeof spec.number === \"object\") {\n return numberFormatString(spec.number as Record<string, unknown>);\n }\n return undefined;\n}\n\nfunction currencyFormatString(c: Record<string, unknown>): string {\n const places = readDecimalPlaces(c) ?? 2;\n const codeRaw = c.currency_code;\n const code =\n typeof codeRaw === \"string\" && codeRaw.trim().length > 0\n ? codeRaw.toUpperCase()\n : \"USD\";\n const symbol = currencySymbol(code);\n return `${symbol}#,##0${fractionalSuffix(places)}`;\n}\n\nfunction percentFormatString(p: Record<string, unknown>): string {\n const places = readDecimalPlaces(p) ?? 0;\n return `0${fractionalSuffix(places)}%`;\n}\n\nfunction numberFormatString(n: Record<string, unknown>): string {\n const places = readDecimalPlaces(n) ?? 0;\n return `#,##0${fractionalSuffix(places)}`;\n}\n\nfunction fractionalSuffix(places: number): string {\n return places > 0 ? `.${\"0\".repeat(places)}` : \"\";\n}\n\n/**\n * Maximum decimal places honored from a format spec.\n */\nconst MAX_DECIMAL_PLACES = 100;\n\nfunction readDecimalPlaces(obj: Record<string, unknown>): number | undefined {\n const dp = obj.decimal_places;\n if (typeof dp === \"number\" && Number.isFinite(dp) && dp >= 0) {\n return Math.min(Math.floor(dp), MAX_DECIMAL_PLACES);\n }\n if (dp && typeof dp === \"object\" && !Array.isArray(dp)) {\n const places = (dp as Record<string, unknown>).places;\n if (typeof places === \"number\" && Number.isFinite(places) && places >= 0) {\n return Math.min(Math.floor(places), MAX_DECIMAL_PLACES);\n }\n }\n return undefined;\n}\n\n/**\n * Map ISO currency codes to their conventional prefix symbol. Unknown codes\n * fall back to the literal code + space.\n */\nconst CURRENCY_SYMBOLS: Record<string, string> = {\n USD: \"$\",\n EUR: \"€\",\n GBP: \"£\",\n JPY: \"¥\",\n CNY: \"¥\",\n INR: \"₹\",\n BRL: \"R$\",\n};\n\nfunction currencySymbol(code: string): string {\n return CURRENCY_SYMBOLS[code] ?? `${code} `;\n}\n\n/**\n * Infer the standard set of valid time grains for a dimension based on its\n * SQL data type.\n */\nfunction inferTimeGrains(type: string): string[] | undefined {\n const normalized = type\n .toLowerCase()\n .replace(/\\(.*\\)$/, \"\")\n .trim();\n if (\n normalized === \"timestamp\" ||\n normalized === \"timestamp_ltz\" ||\n normalized === \"timestamp_ntz\"\n ) {\n return [\"day\", \"hour\", \"minute\", \"month\", \"quarter\", \"week\", \"year\"];\n }\n if (normalized === \"date\") {\n return [\"day\", \"month\", \"quarter\", \"week\", \"year\"];\n }\n return undefined;\n}\n\n/**\n * Build a DescribeFetcher from a real WorkspaceClient + warehouseId.\n */\nexport function createWorkspaceDescribeFetcher(\n client: WorkspaceClient,\n warehouseId: string,\n): DescribeFetcher {\n // One format probe per fetcher (= per typegen run): the first DESCRIBE\n // discovers the warehouse's working format, every later one reuses it.\n const describeFormat: DescribeFormatMemo = {};\n return async (fqn: string) => {\n /**\n * Defense-in-depth: every caller passes a source that already cleared\n * {@link resolveMetricConfig}, but this fetcher is an exported seam — re-check\n * before interpolating into SQL.\n */\n if (!isValidFqn(fqn)) {\n throw new Error(\n `Invalid metric source \"${fqn}\": expected a three-part UC FQN <catalog>.<schema>.<metric_view>.`,\n );\n }\n // Escape + quote every segment before interpolation. isValidFqn already\n // rejects backticks/control chars for metric sources, so this is\n // belt-and-suspenders for the SQL-injection seam — and keeps the quoting\n // independent of the naming rule.\n const quotedFqn = quoteFqnForSql(fqn);\n return describeAdaptive(\n client,\n `DESCRIBE TABLE EXTENDED ${quotedFqn} AS JSON`,\n warehouseId,\n describeFormat,\n );\n };\n}\n"],"mappings":"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"}
|
|
@@ -19,7 +19,7 @@ function emptyMetricSchema(entry) {
|
|
|
19
19
|
}
|
|
20
20
|
const MV_DESCRIBE_CONCURRENCY = 10;
|
|
21
21
|
/**
|
|
22
|
-
* Run schema synchronization for every entry in `
|
|
22
|
+
* Run schema synchronization for every entry in `definitions.json`.
|
|
23
23
|
*/
|
|
24
24
|
async function syncMetrics(resolution, fetcher) {
|
|
25
25
|
const { entries } = resolution;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.js","names":[],"sources":["../../../src/type-generator/mv-registry/sync.ts"],"sourcesContent":["import { getErrorDiagnostic, isConnectivityError } from \"../errors\";\nimport type { DatabricksStatementExecutionResponse } from \"../types\";\nimport {\n extractMetricColumns,\n parseDescribeTableExtendedJson,\n} from \"./describe\";\nimport type {\n DescribeFetcher,\n MetricColumnMetadata,\n MetricConfigResolution,\n MetricSchema,\n MetricSyncFailure,\n MetricSyncResult,\n ResolvedMetricEntry,\n} from \"./types\";\n\n/**\n * Build the degraded schema emitted when an entry's columns are not\n * available — same key/source/lane as a real schema, with empty\n * measure/dimension allowlists and `degraded: true`.\n */\nexport function emptyMetricSchema(\n entry: Pick<MetricSchema, \"key\" | \"source\" | \"lane\">,\n): MetricSchema {\n return {\n key: entry.key,\n source: entry.source,\n lane: entry.lane,\n measures: [],\n dimensions: [],\n degraded: true,\n };\n}\n\n// Maximum number of in-flight DESCRIBE statements per syncMetrics pass.\nconst MV_DESCRIBE_CONCURRENCY = 10;\n\n// Outcome of describing a single metric entry.\ninterface MetricDescribeOutcome {\n index: number;\n schema: MetricSchema;\n failure?: MetricSyncFailure;\n}\n\n/**\n * Run schema synchronization for every entry in `
|
|
1
|
+
{"version":3,"file":"sync.js","names":[],"sources":["../../../src/type-generator/mv-registry/sync.ts"],"sourcesContent":["import { getErrorDiagnostic, isConnectivityError } from \"../errors\";\nimport type { DatabricksStatementExecutionResponse } from \"../types\";\nimport {\n extractMetricColumns,\n parseDescribeTableExtendedJson,\n} from \"./describe\";\nimport type {\n DescribeFetcher,\n MetricColumnMetadata,\n MetricConfigResolution,\n MetricSchema,\n MetricSyncFailure,\n MetricSyncResult,\n ResolvedMetricEntry,\n} from \"./types\";\n\n/**\n * Build the degraded schema emitted when an entry's columns are not\n * available — same key/source/lane as a real schema, with empty\n * measure/dimension allowlists and `degraded: true`.\n */\nexport function emptyMetricSchema(\n entry: Pick<MetricSchema, \"key\" | \"source\" | \"lane\">,\n): MetricSchema {\n return {\n key: entry.key,\n source: entry.source,\n lane: entry.lane,\n measures: [],\n dimensions: [],\n degraded: true,\n };\n}\n\n// Maximum number of in-flight DESCRIBE statements per syncMetrics pass.\nconst MV_DESCRIBE_CONCURRENCY = 10;\n\n// Outcome of describing a single metric entry.\ninterface MetricDescribeOutcome {\n index: number;\n schema: MetricSchema;\n failure?: MetricSyncFailure;\n}\n\n/**\n * Run schema synchronization for every entry in `definitions.json`.\n */\nexport async function syncMetrics(\n resolution: MetricConfigResolution,\n fetcher: DescribeFetcher,\n): Promise<MetricSyncResult> {\n const { entries } = resolution;\n const schemas = new Array<MetricSchema>(entries.length);\n const failureSlots = new Array<MetricSyncFailure | undefined>(entries.length);\n\n const failedOutcome = (\n index: number,\n entry: ResolvedMetricEntry,\n reason: string,\n transient: boolean,\n ): MetricDescribeOutcome => ({\n index,\n schema: emptyMetricSchema(entry),\n failure: { key: entry.key, source: entry.source, reason, transient },\n });\n\n const describeOne = async (\n entry: ResolvedMetricEntry,\n index: number,\n ): Promise<MetricDescribeOutcome> => {\n let response: DatabricksStatementExecutionResponse;\n try {\n response = await fetcher(entry.source);\n } catch (err) {\n const reason = `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(err)}`;\n // Connectivity blips self-converge (retry next pass); auth, a bad\n // warehouse id, a truncated / multi-chunk result, or a malformed request\n // are deterministic and must surface — the same split the query path makes.\n return failedOutcome(index, entry, reason, isConnectivityError(err));\n }\n\n const state = response.status?.state;\n if (state !== \"SUCCEEDED\" && state !== \"FAILED\") {\n return { index, schema: emptyMetricSchema(entry) };\n }\n\n let columns: MetricColumnMetadata[];\n try {\n const parsed = parseDescribeTableExtendedJson(response);\n columns = extractMetricColumns(parsed);\n } catch (err) {\n const reason = `Failed to extract columns from DESCRIBE response: ${(err as Error).message}`;\n return failedOutcome(index, entry, reason, false);\n }\n\n if (columns.length === 0) {\n const reason =\n \"DESCRIBE response yielded zero columns — check the response shape (top-level `columns` array or `schema.fields`).\";\n return failedOutcome(index, entry, reason, false);\n }\n\n const measures = columns.filter((c) => c.isMeasure);\n const dimensions = columns.filter((c) => !c.isMeasure);\n\n return {\n index,\n schema: {\n key: entry.key,\n source: entry.source,\n lane: entry.lane,\n measures,\n dimensions,\n },\n };\n };\n\n for (\n let offset = 0;\n offset < entries.length;\n offset += MV_DESCRIBE_CONCURRENCY\n ) {\n const slice = entries.slice(offset, offset + MV_DESCRIBE_CONCURRENCY);\n const settled = await Promise.allSettled(\n slice.map((entry, i) => describeOne(entry, offset + i)),\n );\n\n for (let i = 0; i < settled.length; i++) {\n const result = settled[i];\n if (result.status === \"fulfilled\") {\n const { index, schema, failure } = result.value;\n schemas[index] = schema;\n if (failure) {\n failureSlots[index] = failure;\n }\n } else {\n const index = offset + i;\n const entry = entries[index];\n const { schema, failure } = failedOutcome(\n index,\n entry,\n `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(result.reason)}`,\n isConnectivityError(result.reason),\n );\n schemas[index] = schema;\n failureSlots[index] = failure;\n }\n }\n }\n\n const failures = failureSlots.filter(\n (failure): failure is MetricSyncFailure => failure !== undefined,\n );\n\n return { schemas, failures };\n}\n"],"mappings":";;;;;;;;;AAqBA,SAAgB,kBACd,OACc;AACd,QAAO;EACL,KAAK,MAAM;EACX,QAAQ,MAAM;EACd,MAAM,MAAM;EACZ,UAAU,EAAE;EACZ,YAAY,EAAE;EACd,UAAU;EACX;;AAIH,MAAM,0BAA0B;;;;AAYhC,eAAsB,YACpB,YACA,SAC2B;CAC3B,MAAM,EAAE,YAAY;CACpB,MAAM,UAAU,IAAI,MAAoB,QAAQ,OAAO;CACvD,MAAM,eAAe,IAAI,MAAqC,QAAQ,OAAO;CAE7E,MAAM,iBACJ,OACA,OACA,QACA,eAC2B;EAC3B;EACA,QAAQ,kBAAkB,MAAM;EAChC,SAAS;GAAE,KAAK,MAAM;GAAK,QAAQ,MAAM;GAAQ;GAAQ;GAAW;EACrE;CAED,MAAM,cAAc,OAClB,OACA,UACmC;EACnC,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,QAAQ,MAAM,OAAO;WAC/B,KAAK;AAKZ,UAAO,cAAc,OAAO,OAJb,mCAAmC,mBAAmB,IAAI,IAI9B,oBAAoB,IAAI,CAAC;;EAGtE,MAAM,QAAQ,SAAS,QAAQ;AAC/B,MAAI,UAAU,eAAe,UAAU,SACrC,QAAO;GAAE;GAAO,QAAQ,kBAAkB,MAAM;GAAE;EAGpD,IAAI;AACJ,MAAI;AAEF,aAAU,qBADK,+BAA+B,SAAS,CACjB;WAC/B,KAAK;AAEZ,UAAO,cAAc,OAAO,OADb,qDAAsD,IAAc,WACxC,MAAM;;AAGnD,MAAI,QAAQ,WAAW,EAGrB,QAAO,cAAc,OAAO,OAD1B,qHACyC,MAAM;EAGnD,MAAM,WAAW,QAAQ,QAAQ,MAAM,EAAE,UAAU;EACnD,MAAM,aAAa,QAAQ,QAAQ,MAAM,CAAC,EAAE,UAAU;AAEtD,SAAO;GACL;GACA,QAAQ;IACN,KAAK,MAAM;IACX,QAAQ,MAAM;IACd,MAAM,MAAM;IACZ;IACA;IACD;GACF;;AAGH,MACE,IAAI,SAAS,GACb,SAAS,QAAQ,QACjB,UAAU,yBACV;EACA,MAAM,QAAQ,QAAQ,MAAM,QAAQ,SAAS,wBAAwB;EACrE,MAAM,UAAU,MAAM,QAAQ,WAC5B,MAAM,KAAK,OAAO,MAAM,YAAY,OAAO,SAAS,EAAE,CAAC,CACxD;AAED,OAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;GACvC,MAAM,SAAS,QAAQ;AACvB,OAAI,OAAO,WAAW,aAAa;IACjC,MAAM,EAAE,OAAO,QAAQ,YAAY,OAAO;AAC1C,YAAQ,SAAS;AACjB,QAAI,QACF,cAAa,SAAS;UAEnB;IACL,MAAM,QAAQ,SAAS;IACvB,MAAM,QAAQ,QAAQ;IACtB,MAAM,EAAE,QAAQ,YAAY,cAC1B,OACA,OACA,mCAAmC,mBAAmB,OAAO,OAAO,IACpE,oBAAoB,OAAO,OAAO,CACnC;AACD,YAAQ,SAAS;AACjB,iBAAa,SAAS;;;;AAS5B,QAAO;EAAE;EAAS,UAJD,aAAa,QAC3B,YAA0C,YAAY,OACxD;EAE2B"}
|
|
@@ -11,7 +11,11 @@ interface AppKitTypesPluginOptions {
|
|
|
11
11
|
* Defaults to a sibling of `outFile`, computed by the generator.
|
|
12
12
|
*/
|
|
13
13
|
mvOutFile?: string;
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Folders to watch for changes. Defaults to `config/queries` and
|
|
16
|
+
* `config/metric-views`. When overridden, include a `queries` folder and/or a
|
|
17
|
+
* `metric-views` folder — they are resolved by their trailing path segment.
|
|
18
|
+
*/
|
|
15
19
|
watchFolders?: string[];
|
|
16
20
|
}
|
|
17
21
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin.d.ts","names":[],"sources":["../../src/type-generator/vite-plugin.ts"],"mappings":";;;;;AAGmC;
|
|
1
|
+
{"version":3,"file":"vite-plugin.d.ts","names":[],"sources":["../../src/type-generator/vite-plugin.ts"],"mappings":";;;;;AAGmC;UA8BzB,wBAAA;EAER,OAAA;EAFgC;;;;EAOhC,SAAA;EAMY;AASd;;;;EATE,YAAA;AAAA;;;;;;;iBASc,iBAAA,CAAkB,OAAA,GAAU,wBAAA,GAA2B,MAAA"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLogger } from "../logging/logger.js";
|
|
2
|
+
import { METRIC_CONFIG_FILE } from "../shared/src/schemas/metric-fqn.js";
|
|
2
3
|
import { getWarehouseState, startWarehouse, waitUntilRunning } from "./warehouse-status.js";
|
|
3
4
|
import { ANALYTICS_TYPES_FILE, TYPES_DIR, TypegenFatalError, TypegenSyntaxError, generateFromEntryPoint } from "./index.js";
|
|
4
5
|
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
@@ -24,6 +25,8 @@ function appKitTypesPlugin(options) {
|
|
|
24
25
|
let outFile;
|
|
25
26
|
let mvOutFile;
|
|
26
27
|
let watchFolders;
|
|
28
|
+
let queryFolder;
|
|
29
|
+
let metricViewsFolder;
|
|
27
30
|
let inFlight = null;
|
|
28
31
|
let queued = false;
|
|
29
32
|
let pendingMode = "non-blocking";
|
|
@@ -48,7 +51,8 @@ function appKitTypesPlugin(options) {
|
|
|
48
51
|
}
|
|
49
52
|
await generateFromEntryPoint({
|
|
50
53
|
outFile,
|
|
51
|
-
queryFolder
|
|
54
|
+
queryFolder,
|
|
55
|
+
metricViewsFolder,
|
|
52
56
|
warehouseId,
|
|
53
57
|
noCache: false,
|
|
54
58
|
mode,
|
|
@@ -167,14 +171,25 @@ function appKitTypesPlugin(options) {
|
|
|
167
171
|
logger.debug("Warehouse ID not found. Skipping type generation.");
|
|
168
172
|
return false;
|
|
169
173
|
}
|
|
170
|
-
|
|
174
|
+
const hasQueries = existsSync(path.join(process.cwd(), "config", "queries"));
|
|
175
|
+
const hasMetricViews = existsSync(path.join(process.cwd(), "config", "metric-views"));
|
|
176
|
+
if (!hasQueries && !hasMetricViews) return false;
|
|
171
177
|
return true;
|
|
172
178
|
},
|
|
173
179
|
configResolved(config) {
|
|
174
180
|
const projectRoot = path.resolve(config.root, "..");
|
|
175
181
|
outFile = path.resolve(projectRoot, options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`);
|
|
176
182
|
mvOutFile = options?.mvOutFile !== void 0 ? path.resolve(projectRoot, options.mvOutFile) : void 0;
|
|
177
|
-
|
|
183
|
+
const defaultQueryFolder = path.join(process.cwd(), "config", "queries");
|
|
184
|
+
const defaultMetricViewsFolder = path.join(process.cwd(), "config", "metric-views");
|
|
185
|
+
watchFolders = options?.watchFolders ?? [defaultQueryFolder, defaultMetricViewsFolder];
|
|
186
|
+
if (options?.watchFolders) {
|
|
187
|
+
queryFolder = watchFolders.find((f) => path.basename(f) === "queries");
|
|
188
|
+
metricViewsFolder = watchFolders.find((f) => path.basename(f) === "metric-views");
|
|
189
|
+
} else {
|
|
190
|
+
queryFolder = defaultQueryFolder;
|
|
191
|
+
metricViewsFolder = defaultMetricViewsFolder;
|
|
192
|
+
}
|
|
178
193
|
},
|
|
179
194
|
buildStart() {
|
|
180
195
|
if (process.env.NODE_ENV === "production") return runGenerate("blocking");
|
|
@@ -184,7 +199,9 @@ function appKitTypesPlugin(options) {
|
|
|
184
199
|
configureServer(server) {
|
|
185
200
|
server.watcher.add(watchFolders);
|
|
186
201
|
server.watcher.on("change", (changedFile) => {
|
|
187
|
-
|
|
202
|
+
const isWatchedFile = watchFolders.some((folder) => changedFile.startsWith(folder));
|
|
203
|
+
const isMetricConfig = metricViewsFolder !== void 0 && path.basename(changedFile) === METRIC_CONFIG_FILE && path.dirname(path.resolve(changedFile)) === path.resolve(metricViewsFolder);
|
|
204
|
+
if (isWatchedFile && (changedFile.endsWith(".sql") || isMetricConfig)) {
|
|
188
205
|
runGenerate("non-blocking");
|
|
189
206
|
armWarehouseWatch();
|
|
190
207
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin.js","names":[],"sources":["../../src/type-generator/vite-plugin.ts"],"sourcesContent":["import { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { Plugin } from \"vite\";\nimport { createLogger } from \"../logging/logger\";\nimport {\n ANALYTICS_TYPES_FILE,\n generateFromEntryPoint,\n TYPES_DIR,\n TypegenFatalError,\n TypegenSyntaxError,\n} from \"./index\";\nimport type { PreflightMode } from \"./preflight\";\nimport {\n getWarehouseState,\n startWarehouse,\n waitUntilRunning,\n} from \"./warehouse-status\";\n\nconst logger = createLogger(\"type-generator:vite-plugin\");\n\n/**\n * How long the DEV background watcher waits for a STARTING warehouse to reach\n * RUNNING before giving up. Short relative to the CLI's preflight budget: this\n * is a best-effort \"regenerate once the warehouse warms up\" convenience, not a\n * gate, so we'd rather stop polling than hold a detached task open for minutes.\n */\nconst DEV_WAREHOUSE_WATCH_MAX_MS = 60_000;\n\n/**\n * Options for the AppKit types plugin.\n */\ninterface AppKitTypesPluginOptions {\n /* Path to the output d.ts file (relative to client folder). */\n outFile?: string;\n /**\n * Path to the metric registry d.ts file (relative to client folder).\n * Defaults to a sibling of `outFile`, computed by the generator.\n */\n mvOutFile?: string;\n /** Folders to watch for changes. */\n watchFolders?: string[];\n}\n\n/**\n * Vite plugin to generate types for AppKit queries.\n * Calls generateFromEntryPoint under the hood.\n * @param options - Options to override default values.\n * @returns Vite plugin to generate types for AppKit queries.\n */\nexport function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin {\n let outFile: string;\n let mvOutFile: string | undefined;\n let watchFolders: string[];\n\n // Single-flight state for runGenerate(). `inFlight` is the promise of the\n // currently-running drain (null when idle); `queued` records that a trigger\n // arrived while a run was active so exactly ONE trailing run fires afterwards\n // (latest-wins — coalesces any number of overlapping triggers into a single\n // rerun). `queued` is read/cleared synchronously inside the drain loop so a\n // trigger landing in any window is caught before the drain exits.\n //\n // `pendingMode` is the mode the next generate should run in (latest-wins, like\n // `queued`): the foreground build runs non-blocking in dev (instant degrade)\n // while the background warehouse watch runs blocking (real DESCRIBEs). A\n // blocking watch trigger that lands while a non-blocking foreground run is in\n // flight therefore still describes when its trailing run fires.\n let inFlight: Promise<void> | null = null;\n let queued = false;\n let pendingMode: PreflightMode = \"non-blocking\";\n\n // The currently-armed DEV background warehouse watch, if any. Aborting it\n // stops a pending waitUntilRunning (server shutdown, or a newer arm replacing\n // an older one).\n let watchController: AbortController | null = null;\n\n /**\n * Generate types once in the given preflight {@link PreflightMode}. Never\n * throws in dev (logs instead); in production it rethrows so the build fails.\n * This is the un-guarded core — callers should go through {@link runGenerate}\n * so concurrent triggers can't race-write the .d.ts.\n *\n * @param mode - preflight policy for this run. The foreground build passes a\n * NODE_ENV-derived mode (blocking in production, non-blocking in dev so it\n * degrades instantly); the background warehouse watch passes \"blocking\" so\n * its regenerate actually DESCRIBEs and lands real (non-degraded) types.\n */\n async function generateOnce(mode: PreflightMode) {\n try {\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n\n if (!warehouseId) {\n logger.debug(\"Warehouse ID not found. Skipping type generation.\");\n return;\n }\n\n await generateFromEntryPoint({\n outFile,\n queryFolder: watchFolders[0],\n warehouseId,\n noCache: false,\n mode,\n mvOutFile,\n });\n } catch (error) {\n // TypegenSyntaxError / TypegenFatalError carry a complete, actionable\n // report in their message. Their stack frames and attached query arrays\n // point into appkit internals and only add noise, so surface just the\n // message — both when failing the prod build and when logging in dev.\n const isTypegenError =\n error instanceof TypegenSyntaxError ||\n error instanceof TypegenFatalError;\n\n // throw in production to fail the build\n if (process.env.NODE_ENV === \"production\") {\n if (isTypegenError) error.stack = error.message;\n throw error;\n }\n\n if (isTypegenError) {\n logger.error(\"%s\", error.message);\n } else {\n logger.error(\"Error generating types: %O\", error);\n }\n }\n }\n\n /**\n * Single-flight wrapper around {@link generateOnce}. The initial build, the\n * .sql watcher, and the DEV warehouse watch all route through here so they can\n * never run typegen concurrently (which would race-write the .d.ts).\n *\n * If a run is already in flight, this does NOT start a second one — it records\n * the requested mode and sets a trailing flag so exactly one more run fires\n * after the current finishes, coalescing any number of overlapping triggers\n * (latest-wins, including the mode: a blocking watch trigger that arrives mid\n * non-blocking foreground run still describes when its trailing run fires).\n *\n * @param mode - preflight policy for this run. Recorded into `pendingMode`,\n * which the drain reads for each generate (latest trigger wins).\n * @returns A promise that resolves when this trigger's work (including any\n * trailing run it scheduled) has completed.\n */\n function runGenerate(mode: PreflightMode): Promise<void> {\n pendingMode = mode;\n\n if (inFlight) {\n // A run is active: remember that another trigger arrived and ride out the\n // current run. One trailing run then covers all coalesced triggers and\n // runs in the latest requested mode (recorded above).\n queued = true;\n return inFlight;\n }\n\n // Drain in a loop rather than recursing after a single queued-check: a\n // trigger can land in the window between generateOnce() resolving and the\n // check, so we re-test `queued` until it's clear. Critically, `inFlight` is\n // cleared synchronously in the SAME tick as the final `queued === false`\n // observation — never deferred to a .finally microtask — so there's no\n // window where a trigger sees `inFlight` set but the drain has already\n // decided to exit. The guard stays held for the whole drain, so concurrent\n // triggers only ever set the flag; they never start a parallel generate.\n const drain = async (): Promise<void> => {\n while (true) {\n queued = false;\n // Snapshot the mode synchronously alongside clearing `queued` so a\n // trigger landing during this generate is observed (via `queued`) on the\n // next loop with its own mode, not silently dropped.\n const runMode = pendingMode;\n await generateOnce(runMode);\n // Synchronous check + clear, atomic w.r.t. other (synchronous) callers.\n if (!queued) {\n inFlight = null;\n return;\n }\n }\n };\n\n inFlight = drain();\n return inFlight;\n }\n\n /**\n * DEV-only: get the warehouse to RUNNING in the background and regenerate with\n * real (non-degraded) types once it is — without blocking dev startup. The\n * foreground build only ever degrades in dev (instant `unknown`/cached types),\n * so this is what lands actual DESCRIBE results in the editor for EVERY\n * reachable warehouse state, not just one that happens to already be warm.\n *\n * Post-probe behaviour by state:\n * - RUNNING → describe right away (the dev foreground degraded, so a running\n * warehouse would otherwise never get real types). `waitUntilRunning`\n * returns immediately for an already-running warehouse, then the blocking\n * regenerate fires.\n * - STARTING → it's already coming up; just wait for RUNNING, then describe.\n * - STOPPED / STOPPING → kick off a start, wait for RUNNING, then describe.\n * - DELETED / DELETING → return (a deleted warehouse can't be started, and\n * blocking typegen would treat it as fatal); leave the degraded types.\n *\n * No-op in production or without a warehouse id. Replaces any previously-armed\n * watch (aborting it first). Fully self-contained: it never throws into the\n * caller and never re-arms itself. The whole lifecycle is abortable via the\n * shared {@link watchController} — its signal is threaded into\n * `waitUntilRunning`, so a dev-server shutdown cancels a pending wait — and the\n * regenerate routes through {@link runGenerate} so it can't race-write the\n * .d.ts with the foreground degrade or a `.sql` re-trigger.\n *\n * The regenerate runs in \"blocking\" mode (not the foreground's non-blocking)\n * so it actually DESCRIBEs the now-RUNNING warehouse and lands real types —\n * the whole point of warming the warehouse in the background.\n */\n function armWarehouseWatch(): void {\n if (process.env.NODE_ENV === \"production\") return;\n\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n if (!warehouseId) return;\n\n // Supersede any in-flight watch so we never run two concurrently.\n watchController?.abort();\n const controller = new AbortController();\n watchController = controller;\n const { signal } = controller;\n\n void (async () => {\n try {\n const client = new WorkspaceClient({});\n const state = await getWarehouseState(client, warehouseId);\n\n // A deleted/deleting warehouse can't be started and blocking typegen\n // would treat it as fatal — leave the degraded types and stop. Every\n // other state (including RUNNING) proceeds to wait-then-describe so the\n // dev editor gets real types, not just the foreground's degraded ones.\n if (state === \"DELETED\" || state === \"DELETING\") {\n return;\n }\n\n // Stopped/stopping won't reach RUNNING on its own — nudge it. RUNNING and\n // STARTING need no start (RUNNING is already up; STARTING is coming up),\n // so don't issue a redundant one. A failed start is non-fatal: give up\n // silently rather than throw out of the detached task (the developer\n // still has degraded/cached types).\n let startedByUs = false;\n if (state === \"STOPPED\" || state === \"STOPPING\") {\n try {\n logger.debug(\"Warehouse is %s; starting it.\", state);\n await startWarehouse(client, warehouseId);\n startedByUs = true;\n } catch {\n return;\n }\n }\n\n // Wait for RUNNING. For an already-RUNNING warehouse this returns on the\n // first poll; for STARTING/STOPPED it polls (abortably) until the\n // warehouse warms up, a terminal state, or the deadline.\n const final = await waitUntilRunning(client, warehouseId, {\n maxMs: DEV_WAREHOUSE_WATCH_MAX_MS,\n signal,\n // We just issued the start, so the first poll(s) often still report\n // STOPPED/STOPPING before the start propagates. Poll through those\n // instead of bailing, or the regenerate would never fire. When we\n // didn't start it (RUNNING/STARTING branch), keep the default terminal\n // states.\n treatStoppedAsTransient: startedByUs,\n });\n\n if (final === \"RUNNING\" && !signal.aborted) {\n logger.debug(\"Warehouse is RUNNING; regenerating types.\");\n // Blocking: the warehouse is RUNNING now, so describe it and emit real\n // (non-degraded) types — unlike the foreground dev run, which degraded.\n // Routed through the single-flight guard so it coalesces with the\n // foreground degrade / any `.sql` re-trigger instead of racing them.\n await runGenerate(\"blocking\");\n }\n } catch {\n // Detached background task: any failure (timeout, abort, connectivity,\n // auth) is non-fatal — the developer still has degraded/cached types.\n }\n })();\n }\n\n return {\n name: \"appkit-types\",\n\n apply() {\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n\n if (!warehouseId) {\n logger.debug(\"Warehouse ID not found. Skipping type generation.\");\n return false;\n }\n\n if (!existsSync(path.join(process.cwd(), \"config\", \"queries\"))) {\n return false;\n }\n\n return true;\n },\n\n configResolved(config) {\n const projectRoot = path.resolve(config.root, \"..\");\n outFile = path.resolve(\n projectRoot,\n options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`,\n );\n // The metric out-path resolves against projectRoot only when explicitly\n // provided; an unset option passes through as undefined so the generator\n // computes its sibling-of-outFile default. In the all-defaults case the\n // final path is identical (the default outFile above lives in\n // shared/<TYPES_DIR>/), and a customized outFile now keeps its metric\n // sibling next to it instead of pinning it under shared/.\n mvOutFile =\n options?.mvOutFile !== undefined\n ? path.resolve(projectRoot, options.mvOutFile)\n : undefined;\n watchFolders = options?.watchFolders ?? [\n path.join(process.cwd(), \"config\", \"queries\"),\n ];\n },\n\n buildStart() {\n // Production: block the build on this generate (and surface failures).\n // The watch is a dev-only no-op, so just run typegen.\n if (process.env.NODE_ENV === \"production\") {\n return runGenerate(\"blocking\");\n }\n\n // Dev: don't block startup waiting on typegen. The foreground generate runs\n // non-blocking — it skips the warehouse entirely and writes degraded\n // (cached/`unknown`) types instantly. Then arm the warehouse watch so the\n // warehouse gets a one-shot BLOCKING regenerate (real types) in the\n // background for EVERY reachable state: RUNNING describes right away, while\n // STARTING/STOPPED are waited (and started) until they reach RUNNING.\n void runGenerate(\"non-blocking\");\n armWarehouseWatch();\n },\n\n configureServer(server) {\n server.watcher.add(watchFolders);\n\n server.watcher.on(\"change\", (changedFile) => {\n const isWatchedFile = watchFolders.some((folder) =>\n changedFile.startsWith(folder),\n );\n\n if (\n isWatchedFile &&\n (changedFile.endsWith(\".sql\") ||\n // Basename equality, not endsWith: a sibling like\n // \"legacy-metric-views.json\" must not trigger a regenerate —\n // only the real config file does.\n path.basename(changedFile) === \"metric-views.json\")\n ) {\n // Route through the single-flight runner (was fire-and-forget\n // generate(), which could race the initial build / watch). This is a\n // dev-only hook, so degrade instantly (non-blocking), then re-arm the\n // warehouse watch so the edited query or metric-view source is\n // re-described in the background against the running warehouse (or\n // once a still-starting one warms up), landing fresh\n // blocking-described types.\n void runGenerate(\"non-blocking\");\n armWarehouseWatch();\n }\n });\n\n // Tear down any pending warehouse watch when the dev server closes so a\n // long backoff can't keep the process alive after shutdown.\n server.httpServer?.once(\"close\", () => {\n watchController?.abort();\n });\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"vite-plugin.js","names":[],"sources":["../../src/type-generator/vite-plugin.ts"],"sourcesContent":["import { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { Plugin } from \"vite\";\nimport { METRIC_CONFIG_FILE } from \"../../../shared/src/schemas/metric-fqn\";\nimport { createLogger } from \"../logging/logger\";\nimport {\n ANALYTICS_TYPES_FILE,\n generateFromEntryPoint,\n TYPES_DIR,\n TypegenFatalError,\n TypegenSyntaxError,\n} from \"./index\";\nimport type { PreflightMode } from \"./preflight\";\nimport {\n getWarehouseState,\n startWarehouse,\n waitUntilRunning,\n} from \"./warehouse-status\";\n\nconst logger = createLogger(\"type-generator:vite-plugin\");\n\n/**\n * How long the DEV background watcher waits for a STARTING warehouse to reach\n * RUNNING before giving up. Short relative to the CLI's preflight budget: this\n * is a best-effort \"regenerate once the warehouse warms up\" convenience, not a\n * gate, so we'd rather stop polling than hold a detached task open for minutes.\n */\nconst DEV_WAREHOUSE_WATCH_MAX_MS = 60_000;\n\n/**\n * Options for the AppKit types plugin.\n */\ninterface AppKitTypesPluginOptions {\n /* Path to the output d.ts file (relative to client folder). */\n outFile?: string;\n /**\n * Path to the metric registry d.ts file (relative to client folder).\n * Defaults to a sibling of `outFile`, computed by the generator.\n */\n mvOutFile?: string;\n /**\n * Folders to watch for changes. Defaults to `config/queries` and\n * `config/metric-views`. When overridden, include a `queries` folder and/or a\n * `metric-views` folder — they are resolved by their trailing path segment.\n */\n watchFolders?: string[];\n}\n\n/**\n * Vite plugin to generate types for AppKit queries.\n * Calls generateFromEntryPoint under the hood.\n * @param options - Options to override default values.\n * @returns Vite plugin to generate types for AppKit queries.\n */\nexport function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin {\n let outFile: string;\n let mvOutFile: string | undefined;\n let watchFolders: string[];\n // The queries + metric-views config folders, resolved in `configResolved`.\n // Passed explicitly into generateFromEntryPoint so neither is inferred from\n // `watchFolders` ordering (which used to assume queries was `watchFolders[0]`).\n let queryFolder: string | undefined;\n let metricViewsFolder: string | undefined;\n\n // Single-flight state for runGenerate(). `inFlight` is the promise of the\n // currently-running drain (null when idle); `queued` records that a trigger\n // arrived while a run was active so exactly ONE trailing run fires afterwards\n // (latest-wins — coalesces any number of overlapping triggers into a single\n // rerun). `queued` is read/cleared synchronously inside the drain loop so a\n // trigger landing in any window is caught before the drain exits.\n //\n // `pendingMode` is the mode the next generate should run in (latest-wins, like\n // `queued`): the foreground build runs non-blocking in dev (instant degrade)\n // while the background warehouse watch runs blocking (real DESCRIBEs). A\n // blocking watch trigger that lands while a non-blocking foreground run is in\n // flight therefore still describes when its trailing run fires.\n let inFlight: Promise<void> | null = null;\n let queued = false;\n let pendingMode: PreflightMode = \"non-blocking\";\n\n // The currently-armed DEV background warehouse watch, if any. Aborting it\n // stops a pending waitUntilRunning (server shutdown, or a newer arm replacing\n // an older one).\n let watchController: AbortController | null = null;\n\n /**\n * Generate types once in the given preflight {@link PreflightMode}. Never\n * throws in dev (logs instead); in production it rethrows so the build fails.\n * This is the un-guarded core — callers should go through {@link runGenerate}\n * so concurrent triggers can't race-write the .d.ts.\n *\n * @param mode - preflight policy for this run. The foreground build passes a\n * NODE_ENV-derived mode (blocking in production, non-blocking in dev so it\n * degrades instantly); the background warehouse watch passes \"blocking\" so\n * its regenerate actually DESCRIBEs and lands real (non-degraded) types.\n */\n async function generateOnce(mode: PreflightMode) {\n try {\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n\n if (!warehouseId) {\n logger.debug(\"Warehouse ID not found. Skipping type generation.\");\n return;\n }\n\n await generateFromEntryPoint({\n outFile,\n queryFolder,\n metricViewsFolder,\n warehouseId,\n noCache: false,\n mode,\n mvOutFile,\n });\n } catch (error) {\n // TypegenSyntaxError / TypegenFatalError carry a complete, actionable\n // report in their message. Their stack frames and attached query arrays\n // point into appkit internals and only add noise, so surface just the\n // message — both when failing the prod build and when logging in dev.\n const isTypegenError =\n error instanceof TypegenSyntaxError ||\n error instanceof TypegenFatalError;\n\n // throw in production to fail the build\n if (process.env.NODE_ENV === \"production\") {\n if (isTypegenError) error.stack = error.message;\n throw error;\n }\n\n if (isTypegenError) {\n logger.error(\"%s\", error.message);\n } else {\n logger.error(\"Error generating types: %O\", error);\n }\n }\n }\n\n /**\n * Single-flight wrapper around {@link generateOnce}. The initial build, the\n * .sql watcher, and the DEV warehouse watch all route through here so they can\n * never run typegen concurrently (which would race-write the .d.ts).\n *\n * If a run is already in flight, this does NOT start a second one — it records\n * the requested mode and sets a trailing flag so exactly one more run fires\n * after the current finishes, coalescing any number of overlapping triggers\n * (latest-wins, including the mode: a blocking watch trigger that arrives mid\n * non-blocking foreground run still describes when its trailing run fires).\n *\n * @param mode - preflight policy for this run. Recorded into `pendingMode`,\n * which the drain reads for each generate (latest trigger wins).\n * @returns A promise that resolves when this trigger's work (including any\n * trailing run it scheduled) has completed.\n */\n function runGenerate(mode: PreflightMode): Promise<void> {\n pendingMode = mode;\n\n if (inFlight) {\n // A run is active: remember that another trigger arrived and ride out the\n // current run. One trailing run then covers all coalesced triggers and\n // runs in the latest requested mode (recorded above).\n queued = true;\n return inFlight;\n }\n\n // Drain in a loop rather than recursing after a single queued-check: a\n // trigger can land in the window between generateOnce() resolving and the\n // check, so we re-test `queued` until it's clear. Critically, `inFlight` is\n // cleared synchronously in the SAME tick as the final `queued === false`\n // observation — never deferred to a .finally microtask — so there's no\n // window where a trigger sees `inFlight` set but the drain has already\n // decided to exit. The guard stays held for the whole drain, so concurrent\n // triggers only ever set the flag; they never start a parallel generate.\n const drain = async (): Promise<void> => {\n while (true) {\n queued = false;\n // Snapshot the mode synchronously alongside clearing `queued` so a\n // trigger landing during this generate is observed (via `queued`) on the\n // next loop with its own mode, not silently dropped.\n const runMode = pendingMode;\n await generateOnce(runMode);\n // Synchronous check + clear, atomic w.r.t. other (synchronous) callers.\n if (!queued) {\n inFlight = null;\n return;\n }\n }\n };\n\n inFlight = drain();\n return inFlight;\n }\n\n /**\n * DEV-only: get the warehouse to RUNNING in the background and regenerate with\n * real (non-degraded) types once it is — without blocking dev startup. The\n * foreground build only ever degrades in dev (instant `unknown`/cached types),\n * so this is what lands actual DESCRIBE results in the editor for EVERY\n * reachable warehouse state, not just one that happens to already be warm.\n *\n * Post-probe behaviour by state:\n * - RUNNING → describe right away (the dev foreground degraded, so a running\n * warehouse would otherwise never get real types). `waitUntilRunning`\n * returns immediately for an already-running warehouse, then the blocking\n * regenerate fires.\n * - STARTING → it's already coming up; just wait for RUNNING, then describe.\n * - STOPPED / STOPPING → kick off a start, wait for RUNNING, then describe.\n * - DELETED / DELETING → return (a deleted warehouse can't be started, and\n * blocking typegen would treat it as fatal); leave the degraded types.\n *\n * No-op in production or without a warehouse id. Replaces any previously-armed\n * watch (aborting it first). Fully self-contained: it never throws into the\n * caller and never re-arms itself. The whole lifecycle is abortable via the\n * shared {@link watchController} — its signal is threaded into\n * `waitUntilRunning`, so a dev-server shutdown cancels a pending wait — and the\n * regenerate routes through {@link runGenerate} so it can't race-write the\n * .d.ts with the foreground degrade or a `.sql` re-trigger.\n *\n * The regenerate runs in \"blocking\" mode (not the foreground's non-blocking)\n * so it actually DESCRIBEs the now-RUNNING warehouse and lands real types —\n * the whole point of warming the warehouse in the background.\n */\n function armWarehouseWatch(): void {\n if (process.env.NODE_ENV === \"production\") return;\n\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n if (!warehouseId) return;\n\n // Supersede any in-flight watch so we never run two concurrently.\n watchController?.abort();\n const controller = new AbortController();\n watchController = controller;\n const { signal } = controller;\n\n void (async () => {\n try {\n const client = new WorkspaceClient({});\n const state = await getWarehouseState(client, warehouseId);\n\n // A deleted/deleting warehouse can't be started and blocking typegen\n // would treat it as fatal — leave the degraded types and stop. Every\n // other state (including RUNNING) proceeds to wait-then-describe so the\n // dev editor gets real types, not just the foreground's degraded ones.\n if (state === \"DELETED\" || state === \"DELETING\") {\n return;\n }\n\n // Stopped/stopping won't reach RUNNING on its own — nudge it. RUNNING and\n // STARTING need no start (RUNNING is already up; STARTING is coming up),\n // so don't issue a redundant one. A failed start is non-fatal: give up\n // silently rather than throw out of the detached task (the developer\n // still has degraded/cached types).\n let startedByUs = false;\n if (state === \"STOPPED\" || state === \"STOPPING\") {\n try {\n logger.debug(\"Warehouse is %s; starting it.\", state);\n await startWarehouse(client, warehouseId);\n startedByUs = true;\n } catch {\n return;\n }\n }\n\n // Wait for RUNNING. For an already-RUNNING warehouse this returns on the\n // first poll; for STARTING/STOPPED it polls (abortably) until the\n // warehouse warms up, a terminal state, or the deadline.\n const final = await waitUntilRunning(client, warehouseId, {\n maxMs: DEV_WAREHOUSE_WATCH_MAX_MS,\n signal,\n // We just issued the start, so the first poll(s) often still report\n // STOPPED/STOPPING before the start propagates. Poll through those\n // instead of bailing, or the regenerate would never fire. When we\n // didn't start it (RUNNING/STARTING branch), keep the default terminal\n // states.\n treatStoppedAsTransient: startedByUs,\n });\n\n if (final === \"RUNNING\" && !signal.aborted) {\n logger.debug(\"Warehouse is RUNNING; regenerating types.\");\n // Blocking: the warehouse is RUNNING now, so describe it and emit real\n // (non-degraded) types — unlike the foreground dev run, which degraded.\n // Routed through the single-flight guard so it coalesces with the\n // foreground degrade / any `.sql` re-trigger instead of racing them.\n await runGenerate(\"blocking\");\n }\n } catch {\n // Detached background task: any failure (timeout, abort, connectivity,\n // auth) is non-fatal — the developer still has degraded/cached types.\n }\n })();\n }\n\n return {\n name: \"appkit-types\",\n\n apply() {\n const warehouseId = process.env.DATABRICKS_WAREHOUSE_ID || \"\";\n\n if (!warehouseId) {\n logger.debug(\"Warehouse ID not found. Skipping type generation.\");\n return false;\n }\n\n // Run when either config surface exists. Metric-view types are\n // independent of `.sql` queries, so a metric-only project (a\n // `config/metric-views/` with no `config/queries/`) must still activate\n // the plugin.\n const hasQueries = existsSync(\n path.join(process.cwd(), \"config\", \"queries\"),\n );\n const hasMetricViews = existsSync(\n path.join(process.cwd(), \"config\", \"metric-views\"),\n );\n if (!hasQueries && !hasMetricViews) {\n return false;\n }\n\n return true;\n },\n\n configResolved(config) {\n const projectRoot = path.resolve(config.root, \"..\");\n outFile = path.resolve(\n projectRoot,\n options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`,\n );\n // The metric out-path resolves against projectRoot only when explicitly\n // provided; an unset option passes through as undefined so the generator\n // computes its sibling-of-outFile default. In the all-defaults case the\n // final path is identical (the default outFile above lives in\n // shared/<TYPES_DIR>/), and a customized outFile now keeps its metric\n // sibling next to it instead of pinning it under shared/.\n mvOutFile =\n options?.mvOutFile !== undefined\n ? path.resolve(projectRoot, options.mvOutFile)\n : undefined;\n\n const defaultQueryFolder = path.join(process.cwd(), \"config\", \"queries\");\n const defaultMetricViewsFolder = path.join(\n process.cwd(),\n \"config\",\n \"metric-views\",\n );\n watchFolders = options?.watchFolders ?? [\n defaultQueryFolder,\n defaultMetricViewsFolder,\n ];\n\n // Resolve the two config folders explicitly rather than assuming a\n // position in `watchFolders`. With a custom `watchFolders`, match by the\n // trailing segment; otherwise use the computed defaults.\n if (options?.watchFolders) {\n queryFolder = watchFolders.find((f) => path.basename(f) === \"queries\");\n metricViewsFolder = watchFolders.find(\n (f) => path.basename(f) === \"metric-views\",\n );\n } else {\n queryFolder = defaultQueryFolder;\n metricViewsFolder = defaultMetricViewsFolder;\n }\n },\n\n buildStart() {\n // Production: block the build on this generate (and surface failures).\n // The watch is a dev-only no-op, so just run typegen.\n if (process.env.NODE_ENV === \"production\") {\n return runGenerate(\"blocking\");\n }\n\n // Dev: don't block startup waiting on typegen. The foreground generate runs\n // non-blocking — it skips the warehouse entirely and writes degraded\n // (cached/`unknown`) types instantly. Then arm the warehouse watch so the\n // warehouse gets a one-shot BLOCKING regenerate (real types) in the\n // background for EVERY reachable state: RUNNING describes right away, while\n // STARTING/STOPPED are waited (and started) until they reach RUNNING.\n void runGenerate(\"non-blocking\");\n armWarehouseWatch();\n },\n\n configureServer(server) {\n server.watcher.add(watchFolders);\n\n server.watcher.on(\"change\", (changedFile) => {\n const isWatchedFile = watchFolders.some((folder) =>\n changedFile.startsWith(folder),\n );\n\n // The metric config is `definitions.json` — a far more generic name\n // than the old `metric-views.json`. Match it by DIRECTORY, not bare\n // basename: only a `definitions.json` sitting directly in the\n // metric-views folder is the config (a `definitions.json` elsewhere in\n // a watched tree must not trigger a regenerate).\n const isMetricConfig =\n metricViewsFolder !== undefined &&\n path.basename(changedFile) === METRIC_CONFIG_FILE &&\n path.dirname(path.resolve(changedFile)) ===\n path.resolve(metricViewsFolder);\n\n if (isWatchedFile && (changedFile.endsWith(\".sql\") || isMetricConfig)) {\n // Route through the single-flight runner (was fire-and-forget\n // generate(), which could race the initial build / watch). This is a\n // dev-only hook, so degrade instantly (non-blocking), then re-arm the\n // warehouse watch so the edited query or metric-view source is\n // re-described in the background against the running warehouse (or\n // once a still-starting one warms up), landing fresh\n // blocking-described types.\n void runGenerate(\"non-blocking\");\n armWarehouseWatch();\n }\n });\n\n // Tear down any pending warehouse watch when the dev server closes so a\n // long backoff can't keep the process alive after shutdown.\n server.httpServer?.once(\"close\", () => {\n watchController?.abort();\n });\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -88,13 +88,13 @@ In blocking mode the generator starts a stopped warehouse, waits (bounded) for i
|
|
|
88
88
|
|
|
89
89
|
## Metric-view types[](#metric-view-types "Direct link to Metric-view types")
|
|
90
90
|
|
|
91
|
-
`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/
|
|
91
|
+
`generate-types` (and the Vite plugin) emit metric-view types **additively** — there is no separate command. When a `config/metric-views/definitions.json` file is present, the same run that generates your query types also DESCRIBEs each declared [UC Metric View](./docs/plugins/analytics.md) and writes `metric-views.d.ts` into `shared/appkit-types/`:
|
|
92
92
|
|
|
93
93
|
* `metric-views.d.ts` — augments the `MetricRegistry` interface so `useMetricView('<key>', …)` is autocompleted and type-checked. Each view's measures, dimensions, and their semantic metadata (SQL type, display name, format, time grains) are encoded at the type level.
|
|
94
94
|
|
|
95
|
-
If `metric-views.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` that same situation fails the build so CI never ships incomplete metric types. A malformed `
|
|
95
|
+
If `config/metric-views/definitions.json` is absent the metric path stays dormant (nothing is emitted). When present it follows the **same** warehouse-readiness contract as query types: in the default non-blocking run a view that can't be described yet — a cold warehouse, or a bad/unreachable source — is written with permissive types and a warning, while under `--wait` that same situation fails the build so CI never ships incomplete metric types. A malformed `definitions.json` (invalid JSON, or a source that isn't a three-part UC FQN) fails fast in every mode.
|
|
96
96
|
|
|
97
|
-
`
|
|
97
|
+
`definitions.json` is keyed by metric key; each entry names the three-part UC FQN of the view and, optionally, the executor it runs as (`app_service_principal`, the default, or `user`):
|
|
98
98
|
|
|
99
99
|
```json
|
|
100
100
|
{
|
|
@@ -86,12 +86,154 @@ The analytics plugin exposes these endpoints (mounted under `/api/analytics`):
|
|
|
86
86
|
|
|
87
87
|
* `POST /api/analytics/query/:query_key`
|
|
88
88
|
* `GET /api/analytics/arrow-result/:jobId`
|
|
89
|
+
* `POST /api/analytics/metric/:key` — measure a Unity Catalog Metric View (see [Metric views](#metric-views))
|
|
89
90
|
|
|
90
91
|
## Format options[](#format-options "Direct link to Format options")
|
|
91
92
|
|
|
92
93
|
* `format: "JSON"` (default) returns JSON rows
|
|
93
94
|
* `format: "ARROW"` returns an Arrow "statement\_id" payload over SSE, then the client fetches binary Arrow from `/api/analytics/arrow-result/:jobId`
|
|
94
95
|
|
|
96
|
+
## Metric views[](#metric-views "Direct link to Metric views")
|
|
97
|
+
|
|
98
|
+
`POST /api/analytics/metric/:key` measures a [Unity Catalog Metric View](https://docs.databricks.com/en/metric-views/index.html) that you declared in `config/metric-views/definitions.json`. Instead of writing SQL, the caller sends a structured request — which measures to aggregate, which dimensions to group by, and an optional filter — and the plugin builds and runs the `SELECT MEASURE(...) ... GROUP BY ALL` for you against the view.
|
|
99
|
+
|
|
100
|
+
The route is **dormant until `config/metric-views/definitions.json` exists**: with no config file, every metric key returns `404`. Declaring the file (and generating types) is covered in [Metric-view types](./docs/development/type-generation.md#metric-view-types); this section documents the runtime endpoint that config activates.
|
|
101
|
+
|
|
102
|
+
### Request body[](#request-body "Direct link to Request body")
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
POST /api/analytics/metric/:key
|
|
106
|
+
Content-Type: application/json
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"measures": ["arr", "revenue"],
|
|
110
|
+
"dimensions": ["region", "order_date"],
|
|
111
|
+
"timeGrain": "month",
|
|
112
|
+
"timeDimension": "order_date",
|
|
113
|
+
"filter": { "member": "region", "operator": "in", "values": ["EMEA", "APAC"] },
|
|
114
|
+
"limit": 100
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`:key` is a metric key from `definitions.json`. The body fields:
|
|
120
|
+
|
|
121
|
+
| Field | Type | Required | Description |
|
|
122
|
+
| --------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `measures` | `string[]` | yes | Measures to aggregate. At least 1, at most 50. Each becomes `MEASURE(<name>) AS <name>`. |
|
|
124
|
+
| `dimensions` | `string[]` | no | Dimensions to group by (max 20). Selected verbatim and grouped via `GROUP BY ALL`. |
|
|
125
|
+
| `filter` | object | no | Structured predicate tree translated into a parameterized `WHERE` clause (see [Filters](#filters)). |
|
|
126
|
+
| `timeGrain` | `string` | no | Bucket a time dimension via `date_trunc('<grain>', …)` — e.g. `day`, `month`. Requires `timeDimension`. |
|
|
127
|
+
| `timeDimension` | `string` | no | The single dimension `timeGrain` buckets. Must be one of `dimensions`. Required whenever `timeGrain` is set. |
|
|
128
|
+
| `limit` | `number` | no | Positive integer row cap (max 100000). |
|
|
129
|
+
| `format` | `string` | no | `JSON_ARRAY` (default). `JSON` is accepted as a deprecated alias for it; Arrow formats (`ARROW`, `ARROW_STREAM`) are rejected on this route. |
|
|
130
|
+
|
|
131
|
+
Measures and dimensions must be unique across both lists — a name cannot repeat, nor appear as both a measure and a dimension.
|
|
132
|
+
|
|
133
|
+
### How the request becomes SQL[](#how-the-request-becomes-sql "Direct link to How the request becomes SQL")
|
|
134
|
+
|
|
135
|
+
Given a view registered as `catalog.schema.revenue_metrics`, the request above produces (measures and dimensions are sorted for a deterministic SELECT list):
|
|
136
|
+
|
|
137
|
+
```sql
|
|
138
|
+
SELECT MEASURE(`arr`) AS `arr`, MEASURE(`revenue`) AS `revenue`,
|
|
139
|
+
date_trunc('month', `order_date`) AS `order_date`, `region`
|
|
140
|
+
FROM `catalog`.`schema`.`revenue_metrics`
|
|
141
|
+
WHERE `region` IN (:f_0, :f_1)
|
|
142
|
+
GROUP BY ALL
|
|
143
|
+
LIMIT 100
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The metric view's FQN and every measure/dimension identifier are backtick-quoted; filter values are bound as parameters (`:f_0`, `:f_1`, …), never interpolated into the SQL string.
|
|
148
|
+
|
|
149
|
+
### Filters[](#filters "Direct link to Filters")
|
|
150
|
+
|
|
151
|
+
`filter` is a recursive tree. A leaf is a single predicate:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "member": "region", "operator": "equals", "values": ["EMEA"] }
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Predicates combine with `and` / `or` groups, which can nest:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"and": [
|
|
163
|
+
{ "member": "region", "operator": "in", "values": ["EMEA", "APAC"] },
|
|
164
|
+
{
|
|
165
|
+
"or": [
|
|
166
|
+
{ "member": "segment", "operator": "equals", "values": ["Enterprise"] },
|
|
167
|
+
{ "member": "deal_size", "operator": "gt", "values": [50000] }
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The operator vocabulary:
|
|
176
|
+
|
|
177
|
+
| Operator | SQL | Values |
|
|
178
|
+
| --------------------------- | ----------------------- | ------------------ |
|
|
179
|
+
| `equals` | `=` | exactly one |
|
|
180
|
+
| `notEquals` | `<>` | exactly one |
|
|
181
|
+
| `in` | `IN (…)` | one or more |
|
|
182
|
+
| `notIn` | `NOT IN (…)` | one or more |
|
|
183
|
+
| `gt` / `gte` / `lt` / `lte` | `>` / `>=` / `<` / `<=` | exactly one |
|
|
184
|
+
| `contains` | `LIKE :param` | exactly one string |
|
|
185
|
+
| `notContains` | `NOT LIKE :param` | exactly one string |
|
|
186
|
+
| `set` | `IS NOT NULL` | none |
|
|
187
|
+
| `notSet` | `IS NULL` | none |
|
|
188
|
+
|
|
189
|
+
For `contains` / `notContains`, the `%…%` wildcards are applied to the *bound parameter value* (`%value%`), not written into the SQL text — so the value is never interpolated, consistent with every other operator.
|
|
190
|
+
|
|
191
|
+
Empty groups of either kind (`{ "or": [] }`, `{ "and": [] }`) are rejected with `400` — every `and` / `or` group must contain at least one predicate. To send no filter, omit the `filter` field entirely rather than passing an empty group.
|
|
192
|
+
|
|
193
|
+
Filters are bounded to keep hostile input from exhausting the server: nesting depth ≤ 8, ≤ 100 children per `and` / `or` group, and ≤ 1000 values per predicate. A request that exceeds a cap is rejected with `400`.
|
|
194
|
+
|
|
195
|
+
### Executors (cache scope)[](#executors-cache-scope "Direct link to Executors (cache scope)")
|
|
196
|
+
|
|
197
|
+
Each entry in `definitions.json` names the executor the query runs as, which also sets the cache scope. This is fixed by config, not the request:
|
|
198
|
+
|
|
199
|
+
| `executor` | Runs as | Cache |
|
|
200
|
+
| --------------------------------- | ---------------------------------- | ----------------------- |
|
|
201
|
+
| `app_service_principal` (default) | The app service principal | Shared across all users |
|
|
202
|
+
| `user` | The requesting user (on-behalf-of) | Per user |
|
|
203
|
+
|
|
204
|
+
This mirrors the `<key>.sql` vs `<key>.obo.sql` distinction for [file-based queries](#execution-context).
|
|
205
|
+
|
|
206
|
+
### Response[](#response "Direct link to Response")
|
|
207
|
+
|
|
208
|
+
The response is the same SSE stream as `POST /api/analytics/query/:query_key`. If the SQL warehouse is cold it first emits `warehouse_status` events (see [Warehouse readiness](#warehouse-readiness)), then a single `result` event with the rows as objects:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"type": "result",
|
|
213
|
+
"data": [
|
|
214
|
+
{
|
|
215
|
+
"region": "EMEA",
|
|
216
|
+
"order_date": "2025-01-01",
|
|
217
|
+
"arr": 1200000,
|
|
218
|
+
"revenue": 340000
|
|
219
|
+
}
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
On failure it emits an `error` event instead.
|
|
226
|
+
|
|
227
|
+
### Errors and behavior[](#errors-and-behavior "Direct link to Errors and behavior")
|
|
228
|
+
|
|
229
|
+
| Status | Body | When |
|
|
230
|
+
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| `404` | `{ "error": "Metric not found" }` | `:key` is not declared in `definitions.json` (also the response for every key when the file is absent). |
|
|
232
|
+
| `400` | `{ "error": "Invalid metric request body (fields: …)", "code": … }` | The request body fails validation. The message names only the offending field paths, never the submitted values. |
|
|
233
|
+
| `503` | `{ "error": "Metric registry not available", "code": "METRIC_REGISTRY_LOAD_FAILED" }` | `definitions.json` is present but malformed or unreadable. |
|
|
234
|
+
|
|
235
|
+
Editing `definitions.json` is picked up on the next request — no server restart is needed. A previously malformed file that you fix likewise starts working on the next request.
|
|
236
|
+
|
|
95
237
|
## Frontend usage[](#frontend-usage "Direct link to Frontend usage")
|
|
96
238
|
|
|
97
239
|
### useAnalyticsQuery[](#useanalyticsquery "Direct link to useAnalyticsQuery")
|
|
@@ -101,7 +243,11 @@ React hook that subscribes to an analytics query over SSE and returns its latest
|
|
|
101
243
|
```ts
|
|
102
244
|
import { useAnalyticsQuery } from "@databricks/appkit-ui/react";
|
|
103
245
|
|
|
104
|
-
const { data, loading, error } = useAnalyticsQuery(
|
|
246
|
+
const { data, loading, error } = useAnalyticsQuery(
|
|
247
|
+
queryKey,
|
|
248
|
+
parameters,
|
|
249
|
+
options,
|
|
250
|
+
);
|
|
105
251
|
|
|
106
252
|
```
|
|
107
253
|
|
|
@@ -109,8 +255,8 @@ const { data, loading, error } = useAnalyticsQuery(queryKey, parameters, options
|
|
|
109
255
|
|
|
110
256
|
```ts
|
|
111
257
|
{
|
|
112
|
-
data: T | null;
|
|
113
|
-
loading: boolean;
|
|
258
|
+
data: T | null; // query result (typed array for JSON, TypedArrowTable for ARROW)
|
|
259
|
+
loading: boolean; // true while the query is executing
|
|
114
260
|
error: string | null; // error message, or null on success
|
|
115
261
|
warehouseStatus: WarehouseStatus | null; // see "Warehouse readiness" below
|
|
116
262
|
}
|
|
@@ -139,8 +285,10 @@ This means a cold start no longer freezes the UI on a stalled spinner. Render th
|
|
|
139
285
|
import { useAnalyticsQuery } from "@databricks/appkit-ui/react";
|
|
140
286
|
|
|
141
287
|
function SpendTable() {
|
|
142
|
-
const { data, loading, error, warehouseStatus } =
|
|
143
|
-
|
|
288
|
+
const { data, loading, error, warehouseStatus } = useAnalyticsQuery(
|
|
289
|
+
"spend_summary",
|
|
290
|
+
params,
|
|
291
|
+
);
|
|
144
292
|
|
|
145
293
|
if (warehouseStatus && warehouseStatus.state !== "RUNNING") {
|
|
146
294
|
return <div>Warehouse is {warehouseStatus.state.toLowerCase()}…</div>;
|
|
@@ -184,10 +332,7 @@ export function AppShell({ children }) {
|
|
|
184
332
|
If you already render your own `<Toaster />` for unrelated app toasts, drop the indicator and call `useResourceStatusToaster()` instead so resource-status toasts share that single Toaster:
|
|
185
333
|
|
|
186
334
|
```tsx
|
|
187
|
-
import {
|
|
188
|
-
useResourceStatusToaster,
|
|
189
|
-
Toaster,
|
|
190
|
-
} from "@databricks/appkit-ui/react";
|
|
335
|
+
import { useResourceStatusToaster, Toaster } from "@databricks/appkit-ui/react";
|
|
191
336
|
|
|
192
337
|
function App() {
|
|
193
338
|
useResourceStatusToaster();
|
|
@@ -207,7 +352,8 @@ For a fully custom toast body, pass `render` (rendered through `toast.custom`):
|
|
|
207
352
|
<ResourceStatusIndicator
|
|
208
353
|
render={(agg) => (
|
|
209
354
|
<div className="rounded-lg border bg-background p-3 shadow">
|
|
210
|
-
{agg.worst?.kind} {agg.worst?.state.toLowerCase()} ({agg.activeCount}
|
|
355
|
+
{agg.worst?.kind} {agg.worst?.state.toLowerCase()} ({agg.activeCount}{" "}
|
|
356
|
+
waiting)
|
|
211
357
|
</div>
|
|
212
358
|
)}
|
|
213
359
|
/>
|
|
@@ -221,8 +367,7 @@ To override copy for a specific kind without rewriting the whole UI, pass `rende
|
|
|
221
367
|
renderers={{
|
|
222
368
|
warehouse: {
|
|
223
369
|
title: () => "Spinning up your data",
|
|
224
|
-
description: (_s, agg) =>
|
|
225
|
-
`${agg.affectedLabels.length} chart(s) waiting`,
|
|
370
|
+
description: (_s, agg) => `${agg.affectedLabels.length} chart(s) waiting`,
|
|
226
371
|
},
|
|
227
372
|
}}
|
|
228
373
|
/>
|
|
@@ -254,11 +399,9 @@ import { useEffect, useId } from "react";
|
|
|
254
399
|
|
|
255
400
|
function useLakebaseReadiness() {
|
|
256
401
|
const id = useId();
|
|
257
|
-
const { publish, unpublish } = useResourceStatusPublisher(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
{ kindHint: "lakebase" },
|
|
261
|
-
);
|
|
402
|
+
const { publish, unpublish } = useResourceStatusPublisher(id, "lakebase", {
|
|
403
|
+
kindHint: "lakebase",
|
|
404
|
+
});
|
|
262
405
|
|
|
263
406
|
useEffect(() => {
|
|
264
407
|
publish({
|
|
@@ -288,21 +431,27 @@ import { sql } from "@databricks/appkit-ui/js";
|
|
|
288
431
|
import { Skeleton } from "@databricks/appkit-ui";
|
|
289
432
|
|
|
290
433
|
function SpendTable() {
|
|
291
|
-
const params = useMemo(
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
434
|
+
const params = useMemo(
|
|
435
|
+
() => ({
|
|
436
|
+
startDate: sql.date("2025-01-01"),
|
|
437
|
+
endDate: sql.date("2025-12-31"),
|
|
438
|
+
}),
|
|
439
|
+
[],
|
|
440
|
+
);
|
|
295
441
|
|
|
296
442
|
const { data, loading, error } = useAnalyticsQuery("spend_summary", params);
|
|
297
443
|
|
|
298
444
|
if (loading) return <Skeleton className="h-32 w-full" />;
|
|
299
445
|
if (error) return <div className="text-destructive">{error}</div>;
|
|
300
|
-
if (!data?.length)
|
|
446
|
+
if (!data?.length)
|
|
447
|
+
return <div className="text-muted-foreground">No results</div>;
|
|
301
448
|
|
|
302
449
|
return (
|
|
303
450
|
<ul>
|
|
304
451
|
{data.map((row) => (
|
|
305
|
-
<li key={row.id}>
|
|
452
|
+
<li key={row.id}>
|
|
453
|
+
{row.name}: ${row.cost_usd}
|
|
454
|
+
</li>
|
|
306
455
|
))}
|
|
307
456
|
</ul>
|
|
308
457
|
);
|