@malloy-publisher/server 0.0.204 → 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 (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -1,41 +1,77 @@
1
- import type { Connection } from "@malloydata/malloy";
2
1
  import { beforeEach, describe, expect, it } from "bun:test";
3
2
  import * as sinon from "sinon";
4
3
  import {
4
+ BadRequestError,
5
5
  EnvironmentNotFoundError,
6
6
  InvalidStateTransitionError,
7
7
  MaterializationConflictError,
8
8
  MaterializationNotFoundError,
9
9
  } from "../errors";
10
10
  import {
11
- ManifestEntry,
11
+ BuildInstruction,
12
+ BuildPlan,
12
13
  Materialization,
13
- MaterializationStatus,
14
14
  ResourceRepository,
15
15
  } from "../storage/DatabaseInterface";
16
16
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
17
17
  import { EnvironmentStore } from "./environment_store";
18
- import { ManifestService } from "./manifest_service";
19
18
  import {
20
- manifestTableKey,
21
19
  MaterializationService,
22
- tablePhysicallyExists,
20
+ stagingSuffix,
23
21
  } from "./materialization_service";
24
22
 
25
- function makeExecution(
23
+ function makeMaterialization(
26
24
  overrides: Partial<Materialization> = {},
27
25
  ): Materialization {
28
26
  return {
29
- id: "exec-1",
30
- environmentId: "proj-1",
27
+ id: "mat-1",
28
+ environmentId: "env-1",
31
29
  packageName: "pkg",
30
+ pauseBetweenPhases: false,
32
31
  status: "PENDING",
32
+ buildPlan: null,
33
+ manifest: null,
33
34
  startedAt: null,
34
35
  completedAt: null,
35
36
  error: null,
36
37
  metadata: null,
37
- createdAt: new Date("2025-01-01"),
38
- updatedAt: new Date("2025-01-01"),
38
+ createdAt: new Date("2026-01-01"),
39
+ updatedAt: new Date("2026-01-01"),
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeBuildPlan(overrides: Partial<BuildPlan> = {}): BuildPlan {
45
+ return {
46
+ graphs: [
47
+ {
48
+ connectionName: "duckdb",
49
+ nodes: [[{ sourceID: "orders@m.malloy", dependsOn: [] }]],
50
+ },
51
+ ],
52
+ sources: {
53
+ "orders@m.malloy": {
54
+ name: "orders",
55
+ sourceID: "orders@m.malloy",
56
+ connectionName: "duckdb",
57
+ dialect: "duckdb",
58
+ buildId: "build-orders",
59
+ sql: "SELECT 1",
60
+ columns: [],
61
+ },
62
+ },
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ function makeInstruction(
68
+ overrides: Partial<BuildInstruction> = {},
69
+ ): BuildInstruction {
70
+ return {
71
+ buildId: "build-orders",
72
+ materializedTableId: "mt-1",
73
+ physicalTableName: '"orders_v1"',
74
+ realization: "COPY",
39
75
  ...overrides,
40
76
  };
41
77
  }
@@ -45,69 +81,40 @@ type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
45
81
  function createMocks() {
46
82
  const sandbox = sinon.createSandbox();
47
83
 
48
- const repository: MockRepo = {
49
- listEnvironments: sandbox.stub(),
50
- getEnvironmentById: sandbox.stub(),
84
+ const repository = {
51
85
  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
86
  listMaterializations: sandbox.stub(),
68
87
  getMaterializationById: sandbox.stub(),
69
88
  getActiveMaterialization: sandbox.stub(),
70
89
  createMaterialization: sandbox.stub(),
71
90
  updateMaterialization: sandbox.stub(),
72
91
  deleteMaterialization: sandbox.stub(),
73
- listManifestEntries: sandbox.stub(),
74
- upsertManifestEntry: sandbox.stub(),
75
- deleteManifestEntry: sandbox.stub(),
76
92
  } as unknown as MockRepo;
77
93
 
78
- const storageManager = {
79
- getRepository: () => repository,
80
- getManifestStore: sandbox.stub(),
81
- };
94
+ const storageManager = { getRepository: () => repository };
82
95
 
83
96
  const environmentStore = {
84
97
  storageManager,
85
98
  getEnvironment: sandbox.stub(),
86
99
  } as unknown as EnvironmentStore;
87
100
 
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
101
+ // Default: environment resolves and yields a package.
102
102
  repository.getEnvironmentByName.resolves({
103
- id: "proj-1",
104
- name: "my-environment",
103
+ id: "env-1",
104
+ name: "my-env",
105
105
  path: "/test",
106
106
  createdAt: new Date(),
107
107
  updatedAt: new Date(),
108
108
  });
109
+ (environmentStore.getEnvironment as sinon.SinonStub).resolves({
110
+ getPackage: sinon.stub().resolves({}),
111
+ withPackageLock: async (_name: string, fn: () => Promise<unknown>) =>
112
+ fn(),
113
+ });
109
114
 
110
- return { sandbox, repository, environmentStore, manifestService, service };
115
+ const service = new MaterializationService(environmentStore);
116
+
117
+ return { sandbox, repository, environmentStore, service };
111
118
  }
112
119
 
113
120
  describe("MaterializationService", () => {
@@ -117,572 +124,377 @@ describe("MaterializationService", () => {
117
124
  ctx = createMocks();
118
125
  });
119
126
 
120
- // ==================== resolveEnvironmentId ====================
121
-
122
- describe("resolveEnvironmentId (via listMaterializations)", () => {
123
- it("should throw EnvironmentNotFoundError when environment is not in DB", async () => {
127
+ describe("resolveEnvironmentId", () => {
128
+ it("throws EnvironmentNotFoundError when the environment is missing", async () => {
124
129
  ctx.repository.getEnvironmentByName.resolves(null);
125
-
126
130
  await expect(
127
131
  ctx.service.listMaterializations("unknown", "pkg"),
128
132
  ).rejects.toThrow(EnvironmentNotFoundError);
129
133
  });
130
134
  });
131
135
 
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);
136
+ describe("queries", () => {
137
+ it("lists materializations for a package", async () => {
138
+ const rows = [
139
+ makeMaterialization(),
140
+ makeMaterialization({ id: "m2" }),
141
+ ];
142
+ ctx.repository.listMaterializations.resolves(rows);
143
+ expect(
144
+ await ctx.service.listMaterializations("my-env", "pkg"),
145
+ ).toEqual(rows);
145
146
  });
146
- });
147
-
148
- describe("getMaterialization", () => {
149
- it("should return a specific build", async () => {
150
- const exec = makeExecution();
151
- ctx.repository.getMaterializationById.resolves(exec);
152
147
 
153
- const result = await ctx.service.getMaterialization(
154
- "my-environment",
155
- "pkg",
156
- "exec-1",
157
- );
158
-
159
- expect(result).toEqual(exec);
148
+ it("returns a specific materialization", async () => {
149
+ const m = makeMaterialization();
150
+ ctx.repository.getMaterializationById.resolves(m);
151
+ expect(
152
+ await ctx.service.getMaterialization("my-env", "pkg", "mat-1"),
153
+ ).toEqual(m);
160
154
  });
161
155
 
162
- it("should throw when build not found", async () => {
156
+ it("throws when the materialization is missing", async () => {
163
157
  ctx.repository.getMaterializationById.resolves(null);
164
-
165
158
  await expect(
166
- ctx.service.getMaterialization("my-environment", "pkg", "missing"),
159
+ ctx.service.getMaterialization("my-env", "pkg", "nope"),
167
160
  ).rejects.toThrow(MaterializationNotFoundError);
168
161
  });
169
162
 
170
- it("should throw when build belongs to a different package", async () => {
163
+ it("throws when the materialization belongs to another package", async () => {
171
164
  ctx.repository.getMaterializationById.resolves(
172
- makeExecution({ packageName: "other-pkg" }),
165
+ makeMaterialization({ packageName: "other" }),
173
166
  );
174
-
175
167
  await expect(
176
- ctx.service.getMaterialization("my-environment", "pkg", "exec-1"),
168
+ ctx.service.getMaterialization("my-env", "pkg", "mat-1"),
177
169
  ).rejects.toThrow(MaterializationNotFoundError);
178
170
  });
179
171
  });
180
172
 
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
- });
173
+ describe("createMaterialization (Round 1)", () => {
174
+ it("creates a PENDING record and persists options", async () => {
248
175
  ctx.repository.getActiveMaterialization.resolves(null);
249
- const pending = makeExecution({
250
- status: "PENDING",
251
- metadata: { forceRefresh: false, autoLoadManifest: true },
252
- });
176
+ const pending = makeMaterialization({ status: "PENDING" });
253
177
  ctx.repository.createMaterialization.resolves(pending);
254
178
 
255
179
  const result = await ctx.service.createMaterialization(
256
- "my-environment",
180
+ "my-env",
257
181
  "pkg",
258
182
  {
259
- autoLoadManifest: true,
183
+ forceRefresh: true,
184
+ sourceNames: ["orders"],
185
+ pauseBetweenPhases: true,
260
186
  },
261
187
  );
262
188
 
263
189
  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
190
  expect(ctx.repository.createMaterialization.firstCall.args).toEqual([
270
- "proj-1",
191
+ "env-1",
271
192
  "pkg",
272
193
  "PENDING",
273
- { forceRefresh: false, autoLoadManifest: true },
194
+ {
195
+ forceRefresh: true,
196
+ sourceNames: ["orders"],
197
+ pauseBetweenPhases: true,
198
+ },
274
199
  ]);
275
- expect(ctx.repository.updateMaterialization.called).toBe(false);
276
200
  });
277
201
 
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" }),
202
+ it("defaults pauseBetweenPhases to false (auto-run) in metadata", async () => {
203
+ ctx.repository.getActiveMaterialization.resolves(null);
204
+ ctx.repository.createMaterialization.resolves(
205
+ makeMaterialization({ status: "PENDING" }),
284
206
  );
285
207
 
208
+ await ctx.service.createMaterialization("my-env", "pkg", {});
209
+
210
+ expect(ctx.repository.createMaterialization.firstCall.args[3]).toEqual(
211
+ {
212
+ forceRefresh: false,
213
+ sourceNames: null,
214
+ pauseBetweenPhases: false,
215
+ },
216
+ );
217
+ });
218
+
219
+ it("rejects when an active materialization already exists", async () => {
220
+ ctx.repository.getActiveMaterialization.resolves(
221
+ makeMaterialization({ id: "existing", status: "BUILD_PLAN_READY" }),
222
+ );
286
223
  await expect(
287
- ctx.service.createMaterialization("my-environment", "pkg"),
224
+ ctx.service.createMaterialization("my-env", "pkg"),
288
225
  ).rejects.toThrow(MaterializationConflictError);
289
226
  });
290
227
 
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.
228
+ it("translates a lost create race into a conflict", async () => {
298
229
  ctx.repository.getActiveMaterialization
299
230
  .onFirstCall()
300
231
  .resolves(null)
301
232
  .onSecondCall()
302
- .resolves(makeExecution({ id: "winner", status: "PENDING" }));
233
+ .resolves(makeMaterialization({ id: "winner" }));
303
234
  ctx.repository.createMaterialization.rejects(
304
- new DuplicateActiveMaterializationError("proj-1", "pkg"),
235
+ new DuplicateActiveMaterializationError("env-1", "pkg"),
305
236
  );
306
-
307
237
  await expect(
308
- ctx.service.createMaterialization("my-environment", "pkg"),
238
+ ctx.service.createMaterialization("my-env", "pkg"),
309
239
  ).rejects.toThrow(/winner/);
310
240
  });
311
241
  });
312
242
 
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",
243
+ describe("buildMaterialization (Round 2)", () => {
244
+ it("rejects action=build on an auto-run materialization", async () => {
245
+ ctx.repository.getMaterializationById.resolves(
246
+ makeMaterialization({
247
+ status: "BUILD_PLAN_READY",
248
+ pauseBetweenPhases: false,
249
+ buildPlan: makeBuildPlan(),
250
+ }),
328
251
  );
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
252
  await expect(
338
- ctx.service.startMaterialization("my-environment", "pkg", "exec-1"),
253
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
254
+ makeInstruction(),
255
+ ]),
339
256
  ).rejects.toThrow(InvalidStateTransitionError);
340
257
  });
341
258
 
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",
259
+ it("rejects when not at BUILD_PLAN_READY", async () => {
260
+ ctx.repository.getMaterializationById.resolves(
261
+ makeMaterialization({
262
+ status: "PENDING",
263
+ pauseBetweenPhases: true,
264
+ }),
386
265
  );
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
266
  await expect(
396
- ctx.service.stopMaterialization("my-environment", "pkg", "exec-1"),
267
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
268
+ makeInstruction(),
269
+ ]),
397
270
  ).rejects.toThrow(InvalidStateTransitionError);
398
271
  });
399
- });
400
-
401
- // ==================== DELETE ====================
402
-
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
272
 
415
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
416
- expect(ctx.repository.deleteMaterialization.firstCall.args[0]).toBe(
417
- "exec-1",
273
+ it("rejects instructions that reference an unknown buildId", async () => {
274
+ ctx.repository.getMaterializationById.resolves(
275
+ makeMaterialization({
276
+ status: "BUILD_PLAN_READY",
277
+ pauseBetweenPhases: true,
278
+ buildPlan: makeBuildPlan(),
279
+ }),
418
280
  );
281
+ await expect(
282
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
283
+ makeInstruction({ buildId: "ghost" }),
284
+ ]),
285
+ ).rejects.toThrow(BadRequestError);
419
286
  });
420
287
 
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",
288
+ it("rejects SNAPSHOT when the connection does not support it", async () => {
289
+ ctx.repository.getMaterializationById.resolves(
290
+ makeMaterialization({
291
+ status: "BUILD_PLAN_READY",
292
+ pauseBetweenPhases: true,
293
+ buildPlan: makeBuildPlan(),
294
+ }),
430
295
  );
431
-
432
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
296
+ await expect(
297
+ ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
298
+ makeInstruction({ realization: "SNAPSHOT" }),
299
+ ]),
300
+ ).rejects.toThrow(BadRequestError);
433
301
  });
434
302
 
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",
303
+ it("accepts a valid build and returns the record (202)", async () => {
304
+ const m = makeMaterialization({
305
+ status: "BUILD_PLAN_READY",
306
+ pauseBetweenPhases: true,
307
+ buildPlan: makeBuildPlan(),
308
+ });
309
+ ctx.repository.getMaterializationById.resolves(m);
310
+ const result = await ctx.service.buildMaterialization(
311
+ "my-env",
442
312
  "pkg",
443
- "exec-1",
313
+ "mat-1",
314
+ [makeInstruction()],
444
315
  );
445
-
446
- expect(ctx.repository.deleteMaterialization.calledOnce).toBe(true);
316
+ expect(result.id).toBe("mat-1");
447
317
  });
318
+ });
448
319
 
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",
320
+ describe("stopMaterialization", () => {
321
+ const cancellable = [
322
+ "PENDING",
323
+ "BUILD_PLAN_READY",
324
+ "MANIFEST_ROWS_READY",
325
+ ] as const;
326
+
327
+ for (const status of cancellable) {
328
+ it(`cancels a ${status} materialization`, async () => {
329
+ ctx.repository.getMaterializationById.resolves(
330
+ makeMaterialization({ status }),
331
+ );
332
+ ctx.repository.updateMaterialization.resolves(
333
+ makeMaterialization({ status: "CANCELLED" }),
334
+ );
335
+ const result = await ctx.service.stopMaterialization(
336
+ "my-env",
469
337
  "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);
338
+ "mat-1",
339
+ );
340
+ expect(result.status).toBe("CANCELLED");
341
+ });
342
+ }
477
343
 
478
- await expect(
479
- ctx.service.deleteMaterialization(
480
- "my-environment",
481
- "pkg",
482
- "missing",
483
- ),
484
- ).rejects.toThrow(MaterializationNotFoundError);
485
- });
344
+ const terminal = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"] as const;
345
+ for (const status of terminal) {
346
+ it(`rejects stopping a ${status} materialization`, async () => {
347
+ ctx.repository.getMaterializationById.resolves(
348
+ makeMaterialization({ status }),
349
+ );
350
+ await expect(
351
+ ctx.service.stopMaterialization("my-env", "pkg", "mat-1"),
352
+ ).rejects.toThrow(InvalidStateTransitionError);
353
+ });
354
+ }
486
355
  });
487
356
 
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
- );
357
+ describe("deleteMaterialization", () => {
358
+ const terminal = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"] as const;
359
+ for (const status of terminal) {
360
+ it(`deletes a ${status} materialization`, async () => {
361
+ ctx.repository.getMaterializationById.resolves(
362
+ makeMaterialization({ status }),
363
+ );
364
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
365
+ expect(
366
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
367
+ ).toBe(true);
368
+ });
369
+ }
495
370
 
496
- await expect(
497
- ctx.service.teardownPackage("my-environment", "pkg"),
498
- ).rejects.toThrow(MaterializationConflictError);
499
- });
371
+ const active = [
372
+ "PENDING",
373
+ "BUILD_PLAN_READY",
374
+ "MANIFEST_ROWS_READY",
375
+ ] as const;
376
+ for (const status of active) {
377
+ it(`rejects deleting a ${status} materialization`, async () => {
378
+ ctx.repository.getMaterializationById.resolves(
379
+ makeMaterialization({ status }),
380
+ );
381
+ await expect(
382
+ ctx.service.deleteMaterialization("my-env", "pkg", "mat-1"),
383
+ ).rejects.toThrow(InvalidStateTransitionError);
384
+ expect(ctx.repository.deleteMaterialization.called).toBe(false);
385
+ });
386
+ }
500
387
 
501
- it("drops every manifest entry for the package and deletes its row", async () => {
388
+ it("does not drop tables by default (record-only delete)", async () => {
502
389
  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
- };
390
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
513
391
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
514
- getPackage: sinon.stub().resolves(pkg),
392
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
393
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
394
+ fn(),
515
395
  });
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",
396
+ ctx.repository.getMaterializationById.resolves(
397
+ makeMaterialization({
398
+ status: "MANIFEST_FILE_READY",
399
+ manifest: {
400
+ builtAt: new Date().toISOString(),
401
+ strict: false,
402
+ entries: {
403
+ b1: {
404
+ buildId: "b1",
405
+ physicalTableName: "orders_mz",
406
+ connectionName: "duckdb",
407
+ },
408
+ },
409
+ },
410
+ }),
546
411
  );
547
412
 
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);
413
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
414
+
415
+ expect(getMalloyConnection.called).toBe(false);
416
+ expect(runSQL.called).toBe(false);
552
417
  expect(
553
- (ctx.manifestService.deleteEntry as sinon.SinonStub).callCount,
554
- ).toBe(2);
418
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
419
+ ).toBe(true);
555
420
  });
556
421
 
557
- it("deletes rows whose connection is no longer registered (teardown force-delete)", async () => {
422
+ it("drops manifest tables (and staging) when dropTables is set", async () => {
558
423
  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
- };
424
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
573
425
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
574
- getPackage: sinon.stub().resolves(pkg),
426
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
427
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
428
+ fn(),
575
429
  });
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",
430
+ ctx.repository.getMaterializationById.resolves(
431
+ makeMaterialization({
432
+ status: "MANIFEST_FILE_READY",
433
+ manifest: {
434
+ builtAt: new Date().toISOString(),
435
+ strict: false,
436
+ entries: {
437
+ b1: {
438
+ buildId: "b1",
439
+ physicalTableName: "orders_mz",
440
+ connectionName: "duckdb",
441
+ },
442
+ },
443
+ },
444
+ }),
595
445
  );
596
446
 
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);
447
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
448
+ dropTables: true,
449
+ });
450
+
451
+ expect(getMalloyConnection.calledOnceWith("duckdb")).toBe(true);
452
+ const dropped = runSQL.getCalls().map((c) => c.args[0] as string);
453
+ expect(dropped).toContain("DROP TABLE IF EXISTS orders_mz");
454
+ expect(dropped.some((s) => s.includes("orders_mz_"))).toBe(true);
602
455
  expect(
603
- (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
456
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
604
457
  ).toBe(true);
605
458
  });
