@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.
- package/dist/app/index.d.ts +49 -2
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +87 -10
- package/dist/app/index.js.map +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cli/commands/generate-types.js +9 -4
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts +16 -0
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js +162 -1
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/analytics/metric.js +7 -0
- package/dist/plugins/analytics/mv/cache.js +51 -0
- package/dist/plugins/analytics/mv/cache.js.map +1 -0
- package/dist/plugins/analytics/mv/constants.js +72 -0
- package/dist/plugins/analytics/mv/constants.js.map +1 -0
- package/dist/plugins/analytics/mv/formatters.js +151 -0
- package/dist/plugins/analytics/mv/formatters.js.map +1 -0
- package/dist/plugins/analytics/mv/index.js +6 -0
- package/dist/plugins/analytics/mv/registry.js +55 -0
- package/dist/plugins/analytics/mv/registry.js.map +1 -0
- package/dist/plugins/analytics/mv/schemas.js +178 -0
- package/dist/plugins/analytics/mv/schemas.js.map +1 -0
- package/dist/plugins/analytics/types.js.map +1 -1
- package/dist/schemas/metric-fqn.js +14 -0
- package/dist/schemas/metric-fqn.js.map +1 -0
- package/dist/shared/src/schemas/metric-fqn.js +78 -46
- package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
- package/dist/shared/src/schemas/metric-source.js +90 -0
- package/dist/shared/src/schemas/metric-source.js.map +1 -0
- package/dist/type-generator/index.js +14 -7
- package/dist/type-generator/index.js.map +1 -1
- package/dist/type-generator/mv-registry/config.js +13 -31
- package/dist/type-generator/mv-registry/config.js.map +1 -1
- package/dist/type-generator/mv-registry/describe.js +1 -31
- package/dist/type-generator/mv-registry/describe.js.map +1 -1
- package/dist/type-generator/mv-registry/sync.js +1 -1
- package/dist/type-generator/mv-registry/sync.js.map +1 -1
- package/dist/type-generator/vite-plugin.d.ts +5 -1
- package/dist/type-generator/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/vite-plugin.js +21 -4
- package/dist/type-generator/vite-plugin.js.map +1 -1
- package/docs/development/type-generation.md +3 -3
- package/docs/plugins/analytics.md +172 -23
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createLogger } from "../../../logging/logger.js";
|
|
2
|
+
import { METRIC_CONFIG_FILE } from "../../../shared/src/schemas/metric-fqn.js";
|
|
3
|
+
import { laneFromExecutor } from "./constants.js";
|
|
4
|
+
import { metricSourceSchema } from "../../../shared/src/schemas/metric-source.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
//#region src/plugins/analytics/mv/registry.ts
|
|
8
|
+
const logger = createLogger("analytics:metric-views");
|
|
9
|
+
/**
|
|
10
|
+
* Read and validate `config/metric-views/definitions.json` into a metric registry.
|
|
11
|
+
*
|
|
12
|
+
* Async and stateless — registration is a pure config parse with no warehouse
|
|
13
|
+
* round-trip, no `DESCRIBE`, and no build-time metadata bundle.
|
|
14
|
+
*
|
|
15
|
+
* The file is read **through {@link AppManager.readMetricViewsConfig}** rather
|
|
16
|
+
* than `node:fs` directly, so this path is dev-tunnel-aware (a `?dev` request
|
|
17
|
+
* reads the developer's local file over the WebSocket tunnel) and inherits the
|
|
18
|
+
* traversal guard. In production that's a plain `fs.readFile` under the hood,
|
|
19
|
+
* so the semantics below are unchanged.
|
|
20
|
+
*
|
|
21
|
+
* Absent file -> empty registry (`null` from `readMetricViewsConfig`).
|
|
22
|
+
* Malformed file -> 503 (throws).
|
|
23
|
+
*
|
|
24
|
+
* @param app - The {@link AppManager} that resolves + reads the config file.
|
|
25
|
+
* @param req - Optional request object, used to detect dev mode.
|
|
26
|
+
* @param devFileReader - Optional dev tunnel reader.
|
|
27
|
+
*/
|
|
28
|
+
async function loadMetricRegistry(app, req, devFileReader) {
|
|
29
|
+
const metricPath = path.join(app.metricViewsDir, METRIC_CONFIG_FILE);
|
|
30
|
+
const raw = await app.readMetricViewsConfig(METRIC_CONFIG_FILE, req, devFileReader);
|
|
31
|
+
if (raw === null) return Object.create(null);
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`Failed to parse definitions.json at ${metricPath}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
const result = metricSourceSchema.safeParse(parsed);
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
41
|
+
throw new Error(`Invalid definitions.json at ${metricPath}: ${issues}`);
|
|
42
|
+
}
|
|
43
|
+
const registry = Object.create(null);
|
|
44
|
+
for (const [key, entry] of Object.entries(result.data.metricViews ?? {})) registry[key] = {
|
|
45
|
+
key,
|
|
46
|
+
source: entry.source,
|
|
47
|
+
lane: laneFromExecutor(entry.executor)
|
|
48
|
+
};
|
|
49
|
+
logger.debug("Loaded metric registry: %d entry(ies)", Object.keys(registry).length);
|
|
50
|
+
return registry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
export { loadMetricRegistry };
|
|
55
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","names":[],"sources":["../../../../src/plugins/analytics/mv/registry.ts"],"sourcesContent":["import path from \"node:path\";\n// Canonical metric-source schema — the single source of truth for\n// `config/metric-views/definitions.json`. Imported from the shared source\n// directly (matching the type-generator's runtime, which pulls the zod-free\n// `metric-fqn.ts` from the same tree) so the runtime and the generated JSON\n// schema validate identically.\nimport { metricSourceSchema } from \"../../../../../shared/src/schemas/metric-source\";\nimport type { AppManager, DevFileReader, RequestLike } from \"../../../app\";\nimport { createLogger } from \"../../../logging/logger\";\nimport type { MetricRegistration } from \"../types\";\nimport { laneFromExecutor, METRIC_CONFIG_FILE } from \"./constants\";\n\nconst logger = createLogger(\"analytics:metric-views\");\n\n/**\n * Read and validate `config/metric-views/definitions.json` into a metric registry.\n *\n * Async and stateless — registration is a pure config parse with no warehouse\n * round-trip, no `DESCRIBE`, and no build-time metadata bundle.\n *\n * The file is read **through {@link AppManager.readMetricViewsConfig}** rather\n * than `node:fs` directly, so this path is dev-tunnel-aware (a `?dev` request\n * reads the developer's local file over the WebSocket tunnel) and inherits the\n * traversal guard. In production that's a plain `fs.readFile` under the hood,\n * so the semantics below are unchanged.\n *\n * Absent file -> empty registry (`null` from `readMetricViewsConfig`).\n * Malformed file -> 503 (throws).\n *\n * @param app - The {@link AppManager} that resolves + reads the config file.\n * @param req - Optional request object, used to detect dev mode.\n * @param devFileReader - Optional dev tunnel reader.\n */\nexport async function loadMetricRegistry(\n app: AppManager,\n req?: RequestLike,\n devFileReader?: DevFileReader,\n): Promise<Record<string, MetricRegistration>> {\n const metricPath = path.join(app.metricViewsDir, METRIC_CONFIG_FILE);\n\n const raw = await app.readMetricViewsConfig(\n METRIC_CONFIG_FILE,\n req,\n devFileReader,\n );\n if (raw === null) {\n // Absent file (ENOENT in prod / dev-tunnel not-found) or a rejected\n // traversal path → dormant. Same as the old ENOENT branch.\n return Object.create(null);\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(\n `Failed to parse definitions.json at ${metricPath}: ${(err as Error).message}`,\n );\n }\n\n const result = metricSourceSchema.safeParse(parsed);\n if (!result.success) {\n const issues = result.error.issues\n .map((i) => `${i.path.join(\".\")}: ${i.message}`)\n .join(\"; \");\n throw new Error(`Invalid definitions.json at ${metricPath}: ${issues}`);\n }\n\n // Null-prototype map so a metric key that collides with an inherited\n // `Object.prototype` member (`__proto__`, `constructor`, `toString`, …)\n // cannot resolve to a truthy non-registration at the `registry[key]` read\n // site and slip past the unknown-key 404.\n // Keys are still grammar-gated by `metricKeySchema` (identifier shape),\n // but the null prototype removes the whole class of inherited-property lookups as a boundary.\n const registry: Record<string, MetricRegistration> = Object.create(null);\n for (const [key, entry] of Object.entries(result.data.metricViews ?? {})) {\n registry[key] = {\n key,\n source: entry.source,\n lane: laneFromExecutor(entry.executor),\n };\n }\n\n logger.debug(\n \"Loaded metric registry: %d entry(ies)\",\n Object.keys(registry).length,\n );\n return registry;\n}\n"],"mappings":";;;;;;;AAYA,MAAM,SAAS,aAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;AAqBrD,eAAsB,mBACpB,KACA,KACA,eAC6C;CAC7C,MAAM,aAAa,KAAK,KAAK,IAAI,gBAAgB,mBAAmB;CAEpE,MAAM,MAAM,MAAM,IAAI,sBACpB,oBACA,KACA,cACD;AACD,KAAI,QAAQ,KAGV,QAAO,OAAO,OAAO,KAAK;CAG5B,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;UACjB,KAAK;AACZ,QAAM,IAAI,MACR,uCAAuC,WAAW,IAAK,IAAc,UACtE;;CAGH,MAAM,SAAS,mBAAmB,UAAU,OAAO;AACnD,KAAI,CAAC,OAAO,SAAS;EACnB,MAAM,SAAS,OAAO,MAAM,OACzB,KAAK,MAAM,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC,IAAI,EAAE,UAAU,CAC/C,KAAK,KAAK;AACb,QAAM,IAAI,MAAM,+BAA+B,WAAW,IAAI,SAAS;;CASzE,MAAM,WAA+C,OAAO,OAAO,KAAK;AACxE,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,KAAK,eAAe,EAAE,CAAC,CACtE,UAAS,OAAO;EACd;EACA,QAAQ,MAAM;EACd,MAAM,iBAAiB,MAAM,SAAS;EACvC;AAGH,QAAO,MACL,yCACA,OAAO,KAAK,SAAS,CAAC,OACvB;AACD,QAAO"}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { ValidationError } from "../../../errors/validation.js";
|
|
2
|
+
import "../../../errors/index.js";
|
|
3
|
+
import { isValidColumnName } from "../../../shared/src/schemas/metric-fqn.js";
|
|
4
|
+
import { LIST_VALUE_OPERATORS, METRIC_DIMENSIONS_MAX, METRIC_FILTER_GROUP_MAX, METRIC_FILTER_MAX_DEPTH, METRIC_FILTER_OPERATORS, METRIC_FILTER_VALUES_MAX, METRIC_LIMIT_MAX, METRIC_MEASURES_MAX, NULL_OPERATORS, SINGLE_VALUE_OPERATORS, STRING_OPERATORS, TIME_GRAIN_PATTERN } from "./constants.js";
|
|
5
|
+
import { normalizeAnalyticsFormat } from "../types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
//#region src/plugins/analytics/mv/schemas.ts
|
|
9
|
+
/** A leaf predicate: `{ member, operator, values? }`, no extra keys. */
|
|
10
|
+
const filterPredicateSchema = z.object({
|
|
11
|
+
member: z.string().min(1, { message: "filter predicate 'member' cannot be empty" }).refine(isValidColumnName, { message: "filter predicate 'member' contains a character that cannot be used in a SQL identifier (control character or newline)" }),
|
|
12
|
+
operator: z.string().min(1, { message: "filter predicate 'operator' cannot be empty" }),
|
|
13
|
+
values: z.array(z.union([z.string(), z.number()])).max(METRIC_FILTER_VALUES_MAX, { message: `filter predicate 'values' length exceeds the maximum of ${METRIC_FILTER_VALUES_MAX}` }).optional()
|
|
14
|
+
}).strict();
|
|
15
|
+
/** Recursive filter: a predicate leaf or an `{ and }` / `{ or }` group. */
|
|
16
|
+
const filterSchema = z.lazy(() => z.union([
|
|
17
|
+
filterPredicateSchema,
|
|
18
|
+
z.object({ and: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, { message: `filter 'and' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children` }) }).strict(),
|
|
19
|
+
z.object({ or: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, { message: `filter 'or' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children` }) }).strict()
|
|
20
|
+
]));
|
|
21
|
+
const metricRequestSchema = z.object({
|
|
22
|
+
measures: z.array(z.string().min(1, "measure name cannot be empty").refine(isValidColumnName, { message: "measure name contains a character that cannot be used in a SQL identifier (control character or newline)" })).min(1, "at least one measure is required").max(METRIC_MEASURES_MAX, { message: `measures length exceeds the maximum of ${METRIC_MEASURES_MAX}` }),
|
|
23
|
+
dimensions: z.array(z.string().min(1, "dimension name cannot be empty").refine(isValidColumnName, { message: "dimension name contains a character that cannot be used in a SQL identifier (control character or newline)" })).max(METRIC_DIMENSIONS_MAX, { message: `dimensions length exceeds the maximum of ${METRIC_DIMENSIONS_MAX}` }).optional(),
|
|
24
|
+
filter: filterSchema.optional(),
|
|
25
|
+
timeGrain: z.string().min(1, { message: "timeGrain cannot be empty" }).regex(TIME_GRAIN_PATTERN, { message: "timeGrain must match /^[a-z][a-z_]*$/" }).optional(),
|
|
26
|
+
timeDimension: z.string().min(1, { message: "timeDimension cannot be empty" }).refine(isValidColumnName, { message: "timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)" }).optional(),
|
|
27
|
+
limit: z.number().int({ message: "limit must be an integer" }).positive({ message: "limit must be positive" }).max(METRIC_LIMIT_MAX, { message: `limit exceeds the maximum of ${METRIC_LIMIT_MAX}` }).optional(),
|
|
28
|
+
format: z.enum([
|
|
29
|
+
"JSON_ARRAY",
|
|
30
|
+
"ARROW_STREAM",
|
|
31
|
+
"JSON",
|
|
32
|
+
"ARROW"
|
|
33
|
+
]).optional()
|
|
34
|
+
}).strict().superRefine((value, ctx) => {
|
|
35
|
+
if (value.filter != null) validateFilterTree(value.filter, ctx, ["filter"], 0);
|
|
36
|
+
const seen = /* @__PURE__ */ new Set();
|
|
37
|
+
const collided = /* @__PURE__ */ new Set();
|
|
38
|
+
for (const name of [...value.measures, ...value.dimensions ?? []]) {
|
|
39
|
+
if (seen.has(name)) collided.add(name);
|
|
40
|
+
seen.add(name);
|
|
41
|
+
}
|
|
42
|
+
if (collided.size > 0) ctx.addIssue({
|
|
43
|
+
code: "custom",
|
|
44
|
+
message: "measures and dimensions must be unique across both lists (a name cannot repeat, nor appear as both a measure and a dimension)",
|
|
45
|
+
path: ["measures"]
|
|
46
|
+
});
|
|
47
|
+
if (value.format != null && normalizeAnalyticsFormat(value.format) !== "JSON_ARRAY") ctx.addIssue({
|
|
48
|
+
code: "custom",
|
|
49
|
+
message: "format: only JSON_ARRAY is supported on the metric route at v1 (ARROW_STREAM is not yet implemented)",
|
|
50
|
+
path: ["format"]
|
|
51
|
+
});
|
|
52
|
+
if (value.timeGrain != null && value.timeDimension == null) ctx.addIssue({
|
|
53
|
+
code: "custom",
|
|
54
|
+
message: "timeDimension is required when timeGrain is set",
|
|
55
|
+
path: ["timeDimension"]
|
|
56
|
+
});
|
|
57
|
+
if (value.timeDimension != null && !(value.dimensions ?? []).includes(value.timeDimension)) ctx.addIssue({
|
|
58
|
+
code: "custom",
|
|
59
|
+
message: "timeDimension must be one of dimensions",
|
|
60
|
+
path: ["timeDimension"]
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
function validateFilterTree(node, ctx, path, depth) {
|
|
64
|
+
if (node === null || typeof node !== "object") {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
path,
|
|
68
|
+
message: "filter node must be a Predicate or { and } / { or } group"
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if ("and" in node || "or" in node) {
|
|
73
|
+
if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {
|
|
74
|
+
ctx.addIssue({
|
|
75
|
+
code: z.ZodIssueCode.custom,
|
|
76
|
+
path,
|
|
77
|
+
message: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const groupKey = "and" in node ? "and" : "or";
|
|
82
|
+
const children = node[groupKey];
|
|
83
|
+
if (!Array.isArray(children)) {
|
|
84
|
+
ctx.addIssue({
|
|
85
|
+
code: z.ZodIssueCode.custom,
|
|
86
|
+
path: [...path, groupKey],
|
|
87
|
+
message: `filter ${groupKey} group must be an array of predicates or nested groups`
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (children.length === 0) {
|
|
92
|
+
ctx.addIssue({
|
|
93
|
+
code: z.ZodIssueCode.custom,
|
|
94
|
+
path: [...path, groupKey],
|
|
95
|
+
message: `filter '${groupKey}' group must contain at least one predicate`
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
children.forEach((child, idx) => {
|
|
100
|
+
validateFilterTree(child, ctx, [
|
|
101
|
+
...path,
|
|
102
|
+
groupKey,
|
|
103
|
+
idx
|
|
104
|
+
], depth + 1);
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const predicate = node;
|
|
109
|
+
if (!METRIC_FILTER_OPERATORS.includes(predicate.operator)) {
|
|
110
|
+
ctx.addIssue({
|
|
111
|
+
code: z.ZodIssueCode.custom,
|
|
112
|
+
path: [...path, "operator"],
|
|
113
|
+
message: `filter operator "${predicate.operator}" is not one of: ${METRIC_FILTER_OPERATORS.join(", ")}`
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const op = predicate.operator;
|
|
118
|
+
const values = predicate.values;
|
|
119
|
+
const valuesLen = values?.length ?? 0;
|
|
120
|
+
if (NULL_OPERATORS.has(op)) {
|
|
121
|
+
if (values != null && valuesLen > 0) ctx.addIssue({
|
|
122
|
+
code: z.ZodIssueCode.custom,
|
|
123
|
+
path: [...path, "values"],
|
|
124
|
+
message: `filter operator "${op}" must not carry values`
|
|
125
|
+
});
|
|
126
|
+
} else if (SINGLE_VALUE_OPERATORS.has(op)) {
|
|
127
|
+
if (valuesLen !== 1) ctx.addIssue({
|
|
128
|
+
code: z.ZodIssueCode.custom,
|
|
129
|
+
path: [...path, "values"],
|
|
130
|
+
message: `filter operator "${op}" requires exactly one value (got ${valuesLen})`
|
|
131
|
+
});
|
|
132
|
+
} else if (LIST_VALUE_OPERATORS.has(op)) {
|
|
133
|
+
if (valuesLen < 1) ctx.addIssue({
|
|
134
|
+
code: z.ZodIssueCode.custom,
|
|
135
|
+
path: [...path, "values"],
|
|
136
|
+
message: `filter operator "${op}" requires at least one value`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (STRING_OPERATORS.has(op) && valuesLen > 0) {
|
|
140
|
+
const v = predicate.values?.[0];
|
|
141
|
+
if (typeof v !== "string") ctx.addIssue({
|
|
142
|
+
code: z.ZodIssueCode.custom,
|
|
143
|
+
path: [...path, "values"],
|
|
144
|
+
message: `filter operator "${op}" requires a string value (got ${typeof v})`
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function preCheckFilterDepth(filter) {
|
|
149
|
+
if (filter == null || typeof filter !== "object") return;
|
|
150
|
+
const stack = [[filter, 0]];
|
|
151
|
+
while (stack.length > 0) {
|
|
152
|
+
const popped = stack.pop();
|
|
153
|
+
if (popped === void 0) continue;
|
|
154
|
+
const [node, depth] = popped;
|
|
155
|
+
if (node == null || typeof node !== "object") continue;
|
|
156
|
+
const obj = node;
|
|
157
|
+
for (const groupKey of ["and", "or"]) {
|
|
158
|
+
const children = obj[groupKey];
|
|
159
|
+
if (!Array.isArray(children)) continue;
|
|
160
|
+
if (children.length > METRIC_FILTER_GROUP_MAX) throw new ValidationError("Invalid metric request body (fields: filter)", { context: { reason: `filter ${groupKey} group has ${children.length} children; the maximum is ${METRIC_FILTER_GROUP_MAX}` } });
|
|
161
|
+
if (depth + 1 > METRIC_FILTER_MAX_DEPTH) throw new ValidationError("Invalid metric request body (fields: filter)", { context: { reason: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}` } });
|
|
162
|
+
for (const child of children) stack.push([child, depth + 1]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function validateMetricRequest(body) {
|
|
167
|
+
if (body != null && typeof body === "object") preCheckFilterDepth(body.filter);
|
|
168
|
+
const result = metricRequestSchema.safeParse(body);
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
const fieldPaths = result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ");
|
|
171
|
+
throw new ValidationError(fieldPaths.length > 0 ? `Invalid metric request body (fields: ${fieldPaths})` : "Invalid metric request body", { context: { issues: result.error.issues } });
|
|
172
|
+
}
|
|
173
|
+
return result.data;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
//#endregion
|
|
177
|
+
export { validateMetricRequest };
|
|
178
|
+
//# sourceMappingURL=schemas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas.js","names":[],"sources":["../../../../src/plugins/analytics/mv/schemas.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { isValidColumnName } from \"../../../../../shared/src/schemas/metric-fqn\";\nimport { ValidationError } from \"../../../errors\";\nimport type {\n IAnalyticsMetricRequest,\n MetricFilter,\n MetricFilterOperatorName,\n MetricPredicate,\n} from \"../types\";\nimport { normalizeAnalyticsFormat } from \"../types\";\nimport {\n LIST_VALUE_OPERATORS,\n METRIC_DIMENSIONS_MAX,\n METRIC_FILTER_GROUP_MAX,\n METRIC_FILTER_MAX_DEPTH,\n METRIC_FILTER_OPERATORS,\n METRIC_FILTER_VALUES_MAX,\n METRIC_LIMIT_MAX,\n METRIC_MEASURES_MAX,\n NULL_OPERATORS,\n SINGLE_VALUE_OPERATORS,\n STRING_OPERATORS,\n TIME_GRAIN_PATTERN,\n} from \"./constants\";\n\n/** A leaf predicate: `{ member, operator, values? }`, no extra keys. */\nconst filterPredicateSchema: z.ZodType<MetricPredicate> = z\n .object({\n member: z\n .string()\n .min(1, { message: \"filter predicate 'member' cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"filter predicate 'member' contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n operator: z.string().min(1, {\n message: \"filter predicate 'operator' cannot be empty\",\n }) as z.ZodType<MetricFilterOperatorName>,\n values: z\n .array(z.union([z.string(), z.number()]))\n .max(METRIC_FILTER_VALUES_MAX, {\n message: `filter predicate 'values' length exceeds the maximum of ${METRIC_FILTER_VALUES_MAX}`,\n })\n .optional(),\n })\n .strict();\n\n/** Recursive filter: a predicate leaf or an `{ and }` / `{ or }` group. */\nconst filterSchema: z.ZodType<MetricFilter> = z.lazy(() =>\n z.union([\n filterPredicateSchema,\n z\n .object({\n and: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'and' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n z\n .object({\n or: z.array(filterSchema).max(METRIC_FILTER_GROUP_MAX, {\n message: `filter 'or' group exceeds the maximum of ${METRIC_FILTER_GROUP_MAX} children`,\n }),\n })\n .strict(),\n ]),\n);\n\nconst metricRequestSchema = z\n .object({\n measures: z\n .array(\n z\n .string()\n .min(1, \"measure name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"measure name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .min(1, \"at least one measure is required\")\n .max(METRIC_MEASURES_MAX, {\n message: `measures length exceeds the maximum of ${METRIC_MEASURES_MAX}`,\n }),\n dimensions: z\n .array(\n z\n .string()\n .min(1, \"dimension name cannot be empty\")\n .refine(isValidColumnName, {\n message:\n \"dimension name contains a character that cannot be used in a SQL identifier (control character or newline)\",\n }),\n )\n .max(METRIC_DIMENSIONS_MAX, {\n message: `dimensions length exceeds the maximum of ${METRIC_DIMENSIONS_MAX}`,\n })\n .optional(),\n filter: filterSchema.optional(),\n // Grammar-shaped bucketing grain, applied to `timeDimension` via\n // `date_trunc`. The token is validated for safety here; the grain literal\n // is interpolated (single-quoted, never a bind param) in\n // `renderDimensionClause`, so this pattern gate is the security boundary.\n timeGrain: z\n .string()\n .min(1, { message: \"timeGrain cannot be empty\" })\n .regex(TIME_GRAIN_PATTERN, {\n message: \"timeGrain must match /^[a-z][a-z_]*$/\",\n })\n .optional(),\n // The single dimension `timeGrain` applies to via `date_trunc`. A column\n // identifier (backtick-quoted at interpolation), so it accepts the full\n // delimited-identifier grammar. Cross-field rules in `superRefine`:\n // required when `timeGrain` is set, and must be one of `dimensions`.\n timeDimension: z\n .string()\n .min(1, { message: \"timeDimension cannot be empty\" })\n .refine(isValidColumnName, {\n message:\n \"timeDimension contains a character that cannot be used in a SQL identifier (control character or newline)\",\n })\n .optional(),\n limit: z\n .number()\n .int({ message: \"limit must be an integer\" })\n .positive({ message: \"limit must be positive\" })\n .max(METRIC_LIMIT_MAX, {\n message: `limit exceeds the maximum of ${METRIC_LIMIT_MAX}`,\n })\n .optional(),\n format: z.enum([\"JSON_ARRAY\", \"ARROW_STREAM\", \"JSON\", \"ARROW\"]).optional(),\n })\n .strict()\n .superRefine((value, ctx) => {\n if (value.filter != null) {\n validateFilterTree(value.filter, ctx, [\"filter\"], 0);\n }\n\n const seen = new Set<string>();\n const collided = new Set<string>();\n for (const name of [...value.measures, ...(value.dimensions ?? [])]) {\n if (seen.has(name)) {\n collided.add(name);\n }\n seen.add(name);\n }\n if (collided.size > 0) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"measures and dimensions must be unique across both lists (a name cannot repeat, nor appear as both a measure and a dimension)\",\n path: [\"measures\"],\n });\n }\n\n if (\n value.format != null &&\n normalizeAnalyticsFormat(value.format) !== \"JSON_ARRAY\"\n ) {\n ctx.addIssue({\n code: \"custom\",\n message:\n \"format: only JSON_ARRAY is supported on the metric route at v1 (ARROW_STREAM is not yet implemented)\",\n path: [\"format\"],\n });\n }\n\n if (value.timeGrain != null && value.timeDimension == null) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension is required when timeGrain is set\",\n path: [\"timeDimension\"],\n });\n }\n if (\n value.timeDimension != null &&\n !(value.dimensions ?? []).includes(value.timeDimension)\n ) {\n ctx.addIssue({\n code: \"custom\",\n message: \"timeDimension must be one of dimensions\",\n path: [\"timeDimension\"],\n });\n }\n }) as z.ZodType<IAnalyticsMetricRequest>;\n\nfunction validateFilterTree(\n node: MetricFilter,\n ctx: z.RefinementCtx,\n path: Array<string | number>,\n depth: number,\n): void {\n if (node === null || typeof node !== \"object\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: \"filter node must be a Predicate or { and } / { or } group\",\n });\n return;\n }\n\n if (\"and\" in node || \"or\" in node) {\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path,\n message: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n });\n return;\n }\n\n const groupKey = \"and\" in node ? \"and\" : \"or\";\n const children = (\n node as { and?: ReadonlyArray<MetricFilter> } & {\n or?: ReadonlyArray<MetricFilter>;\n }\n )[groupKey];\n\n if (!Array.isArray(children)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter ${groupKey} group must be an array of predicates or nested groups`,\n });\n return;\n }\n\n if (children.length === 0) {\n // Reject empty groups of either kind. An empty `or` is vacuously false;\n // an empty `and` contributes no constraint and renders to no WHERE\n // clause — identical SQL to omitting `filter` entirely, but it would\n // canonicalize to a distinct cache key (`and()` vs `_`), needlessly\n // splitting the cache across semantically identical requests. Requiring\n // at least one child keeps request shape ↔ cache key one-to-one.\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, groupKey],\n message: `filter '${groupKey}' group must contain at least one predicate`,\n });\n return;\n }\n\n children.forEach((child, idx) => {\n validateFilterTree(child, ctx, [...path, groupKey, idx], depth + 1);\n });\n return;\n }\n\n const predicate = node as MetricPredicate;\n\n if (\n !METRIC_FILTER_OPERATORS.includes(\n predicate.operator as MetricFilterOperatorName,\n )\n ) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"operator\"],\n message: `filter operator \"${predicate.operator}\" is not one of: ${METRIC_FILTER_OPERATORS.join(\", \")}`,\n });\n return;\n }\n\n const op = predicate.operator;\n const values = predicate.values;\n const valuesLen = values?.length ?? 0;\n\n if (NULL_OPERATORS.has(op)) {\n if (values != null && valuesLen > 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" must not carry values`,\n });\n }\n } else if (SINGLE_VALUE_OPERATORS.has(op)) {\n if (valuesLen !== 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires exactly one value (got ${valuesLen})`,\n });\n }\n } else if (LIST_VALUE_OPERATORS.has(op)) {\n if (valuesLen < 1) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires at least one value`,\n });\n }\n }\n\n if (STRING_OPERATORS.has(op) && valuesLen > 0) {\n const v = predicate.values?.[0];\n if (typeof v !== \"string\") {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [...path, \"values\"],\n message: `filter operator \"${op}\" requires a string value (got ${typeof v})`,\n });\n }\n }\n}\n\nfunction preCheckFilterDepth(filter: unknown): void {\n if (filter == null || typeof filter !== \"object\") return;\n const stack: Array<[unknown, number]> = [[filter, 0]];\n while (stack.length > 0) {\n const popped = stack.pop();\n if (popped === undefined) continue;\n const [node, depth] = popped;\n if (node == null || typeof node !== \"object\") continue;\n const obj = node as Record<string, unknown>;\n for (const groupKey of [\"and\", \"or\"] as const) {\n const children = obj[groupKey];\n if (!Array.isArray(children)) continue;\n if (children.length > METRIC_FILTER_GROUP_MAX) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter ${groupKey} group has ${children.length} children; the maximum is ${METRIC_FILTER_GROUP_MAX}`,\n },\n },\n );\n }\n if (depth + 1 > METRIC_FILTER_MAX_DEPTH) {\n throw new ValidationError(\n \"Invalid metric request body (fields: filter)\",\n {\n context: {\n reason: `filter AND/OR nesting exceeds the maximum depth of ${METRIC_FILTER_MAX_DEPTH}`,\n },\n },\n );\n }\n for (const child of children) {\n stack.push([child, depth + 1]);\n }\n }\n }\n}\n\nexport function validateMetricRequest(body: unknown): IAnalyticsMetricRequest {\n if (body != null && typeof body === \"object\") {\n preCheckFilterDepth((body as { filter?: unknown }).filter);\n }\n const result = metricRequestSchema.safeParse(body);\n if (!result.success) {\n const fieldPaths = result.error.issues\n .map((i) => i.path.join(\".\") || \"(root)\")\n .join(\", \");\n throw new ValidationError(\n fieldPaths.length > 0\n ? `Invalid metric request body (fields: ${fieldPaths})`\n : \"Invalid metric request body\",\n { context: { issues: result.error.issues } },\n );\n }\n return result.data;\n}\n"],"mappings":";;;;;;;;;AA0BA,MAAM,wBAAoD,EACvD,OAAO;CACN,QAAQ,EACL,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,6CAA6C,CAAC,CAChE,OAAO,mBAAmB,EACzB,SACE,yHACH,CAAC;CACJ,UAAU,EAAE,QAAQ,CAAC,IAAI,GAAG,EAC1B,SAAS,+CACV,CAAC;CACF,QAAQ,EACL,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,CACxC,IAAI,0BAA0B,EAC7B,SAAS,2DAA2D,4BACrE,CAAC,CACD,UAAU;CACd,CAAC,CACD,QAAQ;;AAGX,MAAM,eAAwC,EAAE,WAC9C,EAAE,MAAM;CACN;CACA,EACG,OAAO,EACN,KAAK,EAAE,MAAM,aAAa,CAAC,IAAI,yBAAyB,EACtD,SAAS,6CAA6C,wBAAwB,YAC/E,CAAC,EACH,CAAC,CACD,QAAQ;CACX,EACG,OAAO,EACN,IAAI,EAAE,MAAM,aAAa,CAAC,IAAI,yBAAyB,EACrD,SAAS,4CAA4C,wBAAwB,YAC9E,CAAC,EACH,CAAC,CACD,QAAQ;CACZ,CAAC,CACH;AAED,MAAM,sBAAsB,EACzB,OAAO;CACN,UAAU,EACP,MACC,EACG,QAAQ,CACR,IAAI,GAAG,+BAA+B,CACtC,OAAO,mBAAmB,EACzB,SACE,4GACH,CAAC,CACL,CACA,IAAI,GAAG,mCAAmC,CAC1C,IAAI,qBAAqB,EACxB,SAAS,0CAA0C,uBACpD,CAAC;CACJ,YAAY,EACT,MACC,EACG,QAAQ,CACR,IAAI,GAAG,iCAAiC,CACxC,OAAO,mBAAmB,EACzB,SACE,8GACH,CAAC,CACL,CACA,IAAI,uBAAuB,EAC1B,SAAS,4CAA4C,yBACtD,CAAC,CACD,UAAU;CACb,QAAQ,aAAa,UAAU;CAK/B,WAAW,EACR,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,6BAA6B,CAAC,CAChD,MAAM,oBAAoB,EACzB,SAAS,yCACV,CAAC,CACD,UAAU;CAKb,eAAe,EACZ,QAAQ,CACR,IAAI,GAAG,EAAE,SAAS,iCAAiC,CAAC,CACpD,OAAO,mBAAmB,EACzB,SACE,6GACH,CAAC,CACD,UAAU;CACb,OAAO,EACJ,QAAQ,CACR,IAAI,EAAE,SAAS,4BAA4B,CAAC,CAC5C,SAAS,EAAE,SAAS,0BAA0B,CAAC,CAC/C,IAAI,kBAAkB,EACrB,SAAS,gCAAgC,oBAC1C,CAAC,CACD,UAAU;CACb,QAAQ,EAAE,KAAK;EAAC;EAAc;EAAgB;EAAQ;EAAQ,CAAC,CAAC,UAAU;CAC3E,CAAC,CACD,QAAQ,CACR,aAAa,OAAO,QAAQ;AAC3B,KAAI,MAAM,UAAU,KAClB,oBAAmB,MAAM,QAAQ,KAAK,CAAC,SAAS,EAAE,EAAE;CAGtD,MAAM,uBAAO,IAAI,KAAa;CAC9B,MAAM,2BAAW,IAAI,KAAa;AAClC,MAAK,MAAM,QAAQ,CAAC,GAAG,MAAM,UAAU,GAAI,MAAM,cAAc,EAAE,CAAE,EAAE;AACnE,MAAI,KAAK,IAAI,KAAK,CAChB,UAAS,IAAI,KAAK;AAEpB,OAAK,IAAI,KAAK;;AAEhB,KAAI,SAAS,OAAO,EAClB,KAAI,SAAS;EACX,MAAM;EACN,SACE;EACF,MAAM,CAAC,WAAW;EACnB,CAAC;AAGJ,KACE,MAAM,UAAU,QAChB,yBAAyB,MAAM,OAAO,KAAK,aAE3C,KAAI,SAAS;EACX,MAAM;EACN,SACE;EACF,MAAM,CAAC,SAAS;EACjB,CAAC;AAGJ,KAAI,MAAM,aAAa,QAAQ,MAAM,iBAAiB,KACpD,KAAI,SAAS;EACX,MAAM;EACN,SAAS;EACT,MAAM,CAAC,gBAAgB;EACxB,CAAC;AAEJ,KACE,MAAM,iBAAiB,QACvB,EAAE,MAAM,cAAc,EAAE,EAAE,SAAS,MAAM,cAAc,CAEvD,KAAI,SAAS;EACX,MAAM;EACN,SAAS;EACT,MAAM,CAAC,gBAAgB;EACxB,CAAC;EAEJ;AAEJ,SAAS,mBACP,MACA,KACA,MACA,OACM;AACN,KAAI,SAAS,QAAQ,OAAO,SAAS,UAAU;AAC7C,MAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB;GACA,SAAS;GACV,CAAC;AACF;;AAGF,KAAI,SAAS,QAAQ,QAAQ,MAAM;AACjC,MAAI,QAAQ,IAAI,yBAAyB;AACvC,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB;IACA,SAAS,sDAAsD;IAChE,CAAC;AACF;;EAGF,MAAM,WAAW,SAAS,OAAO,QAAQ;EACzC,MAAM,WACJ,KAGA;AAEF,MAAI,CAAC,MAAM,QAAQ,SAAS,EAAE;AAC5B,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB,MAAM,CAAC,GAAG,MAAM,SAAS;IACzB,SAAS,UAAU,SAAS;IAC7B,CAAC;AACF;;AAGF,MAAI,SAAS,WAAW,GAAG;AAOzB,OAAI,SAAS;IACX,MAAM,EAAE,aAAa;IACrB,MAAM,CAAC,GAAG,MAAM,SAAS;IACzB,SAAS,WAAW,SAAS;IAC9B,CAAC;AACF;;AAGF,WAAS,SAAS,OAAO,QAAQ;AAC/B,sBAAmB,OAAO,KAAK;IAAC,GAAG;IAAM;IAAU;IAAI,EAAE,QAAQ,EAAE;IACnE;AACF;;CAGF,MAAM,YAAY;AAElB,KACE,CAAC,wBAAwB,SACvB,UAAU,SACX,EACD;AACA,MAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,WAAW;GAC3B,SAAS,oBAAoB,UAAU,SAAS,mBAAmB,wBAAwB,KAAK,KAAK;GACtG,CAAC;AACF;;CAGF,MAAM,KAAK,UAAU;CACrB,MAAM,SAAS,UAAU;CACzB,MAAM,YAAY,QAAQ,UAAU;AAEpC,KAAI,eAAe,IAAI,GAAG,EACxB;MAAI,UAAU,QAAQ,YAAY,EAChC,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG;GACjC,CAAC;YAEK,uBAAuB,IAAI,GAAG,EACvC;MAAI,cAAc,EAChB,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG,oCAAoC,UAAU;GAC/E,CAAC;YAEK,qBAAqB,IAAI,GAAG,EACrC;MAAI,YAAY,EACd,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG;GACjC,CAAC;;AAIN,KAAI,iBAAiB,IAAI,GAAG,IAAI,YAAY,GAAG;EAC7C,MAAM,IAAI,UAAU,SAAS;AAC7B,MAAI,OAAO,MAAM,SACf,KAAI,SAAS;GACX,MAAM,EAAE,aAAa;GACrB,MAAM,CAAC,GAAG,MAAM,SAAS;GACzB,SAAS,oBAAoB,GAAG,iCAAiC,OAAO,EAAE;GAC3E,CAAC;;;AAKR,SAAS,oBAAoB,QAAuB;AAClD,KAAI,UAAU,QAAQ,OAAO,WAAW,SAAU;CAClD,MAAM,QAAkC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACrD,QAAO,MAAM,SAAS,GAAG;EACvB,MAAM,SAAS,MAAM,KAAK;AAC1B,MAAI,WAAW,OAAW;EAC1B,MAAM,CAAC,MAAM,SAAS;AACtB,MAAI,QAAQ,QAAQ,OAAO,SAAS,SAAU;EAC9C,MAAM,MAAM;AACZ,OAAK,MAAM,YAAY,CAAC,OAAO,KAAK,EAAW;GAC7C,MAAM,WAAW,IAAI;AACrB,OAAI,CAAC,MAAM,QAAQ,SAAS,CAAE;AAC9B,OAAI,SAAS,SAAS,wBACpB,OAAM,IAAI,gBACR,gDACA,EACE,SAAS,EACP,QAAQ,UAAU,SAAS,aAAa,SAAS,OAAO,4BAA4B,2BACrF,EACF,CACF;AAEH,OAAI,QAAQ,IAAI,wBACd,OAAM,IAAI,gBACR,gDACA,EACE,SAAS,EACP,QAAQ,sDAAsD,2BAC/D,EACF,CACF;AAEH,QAAK,MAAM,SAAS,SAClB,OAAM,KAAK,CAAC,OAAO,QAAQ,EAAE,CAAC;;;;AAMtC,SAAgB,sBAAsB,MAAwC;AAC5E,KAAI,QAAQ,QAAQ,OAAO,SAAS,SAClC,qBAAqB,KAA8B,OAAO;CAE5D,MAAM,SAAS,oBAAoB,UAAU,KAAK;AAClD,KAAI,CAAC,OAAO,SAAS;EACnB,MAAM,aAAa,OAAO,MAAM,OAC7B,KAAK,MAAM,EAAE,KAAK,KAAK,IAAI,IAAI,SAAS,CACxC,KAAK,KAAK;AACb,QAAM,IAAI,gBACR,WAAW,SAAS,IAChB,wCAAwC,WAAW,KACnD,+BACJ,EAAE,SAAS,EAAE,QAAQ,OAAO,MAAM,QAAQ,EAAE,CAC7C;;AAEH,QAAO,OAAO"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"sourcesContent":["import type { BasePluginConfig } from \"shared\";\n\nexport interface IAnalyticsConfig extends BasePluginConfig {\n timeout?: number;\n /**\n * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL\n * warehouse to reach RUNNING before failing the request. Defaults to 5 min.\n */\n warehouseStartupTimeoutMs?: number;\n /**\n * When `true` (default), a `STOPPED` SQL warehouse is auto-started on the\n * first analytics request that reaches it. Set to `false` for cost-\n * controlled deployments where billable warehouse starts must not be\n * triggered by user requests; in that case `STOPPED` surfaces as a\n * `ConfigurationError`.\n */\n autoStartWarehouse?: boolean;\n /**\n * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first\n * byte (warehouse readiness + execute + first chunk). Past this, a stuck or\n * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of\n * hanging until the client disconnects. Defaults to 2 min. Once the first\n * byte arrives the stream is not time-bounded.\n */\n arrowFirstByteTimeoutMs?: number;\n}\n\n/**\n * SQL warehouse lifecycle states surfaced by the analytics route.\n * Mirrors the states emitted by the Databricks SQL SDK (`sql.State`).\n */\nexport type WarehouseState =\n | \"RUNNING\"\n | \"STARTING\"\n | \"STOPPED\"\n | \"STOPPING\"\n | \"DELETED\"\n | \"DELETING\";\n\n/**\n * Snapshot of warehouse readiness streamed to the client over SSE before the\n * SQL result. Lets the UI render a \"warehouse starting…\" affordance instead\n * of a frozen spinner during cold starts.\n *\n * Note: the SDK's `health.summary` is intentionally NOT forwarded here. It's\n * free-form operator-oriented diagnostic text (cluster IDs, capacity-failure\n * reasons, internal RPC errors) that must not reach end users; it stays in\n * server-side telemetry only.\n */\nexport interface WarehouseStatus {\n state: WarehouseState;\n /** Milliseconds elapsed since the route began waiting for the warehouse. */\n elapsedMs: number;\n}\n\n/**\n * Discriminated union of every SSE message shape emitted by\n * `POST /api/analytics/query/:query_key`. Useful for typing the client-side\n * `onMessage` handler (and is the source of truth re-mirrored in\n * `appkit-ui` since that package can't depend on `appkit`).\n */\nexport type AnalyticsStreamMessage =\n | { type: \"warehouse_status\"; status: WarehouseStatus }\n | { type: \"result\"; data: unknown[] }\n | {\n type: \"arrow\";\n statement_id: string;\n status: { state: string };\n }\n | { type: \"error\"; error: string; code?: string };\n\n/**\n * Supported response formats for analytics queries.\n *\n * \"JSON\" and \"ARROW\" are legacy aliases kept for backwards compatibility\n * with appkit/appkit-ui < 0.33.0 — safe to remove once no consumer is on\n * a pre-0.33.0 version. The route handler normalizes them to their\n * canonical equivalents before any downstream code reads the value.\n */\nexport type AnalyticsFormat =\n | \"JSON_ARRAY\"\n | \"ARROW_STREAM\"\n /** @deprecated Use \"JSON_ARRAY\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"JSON\"\n /** @deprecated Use \"ARROW_STREAM\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"ARROW\";\n\n/** Canonical (post-normalization) analytics format values. */\ntype CanonicalAnalyticsFormat = \"JSON_ARRAY\" | \"ARROW_STREAM\";\n\n/**\n * Map a (possibly legacy) AnalyticsFormat to its canonical form.\n * Legacy values come from appkit/appkit-ui < 0.33.0 and can be removed\n * along with the deprecated aliases once no such consumer remains.\n */\nexport function normalizeAnalyticsFormat(\n f: AnalyticsFormat,\n): CanonicalAnalyticsFormat {\n if (f === \"JSON\") return \"JSON_ARRAY\";\n if (f === \"ARROW\") return \"ARROW_STREAM\";\n return f;\n}\n\nexport interface IAnalyticsQueryRequest {\n parameters?: Record<string, any>;\n format?: AnalyticsFormat;\n}\n\nexport interface AnalyticsQueryResponse {\n chunk_index: number;\n row_offset: number;\n row_count: number;\n data: any[];\n}\n"],"mappings":";;;;;;AA+FA,SAAgB,yBACd,GAC0B;AAC1B,KAAI,MAAM,OAAQ,QAAO;AACzB,KAAI,MAAM,QAAS,QAAO;AAC1B,QAAO"}
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../../src/plugins/analytics/types.ts"],"sourcesContent":["import type { BasePluginConfig } from \"shared\";\n\nexport interface IAnalyticsConfig extends BasePluginConfig {\n timeout?: number;\n /**\n * Maximum time (ms) the analytics route waits for a STOPPED/STARTING SQL\n * warehouse to reach RUNNING before failing the request. Defaults to 5 min.\n */\n warehouseStartupTimeoutMs?: number;\n /**\n * When `true` (default), a `STOPPED` SQL warehouse is auto-started on the\n * first analytics request that reaches it. Set to `false` for cost-\n * controlled deployments where billable warehouse starts must not be\n * triggered by user requests; in that case `STOPPED` surfaces as a\n * `ConfigurationError`.\n */\n autoStartWarehouse?: boolean;\n /**\n * Fail-fast ceiling (ms) for an `ARROW_STREAM` query to produce its first\n * byte (warehouse readiness + execute + first chunk). Past this, a stuck or\n * overloaded warehouse returns a `503` (`WAREHOUSE_UNAVAILABLE`) instead of\n * hanging until the client disconnects. Defaults to 2 min. Once the first\n * byte arrives the stream is not time-bounded.\n */\n arrowFirstByteTimeoutMs?: number;\n}\n\n/**\n * SQL warehouse lifecycle states surfaced by the analytics route.\n * Mirrors the states emitted by the Databricks SQL SDK (`sql.State`).\n */\nexport type WarehouseState =\n | \"RUNNING\"\n | \"STARTING\"\n | \"STOPPED\"\n | \"STOPPING\"\n | \"DELETED\"\n | \"DELETING\";\n\n/**\n * Snapshot of warehouse readiness streamed to the client over SSE before the\n * SQL result. Lets the UI render a \"warehouse starting…\" affordance instead\n * of a frozen spinner during cold starts.\n *\n * Note: the SDK's `health.summary` is intentionally NOT forwarded here. It's\n * free-form operator-oriented diagnostic text (cluster IDs, capacity-failure\n * reasons, internal RPC errors) that must not reach end users; it stays in\n * server-side telemetry only.\n */\nexport interface WarehouseStatus {\n state: WarehouseState;\n /** Milliseconds elapsed since the route began waiting for the warehouse. */\n elapsedMs: number;\n}\n\n/**\n * Discriminated union of every SSE message shape emitted by\n * `POST /api/analytics/query/:query_key`. Useful for typing the client-side\n * `onMessage` handler (and is the source of truth re-mirrored in\n * `appkit-ui` since that package can't depend on `appkit`).\n */\nexport type AnalyticsStreamMessage =\n | { type: \"warehouse_status\"; status: WarehouseStatus }\n | { type: \"result\"; data: unknown[] }\n | {\n type: \"arrow\";\n statement_id: string;\n status: { state: string };\n }\n | { type: \"error\"; error: string; code?: string };\n\n/**\n * Supported response formats for analytics queries.\n *\n * \"JSON\" and \"ARROW\" are legacy aliases kept for backwards compatibility\n * with appkit/appkit-ui < 0.33.0 — safe to remove once no consumer is on\n * a pre-0.33.0 version. The route handler normalizes them to their\n * canonical equivalents before any downstream code reads the value.\n */\nexport type AnalyticsFormat =\n | \"JSON_ARRAY\"\n | \"ARROW_STREAM\"\n /** @deprecated Use \"JSON_ARRAY\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"JSON\"\n /** @deprecated Use \"ARROW_STREAM\". Safe to remove once no consumer is on appkit < 0.33.0. */\n | \"ARROW\";\n\n/** Canonical (post-normalization) analytics format values. */\ntype CanonicalAnalyticsFormat = \"JSON_ARRAY\" | \"ARROW_STREAM\";\n\n/**\n * Map a (possibly legacy) AnalyticsFormat to its canonical form.\n * Legacy values come from appkit/appkit-ui < 0.33.0 and can be removed\n * along with the deprecated aliases once no such consumer remains.\n */\nexport function normalizeAnalyticsFormat(\n f: AnalyticsFormat,\n): CanonicalAnalyticsFormat {\n if (f === \"JSON\") return \"JSON_ARRAY\";\n if (f === \"ARROW\") return \"ARROW_STREAM\";\n return f;\n}\n\nexport interface IAnalyticsQueryRequest {\n parameters?: Record<string, any>;\n format?: AnalyticsFormat;\n}\n\nexport interface AnalyticsQueryResponse {\n chunk_index: number;\n row_offset: number;\n row_count: number;\n data: any[];\n}\n\n// ────────────────────────────────────────────────────────────────────────────\n// Metric views — POST /api/analytics/metric/:key\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Execution lane for a registered metric view, derived from the entry's\n * `executor` in `definitions.json`:\n * - `\"sp\"` ← `executor: \"app_service_principal\"` — queried as the app\n * service principal (cache shared across all users).\n * - `\"obo\"` ← `executor: \"user\"` — queried on-behalf-of the requesting\n * user (per-user cache). OBO dispatch is wired in a later phase.\n */\nexport type MetricLane = \"sp\" | \"obo\";\n\n/**\n * A single registered metric view, loaded from `config/metric-views/definitions.json`.\n *\n * The registration carries only what the runtime needs to build and dispatch\n * SQL: the metric `key`, the three-part UC FQN `source`, and the `lane`. There\n * is intentionally NO build-time measure/dimension metadata here — the security\n * boundary is the grammar gate plus parameterized values, not a name allowlist,\n * so the runtime never enumerates known measures/dimensions.\n */\nexport interface MetricRegistration {\n key: string;\n source: string;\n lane: MetricLane;\n}\n\n/**\n * v1 filter operator vocabulary — exactly twelve names. The runtime tuple\n * `METRIC_FILTER_OPERATORS` (next to the validator in `metric.ts`) is the\n * server-side source of truth; this union mirrors it statically.\n */\nexport type MetricFilterOperatorName =\n | \"equals\"\n | \"notEquals\"\n | \"in\"\n | \"notIn\"\n | \"gt\"\n | \"gte\"\n | \"lt\"\n | \"lte\"\n | \"contains\"\n | \"notContains\"\n | \"set\"\n | \"notSet\";\n\n/**\n * A single filter predicate — the leaf node of the recursive\n * {@link MetricFilter} tree. `member` is a dimension name (grammar-gated, not\n * allowlisted); `values` is bound through parameterized `:f_<idx>` bind vars\n * and never interpolated into the SQL string.\n */\nexport interface MetricPredicate {\n member: string;\n operator: MetricFilterOperatorName;\n values?: ReadonlyArray<string | number>;\n}\n\n/**\n * Recursive filter expression for the metric-view request body: a leaf\n * {@link MetricPredicate} or an `{ and: [...] }` / `{ or: [...] }` group. The\n * shape is intentionally non-generic server-side — per-metric narrowing (if\n * any) lives client-side.\n */\nexport type MetricFilter =\n | MetricPredicate\n | { and: ReadonlyArray<MetricFilter> }\n | { or: ReadonlyArray<MetricFilter> };\n\n/**\n * Validated request body for `POST /api/analytics/metric/:key`.\n *\n * `measures` is required. `dimensions` drive `GROUP BY ALL`; `filter` is the\n * recursive structured predicate tree translated into a parameterized `WHERE`\n * clause. `timeGrain` buckets the single dimension named by `timeDimension`\n * via `date_trunc`; it requires `timeDimension`, and `timeDimension` must be\n * one of `dimensions` so it is selected and in `GROUP BY ALL`. Both tokens are\n * grammar-gated before they reach SQL.\n */\nexport interface IAnalyticsMetricRequest {\n measures: string[];\n dimensions?: string[];\n filter?: MetricFilter;\n timeGrain?: string;\n /**\n * The single dimension that `timeGrain` buckets via `date_trunc`. Must be\n * one of `dimensions` (so it is selected and in `GROUP BY ALL`) and is\n * required whenever `timeGrain` is set. Grammar-gated as a SQL identifier.\n */\n timeDimension?: string;\n limit?: number;\n format?: AnalyticsFormat;\n}\n"],"mappings":";;;;;;AA+FA,SAAgB,yBACd,GAC0B;AAC1B,KAAI,MAAM,OAAQ,QAAO;AACzB,KAAI,MAAM,QAAS,QAAO;AAC1B,QAAO"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/schemas/metric-fqn.ts
|
|
2
|
+
/**
|
|
3
|
+
* Basename of the metric-view declarations file, resolved inside a
|
|
4
|
+
* `config/metric-views/` folder. Single source of truth shared by the analytics
|
|
5
|
+
* runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and
|
|
6
|
+
* the `generate-types` CLI. It lives in this zod-free module (rather than the
|
|
7
|
+
* canonical `metric-source.ts` schema) so the type-generator can import it
|
|
8
|
+
* without pulling zod into its locked dependency graph.
|
|
9
|
+
*/
|
|
10
|
+
const METRIC_CONFIG_FILE = "definitions.json";
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
export { METRIC_CONFIG_FILE };
|
|
14
|
+
//# sourceMappingURL=metric-fqn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metric-fqn.js","names":[],"sources":["../../src/schemas/metric-fqn.ts"],"sourcesContent":["/**\n * Unity Catalog object-name grammar\n * the single source of truth for metric view FQN (Fully Qualified Name).\n *\n * A metric view's `source` FQN is validated in two places that must agree:\n *\n * 1. The canonical Zod schema (`./metric-source.ts`), which composes the\n * three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the\n * generated JSON schema (`docs/static/schemas/metric-source.schema.json`).\n * 2. The type-generator runtime (`packages/appkit/src/type-generator/mv-registry/config.ts`),\n * which imports {@link UC_FQN_PATTERN} as a plain value to validate each\n * dot-split segment.\n *\n *\n * A Unity Catalog object name:\n * - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and\n * - cannot contain any of these characters:\n * - period (`.`)\n * - space (U+0020)\n * - forward slash (`/`)\n * - all ASCII control characters (U+0000-U+001F)\n * - the DELETE character (U+007F)\n *\n * Every other character is permitted in a quoted name, including non-ASCII\n * letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.\n *\n * @note the regex is the per-segment character set; structure (3 parts) and length are intentionally left to the callers.\n */\n\nexport const MAX_UC_OBJECT_NAME_LENGTH = 255;\n\n/**\n * Basename of the metric-view declarations file, resolved inside a\n * `config/metric-views/` folder. Single source of truth shared by the analytics\n * runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and\n * the `generate-types` CLI. It lives in this zod-free module (rather than the\n * canonical `metric-source.ts` schema) so the type-generator can import it\n * without pulling zod into its locked dependency graph.\n */\nexport const METRIC_CONFIG_FILE = \"definitions.json\";\n\n/**\n * Matches a single, non-empty Unity Catalog object name as it may appear in a backtick-quoted (delimited) identifier.\n *\n * @example\n * UC_FQN_PATTERN.test(\"revenue_metrics\"); // true\n * UC_FQN_PATTERN.test(\"prod-data\"); // true (hyphens are UC-legal)\n * UC_FQN_PATTERN.test(\"cafe\\u0301\"); // true (non-ASCII is UC-legal)\n * UC_FQN_PATTERN.test(\"bad name\"); // false (space is prohibited)\n * UC_FQN_PATTERN.test(\"a/b\"); // false (slash is prohibited)\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: UC explicitly prohibits ASCII control characters in object names; this negated class encodes that rule.\nexport const UC_FQN_PATTERN = /^[^\\x00-\\x20\\x7f./]+$/;\n\n/** A metric view FQN is exactly three segments: catalog.schema.metric_view. */\nconst FQN_SEGMENT_COUNT = 3;\n\n/**\n * Total predicate: is `fqn` a well-formed three-part UC metric view FQN?\n * Well-formed = exactly three non-empty, dot-separated segments, each a valid Unity Catalog object name per {@link UC_FQN_PATTERN}.\n * @example\n * isValidFqn(\"main.analytics.revenue\"); // true\n * isValidFqn(\"prod-data.analytics.rev\"); // true (hyphens are UC-legal)\n * isValidFqn(\"main.analytics\"); // false (only two segments)\n */\nexport function isValidFqn(fqn: string): boolean {\n const segments = fqn.split(\".\");\n if (segments.length !== FQN_SEGMENT_COUNT) {\n return false;\n }\n return segments.every((segment) => UC_FQN_PATTERN.test(segment));\n}\n\n/**\n * Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL\n * statement.\n *\n * Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The\n * one character that can break out of a backtick-quoted identifier is the\n * backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick\n * inside a segment is doubled before the segment is wrapped. Control characters\n * and newlines have no valid escape inside a quoted identifier, so a segment\n * containing one is rejected outright.\n *\n * This is a pure, standalone escaper: it is intentionally independent of FQN\n * naming validation ({@link isValidFqn}). Naming validation decides whether an\n * FQN is an acceptable metric source; this function only guarantees that\n * whatever it is handed cannot break out of the quoted identifier it produces.\n * Grammar and quoting live together here so a metric source is validated and\n * escaped against one shared source of truth.\n *\n * An ordinary identifier is unchanged apart from the wrapping backticks:\n * `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.\n *\n * @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).\n * @returns The backtick-quoted, escaped identifier ready for interpolation.\n * @throws If any segment contains a control character or newline.\n */\nexport function quoteFqnForSql(fqn: string): string {\n return fqn.split(\".\").map(quoteIdentifier).join(\".\");\n}\n\n/**\n * The Unicode \"control\" category (`\\p{Cc}`): C0 (incl. `\\n`, `\\r`, `\\t`), DEL,\n * and C1 — every control character/newline. These have no valid escape inside\n * a backtick-quoted identifier, so a name containing one cannot be safely\n * quoted and is rejected.\n */\nconst CONTROL_OR_NEWLINE = /\\p{Cc}/u;\n\nexport function isValidColumnName(name: string): boolean {\n return name.length > 0 && !CONTROL_OR_NEWLINE.test(name);\n}\n\n/**\n * Quote a single identifier (one column/measure/dimension name, or one FQN\n * segment) as a backtick-delimited identifier for safe SQL interpolation.\n *\n * @throws If `name` contains a control character or newline.\n */\nexport function quoteIdentifier(name: string): string {\n if (CONTROL_OR_NEWLINE.test(name)) {\n throw new Error(\n `Cannot quote identifier \"${name}\" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`,\n );\n }\n // Double every backtick, then wrap in backticks.\n return `\\`${name.replace(/`/g, \"``\")}\\``;\n}\n"],"mappings":";;;;;;;;;AAuCA,MAAa,qBAAqB"}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
//#region ../shared/src/schemas/metric-fqn.ts
|
|
2
2
|
/**
|
|
3
|
-
* Unity Catalog object-name grammar
|
|
4
|
-
* view FQN
|
|
3
|
+
* Unity Catalog object-name grammar
|
|
4
|
+
* the single source of truth for metric view FQN (Fully Qualified Name).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* validated in two places that must agree:
|
|
6
|
+
* A metric view's `source` FQN is validated in two places that must agree:
|
|
8
7
|
*
|
|
9
8
|
* 1. The canonical Zod schema (`./metric-source.ts`), which composes the
|
|
10
9
|
* three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the
|
|
@@ -13,18 +12,8 @@
|
|
|
13
12
|
* which imports {@link UC_FQN_PATTERN} as a plain value to validate each
|
|
14
13
|
* dot-split segment.
|
|
15
14
|
*
|
|
16
|
-
* The type-generator's runtime path must NOT pull the shared Zod schema package
|
|
17
|
-
* in (locked dependency-graph ruling - see the comment in
|
|
18
|
-
* `packages/appkit/src/type-generator/cache.ts`). Keeping the pattern in this
|
|
19
|
-
* zod-free module lets the runtime import the regex without dragging zod into
|
|
20
|
-
* its bundle, while still single-sourcing the grammar.
|
|
21
15
|
*
|
|
22
|
-
*
|
|
23
|
-
* The metric view FQN is always backtick-quoted before interpolation into SQL
|
|
24
|
-
* (see `quoteFqnForSql` in the type-generator), so the **delimited identifier**
|
|
25
|
-
* grammar is the one that applies - not the narrower unquoted-identifier rule.
|
|
26
|
-
*
|
|
27
|
-
* Per the Databricks SQL names reference, a Unity Catalog object name:
|
|
16
|
+
* A Unity Catalog object name:
|
|
28
17
|
* - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and
|
|
29
18
|
* - cannot contain any of these characters:
|
|
30
19
|
* - period (`.`)
|
|
@@ -35,41 +24,21 @@
|
|
|
35
24
|
*
|
|
36
25
|
* Every other character is permitted in a quoted name, including non-ASCII
|
|
37
26
|
* letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.
|
|
38
|
-
* This is intentionally broader than the old hand-rolled allowlist
|
|
39
|
-
* (`[a-zA-Z0-9_-]`), which was flagged in PR #433 review (pkosiec: "more
|
|
40
|
-
* restrictive than UC"): the goal is to accept what UC accepts as a quoted
|
|
41
|
-
* name and reject only what UC rejects.
|
|
42
|
-
*
|
|
43
|
-
* Verified against the Databricks docs on 2026-06-19:
|
|
44
|
-
* https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names
|
|
45
|
-
* (the link cited in the PR #433 review). If the published rules change,
|
|
46
|
-
* re-confirm against that page.
|
|
47
27
|
*
|
|
48
|
-
* @note
|
|
49
|
-
* a name containing a literal dot cannot be expressed in the dotted `source`
|
|
50
|
-
* string at all. The dotted-source arity (exactly three segments) and the
|
|
51
|
-
* 255-char-per-segment cap are enforced structurally by the callers; this
|
|
52
|
-
* pattern only encodes the per-segment allowed character set.
|
|
28
|
+
* @note the regex is the per-segment character set; structure (3 parts) and length are intentionally left to the callers.
|
|
53
29
|
*/
|
|
30
|
+
const MAX_UC_OBJECT_NAME_LENGTH = 255;
|
|
54
31
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
32
|
+
* Basename of the metric-view declarations file, resolved inside a
|
|
33
|
+
* `config/metric-views/` folder. Single source of truth shared by the analytics
|
|
34
|
+
* runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and
|
|
35
|
+
* the `generate-types` CLI. It lives in this zod-free module (rather than the
|
|
36
|
+
* canonical `metric-source.ts` schema) so the type-generator can import it
|
|
37
|
+
* without pulling zod into its locked dependency graph.
|
|
57
38
|
*/
|
|
58
|
-
const
|
|
39
|
+
const METRIC_CONFIG_FILE = "definitions.json";
|
|
59
40
|
/**
|
|
60
|
-
* Matches a single, non-empty Unity Catalog object name as it may appear in a
|
|
61
|
-
* backtick-quoted (delimited) identifier - one segment of a metric view FQN.
|
|
62
|
-
*
|
|
63
|
-
* Accepts any non-empty run of characters EXCEPT the UC-prohibited set:
|
|
64
|
-
* period, space, forward slash, ASCII control characters (U+0000-U+001F), and
|
|
65
|
-
* DELETE (U+007F). Length is NOT bounded here - callers enforce
|
|
66
|
-
* {@link MAX_UC_OBJECT_NAME_LENGTH} separately so they can emit a precise
|
|
67
|
-
* "segment too long" message distinct from a charset violation.
|
|
68
|
-
*
|
|
69
|
-
* The negated character class encodes the prohibited set as one contiguous
|
|
70
|
-
* range plus singletons: U+0000-U+0020 (every ASCII control character plus the
|
|
71
|
-
* space, which sits at U+0020 immediately after the control range), U+007F
|
|
72
|
-
* (DELETE), `.` (period - also the FQN segment separator), and `/` (slash).
|
|
41
|
+
* Matches a single, non-empty Unity Catalog object name as it may appear in a backtick-quoted (delimited) identifier.
|
|
73
42
|
*
|
|
74
43
|
* @example
|
|
75
44
|
* UC_FQN_PATTERN.test("revenue_metrics"); // true
|
|
@@ -79,7 +48,70 @@ const MAX_UC_OBJECT_NAME_LENGTH = 255;
|
|
|
79
48
|
* UC_FQN_PATTERN.test("a/b"); // false (slash is prohibited)
|
|
80
49
|
*/
|
|
81
50
|
const UC_FQN_PATTERN = /^[^\x00-\x20\x7f./]+$/;
|
|
51
|
+
/** A metric view FQN is exactly three segments: catalog.schema.metric_view. */
|
|
52
|
+
const FQN_SEGMENT_COUNT = 3;
|
|
53
|
+
/**
|
|
54
|
+
* Total predicate: is `fqn` a well-formed three-part UC metric view FQN?
|
|
55
|
+
* Well-formed = exactly three non-empty, dot-separated segments, each a valid Unity Catalog object name per {@link UC_FQN_PATTERN}.
|
|
56
|
+
* @example
|
|
57
|
+
* isValidFqn("main.analytics.revenue"); // true
|
|
58
|
+
* isValidFqn("prod-data.analytics.rev"); // true (hyphens are UC-legal)
|
|
59
|
+
* isValidFqn("main.analytics"); // false (only two segments)
|
|
60
|
+
*/
|
|
61
|
+
function isValidFqn(fqn) {
|
|
62
|
+
const segments = fqn.split(".");
|
|
63
|
+
if (segments.length !== FQN_SEGMENT_COUNT) return false;
|
|
64
|
+
return segments.every((segment) => UC_FQN_PATTERN.test(segment));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL
|
|
68
|
+
* statement.
|
|
69
|
+
*
|
|
70
|
+
* Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The
|
|
71
|
+
* one character that can break out of a backtick-quoted identifier is the
|
|
72
|
+
* backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick
|
|
73
|
+
* inside a segment is doubled before the segment is wrapped. Control characters
|
|
74
|
+
* and newlines have no valid escape inside a quoted identifier, so a segment
|
|
75
|
+
* containing one is rejected outright.
|
|
76
|
+
*
|
|
77
|
+
* This is a pure, standalone escaper: it is intentionally independent of FQN
|
|
78
|
+
* naming validation ({@link isValidFqn}). Naming validation decides whether an
|
|
79
|
+
* FQN is an acceptable metric source; this function only guarantees that
|
|
80
|
+
* whatever it is handed cannot break out of the quoted identifier it produces.
|
|
81
|
+
* Grammar and quoting live together here so a metric source is validated and
|
|
82
|
+
* escaped against one shared source of truth.
|
|
83
|
+
*
|
|
84
|
+
* An ordinary identifier is unchanged apart from the wrapping backticks:
|
|
85
|
+
* `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.
|
|
86
|
+
*
|
|
87
|
+
* @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).
|
|
88
|
+
* @returns The backtick-quoted, escaped identifier ready for interpolation.
|
|
89
|
+
* @throws If any segment contains a control character or newline.
|
|
90
|
+
*/
|
|
91
|
+
function quoteFqnForSql(fqn) {
|
|
92
|
+
return fqn.split(".").map(quoteIdentifier).join(".");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The Unicode "control" category (`\p{Cc}`): C0 (incl. `\n`, `\r`, `\t`), DEL,
|
|
96
|
+
* and C1 — every control character/newline. These have no valid escape inside
|
|
97
|
+
* a backtick-quoted identifier, so a name containing one cannot be safely
|
|
98
|
+
* quoted and is rejected.
|
|
99
|
+
*/
|
|
100
|
+
const CONTROL_OR_NEWLINE = /\p{Cc}/u;
|
|
101
|
+
function isValidColumnName(name) {
|
|
102
|
+
return name.length > 0 && !CONTROL_OR_NEWLINE.test(name);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Quote a single identifier (one column/measure/dimension name, or one FQN
|
|
106
|
+
* segment) as a backtick-delimited identifier for safe SQL interpolation.
|
|
107
|
+
*
|
|
108
|
+
* @throws If `name` contains a control character or newline.
|
|
109
|
+
*/
|
|
110
|
+
function quoteIdentifier(name) {
|
|
111
|
+
if (CONTROL_OR_NEWLINE.test(name)) throw new Error(`Cannot quote identifier "${name}" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`);
|
|
112
|
+
return `\`${name.replace(/`/g, "``")}\``;
|
|
113
|
+
}
|
|
82
114
|
|
|
83
115
|
//#endregion
|
|
84
|
-
export { MAX_UC_OBJECT_NAME_LENGTH, UC_FQN_PATTERN };
|
|
116
|
+
export { MAX_UC_OBJECT_NAME_LENGTH, METRIC_CONFIG_FILE, UC_FQN_PATTERN, isValidColumnName, isValidFqn, quoteFqnForSql, quoteIdentifier };
|
|
85
117
|
//# sourceMappingURL=metric-fqn.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metric-fqn.js","names":[],"sources":["../../../../../shared/src/schemas/metric-fqn.ts"],"sourcesContent":["/**\n * Unity Catalog object-name grammar
|
|
1
|
+
{"version":3,"file":"metric-fqn.js","names":[],"sources":["../../../../../shared/src/schemas/metric-fqn.ts"],"sourcesContent":["/**\n * Unity Catalog object-name grammar\n * the single source of truth for metric view FQN (Fully Qualified Name).\n *\n * A metric view's `source` FQN is validated in two places that must agree:\n *\n * 1. The canonical Zod schema (`./metric-source.ts`), which composes the\n * three-part FQN regex from {@link UC_FQN_PATTERN} for IDE/CI and the\n * generated JSON schema (`docs/static/schemas/metric-source.schema.json`).\n * 2. The type-generator runtime (`packages/appkit/src/type-generator/mv-registry/config.ts`),\n * which imports {@link UC_FQN_PATTERN} as a plain value to validate each\n * dot-split segment.\n *\n *\n * A Unity Catalog object name:\n * - cannot exceed 255 characters ({@link MAX_UC_OBJECT_NAME_LENGTH}); and\n * - cannot contain any of these characters:\n * - period (`.`)\n * - space (U+0020)\n * - forward slash (`/`)\n * - all ASCII control characters (U+0000-U+001F)\n * - the DELETE character (U+007F)\n *\n * Every other character is permitted in a quoted name, including non-ASCII\n * letters (the docs demonstrate Chinese/Russian/Portuguese names) and hyphens.\n *\n * @note the regex is the per-segment character set; structure (3 parts) and length are intentionally left to the callers.\n */\n\nexport const MAX_UC_OBJECT_NAME_LENGTH = 255;\n\n/**\n * Basename of the metric-view declarations file, resolved inside a\n * `config/metric-views/` folder. Single source of truth shared by the analytics\n * runtime (`plugins/analytics/mv`), the type-generator, its Vite watcher, and\n * the `generate-types` CLI. It lives in this zod-free module (rather than the\n * canonical `metric-source.ts` schema) so the type-generator can import it\n * without pulling zod into its locked dependency graph.\n */\nexport const METRIC_CONFIG_FILE = \"definitions.json\";\n\n/**\n * Matches a single, non-empty Unity Catalog object name as it may appear in a backtick-quoted (delimited) identifier.\n *\n * @example\n * UC_FQN_PATTERN.test(\"revenue_metrics\"); // true\n * UC_FQN_PATTERN.test(\"prod-data\"); // true (hyphens are UC-legal)\n * UC_FQN_PATTERN.test(\"cafe\\u0301\"); // true (non-ASCII is UC-legal)\n * UC_FQN_PATTERN.test(\"bad name\"); // false (space is prohibited)\n * UC_FQN_PATTERN.test(\"a/b\"); // false (slash is prohibited)\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: UC explicitly prohibits ASCII control characters in object names; this negated class encodes that rule.\nexport const UC_FQN_PATTERN = /^[^\\x00-\\x20\\x7f./]+$/;\n\n/** A metric view FQN is exactly three segments: catalog.schema.metric_view. */\nconst FQN_SEGMENT_COUNT = 3;\n\n/**\n * Total predicate: is `fqn` a well-formed three-part UC metric view FQN?\n * Well-formed = exactly three non-empty, dot-separated segments, each a valid Unity Catalog object name per {@link UC_FQN_PATTERN}.\n * @example\n * isValidFqn(\"main.analytics.revenue\"); // true\n * isValidFqn(\"prod-data.analytics.rev\"); // true (hyphens are UC-legal)\n * isValidFqn(\"main.analytics\"); // false (only two segments)\n */\nexport function isValidFqn(fqn: string): boolean {\n const segments = fqn.split(\".\");\n if (segments.length !== FQN_SEGMENT_COUNT) {\n return false;\n }\n return segments.every((segment) => UC_FQN_PATTERN.test(segment));\n}\n\n/**\n * Quote a dot-separated FQN for safe interpolation into a Spark/Databricks SQL\n * statement.\n *\n * Each dot-split segment is wrapped in backtick-quoted-identifier syntax. The\n * one character that can break out of a backtick-quoted identifier is the\n * backtick itself, escaped by doubling (`` ` `` → `` `` ``) — so every backtick\n * inside a segment is doubled before the segment is wrapped. Control characters\n * and newlines have no valid escape inside a quoted identifier, so a segment\n * containing one is rejected outright.\n *\n * This is a pure, standalone escaper: it is intentionally independent of FQN\n * naming validation ({@link isValidFqn}). Naming validation decides whether an\n * FQN is an acceptable metric source; this function only guarantees that\n * whatever it is handed cannot break out of the quoted identifier it produces.\n * Grammar and quoting live together here so a metric source is validated and\n * escaped against one shared source of truth.\n *\n * An ordinary identifier is unchanged apart from the wrapping backticks:\n * `catalog.schema.view` → `` `catalog`.`schema`.`view` ``.\n *\n * @param fqn - Dot-separated identifier (e.g. `catalog.schema.view`).\n * @returns The backtick-quoted, escaped identifier ready for interpolation.\n * @throws If any segment contains a control character or newline.\n */\nexport function quoteFqnForSql(fqn: string): string {\n return fqn.split(\".\").map(quoteIdentifier).join(\".\");\n}\n\n/**\n * The Unicode \"control\" category (`\\p{Cc}`): C0 (incl. `\\n`, `\\r`, `\\t`), DEL,\n * and C1 — every control character/newline. These have no valid escape inside\n * a backtick-quoted identifier, so a name containing one cannot be safely\n * quoted and is rejected.\n */\nconst CONTROL_OR_NEWLINE = /\\p{Cc}/u;\n\nexport function isValidColumnName(name: string): boolean {\n return name.length > 0 && !CONTROL_OR_NEWLINE.test(name);\n}\n\n/**\n * Quote a single identifier (one column/measure/dimension name, or one FQN\n * segment) as a backtick-delimited identifier for safe SQL interpolation.\n *\n * @throws If `name` contains a control character or newline.\n */\nexport function quoteIdentifier(name: string): string {\n if (CONTROL_OR_NEWLINE.test(name)) {\n throw new Error(\n `Cannot quote identifier \"${name}\" for SQL: it contains a control character or newline, which has no valid escape inside a backtick-quoted identifier.`,\n );\n }\n // Double every backtick, then wrap in backticks.\n return `\\`${name.replace(/`/g, \"``\")}\\``;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,4BAA4B;;;;;;;;;AAUzC,MAAa,qBAAqB;;;;;;;;;;;AAalC,MAAa,iBAAiB;;AAG9B,MAAM,oBAAoB;;;;;;;;;AAU1B,SAAgB,WAAW,KAAsB;CAC/C,MAAM,WAAW,IAAI,MAAM,IAAI;AAC/B,KAAI,SAAS,WAAW,kBACtB,QAAO;AAET,QAAO,SAAS,OAAO,YAAY,eAAe,KAAK,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BlE,SAAgB,eAAe,KAAqB;AAClD,QAAO,IAAI,MAAM,IAAI,CAAC,IAAI,gBAAgB,CAAC,KAAK,IAAI;;;;;;;;AAStD,MAAM,qBAAqB;AAE3B,SAAgB,kBAAkB,MAAuB;AACvD,QAAO,KAAK,SAAS,KAAK,CAAC,mBAAmB,KAAK,KAAK;;;;;;;;AAS1D,SAAgB,gBAAgB,MAAsB;AACpD,KAAI,mBAAmB,KAAK,KAAK,CAC/B,OAAM,IAAI,MACR,4BAA4B,KAAK,uHAClC;AAGH,QAAO,KAAK,KAAK,QAAQ,MAAM,KAAK,CAAC"}
|