@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,408 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CONTEXT_SHED_ORDER,
|
|
4
|
+
MAX_PROPERTIES,
|
|
5
|
+
MAX_PROPERTY_VALUE_LENGTH,
|
|
6
|
+
mergeQueryMetadata,
|
|
7
|
+
mintCorrelationId,
|
|
8
|
+
parseQueryClass,
|
|
9
|
+
parseSuppliedQueryMetadata,
|
|
10
|
+
queryContextProperties,
|
|
11
|
+
RESERVED_CONTEXT_PROPERTIES,
|
|
12
|
+
queryMetadataAdvisoryWarnings,
|
|
13
|
+
queryMetadataBudgetWarning,
|
|
14
|
+
queryMetadataViolations,
|
|
15
|
+
} from "./query_metadata";
|
|
16
|
+
|
|
17
|
+
const originalMode = process.env.PUBLISHER_QUERY_METADATA;
|
|
18
|
+
|
|
19
|
+
// The feature ships dark, so every test of the resolution logic has to turn it
|
|
20
|
+
// on. The one test that asserts what `off` does sets it back for itself.
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
process.env.PUBLISHER_QUERY_METADATA = "on";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (originalMode === undefined) {
|
|
27
|
+
delete process.env.PUBLISHER_QUERY_METADATA;
|
|
28
|
+
} else {
|
|
29
|
+
process.env.PUBLISHER_QUERY_METADATA = originalMode;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("queryMetadataViolations", () => {
|
|
34
|
+
it("accepts a conforming bag", () => {
|
|
35
|
+
expect(
|
|
36
|
+
queryMetadataViolations({ team: "finance", run_id: "abc-123" }),
|
|
37
|
+
).toEqual([]);
|
|
38
|
+
expect(queryMetadataViolations(undefined)).toEqual([]);
|
|
39
|
+
expect(queryMetadataViolations(null)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects a non-object", () => {
|
|
43
|
+
expect(queryMetadataViolations("team=finance")).toHaveLength(1);
|
|
44
|
+
expect(queryMetadataViolations([1, 2])).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects a property name outside the contract", () => {
|
|
48
|
+
// A dot is the one an author reaches for first, and Malloy throws on it at
|
|
49
|
+
// dispatch — so it has to come back as a message here.
|
|
50
|
+
expect(queryMetadataViolations({ "team.name": "finance" })).toHaveLength(
|
|
51
|
+
1,
|
|
52
|
+
);
|
|
53
|
+
expect(queryMetadataViolations({ "team-name": "finance" })).toHaveLength(
|
|
54
|
+
1,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a value that cannot be rendered", () => {
|
|
59
|
+
expect(queryMetadataViolations({ team: 'fin"ance' })).toHaveLength(1);
|
|
60
|
+
expect(queryMetadataViolations({ team: "fin\nance" })).toHaveLength(1);
|
|
61
|
+
expect(queryMetadataViolations({ team: "финансы" })).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a non-string value, naming the property", () => {
|
|
65
|
+
const problems = queryMetadataViolations({ retries: 3 });
|
|
66
|
+
expect(problems).toHaveLength(1);
|
|
67
|
+
expect(problems[0]).toContain("retries");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects an over-long value and too many properties", () => {
|
|
71
|
+
expect(
|
|
72
|
+
queryMetadataViolations({
|
|
73
|
+
team: "x".repeat(MAX_PROPERTY_VALUE_LENGTH + 1),
|
|
74
|
+
}),
|
|
75
|
+
).toHaveLength(1);
|
|
76
|
+
const tooMany: Record<string, string> = {};
|
|
77
|
+
for (let i = 0; i <= MAX_PROPERTIES; i++) tooMany[`p${i}`] = "v";
|
|
78
|
+
expect(queryMetadataViolations(tooMany)).toHaveLength(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("queryMetadataAdvisoryWarnings", () => {
|
|
83
|
+
it("flags a name BigQuery will silently drop", () => {
|
|
84
|
+
// Legal per the contract, kept by every other backend, gone on BigQuery.
|
|
85
|
+
expect(queryMetadataAdvisoryWarnings({ _team: "finance" })).toHaveLength(
|
|
86
|
+
1,
|
|
87
|
+
);
|
|
88
|
+
expect(
|
|
89
|
+
queryMetadataAdvisoryWarnings({ "2team": "finance" }),
|
|
90
|
+
).toHaveLength(1);
|
|
91
|
+
expect(queryMetadataAdvisoryWarnings({ team2: "finance" })).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("queryContextProperties", () => {
|
|
96
|
+
it("emits only the fields that are set", () => {
|
|
97
|
+
expect(
|
|
98
|
+
queryContextProperties({
|
|
99
|
+
queryClass: "materialize",
|
|
100
|
+
environment: "prod",
|
|
101
|
+
package: "sales",
|
|
102
|
+
source: "order_rollup",
|
|
103
|
+
trigger: "scheduler",
|
|
104
|
+
runId: "run-7",
|
|
105
|
+
correlationId: "q-1",
|
|
106
|
+
}),
|
|
107
|
+
).toEqual({
|
|
108
|
+
class: "materialize",
|
|
109
|
+
environment: "prod",
|
|
110
|
+
package: "sales",
|
|
111
|
+
source: "order_rollup",
|
|
112
|
+
trigger: "scheduler",
|
|
113
|
+
run_id: "run-7",
|
|
114
|
+
query_id: "q-1",
|
|
115
|
+
});
|
|
116
|
+
expect(queryContextProperties({})).toEqual({});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("queryMetadataBudgetWarning", () => {
|
|
121
|
+
it("warns when a declaration leaves no room for the server context", () => {
|
|
122
|
+
// 20 is the size of the whole bag, and the server adds its own on top, so
|
|
123
|
+
// a declaration that fills the contract quietly loses properties later.
|
|
124
|
+
expect(queryMetadataBudgetWarning(MAX_PROPERTIES)).toContain(
|
|
125
|
+
"the server adds up to",
|
|
126
|
+
);
|
|
127
|
+
expect(queryMetadataBudgetWarning(1)).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("is a count, not a bag, so a caller can sum the maps that share the budget", () => {
|
|
131
|
+
// A connection declares a default AND an enforced map; 6 + 6 is over
|
|
132
|
+
// budget while neither map is.
|
|
133
|
+
const authorBudget = MAX_PROPERTIES - RESERVED_CONTEXT_PROPERTIES;
|
|
134
|
+
expect(queryMetadataBudgetWarning(authorBudget)).toBeUndefined();
|
|
135
|
+
expect(queryMetadataBudgetWarning(authorBudget + 1)).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("CONTEXT_SHED_ORDER", () => {
|
|
140
|
+
it("covers every context property exactly once", () => {
|
|
141
|
+
// The shed order also sizes the author budget the declaration warning
|
|
142
|
+
// uses, so a context property added to one list and not the other would
|
|
143
|
+
// quietly move that threshold.
|
|
144
|
+
const every = queryContextProperties({
|
|
145
|
+
queryClass: "interactive",
|
|
146
|
+
environment: "e",
|
|
147
|
+
package: "p",
|
|
148
|
+
version: "v",
|
|
149
|
+
model: "m.malloy",
|
|
150
|
+
source: "s",
|
|
151
|
+
trigger: "publish",
|
|
152
|
+
runId: "r",
|
|
153
|
+
correlationId: "q",
|
|
154
|
+
});
|
|
155
|
+
expect([...CONTEXT_SHED_ORDER].sort()).toEqual(Object.keys(every).sort());
|
|
156
|
+
expect(RESERVED_CONTEXT_PROPERTIES).toBe(Object.keys(every).length);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("mintCorrelationId", () => {
|
|
161
|
+
it("mints a distinct id per call", () => {
|
|
162
|
+
const first = mintCorrelationId();
|
|
163
|
+
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
|
164
|
+
expect(mintCorrelationId()).not.toBe(first);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("mergeQueryMetadata", () => {
|
|
169
|
+
it("returns nothing when every layer is empty", () => {
|
|
170
|
+
expect(mergeQueryMetadata({})).toEqual({ drops: [] });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("merges most-specific-wins per property", () => {
|
|
174
|
+
const resolved = mergeQueryMetadata({
|
|
175
|
+
connection: { team: "finance", tier: "bronze", deployment: "eu" },
|
|
176
|
+
model: { tier: "silver", surface: "marts" },
|
|
177
|
+
request: { tier: "gold" },
|
|
178
|
+
});
|
|
179
|
+
expect(resolved.metadata).toEqual({
|
|
180
|
+
team: "finance",
|
|
181
|
+
tier: "gold",
|
|
182
|
+
surface: "marts",
|
|
183
|
+
deployment: "eu",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("lets context win over an author property of the same name", () => {
|
|
188
|
+
// Otherwise a caller could label its own interactive query as a build and
|
|
189
|
+
// corrupt exactly the attribution this feature exists to produce.
|
|
190
|
+
const resolved = mergeQueryMetadata({
|
|
191
|
+
request: { class: "materialize", package: "not-this-one" },
|
|
192
|
+
context: { queryClass: "interactive", package: "sales" },
|
|
193
|
+
});
|
|
194
|
+
expect(resolved.metadata).toEqual({
|
|
195
|
+
class: "interactive",
|
|
196
|
+
package: "sales",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reserves query_id: a caller cannot supply the correlation key", () => {
|
|
201
|
+
// The response hands this back as the join key, so a caller-owned value
|
|
202
|
+
// would let two calls claim the same id.
|
|
203
|
+
const resolved = mergeQueryMetadata({
|
|
204
|
+
request: { query_id: "mine" },
|
|
205
|
+
context: { correlationId: "server-minted" },
|
|
206
|
+
});
|
|
207
|
+
expect(resolved.metadata?.query_id).toBe("server-minted");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("lets an enforced property beat every declaration", () => {
|
|
211
|
+
// The connection API is administered; a package annotation and a request
|
|
212
|
+
// are not. A tenant must not be able to relabel the traffic its host is
|
|
213
|
+
// billed for.
|
|
214
|
+
const resolved = mergeQueryMetadata({
|
|
215
|
+
connection: { org: "acme" },
|
|
216
|
+
model: { org: "not_acme" },
|
|
217
|
+
request: { org: "definitely_not_acme" },
|
|
218
|
+
enforced: { org: "acme" },
|
|
219
|
+
});
|
|
220
|
+
expect(resolved.metadata?.org).toBe("acme");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("still lets the server's own context beat an enforced property", () => {
|
|
224
|
+
// Context describes what the server is actually doing; an admin cannot
|
|
225
|
+
// declare a query into a different class.
|
|
226
|
+
const resolved = mergeQueryMetadata({
|
|
227
|
+
enforced: { class: "interactive", org: "acme" },
|
|
228
|
+
context: { queryClass: "materialize" },
|
|
229
|
+
});
|
|
230
|
+
expect(resolved.metadata).toEqual({ class: "materialize", org: "acme" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("sheds enforced properties after every declared one", () => {
|
|
234
|
+
// The point of enforcing a property is that it is still there at the end:
|
|
235
|
+
// twenty declared properties must not be able to push it out of the bag.
|
|
236
|
+
const request: Record<string, string> = {};
|
|
237
|
+
for (let i = 0; i < MAX_PROPERTIES; i++) request[`p${i}`] = "v";
|
|
238
|
+
const resolved = mergeQueryMetadata({
|
|
239
|
+
request,
|
|
240
|
+
enforced: { org: "acme" },
|
|
241
|
+
context: { queryClass: "interactive" },
|
|
242
|
+
});
|
|
243
|
+
expect(resolved.metadata?.org).toBe("acme");
|
|
244
|
+
expect(resolved.metadata?.class).toBe("interactive");
|
|
245
|
+
expect(resolved.drops.every((d) => d.name.startsWith("p"))).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("keeps a property named __proto__, which the contract allows", () => {
|
|
249
|
+
// `__proto__` passes the name rule, so it reaches the merge — and on an
|
|
250
|
+
// object literal the assignment hits the prototype setter, which ignores
|
|
251
|
+
// a string and drops the property with no drop record and no metric. It
|
|
252
|
+
// is the one silent loss in a module whose whole argument is that a lost
|
|
253
|
+
// property is always accounted for.
|
|
254
|
+
// JSON.parse, not a literal: `__proto__:` in source sets the prototype,
|
|
255
|
+
// while a parsed request body carries it as an own property — which is
|
|
256
|
+
// how it arrives here.
|
|
257
|
+
const resolved = mergeQueryMetadata({
|
|
258
|
+
request: JSON.parse('{"__proto__":"surprise","team":"finance"}'),
|
|
259
|
+
});
|
|
260
|
+
expect(Object.keys(resolved.metadata ?? {}).sort()).toEqual([
|
|
261
|
+
"__proto__",
|
|
262
|
+
"team",
|
|
263
|
+
]);
|
|
264
|
+
expect(resolved.drops).toEqual([]);
|
|
265
|
+
// A string property, not a reshaped bag.
|
|
266
|
+
expect(JSON.stringify(resolved.metadata)).toContain('"__proto__"');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("drops a property whose name violates the contract", () => {
|
|
270
|
+
// Never throws: it would fail the customer's query at dispatch.
|
|
271
|
+
const resolved = mergeQueryMetadata({
|
|
272
|
+
request: { "team.name": "finance", team: "finance" },
|
|
273
|
+
});
|
|
274
|
+
expect(resolved.metadata).toEqual({ team: "finance" });
|
|
275
|
+
expect(resolved.drops).toEqual([
|
|
276
|
+
{ name: "team.name", reason: "invalid_name" },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("sanitizes an unrenderable value instead of failing", () => {
|
|
281
|
+
const resolved = mergeQueryMetadata({
|
|
282
|
+
request: { note: 'a"b\nc' },
|
|
283
|
+
});
|
|
284
|
+
expect(resolved.metadata).toEqual({ note: "a_b_c" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("truncates an over-long value", () => {
|
|
288
|
+
const resolved = mergeQueryMetadata({
|
|
289
|
+
request: { note: "x".repeat(MAX_PROPERTY_VALUE_LENGTH + 50) },
|
|
290
|
+
});
|
|
291
|
+
expect(resolved.metadata?.note).toHaveLength(MAX_PROPERTY_VALUE_LENGTH);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("sheds the least specific author property first", () => {
|
|
295
|
+
// The caller's own per-request property is the last one standing: it is
|
|
296
|
+
// most likely its join key, and precedence says the connection-wide
|
|
297
|
+
// default is the cheapest thing to lose.
|
|
298
|
+
const connection: Record<string, string> = {};
|
|
299
|
+
for (let i = 0; i < MAX_PROPERTIES - 2; i++) connection[`c${i}`] = "v";
|
|
300
|
+
const resolved = mergeQueryMetadata({
|
|
301
|
+
connection,
|
|
302
|
+
model: { modelProp: "m" },
|
|
303
|
+
request: { request_id: "7f3a" },
|
|
304
|
+
context: { queryClass: "interactive", package: "sales" },
|
|
305
|
+
});
|
|
306
|
+
expect(resolved.metadata?.request_id).toBe("7f3a");
|
|
307
|
+
expect(resolved.metadata?.modelProp).toBe("m");
|
|
308
|
+
expect(resolved.drops.every((d) => d.name.startsWith("c"))).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("sheds by the layer whose value won, not the first that mentioned it", () => {
|
|
312
|
+
// `tier` is declared on the connection but the request's value is what
|
|
313
|
+
// survives, so it must be shed as a request property, i.e. last.
|
|
314
|
+
const connection: Record<string, string> = { tier: "bronze" };
|
|
315
|
+
for (let i = 0; i < MAX_PROPERTIES; i++) connection[`c${i}`] = "v";
|
|
316
|
+
const resolved = mergeQueryMetadata({
|
|
317
|
+
connection,
|
|
318
|
+
request: { tier: "gold" },
|
|
319
|
+
context: { queryClass: "interactive" },
|
|
320
|
+
});
|
|
321
|
+
expect(resolved.metadata?.tier).toBe("gold");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("enforces the property cap by dropping author properties, keeping context", () => {
|
|
325
|
+
const request: Record<string, string> = {};
|
|
326
|
+
for (let i = 0; i < MAX_PROPERTIES; i++) request[`p${i}`] = "v";
|
|
327
|
+
const resolved = mergeQueryMetadata({
|
|
328
|
+
request,
|
|
329
|
+
context: { queryClass: "index", package: "sales" },
|
|
330
|
+
});
|
|
331
|
+
expect(Object.keys(resolved.metadata ?? {})).toHaveLength(MAX_PROPERTIES);
|
|
332
|
+
// Context survived; the overflow came out of the author's properties.
|
|
333
|
+
expect(resolved.metadata?.class).toBe("index");
|
|
334
|
+
expect(resolved.metadata?.package).toBe("sales");
|
|
335
|
+
expect(resolved.drops.map((d) => d.reason)).toContain("property_cap");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("keeps the serialized bag inside Snowflake's tag limit", () => {
|
|
339
|
+
// db-snowflake slices an over-long JSON tag, which leaves it unparseable —
|
|
340
|
+
// so whole properties come out here instead.
|
|
341
|
+
const request: Record<string, string> = {};
|
|
342
|
+
for (let i = 0; i < 12; i++) {
|
|
343
|
+
request[`p${i}`] = "x".repeat(MAX_PROPERTY_VALUE_LENGTH);
|
|
344
|
+
}
|
|
345
|
+
const resolved = mergeQueryMetadata({
|
|
346
|
+
request,
|
|
347
|
+
context: { queryClass: "interactive" },
|
|
348
|
+
});
|
|
349
|
+
expect(JSON.stringify(resolved.metadata).length).toBeLessThanOrEqual(
|
|
350
|
+
2000,
|
|
351
|
+
);
|
|
352
|
+
expect(resolved.drops.map((d) => d.reason)).toContain("serialized_cap");
|
|
353
|
+
expect(resolved.metadata?.class).toBe("interactive");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("attaches nothing by default: the feature ships dark", () => {
|
|
357
|
+
// The release default. Every statement leaves exactly as Malloy compiled
|
|
358
|
+
// it until a deployment opts in, because this is the one feature that
|
|
359
|
+
// touches all of them — and on the comment-carrying backends it changes
|
|
360
|
+
// the statement text.
|
|
361
|
+
delete process.env.PUBLISHER_QUERY_METADATA;
|
|
362
|
+
expect(
|
|
363
|
+
mergeQueryMetadata({
|
|
364
|
+
request: { team: "finance" },
|
|
365
|
+
context: { queryClass: "interactive", correlationId: "corr-1" },
|
|
366
|
+
}),
|
|
367
|
+
).toEqual({ drops: [] });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("attaches nothing when PUBLISHER_QUERY_METADATA=off", () => {
|
|
371
|
+
process.env.PUBLISHER_QUERY_METADATA = "off";
|
|
372
|
+
expect(
|
|
373
|
+
mergeQueryMetadata({
|
|
374
|
+
request: { team: "finance" },
|
|
375
|
+
context: { queryClass: "interactive", package: "sales" },
|
|
376
|
+
}),
|
|
377
|
+
).toEqual({ drops: [] });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("parseSuppliedQueryMetadata", () => {
|
|
382
|
+
it("returns the bag when it conforms, undefined when empty", () => {
|
|
383
|
+
expect(parseSuppliedQueryMetadata({ team: "finance" })).toEqual({
|
|
384
|
+
team: "finance",
|
|
385
|
+
});
|
|
386
|
+
expect(parseSuppliedQueryMetadata({})).toBeUndefined();
|
|
387
|
+
expect(parseSuppliedQueryMetadata(undefined)).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("throws listing every violation, for the caller's 400", () => {
|
|
391
|
+
expect(() =>
|
|
392
|
+
parseSuppliedQueryMetadata({ "team.name": "finance", note: 'a"b' }),
|
|
393
|
+
).toThrow(/team.name.*note|note.*team.name/s);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("parseQueryClass", () => {
|
|
398
|
+
it("accepts the four classes and passes through absence", () => {
|
|
399
|
+
expect(parseQueryClass("materialize")).toBe("materialize");
|
|
400
|
+
expect(parseQueryClass(undefined)).toBeUndefined();
|
|
401
|
+
expect(parseQueryClass(null)).toBeUndefined();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("throws on an unrecognized class rather than ignoring it", () => {
|
|
405
|
+
expect(() => parseQueryClass("backfill")).toThrow(/queryClass/);
|
|
406
|
+
expect(() => parseQueryClass(7)).toThrow(/queryClass/);
|
|
407
|
+
});
|
|
408
|
+
});
|