@databricks/appkit 0.41.5 → 0.41.6

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 (32) hide show
  1. package/dist/appkit/package.js +1 -1
  2. package/dist/shared/src/schemas/metric-fqn.js +85 -0
  3. package/dist/shared/src/schemas/metric-fqn.js.map +1 -0
  4. package/dist/type-generator/cache.js +33 -1
  5. package/dist/type-generator/cache.js.map +1 -1
  6. package/dist/type-generator/errors.js +100 -0
  7. package/dist/type-generator/errors.js.map +1 -0
  8. package/dist/type-generator/index.js +190 -2
  9. package/dist/type-generator/index.js.map +1 -1
  10. package/dist/type-generator/mv-registry/config.js +131 -0
  11. package/dist/type-generator/mv-registry/config.js.map +1 -0
  12. package/dist/type-generator/mv-registry/describe.js +230 -0
  13. package/dist/type-generator/mv-registry/describe.js.map +1 -0
  14. package/dist/type-generator/mv-registry/metadata.js +40 -0
  15. package/dist/type-generator/mv-registry/metadata.js.map +1 -0
  16. package/dist/type-generator/mv-registry/render-types.js +118 -0
  17. package/dist/type-generator/mv-registry/render-types.js.map +1 -0
  18. package/dist/type-generator/mv-registry/sync.js +96 -0
  19. package/dist/type-generator/mv-registry/sync.js.map +1 -0
  20. package/dist/type-generator/query-registry.js +126 -88
  21. package/dist/type-generator/query-registry.js.map +1 -1
  22. package/dist/type-generator/statement-result.js +116 -0
  23. package/dist/type-generator/statement-result.js.map +1 -0
  24. package/dist/type-generator/types.js.map +1 -1
  25. package/dist/type-generator/vite-plugin.d.ts +12 -0
  26. package/dist/type-generator/vite-plugin.d.ts.map +1 -1
  27. package/dist/type-generator/vite-plugin.js +11 -5
  28. package/dist/type-generator/vite-plugin.js.map +1 -1
  29. package/docs/development/type-generation.md +4 -0
  30. package/docs/plugins/analytics.md +15 -0
  31. package/package.json +2 -1
  32. package/sbom.cdx.json +1 -1
