@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.
- package/dist/app/api-doc.yaml +84 -66
- package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
- package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
- package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +5822 -304
- package/package.json +2 -1
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/materialization_metrics.ts +117 -34
- package/src/server-old.ts +2 -11
- package/src/server.ts +2 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/package.ts +41 -1
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
- 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
|
|
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 (
|
|
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
|
-
|
|
167
|
+
mode: "auto",
|
|
199
168
|
},
|
|
200
169
|
]);
|
|
201
170
|
});
|
|
202
171
|
|
|
203
|
-
it("defaults
|
|
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
|
-
|
|
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({
|
|
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("
|
|
245
|
-
it("
|
|
246
|
-
ctx.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
pauseBetweenPhases: true,
|
|
265
|
-
}),
|
|
226
|
+
const result = await ctx.service.createMaterialization(
|
|
227
|
+
"my-env",
|
|
228
|
+
"pkg",
|
|
229
|
+
{ buildInstructions: [makeInstruction()] },
|
|
266
230
|
);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
]
|
|
271
|
-
).
|
|
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
|
|
275
|
-
ctx.
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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
|
|
290
|
-
ctx.
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
299
|
-
|
|
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("
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
+
});
|