@malloy-publisher/server 0.0.232 → 0.0.234
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/README.docker.md +1 -0
- package/dist/app/api-doc.yaml +269 -10
- package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DTZQ4Gxc.js} +1 -1
- package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-C5mlDPXK.js} +1 -1
- package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-DGNmhG0u.js} +1 -1
- package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-CVL_wmP4.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-DmzMBCpy.js} +1 -1
- package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-Dbvf4QbB.js} +1 -1
- package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-DxdHc2Qs.js} +1 -1
- package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-OJdT4tCd.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-Bk7s0KXY.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-j_vCWdN3.js} +1 -1
- package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-Rj_4rRnA.es-DoIfLxDJ.js} +1 -1
- package/dist/app/assets/{index-B33zGctF.js → index-B_jKMR35.js} +4 -4
- package/dist/app/assets/{index-CmkW1MiE.js → index-D-rDyK11.js} +1 -1
- package/dist/app/assets/{index-tXJXwdyj.js → index-DWIe_hK0.js} +1 -1
- package/dist/app/assets/{index-BkiWKaAF.js → index-hw-xn0X7.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +53 -3
- package/dist/server.mjs +20277 -925
- package/package.json +1 -1
- package/src/config.ts +35 -1
- package/src/controller/connection.controller.spec.ts +46 -0
- package/src/controller/connection.controller.ts +105 -2
- package/src/controller/materialization.controller.spec.ts +25 -0
- package/src/controller/materialization.controller.ts +60 -0
- package/src/controller/model.controller.ts +24 -0
- package/src/controller/query.controller.ts +83 -10
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/mcp/handler_utils.ts +10 -2
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +240 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/execute_query_tool.spec.ts +154 -3
- package/src/mcp/tools/execute_query_tool.ts +131 -155
- package/src/mcp/tools/get_context_tool.spec.ts +63 -3
- package/src/mcp/tools/get_context_tool.ts +43 -46
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/mcp_config.spec.ts +919 -0
- package/src/mcp_config.ts +425 -0
- package/src/oom_guards.integration.spec.ts +11 -3
- package/src/package_load/package_load_pool.ts +2 -0
- package/src/package_load/package_load_worker.ts +17 -5
- package/src/package_load/protocol.ts +6 -0
- package/src/query_metadata_metrics.ts +49 -0
- package/src/server.ts +99 -3
- package/src/service/build_plan.spec.ts +125 -0
- package/src/service/build_plan.ts +108 -7
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +77 -14
- package/src/service/connection_config.spec.ts +60 -0
- package/src/service/connection_config.ts +75 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/environment.ts +57 -3
- package/src/service/materialization_config_validation.spec.ts +99 -0
- package/src/service/materialization_config_validation.ts +120 -0
- package/src/service/materialization_schedule_surface.spec.ts +124 -0
- package/src/service/materialization_service.spec.ts +119 -0
- package/src/service/materialization_service.ts +186 -3
- package/src/service/materialization_test_fixtures.ts +86 -21
- package/src/service/model.spec.ts +45 -1
- package/src/service/model.ts +171 -23
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/package.ts +24 -1
- package/src/service/package_manifest.spec.ts +137 -4
- package/src/service/package_manifest.ts +140 -5
- package/src/service/persist_annotation_validation.spec.ts +12 -0
- package/src/service/persist_annotation_validation.ts +9 -4
- package/src/service/query_metadata.spec.ts +408 -0
- package/src/service/query_metadata.ts +492 -0
- package/src/service/query_metadata_identity.spec.ts +149 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-query metadata: the publisher's side of Malloy's
|
|
3
|
+
* `RunSQLOptions.queryMetadata` — a flat bag of string properties each connector
|
|
4
|
+
* attaches to the statement it issues (Snowflake per-statement `QUERY_TAG`,
|
|
5
|
+
* BigQuery per-job labels, a leading SQL comment on the rest). It describes the
|
|
6
|
+
* query for the warehouse's own reporting — cost attribution, workload
|
|
7
|
+
* classification, tracing — and never affects results or data identity.
|
|
8
|
+
*
|
|
9
|
+
* Two jobs live here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Resolve.** One bag per query, merged least-specific-first: connection
|
|
12
|
+
* default → model-side resolved value → request override → server context.
|
|
13
|
+
* Context is applied last because it describes what the server is actually
|
|
14
|
+
* doing (which package, which class of work) and a caller must not be able
|
|
15
|
+
* to overwrite that attribution.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Keep the bag legal.** Malloy validates at dispatch and THROWS on a bag
|
|
18
|
+
* that violates its contract, so an assembled bag that is one character too
|
|
19
|
+
* long would fail a customer query. {@link mergeQueryMetadata} therefore
|
|
20
|
+
* never throws: it sanitizes, truncates and drops, metering every drop.
|
|
21
|
+
* Declaration boundaries — a publish, a connection update, an API request —
|
|
22
|
+
* use {@link queryMetadataViolations} instead and fail fast with a message.
|
|
23
|
+
* Config LOAD is the exception: a connection default that violates the
|
|
24
|
+
* contract warns, because a tag must never be the reason an environment
|
|
25
|
+
* refuses to come up.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { QueryMetadata } from "@malloydata/malloy";
|
|
29
|
+
import { getQueryMetadataMode } from "../config";
|
|
30
|
+
import {
|
|
31
|
+
recordQueryMetadataApplied,
|
|
32
|
+
recordQueryMetadataDropped,
|
|
33
|
+
} from "../query_metadata_metrics";
|
|
34
|
+
|
|
35
|
+
export type { QueryMetadata };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What class of work issued the query. Attribution's primary axis: a warehouse
|
|
39
|
+
* bill answers "how much of this is interactive traffic vs builds vs indexing"
|
|
40
|
+
* only if every statement says which it is.
|
|
41
|
+
*/
|
|
42
|
+
export const QUERY_CLASSES = [
|
|
43
|
+
"interactive",
|
|
44
|
+
"materialize",
|
|
45
|
+
"index",
|
|
46
|
+
"ops",
|
|
47
|
+
] as const;
|
|
48
|
+
export type QueryClass = (typeof QUERY_CLASSES)[number];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Malloy's contract for a bag, mirrored (`@malloydata/malloy` exports the
|
|
52
|
+
* `QueryMetadata` type but not its validator):
|
|
53
|
+
*
|
|
54
|
+
* - property names: ASCII alphanumerics and underscore, <=128 chars
|
|
55
|
+
* - property values: printable ASCII except `"`, <=256 chars
|
|
56
|
+
* - at most 20 properties
|
|
57
|
+
*
|
|
58
|
+
* Kept in one place with the upstream pin below so a future core change is a
|
|
59
|
+
* one-line update here rather than a scattered hunt. A bag that satisfies this
|
|
60
|
+
* is renderable by every connector, including into the `-- NAME="value"` comment
|
|
61
|
+
* form.
|
|
62
|
+
*
|
|
63
|
+
* Pinned to @malloydata/malloy 0.0.426 (`packages/malloy/src/query_metadata.ts`).
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_PROPERTY_NAME_LENGTH = 128;
|
|
66
|
+
export const MAX_PROPERTY_VALUE_LENGTH = 256;
|
|
67
|
+
export const MAX_PROPERTIES = 20;
|
|
68
|
+
const PROPERTY_NAME_RE = /^[A-Za-z0-9_]+$/;
|
|
69
|
+
/**
|
|
70
|
+
* Snowflake's `QUERY_TAG` holds the whole bag as one JSON string capped at 2000
|
|
71
|
+
* chars, and db-snowflake clamps by slicing — which truncates mid-JSON and
|
|
72
|
+
* leaves an unparseable tag, i.e. silently unqueryable in `QUERY_HISTORY`,
|
|
73
|
+
* which is the entire point of tagging. So the budget is enforced here, on the
|
|
74
|
+
* serialized size, by dropping whole properties.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_SERIALIZED_LENGTH = 2000;
|
|
77
|
+
|
|
78
|
+
/** Printable ASCII (0x20-0x7e) except `"`, which would break the comment form. */
|
|
79
|
+
function isRenderableValue(value: string): boolean {
|
|
80
|
+
for (let i = 0; i < value.length; i++) {
|
|
81
|
+
const code = value.charCodeAt(i);
|
|
82
|
+
if (code < 0x20 || code > 0x7e || code === 0x22) return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ways `raw` violates the contract, empty when it conforms. For boundaries
|
|
89
|
+
* where a human is present: a publish, a connection update, an API request. The
|
|
90
|
+
* runtime path clamps instead (see {@link mergeQueryMetadata}).
|
|
91
|
+
*/
|
|
92
|
+
export function queryMetadataViolations(raw: unknown): string[] {
|
|
93
|
+
if (raw === undefined || raw === null) return [];
|
|
94
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
95
|
+
return ["queryMetadata must be an object of string properties"];
|
|
96
|
+
}
|
|
97
|
+
const problems: string[] = [];
|
|
98
|
+
const entries = Object.entries(raw as Record<string, unknown>);
|
|
99
|
+
if (entries.length > MAX_PROPERTIES) {
|
|
100
|
+
problems.push(
|
|
101
|
+
`queryMetadata declares ${entries.length} properties; at most ${MAX_PROPERTIES} are allowed`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
for (const [name, value] of entries) {
|
|
105
|
+
if (!PROPERTY_NAME_RE.test(name)) {
|
|
106
|
+
problems.push(
|
|
107
|
+
`queryMetadata property name '${name}' must be ASCII alphanumerics and underscore`,
|
|
108
|
+
);
|
|
109
|
+
} else if (name.length > MAX_PROPERTY_NAME_LENGTH) {
|
|
110
|
+
problems.push(
|
|
111
|
+
`queryMetadata property name '${name}' exceeds ${MAX_PROPERTY_NAME_LENGTH} characters`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (typeof value !== "string") {
|
|
115
|
+
problems.push(
|
|
116
|
+
`queryMetadata property '${name}' must be a string (got ${typeof value})`,
|
|
117
|
+
);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!isRenderableValue(value)) {
|
|
121
|
+
problems.push(
|
|
122
|
+
`queryMetadata property '${name}' has a value outside printable ASCII, or containing '"'`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (value.length > MAX_PROPERTY_VALUE_LENGTH) {
|
|
126
|
+
problems.push(
|
|
127
|
+
`queryMetadata property '${name}' value exceeds ${MAX_PROPERTY_VALUE_LENGTH} characters`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return problems;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Advisory notes about a bag that satisfies the contract but still will not do
|
|
136
|
+
* what it says, so it is reported at declaration rather than discovered in a
|
|
137
|
+
* warehouse console. Two kinds today:
|
|
138
|
+
*
|
|
139
|
+
* - **A backend quietly discards it.** BigQuery's label grammar needs a name
|
|
140
|
+
* that starts with a lowercase letter after transformation; db-bigquery drops
|
|
141
|
+
* one it cannot make valid while every other connector keeps it. Same
|
|
142
|
+
* declaration, present on Snowflake, absent on BigQuery.
|
|
143
|
+
* - **The server will not have room for it.** The contract's cap covers the
|
|
144
|
+
* whole bag, and the server's context is added on top of what was declared.
|
|
145
|
+
*/
|
|
146
|
+
export function queryMetadataAdvisoryWarnings(meta: QueryMetadata): string[] {
|
|
147
|
+
const warnings: string[] = [];
|
|
148
|
+
for (const name of Object.keys(meta)) {
|
|
149
|
+
if (!/^[A-Za-z]/.test(name)) {
|
|
150
|
+
warnings.push(
|
|
151
|
+
`queryMetadata property '${name}' does not start with a letter, so BigQuery drops it (other backends keep it); rename it to start with a letter`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return warnings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The budget warning for a declaration of `declared` properties, or undefined
|
|
160
|
+
* when it fits.
|
|
161
|
+
*
|
|
162
|
+
* Separate from {@link queryMetadataAdvisoryWarnings} because the count that
|
|
163
|
+
* matters is not always one bag's: a connection declares a default AND an
|
|
164
|
+
* enforced map, and they share the budget, so summing them is the caller's job.
|
|
165
|
+
* The contract's 20 covers the whole bag, and the server's own context lands on
|
|
166
|
+
* top of everything declared — so a declaration that fills the contract publishes
|
|
167
|
+
* clean and then loses properties on every statement, visible only as a metric.
|
|
168
|
+
*/
|
|
169
|
+
export function queryMetadataBudgetWarning(
|
|
170
|
+
declared: number,
|
|
171
|
+
): string | undefined {
|
|
172
|
+
const authorBudget = MAX_PROPERTIES - RESERVED_CONTEXT_PROPERTIES;
|
|
173
|
+
if (declared <= authorBudget) return undefined;
|
|
174
|
+
return (
|
|
175
|
+
`queryMetadata declares ${declared} properties; the server adds up to ` +
|
|
176
|
+
`${RESERVED_CONTEXT_PROPERTIES} of its own to every statement, so a bag over ` +
|
|
177
|
+
`${authorBudget} loses its least specific properties at query time`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The server's own description of the query, injected on every statement so
|
|
183
|
+
* attribution needs no modeling work from the author.
|
|
184
|
+
*
|
|
185
|
+
* Neutral by design: a platform that wants its own dimensions (tenant,
|
|
186
|
+
* deployment, cost centre) supplies them as a connection default or a
|
|
187
|
+
* per-request override, and the publisher does not invent them.
|
|
188
|
+
*
|
|
189
|
+
* Every field except the correlation id is a property of the unit of work rather
|
|
190
|
+
* than of the individual call, so a repeated query produces the same values. The
|
|
191
|
+
* correlation id is per call by definition — it is the join key a response hands
|
|
192
|
+
* back — which on a backend with no native tag mechanism makes the statement text
|
|
193
|
+
* unique and so bypasses an exact-text result cache. That is the deliberate cost
|
|
194
|
+
* of being able to find one query again.
|
|
195
|
+
*/
|
|
196
|
+
export interface QueryContext {
|
|
197
|
+
/** What issued the query. Absent only where the class is genuinely unknown. */
|
|
198
|
+
queryClass?: QueryClass;
|
|
199
|
+
environment?: string;
|
|
200
|
+
package?: string;
|
|
201
|
+
/**
|
|
202
|
+
* Published version id, when the query runs against a versioned package.
|
|
203
|
+
*
|
|
204
|
+
* Reserved, not yet reachable: the only path that supplies it is a query
|
|
205
|
+
* request's `versionId`, which the route rejects with 501 until versioned
|
|
206
|
+
* packages land. It keeps its slot in {@link CONTEXT_SHED_ORDER} so that
|
|
207
|
+
* wiring it later is a one-line change rather than a shed-order revision.
|
|
208
|
+
*/
|
|
209
|
+
version?: string;
|
|
210
|
+
/** Package-relative model path, for query paths. */
|
|
211
|
+
model?: string;
|
|
212
|
+
/** Persist source or index dimension, for build paths. */
|
|
213
|
+
source?: string;
|
|
214
|
+
/** What started a build: `publish`, `on_demand`, `scheduler`. */
|
|
215
|
+
trigger?: string;
|
|
216
|
+
/**
|
|
217
|
+
* Groups every statement of one build. The publisher mints it (the
|
|
218
|
+
* materialization id) unless the caller supplies its own.
|
|
219
|
+
*/
|
|
220
|
+
runId?: string;
|
|
221
|
+
/**
|
|
222
|
+
* Identifies this single query, minted by the request boundary that returns
|
|
223
|
+
* it to the caller (see {@link mintCorrelationId}). Set only where there is a
|
|
224
|
+
* response field to carry it: an id nobody can read would cost the result
|
|
225
|
+
* cache and buy nothing.
|
|
226
|
+
*/
|
|
227
|
+
correlationId?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A fresh correlation id for one query. Minted by the publisher rather than
|
|
232
|
+
* accepted from the caller because it is the platform's join key: a caller-owned
|
|
233
|
+
* value can be omitted, reused across calls, or collide with another caller's,
|
|
234
|
+
* and then a response has nothing meaningful to hand back. A caller that wants
|
|
235
|
+
* its own request id in the bag declares it under its own property name.
|
|
236
|
+
*/
|
|
237
|
+
export function mintCorrelationId(): string {
|
|
238
|
+
return crypto.randomUUID();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The order context properties are given up in when even the context alone will
|
|
243
|
+
* not fit: least load-bearing first. `class` and `query_id` are last because
|
|
244
|
+
* they are the two that make the rest worth reading — the workload split, and
|
|
245
|
+
* the id a response handed a caller as its join key.
|
|
246
|
+
*/
|
|
247
|
+
export const CONTEXT_SHED_ORDER = [
|
|
248
|
+
"version",
|
|
249
|
+
"model",
|
|
250
|
+
"source",
|
|
251
|
+
"trigger",
|
|
252
|
+
"environment",
|
|
253
|
+
"package",
|
|
254
|
+
"run_id",
|
|
255
|
+
"query_id",
|
|
256
|
+
"class",
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* How many properties the context can occupy, so a declaration boundary can warn
|
|
261
|
+
* about an author budget that will not survive the merge.
|
|
262
|
+
*/
|
|
263
|
+
export const RESERVED_CONTEXT_PROPERTIES = CONTEXT_SHED_ORDER.length;
|
|
264
|
+
|
|
265
|
+
/** The context as bag properties, dropping every absent field. */
|
|
266
|
+
export function queryContextProperties(context: QueryContext): QueryMetadata {
|
|
267
|
+
const out: QueryMetadata = {};
|
|
268
|
+
const put = (name: string, value: string | undefined) => {
|
|
269
|
+
if (value !== undefined && value !== "") out[name] = value;
|
|
270
|
+
};
|
|
271
|
+
put("class", context.queryClass);
|
|
272
|
+
put("environment", context.environment);
|
|
273
|
+
put("package", context.package);
|
|
274
|
+
put("version", context.version);
|
|
275
|
+
put("model", context.model);
|
|
276
|
+
put("source", context.source);
|
|
277
|
+
put("trigger", context.trigger);
|
|
278
|
+
put("run_id", context.runId);
|
|
279
|
+
put("query_id", context.correlationId);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** One layer of a resolved bag, least specific first. */
|
|
284
|
+
export interface QueryMetadataLayers {
|
|
285
|
+
/** The executing connection's default (least specific). */
|
|
286
|
+
connection?: QueryMetadata | null;
|
|
287
|
+
/** The value the build plan resolved for this materialization unit. */
|
|
288
|
+
model?: QueryMetadata | null;
|
|
289
|
+
/** The caller's per-request override. */
|
|
290
|
+
request?: QueryMetadata | null;
|
|
291
|
+
/**
|
|
292
|
+
* Properties the connection declares as the deployment's own, which no
|
|
293
|
+
* declaration can overwrite and which outlive every author property when the
|
|
294
|
+
* bag has to shrink.
|
|
295
|
+
*
|
|
296
|
+
* A host that runs one Publisher for several tenants needs this layer to win
|
|
297
|
+
* for the properties it is billed by — otherwise the tenant label is both
|
|
298
|
+
* forgeable (a `#@ persist queryMetadata.tenant=…` outranks a connection
|
|
299
|
+
* default) and the first thing shed under budget, which is a poor showing for
|
|
300
|
+
* the property finance reads.
|
|
301
|
+
*
|
|
302
|
+
* The trust split it rests on is the DEPLOYMENT's, not this module's: nothing
|
|
303
|
+
* here authenticates a connection write, so it separates operator from author
|
|
304
|
+
* only where something in front of Publisher does.
|
|
305
|
+
*/
|
|
306
|
+
enforced?: QueryMetadata | null;
|
|
307
|
+
/** The server's own context, which wins over every other layer. */
|
|
308
|
+
context?: QueryContext;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** How a property was lost, for the drop metric and the caller's log. */
|
|
312
|
+
export type QueryMetadataDropReason =
|
|
313
|
+
| "invalid_name"
|
|
314
|
+
| "invalid_value"
|
|
315
|
+
| "property_cap"
|
|
316
|
+
| "serialized_cap";
|
|
317
|
+
|
|
318
|
+
export interface ResolvedQueryMetadata {
|
|
319
|
+
/** The bag to hand to Malloy; undefined when there is nothing to attach. */
|
|
320
|
+
metadata?: QueryMetadata;
|
|
321
|
+
/** Properties that did not survive, most useful in a warning log. */
|
|
322
|
+
drops: { name: string; reason: QueryMetadataDropReason }[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Truncate a value to the contract and strip anything unrenderable. */
|
|
326
|
+
function sanitizeValue(value: string): string {
|
|
327
|
+
let out = "";
|
|
328
|
+
for (
|
|
329
|
+
let i = 0;
|
|
330
|
+
i < value.length && out.length < MAX_PROPERTY_VALUE_LENGTH;
|
|
331
|
+
i++
|
|
332
|
+
) {
|
|
333
|
+
const code = value.charCodeAt(i);
|
|
334
|
+
out += code < 0x20 || code > 0x7e || code === 0x22 ? "_" : value[i];
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Merge the layers into one bag Malloy will accept, and report what was lost.
|
|
341
|
+
*
|
|
342
|
+
* Never throws — metadata must not be the reason a query or a build fails —
|
|
343
|
+
* with one precondition: `getQueryMetadataMode()` rejects an unrecognized
|
|
344
|
+
* `PUBLISHER_QUERY_METADATA`, and it is the boot probe in `server.ts` that
|
|
345
|
+
* turns that into a startup failure rather than a per-query one. A host that
|
|
346
|
+
* embeds this module without that probe owes itself the same check.
|
|
347
|
+
*
|
|
348
|
+
* Precedence is most-specific-wins per property across the author layers
|
|
349
|
+
* (connection < model < request), then the connection's ENFORCED properties,
|
|
350
|
+
* then the server's context — so neither a declaration nor a caller can
|
|
351
|
+
* overwrite what the deployment or the server says about the query.
|
|
352
|
+
*
|
|
353
|
+
* Shedding runs in the same order, from the bottom: the 20-property cap and
|
|
354
|
+
* Snowflake's 2000-char serialized tag take author properties first (losing a
|
|
355
|
+
* caller's `team` label degrades attribution), then enforced properties, and
|
|
356
|
+
* only then context (losing `class` or `query_id` breaks it).
|
|
357
|
+
*
|
|
358
|
+
* Returns no metadata at all when `PUBLISHER_QUERY_METADATA=off`, which is the
|
|
359
|
+
* escape hatch for a deployment that does not want the publisher touching the
|
|
360
|
+
* statements it sends.
|
|
361
|
+
*/
|
|
362
|
+
export function mergeQueryMetadata(
|
|
363
|
+
layers: QueryMetadataLayers,
|
|
364
|
+
): ResolvedQueryMetadata {
|
|
365
|
+
if (getQueryMetadataMode() === "off") return { drops: [] };
|
|
366
|
+
|
|
367
|
+
const drops: { name: string; reason: QueryMetadataDropReason }[] = [];
|
|
368
|
+
const contextProperties = queryContextProperties(layers.context ?? {});
|
|
369
|
+
|
|
370
|
+
// Layers in precedence order, least specific first, then context on top. Each
|
|
371
|
+
// property remembers the layer whose value SURVIVED, not the first layer that
|
|
372
|
+
// mentioned it, so the shed order below matches the precedence that produced
|
|
373
|
+
// the bag.
|
|
374
|
+
// Null-prototype: `__proto__` satisfies the contract's name rule, so it
|
|
375
|
+
// reaches an assignment that an object literal would route to the prototype
|
|
376
|
+
// setter — which ignores a string and drops the property with no record of
|
|
377
|
+
// it. Every other invalid name is dropped loudly; this one would not be.
|
|
378
|
+
const merged: QueryMetadata = Object.create(null) as QueryMetadata;
|
|
379
|
+
const winningLayer = new Map<string, number>();
|
|
380
|
+
const orderedLayers = [
|
|
381
|
+
layers.connection,
|
|
382
|
+
layers.model,
|
|
383
|
+
layers.request,
|
|
384
|
+
layers.enforced,
|
|
385
|
+
];
|
|
386
|
+
orderedLayers.forEach((layer, index) => {
|
|
387
|
+
for (const [name, value] of Object.entries(layer ?? {})) {
|
|
388
|
+
if (
|
|
389
|
+
!PROPERTY_NAME_RE.test(name) ||
|
|
390
|
+
name.length > MAX_PROPERTY_NAME_LENGTH
|
|
391
|
+
) {
|
|
392
|
+
drops.push({ name, reason: "invalid_name" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (typeof value !== "string") {
|
|
396
|
+
drops.push({ name, reason: "invalid_value" });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
winningLayer.set(name, index);
|
|
400
|
+
merged[name] = sanitizeValue(value);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
for (const [name, value] of Object.entries(contextProperties)) {
|
|
404
|
+
merged[name] = sanitizeValue(value);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Shedding runs least-specific-first, the same order that decides which value
|
|
408
|
+
// wins: a connection-wide default is the cheapest thing to lose, the caller's
|
|
409
|
+
// own per-request property — most likely its join key into whatever it is
|
|
410
|
+
// correlating — outlives it, and an enforced property outlives them all,
|
|
411
|
+
// because the deployment is billed by it.
|
|
412
|
+
const shedOrder = [...winningLayer.entries()]
|
|
413
|
+
// hasOwnProperty, not `in`: `__proto__` is a contract-legal property name
|
|
414
|
+
// and `in` finds it on every object literal's prototype, which would take
|
|
415
|
+
// a declared one off the shed list and make it unsheddable.
|
|
416
|
+
.filter(
|
|
417
|
+
([name]) =>
|
|
418
|
+
!Object.prototype.hasOwnProperty.call(contextProperties, name),
|
|
419
|
+
)
|
|
420
|
+
.sort((a, b) => a[1] - b[1])
|
|
421
|
+
.map(([name]) => name);
|
|
422
|
+
const shed = (reason: QueryMetadataDropReason) => {
|
|
423
|
+
const name = shedOrder.shift() as string;
|
|
424
|
+
delete merged[name];
|
|
425
|
+
drops.push({ name, reason });
|
|
426
|
+
};
|
|
427
|
+
while (Object.keys(merged).length > MAX_PROPERTIES && shedOrder.length > 0) {
|
|
428
|
+
shed("property_cap");
|
|
429
|
+
}
|
|
430
|
+
while (
|
|
431
|
+
JSON.stringify(merged).length > MAX_SERIALIZED_LENGTH &&
|
|
432
|
+
shedOrder.length > 0
|
|
433
|
+
) {
|
|
434
|
+
shed("serialized_cap");
|
|
435
|
+
}
|
|
436
|
+
// Context alone over either budget is a server bug, not a caller's — but a
|
|
437
|
+
// thrown query would be worse than a partial tag, so shed context too. Least
|
|
438
|
+
// load-bearing first, ending at the two that make the bag worth reading: the
|
|
439
|
+
// workload class, and the id a response handed back as a join key.
|
|
440
|
+
const contextShedOrder = CONTEXT_SHED_ORDER.filter(
|
|
441
|
+
(name) => name in contextProperties,
|
|
442
|
+
);
|
|
443
|
+
while (
|
|
444
|
+
(Object.keys(merged).length > MAX_PROPERTIES ||
|
|
445
|
+
JSON.stringify(merged).length > MAX_SERIALIZED_LENGTH) &&
|
|
446
|
+
contextShedOrder.length > 0
|
|
447
|
+
) {
|
|
448
|
+
const name = contextShedOrder.shift() as string;
|
|
449
|
+
const overCount = Object.keys(merged).length > MAX_PROPERTIES;
|
|
450
|
+
delete merged[name];
|
|
451
|
+
drops.push({
|
|
452
|
+
name,
|
|
453
|
+
reason: overCount ? "property_cap" : "serialized_cap",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const drop of drops) recordQueryMetadataDropped(drop.reason);
|
|
458
|
+
if (Object.keys(merged).length === 0) return { drops };
|
|
459
|
+
recordQueryMetadataApplied(layers.context?.queryClass ?? "unknown");
|
|
460
|
+
return { metadata: merged, drops };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Read a caller-supplied bag, rejecting rather than clamping: a request or a
|
|
465
|
+
* connection update has a human behind it, so a bad property should come back
|
|
466
|
+
* as a message instead of silently not doing what it says.
|
|
467
|
+
*
|
|
468
|
+
* @throws {Error} listing every violation, for the caller to map to a 400.
|
|
469
|
+
*/
|
|
470
|
+
export function parseSuppliedQueryMetadata(
|
|
471
|
+
raw: unknown,
|
|
472
|
+
): QueryMetadata | undefined {
|
|
473
|
+
if (raw === undefined || raw === null) return undefined;
|
|
474
|
+
const problems = queryMetadataViolations(raw);
|
|
475
|
+
if (problems.length > 0) throw new Error(problems.join("; "));
|
|
476
|
+
const meta = raw as QueryMetadata;
|
|
477
|
+
return Object.keys(meta).length > 0 ? meta : undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Read a `queryClass`, rejecting an unrecognized value. @throws {Error} */
|
|
481
|
+
export function parseQueryClass(raw: unknown): QueryClass | undefined {
|
|
482
|
+
if (raw === undefined || raw === null) return undefined;
|
|
483
|
+
if (
|
|
484
|
+
typeof raw === "string" &&
|
|
485
|
+
(QUERY_CLASSES as readonly string[]).includes(raw)
|
|
486
|
+
) {
|
|
487
|
+
return raw as QueryClass;
|
|
488
|
+
}
|
|
489
|
+
throw new Error(
|
|
490
|
+
`queryClass must be one of ${QUERY_CLASSES.join(" | ")} (got ${JSON.stringify(raw)})`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { Environment } from "./environment";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Query metadata is observability, and it must never be identity.
|
|
10
|
+
*
|
|
11
|
+
* `sourceEntityId` is a content address: `makeBuildId(connectionDigest,
|
|
12
|
+
* getSQL())`. If a tag ever reached either input, declaring one would
|
|
13
|
+
* re-address every persist source in the package — the manifest lookup would
|
|
14
|
+
* miss, every source would rebuild into a new physical table, and the tables
|
|
15
|
+
* built before the change would be orphaned at rest. Silent, expensive, and
|
|
16
|
+
* discovered from a warehouse bill.
|
|
17
|
+
*
|
|
18
|
+
* It holds by construction today: `queryMetadata` exists only on Malloy's
|
|
19
|
+
* `RunSQLOptions` (the dispatch path), never on the compile options `getSQL`
|
|
20
|
+
* takes, and every connector's `getDigest()` enumerates connection-shape fields.
|
|
21
|
+
* Nothing pins it, which is what this file is for — the invariant is cheap to
|
|
22
|
+
* assert and its failure mode is the worst on this surface.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately end-to-end over a real `Environment`/`Package` rather than the
|
|
25
|
+
* fake source fixture: `fakeSource.makeBuildId` returns a constant, so a
|
|
26
|
+
* fixture-based version of this test would pass no matter what leaked. The
|
|
27
|
+
* three layers exercised here are the ones that ride in the model text, where a
|
|
28
|
+
* leak is actually plausible — the manifest block, the model-file `##`
|
|
29
|
+
* envelope, and the source's own `#@ persist`.
|
|
30
|
+
*/
|
|
31
|
+
describe("query metadata is excluded from persist identity", () => {
|
|
32
|
+
let rootDir: string;
|
|
33
|
+
let envPath: string;
|
|
34
|
+
|
|
35
|
+
/** The one persist source both variants declare, byte-identical but for the tags. */
|
|
36
|
+
const model = (tagged: boolean) =>
|
|
37
|
+
[
|
|
38
|
+
"##! experimental.persistence",
|
|
39
|
+
...(tagged
|
|
40
|
+
? ['## materialization.queryMetadata.surface="marts"']
|
|
41
|
+
: []),
|
|
42
|
+
"",
|
|
43
|
+
`source: raw is duckdb.sql("SELECT 1 as id, 'a' as category")`,
|
|
44
|
+
"",
|
|
45
|
+
tagged
|
|
46
|
+
? '#@ persist name="rollup" queryMetadata.tier="platinum"'
|
|
47
|
+
: '#@ persist name="rollup"',
|
|
48
|
+
"source: rollup is raw -> {",
|
|
49
|
+
" group_by: category",
|
|
50
|
+
" aggregate: n is count()",
|
|
51
|
+
"}",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n");
|
|
54
|
+
|
|
55
|
+
const manifest = (tagged: boolean) => ({
|
|
56
|
+
name: "pkg",
|
|
57
|
+
description: "fixture",
|
|
58
|
+
materialization: {
|
|
59
|
+
scope: "version",
|
|
60
|
+
...(tagged
|
|
61
|
+
? { queryMetadata: { team: "finance", tier: "bronze" } }
|
|
62
|
+
: {}),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load a fresh package from a fresh environment and return the entity id its
|
|
68
|
+
* build plan assigned to `rollup`. A new environment per variant so nothing
|
|
69
|
+
* is served from the previous compile.
|
|
70
|
+
*/
|
|
71
|
+
async function entityIdFor(tagged: boolean): Promise<string> {
|
|
72
|
+
const dir = path.join(envPath, "pkg");
|
|
73
|
+
await fs.mkdir(dir, { recursive: true });
|
|
74
|
+
await fs.writeFile(
|
|
75
|
+
path.join(dir, "publisher.json"),
|
|
76
|
+
JSON.stringify(manifest(tagged)),
|
|
77
|
+
);
|
|
78
|
+
await fs.writeFile(path.join(dir, "model.malloy"), model(tagged));
|
|
79
|
+
|
|
80
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
81
|
+
await env.addPackage("pkg");
|
|
82
|
+
const pkg = await env.getPackage("pkg", false);
|
|
83
|
+
const plan = pkg.getBuildPlan();
|
|
84
|
+
const sources = Object.values(plan?.sources ?? {});
|
|
85
|
+
// Guard the guard: an empty plan would make the comparison below vacuous,
|
|
86
|
+
// and a `#@ persist` Malloy declines to treat as a build root yields
|
|
87
|
+
// exactly that.
|
|
88
|
+
expect(sources).toHaveLength(1);
|
|
89
|
+
return sources[0].sourceEntityId as string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-qm-id-"));
|
|
94
|
+
envPath = path.join(rootDir, "env");
|
|
95
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it(
|
|
103
|
+
"declaring metadata at every model-side layer does not re-address the source",
|
|
104
|
+
async () => {
|
|
105
|
+
const bare = await entityIdFor(false);
|
|
106
|
+
await fs.rm(path.join(envPath, "pkg"), {
|
|
107
|
+
recursive: true,
|
|
108
|
+
force: true,
|
|
109
|
+
});
|
|
110
|
+
const tagged = await entityIdFor(true);
|
|
111
|
+
|
|
112
|
+
// Byte-identical, not merely "both defined": this is a content address,
|
|
113
|
+
// and a differing id means every table built under the old one is
|
|
114
|
+
// orphaned the moment someone adds a tag.
|
|
115
|
+
expect(tagged).toBe(bare);
|
|
116
|
+
},
|
|
117
|
+
{ timeout: 30000 },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
it(
|
|
121
|
+
"the resolved metadata really did reach the plan",
|
|
122
|
+
async () => {
|
|
123
|
+
// Without this the test above passes just as well when the tags are
|
|
124
|
+
// being dropped on the floor — the failure mode that would make it
|
|
125
|
+
// look like the invariant holds while nothing is being tagged at all.
|
|
126
|
+
const dir = path.join(envPath, "pkg");
|
|
127
|
+
await fs.mkdir(dir, { recursive: true });
|
|
128
|
+
await fs.writeFile(
|
|
129
|
+
path.join(dir, "publisher.json"),
|
|
130
|
+
JSON.stringify(manifest(true)),
|
|
131
|
+
);
|
|
132
|
+
await fs.writeFile(path.join(dir, "model.malloy"), model(true));
|
|
133
|
+
|
|
134
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
135
|
+
await env.addPackage("pkg");
|
|
136
|
+
const pkg = await env.getPackage("pkg", false);
|
|
137
|
+
const source = Object.values(pkg.getBuildPlan()?.sources ?? {})[0];
|
|
138
|
+
|
|
139
|
+
expect(source.queryMetadata).toMatchObject({
|
|
140
|
+
team: "finance",
|
|
141
|
+
surface: "marts",
|
|
142
|
+
// Most specific wins: the source's own `#@ persist` over the
|
|
143
|
+
// manifest's `tier`.
|
|
144
|
+
tier: "platinum",
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
{ timeout: 30000 },
|
|
148
|
+
);
|
|
149
|
+
});
|