@malloy-publisher/server 0.0.208 → 0.0.209

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 (39) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
  12. package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
  13. package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +5822 -304
  17. package/package.json +2 -1
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/materialization_metrics.ts +117 -34
  21. package/src/server-old.ts +2 -11
  22. package/src/server.ts +2 -10
  23. package/src/service/build_plan.spec.ts +116 -0
  24. package/src/service/build_plan.ts +238 -0
  25. package/src/service/connection.ts +4 -0
  26. package/src/service/connection_config.spec.ts +182 -1
  27. package/src/service/connection_config.ts +70 -0
  28. package/src/service/db_utils.spec.ts +159 -1
  29. package/src/service/db_utils.ts +131 -0
  30. package/src/service/materialization_service.spec.ts +388 -184
  31. package/src/service/materialization_service.ts +156 -442
  32. package/src/service/materialization_test_fixtures.ts +119 -0
  33. package/src/service/package.ts +41 -1
  34. package/src/storage/DatabaseInterface.ts +5 -13
  35. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  36. package/src/storage/duckdb/schema.ts +4 -5
  37. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  38. package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
  39. package/dist/app/assets/MaterializationsPage-BYnr56IV.js +0 -1
@@ -1,3 +1,4 @@
1
+ import type { Connection as MalloyConnection } from "@malloydata/malloy";
1
2
  import { Manifest } from "@malloydata/malloy";
2
3
  import { beforeEach, describe, expect, it } from "bun:test";
3
4
  import * as sinon from "sinon";
@@ -10,73 +11,22 @@ import {
10
11
  } from "../errors";
11
12
  import {
12
13
  BuildInstruction,
13
- BuildPlan,
14
- Materialization,
15
14
  ResourceRepository,
16
15
  } from "../storage/DatabaseInterface";
17
16
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
18
17
  import { EnvironmentStore } from "./environment_store";
18
+ import {
19
+ compiledWith,
20
+ fakeSource,
21
+ makeBuildPlan,
22
+ makeInstruction,
23
+ makeMaterialization,
24
+ } from "./materialization_test_fixtures";
19
25
  import {
20
26
  MaterializationService,
21
27
  stagingSuffix,
22
28
  } from "./materialization_service";
23
29
 
24
- function makeMaterialization(
25
- overrides: Partial<Materialization> = {},
26
- ): Materialization {
27
- return {
28
- id: "mat-1",
29
- environmentId: "env-1",
30
- packageName: "pkg",
31
- pauseBetweenPhases: false,
32
- status: "PENDING",
33
- buildPlan: null,
34
- manifest: null,
35
- startedAt: null,
36
- completedAt: null,
37
- error: null,
38
- metadata: null,
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",
76
- ...overrides,
77
- };
78
- }
79
-
80
30
  type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
81
31
 
