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