@databricks/appkit 0.45.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/CLAUDE.md +1 -0
- package/dist/agents/databricks.d.ts +25 -1
- package/dist/agents/databricks.d.ts.map +1 -1
- package/dist/agents/databricks.js +20 -1
- package/dist/agents/databricks.js.map +1 -1
- 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/beta.d.ts +2 -2
- package/dist/cli/commands/generate-types.js +9 -4
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/core/agent/load-agents.d.ts.map +1 -1
- package/dist/core/agent/load-agents.js +52 -0
- package/dist/core/agent/load-agents.js.map +1 -1
- package/dist/core/agent/types.d.ts +11 -0
- package/dist/core/agent/types.d.ts.map +1 -1
- package/dist/core/agent/types.js.map +1 -1
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +2 -0
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/lifecycle-manager.js +183 -0
- package/dist/core/lifecycle-manager.js.map +1 -0
- package/dist/core/plugin-context.d.ts +19 -1
- package/dist/core/plugin-context.d.ts.map +1 -1
- package/dist/core/plugin-context.js +9 -3
- package/dist/core/plugin-context.js.map +1 -1
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +2 -1
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugins/agents/agents.d.ts.map +1 -1
- package/dist/plugins/agents/agents.js +3 -3
- package/dist/plugins/agents/agents.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/plugins/lakebase/lakebase.d.ts +10 -2
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js +18 -7
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/server/index.d.ts +51 -1
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js +110 -23
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/registry/resource-registry.d.ts +9 -1
- package/dist/registry/resource-registry.d.ts.map +1 -1
- package/dist/registry/resource-registry.js +22 -5
- package/dist/registry/resource-registry.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/execute.d.ts +1 -3
- package/dist/shared/src/execute.d.ts.map +1 -1
- package/dist/shared/src/plugin.d.ts +16 -0
- package/dist/shared/src/plugin.d.ts.map +1 -1
- 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/stream/buffers.js +1 -1
- package/dist/stream/buffers.js.map +1 -1
- package/dist/stream/defaults.js +0 -2
- package/dist/stream/defaults.js.map +1 -1
- package/dist/stream/stream-manager.d.ts +2 -2
- package/dist/stream/stream-manager.d.ts.map +1 -1
- package/dist/stream/stream-manager.js +26 -24
- package/dist/stream/stream-manager.js.map +1 -1
- package/dist/stream/stream-registry.js +30 -23
- package/dist/stream/stream-registry.js.map +1 -1
- package/dist/stream/timers.js +17 -0
- package/dist/stream/timers.js.map +1 -0
- package/dist/stream/types.js.map +1 -1
- package/dist/telemetry/telemetry-manager.js +19 -13
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- 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/dist/utils/safe-handler.js +28 -0
- package/dist/utils/safe-handler.js.map +1 -0
- package/docs/api/appkit/Class.Plugin.md +2 -0
- package/docs/api/appkit/Class.ResourceRegistry.md +1 -1
- package/docs/api/appkit/Interface.AgentDefinition.md +11 -0
- package/docs/api/appkit/Interface.GenerationParams.md +58 -0
- package/docs/api/appkit/Interface.RegisteredAgent.md +11 -0
- package/docs/api/appkit.md +1 -0
- package/docs/development/type-generation.md +3 -3
- package/docs/plugins/analytics.md +172 -23
- package/llms.txt +1 -0
- 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":"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"}
|
|
@@ -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"}
|
|
@@ -93,9 +93,17 @@ declare class LakebasePlugin extends Plugin implements ToolProvider {
|
|
|
93
93
|
private runReadOnlyStatement;
|
|
94
94
|
/**
|
|
95
95
|
* Gracefully drains and closes all connection pools (SP + OBO).
|
|
96
|
-
*
|
|
96
|
+
*
|
|
97
|
+
* Runs as the plugin's `shutdown()` hook (phase 3 of the core lifecycle
|
|
98
|
+
* manager's graceful shutdown), NOT in `abortActiveOperations()` (phase 2):
|
|
99
|
+
* other plugins' `shutdown()` hooks may still need database connections to
|
|
100
|
+
* drain state,
|
|
101
|
+
* so the pools must outlive the abort phase. `pg.Pool#end()` waits for
|
|
102
|
+
* checked-out clients to be released, so hooks running concurrently with
|
|
103
|
+
* this one can still finish their in-flight queries. Errors are caught
|
|
104
|
+
* and logged; this hook never throws.
|
|
97
105
|
*/
|
|
98
|
-
|
|
106
|
+
shutdown(): Promise<void>;
|
|
99
107
|
/**
|
|
100
108
|
* Agent tool registry. Empty by default — the Lakebase plugin does NOT
|
|
101
109
|
* expose its SQL connection to LLM agents unless the developer explicitly
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lakebase.d.ts","names":[],"sources":["../../../src/plugins/lakebase/lakebase.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiEA;;;;;;;;;;;;;;;;cAAa,cAAA,SAAuB,MAAA,YAAkB,YAAA;
|
|
1
|
+
{"version":3,"file":"lakebase.d.ts","names":[],"sources":["../../../src/plugins/lakebase/lakebase.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiEA;;;;;;;;;;;;;;;;cAAa,cAAA,SAAuB,MAAA,YAAkB,YAAA;EA6NU;EAAA,OA3NvD,QAAA,EAAuB,cAAA;EAAA,UAEZ,MAAA,EAAQ,eAAA;EAAA,QAClB,IAAA;EAAA,QACA,cAAA;EAmEL;;;;;;;;EAzDG,KAAA,CAAA,GAAK,OAAA;EAhBqD;;;;;;;;;;;;;;;;;;EAsE1D,KAAA,WAAgB,cAAA,OAAA,CACpB,IAAA,UACA,MAAA,eACC,OAAA,CAAQ,WAAA,CAAY,CAAA;EAAA;;;;;;;;;;;;;;EAAA,QAmBT,oBAAA;EA4HZ;;;;;;;;;;;;EA7FI,QAAA,CAAA,GAAY,OAAA;EArDI;;;;;EAAA,QAkFd,KAAA;cAEI,MAAA,EAAQ,eAAA;EAAA,QAYZ,cAAA;EA2CR,aAAA,CAAA,GAAiB,mBAAA;EAIX,gBAAA,CACJ,IAAA,UACA,IAAA,WACA,MAAA,GAAS,WAAA,GACR,OAAA;EAIH,OAAA,CAAQ,IAAA,GAJE,cAAA,GAIoD,MAAA,SAAA,YAAA;;;;;UAQtD,gBAAA;EAtPA;;;;;;;;;;;;EA2QR,OAAA,CAAA;UAGwB,YAAA;sBAvLF,cAAA,QAAc,IAAA,UACtB,MAAA,iBAEX,OAAA,CAAQ,WAAA,CAAY,CAAA;;;;;;;;;;iBA1Ff,QAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAyRG,QAAA,EAAQ,QAAA,QAAA,cAAA,EAAA,eAAA"}
|
|
@@ -141,22 +141,33 @@ var LakebasePlugin = class extends Plugin {
|
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
143
|
* Gracefully drains and closes all connection pools (SP + OBO).
|
|
144
|
-
*
|
|
144
|
+
*
|
|
145
|
+
* Runs as the plugin's `shutdown()` hook (phase 3 of the core lifecycle
|
|
146
|
+
* manager's graceful shutdown), NOT in `abortActiveOperations()` (phase 2):
|
|
147
|
+
* other plugins' `shutdown()` hooks may still need database connections to
|
|
148
|
+
* drain state,
|
|
149
|
+
* so the pools must outlive the abort phase. `pg.Pool#end()` waits for
|
|
150
|
+
* checked-out clients to be released, so hooks running concurrently with
|
|
151
|
+
* this one can still finish their in-flight queries. Errors are caught
|
|
152
|
+
* and logged; this hook never throws.
|
|
145
153
|
*/
|
|
146
|
-
|
|
147
|
-
super.abortActiveOperations();
|
|
154
|
+
async shutdown() {
|
|
148
155
|
if (this.pool) {
|
|
149
156
|
logger.info("Closing Lakebase SP pool");
|
|
150
|
-
|
|
157
|
+
try {
|
|
158
|
+
await this.pool.end();
|
|
159
|
+
} catch (err) {
|
|
151
160
|
logger.error("Error closing Lakebase SP pool: %O", err);
|
|
152
|
-
}
|
|
161
|
+
}
|
|
153
162
|
this.pool = null;
|
|
154
163
|
}
|
|
155
164
|
if (this.oboPoolManager) {
|
|
156
165
|
logger.info("Closing all Lakebase OBO pools (%d)", this.oboPoolManager.size);
|
|
157
|
-
|
|
166
|
+
try {
|
|
167
|
+
await this.oboPoolManager.closeAll();
|
|
168
|
+
} catch (err) {
|
|
158
169
|
logger.error("Error closing Lakebase OBO pools: %O", err);
|
|
159
|
-
}
|
|
170
|
+
}
|
|
160
171
|
this.oboPoolManager = null;
|
|
161
172
|
}
|
|
162
173
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lakebase.js","names":["manifest"],"sources":["../../../src/plugins/lakebase/lakebase.ts"],"sourcesContent":["import { WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { QueryResult, QueryResultRow } from \"pg\";\nimport type { AgentToolDefinition, ToolProvider } from \"shared\";\nimport { z } from \"zod\";\nimport {\n createLakebasePool,\n createLakebasePoolManager,\n getLakebaseOrmConfig,\n getLakebasePgConfig,\n getUsernameWithApiLookup,\n type LakebasePool,\n type LakebasePoolManager,\n RoutingPool,\n} from \"../../connectors/lakebase\";\nimport { getClientOptions } from \"../../context/client-options\";\nimport { getUserContext } from \"../../context/execution-context\";\nimport { buildToolkitEntries } from \"../../core/agent/build-toolkit\";\nimport {\n defineTool,\n executeFromRegistry,\n toolsFromRegistry,\n} from \"../../core/agent/tools/define-tool\";\nimport { assertReadOnlySql } from \"../../core/agent/tools/sql-policy\";\nimport { createLogger } from \"../../logging/logger\";\nimport { Plugin, toPlugin } from \"../../plugin\";\nimport type { PluginManifest } from \"../../registry\";\nimport manifest from \"./manifest.json\";\nimport type { ILakebaseConfig } from \"./types\";\n\nconst logger = createLogger(\"lakebase\");\n\n/** Default pool settings for per-user OBO pools. */\nconst OBO_POOL_DEFAULTS = {\n max: 3,\n allowExitOnIdle: true,\n idleTimeoutMillis: 30_000,\n};\n\n/**\n * AppKit plugin for Databricks Lakebase Autoscaling.\n *\n * Wraps `@databricks/lakebase` to provide a standard `pg.Pool` with automatic\n * OAuth token refresh, integrated with AppKit's logger and OpenTelemetry setup.\n *\n * Supports On-Behalf-Of (OBO) via `asUser(req)` — each user gets a separate\n * `pg.Pool` authenticated with their Databricks identity, enabling features\n * like Row-Level Security (RLS). Routing is handled transparently by\n * {@link RoutingPool}, which reads the execution context set by the base\n * class `asUser()`.\n *\n * @example\n * ```ts\n * import { createApp, lakebase, server } from \"@databricks/appkit\";\n *\n * const AppKit = await createApp({\n * plugins: [server(), lakebase()],\n * });\n *\n * // Service principal query\n * const result = await AppKit.lakebase.query(\"SELECT * FROM users WHERE id = $1\", [userId]);\n *\n * // User-scoped query (per-user pool, RLS enforced)\n * const mine = await AppKit.lakebase.asUser(req).query(\"SELECT * FROM my_data\");\n * ```\n */\nexport class LakebasePlugin extends Plugin implements ToolProvider {\n /** Plugin manifest declaring metadata and resource requirements */\n static manifest = manifest as PluginManifest<\"lakebase\">;\n\n protected declare config: ILakebaseConfig;\n private pool: RoutingPool | null = null;\n private oboPoolManager: LakebasePoolManager | null = null;\n\n /**\n * Initializes the Lakebase connection pool and OBO pool manager.\n * Called automatically by AppKit during the plugin setup phase.\n *\n * Creates a {@link RoutingPool} that automatically routes queries to either\n * the service-principal pool or a per-user pool based on the execution\n * context (set by `Plugin.asUser(req)` via AsyncLocalStorage).\n */\n async setup() {\n const poolConfig = {\n ...this.config.pool,\n workspaceClient:\n this.config.pool?.workspaceClient ??\n new WorkspaceClient({}, getClientOptions()),\n };\n const user = await getUsernameWithApiLookup(poolConfig);\n\n const spPool = createLakebasePool({ ...poolConfig, user });\n logger.info(\"Lakebase SP pool initialized\");\n\n this.oboPoolManager = createLakebasePoolManager({\n ...poolConfig,\n ...OBO_POOL_DEFAULTS,\n });\n logger.info(\"Lakebase OBO pool manager initialized\");\n\n const oboManager = this.oboPoolManager;\n this.pool = new RoutingPool(spPool, (ctx) => {\n if (!oboManager) throw new Error(\"OBO pool manager not initialized\");\n // Lakebase OAuth roles use email as the postgres role when available\n const userKey = ctx.userEmail ?? ctx.userId;\n const isNew = !oboManager.hasPool(userKey);\n const pool = oboManager.getPool(\n userKey,\n { workspaceClient: ctx.client, user: userKey },\n ctx.tokenFingerprint,\n );\n if (isNew) {\n logger.debug(\"Created OBO pool for user (total: %d)\", oboManager.size);\n }\n return pool;\n });\n }\n\n /**\n * Executes a parameterized SQL query against the Lakebase pool.\n *\n * When called inside `asUser(req)`, the query automatically routes to\n * the per-user pool via {@link RoutingPool}.\n *\n * @param text - SQL query string, using `$1`, `$2`, ... placeholders\n * @param values - Parameter values corresponding to placeholders\n * @returns Query result with typed rows\n *\n * @example\n * ```ts\n * const result = await AppKit.lakebase.query<{ id: number; name: string }>(\n * \"SELECT id, name FROM users WHERE active = $1\",\n * [true],\n * );\n * ```\n */\n async query<T extends QueryResultRow = any>(\n text: string,\n values?: unknown[],\n ): Promise<QueryResult<T>> {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup(), which AppKit always awaits before exposing the plugin API\n return this.pool!.query<T>(text, values);\n }\n\n /**\n * Execute a single statement inside a `BEGIN READ ONLY … ROLLBACK`\n * transaction on a dedicated client.\n *\n * The three commands MUST share a connection — a naive\n * `pool.query(\"BEGIN READ ONLY; <stmt>; ROLLBACK\")` batch cannot accept\n * parameter values (PostgreSQL's Extended Query protocol rejects multi-\n * statement prepared queries), which would silently break every\n * parameterized query the agent tool issues.\n *\n * Returns the raw `rows` array for the user's statement. Side effects the\n * statement may attempt (writes, writable-function side effects) are\n * rejected by PostgreSQL under the read-only transaction posture.\n */\n private async runReadOnlyStatement(\n text: string,\n values?: unknown[],\n ): Promise<unknown[]> {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup()\n const client = await this.pool!.connect();\n try {\n await client.query(\"BEGIN READ ONLY\");\n const result = await client.query(text, values);\n return result.rows;\n } finally {\n try {\n await client.query(\"ROLLBACK\");\n } finally {\n client.release();\n }\n }\n }\n\n /**\n * Gracefully drains and closes all connection pools (SP + OBO).\n * Called automatically by AppKit during shutdown.\n */\n abortActiveOperations(): void {\n super.abortActiveOperations();\n if (this.pool) {\n logger.info(\"Closing Lakebase SP pool\");\n this.pool.end().catch((err) => {\n logger.error(\"Error closing Lakebase SP pool: %O\", err);\n });\n this.pool = null;\n }\n if (this.oboPoolManager) {\n logger.info(\n \"Closing all Lakebase OBO pools (%d)\",\n this.oboPoolManager.size,\n );\n this.oboPoolManager.closeAll().catch((err) => {\n logger.error(\"Error closing Lakebase OBO pools: %O\", err);\n });\n this.oboPoolManager = null;\n }\n }\n\n /**\n * Agent tool registry. Empty by default — the Lakebase plugin does NOT\n * expose its SQL connection to LLM agents unless the developer explicitly\n * opts in via `config.exposeAsAgentTool`. See {@link buildQueryTool}.\n */\n private tools: Record<string, ReturnType<typeof this.buildQueryTool>> = {};\n\n constructor(config: ILakebaseConfig) {\n super(config);\n this.config = config;\n if (config.exposeAsAgentTool) {\n this.tools = { query: this.buildQueryTool(config.exposeAsAgentTool) };\n logger.warn(\n \"Lakebase agent tool is enabled (readOnly=%s). Every agent with access to this plugin can execute SQL against the Lakebase database as the requesting user's identity.\",\n config.exposeAsAgentTool.readOnly !== false,\n );\n }\n }\n\n private buildQueryTool(\n opt: NonNullable<ILakebaseConfig[\"exposeAsAgentTool\"]>,\n ) {\n const readOnly = opt.readOnly !== false;\n return defineTool({\n description: readOnly\n ? \"Execute a read-only SQL query against the Lakebase PostgreSQL database. Only SELECT, WITH, SHOW, EXPLAIN, and DESCRIBE statements are accepted. Use $1, $2, etc. as placeholders and pass values separately.\"\n : \"Execute a parameterized SQL statement against the Lakebase PostgreSQL database. Use $1, $2, etc. as placeholders and pass values separately. This tool can modify data; every invocation requires explicit human approval.\",\n schema: z.object({\n text: z\n .string()\n .describe(\n \"SQL statement with $1, $2, ... placeholders for parameters\",\n ),\n values: z\n .array(z.unknown())\n .optional()\n .describe(\"Parameter values corresponding to placeholders\"),\n }),\n annotations: {\n effect: readOnly ? \"read\" : \"destructive\",\n idempotent: false,\n requiresUserContext: true,\n },\n execute: async (args, signal) => {\n // Matches the files plugin pattern: the pg connection API\n // doesn't accept AbortSignal in its current shape, so deeper\n // mid-call cancellation needs a separate plumbing pass on the\n // connector. This entry check still catches the common case —\n // a tool dispatched after the user already cancelled the\n // stream — and unwinds cleanly instead of running to\n // completion against the SQL warehouse.\n signal?.throwIfAborted();\n if (readOnly) {\n assertReadOnlySql(args.text);\n return this.runReadOnlyStatement(args.text, args.values);\n }\n const result = await this.query(args.text, args.values);\n return result.rows;\n },\n });\n }\n\n getAgentTools(): AgentToolDefinition[] {\n return toolsFromRegistry(this.tools);\n }\n\n async executeAgentTool(\n name: string,\n args: unknown,\n signal?: AbortSignal,\n ): Promise<unknown> {\n return executeFromRegistry(this.tools, name, args, signal);\n }\n\n toolkit(opts?: import(\"../../core/agent/types\").ToolkitOptions) {\n return buildToolkitEntries(this.name, this.tools, opts);\n }\n\n /**\n * Returns the pool config for the current execution context.\n * Inside `asUser(req)`, returns user-scoped config; otherwise SP config.\n */\n private activePoolConfig() {\n const ctx = getUserContext();\n if (ctx) {\n const user = ctx.userEmail ?? ctx.userId;\n return { ...this.config.pool, workspaceClient: ctx.client, user };\n }\n return this.config.pool;\n }\n\n /**\n * Returns the plugin's public API, accessible via `AppKit.lakebase`.\n *\n * - `pool` — The connection pool (routes to per-user pool when inside `asUser(req)`)\n * - `query` — Convenience method for executing parameterized SQL queries\n * - `getOrmConfig()` — Returns a config object compatible with Drizzle, TypeORM, Sequelize, etc.\n * Inside `asUser(req)`, returns user-scoped config.\n * - `getPgConfig()` — Returns a `pg.PoolConfig` object for manual pool construction.\n * Inside `asUser(req)`, returns user-scoped config.\n *\n * Use `AppKit.lakebase.asUser(req)` to get the same API backed by a per-user pool.\n */\n exports() {\n return {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup(), which AppKit always awaits before exposing the plugin API\n pool: this.pool! as LakebasePool,\n query: this.query.bind(this),\n getOrmConfig: () => getLakebaseOrmConfig(this.activePoolConfig()),\n getPgConfig: () => getLakebasePgConfig(this.activePoolConfig()),\n };\n }\n}\n\n/**\n * @internal\n */\nexport const lakebase = toPlugin(LakebasePlugin);\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"lakebase.js","names":["manifest"],"sources":["../../../src/plugins/lakebase/lakebase.ts"],"sourcesContent":["import { WorkspaceClient } from \"@databricks/sdk-experimental\";\nimport type { QueryResult, QueryResultRow } from \"pg\";\nimport type { AgentToolDefinition, ToolProvider } from \"shared\";\nimport { z } from \"zod\";\nimport {\n createLakebasePool,\n createLakebasePoolManager,\n getLakebaseOrmConfig,\n getLakebasePgConfig,\n getUsernameWithApiLookup,\n type LakebasePool,\n type LakebasePoolManager,\n RoutingPool,\n} from \"../../connectors/lakebase\";\nimport { getClientOptions } from \"../../context/client-options\";\nimport { getUserContext } from \"../../context/execution-context\";\nimport { buildToolkitEntries } from \"../../core/agent/build-toolkit\";\nimport {\n defineTool,\n executeFromRegistry,\n toolsFromRegistry,\n} from \"../../core/agent/tools/define-tool\";\nimport { assertReadOnlySql } from \"../../core/agent/tools/sql-policy\";\nimport { createLogger } from \"../../logging/logger\";\nimport { Plugin, toPlugin } from \"../../plugin\";\nimport type { PluginManifest } from \"../../registry\";\nimport manifest from \"./manifest.json\";\nimport type { ILakebaseConfig } from \"./types\";\n\nconst logger = createLogger(\"lakebase\");\n\n/** Default pool settings for per-user OBO pools. */\nconst OBO_POOL_DEFAULTS = {\n max: 3,\n allowExitOnIdle: true,\n idleTimeoutMillis: 30_000,\n};\n\n/**\n * AppKit plugin for Databricks Lakebase Autoscaling.\n *\n * Wraps `@databricks/lakebase` to provide a standard `pg.Pool` with automatic\n * OAuth token refresh, integrated with AppKit's logger and OpenTelemetry setup.\n *\n * Supports On-Behalf-Of (OBO) via `asUser(req)` — each user gets a separate\n * `pg.Pool` authenticated with their Databricks identity, enabling features\n * like Row-Level Security (RLS). Routing is handled transparently by\n * {@link RoutingPool}, which reads the execution context set by the base\n * class `asUser()`.\n *\n * @example\n * ```ts\n * import { createApp, lakebase, server } from \"@databricks/appkit\";\n *\n * const AppKit = await createApp({\n * plugins: [server(), lakebase()],\n * });\n *\n * // Service principal query\n * const result = await AppKit.lakebase.query(\"SELECT * FROM users WHERE id = $1\", [userId]);\n *\n * // User-scoped query (per-user pool, RLS enforced)\n * const mine = await AppKit.lakebase.asUser(req).query(\"SELECT * FROM my_data\");\n * ```\n */\nexport class LakebasePlugin extends Plugin implements ToolProvider {\n /** Plugin manifest declaring metadata and resource requirements */\n static manifest = manifest as PluginManifest<\"lakebase\">;\n\n protected declare config: ILakebaseConfig;\n private pool: RoutingPool | null = null;\n private oboPoolManager: LakebasePoolManager | null = null;\n\n /**\n * Initializes the Lakebase connection pool and OBO pool manager.\n * Called automatically by AppKit during the plugin setup phase.\n *\n * Creates a {@link RoutingPool} that automatically routes queries to either\n * the service-principal pool or a per-user pool based on the execution\n * context (set by `Plugin.asUser(req)` via AsyncLocalStorage).\n */\n async setup() {\n const poolConfig = {\n ...this.config.pool,\n workspaceClient:\n this.config.pool?.workspaceClient ??\n new WorkspaceClient({}, getClientOptions()),\n };\n const user = await getUsernameWithApiLookup(poolConfig);\n\n const spPool = createLakebasePool({ ...poolConfig, user });\n logger.info(\"Lakebase SP pool initialized\");\n\n this.oboPoolManager = createLakebasePoolManager({\n ...poolConfig,\n ...OBO_POOL_DEFAULTS,\n });\n logger.info(\"Lakebase OBO pool manager initialized\");\n\n const oboManager = this.oboPoolManager;\n this.pool = new RoutingPool(spPool, (ctx) => {\n if (!oboManager) throw new Error(\"OBO pool manager not initialized\");\n // Lakebase OAuth roles use email as the postgres role when available\n const userKey = ctx.userEmail ?? ctx.userId;\n const isNew = !oboManager.hasPool(userKey);\n const pool = oboManager.getPool(\n userKey,\n { workspaceClient: ctx.client, user: userKey },\n ctx.tokenFingerprint,\n );\n if (isNew) {\n logger.debug(\"Created OBO pool for user (total: %d)\", oboManager.size);\n }\n return pool;\n });\n }\n\n /**\n * Executes a parameterized SQL query against the Lakebase pool.\n *\n * When called inside `asUser(req)`, the query automatically routes to\n * the per-user pool via {@link RoutingPool}.\n *\n * @param text - SQL query string, using `$1`, `$2`, ... placeholders\n * @param values - Parameter values corresponding to placeholders\n * @returns Query result with typed rows\n *\n * @example\n * ```ts\n * const result = await AppKit.lakebase.query<{ id: number; name: string }>(\n * \"SELECT id, name FROM users WHERE active = $1\",\n * [true],\n * );\n * ```\n */\n async query<T extends QueryResultRow = any>(\n text: string,\n values?: unknown[],\n ): Promise<QueryResult<T>> {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup(), which AppKit always awaits before exposing the plugin API\n return this.pool!.query<T>(text, values);\n }\n\n /**\n * Execute a single statement inside a `BEGIN READ ONLY … ROLLBACK`\n * transaction on a dedicated client.\n *\n * The three commands MUST share a connection — a naive\n * `pool.query(\"BEGIN READ ONLY; <stmt>; ROLLBACK\")` batch cannot accept\n * parameter values (PostgreSQL's Extended Query protocol rejects multi-\n * statement prepared queries), which would silently break every\n * parameterized query the agent tool issues.\n *\n * Returns the raw `rows` array for the user's statement. Side effects the\n * statement may attempt (writes, writable-function side effects) are\n * rejected by PostgreSQL under the read-only transaction posture.\n */\n private async runReadOnlyStatement(\n text: string,\n values?: unknown[],\n ): Promise<unknown[]> {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup()\n const client = await this.pool!.connect();\n try {\n await client.query(\"BEGIN READ ONLY\");\n const result = await client.query(text, values);\n return result.rows;\n } finally {\n try {\n await client.query(\"ROLLBACK\");\n } finally {\n client.release();\n }\n }\n }\n\n /**\n * Gracefully drains and closes all connection pools (SP + OBO).\n *\n * Runs as the plugin's `shutdown()` hook (phase 3 of the core lifecycle\n * manager's graceful shutdown), NOT in `abortActiveOperations()` (phase 2):\n * other plugins' `shutdown()` hooks may still need database connections to\n * drain state,\n * so the pools must outlive the abort phase. `pg.Pool#end()` waits for\n * checked-out clients to be released, so hooks running concurrently with\n * this one can still finish their in-flight queries. Errors are caught\n * and logged; this hook never throws.\n */\n async shutdown(): Promise<void> {\n if (this.pool) {\n logger.info(\"Closing Lakebase SP pool\");\n try {\n await this.pool.end();\n } catch (err) {\n logger.error(\"Error closing Lakebase SP pool: %O\", err);\n }\n this.pool = null;\n }\n if (this.oboPoolManager) {\n logger.info(\n \"Closing all Lakebase OBO pools (%d)\",\n this.oboPoolManager.size,\n );\n try {\n await this.oboPoolManager.closeAll();\n } catch (err) {\n logger.error(\"Error closing Lakebase OBO pools: %O\", err);\n }\n this.oboPoolManager = null;\n }\n }\n\n /**\n * Agent tool registry. Empty by default — the Lakebase plugin does NOT\n * expose its SQL connection to LLM agents unless the developer explicitly\n * opts in via `config.exposeAsAgentTool`. See {@link buildQueryTool}.\n */\n private tools: Record<string, ReturnType<typeof this.buildQueryTool>> = {};\n\n constructor(config: ILakebaseConfig) {\n super(config);\n this.config = config;\n if (config.exposeAsAgentTool) {\n this.tools = { query: this.buildQueryTool(config.exposeAsAgentTool) };\n logger.warn(\n \"Lakebase agent tool is enabled (readOnly=%s). Every agent with access to this plugin can execute SQL against the Lakebase database as the requesting user's identity.\",\n config.exposeAsAgentTool.readOnly !== false,\n );\n }\n }\n\n private buildQueryTool(\n opt: NonNullable<ILakebaseConfig[\"exposeAsAgentTool\"]>,\n ) {\n const readOnly = opt.readOnly !== false;\n return defineTool({\n description: readOnly\n ? \"Execute a read-only SQL query against the Lakebase PostgreSQL database. Only SELECT, WITH, SHOW, EXPLAIN, and DESCRIBE statements are accepted. Use $1, $2, etc. as placeholders and pass values separately.\"\n : \"Execute a parameterized SQL statement against the Lakebase PostgreSQL database. Use $1, $2, etc. as placeholders and pass values separately. This tool can modify data; every invocation requires explicit human approval.\",\n schema: z.object({\n text: z\n .string()\n .describe(\n \"SQL statement with $1, $2, ... placeholders for parameters\",\n ),\n values: z\n .array(z.unknown())\n .optional()\n .describe(\"Parameter values corresponding to placeholders\"),\n }),\n annotations: {\n effect: readOnly ? \"read\" : \"destructive\",\n idempotent: false,\n requiresUserContext: true,\n },\n execute: async (args, signal) => {\n // Matches the files plugin pattern: the pg connection API\n // doesn't accept AbortSignal in its current shape, so deeper\n // mid-call cancellation needs a separate plumbing pass on the\n // connector. This entry check still catches the common case —\n // a tool dispatched after the user already cancelled the\n // stream — and unwinds cleanly instead of running to\n // completion against the SQL warehouse.\n signal?.throwIfAborted();\n if (readOnly) {\n assertReadOnlySql(args.text);\n return this.runReadOnlyStatement(args.text, args.values);\n }\n const result = await this.query(args.text, args.values);\n return result.rows;\n },\n });\n }\n\n getAgentTools(): AgentToolDefinition[] {\n return toolsFromRegistry(this.tools);\n }\n\n async executeAgentTool(\n name: string,\n args: unknown,\n signal?: AbortSignal,\n ): Promise<unknown> {\n return executeFromRegistry(this.tools, name, args, signal);\n }\n\n toolkit(opts?: import(\"../../core/agent/types\").ToolkitOptions) {\n return buildToolkitEntries(this.name, this.tools, opts);\n }\n\n /**\n * Returns the pool config for the current execution context.\n * Inside `asUser(req)`, returns user-scoped config; otherwise SP config.\n */\n private activePoolConfig() {\n const ctx = getUserContext();\n if (ctx) {\n const user = ctx.userEmail ?? ctx.userId;\n return { ...this.config.pool, workspaceClient: ctx.client, user };\n }\n return this.config.pool;\n }\n\n /**\n * Returns the plugin's public API, accessible via `AppKit.lakebase`.\n *\n * - `pool` — The connection pool (routes to per-user pool when inside `asUser(req)`)\n * - `query` — Convenience method for executing parameterized SQL queries\n * - `getOrmConfig()` — Returns a config object compatible with Drizzle, TypeORM, Sequelize, etc.\n * Inside `asUser(req)`, returns user-scoped config.\n * - `getPgConfig()` — Returns a `pg.PoolConfig` object for manual pool construction.\n * Inside `asUser(req)`, returns user-scoped config.\n *\n * Use `AppKit.lakebase.asUser(req)` to get the same API backed by a per-user pool.\n */\n exports() {\n return {\n // biome-ignore lint/style/noNonNullAssertion: pool is guaranteed non-null after setup(), which AppKit always awaits before exposing the plugin API\n pool: this.pool! as LakebasePool,\n query: this.query.bind(this),\n getOrmConfig: () => getLakebaseOrmConfig(this.activePoolConfig()),\n getPgConfig: () => getLakebasePgConfig(this.activePoolConfig()),\n };\n }\n}\n\n/**\n * @internal\n */\nexport const lakebase = toPlugin(LakebasePlugin);\n"],"mappings":"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"}
|
|
@@ -36,6 +36,13 @@ declare class ServerPlugin extends Plugin {
|
|
|
36
36
|
host: string;
|
|
37
37
|
port: number;
|
|
38
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Budget for awaiting `server.close()` during shutdown. Bounded because
|
|
41
|
+
* `closeAllConnections()` runs immediately before the await, so the close
|
|
42
|
+
* is expected to resolve promptly; the core lifecycle manager's overall
|
|
43
|
+
* force-exit timer is the ultimate backstop if it does not.
|
|
44
|
+
*/
|
|
45
|
+
private static readonly SERVER_CLOSE_TIMEOUT_MS;
|
|
39
46
|
/** Plugin manifest declaring metadata and resource requirements */
|
|
40
47
|
static manifest: PluginManifest<"server">;
|
|
41
48
|
private serverApplication;
|
|
@@ -47,6 +54,12 @@ declare class ServerPlugin extends Plugin {
|
|
|
47
54
|
protected config: ServerConfig;
|
|
48
55
|
private serverExtensions;
|
|
49
56
|
private rawBodyPaths;
|
|
57
|
+
/**
|
|
58
|
+
* Resolves when `server.close()` completes. Created in
|
|
59
|
+
* {@link abortActiveOperations} (which initiates the close) and awaited in
|
|
60
|
+
* {@link closeRemainingConnections} after other plugins have drained.
|
|
61
|
+
*/
|
|
62
|
+
private serverClosed?;
|
|
50
63
|
static phase: PluginPhase;
|
|
51
64
|
constructor(config: ServerConfig);
|
|
52
65
|
attachContext(deps?: Parameters<Plugin["attachContext"]>[0]): void;
|
|
@@ -86,6 +99,11 @@ declare class ServerPlugin extends Plugin {
|
|
|
86
99
|
* Call this inside the `onPluginsReady` callback of `createApp` to register
|
|
87
100
|
* custom Express routes or middleware before the server starts listening.
|
|
88
101
|
*
|
|
102
|
+
* Note: async handlers registered directly on the app must handle their own
|
|
103
|
+
* rejections — Express 4 does not forward rejected promises to the error
|
|
104
|
+
* middleware. Errors passed explicitly to `next(err)` are formatted by the
|
|
105
|
+
* terminal error middleware.
|
|
106
|
+
*
|
|
89
107
|
* @param fn - A function that receives the express application.
|
|
90
108
|
* @returns The server plugin instance for chaining.
|
|
91
109
|
*/
|
|
@@ -120,7 +138,39 @@ declare class ServerPlugin extends Plugin {
|
|
|
120
138
|
*/
|
|
121
139
|
private resolveListenPort;
|
|
122
140
|
private logStartupInfo;
|
|
123
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Cancel in-flight work and begin closing the HTTP server.
|
|
143
|
+
*
|
|
144
|
+
* Runs in the first phase of the core LifecycleManager's graceful shutdown,
|
|
145
|
+
* before any plugin `shutdown()` hook. In addition to the base behavior
|
|
146
|
+
* (aborting SSE streams), it stops the server accepting new connections and
|
|
147
|
+
* drops idle keep-alive sockets — without `closeIdleConnections()`, a
|
|
148
|
+
* connected browser would pin `server.close()` open until the force-exit
|
|
149
|
+
* timer fires. The close is only *initiated* here; the returned promise is
|
|
150
|
+
* awaited later in {@link closeRemainingConnections} so peer plugins can
|
|
151
|
+
* still drain in-flight requests through the server first.
|
|
152
|
+
*/
|
|
153
|
+
abortActiveOperations(): void;
|
|
154
|
+
/**
|
|
155
|
+
* Drain the dev-only servers as this plugin's `shutdown()` hook.
|
|
156
|
+
*
|
|
157
|
+
* Runs concurrently with the other plugins' hooks during graceful shutdown.
|
|
158
|
+
* These are no-ops in production (neither is created), so this hook is
|
|
159
|
+
* effectively dev-only.
|
|
160
|
+
*/
|
|
161
|
+
shutdown(): Promise<void>;
|
|
162
|
+
/**
|
|
163
|
+
* Force-close whatever sockets remain and await the server close.
|
|
164
|
+
*
|
|
165
|
+
* Registered on the `"shutdown"` lifecycle event in {@link start}, so it
|
|
166
|
+
* fires after every plugin's `shutdown()` hook has run — meaning any
|
|
167
|
+
* in-flight request a peer plugin was draining has already completed.
|
|
168
|
+
* `closeAllConnections()` destroys the leftover sockets (aborted SSE
|
|
169
|
+
* responses, keep-alive connections) synchronously so the pending
|
|
170
|
+
* `server.close()` can complete; the await is bounded because the close is
|
|
171
|
+
* expected to resolve promptly once the sockets are gone.
|
|
172
|
+
*/
|
|
173
|
+
private closeRemainingConnections;
|
|
124
174
|
/**
|
|
125
175
|
* Returns the public exports for the server plugin.
|
|
126
176
|
* Exposes server management methods.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../../../src/plugins/server/index.ts"],"mappings":";;;;;;;;;;;;;;;;AAkDA;;;;;;;;;;;;;;;;;cAAa,YAAA,SAAqB,MAAA;EAAA,OAClB,cAAA;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../../src/plugins/server/index.ts"],"mappings":";;;;;;;;;;;;;;;;AAkDA;;;;;;;;;;;;;;;;;cAAa,YAAA,SAAqB,MAAA;EAAA,OAClB,cAAA;;;;EADwB;;;;;;EAAA,wBAYd,uBAAA;EAGM;EAAA,OAAvB,QAAA,EAAuB,cAAA;EAAA,QACtB,iBAAA;EAAA,QACA,MAAA;EAAA,QACA,aAAA;EAAA,QACA,sBAAA;EAGU;EAAA,QADV,kBAAA;EAAA,UACU,MAAA,EAAQ,YAAA;EAAA,QAClB,gBAAA;EAAA,QACA,YAAA;EAOD;;;;;EAAA,QADC,YAAA;EAAA,OACD,KAAA,EAAO,WAAA;cAEF,MAAA,EAAQ,YAAA;EAepB,aAAA,CAAc,IAAA,GAAM,UAAA,CAAW,MAAA;EAUzB;EAAA,KAAA,CAAA,GAAK,OAAA;EAGX;EAAA,SAAA,CAAA;IAAA;;;;;;gBAHW,gBAAA;EAAA;EAiBI;;;;;;;;EAAT,KAAA,CAAA,GAAS,OAAA,CAAQ,OAAA,CAAQ,WAAA;EAwGxB;;;;;;;;EAtBP,SAAA,CAAA,GAAa,MAAA;EA6KC;;;;;;;;;;;;;;EAvJd,MAAA,CAAO,EAAA,GAAK,GAAA,EAAK,OAAA,CAAQ,WAAA;;;;;;;EAWzB,YAAA,CAAa,EAAA,GAAK,GAAA,EAAK,OAAA,CAAQ,WAAA;;;;;AA8YjC;;;UAnYgB,YAAA;EAmYG;;;;;;EAAA,QAhUH,aAAA;EAAA,eA4CC,cAAA;EAoRE;;;;;EAAA,QAlQH,iBAAA;EAAA,QAqBN,cAAA;;;;;;;;;;;;;EA2CR,qBAAA,CAAA;;;;;;;;EAmBM,QAAA,CAAA,GAAY,OAAA;;;;;;;;;;;;UAoBJ,yBAAA;;;;;EAoBd,OAAA,CAAA;yEAIgB,GAAA,EAAK,OAAA,CAAQ,WAAA;qBA5ShB,MAAA;;;;;;;;kBAAU,gBAAA;IAAA;;;;;;;cA+aZ,MAAA,EAAM,QAAA,QAAA,YAAA,EAAA,YAAA"}
|