82
32
  function createMocks() {
@@ -99,7 +49,10 @@ function createMocks() {
99
49
  getEnvironment: sandbox.stub(),
100
50
  } as unknown as EnvironmentStore;
101
51
 
102
- // Default: environment resolves and yields a package.
52
+ // Default: environment resolves and yields a package whose build plan is
53
+ // empty (no persist sources). Individual tests override getBuildPlan to
54
+ // exercise the orchestrated path. getModelPaths()=>[] keeps the background
55
+ // build from touching the Malloy runtime.
103
56
  repository.getEnvironmentByName.resolves({
104
57
  id: "env-1",
105
58
  name: "my-env",
@@ -107,17 +60,34 @@ function createMocks() {
107
60
  createdAt: new Date(),
108
61
  updatedAt: new Date(),
109
62
  });
110
- (environmentStore.getEnvironment as sinon.SinonStub).resolves({
111
- getPackage: sinon.stub().resolves({}),
112
- withPackageLock: async (_name: string, fn: () => Promise<unknown>) =>
113
- fn(),
114
- });
63
+ setPackage(environmentStore, { getBuildPlan: () => null });
115
64
 
116
65
  const service = new MaterializationService(environmentStore);
117
66
 
118
67
  return { sandbox, repository, environmentStore, service };
119
68
  }
120
69
 
70
+ /** Point environmentStore.getEnvironment at a package with the given overrides. */
71
+ function setPackage(
72
+ environmentStore: EnvironmentStore,
73
+ pkgOverrides: Record<string, unknown>,
74
+ ): void {
75
+ const pkg = {
76
+ getBuildPlan: () => null,
77
+ getModelPaths: () => [],
78
+ getPackagePath: () => "/test",
79
+ getMalloyConfig: () => ({}),
80
+ getMalloyConnection: async () => ({}),
81
+ ...pkgOverrides,
82
+ };
83
+ (environmentStore.getEnvironment as sinon.SinonStub).resolves({
84
+ getPackage: sinon.stub().resolves(pkg),
85
+ withPackageLock: async (_name: string, fn: () => Promise<unknown>) =>
86
+ fn(),
87
+ reloadAllModelsForPackage: sinon.stub().resolves(),
88
+ });
89
+ }
90
+
121
91
  describe("MaterializationService", () => {
122
92
  let ctx: ReturnType<typeof createMocks>;
123
93
 
@@ -171,8 +141,8 @@ describe("MaterializationService", () => {
171
141
  });
172
142
  });
173
143
 
174
- describe("createMaterialization (Round 1)", () => {
175
- it("creates a PENDING record and persists options", async () => {
144
+ describe("createMaterialization (auto-run)", () => {
145
+ it("creates a PENDING record and persists options with mode=auto", async () => {
176
146
  ctx.repository.getActiveMaterialization.resolves(null);
177
147
  const pending = makeMaterialization({ status: "PENDING" });
178
148
  ctx.repository.createMaterialization.resolves(pending);
@@ -183,7 +153,6 @@ describe("MaterializationService", () => {
183
153
  {
184
154
  forceRefresh: true,
185
155
  sourceNames: ["orders"],
186
- pauseBetweenPhases: true,
187
156
  },
188
157
  );
189
158
 
@@ -195,12 +164,12 @@ describe("MaterializationService", () => {
195
164
  {
196
165
  forceRefresh: true,
197
166
  sourceNames: ["orders"],
198
- pauseBetweenPhases: true,
167
+ mode: "auto",
199
168
  },
200
169
  ]);
201
170
  });
202
171
 
203
- it("defaults pauseBetweenPhases to false (auto-run) in metadata", async () => {
172
+ it("defaults to auto mode in metadata", async () => {
204
173
  ctx.repository.getActiveMaterialization.resolves(null);
205
174
  ctx.repository.createMaterialization.resolves(
206
175
  makeMaterialization({ status: "PENDING" }),
@@ -212,14 +181,17 @@ describe("MaterializationService", () => {
212
181
  {
213
182
  forceRefresh: false,
214
183
  sourceNames: null,
215
- pauseBetweenPhases: false,
184
+ mode: "auto",
216
185
  },
217
186
  );
218
187
  });
219
188
 
220
189
  it("rejects when an active materialization already exists", async () => {
221
190
  ctx.repository.getActiveMaterialization.resolves(
222
- makeMaterialization({ id: "existing", status: "BUILD_PLAN_READY" }),
191
+ makeMaterialization({
192
+ id: "existing",
193
+ status: "MANIFEST_ROWS_READY",
194
+ }),
223
195
  );
224
196
  await expect(
225
197
  ctx.service.createMaterialization("my-env", "pkg"),
@@ -241,89 +213,65 @@ describe("MaterializationService", () => {
241
213
  });
242
214
  });
243
215
 
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
- }),
216
+ describe("createMaterialization (orchestrated)", () => {
217
+ it("creates a PENDING record with mode=orchestrated for valid instructions", async () => {
218
+ setPackage(ctx.environmentStore, {
219
+ getBuildPlan: () => makeBuildPlan(),
220
+ });
221
+ ctx.repository.getActiveMaterialization.resolves(null);
222
+ ctx.repository.createMaterialization.resolves(
223
+ makeMaterialization({ status: "PENDING" }),
252
224
  );
253
- await expect(
254
- ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
255
- makeInstruction(),
256
- ]),
257
- ).rejects.toThrow(InvalidStateTransitionError);
258
- });
259
225
 
260
- it("rejects when not at BUILD_PLAN_READY", async () => {
261
- ctx.repository.getMaterializationById.resolves(
262
- makeMaterialization({
263
- status: "PENDING",
264
- pauseBetweenPhases: true,
265
- }),
226
+ const result = await ctx.service.createMaterialization(
227
+ "my-env",
228
+ "pkg",
229
+ { buildInstructions: [makeInstruction()] },
266
230
  );
267
- await expect(
268
- ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
269
- makeInstruction(),
270
- ]),
271
- ).rejects.toThrow(InvalidStateTransitionError);
231
+
232
+ expect(result.status).toBe("PENDING");
233
+ expect(
234
+ ctx.repository.createMaterialization.firstCall.args[3],
235
+ ).toMatchObject({ mode: "orchestrated" });
272
236
  });
273
237
 
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
- }),
281
- );
238
+ it("rejects instructions referencing an unknown buildId before creating", async () => {
239
+ setPackage(ctx.environmentStore, {
240
+ getBuildPlan: () => makeBuildPlan(),
241
+ });
282
242
  await expect(
283
- ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
284
- makeInstruction({ buildId: "ghost" }),
285
- ]),
243
+ ctx.service.createMaterialization("my-env", "pkg", {
244
+ buildInstructions: [makeInstruction({ buildId: "ghost" })],
245
+ }),
286
246
  ).rejects.toThrow(BadRequestError);
247
+ expect(ctx.repository.createMaterialization.called).toBe(false);
287
248
  });
288
249
 
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
- }),
296
- );
250
+ it("rejects SNAPSHOT realization", async () => {
251
+ setPackage(ctx.environmentStore, {
252
+ getBuildPlan: () => makeBuildPlan(),
253
+ });
297
254
  await expect(
298
- ctx.service.buildMaterialization("my-env", "pkg", "mat-1", [
299
- makeInstruction({ realization: "SNAPSHOT" }),
300
- ]),
255
+ ctx.service.createMaterialization("my-env", "pkg", {
256
+ buildInstructions: [
257
+ makeInstruction({ realization: "SNAPSHOT" }),
258
+ ],
259
+ }),
301
260
  ).rejects.toThrow(BadRequestError);
302
261
  });
303
262
 
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",
313
- "pkg",
314
- "mat-1",
315
- [makeInstruction()],
316
- );
317
- expect(result.id).toBe("mat-1");
263
+ it("rejects buildInstructions when the package has no persist sources", async () => {
264
+ setPackage(ctx.environmentStore, { getBuildPlan: () => null });
265
+ await expect(
266
+ ctx.service.createMaterialization("my-env", "pkg", {
267
+ buildInstructions: [makeInstruction()],
268
+ }),
269
+ ).rejects.toThrow(BadRequestError);
318
270
  });
319
271
  });
