@malloy-publisher/server 0.0.205 → 0.0.206

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 (69) hide show
  1. package/dist/app/api-doc.yaml +363 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-ivu4vdpj.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-B2DnHEDU.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-BZEuwF9P.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-Dpu3bfPg.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-B8PwcRHt.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-BhbywAeC.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-C-JXsJG0.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-BHEm8Egc.js} +1 -1
  12. package/dist/app/assets/{index-DHHAcY5o.js → index-BsvDrV14.js} +1 -1
  13. package/dist/app/assets/{index-RX3QOTde.js → index-ChR1fKR2.js} +127 -127
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-BVLPYNuj.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1151 -1264
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +66 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +212 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +324 -512
  44. package/src/service/materialization_service.ts +816 -656
  45. package/src/service/model.ts +400 -13
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +254 -14
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -12,7 +12,16 @@ const PROJECT_NAME = "test-project";
12
12
  const PACKAGE_NAME = "persist-test";
13
13
  const API = `/api/v0/environments/${PROJECT_NAME}/packages/${PACKAGE_NAME}`;
14
14
 
15
- describe("Materialization & Manifest REST API (E2E)", () => {
15
+ /** Statuses from which no background round is in flight. */
16
+ const SETTLED_STATUSES = [
17
+ "BUILD_PLAN_READY",
18
+ "MANIFEST_FILE_READY",
19
+ "FAILED",
20
+ "CANCELLED",
21
+ ];
22
+ const TERMINAL_STATUSES = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"];
23
+
24
+ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
16
25
  let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
17
26
  let baseUrl: string;
18
27
 
@@ -82,45 +91,60 @@ describe("Materialization & Manifest REST API (E2E)", () => {
82
91
  return `${baseUrl}${API}${p}`;
83
92
  }
84
93
 
94
+ // Most tests here exercise the control-plane two-round flow, so default to
95
+ // pauseBetweenPhases=true (pause at BUILD_PLAN_READY). The auto-run group
96
+ // overrides this with pauseBetweenPhases=false.
85
97
  async function createMaterialization(
86
98
  body: Record<string, unknown> = {},
87
99
  ): Promise<Response> {
88
100
  return fetch(url("/materializations"), {
89
101
  method: "POST",
90
102
  headers: { "Content-Type": "application/json" },
91
- body: JSON.stringify(body),
103
+ body: JSON.stringify({ pauseBetweenPhases: true, ...body }),
92
104
  });
93
105
  }
94
106
 
95
- async function pollUntilTerminal(
107
+ async function getMaterialization(
96
108
  id: string,
109
+ ): Promise<Record<string, unknown>> {
110
+ const res = await fetch(url(`/materializations/${id}`));
111
+ expect(res.status).toBe(200);
112
+ return (await res.json()) as Record<string, unknown>;
113
+ }
114
+
115
+ /** Poll until `status` satisfies `done`, returning the record. */
116
+ async function pollUntil(
117
+ id: string,
118
+ done: (status: string) => boolean,
97
119
  timeoutMs = 30_000,
98
120
  ): Promise<Record<string, unknown>> {
99
121
  const deadline = Date.now() + timeoutMs;
100
122
  while (Date.now() < deadline) {
101
- const res = await fetch(url(`/materializations/${id}`));
102
- expect(res.status).toBe(200);
103
- const data = (await res.json()) as Record<string, unknown>;
104
- const status = data.status as string;
105
- if (["SUCCESS", "FAILED", "CANCELLED"].includes(status)) {
123
+ const data = await getMaterialization(id);
124
+ if (done(data.status as string)) {
106
125
  return data;
107
126
  }
108
- await new Promise((r) => setTimeout(r, 500));
127
+ await new Promise((r) => setTimeout(r, 250));
109
128
  }
110
- throw new Error(`Materialization ${id} did not reach terminal state`);
129
+ throw new Error(`Materialization ${id} did not reach the expected state`);
111
130
  }
112
131
 
132
+ const pollUntilSettled = (id: string) =>
133
+ pollUntil(id, (s) => SETTLED_STATUSES.includes(s));
134
+ const pollUntilTerminal = (id: string) =>
135
+ pollUntil(id, (s) => TERMINAL_STATUSES.includes(s));
136
+
113
137
  /**
114
- * Clean up a materialization so it doesn't interfere with other tests.
115
- * Stops it if active, then deletes if terminal.
138
+ * Drive a materialization to a terminal state and delete its record so it
139
+ * doesn't hold the per-package active slot for later tests.
116
140
  */
117
141
  async function cleanup(id: string): Promise<void> {
118
142
  const res = await fetch(url(`/materializations/${id}`));
119
143
  if (res.status !== 200) return;
120
- const data = (await res.json()) as Record<string, unknown>;
121
- const status = data.status as string;
122
144
 
123
- if (status === "PENDING" || status === "RUNNING") {
145
+ // Let any in-flight round settle before acting on it.
146
+ const settled = await pollUntilSettled(id);
147
+ if (!TERMINAL_STATUSES.includes(settled.status as string)) {
124
148
  await fetch(url(`/materializations/${id}?action=stop`), {
125
149
  method: "POST",
126
150
  });
@@ -129,104 +153,204 @@ describe("Materialization & Manifest REST API (E2E)", () => {
129
153
  await fetch(url(`/materializations/${id}`), { method: "DELETE" });
130
154
  }
131
155
 
132
- // ── Group A: Full lifecycle with persist sources ──────────────────
133
-
134
- describe("full lifecycle (happy path)", () => {
135
- let materializationId: string;
156
+ /** First planned source from a BUILD_PLAN_READY materialization. */
157
+ function firstPlannedSource(
158
+ materialization: Record<string, unknown>,
159
+ ): Record<string, unknown> {
160
+ const plan = materialization.buildPlan as Record<string, unknown>;
161
+ expect(plan).toBeDefined();
162
+ const sources = plan.sources as Record<string, Record<string, unknown>>;
163
+ const values = Object.values(sources);
164
+ expect(values.length).toBeGreaterThan(0);
165
+ return values[0];
166
+ }
136
167
 
137
- afterAll(async () => {
138
- if (materializationId) await cleanup(materializationId);
139
- });
168
+ // ── Group A0: Auto-run lifecycle (default) ───────────────────────
140
169
 
170
+ describe("auto-run lifecycle (default)", () => {
141
171
  it(
142
- "should create, start, build, verify manifest, and delete",
172
+ "runs all phases on create, self-assigns names, and auto-loads",
143
173
  async () => {
144
- // 1. Create
174
+ // pauseBetweenPhases=false (default): the publisher runs compile +
175
+ // plan + build + load in one pass with no Round 2.
145
176
  const createRes = await createMaterialization({
146
- autoLoadManifest: true,
177
+ pauseBetweenPhases: false,
147
178
  });
148
179
  expect(createRes.status).toBe(201);
149
180
  const created = (await createRes.json()) as Record<string, unknown>;
150
181
  expect(created.status).toBe("PENDING");
182
+ expect(created.pauseBetweenPhases).toBe(false);
183
+ const id = created.id as string;
184
+
185
+ // It settles at MANIFEST_FILE_READY without any build instruction.
186
+ const built = await pollUntil(
187
+ id,
188
+ (s) => TERMINAL_STATUSES.includes(s),
189
+ 90_000,
190
+ );
191
+ expect(built.status).toBe("MANIFEST_FILE_READY");
192
+
193
+ // The manifest carries the self-assigned physical table name (from
194
+ // `#@ persist name="order_summary"`).
195
+ const manifest = built.manifest as Record<string, unknown>;
196
+ expect(manifest).toBeDefined();
197
+ const entries = manifest.entries as Record<
198
+ string,
199
+ Record<string, unknown>
200
+ >;
201
+ const values = Object.values(entries);
202
+ expect(values.length).toBe(1);
203
+ expect(values[0].physicalTableName).toBe("order_summary");
204
+ expect(values[0].sourceName).toBe("order_summary");
205
+ expect(values[0].realization).toBe("COPY");
206
+
207
+ // Cleanup: delete the record and drop the self-built table.
208
+ const deleteRes = await fetch(
209
+ url(`/materializations/${id}?dropTables=true`),
210
+ { method: "DELETE" },
211
+ );
212
+ expect(deleteRes.status).toBe(204);
213
+ },
214
+ { timeout: 120_000 },
215
+ );
216
+
217
+ it("rejects action=build on an auto-run materialization (409)", async () => {
218
+ // Force a pause so we can observe a BUILD_PLAN_READY record, but mark it
219
+ // auto-run via metadata by creating a real auto-run and racing build is
220
+ // flaky; instead assert the guard on a freshly created auto-run that we
221
+ // immediately try to build. Auto-run never needs Round 2.
222
+ const createRes = await createMaterialization({
223
+ pauseBetweenPhases: false,
224
+ });
225
+ expect(createRes.status).toBe(201);
226
+ const { id } = (await createRes.json()) as { id: string };
227
+
228
+ // Drive to terminal so there is no in-flight round, then assert build
229
+ // is rejected (auto-run records never accept action=build).
230
+ await pollUntil(id, (s) => TERMINAL_STATUSES.includes(s), 90_000);
231
+
232
+ const buildRes = await fetch(
233
+ url(`/materializations/${id}?action=build`),
234
+ {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({
238
+ sources: [
239
+ {
240
+ buildId: "x",
241
+ materializedTableId: "mt",
242
+ physicalTableName: "t",
243
+ realization: "COPY",
244
+ },
245
+ ],
246
+ }),
247
+ },
248
+ );
249
+ expect(buildRes.status).toBe(409);
250
+
251
+ await fetch(url(`/materializations/${id}?dropTables=true`), {
252
+ method: "DELETE",
253
+ });
254
+ });
255
+ });
256
+
257
+ // ── Group A: Full two-round lifecycle (happy path) ────────────────
258
+
259
+ describe("full two-round lifecycle", () => {
260
+ it(
261
+ "plans (round 1), builds on control-plane instruction (round 2), then deletes",
262
+ async () => {
263
+ // Round 1: create kicks off compile + plan in the background.
264
+ const createRes = await createMaterialization();
265
+ expect(createRes.status).toBe(201);
266
+ const created = (await createRes.json()) as Record<string, unknown>;
267
+ expect(created.status).toBe("PENDING");
151
268
  expect(created.id).toBeDefined();
152
- materializationId = created.id as string;
269
+ const id = created.id as string;
153
270
 
154
- // 2. List
271
+ // List should include the new run.
155
272
  const listRes = await fetch(url("/materializations"));
156
273
  expect(listRes.status).toBe(200);
157
274
  const list = (await listRes.json()) as Record<string, unknown>[];
158
- expect(list.some((m) => m.id === materializationId)).toBe(true);
275
+ expect(list.some((m) => m.id === id)).toBe(true);
159
276
 
160
- // 3. Get by ID
161
- const getRes = await fetch(
162
- url(`/materializations/${materializationId}`),
277
+ // Round 1 completes at BUILD_PLAN_READY with a plan for our source.
278
+ const planned = await pollUntil(
279
+ id,
280
+ (s) => s === "BUILD_PLAN_READY" || TERMINAL_STATUSES.includes(s),
163
281
  );
164
- expect(getRes.status).toBe(200);
165
- const got = (await getRes.json()) as Record<string, unknown>;
166
- expect(got.status).toBe("PENDING");
167
-
168
- // 4. Start
169
- const startRes = await fetch(
170
- url(`/materializations/${materializationId}?action=start`),
171
- { method: "POST" },
282
+ expect(planned.status).toBe("BUILD_PLAN_READY");
283
+ const source = firstPlannedSource(planned);
284
+ expect(source.name).toBe("order_summary");
285
+ expect(typeof source.buildId).toBe("string");
286
+
287
+ // Round 2: control plane instructs a COPY build into a physical name.
288
+ const physicalTableName = "order_summary_built";
289
+ const buildRes = await fetch(
290
+ url(`/materializations/${id}?action=build`),
291
+ {
292
+ method: "POST",
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify({
295
+ sources: [
296
+ {
297
+ buildId: source.buildId,
298
+ sourceID: source.sourceID,
299
+ materializedTableId: "mt-order-summary",
300
+ physicalTableName,
301
+ realization: "COPY",
302
+ },
303
+ ],
304
+ }),
305
+ },
172
306
  );
173
- expect(startRes.status).toBe(202);
174
- const started = (await startRes.json()) as Record<string, unknown>;
175
- expect(started.status).toBe("RUNNING");
176
-
177
- // 5. Poll until terminal
178
- const terminal = await pollUntilTerminal(materializationId);
179
- expect(terminal.status).toBe("SUCCESS");
180
- const metadata = terminal.metadata as Record<string, unknown>;
181
- expect(metadata.sourcesBuilt).toBeGreaterThan(0);
182
-
183
- // 6. Get manifest
184
- const manifestRes = await fetch(url("/manifest"));
185
- expect(manifestRes.status).toBe(200);
186
- const manifest = (await manifestRes.json()) as Record<
187
- string,
188
- unknown
189
- >;
190
- expect(manifest.entries).toBeDefined();
191
- const entries = manifest.entries as Record<string, unknown>;
192
- expect(Object.keys(entries).length).toBeGreaterThan(0);
193
- const firstEntry = Object.values(entries)[0] as Record<
194
- string,
195
- unknown
196
- >;
197
- expect(firstEntry.tableName).toBe("order_summary");
198
-
199
- // 7. Reload manifest
200
- const reloadRes = await fetch(url("/manifest?action=reload"), {
201
- method: "POST",
202
- });
203
- expect(reloadRes.status).toBe(200);
204
- const reloadedManifest = (await reloadRes.json()) as Record<
307
+ expect(buildRes.status).toBe(202);
308
+
309
+ // Round 2 completes at MANIFEST_FILE_READY with an inline manifest.
310
+ const built = await pollUntil(
311
+ id,
312
+ (s) =>
313
+ s === "MANIFEST_FILE_READY" ||
314
+ s === "FAILED" ||
315
+ s === "CANCELLED",
316
+ );
317
+ expect(built.status).toBe("MANIFEST_FILE_READY");
318
+ const manifest = built.manifest as Record<string, unknown>;
319
+ expect(manifest).toBeDefined();
320
+ const entries = manifest.entries as Record<
205
321
  string,
206
- unknown
322
+ Record<string, unknown>
207
323
  >;
208
- expect(reloadedManifest.entries).toBeDefined();
324
+ const entry = entries[source.buildId as string];
325
+ expect(entry).toBeDefined();
326
+ expect(entry.physicalTableName).toBe(physicalTableName);
327
+ expect(entry.sourceName).toBe("order_summary");
209
328
 
210
- // 8. Delete
329
+ // A terminal materialization can be deleted; dropTables=true also
330
+ // drops the physical table this run produced in Round 2.
211
331
  const deleteRes = await fetch(
212
- url(`/materializations/${materializationId}`),
332
+ url(`/materializations/${id}?dropTables=true`),
213
333
  { method: "DELETE" },
214
334
  );
215
335
  expect(deleteRes.status).toBe(204);
216
- materializationId = ""; // prevent afterAll cleanup
336
+
337
+ // It's gone.
338
+ const getRes = await fetch(url(`/materializations/${id}`));
339
+ expect(getRes.status).toBe(404);
217
340
  },
218
- { timeout: 60_000 },
341
+ { timeout: 90_000 },
219
342
  );
220
343
  });
221
344
 
222
- // ── Group B: Error cases and state machine validation ────────────
345
+ // ── Group B: State machine and error cases ───────────────────────
223
346
 
224
- describe("error cases", () => {
225
- it("should stop a PENDING materialization (PENDING -> CANCELLED)", async () => {
347
+ describe("state machine and errors", () => {
348
+ it("stops a plan-ready materialization (-> CANCELLED)", async () => {
226
349
  const createRes = await createMaterialization();
227
350
  expect(createRes.status).toBe(201);
228
- const created = (await createRes.json()) as Record<string, unknown>;
229
- const id = created.id as string;
351
+ const { id } = (await createRes.json()) as { id: string };
352
+
353
+ await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
230
354
 
231
355
  const stopRes = await fetch(
232
356
  url(`/materializations/${id}?action=stop`),
@@ -241,230 +365,117 @@ describe("Materialization & Manifest REST API (E2E)", () => {
241
365
  await cleanup(id);
242
366
  });
243
367
 
244
- it("should reject a second concurrent materialization with 409", async () => {
368
+ it("rejects a second concurrent materialization with 409", async () => {
245
369
  const first = await createMaterialization();
246
370
  expect(first.status).toBe(201);
247
- const firstData = (await first.json()) as Record<string, unknown>;
248
- const firstId = firstData.id as string;
371
+ const { id } = (await first.json()) as { id: string };
249
372
 
250
373
  const second = await createMaterialization();
251
374
  expect(second.status).toBe(409);
252
375
 
253
- await cleanup(firstId);
376
+ await cleanup(id);
254
377
  });
255
378
 
256
- it("should reject starting a CANCELLED materialization with 409", async () => {
379
+ it("rejects building from a non-plan-ready state with 409", async () => {
257
380
  const createRes = await createMaterialization();
258
381
  expect(createRes.status).toBe(201);
259
- const created = (await createRes.json()) as Record<string, unknown>;
260
- const id = created.id as string;
382
+ const { id } = (await createRes.json()) as { id: string };
261
383
 
384
+ await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
262
385
  await fetch(url(`/materializations/${id}?action=stop`), {
263
386
  method: "POST",
264
387
  });
388
+ await pollUntil(id, (s) => s === "CANCELLED");
265
389
 
266
- const startRes = await fetch(
267
- url(`/materializations/${id}?action=start`),
390
+ const buildRes = await fetch(
391
+ url(`/materializations/${id}?action=build`),
268
392
  {
269
393
  method: "POST",
394
+ headers: { "Content-Type": "application/json" },
395
+ body: JSON.stringify({
396
+ sources: [
397
+ {
398
+ buildId: "deadbeef",
399
+ materializedTableId: "mt",
400
+ physicalTableName: "t",
401
+ realization: "COPY",
402
+ },
403
+ ],
404
+ }),
270
405
  },
271
406
  );
272
- expect(startRes.status).toBe(409);
407
+ expect(buildRes.status).toBe(409);
273
408
 
274
- await cleanup(id);
409
+ await fetch(url(`/materializations/${id}`), { method: "DELETE" });
275
410
  });
276
411
 
277
- it("should reject deleting a PENDING materialization with 409", async () => {
412
+ it("rejects a build instruction with an unknown buildId (400)", async () => {
278
413
  const createRes = await createMaterialization();
279
414
  expect(createRes.status).toBe(201);
280
- const created = (await createRes.json()) as Record<string, unknown>;
281
- const id = created.id as string;
282
-
283
- const deleteRes = await fetch(url(`/materializations/${id}`), {
284
- method: "DELETE",
285
- });
286
- expect(deleteRes.status).toBe(409);
287
-
288
- await cleanup(id);
289
- });
290
-
291
- it("should return 404 for a non-existent materialization", async () => {
292
- const res = await fetch(
293
- url("/materializations/non-existent-id-12345"),
294
- );
295
- expect(res.status).toBe(404);
296
- });
297
- });
298
-
299
- // ── Group C: Package Teardown ────────────────────────────────────
415
+ const { id } = (await createRes.json()) as { id: string };
300
416
 
301
- describe("package teardown", () => {
302
- it(
303
- "dryRun reports stale entries without dropping tables or deleting rows",
304
- async () => {
305
- // Run a full build so there are manifest entries to tear down.
306
- const createRes = await createMaterialization({
307
- autoLoadManifest: true,
308
- });
309
- expect(createRes.status).toBe(201);
310
- const created = (await createRes.json()) as Record<string, unknown>;
311
- const id = created.id as string;
312
- await fetch(url(`/materializations/${id}?action=start`), {
313
- method: "POST",
314
- });
315
- const terminal = await pollUntilTerminal(id);
316
- expect(terminal.status).toBe("SUCCESS");
317
-
318
- // Must delete the materialization record before teardown
319
- // (teardown refuses to run while an active materialization exists).
320
- await fetch(url(`/materializations/${id}`), { method: "DELETE" });
417
+ await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
321
418
 
322
- // dryRun teardown should report entries but not actually drop them.
323
- const teardownRes = await fetch(url("/materializations/teardown"), {
419
+ const buildRes = await fetch(
420
+ url(`/materializations/${id}?action=build`),
421
+ {
324
422
  method: "POST",
325
423
  headers: { "Content-Type": "application/json" },
326
- body: JSON.stringify({ dryRun: true }),
327
- });
328
- expect(teardownRes.status).toBe(200);
329
- const teardownResult = (await teardownRes.json()) as Record<
330
- string,
331
- unknown
332
- >;
333
- const dropped = teardownResult.dropped as Record<string, unknown>[];
334
- expect(dropped).toBeDefined();
335
- expect(teardownResult.errors).toBeDefined();
336
-
337
- // Manifest should still be intact after a dry run.
338
- const manifestRes = await fetch(url("/manifest"));
339
- expect(manifestRes.status).toBe(200);
340
- const manifest = (await manifestRes.json()) as Record<
341
- string,
342
- unknown
343
- >;
344
- const entries = manifest.entries as Record<string, unknown>;
345
- expect(Object.keys(entries).length).toBeGreaterThan(0);
346
- },
347
- { timeout: 60_000 },
348
- );
349
-
350
- it(
351
- "live teardown drops stale manifest entries and cleans up tables",
352
- async () => {
353
- // Build so there are manifest entries.
354
- const createRes = await createMaterialization({
355
- autoLoadManifest: true,
356
- });
357
- expect(createRes.status).toBe(201);
358
- const created = (await createRes.json()) as Record<string, unknown>;
359
- const id = created.id as string;
360
- await fetch(url(`/materializations/${id}?action=start`), {
361
- method: "POST",
362
- });
363
- const terminal = await pollUntilTerminal(id);
364
- expect(terminal.status).toBe("SUCCESS");
365
-
366
- await fetch(url(`/materializations/${id}`), { method: "DELETE" });
424
+ body: JSON.stringify({
425
+ sources: [
426
+ {
427
+ buildId: "not-a-real-build-id",
428
+ materializedTableId: "mt",
429
+ physicalTableName: "t",
430
+ realization: "COPY",
431
+ },
432
+ ],
433
+ }),
434
+ },
435
+ );
436
+ expect(buildRes.status).toBe(400);
367
437
 
368
- // Live teardown — should drop everything since all entries are
369
- // stale (no active build claims them).
370
- const teardownRes = await fetch(url("/materializations/teardown"), {
371
- method: "POST",
372
- headers: { "Content-Type": "application/json" },
373
- body: JSON.stringify({}),
374
- });
375
- expect(teardownRes.status).toBe(200);
376
- const teardownResult = (await teardownRes.json()) as Record<
377
- string,
378
- unknown
379
- >;
380
- const dropped = teardownResult.dropped as Record<string, unknown>[];
381
- expect(dropped.length).toBeGreaterThan(0);
382
- expect((teardownResult.errors as unknown[]).length).toBe(0);
383
-
384
- // Manifest should be empty after live teardown.
385
- const manifestRes = await fetch(url("/manifest"));
386
- expect(manifestRes.status).toBe(200);
387
- const manifest = (await manifestRes.json()) as Record<
388
- string,
389
- unknown
390
- >;
391
- const entries = manifest.entries as Record<string, unknown>;
392
- expect(Object.keys(entries).length).toBe(0);
393
- },
394
- { timeout: 60_000 },
395
- );
438
+ await cleanup(id);
439
+ });
396
440
 
397
- it("teardown rejects while an active materialization exists", async () => {
441
+ it("rejects deleting a non-terminal materialization with 409", async () => {
398
442
  const createRes = await createMaterialization();
399
443
  expect(createRes.status).toBe(201);
400
- const created = (await createRes.json()) as Record<string, unknown>;
401
- const id = created.id as string;
444
+ const { id } = (await createRes.json()) as { id: string };
402
445
 
403
- const teardownRes = await fetch(url("/materializations/teardown"), {
404
- method: "POST",
405
- headers: { "Content-Type": "application/json" },
406
- body: JSON.stringify({}),
446
+ await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
447
+
448
+ const deleteRes = await fetch(url(`/materializations/${id}`), {
449
+ method: "DELETE",
407
450
  });
408
- expect(teardownRes.status).toBe(409);
451
+ expect(deleteRes.status).toBe(409);
409
452
 
410
453
  await cleanup(id);
411
454
  });
412
455
 
413
- it(
414
- "forceRefresh rebuilds and post-build GC step executes",
415
- async () => {
416
- // First build populates manifest.
417
- const first = await createMaterialization({
418
- autoLoadManifest: true,
419
- });
420
- expect(first.status).toBe(201);
421
- const firstData = (await first.json()) as Record<string, unknown>;
422
- const firstId = firstData.id as string;
423
- await fetch(url(`/materializations/${firstId}?action=start`), {
424
- method: "POST",
425
- });
426
- const firstTerminal = await pollUntilTerminal(firstId);
427
- expect(firstTerminal.status).toBe("SUCCESS");
428
- await fetch(url(`/materializations/${firstId}`), {
429
- method: "DELETE",
430
- });
456
+ it("rejects an unsupported action with 400", async () => {
457
+ const createRes = await createMaterialization();
458
+ expect(createRes.status).toBe(201);
459
+ const { id } = (await createRes.json()) as { id: string };
431
460
 
432
- // Second build with forceRefresh the buildId won't change
433
- // (hash of SQL + connection is identical), but forceRefresh
434
- // forces a rebuild rather than skipping.
435
- const second = await createMaterialization({
436
- forceRefresh: true,
437
- autoLoadManifest: true,
438
- });
439
- expect(second.status).toBe(201);
440
- const secondData = (await second.json()) as Record<string, unknown>;
441
- const secondId = secondData.id as string;
442
- await fetch(url(`/materializations/${secondId}?action=start`), {
461
+ await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
462
+
463
+ const res = await fetch(
464
+ url(`/materializations/${id}?action=frobnicate`),
465
+ {
443
466
  method: "POST",
444
- });
445
- const secondTerminal = await pollUntilTerminal(secondId);
446
- expect(secondTerminal.status).toBe("SUCCESS");
447
-
448
- const metadata = secondTerminal.metadata as Record<string, unknown>;
449
- // forceRefresh should actually rebuild, not skip.
450
- expect(metadata.sourcesBuilt).toBeGreaterThan(0);
451
- expect(metadata.sourcesSkipped).toBe(0);
452
- // Post-build GC step ran (arrays present even if empty).
453
- expect(metadata.gcDropped).toBeDefined();
454
- expect(metadata.gcErrors).toBeDefined();
455
-
456
- // Manifest should still have entries after rebuild.
457
- const manifestRes = await fetch(url("/manifest"));
458
- const manifest = (await manifestRes.json()) as Record<
459
- string,
460
- unknown
461
- >;
462
- const entries = manifest.entries as Record<string, unknown>;
463
- expect(Object.keys(entries).length).toBeGreaterThan(0);
467
+ },
468
+ );
469
+ expect(res.status).toBe(400);
464
470
 
465
- await cleanup(secondId);
466
- },
467
- { timeout: 90_000 },
468
- );
471
+ await cleanup(id);
472
+ });
473
+
474
+ it("returns 404 for a non-existent materialization", async () => {
475
+ const res = await fetch(
476
+ url("/materializations/non-existent-id-12345"),
477
+ );
478
+ expect(res.status).toBe(404);
479
+ });
469
480
  });
470
481
  });