606
459
 
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
- };
460
+ it("still deletes the record when a table drop fails", async () => {
461
+ const runSQL = sinon.stub().rejects(new Error("boom"));
462
+ const getMalloyConnection = sinon.stub().resolves({ runSQL });
619
463
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
620
- getPackage: sinon.stub().resolves(pkg),
464
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
465
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
466
+ fn(),
621
467
  });
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
- },
468
+ ctx.repository.getMaterializationById.resolves(
469
+ makeMaterialization({
470
+ status: "FAILED",
471
+ manifest: {
472
+ builtAt: new Date().toISOString(),
473
+ strict: false,
474
+ entries: {
475
+ b1: {
476
+ buildId: "b1",
477
+ physicalTableName: "orders_mz",
478
+ connectionName: "duckdb",
479
+ },
480
+ },
481
+ },
482
+ }),
642
483
  );
643
484
 
644
- expect(result.dropped).toHaveLength(1);
645
- expect(result.dropped[0].tableName).toBe("orphan");
646
- expect(runSQL.called).toBe(false);
485
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
486
+ dropTables: true,
487
+ });
488
+
647
489
  expect(
648
- (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
649
- ).toBe(false);
490
+ ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
491
+ ).toBe(true);
650
492
  });
651
493
  });
652
494
  });
653
495
 
654
- // ==================== HELPER UNIT TESTS ====================
655
-
656
- describe("manifestTableKey", () => {
657
- it("returns connectionName::tableName", () => {
658
- expect(manifestTableKey("duckdb", "orders")).toBe("duckdb::orders");
659
- });
660
-
661
- it("handles schema-qualified table names", () => {
662
- expect(manifestTableKey("pg", "analytics.summary")).toBe(
663
- "pg::analytics.summary",
664
- );
665
- });
666
- });
667
-
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;
673
-
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',
678
- );
679
- });
680
-
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;
685
-
686
- expect(await tablePhysicallyExists(conn, '"missing"')).toBe(false);
496
+ describe("stagingSuffix", () => {
497
+ it("derives a short, stable suffix from the buildId", () => {
498
+ expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
687
499
  });
688
500
  });