@malloy-publisher/server 0.0.233 → 0.0.235
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 +208 -0
- package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
- package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
- package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
- package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
- package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
- package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
- package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
- package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
- package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
- package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
- package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
- package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
- package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +53 -3
- package/dist/server.mjs +835 -97
- package/package.json +12 -12
- 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 -15
- package/src/mcp/handler_utils.ts +10 -2
- package/src/mcp/query_envelope.ts +10 -0
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
- package/src/mcp/tools/execute_query_tool.ts +62 -25
- 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/connection_config.ts +49 -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 +145 -19
- 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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { components } from "../api";
|
|
3
|
+
import { materializationConfigWarnings } from "./materialization_config_validation";
|
|
4
|
+
|
|
5
|
+
const source = (
|
|
6
|
+
name: string,
|
|
7
|
+
queryMetadata: Record<string, string> | null,
|
|
8
|
+
): components["schemas"]["PersistSourcePlan"] => ({
|
|
9
|
+
name,
|
|
10
|
+
sourceID: `${name}@m`,
|
|
11
|
+
connectionName: "duckdb",
|
|
12
|
+
sourceEntityId: "bid",
|
|
13
|
+
sql: "SELECT 1",
|
|
14
|
+
columns: [],
|
|
15
|
+
queryMetadata,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("materializationConfigWarnings", () => {
|
|
19
|
+
it("says nothing about a clean config", () => {
|
|
20
|
+
expect(
|
|
21
|
+
materializationConfigWarnings({
|
|
22
|
+
packageMaterialization: {
|
|
23
|
+
schedule: null,
|
|
24
|
+
freshness: null,
|
|
25
|
+
queryMetadata: { team: "finance" },
|
|
26
|
+
},
|
|
27
|
+
sources: [source("orders", { team: "finance" })],
|
|
28
|
+
}),
|
|
29
|
+
).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("passes manifest deprecations through", () => {
|
|
33
|
+
expect(
|
|
34
|
+
materializationConfigWarnings({
|
|
35
|
+
manifestWarnings: ['"scope" at the manifest root is deprecated'],
|
|
36
|
+
}),
|
|
37
|
+
).toEqual([{ message: '"scope" at the manifest root is deprecated' }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reports a package-level property that violates the contract", () => {
|
|
41
|
+
const warnings = materializationConfigWarnings({
|
|
42
|
+
packageMaterialization: {
|
|
43
|
+
schedule: null,
|
|
44
|
+
freshness: null,
|
|
45
|
+
queryMetadata: { "team.name": "finance" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
expect(warnings).toHaveLength(1);
|
|
49
|
+
expect(warnings[0].message).toContain("materialization.queryMetadata");
|
|
50
|
+
expect(warnings[0].message).toContain("team.name");
|
|
51
|
+
expect(warnings[0].target).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reports the offending property at the source that resolves it", () => {
|
|
55
|
+
const warnings = materializationConfigWarnings({
|
|
56
|
+
sources: [source("orders", { "team.name": "finance" })],
|
|
57
|
+
});
|
|
58
|
+
expect(warnings).toHaveLength(1);
|
|
59
|
+
expect(warnings[0].target).toBe("orders");
|
|
60
|
+
expect(warnings[0].message).toContain("#@ persist queryMetadata");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reports a BigQuery-dropped name as a warning, not silence", () => {
|
|
64
|
+
const warnings = materializationConfigWarnings({
|
|
65
|
+
sources: [source("orders", { _team: "finance" })],
|
|
66
|
+
});
|
|
67
|
+
expect(warnings).toHaveLength(1);
|
|
68
|
+
expect(warnings[0].message).toMatch(/BigQuery/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reports the problem at every level that resolves it", () => {
|
|
72
|
+
// The package declares it and both sources inherit it. Reporting each
|
|
73
|
+
// level is what lets an author find the one they can edit; identical
|
|
74
|
+
// messages are collapsed, different levels are not.
|
|
75
|
+
const warnings = materializationConfigWarnings({
|
|
76
|
+
packageMaterialization: {
|
|
77
|
+
schedule: null,
|
|
78
|
+
freshness: null,
|
|
79
|
+
queryMetadata: { "team.name": "finance" },
|
|
80
|
+
},
|
|
81
|
+
sources: [
|
|
82
|
+
source("orders", { "team.name": "finance" }),
|
|
83
|
+
source("returns", { "team.name": "finance" }),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
expect(warnings).toHaveLength(3);
|
|
87
|
+
expect(warnings.map((w) => w.target)).toEqual([
|
|
88
|
+
undefined,
|
|
89
|
+
"orders",
|
|
90
|
+
"returns",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores sources with no metadata", () => {
|
|
95
|
+
expect(
|
|
96
|
+
materializationConfigWarnings({ sources: [source("orders", null)] }),
|
|
97
|
+
).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish-time validation of a package's materialization config.
|
|
3
|
+
*
|
|
4
|
+
* One module for the rules so they cannot drift between the levels a knob can be
|
|
5
|
+
* declared at. Two principles, both learned from shipped bugs:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Nothing is silently dropped.** A declaration that will not do what it
|
|
8
|
+
* says has to surface somewhere — a publish error, a publish warning, a 400,
|
|
9
|
+
* or a metered runtime drop. A `#@ persist` name that was silently ignored
|
|
10
|
+
* (source published, never materialized, no error anywhere) is why
|
|
11
|
+
* `persist_annotation_validation.ts` exists; the same rule applies here.
|
|
12
|
+
* 2. **Strict at declaration, lenient at execution.** Publish and the API have a
|
|
13
|
+
* human behind them and report problems; the runtime resolve path clamps and
|
|
14
|
+
* meters instead, because metadata must never break a query or a build.
|
|
15
|
+
*
|
|
16
|
+
* Today this owns the `queryMetadata` rules and the manifest-shape deprecations,
|
|
17
|
+
* which are ADVISORY — they surface on the package's operator warnings array.
|
|
18
|
+
* They deliberately do NOT go through `Package.persistencePolicyWarnings`, which
|
|
19
|
+
* is a publish REJECTION gate (and disarms the scheduler): a deprecated manifest
|
|
20
|
+
* shape or a mistyped tag must not stop a package from publishing. The
|
|
21
|
+
* scope/schedule/freshness coherence rules that do reject still live there;
|
|
22
|
+
* moving them here is a mechanical follow-up with no behavior change.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { components } from "../api";
|
|
26
|
+
import {
|
|
27
|
+
queryMetadataAdvisoryWarnings,
|
|
28
|
+
queryMetadataBudgetWarning,
|
|
29
|
+
queryMetadataViolations,
|
|
30
|
+
type QueryMetadata,
|
|
31
|
+
} from "./query_metadata";
|
|
32
|
+
|
|
33
|
+
type WirePackageMaterialization =
|
|
34
|
+
components["schemas"]["PackageMaterializationConfig"];
|
|
35
|
+
type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
|
|
36
|
+
|
|
37
|
+
export interface MaterializationConfigInput {
|
|
38
|
+
/** The package manifest's `materialization` block, as parsed. */
|
|
39
|
+
packageMaterialization?: WirePackageMaterialization | null;
|
|
40
|
+
/** The compiled plan's sources, carrying each one's RESOLVED metadata. */
|
|
41
|
+
sources?: WirePersistSourcePlan[];
|
|
42
|
+
/**
|
|
43
|
+
* Deprecations the manifest parse tolerated (e.g. a root-level `scope`), which
|
|
44
|
+
* a load keeps working but a publish should report.
|
|
45
|
+
*/
|
|
46
|
+
manifestWarnings?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One finding, in the shape the wire package's operator warnings array uses. */
|
|
50
|
+
export interface MaterializationConfigWarning {
|
|
51
|
+
/** The persist source the finding belongs to, absent for a package-level one. */
|
|
52
|
+
target?: string;
|
|
53
|
+
message: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function metadataWarnings(
|
|
57
|
+
level: string,
|
|
58
|
+
metadata: QueryMetadata | null | undefined,
|
|
59
|
+
target?: string,
|
|
60
|
+
): MaterializationConfigWarning[] {
|
|
61
|
+
if (!metadata) return [];
|
|
62
|
+
const budget = queryMetadataBudgetWarning(Object.keys(metadata).length);
|
|
63
|
+
return [
|
|
64
|
+
...queryMetadataViolations(metadata),
|
|
65
|
+
...queryMetadataAdvisoryWarnings(metadata),
|
|
66
|
+
...(budget ? [budget] : []),
|
|
67
|
+
].map((message) => ({
|
|
68
|
+
// The level is in the message because a reader needs to know WHICH
|
|
69
|
+
// declaration to edit, and an inherited property has more than one.
|
|
70
|
+
message: `${level}: ${message}`,
|
|
71
|
+
...(target ? { target } : {}),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Everything wrong or inadvisable about a package's materialization config, as
|
|
77
|
+
* actionable messages. Warnings rather than errors: a package published before a
|
|
78
|
+
* rule existed must keep loading, and a bad metadata property degrades
|
|
79
|
+
* attribution rather than corrupting anything.
|
|
80
|
+
*
|
|
81
|
+
* A source's metadata is checked in its RESOLVED form (the effective bag from
|
|
82
|
+
* package → model-file → `#@ persist`), because that is what will actually be
|
|
83
|
+
* attached — a property inherited from the manifest is just as broken at the
|
|
84
|
+
* source as it is at the package, and reporting it at both is how an author finds
|
|
85
|
+
* the one they can edit.
|
|
86
|
+
*/
|
|
87
|
+
export function materializationConfigWarnings(
|
|
88
|
+
input: MaterializationConfigInput,
|
|
89
|
+
): MaterializationConfigWarning[] {
|
|
90
|
+
const warnings: MaterializationConfigWarning[] = (
|
|
91
|
+
input.manifestWarnings ?? []
|
|
92
|
+
).map((message) => ({ message }));
|
|
93
|
+
|
|
94
|
+
warnings.push(
|
|
95
|
+
...metadataWarnings(
|
|
96
|
+
"materialization.queryMetadata",
|
|
97
|
+
input.packageMaterialization?.queryMetadata,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const source of input.sources ?? []) {
|
|
102
|
+
warnings.push(
|
|
103
|
+
...metadataWarnings(
|
|
104
|
+
`#@ persist queryMetadata`,
|
|
105
|
+
source.queryMetadata,
|
|
106
|
+
source.name,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Identical findings collapse (two sources inheriting one bad package
|
|
112
|
+
// property produce one message each, not one per level per source).
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
return warnings.filter((warning) => {
|
|
115
|
+
const key = `${warning.target ?? ""}\u0000${warning.message}`;
|
|
116
|
+
if (seen.has(key)) return false;
|
|
117
|
+
seen.add(key);
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -54,6 +54,7 @@ describe("materialization schedule surfacing", () => {
|
|
|
54
54
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
55
55
|
schedule: "0 6 * * *",
|
|
56
56
|
freshness: null,
|
|
57
|
+
queryMetadata: null,
|
|
57
58
|
});
|
|
58
59
|
},
|
|
59
60
|
{ timeout: 20000 },
|
|
@@ -77,6 +78,7 @@ describe("materialization schedule surfacing", () => {
|
|
|
77
78
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
78
79
|
schedule: null,
|
|
79
80
|
freshness: { window: "24h", fallback: "stale_ok" },
|
|
81
|
+
queryMetadata: null,
|
|
80
82
|
});
|
|
81
83
|
},
|
|
82
84
|
{ timeout: 20000 },
|
|
@@ -119,14 +121,136 @@ describe("materialization schedule surfacing", () => {
|
|
|
119
121
|
expect(updated.materialization).toEqual({
|
|
120
122
|
schedule: "0 6 * * *",
|
|
121
123
|
freshness: null,
|
|
124
|
+
queryMetadata: null,
|
|
122
125
|
});
|
|
123
126
|
|
|
124
127
|
const pkg = await env.getPackage("pkg", false);
|
|
125
128
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
126
129
|
schedule: "0 6 * * *",
|
|
127
130
|
freshness: null,
|
|
131
|
+
queryMetadata: null,
|
|
128
132
|
});
|
|
129
133
|
},
|
|
130
134
|
{ timeout: 20000 },
|
|
131
135
|
);
|
|
136
|
+
|
|
137
|
+
async function readManifest(): Promise<Record<string, unknown>> {
|
|
138
|
+
return JSON.parse(
|
|
139
|
+
await fs.readFile(path.join(envPath, "pkg", "publisher.json"), "utf8"),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it(
|
|
144
|
+
"writes scope to both homes so the manifest it authors still loads",
|
|
145
|
+
async () => {
|
|
146
|
+
// A scope PATCH used to write only the manifest root. With scope also
|
|
147
|
+
// living in the materialization block, that authored a manifest whose
|
|
148
|
+
// two homes disagreed — which the loader refuses, so the package
|
|
149
|
+
// survived the PATCH and then failed on the next restart.
|
|
150
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
151
|
+
await writePackageDir({
|
|
152
|
+
materialization: {
|
|
153
|
+
scope: "package",
|
|
154
|
+
queryMetadata: { team: "fin" },
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
await env.addPackage("pkg");
|
|
158
|
+
|
|
159
|
+
await env.updatePackage("pkg", { name: "pkg", scope: "version" });
|
|
160
|
+
|
|
161
|
+
const manifest = await readManifest();
|
|
162
|
+
expect(manifest.scope).toBe("version");
|
|
163
|
+
expect(manifest.materialization).toMatchObject({
|
|
164
|
+
scope: "version",
|
|
165
|
+
queryMetadata: { team: "fin" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The real assertion: it loads again.
|
|
169
|
+
const reloaded = await Environment.create("testEnv", envPath, []);
|
|
170
|
+
await reloaded.addPackage("pkg");
|
|
171
|
+
const pkg = await reloaded.getPackage("pkg", false);
|
|
172
|
+
expect(pkg.getPackageMetadata().scope).toBe("version");
|
|
173
|
+
},
|
|
174
|
+
{ timeout: 20000 },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it(
|
|
178
|
+
"keeps the envelope scope a materialization PATCH cannot express",
|
|
179
|
+
async () => {
|
|
180
|
+
// The wire materialization block has no `scope`, so a PATCH that sends
|
|
181
|
+
// one must not be read as "the author dropped it".
|
|
182
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
183
|
+
await writePackageDir({ materialization: { scope: "version" } });
|
|
184
|
+
await env.addPackage("pkg");
|
|
185
|
+
|
|
186
|
+
await env.updatePackage("pkg", {
|
|
187
|
+
name: "pkg",
|
|
188
|
+
materialization: { schedule: "0 6 * * *" },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const manifest = await readManifest();
|
|
192
|
+
expect(manifest.materialization).toMatchObject({
|
|
193
|
+
scope: "version",
|
|
194
|
+
schedule: "0 6 * * *",
|
|
195
|
+
});
|
|
196
|
+
expect(manifest.scope).toBe("version");
|
|
197
|
+
},
|
|
198
|
+
{ timeout: 20000 },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
it(
|
|
202
|
+
"keeps queryMetadata a schedule PATCH said nothing about",
|
|
203
|
+
async () => {
|
|
204
|
+
// The block is replaced wholesale, which is right for the policy the
|
|
205
|
+
// caller is setting — but queryMetadata is orthogonal to it, so a
|
|
206
|
+
// client that sets a schedule without re-sending the tags would
|
|
207
|
+
// silently untag every statement the package's builds issue. The UI
|
|
208
|
+
// and the control plane are separate clients; preserving it here fixes
|
|
209
|
+
// all of them at once.
|
|
210
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
211
|
+
await writePackageDir({
|
|
212
|
+
materialization: {
|
|
213
|
+
scope: "version",
|
|
214
|
+
queryMetadata: { team: "finance" },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
await env.addPackage("pkg");
|
|
218
|
+
|
|
219
|
+
await env.updatePackage("pkg", {
|
|
220
|
+
name: "pkg",
|
|
221
|
+
materialization: { schedule: "0 6 * * *" },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect((await readManifest()).materialization).toMatchObject({
|
|
225
|
+
schedule: "0 6 * * *",
|
|
226
|
+
queryMetadata: { team: "finance" },
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
{ timeout: 20000 },
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
it(
|
|
233
|
+
"still lets an explicit null clear queryMetadata",
|
|
234
|
+
async () => {
|
|
235
|
+
// Preserved-on-omission must not make it unclearable.
|
|
236
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
237
|
+
await writePackageDir({
|
|
238
|
+
materialization: {
|
|
239
|
+
scope: "version",
|
|
240
|
+
queryMetadata: { team: "finance" },
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
await env.addPackage("pkg");
|
|
244
|
+
|
|
245
|
+
await env.updatePackage("pkg", {
|
|
246
|
+
name: "pkg",
|
|
247
|
+
materialization: { schedule: "0 6 * * *", queryMetadata: null },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(
|
|
251
|
+
(await readManifest()).materialization as Record<string, unknown>,
|
|
252
|
+
).toMatchObject({ schedule: "0 6 * * *", queryMetadata: null });
|
|
253
|
+
},
|
|
254
|
+
{ timeout: 20000 },
|
|
255
|
+
);
|
|
132
256
|
});
|
|
@@ -39,6 +39,23 @@ import {
|
|
|
39
39
|
|
|
40
40
|
type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Turn per-query metadata on for one test and put the environment back after.
|
|
44
|
+
* The feature ships dark, so a test that asserts what a statement carries has
|
|
45
|
+
* to enable it — and saying so per test keeps the default honest here: a test
|
|
46
|
+
* that forgets is testing the shipped default, which is "nothing attached".
|
|
47
|
+
*/
|
|
48
|
+
function withQueryMetadataOn(): void {
|
|
49
|
+
const original = process.env.PUBLISHER_QUERY_METADATA;
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
process.env.PUBLISHER_QUERY_METADATA = "on";
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
if (original === undefined) delete process.env.PUBLISHER_QUERY_METADATA;
|
|
55
|
+
else process.env.PUBLISHER_QUERY_METADATA = original;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
describe("redactConnectionSecrets", () => {
|
|
43
60
|
it("strips credential values but keeps the message legible", () => {
|
|
44
61
|
const source = {
|
|
@@ -877,6 +894,7 @@ describe("MaterializationService", () => {
|
|
|
877
894
|
describe("deleteMaterialization telemetry", () => {
|
|
878
895
|
let ctx: ReturnType<typeof createMocks>;
|
|
879
896
|
let harness: MetricsHarness;
|
|
897
|
+
withQueryMetadataOn();
|
|
880
898
|
|
|
881
899
|
beforeEach(async () => {
|
|
882
900
|
ctx = createMocks();
|
|
@@ -936,6 +954,27 @@ describe("deleteMaterialization telemetry", () => {
|
|
|
936
954
|
).toBe(0);
|
|
937
955
|
});
|
|
938
956
|
|
|
957
|
+
it("tags the retiring drops as ops work", async () => {
|
|
958
|
+
// A drop is warehouse work someone accounts for later, and it is the one
|
|
959
|
+
// statement an operator goes looking for after a table disappears.
|
|
960
|
+
const runSQL = sinon.stub().resolves();
|
|
961
|
+
dropTablesFixture(runSQL);
|
|
962
|
+
|
|
963
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
964
|
+
dropTables: true,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
expect(runSQL.callCount).toBeGreaterThan(0);
|
|
968
|
+
for (const call of runSQL.getCalls()) {
|
|
969
|
+
expect(call.args[1]?.queryMetadata).toMatchObject({
|
|
970
|
+
class: "ops",
|
|
971
|
+
environment: "my-env",
|
|
972
|
+
package: "pkg",
|
|
973
|
+
run_id: "mat-1",
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
939
978
|
it("meters a failed drop as outcome=failure and still deletes the record", async () => {
|
|
940
979
|
dropTablesFixture(sinon.stub().rejects(new Error("drop boom")));
|
|
941
980
|
|
|
@@ -1472,6 +1511,7 @@ describe("executeInstructedBuild", () => {
|
|
|
1472
1511
|
|
|
1473
1512
|
describe("buildOneSource", () => {
|
|
1474
1513
|
let ctx: ReturnType<typeof createMocks>;
|
|
1514
|
+
withQueryMetadataOn();
|
|
1475
1515
|
beforeEach(() => {
|
|
1476
1516
|
ctx = createMocks();
|
|
1477
1517
|
});
|
|
@@ -1481,12 +1521,19 @@ describe("buildOneSource", () => {
|
|
|
1481
1521
|
physicalTableName: string,
|
|
1482
1522
|
dialectName?: string,
|
|
1483
1523
|
manifest: Manifest = new Manifest(),
|
|
1524
|
+
metadata?: {
|
|
1525
|
+
source?: Record<string, string>;
|
|
1526
|
+
package?: Record<string, string>;
|
|
1527
|
+
connection?: Record<string, string>;
|
|
1528
|
+
context?: Record<string, string | undefined>;
|
|
1529
|
+
},
|
|
1484
1530
|
): Promise<{ sourceEntityId: string; physicalTableName: string }> {
|
|
1485
1531
|
const source = fakeSource({
|
|
1486
1532
|
name: "orders",
|
|
1487
1533
|
sourceEntityId: "abcdef1234567890",
|
|
1488
1534
|
sql: "SELECT * FROM t",
|
|
1489
1535
|
dialectName,
|
|
1536
|
+
queryMetadata: metadata?.source,
|
|
1490
1537
|
});
|
|
1491
1538
|
const instruction: BuildInstruction = {
|
|
1492
1539
|
sourceEntityId: "abcdef1234567890",
|
|
@@ -1504,6 +1551,7 @@ describe("buildOneSource", () => {
|
|
|
1504
1551
|
m: Manifest,
|
|
1505
1552
|
env: unknown,
|
|
1506
1553
|
built: Record<string, unknown>,
|
|
1554
|
+
buildMetadata?: unknown,
|
|
1507
1555
|
) => Promise<{ sourceEntityId: string; physicalTableName: string }>;
|
|
1508
1556
|
}
|
|
1509
1557
|
).buildOneSource(
|
|
@@ -1514,14 +1562,85 @@ describe("buildOneSource", () => {
|
|
|
1514
1562
|
manifest,
|
|
1515
1563
|
{
|
|
1516
1564
|
getApiConnection: () => {
|
|
1565
|
+
if (metadata?.connection) {
|
|
1566
|
+
return { queryMetadata: metadata.connection };
|
|
1567
|
+
}
|
|
1517
1568
|
throw new Error("no connection config in this colocated test");
|
|
1518
1569
|
},
|
|
1519
1570
|
getEnvironmentPath: () => "/tmp/env",
|
|
1520
1571
|
},
|
|
1521
1572
|
{}, // builtEntries — colocated build with no prior storage upstreams
|
|
1573
|
+
metadata
|
|
1574
|
+
? {
|
|
1575
|
+
packageMaterialization: metadata.package
|
|
1576
|
+
? {
|
|
1577
|
+
schedule: null,
|
|
1578
|
+
freshness: null,
|
|
1579
|
+
queryMetadata: metadata.package,
|
|
1580
|
+
}
|
|
1581
|
+
: null,
|
|
1582
|
+
context: metadata.context ?? { queryClass: "materialize" },
|
|
1583
|
+
}
|
|
1584
|
+
: undefined,
|
|
1522
1585
|
);
|
|
1523
1586
|
}
|
|
1524
1587
|
|
|
1588
|
+
it("tags every statement of the build with the resolved metadata", async () => {
|
|
1589
|
+
// One unit of work: the warehouse's query history has to show the staging
|
|
1590
|
+
// drop, the CTAS, the swap and the rename as the same build.
|
|
1591
|
+
const runSQL = sinon.stub().resolves();
|
|
1592
|
+
await callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
|
|
1593
|
+
connection: { deployment: "eu" },
|
|
1594
|
+
package: { team: "finance", tier: "bronze" },
|
|
1595
|
+
source: { tier: "gold" },
|
|
1596
|
+
context: { queryClass: "materialize", runId: "run-7" },
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
const bags = runSQL.getCalls().map((c) => c.args[1]?.queryMetadata);
|
|
1600
|
+
expect(bags).toHaveLength(4);
|
|
1601
|
+
for (const bag of bags) {
|
|
1602
|
+
expect(bag).toEqual({
|
|
1603
|
+
deployment: "eu",
|
|
1604
|
+
team: "finance",
|
|
1605
|
+
// The source's own declaration wins over the package's.
|
|
1606
|
+
tier: "gold",
|
|
1607
|
+
class: "materialize",
|
|
1608
|
+
run_id: "run-7",
|
|
1609
|
+
source: "orders",
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it("issues untagged statements when nothing declares metadata", async () => {
|
|
1615
|
+
// No metadata context (an internal caller) means the statements are exactly
|
|
1616
|
+
// what they were before this existed.
|
|
1617
|
+
const runSQL = sinon.stub().resolves();
|
|
1618
|
+
await callBuildOneSource({ runSQL }, "orders_v1");
|
|
1619
|
+
for (const call of runSQL.getCalls()) {
|
|
1620
|
+
expect(call.args[1]?.queryMetadata).toBeUndefined();
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("tags the cleanup drop after a failed build", async () => {
|
|
1625
|
+
// The cleanup statement belongs to the same build, and it is the one an
|
|
1626
|
+
// operator goes looking for after a failure.
|
|
1627
|
+
const runSQL = sinon.stub();
|
|
1628
|
+
runSQL.onCall(0).resolves();
|
|
1629
|
+
runSQL.onCall(1).rejects(new Error("create boom"));
|
|
1630
|
+
runSQL.onCall(2).resolves();
|
|
1631
|
+
await expect(
|
|
1632
|
+
callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
|
|
1633
|
+
source: { team: "finance" },
|
|
1634
|
+
context: { queryClass: "materialize" },
|
|
1635
|
+
}),
|
|
1636
|
+
).rejects.toThrow("create boom");
|
|
1637
|
+
expect(runSQL.lastCall.args[1]?.queryMetadata).toEqual({
|
|
1638
|
+
team: "finance",
|
|
1639
|
+
class: "materialize",
|
|
1640
|
+
source: "orders",
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1525
1644
|
it("stages, swaps, and renames in a crash-safe order", async () => {
|
|
1526
1645
|
const runSQL = sinon.stub().resolves();
|
|
1527
1646
|
const entry = await callBuildOneSource({ runSQL }, "orders_v1");
|