@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.
Files changed (62) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +208 -0
  3. package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
  4. package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
  5. package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
  6. package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
  8. package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
  9. package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
  10. package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
  13. package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
  14. package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
  15. package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
  16. package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
  17. package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
  18. package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/package_load_worker.mjs +53 -3
  21. package/dist/server.mjs +835 -97
  22. package/package.json +12 -12
  23. package/src/config.ts +35 -1
  24. package/src/controller/connection.controller.spec.ts +46 -0
  25. package/src/controller/connection.controller.ts +105 -2
  26. package/src/controller/materialization.controller.spec.ts +25 -0
  27. package/src/controller/materialization.controller.ts +60 -0
  28. package/src/controller/model.controller.ts +24 -0
  29. package/src/controller/query.controller.ts +83 -15
  30. package/src/mcp/handler_utils.ts +10 -2
  31. package/src/mcp/query_envelope.ts +10 -0
  32. package/src/mcp/skills/skills_bundle.json +1 -1
  33. package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
  34. package/src/mcp/tools/execute_query_tool.ts +62 -25
  35. package/src/mcp_config.spec.ts +919 -0
  36. package/src/mcp_config.ts +425 -0
  37. package/src/oom_guards.integration.spec.ts +11 -3
  38. package/src/package_load/package_load_pool.ts +2 -0
  39. package/src/package_load/package_load_worker.ts +17 -5
  40. package/src/package_load/protocol.ts +6 -0
  41. package/src/query_metadata_metrics.ts +49 -0
  42. package/src/server.ts +99 -3
  43. package/src/service/build_plan.spec.ts +125 -0
  44. package/src/service/build_plan.ts +108 -7
  45. package/src/service/connection_config.ts +49 -0
  46. package/src/service/environment.ts +57 -3
  47. package/src/service/materialization_config_validation.spec.ts +99 -0
  48. package/src/service/materialization_config_validation.ts +120 -0
  49. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  50. package/src/service/materialization_service.spec.ts +119 -0
  51. package/src/service/materialization_service.ts +186 -3
  52. package/src/service/materialization_test_fixtures.ts +86 -21
  53. package/src/service/model.spec.ts +45 -1
  54. package/src/service/model.ts +145 -19
  55. package/src/service/package.ts +24 -1
  56. package/src/service/package_manifest.spec.ts +137 -4
  57. package/src/service/package_manifest.ts +140 -5
  58. package/src/service/persist_annotation_validation.spec.ts +12 -0
  59. package/src/service/persist_annotation_validation.ts +9 -4
  60. package/src/service/query_metadata.spec.ts +408 -0
  61. package/src/service/query_metadata.ts +492 -0
  62. 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");