@databricks/appkit 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/app/index.d.ts +49 -2
  2. package/dist/app/index.d.ts.map +1 -1
  3. package/dist/app/index.js +87 -10
  4. package/dist/app/index.js.map +1 -1
  5. package/dist/appkit/package.js +1 -1
  6. package/dist/cli/commands/generate-types.js +9 -4
  7. package/dist/cli/commands/generate-types.js.map +1 -1
  8. package/dist/plugins/analytics/analytics.d.ts +16 -0
  9. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  10. package/dist/plugins/analytics/analytics.js +162 -1
  11. package/dist/plugins/analytics/analytics.js.map +1 -1
  12. package/dist/plugins/analytics/metric.js +7 -0
  13. package/dist/plugins/analytics/mv/cache.js +51 -0
  14. package/dist/plugins/analytics/mv/cache.js.map +1 -0
  15. package/dist/plugins/analytics/mv/constants.js +72 -0
  16. package/dist/plugins/analytics/mv/constants.js.map +1 -0
  17. package/dist/plugins/analytics/mv/formatters.js +151 -0
  18. package/dist/plugins/analytics/mv/formatters.js.map +1 -0
  19. package/dist/plugins/analytics/mv/index.js +6 -0
  20. package/dist/plugins/analytics/mv/registry.js +55 -0
  21. package/dist/plugins/analytics/mv/registry.js.map +1 -0
  22. package/dist/plugins/analytics/mv/schemas.js +178 -0
  23. package/dist/plugins/analytics/mv/schemas.js.map +1 -0
  24. package/dist/plugins/analytics/types.js.map +1 -1
  25. package/dist/schemas/metric-fqn.js +14 -0
  26. package/dist/schemas/metric-fqn.js.map +1 -0
  27. package/dist/shared/src/schemas/metric-fqn.js +78 -46
  28. package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
  29. package/dist/shared/src/schemas/metric-source.js +90 -0
  30. package/dist/shared/src/schemas/metric-source.js.map +1 -0
  31. package/dist/type-generator/index.js +14 -7
  32. package/dist/type-generator/index.js.map +1 -1
  33. package/dist/type-generator/mv-registry/config.js +13 -31
  34. package/dist/type-generator/mv-registry/config.js.map +1 -1
  35. package/dist/type-generator/mv-registry/describe.js +1 -31
  36. package/dist/type-generator/mv-registry/describe.js.map +1 -1
  37. package/dist/type-generator/mv-registry/sync.js +1 -1
  38. package/dist/type-generator/mv-registry/sync.js.map +1 -1
  39. package/dist/type-generator/vite-plugin.d.ts +5 -1
  40. package/dist/type-generator/vite-plugin.d.ts.map +1 -1
  41. package/dist/type-generator/vite-plugin.js +21 -4
  42. package/dist/type-generator/vite-plugin.js.map +1 -1
  43. package/docs/development/type-generation.md +3 -3
  44. package/docs/plugins/analytics.md +172 -23
  45. package/package.json +1 -1
  46. package/sbom.cdx.json +1 -1
@@ -12,9 +12,23 @@ interface QueryResult {
12
12
  isAsUser: boolean;
13
13
  }
14
14
  declare class AppManager {
15
- private readonly queriesDir;
15
+ private readonly _queriesDir;
16
+ private readonly _metricViewsDir;
17
+ constructor(queriesDir?: string, metricViewsDir?: string);
18
+ get queriesDir(): string;
19
+ get metricViewsDir(): string;
16
20
  /**
17
- * Validates that a file path is within the queries directory
21
+ * Whether `req` is a dev-tunnel (`?dev`) request. Internal `?dev` predicate
22
+ * shared by {@link createFsAdapter} (tunnel-vs-`fs` branch) and
23
+ * {@link isNotFoundError} (dev's message-match not-found classification).
24
+ */
25
+ private isDevRequest;
26
+ /**
27
+ * Validates that a file path is within the given base directory. The
28
+ * `baseDir` is passed explicitly (rather than pinned to `queriesDir`) so the
29
+ * same traversal guard protects reads from any config directory —
30
+ * `config/queries/` for `.sql` files and `config/metric-views/` for the
31
+ * metric-view definitions.
18
32
  */
19
33
  private validatePath;
20
34
  /**
@@ -30,6 +44,39 @@ declare class AppManager {
30
44
  * @returns The query content and execution mode (as user or as service principal)
31
45
  */
32
46
  getAppQuery(queryKey: string, req?: RequestLike, devFileReader?: DevFileReader): Promise<QueryResult | null>;
47
+ /**
48
+ * Read a single config file from a given base directory, dev-tunnel-aware.
49
+ * Shared core behind {@link readConfigFile} (queries dir) and
50
+ * {@link readMetricViewsConfig} (metric-views dir).
51
+ *
52
+ * @param baseDir - The config directory the file must resolve within.
53
+ * @param fileName - File name (or relative path) within `baseDir`.
54
+ * @param req - Optional request object to detect dev mode.
55
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
56
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
57
+ */
58
+ private readFileFromDir;
59
+ /**
60
+ * Read a single config file from the queries directory, dev-tunnel-aware.
61
+ *
62
+ * @param fileName - File name (or relative path) within the queries directory.
63
+ * @param req - Optional request object to detect dev mode.
64
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
65
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
66
+ */
67
+ readConfigFile(fileName: string, req?: RequestLike, devFileReader?: DevFileReader): Promise<string | null>;
68
+ /**
69
+ * Read a single config file from the metric-views directory
70
+ * (`config/metric-views/`), dev-tunnel-aware. Same absent-file/traversal
71
+ * semantics as {@link readConfigFile}, but rooted at {@link metricViewsDir}.
72
+ *
73
+ * @param fileName - File name (or relative path) within the metric-views directory.
74
+ * @param req - Optional request object to detect dev mode.
75
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
76
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
77
+ */
78
+ readMetricViewsConfig(fileName: string, req?: RequestLike, devFileReader?: DevFileReader): Promise<string | null>;
79
+ private isNotFoundError;
33
80
  }
34
81
  //#endregion
35
82
  export { AppManager };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/app/index.ts"],"mappings":";UAMU,WAAA;EACR,KAAA,GAAQ,MAAA;EACR,OAAA,EAAS,MAAA;AAAA;AAAA,UAGD,aAAA;EACR,QAAA,CAAS,QAAA,UAAkB,GAAA,EAAK,WAAA,GAAc,OAAA;EAC9C,OAAA,CAAQ,OAAA,UAAiB,GAAA,EAAK,WAAA,GAAc,OAAA;AAAA;AAAA,UAGpC,WAAA;EACR,KAAA;EACA,QAAA;AAAA;AAAA,cAWW,UAAA;EAAA,iBACM,UAAA;EAlBe;;;EAAA,QAuBxB,YAAA;EAtB2C;;;EAAA,QAsC3C,eAAA;EAvCwB;;;;;;;;EA0E1B,WAAA,CACJ,QAAA,UACA,GAAA,GAAM,WAAA,EACN,aAAA,GAAgB,aAAA,GACf,OAAA,CAAQ,WAAA;AAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/app/index.ts"],"mappings":";UAMU,WAAA;EACR,KAAA,GAAQ,MAAA;EACR,OAAA,EAAS,MAAA;AAAA;AAAA,UAGD,aAAA;EACR,QAAA,CAAS,QAAA,UAAkB,GAAA,EAAK,WAAA,GAAc,OAAA;EAC9C,OAAA,CAAQ,OAAA,UAAiB,GAAA,EAAK,WAAA,GAAc,OAAA;AAAA;AAAA,UAGpC,WAAA;EACR,KAAA;EACA,QAAA;AAAA;AAAA,cAWW,UAAA;EAAA,iBACM,WAAA;EAAA,iBACA,eAAA;cAGf,UAAA,WAMA,cAAA;EAAA,IASE,UAAA,CAAA;EAAA,IAIA,cAAA,CAAA;EAxC+C;;;;;EAAA,QAiD3C,YAAA;EAlDsC;;;;;;;EAAA,QA6DtC,YAAA;EAzDA;;;EAAA,QAyEA,eAAA;EAvEA;AAWV;;;;;;;EA+FQ,WAAA,CACJ,QAAA,UACA,GAAA,GAAM,WAAA,EACN,aAAA,GAAgB,aAAA,GACf,OAAA,CAAQ,WAAA;EAoHO;;;;;;;;;;;EAAA,QArCJ,eAAA;EA9JV;;;;;;;;EAgME,cAAA,CACJ,QAAA,UACA,GAAA,GAAM,WAAA,EACN,aAAA,GAAgB,aAAA,GACf,OAAA;EAtHe;;;;;;;;;;EAoIZ,qBAAA,CACJ,QAAA,UACA,GAAA,GAAM,WAAA,EACN,aAAA,GAAgB,aAAA,GACf,OAAA;EAAA,QASK,eAAA;AAAA"}
package/dist/app/index.js CHANGED
@@ -5,16 +5,39 @@ import path from "node:path";
5
5
  //#region src/app/index.ts
