@malloy-publisher/server 0.0.205 → 0.0.207

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 +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.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 +1272 -1299
  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 +121 -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 +270 -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 +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  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 +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  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
@@ -1,41 +1,78 @@
1
- import type { Connection } from "@malloydata/malloy";
1
+ import { Manifest } from "@malloydata/malloy";
2
2
  import { beforeEach, describe, expect, it } from "bun:test";
3
3
  import * as sinon from "sinon";
4
4
  import {
5
+ BadRequestError,
5
6
  EnvironmentNotFoundError,
6
7
  InvalidStateTransitionError,
7
8
  MaterializationConflictError,
8
9
  MaterializationNotFoundError,
9
10
  } from "../errors";
10
11
  import {
11
- ManifestEntry,
12
+ BuildInstruction,
13
+ BuildPlan,
12
14
  Materialization,
13
- MaterializationStatus,
14
15
  ResourceRepository,
15
16
  } from "../storage/DatabaseInterface";
16
17
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
17
18
  import { EnvironmentStore } from "./environment_store";
18
- import { ManifestService } from "./manifest_service";
19
19
  import {
20
- manifestTableKey,
21
20
  MaterializationService,
22
- tablePhysicallyExists,
21
+ stagingSuffix,
23
22
  } from "./materialization_service";
24
23
 