320
272
 
321
273
  describe("stopMaterialization", () => {
322
- const cancellable = [
323
- "PENDING",
324
- "BUILD_PLAN_READY",
325
- "MANIFEST_ROWS_READY",
326
- ] as const;
274
+ const cancellable = ["PENDING", "MANIFEST_ROWS_READY"] as const;
327
275
 
328
276
  for (const status of cancellable) {
329
277
  it(`cancels a ${status} materialization`, async () => {
@@ -369,11 +317,7 @@ describe("MaterializationService", () => {
369
317
  });
370
318
  }
371
319
 
372
- const active = [
373
- "PENDING",
374
- "BUILD_PLAN_READY",
375
- "MANIFEST_ROWS_READY",
376
- ] as const;
320
+ const active = ["PENDING", "MANIFEST_ROWS_READY"] as const;
377
321
  for (const status of active) {
378
322
  it(`rejects deleting a ${status} materialization`, async () => {
379
323
  ctx.repository.getMaterializationById.resolves(
@@ -500,52 +444,12 @@ describe("stagingSuffix", () => {
500
444
  });
501
445
  });
502
446
 
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)", () => {
447
+ describe("deriveSelfInstructions", () => {
529
448
  let ctx: ReturnType<typeof createMocks>;
530
449
  beforeEach(() => {
531
450
  ctx = createMocks();
532
451
  });
533
452
 
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
453
  it("carries forward unchanged buildIds and builds the rest (deduping repeats)", () => {
550
454
  const compiled = compiledWith(
551
455
  {
@@ -702,7 +606,98 @@ describe("stopMaterialization (in-flight)", () => {
702
606
  });
703
607
  });
704
608
 
705
- describe("buildOneSource (characterization)", () => {
609
+ describe("executeInstructedBuild", () => {
610
+ let ctx: ReturnType<typeof createMocks>;
611
+ beforeEach(() => {
612
+ ctx = createMocks();
613
+ });
614
+
615
+ function callExecute(
616
+ compiled: unknown,
617
+ instructions: BuildInstruction[],
618
+ seed: Record<string, unknown>,
619
+ ): Promise<Record<string, { physicalTableName?: string }>> {
620
+ return (
621
+ ctx.service as unknown as {
622
+ executeInstructedBuild: (
623
+ c: unknown,
624
+ i: BuildInstruction[],
625
+ s: Record<string, unknown>,
626
+ sig: AbortSignal,
627
+ ) => Promise<Record<string, { physicalTableName?: string }>>;
628
+ }
629
+ ).executeInstructedBuild(
630
+ compiled,
631
+ instructions,
632
+ seed,
633
+ new AbortController().signal,
634
+ );
635
+ }
636
+
637
+ it("builds instructed sources in dependency order and seeds carried entries", async () => {
638
+ const runSQL = sinon.stub().resolves();
639
+ const connection = { runSQL } as unknown as MalloyConnection;
640
+ const s1 = fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" });
641
+ const s2 = fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" });
642
+ const compiled = compiledWith(
643
+ { s1, s2 },
644
+ [["s1"], ["s2"]],
645
+ new Map([["duckdb", connection]]),
646
+ );
647
+
648
+ const entries = await callExecute(
649
+ compiled,
650
+ [
651
+ {
652
+ buildId: "b1aaaaaaaaaaaaaa",
653
+ materializedTableId: "mt-1",
654
+ physicalTableName: "s1_v1",
655
+ realization: "COPY",
656
+ },
657
+ {
658
+ buildId: "b2bbbbbbbbbbbbbb",
659
+ materializedTableId: "mt-2",
660
+ physicalTableName: "s2_v1",
661
+ realization: "COPY",
662
+ },
663
+ ],
664
+ {
665
+ carried0: {
666
+ buildId: "carried0",
667
+ physicalTableName: "carried_tbl",
668
+ connectionName: "duckdb",
669
+ },
670
+ },
671
+ );
672
+
673
+ // Both instructed sources built, plus the carried seed entry retained.
674
+ expect(entries["b1aaaaaaaaaaaaaa"].physicalTableName).toBe("s1_v1");
675
+ expect(entries["b2bbbbbbbbbbbbbb"].physicalTableName).toBe("s2_v1");
676
+ expect(entries["carried0"].physicalTableName).toBe("carried_tbl");
677
+ });
678
+
679
+ it("throws when an instructed graph's connection is missing", async () => {
680
+ const s1 = fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" });
681
+ const compiled = compiledWith({ s1 }, [["s1"]], new Map());
682
+
683
+ await expect(
684
+ callExecute(
685
+ compiled,
686
+ [
687
+ {
688
+ buildId: "b1aaaaaaaaaaaaaa",
689
+ materializedTableId: "mt-1",
690
+ physicalTableName: "s1_v1",
691
+ realization: "COPY",
692
+ },
693
+ ],
694
+ {},
695
+ ),
696
+ ).rejects.toThrow(BadRequestError);
697
+ });
698
+ });
699
+
700
+ describe("buildOneSource", () => {
706
701
  let ctx: ReturnType<typeof createMocks>;
707
702
  beforeEach(() => {
708
703
  ctx = createMocks();
@@ -770,3 +765,212 @@ describe("buildOneSource (characterization)", () => {
770
765
  );
771
766
  });
772
767
  });
768
+
769
+ describe("runBuild (branch behavior)", () => {
770
+ let ctx: ReturnType<typeof createMocks>;
771
+ beforeEach(() => {
772
+ ctx = createMocks();
773
+ });
774
+
775
+ // Exercise runBuild's orchestration in isolation: the build engine and
776
+ // manifest commit are stubbed so the test asserts which instructions flow
777
+ // through and whether the manifest is auto-loaded, not how a source is built.
778
+ type RunBuildInternals = {
779
+ executeInstructedBuild: sinon.SinonStub;
780
+ commitManifest: sinon.SinonStub;
781
+ autoLoadManifest: sinon.SinonStub;
782
+ runBuild: (
783
+ id: string,
784
+ env: string,
785
+ pkg: string,
786
+ opts: {
787
+ sourceNames: string[] | undefined;
788
+ forceRefresh: boolean;
789
+ buildInstructions: BuildInstruction[] | undefined;
790
+ },
791
+ signal: AbortSignal,
792
+ ) => Promise<void>;
793
+ };
794
+
795
+ function stubEngine(): RunBuildInternals {
796
+ const entries = {
797
+ "build-orders": {
798
+ buildId: "build-orders",
799
+ physicalTableName: '"orders_v1"',
800
+ connectionName: "duckdb",
801
+ },
802
+ };
803
+ const svc = ctx.service as unknown as RunBuildInternals;
804
+ svc.executeInstructedBuild = sinon.stub().resolves(entries);
805
+ svc.commitManifest = sinon.stub().resolves();
806
+ svc.autoLoadManifest = sinon.stub().resolves();
807
+ return svc;
808
+ }
809
+
810
+ it("orchestrated: builds caller instructions and does not auto-load", async () => {
811
+ const svc = stubEngine();
812
+ const instructions = [makeInstruction()];
813
+
814
+ await svc.runBuild(
815
+ "mat-1",
816
+ "my-env",
817
+ "pkg",
818
+ {
819
+ sourceNames: undefined,
820
+ forceRefresh: false,
821
+ buildInstructions: instructions,
822
+ },
823
+ new AbortController().signal,
824
+ );
825
+
826
+ expect(svc.executeInstructedBuild.calledOnce).toBe(true);
827
+ // Caller instructions pass straight through; nothing is carried/reused.
828
+ expect(svc.executeInstructedBuild.firstCall.args[1]).toEqual(
829
+ instructions,
830
+ );
831
+ expect(svc.executeInstructedBuild.firstCall.args[2]).toEqual({});
832
+ expect(svc.commitManifest.firstCall.args[2]).toMatchObject({
833
+ mode: "orchestrated",
834
+ sourcesBuilt: 1,
835
+ sourcesReused: 0,
836
+ });
837
+ // Orchestrated leaves distribution to the caller.
838
+ expect(svc.autoLoadManifest.called).toBe(false);
839
+ });
840
+
841
+ it("auto-run: derives instructions and auto-loads the manifest", async () => {
842
+ const svc = stubEngine();
843
+
844
+ await svc.runBuild(
845
+ "mat-1",
846
+ "my-env",
847
+ "pkg",
848
+ {
849
+ sourceNames: undefined,
850
+ forceRefresh: true,
851
+ buildInstructions: undefined,
852
+ },
853
+ new AbortController().signal,
854
+ );
855
+
856
+ expect(svc.commitManifest.firstCall.args[2]).toMatchObject({
857
+ mode: "auto",
858
+ });
859
+ // Auto-run owns distribution: it loads the fresh manifest into the models.
860
+ expect(svc.autoLoadManifest.calledOnce).toBe(true);
861
+ });
862
+ });
863
+
864
+ describe("autoLoadManifest", () => {
865
+ let ctx: ReturnType<typeof createMocks>;
866
+ beforeEach(() => {
867
+ ctx = createMocks();
868
+ });
869
+
870
+ it("loads only entries with a physical table into the package models", async () => {
871
+ const reloadAllModelsForPackage = sinon.stub().resolves();
872
+ const entries = {
873
+ b1: {
874
+ buildId: "b1",
875
+ physicalTableName: "orders_v1",
876
+ connectionName: "duckdb",
877
+ },
878
+ b2: { buildId: "b2", physicalTableName: undefined },
879
+ };
880
+
881
+ await (
882
+ ctx.service as unknown as {
883
+ autoLoadManifest: (
884
+ env: { reloadAllModelsForPackage: sinon.SinonStub },
885
+ pkg: string,
886
+ entries: Record<string, unknown>,
887
+ ) => Promise<void>;
888
+ }
889
+ ).autoLoadManifest({ reloadAllModelsForPackage }, "pkg", entries);
890
+
891
+ expect(reloadAllModelsForPackage.calledOnce).toBe(true);
892
+ expect(reloadAllModelsForPackage.firstCall.args).toEqual([
893
+ "pkg",
894
+ { b1: { tableName: "orders_v1" } },
895
+ ]);
896
+ });
897
+
898
+ it("swallows a reload failure (best-effort; run already succeeded)", async () => {
899
+ const reloadAllModelsForPackage = sinon
900
+ .stub()
901
+ .rejects(new Error("reload boom"));
902
+ await expect(
903
+ (
904
+ ctx.service as unknown as {
905
+ autoLoadManifest: (
906
+ env: { reloadAllModelsForPackage: sinon.SinonStub },
907
+ pkg: string,
908
+ entries: Record<string, unknown>,
909
+ ) => Promise<void>;
910
+ }
911
+ ).autoLoadManifest({ reloadAllModelsForPackage }, "pkg", {
912
+ b1: { buildId: "b1", physicalTableName: "t" },
913
+ }),
914
+ ).resolves.toBeUndefined();
915
+ });
916
+ });
917
+
918
+ describe("runInBackground (terminal recording)", () => {
919
+ let ctx: ReturnType<typeof createMocks>;
920
+ beforeEach(() => {
921
+ ctx = createMocks();
922
+ });
923
+
924
+ const flush = () => new Promise((r) => setTimeout(r, 20));
925
+
926
+ function background(): {
927
+ runInBackground: (
928
+ id: string,
929
+ run: (signal: AbortSignal) => Promise<void>,
930
+ ) => void;
931
+ runningAbortControllers: Map<string, AbortController>;
932
+ } {
933
+ return ctx.service as unknown as {
934
+ runInBackground: (
935
+ id: string,
936
+ run: (signal: AbortSignal) => Promise<void>,
937
+ ) => void;
938
+ runningAbortControllers: Map<string, AbortController>;
939
+ };
940
+ }
941
+
942
+ it("records FAILED with the error message when the run rejects", async () => {
943
+ background().runInBackground("bg-1", async () => {
944
+ throw new Error("boom");
945
+ });
946
+ await flush();
947
+
948
+ expect(ctx.repository.updateMaterialization.calledOnce).toBe(true);
949
+ expect(ctx.repository.updateMaterialization.firstCall.args[0]).toBe(
950
+ "bg-1",
951
+ );
952
+ expect(
953
+ ctx.repository.updateMaterialization.firstCall.args[1],
954
+ ).toMatchObject({ status: "FAILED", error: "boom" });
955
+ });
956
+
957
+ it("records CANCELLED when the run aborts cooperatively", async () => {
958
+ const bg = background();
959
+ bg.runInBackground("bg-2", async () => {
960
+ bg.runningAbortControllers.get("bg-2")!.abort();
961
+ throw new Error("boom");
962
+ });
963
+ await flush();
964
+
965
+ expect(
966
+ ctx.repository.updateMaterialization.firstCall.args[1],
967
+ ).toMatchObject({ status: "CANCELLED", error: "Cancelled" });
968
+ });
969
+
970
+ it("clears the abort controller once the run settles", async () => {
971
+ const bg = background();
972
+ bg.runInBackground("bg-3", async () => {});
973
+ await flush();
974
+ expect(bg.runningAbortControllers.has("bg-3")).toBe(false);
975
+ });
976
+ });