@@ -0,0 +1,118 @@
1
+ //#region src/type-generator/mv-registry/render-types.ts
2
+ /**
3
+ * @todo unify with query-registry.ts
4
+ * Map a Databricks SQL type to a TypeScript primitive.
5
+ * Centralized here (not imported from query-registry) so this module
6
+ * stays self-contained.
7
+ */
8
+ function tsTypeFor(sqlType) {
9
+ switch (sqlType.toUpperCase().replace(/\(.*\)$/, "").replace(/<.*>$/, "").split(" ")[0]) {
10
+ case "BOOLEAN": return "boolean";
11
+ case "TINYINT":
12
+ case "SMALLINT":
13
+ case "INT":
14
+ case "INTEGER":
15
+ case "BIGINT":
16
+ case "FLOAT":
17
+ case "DOUBLE":
18
+ case "DECIMAL":
19
+ case "NUMERIC": return "number";
20
+ default: return "string";
21
+ }
22
+ }
23
+ function renderMetricEntry(schema) {
24
+ if (schema.degraded) return renderDegradedMetricEntry(schema);
25
+ const indent = " ";
26
+ const colsBlock = (cols) => {
27
+ if (cols.length === 0) return "Record<string, never>";
28
+ return `{
29
+ ${cols.map((col) => {
30
+ const grainComment = col.timeGrains?.length ? ` @timeGrain ${col.timeGrains.join("|")}` : "";
31
+ return `${indent}/** @sqlType ${col.type.replace(/\*\//g, "* /")}${grainComment} */
32
+ ${indent}${JSON.stringify(col.name)}: ${tsTypeFor(col.type)}`;
33
+ }).join(";\n")};
34
+ }`;
35
+ };
36
+ const unionOf = (keys) => keys.length > 0 ? keys.join(" | ") : "never";
37
+ const measuresBlock = colsBlock(schema.measures);
38
+ const dimensionsBlock = colsBlock(schema.dimensions);
39
+ const measureUnion = unionOf(schema.measures.map((m) => JSON.stringify(m.name)));
40
+ const dimensionUnion = unionOf(schema.dimensions.map((d) => JSON.stringify(d.name)));
41
+ const timeGrainSet = /* @__PURE__ */ new Set();
42
+ for (const d of schema.dimensions) for (const g of d.timeGrains ?? []) timeGrainSet.add(g);
43
+ const timeGrainUnion = timeGrainSet.size > 0 ? [...timeGrainSet].sort().map((g) => JSON.stringify(g)).join(" | ") : "never";
44
+ const measureMetadata = renderMetadataMap(schema.measures, indent);
45
+ const dimensionMetadata = renderMetadataMap(schema.dimensions, indent, true);
46
+ return ` ${JSON.stringify(schema.key)}: {
47
+ key: ${JSON.stringify(schema.key)};
48
+ source: ${JSON.stringify(schema.source)};
49
+ lane: ${JSON.stringify(schema.lane)};
50
+ measures: ${measuresBlock};
51
+ dimensions: ${dimensionsBlock};
52
+ measureKeys: ${measureUnion};
53
+ dimensionKeys: ${dimensionUnion};
54
+ timeGrains: ${timeGrainUnion};
55
+ metadata: {
56
+ measures: ${measureMetadata};
57
+ dimensions: ${dimensionMetadata};
58
+ };
59
+ }`;
60
+ }
61
+ function renderDegradedMetricEntry(schema) {
62
+ return ` /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */
63
+ ${JSON.stringify(schema.key)}: {
64
+ key: ${JSON.stringify(schema.key)};
65
+ source: ${JSON.stringify(schema.source)};
66
+ lane: ${JSON.stringify(schema.lane)};
67
+ measures: Record<string, unknown>;
68
+ dimensions: Record<string, unknown>;
69
+ measureKeys: string;
70
+ dimensionKeys: string;
71
+ timeGrains: string;
72
+ metadata: {
73
+ measures: Record<string, never>;
74
+ dimensions: Record<string, never>;
75
+ };
76
+ }`;
77
+ }
78
+ function renderMetadataMap(cols, indent, includeTimeGrain = false) {
79
+ if (cols.length === 0) return "Record<string, never>";
80
+ return `{
81
+ ${cols.map((col) => {
82
+ const fields = [`type: ${JSON.stringify(col.type)}`];
83
+ if (col.displayName) fields.push(`display_name: ${JSON.stringify(col.displayName)}`);
84
+ if (col.format) fields.push(`format: ${JSON.stringify(col.format)}`);
85
+ if (col.description) fields.push(`description: ${JSON.stringify(col.description)}`);
86
+ if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) {
87
+ const grainTuple = col.timeGrains.map((g) => JSON.stringify(g)).join(", ");
88
+ fields.push(`time_grain: readonly [${grainTuple}]`);
89
+ }
90
+ const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(";\n");
91
+ return `${indent}${JSON.stringify(col.name)}: {
92
+ ${fieldsBlock};
93
+ ${indent}}`;
94
+ }).join(";\n")};
95
+ }`;
96
+ }
97
+ function renderMetricRegistry(schemas) {
98
+ if (schemas.length === 0) return `declare module "@databricks/appkit-ui/react" {
99
+ interface MetricRegistry {}
100
+ }
101
+ `;
102
+ return `declare module "@databricks/appkit-ui/react" {
103
+ interface MetricRegistry {
104
+ ${schemas.map(renderMetricEntry).join(";\n")};
105
+ }
106
+ }
107
+ `;
108
+ }
109
+ function generateMetricTypeDeclarations(schemas) {
110
+ return `// Auto-generated by AppKit - DO NOT EDIT
111
+ // Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build
112
+ import "@databricks/appkit-ui/react";
113
+ ${renderMetricRegistry(schemas)}`;
114
+ }
115
+
116
+ //#endregion
117
+ export { generateMetricTypeDeclarations };
118
+ //# sourceMappingURL=render-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-types.js","names":[],"sources":["../../../src/type-generator/mv-registry/render-types.ts"],"sourcesContent":["import type { MetricColumnMetadata, MetricSchema } from \"./types\";\n\n/**\n * @todo unify with query-registry.ts\n * Map a Databricks SQL type to a TypeScript primitive.\n * Centralized here (not imported from query-registry) so this module\n * stays self-contained.\n */\nfunction tsTypeFor(sqlType: string): string {\n const normalized = sqlType\n .toUpperCase()\n .replace(/\\(.*\\)$/, \"\")\n .replace(/<.*>$/, \"\")\n .split(\" \")[0];\n\n switch (normalized) {\n case \"BOOLEAN\":\n return \"boolean\";\n case \"TINYINT\":\n case \"SMALLINT\":\n case \"INT\":\n case \"INTEGER\":\n case \"BIGINT\":\n case \"FLOAT\":\n case \"DOUBLE\":\n case \"DECIMAL\":\n case \"NUMERIC\":\n return \"number\";\n default:\n return \"string\";\n }\n}\n\n// Render a MetricRegistry interface entry from a MetricSchema.\nfunction renderMetricEntry(schema: MetricSchema): string {\n if (schema.degraded) {\n return renderDegradedMetricEntry(schema);\n }\n const indent = \" \";\n const colsBlock = (cols: MetricColumnMetadata[]): string => {\n if (cols.length === 0) return \"Record<string, never>\";\n const fields = cols\n .map((col) => {\n const grainComment = col.timeGrains?.length\n ? ` @timeGrain ${col.timeGrains.join(\"|\")}`\n : \"\";\n return `${indent}/** @sqlType ${col.type.replace(/\\*\\//g, \"* /\")}${grainComment} */\n${indent}${JSON.stringify(col.name)}: ${tsTypeFor(col.type)}`;\n })\n .join(\";\\n\");\n return `{\n${fields};\n }`;\n };\n const unionOf = (keys: string[]): string =>\n keys.length > 0 ? keys.join(\" | \") : \"never\";\n\n const measuresBlock = colsBlock(schema.measures);\n const dimensionsBlock = colsBlock(schema.dimensions);\n const measureUnion = unionOf(\n schema.measures.map((m) => JSON.stringify(m.name)),\n );\n const dimensionUnion = unionOf(\n schema.dimensions.map((d) => JSON.stringify(d.name)),\n );\n\n const timeGrainSet = new Set<string>();\n for (const d of schema.dimensions) {\n for (const g of d.timeGrains ?? []) {\n timeGrainSet.add(g);\n }\n }\n const timeGrainUnion =\n timeGrainSet.size > 0\n ? [...timeGrainSet]\n .sort()\n .map((g) => JSON.stringify(g))\n .join(\" | \")\n : \"never\";\n\n const measureMetadata = renderMetadataMap(schema.measures, indent);\n const dimensionMetadata = renderMetadataMap(schema.dimensions, indent, true);\n\n return ` ${JSON.stringify(schema.key)}: {\n key: ${JSON.stringify(schema.key)};\n source: ${JSON.stringify(schema.source)};\n lane: ${JSON.stringify(schema.lane)};\n measures: ${measuresBlock};\n dimensions: ${dimensionsBlock};\n measureKeys: ${measureUnion};\n dimensionKeys: ${dimensionUnion};\n timeGrains: ${timeGrainUnion};\n metadata: {\n measures: ${measureMetadata};\n dimensions: ${dimensionMetadata};\n };\n }`;\n}\n\n// Render the permissive (\"degraded-open\") entry for a schema the warehouse could not describe.\nfunction renderDegradedMetricEntry(schema: MetricSchema): string {\n return ` /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */\n ${JSON.stringify(schema.key)}: {\n key: ${JSON.stringify(schema.key)};\n source: ${JSON.stringify(schema.source)};\n lane: ${JSON.stringify(schema.lane)};\n measures: Record<string, unknown>;\n dimensions: Record<string, unknown>;\n measureKeys: string;\n dimensionKeys: string;\n timeGrains: string;\n metadata: {\n measures: Record<string, never>;\n dimensions: Record<string, never>;\n };\n }`;\n}\n\n// Render the type-level shape of a column's semantic-metadata map\n// for the `metadata` field of a MetricRegistry entry.\nfunction renderMetadataMap(\n cols: MetricColumnMetadata[],\n indent: string,\n includeTimeGrain = false,\n): string {\n if (cols.length === 0) return \"Record<string, never>\";\n\n const inner = cols\n .map((col) => {\n const fields: string[] = [`type: ${JSON.stringify(col.type)}`];\n if (col.displayName) {\n fields.push(`display_name: ${JSON.stringify(col.displayName)}`);\n }\n if (col.format) {\n fields.push(`format: ${JSON.stringify(col.format)}`);\n }\n if (col.description) {\n fields.push(`description: ${JSON.stringify(col.description)}`);\n }\n if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) {\n const grainTuple = col.timeGrains\n .map((g) => JSON.stringify(g))\n .join(\", \");\n fields.push(`time_grain: readonly [${grainTuple}]`);\n }\n const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(\";\\n\");\n return `${indent}${JSON.stringify(col.name)}: {\n${fieldsBlock};\n${indent}}`;\n })\n .join(\";\\n\");\n\n return `{\n${inner};\n }`;\n}\n\n// Render the augmentation block for the appkit-ui MetricRegistry interface.\nfunction renderMetricRegistry(schemas: MetricSchema[]): string {\n if (schemas.length === 0) {\n return `declare module \"@databricks/appkit-ui/react\" {\n interface MetricRegistry {}\n}\n`;\n }\n const entries = schemas.map(renderMetricEntry).join(\";\\n\");\n return `declare module \"@databricks/appkit-ui/react\" {\n interface MetricRegistry {\n${entries};\n }\n}\n`;\n}\n\n// Build the full metric.d.ts file from a list of metric schemas.\nexport function generateMetricTypeDeclarations(\n schemas: MetricSchema[],\n): string {\n return `// Auto-generated by AppKit - DO NOT EDIT\n// Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build\nimport \"@databricks/appkit-ui/react\";\n${renderMetricRegistry(schemas)}`;\n}\n"],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ import { getErrorDiagnostic, isConnectivityError } from "../errors.js";
2
+ import { extractMetricColumns, parseDescribeTableExtendedJson } from "./describe.js";
3
+
4
+ //#region src/type-generator/mv-registry/sync.ts
5
+ /**
6
+ * Build the degraded schema emitted when an entry's columns are not
7
+ * available — same key/source/lane as a real schema, with empty
8
+ * measure/dimension allowlists and `degraded: true`.
9
+ */
10
+ function emptyMetricSchema(entry) {
11
+ return {
12
+ key: entry.key,
13
+ source: entry.source,
14
+ lane: entry.lane,
15
+ measures: [],
16
+ dimensions: [],
17
+ degraded: true
18
+ };
19
+ }
20
+ const MV_DESCRIBE_CONCURRENCY = 10;
21
+ /**
22
+ * Run schema synchronization for every entry in `metric-views.json`.
23
+ */
24
+ async function syncMetrics(resolution, fetcher) {
25
+ const { entries } = resolution;
26
+ const schemas = new Array(entries.length);
27
+ const failureSlots = new Array(entries.length);
28
+ const failedOutcome = (index, entry, reason, transient) => ({
29
+ index,
30
+ schema: emptyMetricSchema(entry),
31
+ failure: {
32
+ key: entry.key,
33
+ source: entry.source,
34
+ reason,
35
+ transient
36
+ }
37
+ });
38
+ const describeOne = async (entry, index) => {
39
+ let response;
40
+ try {
41
+ response = await fetcher(entry.source);
42
+ } catch (err) {
43
+ return failedOutcome(index, entry, `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(err)}`, isConnectivityError(err));
44
+ }
45
+ const state = response.status?.state;
46
+ if (state !== "SUCCEEDED" && state !== "FAILED") return {
47
+ index,
48
+ schema: emptyMetricSchema(entry)
49
+ };
50
+ let columns;
51
+ try {
52
+ columns = extractMetricColumns(parseDescribeTableExtendedJson(response));
53
+ } catch (err) {
54
+ return failedOutcome(index, entry, `Failed to extract columns from DESCRIBE response: ${err.message}`, false);
55
+ }
56
+ if (columns.length === 0) return failedOutcome(index, entry, "DESCRIBE response yielded zero columns — check the response shape (top-level `columns` array or `schema.fields`).", false);
57
+ const measures = columns.filter((c) => c.isMeasure);
58
+ const dimensions = columns.filter((c) => !c.isMeasure);
59
+ return {
60
+ index,
61
+ schema: {
62
+ key: entry.key,
63
+ source: entry.source,
64
+ lane: entry.lane,
65
+ measures,
66
+ dimensions
67
+ }
68
+ };
69
+ };
70
+ for (let offset = 0; offset < entries.length; offset += MV_DESCRIBE_CONCURRENCY) {
71
+ const slice = entries.slice(offset, offset + MV_DESCRIBE_CONCURRENCY);
72
+ const settled = await Promise.allSettled(slice.map((entry, i) => describeOne(entry, offset + i)));
73
+ for (let i = 0; i < settled.length; i++) {
74
+ const result = settled[i];
75
+ if (result.status === "fulfilled") {
76
+ const { index, schema, failure } = result.value;
77
+ schemas[index] = schema;
78
+ if (failure) failureSlots[index] = failure;
79
+ } else {
80
+ const index = offset + i;
81
+ const entry = entries[index];
82
+ const { schema, failure } = failedOutcome(index, entry, `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(result.reason)}`, isConnectivityError(result.reason));
83
+ schemas[index] = schema;
84
+ failureSlots[index] = failure;
85
+ }
86
+ }
87
+ }
88
+ return {
89
+ schemas,
90
+ failures: failureSlots.filter((failure) => failure !== void 0)
91
+ };
92
+ }
93
+
94
+ //#endregion
95
+ export { emptyMetricSchema, syncMetrics };
96
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +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 `metric-views.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"}
@@ -1,5 +1,7 @@
1
1
  import { createLogger } from "../logging/logger.js";
2
2
  import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache.js";
3
+ import { getErrorDiagnostic, isConnectivityError } from "./errors.js";
4
+ import { describeAdaptive } from "./statement-result.js";
3
5
  import { decidePreflight } from "./preflight.js";
4
6
  import { Spinner } from "./spinner.js";
5
7
  import { sqlTypeToHelper, sqlTypeToMarker } from "./types.js";
@@ -66,83 +68,6 @@ function parseError(raw) {
66
68
  } catch {}
67
69
  return { message: raw };
68
70
  }
69
- function isObject(value) {
70
- return typeof value === "object" && value !== null;
71
- }
72
- function getErrorMessage(error) {
73
- if (error instanceof Error) return error.message;
74
- if (isObject(error) && typeof error.message === "string") return error.message;
75
- return String(error);
76
- }
77
- function getErrorDiagnostic(error) {
78
- const seen = /* @__PURE__ */ new Set();
79
- const messages = [];
80
- const stack = [error];
81
- while (stack.length > 0) {
82
- const current = stack.pop();
83
- if (current === void 0 || seen.has(current)) continue;
84
- seen.add(current);
85
- const message = getErrorMessage(current);
86
- if (message && message !== "[object Object]" && !messages.includes(message)) messages.push(message);
87
- const code = getErrorCode(current);
88
- if (code && !messages.includes(code)) messages.push(code);
89
- stack.push(...getErrorChildren(current));
90
- }
91
- return messages.length > 0 ? messages.join(": ") : getErrorMessage(error);
92
- }
93
- function getErrorCode(error) {
94
- if (!isObject(error)) return void 0;
95
- const code = error.code ?? error.errno;
96
- return typeof code === "string" ? code : void 0;
97
- }
98
- function getErrorStatus(error) {
99
- if (!isObject(error)) return void 0;
100
- const direct = error.status ?? error.statusCode;
101
- if (typeof direct === "number") return direct;
102
- if (isObject(error.response) && typeof error.response.status === "number") return error.response.status;
103
- }
104
- function getErrorChildren(error) {
105
- if (!isObject(error)) return [];
106
- const children = [];
107
- if ("cause" in error) children.push(error.cause);
108
- if (error instanceof AggregateError) children.push(...error.errors);
109
- return children;
110
- }
111
- const CONNECTIVITY_ERROR_CODES = new Set([
112
- "ECONNREFUSED",
113
- "ECONNRESET",
114
- "ENOTFOUND",
115
- "ETIMEDOUT",
116
- "EAI_AGAIN",
117
- "EAI_NODATA",
118
- "EAI_NONAME",
119
- "EHOSTUNREACH",
120
- "ENETUNREACH",
121
- "CERT_HAS_EXPIRED",
122
- "DEPTH_ZERO_SELF_SIGNED_CERT",
123
- "ERR_TLS_CERT_ALTNAME_INVALID",
124
- "SELF_SIGNED_CERT_IN_CHAIN",
125
- "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
126
- ]);
127
- function isConnectivityMessage(message) {
128
- return /\bconnection (?:refused|reset|timed out)\b/i.test(message) || /\bsocket hang up\b/i.test(message) || /\bnetwork error\b/i.test(message) || /\bcan'?t connect to\b/i.test(message) || /\bcertificate has expired\b/i.test(message) || /\bunable to verify the first certificate\b/i.test(message) || /\bupstream connect error or disconnect\/reset before headers\b/i.test(message);
129
- }
130
- function isConnectivityError(error) {
131
- const seen = /* @__PURE__ */ new Set();
132
- const stack = [error];
133
- while (stack.length > 0) {
134
- const current = stack.pop();
135
- if (current === void 0 || seen.has(current)) continue;
136
- seen.add(current);
137
- const code = getErrorCode(current);
138
- if (code && (CONNECTIVITY_ERROR_CODES.has(code) || code.startsWith("UND_ERR_"))) return true;
139
- const status = getErrorStatus(current);
140
- if (status === 502 || status === 503 || status === 504) return true;
141
- if (isConnectivityMessage(getErrorMessage(current))) return true;
142
- stack.push(...getErrorChildren(current));
143
- }
144
- return false;
145
- }
146
71
  /**
147
72
  * Extract parameters from a SQL query
148
73
  * @param sql - the SQL query to extract parameters from
@@ -180,7 +105,7 @@ function convertToQueryType(result, sql, queryName) {
180
105
  const resultFields = columns.map((column) => {
181
106
  const mappedType = typeMap[normalizeTypeName(column.type_name)] || "unknown";
182
107
  const name = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(column.name) ? column.name : `"${column.name}"`;
183
- return `${column.comment ? `/** ${column.comment} */\n ` : `/** @sqlType ${column.type_name} */\n `}${name}: ${mappedType}`;
108
+ return `${column.comment ? `/** ${column.comment.replace(/\*\//g, "* /")} */\n ` : `/** @sqlType ${column.type_name.replace(/\*\//g, "* /")} */\n `}${name}: ${mappedType}`;
184
109
  });
185
110
  const hasResults = resultFields.length > 0;
186
111
  return {
@@ -215,9 +140,11 @@ function degradedType(cache, queryName, sql, sqlHash) {
215
140
  const prior = cache.queries[queryName];
216
141
  return prior?.hash === sqlHash && !prior.retry ? prior.type : generateUnknownResultQuery(sql, queryName);
217
142
  }
143
+ const PARAM_TYPE_ALTERNATION = "STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP_NTZ|TIMESTAMP|BINARY";
218
144
  function extractParameterTypes(sql) {
219
145
  const paramTypes = {};
220
- const matches = sql.matchAll(/--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP_NTZ|TIMESTAMP|BINARY)\b/gi);
146
+ const regex = new RegExp(`--\\s*@param\\s+(\\w+)\\s+(${PARAM_TYPE_ALTERNATION})\\b`, "gi");
147
+ const matches = sql.matchAll(regex);
221
148
  for (const match of matches) {
222
149
  const [, paramName, paramType] = match;
223
150
  paramTypes[paramName] = paramType.toUpperCase();
@@ -244,6 +171,120 @@ function defaultForType(sqlType) {
244
171
  }
245
172
  }
246
173
  /**
174
+ * True when `raw` is already a single, well-formed SQL single-quoted string
175
+ * literal — i.e. it opens and closes with `'` and every interior quote is part
176
+ * of an escaped `''` pair. `'2024-01-01'` and `'O''Brien'` qualify;
177
+ * `'a' OR 1=1 OR 'b'` does not (it has lone interior quotes), so it is treated
178
+ * as raw content and re-escaped rather than trusted. A backslash also
179
+ * disqualifies it: Databricks/Spark treats `\` as an escape inside literals (so
180
+ * `'a\'` is unterminated), and we never trust such input — it is re-escaped.
181
+ */
182
+ function isWellFormedStringLiteral(raw) {
183
+ if (raw.length < 2 || !raw.startsWith("'") || !raw.endsWith("'")) return false;
184
+ const inner = raw.slice(1, -1);
185
+ if (inner.includes("\\")) return false;
186
+ return !inner.replace(/''/g, "").includes("'");
187
+ }
188
+ /**
189
+ * Format a user-supplied sample value as a SQL literal for substitution into
190
+ * the build-time DESCRIBE statement. Returns `null` when the value isn't valid
191
+ * for its type, so the caller falls back to the safe type-based placeholder
192
+ * instead of substituting attacker-controllable text.
193
+ *
194
+ * The value comes from a `.sql` file that may be shared via a template or
195
+ * dependency, so it must not be able to inject SQL into `DESCRIBE QUERY`:
196
+ * - string-like types are always emitted as one well-formed, fully-escaped
197
+ * single-quoted literal (a pre-quoted literal is kept as-is, anything else is
198
+ * quoted with both `\` and `'` doubled so neither can terminate the literal),
199
+ * so the value can never break out of the string;
200
+ * - numeric / boolean / binary values must match a strict literal shape and are
201
+ * rejected (`null`) otherwise, rather than being passed through verbatim.
202
+ */
203
+ function formatSampleValue(sqlType, raw) {
204
+ switch (sqlType?.toUpperCase()) {
205
+ case "STRING":
206
+ case "DATE":
207
+ case "TIMESTAMP":
208
+ case "TIMESTAMP_NTZ": return isWellFormedStringLiteral(raw) ? raw : `'${raw.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
209
+ case "NUMERIC":
210
+ case "DECIMAL":
211
+ case "BIGINT":
212
+ case "TINYINT":
213
+ case "SMALLINT":
214
+ case "INT":
215
+ case "FLOAT":
216
+ case "DOUBLE": return /^[+-]?\d+(\.\d+)?$/.test(raw) ? raw : null;
217
+ case "BOOLEAN": return /^(?:true|false)$/i.test(raw) ? raw.toLowerCase() : null;
218
+ case "BINARY": return /^X'[0-9a-fA-F]*'$/i.test(raw) ? raw : null;
219
+ default: return null;
220
+ }
221
+ }
222
+ /**
223
+ * Parse optional describe-time sample values from `@param` annotations, e.g.
224
+ * `-- @param target_catalog STRING = main`. The value is substituted into the
225
+ * SQL **only during DESCRIBE QUERY** so type generation can resolve queries
226
+ * whose shape depends on a parameter value — most notably dynamic table names
227
+ * via `IDENTIFIER(:target_catalog || '.schema.table')`, where the empty-string
228
+ * default would otherwise produce malformed SQL. Runtime binding is unaffected:
229
+ * the analytics plugin still binds the real parameter at execution time, so the
230
+ * query stays portable across environments.
231
+ *
232
+ * Returns a map of parameter name to the formatted SQL literal to substitute.
233
+ */
234
+ function extractParameterDefaults(sql) {
235
+ const defaults = {};
236
+ const regex = new RegExp(`--[^\\S\\r\\n]*@param[^\\S\\r\\n]+(\\w+)[^\\S\\r\\n]+(${PARAM_TYPE_ALTERNATION})[^\\S\\r\\n]*=[^\\S\\r\\n]*(.+?)[^\\S\\r\\n]*$`, "gim");
237
+ for (const match of sql.matchAll(regex)) {
238
+ const [, paramName, paramType, rawValue] = match;
239
+ const formatted = formatSampleValue(paramType, rawValue);
240
+ if (formatted !== null) defaults[paramName] = formatted;
241
+ }
242
+ return defaults;
243
+ }
244
+ /**
245
+ * Replace `:param` placeholders with describe-time literals so `DESCRIBE QUERY`
246
+ * can run without bound parameters. Resolution order per parameter:
247
+ * 1. An explicit `-- @param name TYPE = value` sample value (wins), which lets
248
+ * dynamic table names via `IDENTIFIER(...)` resolve to a real table.
249
+ * 2. Otherwise a placeholder default derived from the annotated/inferred type.
250
+ * Placeholders inside string literals or comments are left untouched.
251
+ */
252
+ function substituteParametersForDescribe(sql) {
253
+ const protectedRanges = getProtectedRanges(sql);
254
+ const annotatedTypes = extractParameterTypes(sql);
255
+ const parameterTypes = {
256
+ ...inferParameterTypes(sql, protectedRanges),
257
+ ...annotatedTypes
258
+ };
259
+ const parameterDefaults = extractParameterDefaults(sql);
260
+ return sql.replace(/(?<!:):([a-zA-Z_]\w*)/g, (original, paramName, offset) => {
261
+ if (isInsideProtectedRange(offset, protectedRanges)) return original;
262
+ const sampleValue = parameterDefaults[paramName];
263
+ if (sampleValue !== void 0) return sampleValue;
264
+ return defaultForType(parameterTypes[paramName]);
265
+ });
266
+ }
267
+ /**
268
+ * Append a remediation hint when a DESCRIBE failure looks like a dynamic
269
+ * identifier that couldn't be resolved: the query calls `IDENTIFIER(...)` and
270
+ * has at least one parameter without a describe-time sample value. These fail
271
+ * because typegen substitutes a placeholder default (e.g. `''`) that yields a
272
+ * malformed or non-existent table name. Steering the user to the `= value`
273
+ * annotation turns the fatal error into a one-line fix.
274
+ */
275
+ function withIdentifierHint(error, sql) {
276
+ if (!/\bIDENTIFIER\s*\(/i.test(sql)) return error;
277
+ const params = extractParameters(sql, getProtectedRanges(sql));
278
+ const defaults = extractParameterDefaults(sql);
279
+ const unresolved = params.filter((p) => !SERVER_INJECTED_PARAMS.includes(p) && defaults[p] === void 0);
280
+ if (unresolved.length === 0) return error;
281
+ const example = unresolved[0];
282
+ return {
283
+ ...error,
284
+ message: `${error.message}\n Hint: this query uses IDENTIFIER() with parameter(s) ${unresolved.map((p) => `:${p}`).join(", ")}. Give type generation a sample value so it can resolve the table, e.g. \`-- @param ${example} STRING = my_catalog\`. The runtime query still binds the real parameter.`
285
+ };
286
+ }
287
+ /**
247
288
  * Infer parameter types from positional context in SQL.
248
289
  * V1 only infers NUMERIC from patterns like LIMIT, OFFSET, TOP,
249
290
  * FETCH FIRST ... ROWS, and arithmetic operators.
@@ -310,13 +351,12 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
310
351
  ...inferParameterTypes(sql, protectedRanges),
311
352
  ...annotatedTypes
312
353
  };
313
- const sqlWithDefaults = sql.replace(/(?<!:):([a-zA-Z_]\w*)/g, (original, paramName, offset) => {
314
- if (isInsideProtectedRange(offset, protectedRanges)) return original;
315
- return defaultForType(parameterTypes[paramName]);
316
- });
354
+ const parameterDefaults = extractParameterDefaults(sql);
355
+ const sqlWithDefaults = substituteParametersForDescribe(sql);
317
356
  const allParams = extractParameters(sql, protectedRanges);
318
357
  for (const param of allParams) {
319
358
  if (SERVER_INJECTED_PARAMS.includes(param)) continue;
359
+ if (parameterDefaults[param]) continue;
320
360
  if (parameterTypes[param]) continue;
321
361
  logger.warn("%s: parameter \":%s\" has no type annotation or inference. Add %s to the query file.", queryFiles[i], param, `-- @param ${param} <TYPE>`);
322
362
  }
@@ -399,11 +439,9 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
399
439
  let completed = 0;
400
440
  const total = uncachedQueries.length;
401
441
  spinner.start(`Describing ${total} ${total === 1 ? "query" : "queries"} (0/${total})`);
442
+ const describeFormat = {};
402
443
  const describeOne = async ({ index, queryName, sql, sqlHash, cleanedSql }) => {
403
- const result = await client.statementExecution.executeStatement({
404
- statement: `DESCRIBE QUERY ${cleanedSql}`,
405
- warehouse_id: warehouseId
406
- });
444
+ const result = await describeAdaptive(client, `DESCRIBE QUERY ${cleanedSql}`, warehouseId, describeFormat);
407
445
  completed++;
408
446
  spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
409
447
  logger.debug("DESCRIBE result for %s: state=%s, rows=%d", queryName, result.status.state, result.result?.data_array?.length ?? 0);
@@ -416,7 +454,7 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
416
454
  name: queryName,
417
455
  type: generateUnknownResultQuery(sql, queryName)
418
456
  },
419
- error: parseError(sqlError)
457
+ error: withIdentifierHint(parseError(sqlError), sql)
420
458
  };
421
459
  }
422
460
  if (result.status.state !== "SUCCEEDED") return {