25
- function makeExecution(
24
+ function makeMaterialization(
26
25
  overrides: Partial<Materialization> = {},
27
26
  ): Materialization {
28
27
  return {
29
- id: "exec-1",
30
- environmentId: "proj-1",
28
+ id: "mat-1",
29
+ environmentId: "env-1",
31
30
  packageName: "pkg",
31
+ pauseBetweenPhases: false,
32
32
  status: "PENDING",
33
+ buildPlan: null,
34
+ manifest: null,
33
35
  startedAt: null,
34
36
  completedAt: null,
35
37
  error: null,
36
38
  metadata: null,
37
- createdAt: new Date("2025-01-01"),
38
- updatedAt: new Date("2025-01-01"),
39
+ createdAt: new Date("2026-01-01"),
40
+ updatedAt: new Date("2026-01-01"),
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function makeBuildPlan(overrides: Partial<BuildPlan> = {}): BuildPlan {
46
+ return {
47
+ graphs: [
48
+ {
49
+ connectionName: "duckdb",
50
+ nodes: [[{ sourceID: "orders@m.malloy", dependsOn: [] }]],
51
+ },
52
+ ],
53
+ sources: {
54
+ "orders@m.malloy": {
55
+ name: "orders",
56
+ sourceID: "orders@m.malloy",
57
+ connectionName: "duckdb",
58
+ dialect: "duckdb",
59
+ buildId: "build-orders",
60
+ sql: "SELECT 1",
61
+ columns: [],
62
+ },
63
+ },
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function makeInstruction(
69
+ overrides: Partial<BuildInstruction> = {},
70
+ ): BuildInstruction {
71
+ return {
72
+ buildId: "build-orders",
73
+ materializedTableId: "mt-1",
74
+ physicalTableName: '"orders_v1"',
75
+ realization: "COPY",
39
76
  ...overrides,
40
77
  };
41
78
  }
@@ -45,69 +82,40 @@ type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
45
82
  function createMocks() {
46
83
  const sandbox = sinon.createSandbox();
47
84
 
48
- const repository: MockRepo = {
49
- listEnvironments: sandbox.stub(),
50
- getEnvironmentById: sandbox.stub(),
85
+ const repository = {
51
86
  getEnvironmentByName: sandbox.stub(),
52
- createEnvironment: sandbox.stub(),
53
- updateEnvironment: sandbox.stub(),
54
- deleteEnvironment: sandbox.stub(),
55
- listPackages: sandbox.stub(),
56
- getPackageById: sandbox.stub(),
57
- getPackageByName: sandbox.stub(),
58
- createPackage: sandbox.stub(),
59
- updatePackage: sandbox.stub(),
60
- deletePackage: sandbox.stub(),
61
- listConnections: sandbox.stub(),
62
- getConnectionById: sandbox.stub(),
63
- getConnectionByName: sandbox.stub(),
64
- createConnection: sandbox.stub(),
65
- updateConnection: sandbox.stub(),
66
- deleteConnection: sandbox.stub(),
67
87
  listMaterializations: sandbox.stub(),
68
88
  getMaterializationById: sandbox.stub(),
69
89
  getActiveMaterialization: sandbox.stub(),
70
90
  createMaterialization: sandbox.stub(),
71
91
  updateMaterialization: sandbox.stub(),
72
92
  deleteMaterialization: sandbox.stub(),
73
- listManifestEntries: sandbox.stub(),
74
- upsertManifestEntry: sandbox.stub(),
75
- deleteManifestEntry: sandbox.stub(),
76
93
  } as unknown as MockRepo;
77
94
 
78
- const storageManager = {
79
- getRepository: () => repository,
80
- getManifestStore: sandbox.stub(),
81
- };
95
+ const storageManager = { getRepository: () => repository };
82
96
 
83
97
  const environmentStore = {
84
98
  storageManager,
85
99
  getEnvironment: sandbox.stub(),
86
100
  } as unknown as EnvironmentStore;
87
101
 
88
- const manifestService = {
89
- getManifest: sandbox.stub().resolves({ entries: {}, strict: false }),
90
- writeEntry: sandbox.stub().resolves(),
91
- deleteEntry: sandbox.stub().resolves(),
92
- reloadManifest: sandbox.stub().resolves({ entries: {}, strict: false }),
93
- listEntries: sandbox.stub().resolves([]),
94
- } as unknown as sinon.SinonStubbedInstance<ManifestService>;
95
-
96
- const service = new MaterializationService(
97
- environmentStore,
98
- manifestService as unknown as ManifestService,
99
- );
100
-
101
- // Default: resolveEnvironmentId succeeds
102
+ // Default: environment resolves and yields a package.
102
103
  repository.getEnvironmentByName.resolves({
103
- id: "proj-1",
104
- name: "my-environment",
104
+ id: "env-1",
105
+ name: "my-env",
105
106
  path: "/test",
106
107
  createdAt: new Date(),
107
108
  updatedAt: new Date(),
108
109
  });
110
+ (environmentStore.getEnvironment as sinon.SinonStub).resolves({
111
+ getPackage: sinon.stub().resolves({}),
112
+ withPackageLock: async (_name: string, fn: () => Promise<unknown>) =>
113
+ fn(),
114
+ });
109
115
 
110
- return { sandbox, repository, environmentStore, manifestService, service };
116
+ const service = new MaterializationService(environmentStore);
117
+
118
+ return { sandbox, repository, environmentStore, service };
111
119
  }
112
120
 
113
121
  describe("MaterializationService", () => {
@@ -117,572 +125,648 @@ describe("MaterializationService", () => {
117
125
  ctx = createMocks();
118
126
  });
119
127
 
120
- // ==================== resolveEnvironmentId ====================
121
-
122
- describe("resolveEnvironmentId (via listMaterializations)", () => {
123
- it("should throw EnvironmentNotFoundError when environment is not in DB", async () => {
128
+ describe("resolveEnvironmentId", () => {
129
+ it("throws EnvironmentNotFoundError when the environment is missing", async () => {
124
130
  ctx.repository.getEnvironmentByName.resolves(null);
125
-
126
131
  await expect(
127
132
  ctx.service.listMaterializations("unknown", "pkg"),
128
133
  ).rejects.toThrow(EnvironmentNotFoundError);
129
134
  });
130
135
  });
131
136
 
132
- // ==================== BUILD QUERIES ====================
133
-
134
- describe("listMaterializations", () => {
135
- it("should list builds for a package", async () => {
136
- const builds = [makeExecution(), makeExecution({ id: "exec-2" })];
137
- ctx.repository.listMaterializations.resolves(builds);
138
-
139
- const result = await ctx.service.listMaterializations(
140
- "my-environment",
141
- "pkg",
142
- );
143
-
144
- expect(result).toEqual(builds);
137
+ describe("queries", () => {
138
+ it("lists materializations for a package", async () => {
139
+ const rows = [
140
+ makeMaterialization(),
141
+ makeMaterialization({ id: "m2" }),
142
+ ];
143
+ ctx.repository.listMaterializations.resolves(rows);
144
+ expect(
145
+ await ctx.service.listMaterializations("my-env", "pkg"),
146
+ ).toEqual(rows);
145
147
  });
146
- });
147
-
148
- describe("getMaterialization", () => {
149
- it("should return a specific build", async () => {
150
- const exec = makeExecution();
151
- ctx.repository.getMaterializationById.resolves(exec);
152
148
 
153
- const result = await ctx.service.getMaterialization(
154
- "my-environment",
155
- "pkg",
156
- "exec-1",
157
- );
158
-
159
- expect(result).toEqual(exec);
149
+ it("returns a specific materialization", async () => {
150
+ const m = makeMaterialization();
151
+ ctx.repository.getMaterializationById.resolves(m);
152
+ expect(
153
+ await ctx.service.getMaterialization("my-env", "pkg", "mat-1"),
154
+ ).toEqual(m);
160
155
  });
161
156
 
162
- it("should throw when build not found", async () => {
157
+ it("throws when the materialization is missing", async () => {
163
158
  ctx.repository.getMaterializationById.resolves(null);
164
-
165
159
  await expect(
166
- ctx.service.getMaterialization("my-environment", "pkg", "missing"),
160
+ ctx.service.getMaterialization("my-env", "pkg", "nope"),
167
161
  ).rejects.toThrow(MaterializationNotFoundError);
168
162
  });
169
163
 
170
- it("should throw when build belongs to a different package", async () => {
164
+ it("throws when the materialization belongs to another package", async () => {
171
165
  ctx.repository.getMaterializationById.resolves(
172
- makeExecution({ packageName: "other-pkg" }),
166
+ makeMaterialization({ packageName: "other" }),
173
167
  );
174
-
175
168
  await expect(
176
- ctx.service.getMaterialization("my-environment", "pkg", "exec-1"),
169
+ ctx.service.getMaterialization("my-env", "pkg", "mat-1"),
177
170
  ).rejects.toThrow(MaterializationNotFoundError);
178
171
  });
179
172
  });
180
173
 
181
- // ==================== STATE MACHINE ====================
182
-
183
- describe("state transitions", () => {
184
- const validTransitions: [MaterializationStatus, MaterializationStatus][] =
185
- [
186
- ["PENDING", "RUNNING"],
187
- ["PENDING", "CANCELLED"],
188
- ["RUNNING", "SUCCESS"],
189
- ["RUNNING", "FAILED"],
190
- ["RUNNING", "CANCELLED"],
191
- ];
192
-
193
- const invalidTransitions: [
194
- MaterializationStatus,
195
- MaterializationStatus,
196
- ][] = [
197
- ["PENDING", "SUCCESS"],
198
- ["PENDING", "FAILED"],
199
- ["RUNNING", "PENDING"],
200
- ["SUCCESS", "RUNNING"],
201
- ["SUCCESS", "FAILED"],
202
- ["FAILED", "RUNNING"],
203
- ["FAILED", "SUCCESS"],
204
- ["CANCELLED", "RUNNING"],
205
- ["CANCELLED", "SUCCESS"],
206
- ];
207
-
208
- for (const [from, to] of validTransitions) {
209
- it(`should allow ${from} -> ${to}`, async () => {
210
- const exec = makeExecution({ status: from });
211
- ctx.repository.getMaterializationById.resolves(exec);
212
- ctx.repository.updateMaterialization.resolves(
213
- makeExecution({ status: to }),
214
- );
215
-
216
- // Trigger via stopMaterialization for RUNNING->CANCELLED (orphaned path)
217
- if (from === "RUNNING" && to === "CANCELLED") {
218
- const result = await ctx.service.stopMaterialization(
219
- "my-environment",
220
- "pkg",
221
- exec.id,
222
- );
223
- expect(result).not.toBeNull();
224
- }
225
- });
226
- }
227
-
228
- for (const [from, to] of invalidTransitions) {
229
- it(`should reject ${from} -> ${to}`, async () => {
230
- const exec = makeExecution({ status: from });
231
- ctx.repository.getMaterializationById.resolves(exec);
232
-
233
- await expect(
234
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
235
- (ctx.service as any).transitionExecution(exec.id, to),
236
- ).rejects.toThrow(InvalidStateTransitionError);
237
- });
238
- }
239
- });
240
-
241
- // ==================== CREATE / START / STOP ====================
242
-
243
- describe("createMaterialization", () => {
244
- it("should create a PENDING build", async () => {
245
- (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
246
- getPackage: sinon.stub().resolves({}),
247
- });
174
+ describe("createMaterialization (Round 1)", () => {
175
+ it("creates a PENDING record and persists options", async () => {
248
176
  ctx.repository.getActiveMaterialization.resolves(null);
249
- const pending = makeExecution({
250
- status: "PENDING",
251
- metadata: { forceRefresh: false, autoLoadManifest: true },
252
- });
177
+ const pending = makeMaterialization({ status: "PENDING" });
253
178
  ctx.repository.createMaterialization.resolves(pending);
254
179
 
255
180
  const result = await ctx.service.createMaterialization(
256
- "my-environment",
181
+ "my-env",
257
182
  "pkg",
258
183
  {
259
- autoLoadManifest: true,
184
+ forceRefresh: true,
185
+ sourceNames: ["orders"],
186
+ pauseBetweenPhases: true,
260
187
  },
261
188
  );
262
189
 
263
190
  expect(result.status).toBe("PENDING");
264
- expect(
265
- (result.metadata as Record<string, unknown>)?.autoLoadManifest,
266
- ).toBe(true);
267
- // Options persist on the INSERT itself; no follow-up update.
268
- expect(ctx.repository.createMaterialization.calledOnce).toBe(true);
269
191
  expect(ctx.repository.createMaterialization.firstCall.args).toEqual([
270
- "proj-1",
192
+ "env-1",
271
193
  "pkg",
272
194
  "PENDING",
273
- { forceRefresh: false, autoLoadManifest: true },
195
+ {
196
+ forceRefresh: true,
197
+ sourceNames: ["orders"],
198
+ pauseBetweenPhases: true,
199
+ },
274
200
  ]);
275
- expect(ctx.repository.updateMaterialization.called).toBe(false);
276
201
  });
277
202
 
278
- it("should reject creation when an active materialization exists", async () => {
279
- (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
280
- getPackage: sinon.stub().resolves({}),
281
- });
282
- ctx.repository.getActiveMaterialization.resolves(
283
- makeExecution({ id: "existing", status: "RUNNING" }),
203
+ it("defaults pauseBetweenPhases to false (auto-run) in metadata", async () => {
204
+ ctx.repository.getActiveMaterialization.resolves(null);
205
+ ctx.repository.createMaterialization.resolves(
206
+ makeMaterialization({ status: "PENDING" }),
207
+ );
208
+
209
+ await ctx.service.createMaterialization("my-env", "pkg", {});
210
+
211
+ expect(ctx.repository.createMaterialization.firstCall.args[3]).toEqual(
212
+ {
213
+ forceRefresh: false,
214
+ sourceNames: null,
215
+ pauseBetweenPhases: false,
216
+ },
284
217
  );
218
+ });
285
219
 
220
+ it("rejects when an active materialization already exists", async () => {
221
+ ctx.repository.getActiveMaterialization.resolves(
222
+ makeMaterialization({ id: "existing", status: "BUILD_PLAN_READY" }),
223
+ );
286
224
  await expect(
287
- ctx.service.createMaterialization("my-environment", "pkg"),
225
+ ctx.service.createMaterialization("my-env", "pkg"),
288
226
  ).rejects.toThrow(MaterializationConflictError);
289
227
  });
290
228
 
291
- it("should translate DuplicateActiveMaterializationError from a lost race", async () => {
292
- (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
293
- getPackage: sinon.stub().resolves({}),
294
- });
295
- // The pre-check finds nothing (race is still possible), but the
296
- // atomic insert loses to a concurrent create and the repository
297
- // raises DuplicateActiveMaterializationError.
229
+ it("translates a lost create race into a conflict", async () => {
298
230
  ctx.repository.getActiveMaterialization
299
231
  .onFirstCall()
300
232
  .resolves(null)
301
233
  .onSecondCall()
302
- .resolves(makeExecution({ id: "winner", status: "PENDING" }));
234
+ .resolves(makeMaterialization({ id: "winner" }));
303
235
  ctx.repository.createMaterialization.rejects(
304
- new DuplicateActiveMaterializationError("proj-1", "pkg"),
236
+ new DuplicateActiveMaterializationError("env-1", "pkg"),
305
237
  );
306
-
307
238
  await expect(
308
- ctx.service.createMaterialization("my-environment", "pkg"),
239
+ ctx.service.createMaterialization("my-env", "pkg"),
309
240
  ).rejects.toThrow(/winner/);
310
241
  });
311
242
  });
312
243
 
313
- describe("startMaterialization", () => {
314
- it("should transition PENDING to RUNNING", async () => {
315
- const pending = makeExecution({
316
- status: "PENDING",
317
- metadata: { forceRefresh: false, autoLoadManifest: false },
318
- });
319
- const running = makeExecution({ status: "RUNNING" });
320
- ctx.repository.getMaterializationById.resolves(pending);
321
- ctx.repository.getActiveMaterialization.resolves(null);
322
- ctx.repository.updateMaterialization.resolves(running);
323
-
324
- const result = await ctx.service.startMaterialization(
325
- "my-environment",
326
- "pkg",
327
- "exec-1",
244
+ describe("buildMaterialization (Round 2)", () => {
245
+ it("rejects action=build on an auto-run materialization", async () => {
246
+ ctx.repository.getMaterializationById.resolves(
247
+ makeMaterialization({
248
+ status: "BUILD_PLAN_READY",
249
+ pauseBetweenPhases: false,
250
+ buildPlan: makeBuildPlan(),
251
+ }),
328
252
  );
329
-
330
- expect(result.status).toBe("RUNNING");
331
- });
332
-
333
- it("should reject non-PENDING builds", async () => {
334
- const running = makeExecution({ status: "RUNNING" });
335
- ctx.repository.getMaterializationById.resolves(running);
336
-
337
253
  await expect(
338
- ctx.service.startMaterialization("my-environment", "pkg", "exec-1"),
254
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
255
+ makeInstruction(),
256
+ ]),
339
257
  ).rejects.toThrow(InvalidStateTransitionError);
340
258
  });
341
259
 
342
- it("should throw MaterializationConflictError when another is already running", async () => {
343
- const pending = makeExecution({
344
- status: "PENDING",
345
- metadata: { forceRefresh: false, autoLoadManifest: false },
346
- });
347
- ctx.repository.getMaterializationById.resolves(pending);
348
- ctx.repository.getActiveMaterialization.resolves(
349
- makeExecution({ id: "other", status: "RUNNING" }),
350
- );
351
-
352
- await expect(
353
- ctx.service.startMaterialization("my-environment", "pkg", "exec-1"),
354
- ).rejects.toThrow(MaterializationConflictError);
355
- });
356
- });
357
-
358
- describe("stopMaterialization", () => {
359
- it("should cancel a PENDING build", async () => {
360
- const pending = makeExecution({ status: "PENDING" });
361
- ctx.repository.getMaterializationById.resolves(pending);
362
- ctx.repository.updateMaterialization.resolves(
363
- makeExecution({ status: "CANCELLED" }),
364
- );
365
-
366
- const result = await ctx.service.stopMaterialization(
367
- "my-environment",
368
- "pkg",
369
- "exec-1",
370
- );
371
-
372
- expect(result.status).toBe("CANCELLED");
373
- });
374
-
375
- it("should force-cancel an orphaned RUNNING build", async () => {
376
- const running = makeExecution({ id: "orphan", status: "RUNNING" });
377
- ctx.repository.getMaterializationById.resolves(running);
378
- ctx.repository.updateMaterialization.resolves(
379
- makeExecution({ id: "orphan", status: "CANCELLED" }),
380
- );
381
-
382
- const result = await ctx.service.stopMaterialization(
383
- "my-environment",
384
- "pkg",
385
- "orphan",
260
+ it("rejects when not at BUILD_PLAN_READY", async () => {
261
+ ctx.repository.getMaterializationById.resolves(
262
+ makeMaterialization({
263
+ status: "PENDING",
264
+ pauseBetweenPhases: true,
265
+ }),
386
266
  );
387
-
388
- expect(result.status).toBe("CANCELLED");
389
- });
390
-
391
- it("should reject stopping a terminal build", async () => {
392
- const succeeded = makeExecution({ status: "SUCCESS" });
393
- ctx.repository.getMaterializationById.resolves(succeeded);
394
-
395
267
  await expect(
396
- ctx.service.stopMaterialization("my-environment", "pkg", "exec-1"),
268
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
269
+ makeInstruction(),
270
+ ]),
397
271
  ).rejects.toThrow(InvalidStateTransitionError);
398
272
  });
399
- });
400
-
401
- // ==================== DELETE ====================
402
273
 
403
- describe("deleteMaterialization", () => {
404
- it("should delete a SUCCESS materialization", async () => {
405
- const succeeded = makeExecution({ status: "SUCCESS" });
406
- ctx.repository.getMaterializationById.resolves(succeeded);
407
- ctx.repository.deleteMaterialization.resolves();
408
-
409
- await ctx.service.deleteMaterialization(
410
- "my-environment",
411
- "pkg",
412
- "exec-1",
413
- );
414
-
415
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
416
- expect(ctx.repository.deleteMaterialization.firstCall.args[0]).toBe(
417
- "exec-1",
274
+ it("rejects instructions that reference an unknown buildId", async () => {
275
+ ctx.repository.getMaterializationById.resolves(
276
+ makeMaterialization({
277
+ status: "BUILD_PLAN_READY",
278
+ pauseBetweenPhases: true,
279
+ buildPlan: makeBuildPlan(),
280
+ }),
418
281
  );
282
+ await expect(
283
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
284
+ makeInstruction({ buildId: "ghost" }),
285
+ ]),
286
+ ).rejects.toThrow(BadRequestError);
419
287
  });
420
288
 
421
- it("should delete a FAILED materialization", async () => {
422
- const failed = makeExecution({ status: "FAILED" });
423
- ctx.repository.getMaterializationById.resolves(failed);
424
- ctx.repository.deleteMaterialization.resolves();
425
-
426
- await ctx.service.deleteMaterialization(
427
- "my-environment",
428
- "pkg",
429
- "exec-1",
289
+ it("rejects SNAPSHOT when the connection does not support it", async () => {
290
+ ctx.repository.getMaterializationById.resolves(
291
+ makeMaterialization({
292
+ status: "BUILD_PLAN_READY",
293
+ pauseBetweenPhases: true,
294
+ buildPlan: makeBuildPlan(),
295
+ }),
430
296
  );
431
-
432
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
297
+ await expect(
298
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
299
+ makeInstruction({ realization: "SNAPSHOT" }),
300
+ ]),
301
+ ).rejects.toThrow(BadRequestError);
433
302
  });
434
303
 
435
- it("should delete a CANCELLED materialization", async () => {
436
- const cancelled = makeExecution({ status: "CANCELLED" });
437
- ctx.repository.getMaterializationById.resolves(cancelled);
438
- ctx.repository.deleteMaterialization.resolves();
439
-
440
- await ctx.service.deleteMaterialization(
441
- "my-environment",
304
+ it("accepts a valid build and returns the record (202)", async () => {
305
+ const m = makeMaterialization({
306
+ status: "BUILD_PLAN_READY",
307
+ pauseBetweenPhases: true,
308
+ buildPlan: makeBuildPlan(),
309
+ });
310
+ ctx.repository.getMaterializationById.resolves(m);
311
+ const result = await ctx.service.buildMaterialization(
312
+ "my-env",
442
313
  "pkg",
443
- "exec-1",
314
+ "mat-1",
315
+ [makeInstruction()],
444
316
  );
445
-
446
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
317
+ expect(result.id).toBe("mat-1");
447
318
  });
319
+ });
448
320
 
449
- it("should reject deleting a PENDING materialization", async () => {
450
- const pending = makeExecution({ status: "PENDING" });
451
- ctx.repository.getMaterializationById.resolves(pending);
452
-
453
- await expect(
454
- ctx.service.deleteMaterialization(
455
- "my-environment",
456
- "pkg",
457
- "exec-1",
458
- ),
459
- ).rejects.toThrow(InvalidStateTransitionError);
460
- });
461
-
462
- it("should reject deleting a RUNNING materialization", async () => {
463
- const running = makeExecution({ status: "RUNNING" });
464
- ctx.repository.getMaterializationById.resolves(running);
465
-
466
- await expect(
467
- ctx.service.deleteMaterialization(
468
- "my-environment",
321
+ describe("stopMaterialization", () => {
322
+ const cancellable = [
323
+ "PENDING",
324
+ "BUILD_PLAN_READY",
325
+ "MANIFEST_ROWS_READY",
326
+ ] as const;
327
+
328
+ for (const status of cancellable) {
329
+ it(`cancels a ${status} materialization`, async () => {
330
+ ctx.repository.getMaterializationById.resolves(
331
+ makeMaterialization({ status }),
332
+ );
333
+ ctx.repository.updateMaterialization.resolves(
334
+ makeMaterialization({ status: "CANCELLED" }),
335
+ );
336
+ const result = await ctx.service.stopMaterialization(
337
+ "my-env",
469
338
  "pkg",
470
- "exec-1",
471
- ),
472
- ).rejects.toThrow(InvalidStateTransitionError);
473
- });
474
-
475
- it("should throw when materialization not found", async () => {
476
- ctx.repository.getMaterializationById.resolves(null);
339
+ "mat-1",
340
+ );
341
+ expect(result.status).toBe("CANCELLED");
342
+ });
343
+ }
477
344
 
478
- await expect(
479
- ctx.service.deleteMaterialization(
480
- "my-environment",
481
- "pkg",
482
- "missing",
483
- ),
484
- ).rejects.toThrow(MaterializationNotFoundError);
485
- });
345
+ const terminal = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"] as const;
346
+ for (const status of terminal) {
347
+ it(`rejects stopping a ${status} materialization`, async () => {
348
+ ctx.repository.getMaterializationById.resolves(
349
+ makeMaterialization({ status }),
350
+ );
351
+ await expect(
352
+ ctx.service.stopMaterialization("my-env", "pkg", "mat-1"),
353
+ ).rejects.toThrow(InvalidStateTransitionError);
354
+ });
355
+ }
486
356
  });
487
357
 
488
- // ==================== TEARDOWN ====================
489
-
490
- describe("teardownPackage", () => {
491
- it("refuses to run while a materialization is active", async () => {
492
- ctx.repository.getActiveMaterialization.resolves(
493
- makeExecution({ status: "RUNNING" }),
494
- );
358
+ describe("deleteMaterialization", () => {
359
+ const terminal = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"] as const;
360
+ for (const status of terminal) {
361
+ it(`deletes a ${status} materialization`, async () => {
362
+ ctx.repository.getMaterializationById.resolves(
363
+ makeMaterialization({ status }),
364
+ );
365
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
366
+ expect(
367
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
368
+ ).toBe(true);
369
+ });
370
+ }
495
371
 
496
- await expect(
497
- ctx.service.teardownPackage("my-environment", "pkg"),
498
- ).rejects.toThrow(MaterializationConflictError);
499
- });
372
+ const active = [
373
+ "PENDING",
374
+ "BUILD_PLAN_READY",
375
+ "MANIFEST_ROWS_READY",
376
+ ] as const;
377
+ for (const status of active) {
378
+ it(`rejects deleting a ${status} materialization`, async () => {
379
+ ctx.repository.getMaterializationById.resolves(
380
+ makeMaterialization({ status }),
381
+ );
382
+ await expect(
383
+ ctx.service.deleteMaterialization("my-env", "pkg", "mat-1"),
384
+ ).rejects.toThrow(InvalidStateTransitionError);
385
+ expect(ctx.repository.deleteMaterialization.called).toBe(false);
386
+ });
387
+ }
500
388
 
501
- it("drops every manifest entry for the package and deletes its row", async () => {
389
+ it("does not drop tables by default (record-only delete)", async () => {
502
390
  const runSQL = sinon.stub().resolves();
503
- const connection = {
504
- dialectName: "duckdb",
505
- runSQL,
506
- } as unknown as Connection;
507
- const pkg = {
508
- getMalloyConnection: async (name: string): Promise<Connection> => {
509
- if (name === "conn") return connection;
510
- throw new Error(`unknown connection: ${name}`);
511
- },
512
- };
391
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
513
392
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
514
- getPackage: sinon.stub().resolves(pkg),
393
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
394
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
395
+ fn(),
515
396
  });
516
- ctx.repository.getActiveMaterialization.resolves(null);
517
- const entries: ManifestEntry[] = [
518
- {
519
- id: "entry-1",
520
- environmentId: "proj-1",
521
- packageName: "pkg",
522
- buildId: "abcdef1234567890abcdef1234567890",
523
- tableName: "table_a",
524
- sourceName: "src",
525
- connectionName: "conn",
526
- createdAt: new Date(),
527
- updatedAt: new Date(),
528
- },
529
- {
530
- id: "entry-2",
531
- environmentId: "proj-1",
532
- packageName: "pkg",
533
- buildId: "1234567890abcdef1234567890abcdef",
534
- tableName: "table_b",
535
- sourceName: "src",
536
- connectionName: "conn",
537
- createdAt: new Date(),
538
- updatedAt: new Date(),
539
- },
540
- ];
541
- (ctx.manifestService.listEntries as sinon.SinonStub).resolves(entries);
542
-
543
- const result = await ctx.service.teardownPackage(
544
- "my-environment",
545
- "pkg",
397
+ ctx.repository.getMaterializationById.resolves(
398
+ makeMaterialization({
399
+ status: "MANIFEST_FILE_READY",
400
+ manifest: {
401
+ builtAt: new Date().toISOString(),
402
+ strict: false,
403
+ entries: {
404
+ b1: {
405
+ buildId: "b1",
406
+ physicalTableName: "orders_mz",
407
+ connectionName: "duckdb",
408
+ },
409
+ },
410
+ },
411
+ }),
546
412
  );
547
413
 
548
- expect(result.dropped).toHaveLength(2);
549
- expect(result.errors).toHaveLength(0);
550
- // Each entry triggers target DROP + staging DROP = 2 calls per entry.
551
- expect(runSQL.callCount).toBe(4);
414
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
415
+
416
+ expect(getMalloyConnection.called).toBe(false);
417
+ expect(runSQL.called).toBe(false);
552
418
  expect(
553
- (ctx.manifestService.deleteEntry as sinon.SinonStub).callCount,
554
- ).toBe(2);
419
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
420
+ ).toBe(true);
555
421
  });
556
422
 
557
- it("deletes rows whose connection is no longer registered (teardown force-delete)", async () => {
423
+ it("drops manifest tables (and staging) when dropTables is set", async () => {
558
424
  const runSQL = sinon.stub().resolves();
559
- const livingConn = {
560
- dialectName: "duckdb",
561
- runSQL,
562
- } as unknown as Connection;
563
- // Only "live_conn" is registered; the manifest row below points at
564
- // a vanished "ghost_conn", which used to be impossible to tear down.
565
- // `teardownPackage` must force-delete the row anyway so teardown
566
- // can complete.
567
- const pkg = {
568
- getMalloyConnection: async (name: string): Promise<Connection> => {
569
- if (name === "live_conn") return livingConn;
570
- throw new Error(`unknown connection: ${name}`);
571
- },
572
- };
425
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
573
426
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
574
- getPackage: sinon.stub().resolves(pkg),
427
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
428
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
429
+ fn(),
575
430
  });
576
- ctx.repository.getActiveMaterialization.resolves(null);
577
- const entries: ManifestEntry[] = [
578
- {
579
- id: "entry-ghost",
580
- environmentId: "proj-1",
581
- packageName: "pkg",
582
- buildId: "abcdef1234567890abcdef1234567890",
583
- tableName: "table_ghost",
584
- sourceName: "src",
585
- connectionName: "ghost_conn",
586
- createdAt: new Date(),
587
- updatedAt: new Date(),
588
- },
589
- ];
590
- (ctx.manifestService.listEntries as sinon.SinonStub).resolves(entries);
591
-
592
- const result = await ctx.service.teardownPackage(
593
- "my-environment",
594
- "pkg",
431
+ ctx.repository.getMaterializationById.resolves(
432
+ makeMaterialization({
433
+ status: "MANIFEST_FILE_READY",
434
+ manifest: {
435
+ builtAt: new Date().toISOString(),
436
+ strict: false,
437
+ entries: {
438
+ b1: {
439
+ buildId: "b1",
440
+ physicalTableName: "orders_mz",
441
+ connectionName: "duckdb",
442
+ },
443
+ },
444
+ },
445
+ }),
595
446
  );
596
447
 
597
- expect(result.dropped).toHaveLength(1);
598
- expect(result.dropped[0].targetDropSkipped).toBe(true);
599
- expect(result.errors).toHaveLength(0);
600
- // No DROP on the vanished connection, but the manifest row is gone.
601
- expect(runSQL.called).toBe(false);
448
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
449
+ dropTables: true,
450
+ });
451
+
452
+ expect(getMalloyConnection.calledOnceWith("duckdb")).toBe(true);
453
+ const dropped = runSQL.getCalls().map((c) => c.args[0] as string);
454
+ expect(dropped).toContain("DROP TABLE IF EXISTS orders_mz");
455
+ expect(dropped.some((s) => s.includes("orders_mz_"))).toBe(true);
602
456
  expect(
603
- (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
457
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
604
458
  ).toBe(true);
605
459
  });
606
460
 
607
- it("dryRun reports would-drop entries without running DROP or deleting rows", async () => {
608
- const runSQL = sinon.stub().resolves();
609
- const connection = {
610
- dialectName: "duckdb",
611
- runSQL,
612
- } as unknown as Connection;
613
- const pkg = {
614
- getMalloyConnection: async (name: string): Promise<Connection> => {
615
- if (name === "conn") return connection;
616
- throw new Error(`unknown connection: ${name}`);
617
- },
618
- };
461
+ it("still deletes the record when a table drop fails", async () => {
462
+ const runSQL = sinon.stub().rejects(new Error("boom"));
463
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
619
464
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
620
- getPackage: sinon.stub().resolves(pkg),
465
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
466
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
467
+ fn(),
621
468
  });
622
- ctx.repository.getActiveMaterialization.resolves(null);
623
- const entry: ManifestEntry = {
624
- id: "entry-1",
625
- environmentId: "proj-1",
626
- packageName: "pkg",
627
- buildId: "abcdef1234567890abcdef1234567890",
628
- tableName: "orphan",
629
- sourceName: "src",
630
- connectionName: "conn",
631
- createdAt: new Date(),
632
- updatedAt: new Date(),
633
- };
634
- (ctx.manifestService.listEntries as sinon.SinonStub).resolves([entry]);
635
-
636
- const result = await ctx.service.teardownPackage(
637
- "my-environment",
638
- "pkg",
639
- {
640
- dryRun: true,
641
- },
469
+ ctx.repository.getMaterializationById.resolves(
470
+ makeMaterialization({
471
+ status: "FAILED",
472
+ manifest: {
473
+ builtAt: new Date().toISOString(),
474
+ strict: false,
475
+ entries: {
476
+ b1: {
477
+ buildId: "b1",
478
+ physicalTableName: "orders_mz",
479
+ connectionName: "duckdb",
480
+ },
481
+ },
482
+ },
483
+ }),
642
484
  );
643
485
 
644
- expect(result.dropped).toHaveLength(1);
645
- expect(result.dropped[0].tableName).toBe("orphan");
646
- expect(runSQL.called).toBe(false);
486
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
487
+ dropTables: true,
488
+ });
489
+
647
490
  expect(
648
- (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
649
- ).toBe(false);
491
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
492
+ ).toBe(true);
650
493
  });
651
494
  });
652
495
  });
653
496
 
654
- // ==================== HELPER UNIT TESTS ====================
497
+ describe("stagingSuffix", () => {
498
+ it("derives a short, stable suffix from the buildId", () => {
499
+ expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
500
+ });
501
+ });
655
502
 
656
- describe("manifestTableKey", () => {
657
- it("returns connectionName::tableName", () => {
658
- expect(manifestTableKey("duckdb", "orders")).toBe("duckdb::orders");
503
+ // Characterization (Pass 0): lock in the auto-run instruction-derivation and
504
+ // the single-source build SQL sequence before the simplify pass moves them.
505
+ // deriveSelfInstructions / buildOneSource are private; we exercise them via a
506
+ // typed cast rather than the heavyweight runtime path.
507
+
508
+ // A minimal stand-in for a Malloy PersistSource exposing only what the build
509
+ // internals touch.
510
+ function fakeSource(opts: {
511
+ name: string;
512
+ buildId: string;
513
+ sql?: string;
514
+ connectionName?: string;
515
+ }): unknown {
516
+ return {
517
+ name: opts.name,
518
+ sourceID: opts.name,
519
+ connectionName: opts.connectionName ?? "duckdb",
520
+ makeBuildId: () => opts.buildId,
521
+ getSQL: () => opts.sql ?? "SELECT 1",
522
+ annotations: {
523
+ parseAsTag: () => ({ tag: { text: () => undefined } }),
524
+ },
525
+ };
526
+ }
527
+
528
+ describe("deriveSelfInstructions (characterization)", () => {
529
+ let ctx: ReturnType<typeof createMocks>;
530
+ beforeEach(() => {
531
+ ctx = createMocks();
532
+ });
533
+
534
+ function compiledWith(sources: Record<string, unknown>, levels: string[][]) {
535
+ return {
536
+ graphs: [
537
+ {
538
+ connectionName: "duckdb",
539
+ nodes: levels.map((level) =>
540
+ level.map((sourceID) => ({ sourceID, dependsOn: [] })),
541
+ ),
542
+ },
543
+ ],
544
+ sources,
545
+ connectionDigests: { duckdb: "dig" },
546
+ };
547
+ }
548
+
549
+ it("carries forward unchanged buildIds and builds the rest (deduping repeats)", () => {
550
+ const compiled = compiledWith(
551
+ {
552
+ s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
553
+ s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
554
+ },
555
+ [["s1", "s2"], ["s2"]],
556
+ );
557
+ const priorEntries = {
558
+ b1aaaaaaaaaaaaaa: {
559
+ buildId: "b1aaaaaaaaaaaaaa",
560
+ physicalTableName: "s1_prev",
561
+ connectionName: "duckdb",
562
+ },
563
+ };
564
+
565
+ const { instructions, carried } = (
566
+ ctx.service as unknown as {
567
+ deriveSelfInstructions: (
568
+ c: unknown,
569
+ n: string[] | undefined,
570
+ p: unknown,
571
+ ) => { instructions: BuildInstruction[]; carried: unknown };
572
+ }
573
+ ).deriveSelfInstructions(compiled, undefined, priorEntries);
574
+
575
+ expect(instructions).toHaveLength(1);
576
+ expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
577
+ expect(instructions[0].physicalTableName).toBe("s2");
578
+ expect(instructions[0].realization).toBe("COPY");
579
+ expect(instructions[0].materializedTableId).toBe("local-b2bbbbbbbbbb");
580
+ expect(
581
+ (carried as Record<string, unknown>)["b1aaaaaaaaaaaaaa"],
582
+ ).toBeDefined();
659
583
  });
660
584
 
661
- it("handles schema-qualified table names", () => {
662
- expect(manifestTableKey("pg", "analytics.summary")).toBe(
663
- "pg::analytics.summary",
585
+ it("honors the sourceNames filter (excluded sources are neither built nor carried)", () => {
586
+ const compiled = compiledWith(
587
+ {
588
+ s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
589
+ s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
590
+ },
591
+ [["s1", "s2"]],
664
592
  );
593
+ const { instructions, carried } = (
594
+ ctx.service as unknown as {
595
+ deriveSelfInstructions: (
596
+ c: unknown,
597
+ n: string[] | undefined,
598
+ p: unknown,
599
+ ) => { instructions: BuildInstruction[]; carried: unknown };
600
+ }
601
+ ).deriveSelfInstructions(compiled, ["s2"], {});
602
+
603
+ expect(instructions).toHaveLength(1);
604
+ expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
605
+ expect(Object.keys(carried as Record<string, unknown>)).toHaveLength(0);
665
606
  });
666
607
  });
667
608
 
668
- describe("tablePhysicallyExists", () => {
669
- it("returns true when SELECT succeeds", async () => {
670
- const conn = {
671
- runSQL: sinon.stub().resolves({ rows: [] }),
672
- } as unknown as Connection;
609
+ describe("getMostRecentManifestEntries (skip-if-unchanged)", () => {
610
+ let ctx: ReturnType<typeof createMocks>;
611
+ beforeEach(() => {
612
+ ctx = createMocks();
613
+ });
614
+
615
+ it("returns entries from the most recent successful run, excluding the in-flight one", async () => {
616
+ const entries = {
617
+ b1: {
618
+ buildId: "b1",
619
+ physicalTableName: "orders_v1",
620
+ connectionName: "duckdb",
621
+ },
622
+ };
623
+ ctx.repository.listMaterializations.resolves([
624
+ makeMaterialization({
625
+ id: "in-flight",
626
+ status: "MANIFEST_ROWS_READY",
627
+ }),
628
+ makeMaterialization({
629
+ id: "old-success",
630
+ status: "MANIFEST_FILE_READY",
631
+ manifest: { builtAt: "t", strict: false, entries },
632
+ }),
633
+ ]);
634
+
635
+ const result = await (
636
+ ctx.service as unknown as {
637
+ getMostRecentManifestEntries: (
638
+ e: string,
639
+ p: string,
640
+ x: string,
641
+ ) => Promise<unknown>;
642
+ }
643
+ ).getMostRecentManifestEntries("env-1", "pkg", "in-flight");
644
+
645
+ expect(result).toEqual(entries);
646
+ });
647
+
648
+ it("returns {} when the only successful run is the excluded one", async () => {
649
+ ctx.repository.listMaterializations.resolves([
650
+ makeMaterialization({
651
+ id: "self",
652
+ status: "MANIFEST_FILE_READY",
653
+ manifest: {
654
+ builtAt: "t",
655
+ strict: false,
656
+ entries: { b1: { buildId: "b1", physicalTableName: "t1" } },
657
+ },
658
+ }),
659
+ ]);
660
+
661
+ const result = await (
662
+ ctx.service as unknown as {
663
+ getMostRecentManifestEntries: (
664
+ e: string,
665
+ p: string,
666
+ x: string,
667
+ ) => Promise<unknown>;
668
+ }
669
+ ).getMostRecentManifestEntries("env-1", "pkg", "self");
670
+
671
+ expect(result).toEqual({});
672
+ });
673
+ });
674
+
675
+ describe("stopMaterialization (in-flight)", () => {
676
+ let ctx: ReturnType<typeof createMocks>;
677
+ beforeEach(() => {
678
+ ctx = createMocks();
679
+ });
673
680
 
674
- expect(await tablePhysicallyExists(conn, '"my_table"')).toBe(true);
675
- expect((conn.runSQL as sinon.SinonStub).calledOnce).toBe(true);
676
- expect((conn.runSQL as sinon.SinonStub).firstCall.args[0]).toBe(
677
- 'SELECT 1 FROM "my_table" WHERE 1=0',
681
+ it("aborts a running build cooperatively without forcing a transition", async () => {
682
+ ctx.repository.getMaterializationById.resolves(
683
+ makeMaterialization({ status: "MANIFEST_ROWS_READY" }),
678
684
  );
685
+ const controller = new AbortController();
686
+ (
687
+ ctx.service as unknown as {
688
+ runningAbortControllers: Map<string, AbortController>;
689
+ }
690
+ ).runningAbortControllers.set("mat-1", controller);
691
+
692
+ const result = await ctx.service.stopMaterialization(
693
+ "my-env",
694
+ "pkg",
695
+ "mat-1",
696
+ );
697
+
698
+ expect(controller.signal.aborted).toBe(true);
699
+ // The background run records the terminal state; stop must not also write.
700
+ expect(ctx.repository.updateMaterialization.called).toBe(false);
701
+ expect(result.status).toBe("MANIFEST_ROWS_READY");
679
702
  });
703
+ });
680
704
 
681
- it("returns false when SELECT throws (table does not exist)", async () => {
682
- const conn = {
683
- runSQL: sinon.stub().rejects(new Error("table not found")),
684
- } as unknown as Connection;
705
+ describe("buildOneSource (characterization)", () => {
706
+ let ctx: ReturnType<typeof createMocks>;
707
+ beforeEach(() => {
708
+ ctx = createMocks();
709
+ });
685
710
 
686
- expect(await tablePhysicallyExists(conn, '"missing"')).toBe(false);
711
+ function callBuildOneSource(
712
+ connection: { runSQL: sinon.SinonStub },
713
+ physicalTableName: string,
714
+ ): Promise<{ buildId: string; physicalTableName: string }> {
715
+ const source = fakeSource({
716
+ name: "orders",
717
+ buildId: "abcdef1234567890",
718
+ sql: "SELECT * FROM t",
719
+ });
720
+ const instruction: BuildInstruction = {
721
+ buildId: "abcdef1234567890",
722
+ materializedTableId: "mt-1",
723
+ physicalTableName,
724
+ realization: "COPY",
725
+ };
726
+ return (
727
+ ctx.service as unknown as {
728
+ buildOneSource: (
729
+ s: unknown,
730
+ i: BuildInstruction,
731
+ c: unknown,
732
+ d: Record<string, string>,
733
+ m: Manifest,
734
+ ) => Promise<{ buildId: string; physicalTableName: string }>;
735
+ }
736
+ ).buildOneSource(
737
+ source,
738
+ instruction,
739
+ connection,
740
+ { duckdb: "dig" },
741
+ new Manifest(),
742
+ );
743
+ }
744
+
745
+ it("stages, swaps, and renames in a crash-safe order", async () => {
746
+ const runSQL = sinon.stub().resolves();
747
+ const entry = await callBuildOneSource({ runSQL }, "orders_v1");
748
+
749
+ const sql = runSQL.getCalls().map((c) => c.args[0] as string);
750
+ expect(sql).toEqual([
751
+ "DROP TABLE IF EXISTS orders_v1_abcdef123456",
752
+ "CREATE TABLE orders_v1_abcdef123456 AS (SELECT * FROM t)",
753
+ "DROP TABLE IF EXISTS orders_v1",
754
+ "ALTER TABLE orders_v1_abcdef123456 RENAME TO orders_v1",
755
+ ]);
756
+ expect(entry.physicalTableName).toBe("orders_v1");
757
+ expect(entry.buildId).toBe("abcdef1234567890");
758
+ });
759
+
760
+ it("drops the staging table and rethrows when the build SQL fails", async () => {
761
+ const runSQL = sinon.stub();
762
+ runSQL.onCall(0).resolves(); // initial staging drop
763
+ runSQL.onCall(1).rejects(new Error("create boom")); // CREATE TABLE AS
764
+ runSQL.onCall(2).resolves(); // cleanup staging drop
765
+ await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
766
+ "create boom",
767
+ );
768
+ expect(runSQL.lastCall.args[0]).toBe(
769
+ "DROP TABLE IF EXISTS orders_v1_abcdef123456",
770
+ );
687
771
  });
688
772
  });