6
6
  const logger = createLogger("app");
7
7
  var AppManager = class {
8
- queriesDir = path.resolve(process.cwd(), "config/queries");
8
+ _queriesDir;
9
+ _metricViewsDir;
10
+ constructor(queriesDir = path.resolve(process.cwd(), "config/queries"), metricViewsDir = path.resolve(path.dirname(queriesDir), "metric-views")) {
11
+ this._queriesDir = queriesDir;
12
+ this._metricViewsDir = metricViewsDir;
13
+ }
14
+ get queriesDir() {
15
+ return this._queriesDir;
16
+ }
17
+ get metricViewsDir() {
18
+ return this._metricViewsDir;
19
+ }
20
+ /**
21
+ * Whether `req` is a dev-tunnel (`?dev`) request. Internal `?dev` predicate
22
+ * shared by {@link createFsAdapter} (tunnel-vs-`fs` branch) and
23
+ * {@link isNotFoundError} (dev's message-match not-found classification).
24
+ */
25
+ isDevRequest(req) {
26
+ return req?.query?.dev !== void 0;
27
+ }
9
28
  /**
10
- * Validates that a file path is within the queries directory
29
+ * Validates that a file path is within the given base directory. The
30
+ * `baseDir` is passed explicitly (rather than pinned to `queriesDir`) so the
31
+ * same traversal guard protects reads from any config directory —
32
+ * `config/queries/` for `.sql` files and `config/metric-views/` for the
33
+ * metric-view definitions.
11
34
  */
12
- validatePath(fileName) {
13
- const queryFilePath = path.join(this.queriesDir, fileName);
14
- const resolvedPath = path.resolve(queryFilePath);
15
- const resolvedQueriesDir = path.resolve(this.queriesDir);
16
- if (!resolvedPath.startsWith(resolvedQueriesDir)) {
17
- logger.error("Invalid query path: path traversal detected");
35
+ validatePath(fileName, baseDir) {
36
+ const filePath = path.join(baseDir, fileName);
37
+ const resolvedPath = path.resolve(filePath);
38
+ const resolvedBaseDir = path.resolve(baseDir);
39
+ if (!resolvedPath.startsWith(resolvedBaseDir)) {
40
+ logger.error("Invalid config path: path traversal detected");
18
41
  return null;
19
42
  }
20
43
  return resolvedPath;
@@ -23,7 +46,7 @@ var AppManager = class {
23
46
  * Creates a filesystem adapter based on dev mode or production mode
24
47
  */
25
48
  createFsAdapter(req, devFileReader) {
26
- if (req?.query?.dev !== void 0 && devFileReader && req) return {
49
+ if (this.isDevRequest(req) && devFileReader && req) return {
27
50
  readdir: async (dirPath) => {
28
51
  const relativePath = path.relative(process.cwd(), dirPath);
29
52
  return devFileReader.readdir(relativePath, req);
@@ -75,7 +98,7 @@ var AppManager = class {
75
98
  logger.error(`Query file not found: ${queryKey}`);
76
99
  return null;
77
100
  }
78
- const resolvedPath = this.validatePath(queryFileName);
101
+ const resolvedPath = this.validatePath(queryFileName, this._queriesDir);
79
102
  if (!resolvedPath) return null;
80
103
  try {
81
104
  return {
@@ -87,6 +110,60 @@ var AppManager = class {
87
110
  return null;
88
111
  }
89
112
  }
113
+ /**
114
+ * Read a single config file from a given base directory, dev-tunnel-aware.
115
+ * Shared core behind {@link readConfigFile} (queries dir) and
116
+ * {@link readMetricViewsConfig} (metric-views dir).
117
+ *
118
+ * @param baseDir - The config directory the file must resolve within.
119
+ * @param fileName - File name (or relative path) within `baseDir`.
120
+ * @param req - Optional request object to detect dev mode.
121
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
122
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
123
+ */
124
+ async readFileFromDir(baseDir, fileName, req, devFileReader) {
125
+ const resolvedPath = this.validatePath(fileName, baseDir);
126
+ if (!resolvedPath) return null;
127
+ const fsAdapter = this.createFsAdapter(req, devFileReader);
128
+ try {
129
+ return await fsAdapter.readFile(resolvedPath);
130
+ } catch (error) {
131
+ if (this.isNotFoundError(error, req)) return null;
132
+ throw error;
133
+ }
134
+ }
135
+ /**
136
+ * Read a single config file from the queries directory, dev-tunnel-aware.
137
+ *
138
+ * @param fileName - File name (or relative path) within the queries directory.
139
+ * @param req - Optional request object to detect dev mode.
140
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
141
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
142
+ */
143
+ async readConfigFile(fileName, req, devFileReader) {
144
+ return this.readFileFromDir(this._queriesDir, fileName, req, devFileReader);
145
+ }
146
+ /**
147
+ * Read a single config file from the metric-views directory
148
+ * (`config/metric-views/`), dev-tunnel-aware. Same absent-file/traversal
149
+ * semantics as {@link readConfigFile}, but rooted at {@link metricViewsDir}.
150
+ *
151
+ * @param fileName - File name (or relative path) within the metric-views directory.
152
+ * @param req - Optional request object to detect dev mode.
153
+ * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.
154
+ * @returns The raw file contents, or `null` when the file is absent / the path is rejected.
155
+ */
156
+ async readMetricViewsConfig(fileName, req, devFileReader) {
157
+ return this.readFileFromDir(this._metricViewsDir, fileName, req, devFileReader);
158
+ }
159
+ isNotFoundError(error, req) {
160
+ if (error?.code === "ENOENT") return true;
161
+ if (this.isDevRequest(req)) {
162
+ const message = error?.message ?? "";
163
+ return /ENOENT|no such file/i.test(message);
164
+ }
165
+ return false;
166
+ }
90
167
  };
91
168
 
92
169
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/app/index.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { createLogger } from \"../logging/logger\";\n\nconst logger = createLogger(\"app\");\n\ninterface RequestLike {\n query?: Record<string, any>;\n headers: Record<string, string | string[] | undefined>;\n}\n\ninterface DevFileReader {\n readFile(filePath: string, req: RequestLike): Promise<string>;\n readdir(dirPath: string, req: RequestLike): Promise<string[]>;\n}\n\ninterface QueryResult {\n query: string;\n isAsUser: boolean;\n}\n\n/**\n * Abstraction for filesystem operations that works in both dev and production modes\n */\ninterface FileSystemAdapter {\n readdir(dirPath: string): Promise<string[]>;\n readFile(filePath: string): Promise<string>;\n}\n\nexport class AppManager {\n private readonly queriesDir = path.resolve(process.cwd(), \"config/queries\");\n\n /**\n * Validates that a file path is within the queries directory\n */\n private validatePath(fileName: string): string | null {\n const queryFilePath = path.join(this.queriesDir, fileName);\n const resolvedPath = path.resolve(queryFilePath);\n const resolvedQueriesDir = path.resolve(this.queriesDir);\n\n if (!resolvedPath.startsWith(resolvedQueriesDir)) {\n logger.error(\"Invalid query path: path traversal detected\");\n return null;\n }\n\n return resolvedPath;\n }\n\n /**\n * Creates a filesystem adapter based on dev mode or production mode\n */\n private createFsAdapter(\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): FileSystemAdapter {\n const isDevMode = req?.query?.dev !== undefined;\n\n if (isDevMode && devFileReader && req) {\n // Dev mode: use WebSocket tunnel to read from local filesystem\n return {\n readdir: async (dirPath: string) => {\n const relativePath = path.relative(process.cwd(), dirPath);\n return devFileReader.readdir(relativePath, req);\n },\n readFile: async (filePath: string) => {\n const relativePath = path.relative(process.cwd(), filePath);\n return devFileReader.readFile(relativePath, req);\n },\n };\n }\n\n // Production mode: use server filesystem\n return {\n readdir: (dirPath: string) => fs.readdir(dirPath),\n readFile: (filePath: string) => fs.readFile(filePath, \"utf8\"),\n };\n }\n\n /**\n * Retrieves a query file by key from the queries directory\n * In dev mode with a request context, reads from local filesystem via WebSocket\n * @param queryKey - The query file name (without extension)\n * @param req - Optional request object to detect dev mode\n * @param devFileReader - Optional DevFileReader instance to read files from local filesystem\n * @returns The query content and execution mode (as user or as service principal)\n */\n async getAppQuery(\n queryKey: string,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): Promise<QueryResult | null> {\n // Security: Sanitize query key to prevent path traversal\n if (!queryKey || !/^[a-zA-Z0-9_-]+$/.test(queryKey)) {\n logger.error(\n \"Invalid query key format: %s. Only alphanumeric characters, underscores, and hyphens are allowed.\",\n queryKey,\n );\n return null;\n }\n\n // Create filesystem adapter for dev or production mode\n const fsAdapter = this.createFsAdapter(req, devFileReader);\n\n // Priority order: .obo.sql first (as user), then .sql (as service principal)\n const oboFileName = `${queryKey}.obo.sql`;\n const defaultFileName = `${queryKey}.sql`;\n\n // List directory to find which query file exists\n let files: string[];\n try {\n files = await fsAdapter.readdir(this.queriesDir);\n } catch (error) {\n logger.error(\n `Failed to read queries directory: ${(error as Error).message}`,\n );\n return null;\n }\n\n // Determine which query file to use\n let queryFileName: string | null = null;\n let isAsUser = false;\n\n if (files.includes(oboFileName)) {\n queryFileName = oboFileName;\n isAsUser = true;\n\n // Warn if both variants exist\n if (files.includes(defaultFileName)) {\n logger.warn(\n `Both ${oboFileName} and ${defaultFileName} found for query ${queryKey}. Using ${oboFileName}.`,\n );\n }\n } else if (files.includes(defaultFileName)) {\n queryFileName = defaultFileName;\n isAsUser = false;\n }\n\n if (!queryFileName) {\n logger.error(`Query file not found: ${queryKey}`);\n return null;\n }\n\n // Validate and resolve the file path\n const resolvedPath = this.validatePath(queryFileName);\n if (!resolvedPath) {\n return null;\n }\n\n // Read the query file\n try {\n const query = await fsAdapter.readFile(resolvedPath);\n return { query, isAsUser };\n } catch (error) {\n logger.error(`Failed to read query file: ${(error as Error).message}`);\n return null;\n }\n }\n}\n\nexport type { DevFileReader };\n"],"mappings":";;;;;AAIA,MAAM,SAAS,aAAa,MAAM;AAyBlC,IAAa,aAAb,MAAwB;CACtB,AAAiB,aAAa,KAAK,QAAQ,QAAQ,KAAK,EAAE,iBAAiB;;;;CAK3E,AAAQ,aAAa,UAAiC;EACpD,MAAM,gBAAgB,KAAK,KAAK,KAAK,YAAY,SAAS;EAC1D,MAAM,eAAe,KAAK,QAAQ,cAAc;EAChD,MAAM,qBAAqB,KAAK,QAAQ,KAAK,WAAW;AAExD,MAAI,CAAC,aAAa,WAAW,mBAAmB,EAAE;AAChD,UAAO,MAAM,8CAA8C;AAC3D,UAAO;;AAGT,SAAO;;;;;CAMT,AAAQ,gBACN,KACA,eACmB;AAGnB,MAFkB,KAAK,OAAO,QAAQ,UAErB,iBAAiB,IAEhC,QAAO;GACL,SAAS,OAAO,YAAoB;IAClC,MAAM,eAAe,KAAK,SAAS,QAAQ,KAAK,EAAE,QAAQ;AAC1D,WAAO,cAAc,QAAQ,cAAc,IAAI;;GAEjD,UAAU,OAAO,aAAqB;IACpC,MAAM,eAAe,KAAK,SAAS,QAAQ,KAAK,EAAE,SAAS;AAC3D,WAAO,cAAc,SAAS,cAAc,IAAI;;GAEnD;AAIH,SAAO;GACL,UAAU,YAAoB,GAAG,QAAQ,QAAQ;GACjD,WAAW,aAAqB,GAAG,SAAS,UAAU,OAAO;GAC9D;;;;;;;;;;CAWH,MAAM,YACJ,UACA,KACA,eAC6B;AAE7B,MAAI,CAAC,YAAY,CAAC,mBAAmB,KAAK,SAAS,EAAE;AACnD,UAAO,MACL,qGACA,SACD;AACD,UAAO;;EAIT,MAAM,YAAY,KAAK,gBAAgB,KAAK,cAAc;EAG1D,MAAM,cAAc,GAAG,SAAS;EAChC,MAAM,kBAAkB,GAAG,SAAS;EAGpC,IAAI;AACJ,MAAI;AACF,WAAQ,MAAM,UAAU,QAAQ,KAAK,WAAW;WACzC,OAAO;AACd,UAAO,MACL,qCAAsC,MAAgB,UACvD;AACD,UAAO;;EAIT,IAAI,gBAA+B;EACnC,IAAI,WAAW;AAEf,MAAI,MAAM,SAAS,YAAY,EAAE;AAC/B,mBAAgB;AAChB,cAAW;AAGX,OAAI,MAAM,SAAS,gBAAgB,CACjC,QAAO,KACL,QAAQ,YAAY,OAAO,gBAAgB,mBAAmB,SAAS,UAAU,YAAY,GAC9F;aAEM,MAAM,SAAS,gBAAgB,EAAE;AAC1C,mBAAgB;AAChB,cAAW;;AAGb,MAAI,CAAC,eAAe;AAClB,UAAO,MAAM,yBAAyB,WAAW;AACjD,UAAO;;EAIT,MAAM,eAAe,KAAK,aAAa,cAAc;AACrD,MAAI,CAAC,aACH,QAAO;AAIT,MAAI;AAEF,UAAO;IAAE,OADK,MAAM,UAAU,SAAS,aAAa;IACpC;IAAU;WACnB,OAAO;AACd,UAAO,MAAM,8BAA+B,MAAgB,UAAU;AACtE,UAAO"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/app/index.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { createLogger } from \"../logging/logger\";\n\nconst logger = createLogger(\"app\");\n\ninterface RequestLike {\n query?: Record<string, any>;\n headers: Record<string, string | string[] | undefined>;\n}\n\ninterface DevFileReader {\n readFile(filePath: string, req: RequestLike): Promise<string>;\n readdir(dirPath: string, req: RequestLike): Promise<string[]>;\n}\n\ninterface QueryResult {\n query: string;\n isAsUser: boolean;\n}\n\n/**\n * Abstraction for filesystem operations that works in both dev and production modes\n */\ninterface FileSystemAdapter {\n readdir(dirPath: string): Promise<string[]>;\n readFile(filePath: string): Promise<string>;\n}\n\nexport class AppManager {\n private readonly _queriesDir: string;\n private readonly _metricViewsDir: string;\n\n constructor(\n queriesDir: string = path.resolve(process.cwd(), \"config/queries\"),\n // Metric-view declarations live in a sibling `config/metric-views/`\n // directory (next to `config/queries/` and `config/agents/`), NOT inside\n // the queries folder. Default it as a sibling of `queriesDir` so a\n // single-arg `new AppManager(dir)` (and every test that overrides only the\n // queries dir) still resolves the metric-views dir consistently.\n metricViewsDir: string = path.resolve(\n path.dirname(queriesDir),\n \"metric-views\",\n ),\n ) {\n this._queriesDir = queriesDir;\n this._metricViewsDir = metricViewsDir;\n }\n\n get queriesDir(): string {\n return this._queriesDir;\n }\n\n get metricViewsDir(): string {\n return this._metricViewsDir;\n }\n\n /**\n * Whether `req` is a dev-tunnel (`?dev`) request. Internal `?dev` predicate\n * shared by {@link createFsAdapter} (tunnel-vs-`fs` branch) and\n * {@link isNotFoundError} (dev's message-match not-found classification).\n */\n private isDevRequest(req?: RequestLike): boolean {\n return req?.query?.dev !== undefined;\n }\n\n /**\n * Validates that a file path is within the given base directory. The\n * `baseDir` is passed explicitly (rather than pinned to `queriesDir`) so the\n * same traversal guard protects reads from any config directory —\n * `config/queries/` for `.sql` files and `config/metric-views/` for the\n * metric-view definitions.\n */\n private validatePath(fileName: string, baseDir: string): string | null {\n const filePath = path.join(baseDir, fileName);\n const resolvedPath = path.resolve(filePath);\n const resolvedBaseDir = path.resolve(baseDir);\n\n if (!resolvedPath.startsWith(resolvedBaseDir)) {\n logger.error(\"Invalid config path: path traversal detected\");\n return null;\n }\n\n return resolvedPath;\n }\n\n /**\n * Creates a filesystem adapter based on dev mode or production mode\n */\n private createFsAdapter(\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): FileSystemAdapter {\n const isDevMode = this.isDevRequest(req);\n\n if (isDevMode && devFileReader && req) {\n // Dev mode: use WebSocket tunnel to read from local filesystem\n return {\n readdir: async (dirPath: string) => {\n const relativePath = path.relative(process.cwd(), dirPath);\n return devFileReader.readdir(relativePath, req);\n },\n readFile: async (filePath: string) => {\n const relativePath = path.relative(process.cwd(), filePath);\n return devFileReader.readFile(relativePath, req);\n },\n };\n }\n\n // Production mode: use server filesystem\n return {\n readdir: (dirPath: string) => fs.readdir(dirPath),\n readFile: (filePath: string) => fs.readFile(filePath, \"utf8\"),\n };\n }\n\n /**\n * Retrieves a query file by key from the queries directory\n * In dev mode with a request context, reads from local filesystem via WebSocket\n * @param queryKey - The query file name (without extension)\n * @param req - Optional request object to detect dev mode\n * @param devFileReader - Optional DevFileReader instance to read files from local filesystem\n * @returns The query content and execution mode (as user or as service principal)\n */\n async getAppQuery(\n queryKey: string,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): Promise<QueryResult | null> {\n // Security: Sanitize query key to prevent path traversal\n if (!queryKey || !/^[a-zA-Z0-9_-]+$/.test(queryKey)) {\n logger.error(\n \"Invalid query key format: %s. Only alphanumeric characters, underscores, and hyphens are allowed.\",\n queryKey,\n );\n return null;\n }\n\n // Create filesystem adapter for dev or production mode\n const fsAdapter = this.createFsAdapter(req, devFileReader);\n\n // Priority order: .obo.sql first (as user), then .sql (as service principal)\n const oboFileName = `${queryKey}.obo.sql`;\n const defaultFileName = `${queryKey}.sql`;\n\n // List directory to find which query file exists\n let files: string[];\n try {\n files = await fsAdapter.readdir(this.queriesDir);\n } catch (error) {\n logger.error(\n `Failed to read queries directory: ${(error as Error).message}`,\n );\n return null;\n }\n\n // Determine which query file to use\n let queryFileName: string | null = null;\n let isAsUser = false;\n\n if (files.includes(oboFileName)) {\n queryFileName = oboFileName;\n isAsUser = true;\n\n // Warn if both variants exist\n if (files.includes(defaultFileName)) {\n logger.warn(\n `Both ${oboFileName} and ${defaultFileName} found for query ${queryKey}. Using ${oboFileName}.`,\n );\n }\n } else if (files.includes(defaultFileName)) {\n queryFileName = defaultFileName;\n isAsUser = false;\n }\n\n if (!queryFileName) {\n logger.error(`Query file not found: ${queryKey}`);\n return null;\n }\n\n // Validate and resolve the file path\n const resolvedPath = this.validatePath(queryFileName, this._queriesDir);\n if (!resolvedPath) {\n return null;\n }\n\n // Read the query file\n try {\n const query = await fsAdapter.readFile(resolvedPath);\n return { query, isAsUser };\n } catch (error) {\n logger.error(`Failed to read query file: ${(error as Error).message}`);\n return null;\n }\n }\n\n /**\n * Read a single config file from a given base directory, dev-tunnel-aware.\n * Shared core behind {@link readConfigFile} (queries dir) and\n * {@link readMetricViewsConfig} (metric-views dir).\n *\n * @param baseDir - The config directory the file must resolve within.\n * @param fileName - File name (or relative path) within `baseDir`.\n * @param req - Optional request object to detect dev mode.\n * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.\n * @returns The raw file contents, or `null` when the file is absent / the path is rejected.\n */\n private async readFileFromDir(\n baseDir: string,\n fileName: string,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): Promise<string | null> {\n // Traversal guard: refuse to read outside the base directory.\n const resolvedPath = this.validatePath(fileName, baseDir);\n if (!resolvedPath) {\n return null;\n }\n\n const fsAdapter = this.createFsAdapter(req, devFileReader);\n\n try {\n return await fsAdapter.readFile(resolvedPath);\n } catch (error) {\n if (this.isNotFoundError(error, req)) {\n return null;\n }\n // Any other error (permission / IO / tunnel) is fatal: propagate so the\n // caller can distinguish it from a dormant (absent) config.\n throw error;\n }\n }\n\n /**\n * Read a single config file from the queries directory, dev-tunnel-aware.\n *\n * @param fileName - File name (or relative path) within the queries directory.\n * @param req - Optional request object to detect dev mode.\n * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.\n * @returns The raw file contents, or `null` when the file is absent / the path is rejected.\n */\n async readConfigFile(\n fileName: string,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): Promise<string | null> {\n return this.readFileFromDir(this._queriesDir, fileName, req, devFileReader);\n }\n\n /**\n * Read a single config file from the metric-views directory\n * (`config/metric-views/`), dev-tunnel-aware. Same absent-file/traversal\n * semantics as {@link readConfigFile}, but rooted at {@link metricViewsDir}.\n *\n * @param fileName - File name (or relative path) within the metric-views directory.\n * @param req - Optional request object to detect dev mode.\n * @param devFileReader - Optional DevFileReader to read via the WebSocket tunnel.\n * @returns The raw file contents, or `null` when the file is absent / the path is rejected.\n */\n async readMetricViewsConfig(\n fileName: string,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n ): Promise<string | null> {\n return this.readFileFromDir(\n this._metricViewsDir,\n fileName,\n req,\n devFileReader,\n );\n }\n\n private isNotFoundError(error: unknown, req?: RequestLike): boolean {\n if ((error as NodeJS.ErrnoException)?.code === \"ENOENT\") {\n return true;\n }\n if (this.isDevRequest(req)) {\n const message = (error as Error)?.message ?? \"\";\n return /ENOENT|no such file/i.test(message);\n }\n return false;\n }\n}\n\nexport type { DevFileReader, RequestLike };\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  //#region package.json
2
2
  var name = "@databricks/appkit";
3
- var version = "0.46.0";
3
+ var version = "0.47.0";
4
4
 
5
5
  //#endregion
6
6
  export { name, version };
@@ -1,3 +1,4 @@
1
+ import { METRIC_CONFIG_FILE } from "../../schemas/metric-fqn.js";
1
2
  import { acquireSpawnLock, getSpawnLockPath, releaseSpawnLock } from "./spawn-lock.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -33,16 +34,20 @@ async function runGenerateTypes(rootDir, outFile, warehouseId, options) {
33
34
  if (resolvedWarehouseId) {
34
35
  const resolvedOutFile = outFile || path.join(process.cwd(), "shared/appkit-types/analytics.d.ts");
35
36
  const queryFolder = path.join(resolvedRootDir, "config/queries");
36
- if (fs.existsSync(queryFolder)) {
37
+ const metricViewsFolder = path.join(resolvedRootDir, "config/metric-views");
38
+ const hasQueries = fs.existsSync(queryFolder);
39
+ const hasMetricViews = fs.existsSync(metricViewsFolder);
40
+ if (hasQueries || hasMetricViews) {
37
41
  await typeGen.generateFromEntryPoint({
38
- queryFolder,
42
+ queryFolder: hasQueries ? queryFolder : void 0,
43
+ metricViewsFolder: hasMetricViews ? metricViewsFolder : void 0,
39
44
  outFile: resolvedOutFile,
40
45
  warehouseId: resolvedWarehouseId,
41
46
  noCache,
42
47
  mode
43
48
  });
44
- console.log(`Generated query types: ${resolvedOutFile}`);
45
- const metricConfig = path.join(queryFolder, "metric-views.json");
49
+ if (hasQueries) console.log(`Generated query types: ${resolvedOutFile}`);
50
+ const metricConfig = path.join(metricViewsFolder, METRIC_CONFIG_FILE);
46
51
  if (fs.existsSync(metricConfig)) {
47
52
  const typesDir = path.dirname(resolvedOutFile);
48
53
  console.log(`Generated metric types: ${path.join(typesDir, "metric-views.d.ts")}`);
@@ -1 +1 @@
1
- {"version":3,"file":"generate-types.js","names":[],"sources":["../../../src/cli/commands/generate-types.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { Command, Option } from \"commander\";\nimport {\n acquireSpawnLock,\n getSpawnLockPath,\n releaseSpawnLock,\n} from \"./spawn-lock.js\";\n\n/**\n * Resolve the typegen pre-flight mode for the CLI. Defaults to \"non-blocking\" —\n * a one-shot CLI can't describe in the background, so by default it never\n * describes at all: it skips the warehouse probe AND every DESCRIBE, emits\n * best-available types (cache where the SQL hash matches, else `result: unknown`)\n * and returns immediately, never blocking on — or failing because of — a\n * warehouse, even a RUNNING one. Pass `--wait` (commander sets `wait: true`)\n * for a deliberate/CI invocation that should wait for a starting warehouse and\n * fail fast on a stopped one.\n */\nexport function resolveTypegenMode(options?: {\n wait?: boolean;\n}): \"non-blocking\" | \"blocking\" {\n return options?.wait ? \"blocking\" : \"non-blocking\";\n}\n\n/** Options parsed by commander for the generate-types command. */\ninterface GenerateTypesOptions {\n noCache?: boolean;\n wait?: boolean;\n /**\n * Internal: present only on the detached worker invocation. Carries the path\n * of the single-flight lock this worker must release when it finishes. Its\n * presence is what marks an invocation as \"the worker\" — workers always run\n * with `--wait`, so they never spawn another worker (only non-blocking runs\n * spawn), which terminates the recursion.\n */\n workerLock?: string;\n}\n\n/**\n * Generate types command implementation. Runs the library generate (which, in\n * non-blocking mode, writes degraded types and returns immediately). This is the\n * SAME work the worker performs in blocking mode in the background.\n */\nasync function runGenerateTypes(\n rootDir?: string,\n outFile?: string,\n warehouseId?: string,\n options?: GenerateTypesOptions,\n) {\n try {\n const resolvedRootDir = rootDir || process.cwd();\n const noCache = options?.noCache || false;\n const mode = resolveTypegenMode(options);\n\n const typeGen = await import(\"@databricks/appkit/type-generator\");\n\n // Generate analytics query types (requires warehouse ID)\n const resolvedWarehouseId =\n warehouseId || process.env.DATABRICKS_WAREHOUSE_ID;\n\n if (resolvedWarehouseId) {\n const resolvedOutFile =\n outFile ||\n path.join(process.cwd(), \"shared/appkit-types/analytics.d.ts\");\n\n const queryFolder = path.join(resolvedRootDir, \"config/queries\");\n if (fs.existsSync(queryFolder)) {\n await typeGen.generateFromEntryPoint({\n queryFolder,\n outFile: resolvedOutFile,\n warehouseId: resolvedWarehouseId,\n noCache,\n mode,\n });\n console.log(`Generated query types: ${resolvedOutFile}`);\n\n const metricConfig = path.join(queryFolder, \"metric-views.json\");\n if (fs.existsSync(metricConfig)) {\n const typesDir = path.dirname(resolvedOutFile);\n console.log(\n `Generated metric types: ${path.join(typesDir, \"metric-views.d.ts\")}`,\n );\n }\n }\n } else {\n console.error(\n \"Skipping query type generation: no warehouse ID. Set DATABRICKS_WAREHOUSE_ID or pass as argument.\",\n );\n }\n\n // Generate serving endpoint types (no warehouse required)\n const servingOutFile = path.join(\n process.cwd(),\n \"shared/appkit-types/serving.d.ts\",\n );\n await typeGen.generateServingTypes({\n outFile: servingOutFile,\n noCache,\n });\n console.log(`Generated serving types: ${servingOutFile}`);\n } catch (error) {\n if (\n error instanceof Error &&\n error.message.includes(\"Cannot find module\")\n ) {\n console.error(\n \"Error: The 'generate-types' command is only available in @databricks/appkit.\",\n );\n console.error(\"Please install @databricks/appkit to use this command.\");\n process.exit(1);\n }\n // TypegenSyntaxError / TypegenFatalError carry a complete, actionable\n // message (which queries failed and how to debug them). The stack trace\n // points into appkit internals and is noise for app developers, so print\n // only the message and exit non-zero instead of letting it bubble up.\n if (\n error instanceof Error &&\n (error.name === \"TypegenSyntaxError\" ||\n error.name === \"TypegenFatalError\")\n ) {\n console.error(error.message);\n process.exit(1);\n }\n throw error;\n }\n}\n\n/**\n * Spawn the detached blocking worker that refreshes real types in the background\n * after the foreground non-blocking generate has already written degraded types.\n *\n * Re-invokes THIS CLI (`process.execPath` + `process.argv[1]` — the bin entry\n * that launched us) with `generate-types --wait --worker-lock <lockPath>` plus\n * the same positional target options the foreground used, so the worker writes\n * to the same out file / reads the same query folder. The worker is:\n * - `detached: true` + `.unref()` so it outlives this process (install/dev-setup\n * can finish and exit while the worker keeps warming the warehouse).\n * - `stdio: \"ignore\"` so it never holds the parent's pipes open or interleaves\n * output into the install/dev log.\n *\n * Spawning is wrapped so any failure is non-fatal: the caller still has degraded\n * types and exits 0.\n *\n * @param lockPath - the acquired single-flight lock; passed to the worker so it\n * releases the SAME lock when it finishes.\n * @param targets - the foreground's positional args, forwarded verbatim.\n * @returns true if the worker was spawned, false if spawning threw.\n */\nexport function spawnTypegenWorker(\n lockPath: string,\n targets: { rootDir?: string; outFile?: string; warehouseId?: string },\n): boolean {\n // The script the runtime launched us with (the `appkit` bin shim). Re-running\n // it under the same node binary reproduces this exact CLI in the worker.\n const cliEntry = process.argv[1];\n\n // Forward the positionals in declaration order (rootDir, outFile,\n // warehouseId). Stop at the first undefined so we never pass a literal\n // \"undefined\" — commander would treat it as a positional value. (rootDir is\n // effectively always set by commander's default, but guard anyway.)\n const positionals: string[] = [];\n for (const value of [targets.rootDir, targets.outFile, targets.warehouseId]) {\n if (value === undefined) break;\n positionals.push(value);\n }\n\n const args = [\n // Forward the parent's node/loader flags so the worker runs under the same\n // runtime. Critically this carries tsx's `--require`/`--import` when the CLI\n // is run from source (`tsx index.ts …`); without them the worker would be\n // `node index.ts …`, which can't parse TypeScript and dies silently — the\n // degraded types would then never refresh. Empty for the built bin (plain\n // `node bin/appkit.js`), so production behaviour is unchanged.\n ...process.execArgv,\n cliEntry,\n \"generate-types\",\n \"--wait\",\n \"--worker-lock\",\n lockPath,\n ...positionals,\n ];\n\n try {\n const child = spawn(process.execPath, args, {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n return true;\n } catch (error) {\n // Non-fatal: the foreground already wrote degraded types. Log and move on.\n console.error(\n `Could not start background type refresh: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n return false;\n }\n}\n\n/**\n * The command action. Orchestrates the non-blocking foreground contract:\n * 1. Run the library generate (writes degraded types immediately in non-blocking\n * mode; does the full blocking lifecycle when this is the worker).\n * 2. If this is a non-blocking, non-worker invocation, try to spawn the detached\n * blocking worker behind the single-flight lock. If the lock is already held\n * by a live worker, skip (single-flight) with a one-line note. Either way the\n * foreground returns normally (exit 0).\n * 3. If this IS the worker (`--worker-lock` present), it ran blocking above and\n * releases the lock here (and via a process-exit guard, so a hard failure /\n * process.exit still frees it).\n */\nasync function generateTypesAction(\n rootDir: string | undefined,\n outFile: string | undefined,\n warehouseId: string | undefined,\n options: GenerateTypesOptions,\n) {\n const isWorker = typeof options.workerLock === \"string\";\n\n // A worker must always free its lock, even if the blocking generate throws or\n // calls process.exit (TypegenFatalError → exit 1). The exit handler covers the\n // process.exit / uncaught paths; the finally covers the normal return.\n if (isWorker && options.workerLock) {\n const lockPath = options.workerLock;\n process.once(\"exit\", () => releaseSpawnLock(lockPath));\n }\n\n try {\n await runGenerateTypes(rootDir, outFile, warehouseId, options);\n } finally {\n if (isWorker && options.workerLock) {\n releaseSpawnLock(options.workerLock);\n }\n }\n\n // Only a non-blocking, non-worker invocation spawns. A worker is always\n // --wait (so resolveTypegenMode → \"blocking\"), which both prevents recursion\n // and means we never get here for a worker.\n if (!isWorker && resolveTypegenMode(options) === \"non-blocking\") {\n const resolvedRootDir = rootDir || process.cwd();\n const lockPath = getSpawnLockPath(resolvedRootDir);\n\n if (acquireSpawnLock(lockPath)) {\n spawnTypegenWorker(lockPath, { rootDir, outFile, warehouseId });\n } else {\n console.log(\"Type refresh already in progress, skipping.\");\n }\n }\n}\n\nexport const generateTypesCommand = new Command(\"generate-types\")\n .description(\"Generate TypeScript types from SQL queries\")\n .argument(\"[rootDir]\", \"Root directory of the project\", process.cwd())\n .argument(\n \"[outFile]\",\n \"Output file path\",\n path.join(process.cwd(), \"shared/appkit-types/analytics.d.ts\"),\n )\n .argument(\"[warehouseId]\", \"Databricks warehouse ID\")\n .option(\"--no-cache\", \"Disable caching for type generation\")\n .option(\n \"--wait\",\n \"Wait for warehouse readiness instead of degrading (use for CI)\",\n )\n // Internal: marks the detached background worker and carries the lock it must\n // release. Hidden from --help; users should never pass it.\n .addOption(\n new Option(\n \"--worker-lock <path>\",\n \"Internal: detached worker lock path\",\n ).hideHelp(),\n )\n .addHelpText(\n \"after\",\n `\nExamples:\n $ appkit generate-types\n $ appkit generate-types . shared/appkit-types/analytics.d.ts\n $ appkit generate-types . shared/appkit-types/analytics.d.ts my-warehouse-id\n $ appkit generate-types --no-cache\n $ appkit generate-types --wait # CI: wait for the warehouse and fail on a cold one`,\n )\n .action(generateTypesAction);\n"],"mappings":"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"}
1
+ {"version":3,"file":"generate-types.js","names":[],"sources":["../../../src/cli/commands/generate-types.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { Command, Option } from \"commander\";\nimport { METRIC_CONFIG_FILE } from \"../../schemas/metric-fqn\";\nimport {\n acquireSpawnLock,\n getSpawnLockPath,\n releaseSpawnLock,\n} from \"./spawn-lock.js\";\n\n/**\n * Resolve the typegen pre-flight mode for the CLI. Defaults to \"non-blocking\" —\n * a one-shot CLI can't describe in the background, so by default it never\n * describes at all: it skips the warehouse probe AND every DESCRIBE, emits\n * best-available types (cache where the SQL hash matches, else `result: unknown`)\n * and returns immediately, never blocking on — or failing because of — a\n * warehouse, even a RUNNING one. Pass `--wait` (commander sets `wait: true`)\n * for a deliberate/CI invocation that should wait for a starting warehouse and\n * fail fast on a stopped one.\n */\nexport function resolveTypegenMode(options?: {\n wait?: boolean;\n}): \"non-blocking\" | \"blocking\" {\n return options?.wait ? \"blocking\" : \"non-blocking\";\n}\n\n/** Options parsed by commander for the generate-types command. */\ninterface GenerateTypesOptions {\n noCache?: boolean;\n wait?: boolean;\n /**\n * Internal: present only on the detached worker invocation. Carries the path\n * of the single-flight lock this worker must release when it finishes. Its\n * presence is what marks an invocation as \"the worker\" — workers always run\n * with `--wait`, so they never spawn another worker (only non-blocking runs\n * spawn), which terminates the recursion.\n */\n workerLock?: string;\n}\n\n/**\n * Generate types command implementation. Runs the library generate (which, in\n * non-blocking mode, writes degraded types and returns immediately). This is the\n * SAME work the worker performs in blocking mode in the background.\n */\nasync function runGenerateTypes(\n rootDir?: string,\n outFile?: string,\n warehouseId?: string,\n options?: GenerateTypesOptions,\n) {\n try {\n const resolvedRootDir = rootDir || process.cwd();\n const noCache = options?.noCache || false;\n const mode = resolveTypegenMode(options);\n\n const typeGen = await import(\"@databricks/appkit/type-generator\");\n\n // Generate analytics query types (requires warehouse ID)\n const resolvedWarehouseId =\n warehouseId || process.env.DATABRICKS_WAREHOUSE_ID;\n\n if (resolvedWarehouseId) {\n const resolvedOutFile =\n outFile ||\n path.join(process.cwd(), \"shared/appkit-types/analytics.d.ts\");\n\n const queryFolder = path.join(resolvedRootDir, \"config/queries\");\n const metricViewsFolder = path.join(\n resolvedRootDir,\n \"config/metric-views\",\n );\n const hasQueries = fs.existsSync(queryFolder);\n const hasMetricViews = fs.existsSync(metricViewsFolder);\n\n // Generate when either config surface exists. Metric-view types are\n // independent of `.sql` queries — an app can declare metric views in\n // `config/metric-views/` without a `config/queries/` folder.\n if (hasQueries || hasMetricViews) {\n await typeGen.generateFromEntryPoint({\n queryFolder: hasQueries ? queryFolder : undefined,\n metricViewsFolder: hasMetricViews ? metricViewsFolder : undefined,\n outFile: resolvedOutFile,\n warehouseId: resolvedWarehouseId,\n noCache,\n mode,\n });\n\n if (hasQueries) {\n console.log(`Generated query types: ${resolvedOutFile}`);\n }\n\n const metricConfig = path.join(metricViewsFolder, METRIC_CONFIG_FILE);\n if (fs.existsSync(metricConfig)) {\n const typesDir = path.dirname(resolvedOutFile);\n console.log(\n `Generated metric types: ${path.join(typesDir, \"metric-views.d.ts\")}`,\n );\n }\n }\n } else {\n console.error(\n \"Skipping query type generation: no warehouse ID. Set DATABRICKS_WAREHOUSE_ID or pass as argument.\",\n );\n }\n\n // Generate serving endpoint types (no warehouse required)\n const servingOutFile = path.join(\n process.cwd(),\n \"shared/appkit-types/serving.d.ts\",\n );\n await typeGen.generateServingTypes({\n outFile: servingOutFile,\n noCache,\n });\n console.log(`Generated serving types: ${servingOutFile}`);\n } catch (error) {\n if (\n error instanceof Error &&\n error.message.includes(\"Cannot find module\")\n ) {\n console.error(\n \"Error: The 'generate-types' command is only available in @databricks/appkit.\",\n );\n console.error(\"Please install @databricks/appkit to use this command.\");\n process.exit(1);\n }\n // TypegenSyntaxError / TypegenFatalError carry a complete, actionable\n // message (which queries failed and how to debug them). The stack trace\n // points into appkit internals and is noise for app developers, so print\n // only the message and exit non-zero instead of letting it bubble up.\n if (\n error instanceof Error &&\n (error.name === \"TypegenSyntaxError\" ||\n error.name === \"TypegenFatalError\")\n ) {\n console.error(error.message);\n process.exit(1);\n }\n throw error;\n }\n}\n\n/**\n * Spawn the detached blocking worker that refreshes real types in the background\n * after the foreground non-blocking generate has already written degraded types.\n *\n * Re-invokes THIS CLI (`process.execPath` + `process.argv[1]` — the bin entry\n * that launched us) with `generate-types --wait --worker-lock <lockPath>` plus\n * the same positional target options the foreground used, so the worker writes\n * to the same out file / reads the same query folder. The worker is:\n * - `detached: true` + `.unref()` so it outlives this process (install/dev-setup\n * can finish and exit while the worker keeps warming the warehouse).\n * - `stdio: \"ignore\"` so it never holds the parent's pipes open or interleaves\n * output into the install/dev log.\n *\n * Spawning is wrapped so any failure is non-fatal: the caller still has degraded\n * types and exits 0.\n *\n * @param lockPath - the acquired single-flight lock; passed to the worker so it\n * releases the SAME lock when it finishes.\n * @param targets - the foreground's positional args, forwarded verbatim.\n * @returns true if the worker was spawned, false if spawning threw.\n */\nexport function spawnTypegenWorker(\n lockPath: string,\n targets: { rootDir?: string; outFile?: string; warehouseId?: string },\n): boolean {\n // The script the runtime launched us with (the `appkit` bin shim). Re-running\n // it under the same node binary reproduces this exact CLI in the worker.\n const cliEntry = process.argv[1];\n\n // Forward the positionals in declaration order (rootDir, outFile,\n // warehouseId). Stop at the first undefined so we never pass a literal\n // \"undefined\" — commander would treat it as a positional value. (rootDir is\n // effectively always set by commander's default, but guard anyway.)\n const positionals: string[] = [];\n for (const value of [targets.rootDir, targets.outFile, targets.warehouseId]) {\n if (value === undefined) break;\n positionals.push(value);\n }\n\n const args = [\n // Forward the parent's node/loader flags so the worker runs under the same\n // runtime. Critically this carries tsx's `--require`/`--import` when the CLI\n // is run from source (`tsx index.ts …`); without them the worker would be\n // `node index.ts …`, which can't parse TypeScript and dies silently — the\n // degraded types would then never refresh. Empty for the built bin (plain\n // `node bin/appkit.js`), so production behaviour is unchanged.\n ...process.execArgv,\n cliEntry,\n \"generate-types\",\n \"--wait\",\n \"--worker-lock\",\n lockPath,\n ...positionals,\n ];\n\n try {\n const child = spawn(process.execPath, args, {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n return true;\n } catch (error) {\n // Non-fatal: the foreground already wrote degraded types. Log and move on.\n console.error(\n `Could not start background type refresh: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n return false;\n }\n}\n\n/**\n * The command action. Orchestrates the non-blocking foreground contract:\n * 1. Run the library generate (writes degraded types immediately in non-blocking\n * mode; does the full blocking lifecycle when this is the worker).\n * 2. If this is a non-blocking, non-worker invocation, try to spawn the detached\n * blocking worker behind the single-flight lock. If the lock is already held\n * by a live worker, skip (single-flight) with a one-line note. Either way the\n * foreground returns normally (exit 0).\n * 3. If this IS the worker (`--worker-lock` present), it ran blocking above and\n * releases the lock here (and via a process-exit guard, so a hard failure /\n * process.exit still frees it).\n */\nasync function generateTypesAction(\n rootDir: string | undefined,\n outFile: string | undefined,\n warehouseId: string | undefined,\n options: GenerateTypesOptions,\n) {\n const isWorker = typeof options.workerLock === \"string\";\n\n // A worker must always free its lock, even if the blocking generate throws or\n // calls process.exit (TypegenFatalError → exit 1). The exit handler covers the\n // process.exit / uncaught paths; the finally covers the normal return.\n if (isWorker && options.workerLock) {\n const lockPath = options.workerLock;\n process.once(\"exit\", () => releaseSpawnLock(lockPath));\n }\n\n try {\n await runGenerateTypes(rootDir, outFile, warehouseId, options);\n } finally {\n if (isWorker && options.workerLock) {\n releaseSpawnLock(options.workerLock);\n }\n }\n\n // Only a non-blocking, non-worker invocation spawns. A worker is always\n // --wait (so resolveTypegenMode → \"blocking\"), which both prevents recursion\n // and means we never get here for a worker.\n if (!isWorker && resolveTypegenMode(options) === \"non-blocking\") {\n const resolvedRootDir = rootDir || process.cwd();\n const lockPath = getSpawnLockPath(resolvedRootDir);\n\n if (acquireSpawnLock(lockPath)) {\n spawnTypegenWorker(lockPath, { rootDir, outFile, warehouseId });\n } else {\n console.log(\"Type refresh already in progress, skipping.\");\n }\n }\n}\n\nexport const generateTypesCommand = new Command(\"generate-types\")\n .description(\"Generate TypeScript types from SQL queries\")\n .argument(\"[rootDir]\", \"Root directory of the project\", process.cwd())\n .argument(\n \"[outFile]\",\n \"Output file path\",\n path.join(process.cwd(), \"shared/appkit-types/analytics.d.ts\"),\n )\n .argument(\"[warehouseId]\", \"Databricks warehouse ID\")\n .option(\"--no-cache\", \"Disable caching for type generation\")\n .option(\n \"--wait\",\n \"Wait for warehouse readiness instead of degrading (use for CI)\",\n )\n // Internal: marks the detached background worker and carries the lock it must\n // release. Hidden from --help; users should never pass it.\n .addOption(\n new Option(\n \"--worker-lock <path>\",\n \"Internal: detached worker lock path\",\n ).hideHelp(),\n )\n .addHelpText(\n \"after\",\n `\nExamples:\n $ appkit generate-types\n $ appkit generate-types . shared/appkit-types/analytics.d.ts\n $ appkit generate-types . shared/appkit-types/analytics.d.ts my-warehouse-id\n $ appkit generate-types --no-cache\n $ appkit generate-types --wait # CI: wait for the warehouse and fail on a cold one`,\n )\n .action(generateTypesAction);\n"],"mappings":"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"}
@@ -56,6 +56,22 @@ declare class AnalyticsPlugin extends Plugin implements ToolProvider {
56
56
  * When called via asUser(req), uses the user's Databricks credentials.
57
57
  */
58
58
  _handleQueryRoute(req: express.Request, res: express.Response): Promise<void>;
59
+ /**
60
+ * Handle metric-view execution requests (`POST /api/analytics/metric/:key`).
61
+ *
62
+ * Mirrors {@link _handleQueryRoute}'s JSON SSE path: the outer
63
+ * `executeStream` disables cache/retry and streams warehouse-readiness
64
+ * (`warehouse_status`) events, then the inner `execute` builds the metric SQL
65
+ * and delivers rows through {@link deliverJsonResult} as a `result` message.
66
+ * The `originalError` re-throw discipline preserves each error's structured
67
+ * `errorCode`/`clientMessage` for the SSE error payload.
68
+ *
69
+ * Lane dispatch is driven by the registration: an SP-lane metric runs as the
70
+ * app service principal (shared cache); an OBO-lane metric runs
71
+ * on-behalf-of the requesting user via `asUser(req)` (per-user cache keyed by
72
+ * a hash of the user identity).
73
+ */
74
+ _handleMetricRoute(req: express.Request, res: express.Response): Promise<void>;
59
75
  /**
60
76
  * JSON_ARRAY SSE path. Delegates the disposition/format fallback to
61
77
  * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`,
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.d.ts","names":[],"sources":["../../../src/plugins/analytics/analytics.ts"],"mappings":";;;;;;;;;;;;;;cAiGa,eAAA,SAAwB,MAAA,YAAkB,YAAA;;SAE9C,QAAA,EAAuB,cAAA;EAAA,iBAEb,WAAA;EAAA,UACC,MAAA,EAAQ,gBAAA;EAAA,QAGlB,SAAA;EAAA,QACA,cAAA;;;AATV;;;;;;UAmBU,gBAAA;cAEI,MAAA,EAAQ,gBAAA;EAWpB,YAAA,CAAa,MAAA,EAAQ,UAAA;EAiClB;;;;;;EAHG,mBAAA,CACJ,GAAA,EAAK,OAAA,CAAQ,OAAA,EACb,GAAA,EAAK,OAAA,CAAQ,QAAA,GACZ,OAAA;EA8jB2B;;;;;;;EAAA,QAziBhB,mBAAA;EAomBX;;;;;EAxkBG,eAAA,CAAgB,WAAA,WAAsB,OAAA;EA8gBvB;;;;EAtgBf,iBAAA,CACJ,GAAA,EAAK,OAAA,CAAQ,OAAA,EACb,GAAA,EAAK,OAAA,CAAQ,QAAA,GACZ,OAAA;EA7H8D;;;;;;EAAA,QAiVnD,qBAAA;EA5UI;;;;;;;;;EAAA,QAyWV,sBAAA;EA9UK;;;;;;;;;;;EAAA,QA8WC,uBAAA;EA5R8B;;;;;;;EAibtC,0BAAA,CAA2B,MAAA,EAAQ,WAAA,GAAc,OAAA;EAtapD;;;;;;;;;;;;;;;;;;EAAA,QAucK,qBAAA;EAmFF;;;;;;;;;;;;;;;EA1BA,KAAA,CACJ,KAAA,UACA,UAAA,GAAa,MAAA,SAAe,aAAA,sBAC5B,gBAAA,GAAmB,MAAA,eACnB,MAAA,GAAS,WAAA,GACR,OAAA;EAqBG,QAAA,CAAA,GAAY,OAAA;EAAA,QAIV,KAAA;EAuBR,aAAA,CAAA,GAAiB,mBAAA;EAIX,gBAAA,CACJ,IAAA,UACA,IAAA,WACA,MAAA,GAAS,WAAA,GACR,OAAA;EA3D2B;;;;;;;EAsE9B,OAAA,CAAQ,IAAA,GAXE,cAAA,GAWoD,MAAA,SAAA,YAAA;EAnEpD;AAyJZ;;;EA9EE,OAAA,CAAA;IA8EoB;;;2BA7JL,UAAA,GACA,MAAA,SAAe,aAAA,sBAAiC,gBAAA,GAC1C,MAAA,eAAmB,MAAA,GAC7B,WAAA,KACR,OAAA;EAAA;AAAA;;;;cAyJQ,SAAA,EAAS,QAAA,QAAA,eAAA,EAAA,gBAAA"}
1
+ {"version":3,"file":"analytics.d.ts","names":[],"sources":["../../../src/plugins/analytics/analytics.ts"],"mappings":";;;;;;;;;;;;;;cAyGa,eAAA,SAAwB,MAAA,YAAkB,YAAA;;SAE9C,QAAA,EAAuB,cAAA;EAAA,iBAEb,WAAA;EAAA,UACC,MAAA,EAAQ,gBAAA;EAAA,QAGlB,SAAA;EAAA,QACA,cAAA;;;AATV;;;;;;UAmBU,gBAAA;cAEI,MAAA,EAAQ,gBAAA;EAWpB,YAAA,CAAa,MAAA,EAAQ,UAAA;EA2ClB;;;;;;EAHG,mBAAA,CACJ,GAAA,EAAK,OAAA,CAAQ,OAAA,EACb,GAAA,EAAK,OAAA,CAAQ,QAAA,GACZ,OAAA;EA4RA;;;;;;;EAAA,QAvQW,mBAAA;EAm0BI;;;;;EAvyBZ,eAAA,CAAgB,WAAA,WAAsB,OAAA;EAq1BkB;;;;EA70BxD,iBAAA,CACJ,GAAA,EAAK,OAAA,CAAQ,OAAA,EACb,GAAA,EAAK,OAAA,CAAQ,QAAA,GACZ,OAAA;EAuwBA;;;;;;;;;;;;;;;EA1iBG,kBAAA,CACJ,GAAA,EAAK,OAAA,CAAQ,OAAA,EACb,GAAA,EAAK,OAAA,CAAQ,QAAA,GACZ,OAAA;EAlViB;;;;;;EAAA,QAwkBN,qBAAA;EAphBC;;;;;;;;;EAAA,QAijBP,sBAAA;EAtfF;;;;;;;;;;;EAAA,QAshBQ,uBAAA;EApTP;;;;;;;EAycD,0BAAA,CAA2B,MAAA,EAAQ,WAAA,GAAc,OAAA;EAAd;;;;;;;;;;;;;;;;;;EAAA,QAiCjC,qBAAA;EAkHF;;;;;;;;;;;;;;;EAzDA,KAAA,CACJ,KAAA,UACA,UAAA,GAAa,MAAA,SAAe,aAAA,sBAC5B,gBAAA,GAAmB,MAAA,eACnB,MAAA,GAAS,WAAA,GACR,OAAA;EAqBG,QAAA,CAAA,GAAY,OAAA;EAAA,QAIV,KAAA;EAuBR,aAAA,CAAA,GAAiB,mBAAA;EAIX,gBAAA,CACJ,IAAA,UACA,IAAA,WACA,MAAA,GAAS,WAAA,GACR,OAAA;EA1DqC;;;;AA2J1C;;;EAtFE,OAAA,CAAQ,IAAA,GAXE,cAAA,GAWoD,MAAA,SAAA,YAAA;EAsF1C;;;;EA9EpB,OAAA,CAAA;IA8EoB;;;2BA7JL,UAAA,GACA,MAAA,SAAe,aAAA,sBAAiC,gBAAA,GAC1C,MAAA,eAAmB,MAAA,GAC7B,WAAA,KACR,OAAA;EAAA;AAAA;;;;cAyJQ,SAAA,EAAS,QAAA,QAAA,eAAA,EAAA,gBAAA"}
@@ -15,9 +15,14 @@ import { defineTool, executeFromRegistry, toolsFromRegistry } from "../../core/a
15
15
  import { assertReadOnlySql } from "../../core/agent/tools/sql-policy.js";
16
16
  import { queryDefaults } from "./defaults.js";
17
17
  import manifest_default from "./manifest.js";
18
+ import { buildMetricSql } from "./mv/formatters.js";
19
+ import { composeMetricCacheKey, deriveMetricExecutorKey } from "./mv/cache.js";
20
+ import { loadMetricRegistry } from "./mv/registry.js";
21
+ import { normalizeAnalyticsFormat } from "./types.js";
22
+ import { validateMetricRequest } from "./mv/schemas.js";
23
+ import "./metric.js";
18
24
  import { QueryProcessor } from "./query.js";
19
25
  import { deliverArrowBytes, deliverJsonResult } from "./result-delivery.js";
20
- import { normalizeAnalyticsFormat } from "./types.js";
21
26
  import { z } from "zod";
22
27
 
23
28
  //#region src/plugins/analytics/analytics.ts
@@ -92,6 +97,14 @@ var AnalyticsPlugin = class extends Plugin {
92
97
  await this._handleQueryRoute(req, res);
93
98
  }
94
99
  });
100
+ this.route(router, {
101
+ name: "metric",
102
+ method: "post",
103
+ path: "/metric/:key",
104
+ handler: async (req, res) => {
105
+ await this._handleMetricRoute(req, res);
106
+ }
107
+ });
95
108
  this.route(router, {
96
109
  name: "arrow-columns",
97
110
  method: "get",
@@ -240,6 +253,154 @@ var AnalyticsPlugin = class extends Plugin {
240
253
  }, streamExecutionSettings, executorKey);
241
254
  }
242
255
  /**
256
+ * Handle metric-view execution requests (`POST /api/analytics/metric/:key`).
257
+ *
258
+ * Mirrors {@link _handleQueryRoute}'s JSON SSE path: the outer
259
+ * `executeStream` disables cache/retry and streams warehouse-readiness
260
+ * (`warehouse_status`) events, then the inner `execute` builds the metric SQL
261
+ * and delivers rows through {@link deliverJsonResult} as a `result` message.
262
+ * The `originalError` re-throw discipline preserves each error's structured
263
+ * `errorCode`/`clientMessage` for the SSE error payload.
264
+ *
265
+ * Lane dispatch is driven by the registration: an SP-lane metric runs as the
266
+ * app service principal (shared cache); an OBO-lane metric runs
267
+ * on-behalf-of the requesting user via `asUser(req)` (per-user cache keyed by
268
+ * a hash of the user identity).
269
+ */
270
+ async _handleMetricRoute(req, res) {
271
+ const { key } = req.params;
272
+ logger.debug(req, "Executing metric: %s", key);
273
+ const event = logger.event(req);
274
+ event?.setComponent("analytics", "executeMetric").setContext("analytics", {
275
+ metric_key: key,
276
+ plugin: this.name
277
+ });
278
+ if (!key) {
279
+ res.status(400).json({ error: "metric key is required" });
280
+ return;
281
+ }
282
+ let registry;
283
+ try {
284
+ registry = await loadMetricRegistry(this.app, req, this.devFileReader);
285
+ } catch (err) {
286
+ const reason = err instanceof Error ? err.message : String(err);
287
+ logger.warn(req, "Failed to load metric registry: %s", reason);
288
+ event?.setContext("analytics", { metric_registry_load_error: reason });
289
+ res.status(503).json({
290
+ error: "Metric registry not available",
291
+ code: "METRIC_REGISTRY_LOAD_FAILED"
292
+ });
293
+ return;
294
+ }
295
+ const registration = Object.hasOwn(registry, key) ? registry[key] : void 0;
296
+ if (!registration) {
297
+ event?.setContext("analytics", { unknown_metric_key: key });
298
+ res.status(404).json({ error: "Metric not found" });
299
+ return;
300
+ }
301
+ let request;
302
+ try {
303
+ request = validateMetricRequest(req.body ?? {});
304
+ } catch (err) {
305
+ if (err instanceof AppKitError) {
306
+ res.status(err.statusCode).json({
307
+ error: err.message,
308
+ code: err.code
309
+ });
310
+ return;
311
+ }
312
+ event?.setContext("analytics", {
313
+ unexpected_error: err instanceof Error ? err.message : String(err),
314
+ metric_key: key
315
+ });
316
+ logger.warn(req, "Unexpected throw during metric request validation for %s: %s", key, err instanceof Error ? err.message : String(err));
317
+ res.status(400).json({ error: "Invalid request body" });
318
+ return;
319
+ }
320
+ let executor;
321
+ let executorKey;
322
+ try {
323
+ const isObo = registration.lane === "obo";
324
+ executor = isObo ? this.asUser(req) : this;
325
+ executorKey = deriveMetricExecutorKey({
326
+ lane: registration.lane,
327
+ userIdentity: isObo ? this.resolveUserId(req) : void 0
328
+ });
329
+ } catch (err) {
330
+ if (err instanceof AppKitError) {
331
+ res.status(err.statusCode).json({
332
+ error: err.message,
333
+ code: err.code
334
+ });
335
+ return;
336
+ }
337
+ throw err;
338
+ }
339
+ const cacheConfig = {
340
+ ...queryDefaults.cache,
341
+ cacheKey: composeMetricCacheKey({
342
+ metricKey: key,
343
+ source: registration.source,
344
+ measures: request.measures,
345
+ dimensions: request.dimensions,
346
+ timeGrain: request.timeGrain,
347
+ timeDimension: request.timeDimension,
348
+ filter: request.filter,
349
+ format: "JSON_ARRAY",
350
+ executorKey,
351
+ limit: request.limit
352
+ })
353
+ };
354
+ const sqlConfig = {
355
+ ...queryDefaults,
356
+ cache: cacheConfig
357
+ };
358
+ const streamExecutionSettings = { default: {
359
+ cache: { enabled: false },
360
+ retry: { enabled: false }
361
+ } };
362
+ const startupTimeoutMs = this.config.warehouseStartupTimeoutMs ?? DEFAULT_WAREHOUSE_STARTUP_TIMEOUT_MS;
363
+ const autoStartWarehouse = this.config.autoStartWarehouse ?? true;
364
+ const self = this;
365
+ await executor.executeStream(res, async function* (signal) {
366
+ const workspaceClient = getWorkspaceClient();
367
+ const warehouseId = await getWarehouseId();
368
+ const readinessUpdates = streamCallbacks((emit) => self.SQLClient.ensureWarehouseRunning(workspaceClient, warehouseId, {
369
+ signal,
370
+ timeoutMs: startupTimeoutMs,
371
+ autoStart: autoStartWarehouse,
372
+ onStatus: emit
373
+ }));
374
+ for await (const update of readinessUpdates) yield {
375
+ type: "warehouse_status",
376
+ status: {
377
+ state: update.state,
378
+ elapsedMs: update.elapsedMs
379
+ }
380
+ };
381
+ let originalError;
382
+ const sqlResult = await executor.execute(async (sig) => {
383
+ try {
384
+ const { statement, parameters } = buildMetricSql(registration, request);
385
+ const processedParams = await self.queryProcessor.processQueryParams(statement, Object.keys(parameters).length > 0 ? parameters : void 0);
386
+ return await self._executeJsonArrayPath(executor, statement, processedParams, sig);
387
+ } catch (err) {
388
+ originalError = err;
389
+ throw err;
390
+ }
391
+ }, { default: sqlConfig }, executorKey);
392
+ if (!sqlResult.ok) {
393
+ const msg = sqlResult.message;
394
+ const lower = msg.toLowerCase();
395
+ if (lower.includes("operation was aborted") || lower.includes("the request was aborted") || lower.includes("statement was canceled")) throw new DOMException(lower.includes("canceled") ? msg : "The operation was aborted.", "AbortError");
396
+ if (originalError instanceof AppKitError) throw originalError;
397
+ const inner = msg.startsWith("Statement failed: ") ? msg.slice(18) : msg;
398
+ throw ExecutionError.statementFailed(inner);
399
+ }
400
+ yield sqlResult.data;
401
+ }, streamExecutionSettings, executorKey);
402
+ }
403
+ /**
243
404
  * JSON_ARRAY SSE path. Delegates the disposition/format fallback to
244
405
  * {@link deliverJsonResult} (INLINE JSON_ARRAY → on `needs-arrow-inline`,
245
406
  * INLINE ARROW_STREAM decoded to rows) and wraps the rows in a `result`