@malloy-publisher/server 0.0.231 → 0.0.233
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/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +22627 -1260
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -3,6 +3,7 @@ import { Manifest } from "@malloydata/malloy";
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
4
|
import * as sinon from "sinon";
|
|
5
5
|
import {
|
|
6
|
+
MaterializationEligibilityError,
|
|
6
7
|
BadRequestError,
|
|
7
8
|
EnvironmentNotFoundError,
|
|
8
9
|
InvalidStateTransitionError,
|
|
@@ -24,9 +25,12 @@ import {
|
|
|
24
25
|
makeMaterialization,
|
|
25
26
|
} from "./materialization_test_fixtures";
|
|
26
27
|
import {
|
|
28
|
+
manifestExcludingStorage,
|
|
27
29
|
MaterializationService,
|
|
30
|
+
redactConnectionSecrets,
|
|
28
31
|
stagingSuffix,
|
|
29
32
|
} from "./materialization_service";
|
|
33
|
+
import { logger } from "../logger";
|
|
30
34
|
import { resetMaterializationTelemetryForTesting } from "../materialization_metrics";
|
|
31
35
|
import {
|
|
32
36
|
startMetricsHarness,
|
|
@@ -35,6 +39,144 @@ import {
|
|
|
35
39
|
|
|
36
40
|
type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
|
|
37
41
|
|
|
42
|
+
describe("redactConnectionSecrets", () => {
|
|
43
|
+
it("strips credential values but keeps the message legible", () => {
|
|
44
|
+
const source = {
|
|
45
|
+
name: "orders_pg",
|
|
46
|
+
type: "postgres",
|
|
47
|
+
postgresConnection: {
|
|
48
|
+
host: "db.internal",
|
|
49
|
+
userName: "svc",
|
|
50
|
+
password: "sup3r-secret-pw",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const msg =
|
|
54
|
+
"IO Error: could not connect password=sup3r-secret-pw to db.internal";
|
|
55
|
+
const out = redactConnectionSecrets(msg, source);
|
|
56
|
+
expect(out).not.toContain("sup3r-secret-pw");
|
|
57
|
+
expect(out).toContain("could not connect");
|
|
58
|
+
expect(out).toContain("db.internal"); // host isn't a secret — stays
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("passes a credential-free error (e.g. schema not found) through verbatim", () => {
|
|
62
|
+
const source = {
|
|
63
|
+
name: "lake",
|
|
64
|
+
type: "ducklake",
|
|
65
|
+
ducklakeConnection: {
|
|
66
|
+
catalog: { postgresConnection: { password: "catpw123" } },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const msg =
|
|
70
|
+
"Catalog Error: Schema 'analytics' not found in DuckLake 'lake'";
|
|
71
|
+
expect(redactConnectionSecrets(msg, source)).toBe(msg);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("redacts a secret in its SQL-escaped form (single quotes doubled)", () => {
|
|
75
|
+
// A DuckDB error echoes the offending statement, in which a secret with a
|
|
76
|
+
// single quote appears escaped (`'` -> `''`), so the raw value won't match.
|
|
77
|
+
const source = {
|
|
78
|
+
name: "pg",
|
|
79
|
+
type: "postgres",
|
|
80
|
+
postgresConnection: { password: "pa'ss'word" },
|
|
81
|
+
};
|
|
82
|
+
const msg =
|
|
83
|
+
"Parser Error: syntax error near 'password=pa''ss''word host=h'";
|
|
84
|
+
const out = redactConnectionSecrets(msg, source);
|
|
85
|
+
expect(out).not.toContain("pa''ss''word");
|
|
86
|
+
expect(out).toContain("***");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Stubbed at the seam because the leak needs an ATTACH (not CTAS) failure:
|
|
90
|
+
// bad catalog creds fast-fail at connection validation, so no black-box test
|
|
91
|
+
// can reach this branch.
|
|
92
|
+
it("redacts an INFRA chained-build failure, and does not call it a refusal", async () => {
|
|
93
|
+
const sandbox = sinon.createSandbox();
|
|
94
|
+
try {
|
|
95
|
+
const destinationConnection = {
|
|
96
|
+
name: "lake",
|
|
97
|
+
type: "ducklake",
|
|
98
|
+
ducklakeConnection: {
|
|
99
|
+
catalog: {
|
|
100
|
+
postgresConnection: {
|
|
101
|
+
host: "catalog.internal",
|
|
102
|
+
userName: "lake_rw",
|
|
103
|
+
password: "c4talog-pw",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const sourceConnection = {
|
|
109
|
+
name: "orders_pg",
|
|
110
|
+
type: "postgres",
|
|
111
|
+
postgresConnection: { host: "db.internal", password: "src-pw-99" },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// A failed RW ATTACH echoes the statement, connstring included.
|
|
115
|
+
sandbox
|
|
116
|
+
.stub(
|
|
117
|
+
MaterializationService.prototype as unknown as Record<
|
|
118
|
+
string,
|
|
119
|
+
unknown
|
|
120
|
+
>,
|
|
121
|
+
"buildDownstreamViaParents" as never,
|
|
122
|
+
)
|
|
123
|
+
.rejects(
|
|
124
|
+
new Error(
|
|
125
|
+
"IO Error: failed to ATTACH 'ducklake:postgres:host=catalog.internal " +
|
|
126
|
+
"user=lake_rw password=c4talog-pw' (db lake)",
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const { service } = createMocks();
|
|
131
|
+
const environment = {
|
|
132
|
+
getApiConnection: (name: string) =>
|
|
133
|
+
name === "lake" ? destinationConnection : sourceConnection,
|
|
134
|
+
getEnvironmentPath: () => "/tmp/env",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const call = (
|
|
138
|
+
service as unknown as {
|
|
139
|
+
buildOneSourceIntoStorage: (...a: unknown[]) => Promise<unknown>;
|
|
140
|
+
}
|
|
141
|
+
).buildOneSourceIntoStorage(
|
|
142
|
+
{ name: "orders_by_month", connectionName: "orders_pg" },
|
|
143
|
+
{
|
|
144
|
+
sourceEntityId: "sid-1",
|
|
145
|
+
physicalTableName: "mz_orders_by_month",
|
|
146
|
+
destination: "lake",
|
|
147
|
+
},
|
|
148
|
+
// strict: throw instead of falling through to recompute-from-raw.
|
|
149
|
+
{ strict: true, update: () => {} },
|
|
150
|
+
environment,
|
|
151
|
+
"SELECT 1",
|
|
152
|
+
{},
|
|
153
|
+
true, // dependsOnStorageUpstream — take the chained path
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// A failed ATTACH is infrastructure, not a shape limit: it must NOT
|
|
157
|
+
// present as the strict-upstreams refusal (that message means "we could
|
|
158
|
+
// have recomputed from raw but you forbade it", which is not what
|
|
159
|
+
// happened here).
|
|
160
|
+
await expect(call).rejects.toThrow(
|
|
161
|
+
/Failed to materialize chained source/i,
|
|
162
|
+
);
|
|
163
|
+
await expect(call).rejects.not.toThrow(/strict upstreams forbid/i);
|
|
164
|
+
const message = await call.then(
|
|
165
|
+
() => "",
|
|
166
|
+
(e: unknown) => (e instanceof Error ? e.message : String(e)),
|
|
167
|
+
);
|
|
168
|
+
expect(message).not.toContain("c4talog-pw");
|
|
169
|
+
expect(message).toContain("***");
|
|
170
|
+
// Redacted but still legible.
|
|
171
|
+
expect(message).toContain("failed to ATTACH");
|
|
172
|
+
expect(message).toContain("catalog.internal");
|
|
173
|
+
expect(message).toContain("orders_by_month");
|
|
174
|
+
} finally {
|
|
175
|
+
sandbox.restore();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
38
180
|
function createMocks() {
|
|
39
181
|
const sandbox = sinon.createSandbox();
|
|
40
182
|
|
|
@@ -84,6 +226,8 @@ function setPackage(
|
|
|
84
226
|
getPackagePath: () => "/test",
|
|
85
227
|
getMalloyConfig: () => ({}),
|
|
86
228
|
getMalloyConnection: async () => ({}),
|
|
229
|
+
// No bound manifestLocation by default, so seedFromBoundManifest no-ops.
|
|
230
|
+
getPackageMetadata: () => ({}),
|
|
87
231
|
...pkgOverrides,
|
|
88
232
|
};
|
|
89
233
|
(environmentStore.getEnvironment as sinon.SinonStub).resolves({
|
|
@@ -110,6 +254,231 @@ describe("MaterializationService", () => {
|
|
|
110
254
|
});
|
|
111
255
|
});
|
|
112
256
|
|
|
257
|
+
describe("deleteMaterialization drop of a storage= table", () => {
|
|
258
|
+
it("routes a storage entry to a destination-aware RW drop (best-effort, delete still completes)", async () => {
|
|
259
|
+
// getApiConnection returns a non-storage type so the RW drop refuses
|
|
260
|
+
// at its destination gate (pre-session), exercising the routing + the
|
|
261
|
+
// best-effort failure handling without needing a live DuckDB attach.
|
|
262
|
+
const getApiConnection = sinon
|
|
263
|
+
.stub()
|
|
264
|
+
.returns({ name: "lake", type: "postgres" });
|
|
265
|
+
(ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
|
|
266
|
+
getPackage: sinon
|
|
267
|
+
.stub()
|
|
268
|
+
.resolves({ getMalloyConnection: async () => ({}) }),
|
|
269
|
+
getApiConnection,
|
|
270
|
+
getEnvironmentPath: () => "/test",
|
|
271
|
+
});
|
|
272
|
+
ctx.repository.getMaterializationById.resolves(
|
|
273
|
+
makeMaterialization({
|
|
274
|
+
status: "MANIFEST_FILE_READY",
|
|
275
|
+
manifest: {
|
|
276
|
+
entries: {
|
|
277
|
+
se1: {
|
|
278
|
+
sourceEntityId: "se1",
|
|
279
|
+
sourceName: "daily",
|
|
280
|
+
physicalTableName: "daily__mabc123",
|
|
281
|
+
connectionName: "wh",
|
|
282
|
+
storageConnectionName: "lake",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
286
|
+
} as any,
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
291
|
+
dropTables: true,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Routed to the storage path (resolved the destination connection for
|
|
295
|
+
// an RW drop, not the old skip), and the delete completed despite the
|
|
296
|
+
// drop's best-effort failure.
|
|
297
|
+
expect(getApiConnection.calledWith("lake")).toBe(true);
|
|
298
|
+
expect(
|
|
299
|
+
(
|
|
300
|
+
ctx.repository.deleteMaterialization as sinon.SinonStub
|
|
301
|
+
).calledWith("mat-1"),
|
|
302
|
+
).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("skips the drop when another MANIFEST_FILE_READY run still references the table (MED-3)", async () => {
|
|
306
|
+
// name=-verbatim: generations of a source share one physical name, so
|
|
307
|
+
// dropping a superseded record's table must NOT take out the table a
|
|
308
|
+
// remaining run still serves.
|
|
309
|
+
const getApiConnection = sinon
|
|
310
|
+
.stub()
|
|
311
|
+
.returns({ name: "lake", type: "duckdb" });
|
|
312
|
+
(ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
|
|
313
|
+
getPackage: sinon
|
|
314
|
+
.stub()
|
|
315
|
+
.resolves({ getMalloyConnection: async () => ({}) }),
|
|
316
|
+
getApiConnection,
|
|
317
|
+
getEnvironmentPath: () => "/test",
|
|
318
|
+
});
|
|
319
|
+
const entries = {
|
|
320
|
+
se1: {
|
|
321
|
+
sourceEntityId: "se1",
|
|
322
|
+
sourceName: "daily",
|
|
323
|
+
physicalTableName: "daily", // shared logical name
|
|
324
|
+
connectionName: "wh",
|
|
325
|
+
storageConnectionName: "lake",
|
|
326
|
+
},
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
328
|
+
} as any;
|
|
329
|
+
ctx.repository.getMaterializationById.resolves(
|
|
330
|
+
makeMaterialization({
|
|
331
|
+
id: "mat-1",
|
|
332
|
+
status: "MANIFEST_FILE_READY",
|
|
333
|
+
manifest: { entries },
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
// Another remaining successful run references the SAME lake.daily table.
|
|
337
|
+
ctx.repository.listMaterializations.resolves([
|
|
338
|
+
makeMaterialization({ id: "mat-1", manifest: { entries } }),
|
|
339
|
+
makeMaterialization({
|
|
340
|
+
id: "mat-2",
|
|
341
|
+
status: "MANIFEST_FILE_READY",
|
|
342
|
+
manifest: { entries },
|
|
343
|
+
}),
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
347
|
+
dropTables: true,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// The drop was skipped (never resolved the destination connection), and
|
|
351
|
+
// the record was still deleted.
|
|
352
|
+
expect(getApiConnection.called).toBe(false);
|
|
353
|
+
expect(
|
|
354
|
+
(
|
|
355
|
+
ctx.repository.deleteMaterialization as sinon.SinonStub
|
|
356
|
+
).calledWith("mat-1"),
|
|
357
|
+
).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("deleteMaterialization rebinds serve bindings (both tiers)", () => {
|
|
362
|
+
const storageEntry = {
|
|
363
|
+
se1: {
|
|
364
|
+
sourceEntityId: "se1",
|
|
365
|
+
sourceName: "daily",
|
|
366
|
+
physicalTableName: "daily__mabc",
|
|
367
|
+
connectionName: "wh",
|
|
368
|
+
storageConnectionName: "lake",
|
|
369
|
+
schema: [{ name: "a", type: "BIGINT" }],
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
// A colocated entry carries NO storageConnectionName.
|
|
373
|
+
const colocatedEntry = {
|
|
374
|
+
ce1: {
|
|
375
|
+
sourceEntityId: "ce1",
|
|
376
|
+
sourceName: "daily",
|
|
377
|
+
physicalTableName: "daily__cabc",
|
|
378
|
+
connectionName: "wh",
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
function setupEnv(): {
|
|
383
|
+
storageSpy: sinon.SinonStub;
|
|
384
|
+
colocatedSpy: sinon.SinonStub;
|
|
385
|
+
} {
|
|
386
|
+
const storageSpy = sinon.stub().resolves();
|
|
387
|
+
const colocatedSpy = sinon.stub().resolves();
|
|
388
|
+
(ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
|
|
389
|
+
getPackage: sinon
|
|
390
|
+
.stub()
|
|
391
|
+
.resolves({ getMalloyConnection: async () => ({}) }),
|
|
392
|
+
getApiConnection: () => ({ name: "lake", type: "duckdb" }),
|
|
393
|
+
getEnvironmentPath: () => "/test",
|
|
394
|
+
bindPackageStorageServeBindings: storageSpy,
|
|
395
|
+
bindPackageColocatedServeManifest: colocatedSpy,
|
|
396
|
+
});
|
|
397
|
+
ctx.repository.getMaterializationById.resolves(
|
|
398
|
+
makeMaterialization({
|
|
399
|
+
status: "MANIFEST_FILE_READY",
|
|
400
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
401
|
+
manifest: { entries: {} } as any,
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
return { storageSpy, colocatedSpy };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
it("re-derives storage bindings from the next-latest materialization (on)", async () => {
|
|
408
|
+
process.env.PERSIST_STORAGE_MODE = "on";
|
|
409
|
+
try {
|
|
410
|
+
const { storageSpy, colocatedSpy } = setupEnv();
|
|
411
|
+
// The latest REMAINING successful run carries a storage entry.
|
|
412
|
+
ctx.repository.listMaterializations.resolves([
|
|
413
|
+
makeMaterialization({
|
|
414
|
+
id: "m-prev",
|
|
415
|
+
status: "MANIFEST_FILE_READY",
|
|
416
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
417
|
+
manifest: { entries: storageEntry } as any,
|
|
418
|
+
}),
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
|
|
422
|
+
|
|
423
|
+
expect(storageSpy.calledOnce).toBe(true);
|
|
424
|
+
expect(Object.keys(storageSpy.firstCall.args[1])).toContain("se1");
|
|
425
|
+
// Colocated is re-derived too (with no colocated entries ⇒ cleared).
|
|
426
|
+
expect(colocatedSpy.calledOnce).toBe(true);
|
|
427
|
+
expect(colocatedSpy.firstCall.args[1]).toEqual({});
|
|
428
|
+
} finally {
|
|
429
|
+
delete process.env.PERSIST_STORAGE_MODE;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("re-derives colocated bindings regardless of tier mode (off)", async () => {
|
|
434
|
+
// PERSIST_STORAGE_MODE unset ⇒ off. Colocated is the v0 path, not gated
|
|
435
|
+
// by the storage kill switch, so it is still re-derived; storage is not.
|
|
436
|
+
const { storageSpy, colocatedSpy } = setupEnv();
|
|
437
|
+
ctx.repository.listMaterializations.resolves([
|
|
438
|
+
makeMaterialization({
|
|
439
|
+
id: "m-prev",
|
|
440
|
+
status: "MANIFEST_FILE_READY",
|
|
441
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
442
|
+
manifest: { entries: colocatedEntry } as any,
|
|
443
|
+
}),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
|
|
447
|
+
|
|
448
|
+
expect(colocatedSpy.calledOnce).toBe(true);
|
|
449
|
+
expect(Object.keys(colocatedSpy.firstCall.args[1])).toContain("ce1");
|
|
450
|
+
// Storage rebind is skipped when the tier is off.
|
|
451
|
+
expect(storageSpy.called).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("clears both tiers' bindings when no successful materialization remains (on)", async () => {
|
|
455
|
+
process.env.PERSIST_STORAGE_MODE = "on";
|
|
456
|
+
try {
|
|
457
|
+
const { storageSpy, colocatedSpy } = setupEnv();
|
|
458
|
+
ctx.repository.listMaterializations.resolves([]); // none remain
|
|
459
|
+
|
|
460
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
|
|
461
|
+
|
|
462
|
+
expect(storageSpy.calledOnce).toBe(true);
|
|
463
|
+
expect(storageSpy.firstCall.args[1]).toEqual({}); // empty ⇒ serve live
|
|
464
|
+
expect(colocatedSpy.calledOnce).toBe(true);
|
|
465
|
+
expect(colocatedSpy.firstCall.args[1]).toEqual({});
|
|
466
|
+
} finally {
|
|
467
|
+
delete process.env.PERSIST_STORAGE_MODE;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("skips only the storage rebind when the tier is off (dark-ship)", async () => {
|
|
472
|
+
const { storageSpy, colocatedSpy } = setupEnv(); // off
|
|
473
|
+
ctx.repository.listMaterializations.resolves([]); // none remain
|
|
474
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1");
|
|
475
|
+
// Storage is skipped when off; colocated still re-derives (to clear).
|
|
476
|
+
expect(storageSpy.called).toBe(false);
|
|
477
|
+
expect(colocatedSpy.calledOnce).toBe(true);
|
|
478
|
+
expect(colocatedSpy.firstCall.args[1]).toEqual({});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
113
482
|
describe("queries", () => {
|
|
114
483
|
it("lists materializations for a package", async () => {
|
|
115
484
|
const rows = [
|
|
@@ -591,6 +960,38 @@ describe("stagingSuffix", () => {
|
|
|
591
960
|
});
|
|
592
961
|
});
|
|
593
962
|
|
|
963
|
+
describe("manifestExcludingStorage (chained-storage inline)", () => {
|
|
964
|
+
it("drops storage entries, keeps colocated entries with the manifest's quoting, preserves strict", () => {
|
|
965
|
+
// The source manifest is already dialect-quoted by the seed loop (#904);
|
|
966
|
+
// manifestExcludingStorage reuses that quoting for the kept entries.
|
|
967
|
+
const manifest = new Manifest();
|
|
968
|
+
manifest.strict = true;
|
|
969
|
+
manifest.update("wh_up", { tableName: '"wh_up_v1"' });
|
|
970
|
+
manifest.update("lake_up", { tableName: '"lake"."lake_up__mabc"' });
|
|
971
|
+
const built: Record<string, unknown> = {
|
|
972
|
+
wh_up: {
|
|
973
|
+
sourceEntityId: "wh_up",
|
|
974
|
+
physicalTableName: "wh_up_v1",
|
|
975
|
+
connectionName: "duckdb",
|
|
976
|
+
// no storageConnectionName -> colocated, keep it
|
|
977
|
+
},
|
|
978
|
+
lake_up: {
|
|
979
|
+
sourceEntityId: "lake_up",
|
|
980
|
+
physicalTableName: "lake_up__mabc",
|
|
981
|
+
connectionName: "wh",
|
|
982
|
+
storageConnectionName: "lake", // storage -> exclude it
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
986
|
+
const reduced = manifestExcludingStorage(manifest, built as any) as any;
|
|
987
|
+
expect(Object.keys(reduced.entries)).toEqual(["wh_up"]);
|
|
988
|
+
// Kept with the manifest's dialect-quoted name, not the raw physical name.
|
|
989
|
+
expect(reduced.entries.wh_up.tableName).toBe('"wh_up_v1"');
|
|
990
|
+
expect(reduced.entries.lake_up).toBeUndefined();
|
|
991
|
+
expect(reduced.strict).toBe(true);
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
594
995
|
describe("deriveSelfInstructions", () => {
|
|
595
996
|
let ctx: ReturnType<typeof createMocks>;
|
|
596
997
|
beforeEach(() => {
|
|
@@ -633,6 +1034,49 @@ describe("deriveSelfInstructions", () => {
|
|
|
633
1034
|
).toBeDefined();
|
|
634
1035
|
});
|
|
635
1036
|
|
|
1037
|
+
it("self-assigns the name= verbatim for a storage= source, exactly as a colocated build", () => {
|
|
1038
|
+
process.env.PERSIST_STORAGE_MODE = "on";
|
|
1039
|
+
try {
|
|
1040
|
+
const compiled = compiledWith(
|
|
1041
|
+
{
|
|
1042
|
+
lake_src: fakeSource({
|
|
1043
|
+
name: "lake_src",
|
|
1044
|
+
sourceEntityId: "aa11aa11aa11aa11aa11",
|
|
1045
|
+
annotationFields: { storage: "lake", name: "daily" },
|
|
1046
|
+
}),
|
|
1047
|
+
wh_src: fakeSource({
|
|
1048
|
+
name: "wh_src",
|
|
1049
|
+
sourceEntityId: "bb22bb22bb22bb22bb22",
|
|
1050
|
+
}),
|
|
1051
|
+
},
|
|
1052
|
+
[["lake_src", "wh_src"]],
|
|
1053
|
+
);
|
|
1054
|
+
const { instructions } = (
|
|
1055
|
+
ctx.service as unknown as {
|
|
1056
|
+
deriveSelfInstructions: (
|
|
1057
|
+
c: unknown,
|
|
1058
|
+
n: string[] | undefined,
|
|
1059
|
+
p: unknown,
|
|
1060
|
+
) => { instructions: BuildInstruction[] };
|
|
1061
|
+
}
|
|
1062
|
+
).deriveSelfInstructions(compiled, undefined, {});
|
|
1063
|
+
const byId = Object.fromEntries(
|
|
1064
|
+
instructions.map((i) => [i.sourceEntityId, i]),
|
|
1065
|
+
);
|
|
1066
|
+
const lake = byId["aa11aa11aa11aa11aa11"];
|
|
1067
|
+
const wh = byId["bb22bb22bb22bb22bb22"];
|
|
1068
|
+
// storage= → the `name=` value verbatim in the destination (no
|
|
1069
|
+
// content-hash decoration); only the destination differs from a colocated build.
|
|
1070
|
+
expect(lake.destination).toBe("lake");
|
|
1071
|
+
expect(lake.physicalTableName).toBe("daily");
|
|
1072
|
+
// colocated → the plain logical (source) name, no destination.
|
|
1073
|
+
expect(wh.destination).toBeUndefined();
|
|
1074
|
+
expect(wh.physicalTableName).toBe("wh_src");
|
|
1075
|
+
} finally {
|
|
1076
|
+
delete process.env.PERSIST_STORAGE_MODE;
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
636
1080
|
it("honors the sourceNames filter (excluded sources are neither built nor carried)", () => {
|
|
637
1081
|
const compiled = compiledWith(
|
|
638
1082
|
{
|
|
@@ -766,10 +1210,19 @@ describe("executeInstructedBuild", () => {
|
|
|
766
1210
|
instructions: BuildInstruction[],
|
|
767
1211
|
seed: Record<string, unknown>,
|
|
768
1212
|
): Promise<Record<string, { physicalTableName?: string }>> {
|
|
1213
|
+
// A stub BuildEnvironment: only the `storage=` branch touches it, and
|
|
1214
|
+
// these tests exercise the default in-warehouse path, so it is never read.
|
|
1215
|
+
const environment = {
|
|
1216
|
+
getApiConnection: () => {
|
|
1217
|
+
throw new Error("no connection config in this test");
|
|
1218
|
+
},
|
|
1219
|
+
getEnvironmentPath: () => "/tmp/env",
|
|
1220
|
+
};
|
|
769
1221
|
return (
|
|
770
1222
|
ctx.service as unknown as {
|
|
771
1223
|
executeInstructedBuild: (
|
|
772
1224
|
c: unknown,
|
|
1225
|
+
env: unknown,
|
|
773
1226
|
i: BuildInstruction[],
|
|
774
1227
|
s: Record<string, unknown>,
|
|
775
1228
|
sig: AbortSignal,
|
|
@@ -777,6 +1230,7 @@ describe("executeInstructedBuild", () => {
|
|
|
777
1230
|
}
|
|
778
1231
|
).executeInstructedBuild(
|
|
779
1232
|
compiled,
|
|
1233
|
+
environment,
|
|
780
1234
|
instructions,
|
|
781
1235
|
seed,
|
|
782
1236
|
new AbortController().signal,
|
|
@@ -1048,6 +1502,8 @@ describe("buildOneSource", () => {
|
|
|
1048
1502
|
c: unknown,
|
|
1049
1503
|
d: Record<string, string>,
|
|
1050
1504
|
m: Manifest,
|
|
1505
|
+
env: unknown,
|
|
1506
|
+
built: Record<string, unknown>,
|
|
1051
1507
|
) => Promise<{ sourceEntityId: string; physicalTableName: string }>;
|
|
1052
1508
|
}
|
|
1053
1509
|
).buildOneSource(
|
|
@@ -1056,6 +1512,13 @@ describe("buildOneSource", () => {
|
|
|
1056
1512
|
connection,
|
|
1057
1513
|
{ duckdb: "dig" },
|
|
1058
1514
|
manifest,
|
|
1515
|
+
{
|
|
1516
|
+
getApiConnection: () => {
|
|
1517
|
+
throw new Error("no connection config in this colocated test");
|
|
1518
|
+
},
|
|
1519
|
+
getEnvironmentPath: () => "/tmp/env",
|
|
1520
|
+
},
|
|
1521
|
+
{}, // builtEntries — colocated build with no prior storage upstreams
|
|
1059
1522
|
);
|
|
1060
1523
|
}
|
|
1061
1524
|
|
|
@@ -1140,6 +1603,66 @@ describe("buildOneSource", () => {
|
|
|
1140
1603
|
expect(entry.physicalTableName).toBe("ds.orders_v1");
|
|
1141
1604
|
});
|
|
1142
1605
|
|
|
1606
|
+
it("colocated build excludes a storage upstream from its warehouse manifest (mixed chain)", async () => {
|
|
1607
|
+
// A colocated downstream that reads a storage-materialized
|
|
1608
|
+
// upstream must NOT get the lake table name in its warehouse build SQL —
|
|
1609
|
+
// the warehouse can't resolve it. The storage upstream is excluded (⇒
|
|
1610
|
+
// inlined/recomputed); a colocated upstream is kept.
|
|
1611
|
+
const runSQL = sinon.stub().resolves();
|
|
1612
|
+
let seenManifest:
|
|
1613
|
+
| { entries?: Record<string, { tableName?: string }> }
|
|
1614
|
+
| undefined;
|
|
1615
|
+
const down = fakeSource({
|
|
1616
|
+
name: "down",
|
|
1617
|
+
sourceEntityId: "downdowndowndown",
|
|
1618
|
+
sql: "SELECT 1",
|
|
1619
|
+
onGetSQL: (o) => {
|
|
1620
|
+
seenManifest = (o as { buildManifest?: typeof seenManifest })
|
|
1621
|
+
.buildManifest;
|
|
1622
|
+
},
|
|
1623
|
+
});
|
|
1624
|
+
const manifest = new Manifest();
|
|
1625
|
+
manifest.update("up_storage", { tableName: "daily__mabc" });
|
|
1626
|
+
manifest.update("up_pathc", { tableName: '"orders_v1"' });
|
|
1627
|
+
const builtEntries = {
|
|
1628
|
+
up_storage: {
|
|
1629
|
+
sourceEntityId: "up_storage",
|
|
1630
|
+
physicalTableName: "daily__mabc",
|
|
1631
|
+
storageConnectionName: "lake",
|
|
1632
|
+
},
|
|
1633
|
+
up_pathc: {
|
|
1634
|
+
sourceEntityId: "up_pathc",
|
|
1635
|
+
physicalTableName: "orders_v1",
|
|
1636
|
+
connectionName: "duckdb",
|
|
1637
|
+
},
|
|
1638
|
+
};
|
|
1639
|
+
await (
|
|
1640
|
+
ctx.service as unknown as {
|
|
1641
|
+
buildOneSource: (...a: unknown[]) => Promise<unknown>;
|
|
1642
|
+
}
|
|
1643
|
+
).buildOneSource(
|
|
1644
|
+
down,
|
|
1645
|
+
{
|
|
1646
|
+
sourceEntityId: "downdowndowndown",
|
|
1647
|
+
materializedTableId: "mt",
|
|
1648
|
+
physicalTableName: "down_v1",
|
|
1649
|
+
realization: "COPY",
|
|
1650
|
+
}, // no destination ⇒ colocated
|
|
1651
|
+
{ runSQL },
|
|
1652
|
+
{ duckdb: "dig" },
|
|
1653
|
+
manifest,
|
|
1654
|
+
{
|
|
1655
|
+
getApiConnection: () => {
|
|
1656
|
+
throw new Error("unused in the colocated build");
|
|
1657
|
+
},
|
|
1658
|
+
getEnvironmentPath: () => "/tmp/env",
|
|
1659
|
+
},
|
|
1660
|
+
builtEntries,
|
|
1661
|
+
);
|
|
1662
|
+
expect(seenManifest?.entries?.up_storage).toBeUndefined();
|
|
1663
|
+
expect(seenManifest?.entries?.up_pathc?.tableName).toBe('"orders_v1"');
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1143
1666
|
it("records the QUOTED just-built name in the build manifest (matches the CREATE)", async () => {
|
|
1144
1667
|
// The build manifest feeds a downstream persist's FROM verbatim, so the
|
|
1145
1668
|
// recorded name must be the SAME quoted path the CREATE used — else a
|
|
@@ -1168,6 +1691,129 @@ describe("buildOneSource", () => {
|
|
|
1168
1691
|
});
|
|
1169
1692
|
});
|
|
1170
1693
|
|
|
1694
|
+
describe("buildOneSourceIntoStorage (chained-build fallback ladder)", () => {
|
|
1695
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
1696
|
+
beforeEach(() => {
|
|
1697
|
+
ctx = createMocks();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
function callInto(opts: {
|
|
1701
|
+
strict: boolean;
|
|
1702
|
+
stackOnParent: "ok" | "throw" | "infra";
|
|
1703
|
+
}): Promise<{
|
|
1704
|
+
physicalTableName: string;
|
|
1705
|
+
storageConnectionName?: string;
|
|
1706
|
+
}> {
|
|
1707
|
+
const source = fakeSource({
|
|
1708
|
+
name: "monthly",
|
|
1709
|
+
sourceEntityId: "abcdef1234567890",
|
|
1710
|
+
annotationFields: { storage: "lake" },
|
|
1711
|
+
});
|
|
1712
|
+
const instruction: BuildInstruction = {
|
|
1713
|
+
sourceEntityId: "abcdef1234567890",
|
|
1714
|
+
materializedTableId: "mt",
|
|
1715
|
+
physicalTableName: "monthly__mabc",
|
|
1716
|
+
realization: "COPY",
|
|
1717
|
+
destination: "lake",
|
|
1718
|
+
};
|
|
1719
|
+
const manifest = new Manifest();
|
|
1720
|
+
manifest.strict = opts.strict;
|
|
1721
|
+
const environment = {
|
|
1722
|
+
getApiConnection: (n: string) => ({
|
|
1723
|
+
name: n,
|
|
1724
|
+
type: n === "lake" ? "duckdb" : "postgres",
|
|
1725
|
+
}),
|
|
1726
|
+
getEnvironmentPath: () => "/tmp/env",
|
|
1727
|
+
};
|
|
1728
|
+
const svc = ctx.service as unknown as {
|
|
1729
|
+
buildDownstreamViaParents: unknown;
|
|
1730
|
+
buildOneSourceIntoStorage: (
|
|
1731
|
+
s: unknown,
|
|
1732
|
+
i: BuildInstruction,
|
|
1733
|
+
m: Manifest,
|
|
1734
|
+
e: unknown,
|
|
1735
|
+
sql: string,
|
|
1736
|
+
built: Record<string, unknown>,
|
|
1737
|
+
dep: boolean,
|
|
1738
|
+
) => Promise<{
|
|
1739
|
+
physicalTableName: string;
|
|
1740
|
+
storageConnectionName?: string;
|
|
1741
|
+
}>;
|
|
1742
|
+
};
|
|
1743
|
+
// Stub the stack-on-parent seam so the test exercises the LADDER, not the build.
|
|
1744
|
+
svc.buildDownstreamViaParents =
|
|
1745
|
+
opts.stackOnParent === "ok"
|
|
1746
|
+
? sinon.stub().resolves({
|
|
1747
|
+
storageConnectionName: "lake",
|
|
1748
|
+
schema: [
|
|
1749
|
+
{ name: "order_month", type: "DATE" },
|
|
1750
|
+
{ name: "monthly_total", type: "DOUBLE" },
|
|
1751
|
+
],
|
|
1752
|
+
})
|
|
1753
|
+
: opts.stackOnParent === "infra"
|
|
1754
|
+
? // An outage, not a modelling limit: a plain Error, as a failed
|
|
1755
|
+
// ATTACH or CTAS would throw.
|
|
1756
|
+
sinon.stub().rejects(new Error("IO Error: destination is down"))
|
|
1757
|
+
: // A shape limit: the downstream cannot be expressed over its
|
|
1758
|
+
// rebound parents.
|
|
1759
|
+
sinon.stub().rejects(
|
|
1760
|
+
new MaterializationEligibilityError({
|
|
1761
|
+
message: "uncarried parent",
|
|
1762
|
+
}),
|
|
1763
|
+
);
|
|
1764
|
+
return svc.buildOneSourceIntoStorage(
|
|
1765
|
+
source,
|
|
1766
|
+
instruction,
|
|
1767
|
+
manifest,
|
|
1768
|
+
environment,
|
|
1769
|
+
"SELECT should_not_run",
|
|
1770
|
+
{
|
|
1771
|
+
up: {
|
|
1772
|
+
sourceEntityId: "up",
|
|
1773
|
+
sourceName: "daily",
|
|
1774
|
+
physicalTableName: "daily__mabc",
|
|
1775
|
+
storageConnectionName: "lake",
|
|
1776
|
+
schema: [{ name: "order_date", type: "DATE" }],
|
|
1777
|
+
},
|
|
1778
|
+
},
|
|
1779
|
+
true, // dependsOnStorageUpstream
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
it("stacks on the parent: builds by reading it and returns the storage entry", async () => {
|
|
1784
|
+
const entry = await callInto({ strict: false, stackOnParent: "ok" });
|
|
1785
|
+
expect(entry.storageConnectionName).toBe("lake");
|
|
1786
|
+
expect(entry.physicalTableName).toBe("monthly__mabc");
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
it("strict + cannot stack on the parent: refuses loudly instead of recomputing from raw", async () => {
|
|
1790
|
+
await expect(
|
|
1791
|
+
callInto({ strict: true, stackOnParent: "throw" }),
|
|
1792
|
+
).rejects.toThrow(/strict upstreams forbid/i);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("non-strict + an INFRA failure fails rather than recomputing from raw", async () => {
|
|
1796
|
+
// Recompute-from-raw writes to the same destination, so retrying an outage
|
|
1797
|
+
// there just fails again — and metering it as a fallback files the outage
|
|
1798
|
+
// under the same label as a legitimate shape miss. Only a shape failure is
|
|
1799
|
+
// a reason to try the other path.
|
|
1800
|
+
await expect(
|
|
1801
|
+
callInto({ strict: false, stackOnParent: "infra" }),
|
|
1802
|
+
).rejects.toThrow(/Failed to materialize chained source/i);
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
it("non-strict + a SHAPE failure falls through to recompute-from-raw", async () => {
|
|
1806
|
+
// Only the parent-reuse seam is stubbed, so the recompute runs for real and
|
|
1807
|
+
// fails for its own reason (no destination file). That is the proof it was
|
|
1808
|
+
// reached: the two paths report differently — the chained path says
|
|
1809
|
+
// "chained source", the single-source recompute says "source". A shape
|
|
1810
|
+
// failure must reach the second; an infra failure must not.
|
|
1811
|
+
await expect(
|
|
1812
|
+
callInto({ strict: false, stackOnParent: "throw" }),
|
|
1813
|
+
).rejects.toThrow(/Failed to materialize source 'monthly'/i);
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1171
1817
|
describe("runBuild (branch behavior)", () => {
|
|
1172
1818
|
let ctx: ReturnType<typeof createMocks>;
|
|
1173
1819
|
beforeEach(() => {
|
|
@@ -1235,10 +1881,11 @@ describe("runBuild (branch behavior)", () => {
|
|
|
1235
1881
|
|
|
1236
1882
|
expect(svc.executeInstructedBuild.calledOnce).toBe(true);
|
|
1237
1883
|
// Caller instructions pass straight through; nothing is carried/reused.
|
|
1238
|
-
|
|
1884
|
+
// (arg[1] is the environment; instructions are arg[2], seed entries arg[3].)
|
|
1885
|
+
expect(svc.executeInstructedBuild.firstCall.args[2]).toEqual(
|
|
1239
1886
|
instructions,
|
|
1240
1887
|
);
|
|
1241
|
-
expect(svc.executeInstructedBuild.firstCall.args[
|
|
1888
|
+
expect(svc.executeInstructedBuild.firstCall.args[3]).toEqual({});
|
|
1242
1889
|
expect(svc.commitManifest.firstCall.args[2]).toMatchObject({
|
|
1243
1890
|
mode: "orchestrated",
|
|
1244
1891
|
sourcesBuilt: 1,
|
|
@@ -1274,17 +1921,18 @@ describe("runBuild (branch behavior)", () => {
|
|
|
1274
1921
|
|
|
1275
1922
|
// The reference manifest becomes the seed (carried) entries so a
|
|
1276
1923
|
// downstream build resolves its upstream to the existing physical table.
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
|
|
1924
|
+
// arg[1] is environment (the storage build session), so the seed
|
|
1925
|
+
// (carried) entries are arg[3]. connectionName is carried through so the
|
|
1926
|
+
// seed can be quoted for the upstream's dialect (see quoteSeedTablePath).
|
|
1927
|
+
expect(svc.executeInstructedBuild.firstCall.args[3]).toEqual({
|
|
1280
1928
|
"up-1": {
|
|
1281
1929
|
sourceEntityId: "up-1",
|
|
1282
1930
|
physicalTableName: "upstream_tbl",
|
|
1283
1931
|
connectionName: "sf",
|
|
1284
1932
|
},
|
|
1285
1933
|
});
|
|
1286
|
-
// strictUpstreams flows through as the strict flag (
|
|
1287
|
-
expect(svc.executeInstructedBuild.firstCall.args[
|
|
1934
|
+
// strictUpstreams flows through as the strict flag (6th arg).
|
|
1935
|
+
expect(svc.executeInstructedBuild.firstCall.args[5]).toBe(true);
|
|
1288
1936
|
// Reused upstreams are counted as carried, not built.
|
|
1289
1937
|
expect(svc.commitManifest.firstCall.args[2]).toMatchObject({
|
|
1290
1938
|
mode: "orchestrated",
|
|
@@ -1485,3 +2133,122 @@ describe("transition (state machine)", () => {
|
|
|
1485
2133
|
).toBe(true);
|
|
1486
2134
|
});
|
|
1487
2135
|
});
|
|
2136
|
+
|
|
2137
|
+
// The only path here that issues a DROP against a customer's warehouse, so its
|
|
2138
|
+
// guards get assertions rather than a trace. Scenario 64 covers it end to end,
|
|
2139
|
+
// but hammer does not run in CI — and the failure mode is silent data loss, not
|
|
2140
|
+
// a broken build.
|
|
2141
|
+
describe("reclaimStorageTablesFromFailedRun", () => {
|
|
2142
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
2143
|
+
let infoLog: sinon.SinonStub;
|
|
2144
|
+
let warnLog: sinon.SinonStub;
|
|
2145
|
+
|
|
2146
|
+
beforeEach(() => {
|
|
2147
|
+
ctx = createMocks();
|
|
2148
|
+
infoLog = ctx.sandbox.stub(logger, "info");
|
|
2149
|
+
warnLog = ctx.sandbox.stub(logger, "warn");
|
|
2150
|
+
});
|
|
2151
|
+
afterEach(() => ctx.sandbox.restore());
|
|
2152
|
+
|
|
2153
|
+
const entry = (table: string) =>
|
|
2154
|
+
({
|
|
2155
|
+
sourceEntityId: `eid-${table}`,
|
|
2156
|
+
sourceName: "daily",
|
|
2157
|
+
physicalTableName: table,
|
|
2158
|
+
storageConnectionName: "lake",
|
|
2159
|
+
}) as unknown as Parameters<
|
|
2160
|
+
MaterializationService["reclaimStorageTablesFromFailedRun"]
|
|
2161
|
+
>[0][number];
|
|
2162
|
+
|
|
2163
|
+
/** A destination whose drop would fail loudly if one were ever attempted. */
|
|
2164
|
+
const environment = {
|
|
2165
|
+
getApiConnection: () => ({ name: "lake", type: "duckdb" }),
|
|
2166
|
+
getEnvironmentPath: () => "/test",
|
|
2167
|
+
} as unknown as Parameters<
|
|
2168
|
+
MaterializationService["reclaimStorageTablesFromFailedRun"]
|
|
2169
|
+
>[1];
|
|
2170
|
+
|
|
2171
|
+
const reclaim = (
|
|
2172
|
+
built: ReturnType<typeof entry>[],
|
|
2173
|
+
others: unknown[],
|
|
2174
|
+
): Promise<void> => {
|
|
2175
|
+
(ctx.repository.listMaterializations as sinon.SinonStub).resolves(others);
|
|
2176
|
+
return (
|
|
2177
|
+
ctx.service as unknown as {
|
|
2178
|
+
reclaimStorageTablesFromFailedRun: (
|
|
2179
|
+
b: unknown,
|
|
2180
|
+
e: unknown,
|
|
2181
|
+
o: unknown,
|
|
2182
|
+
) => Promise<void>;
|
|
2183
|
+
}
|
|
2184
|
+
).reclaimStorageTablesFromFailedRun(built, environment, {
|
|
2185
|
+
environmentId: "env-1",
|
|
2186
|
+
packageName: "pkg",
|
|
2187
|
+
});
|
|
2188
|
+
};
|
|
2189
|
+
|
|
2190
|
+
const said = (stub: sinon.SinonStub, needle: RegExp) =>
|
|
2191
|
+
stub.getCalls().some((c) => needle.test(String(c.args[0])));
|
|
2192
|
+
|
|
2193
|
+
it("never drops a table a live manifest still serves", async () => {
|
|
2194
|
+
// The carried-forward case: a previous successful run's manifest names the
|
|
2195
|
+
// same destination + table. Dropping it would delete a table that is being
|
|
2196
|
+
// served right now.
|
|
2197
|
+
await reclaim(
|
|
2198
|
+
[entry("daily_v1")],
|
|
2199
|
+
[
|
|
2200
|
+
{
|
|
2201
|
+
status: "MANIFEST_FILE_READY",
|
|
2202
|
+
manifest: {
|
|
2203
|
+
entries: {
|
|
2204
|
+
other: {
|
|
2205
|
+
storageConnectionName: "lake",
|
|
2206
|
+
physicalTableName: "daily_v1",
|
|
2207
|
+
},
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
],
|
|
2212
|
+
);
|
|
2213
|
+
|
|
2214
|
+
expect(said(infoLog, /Keeping a table from a failed run/)).toBe(true);
|
|
2215
|
+
expect(said(infoLog, /Reclaimed a table/)).toBe(false);
|
|
2216
|
+
expect(said(warnLog, /Failed to reclaim/)).toBe(false);
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it("does attempt the drop when nothing references the table", async () => {
|
|
2220
|
+
// The inverse, so the test above is known to be the still-referenced check
|
|
2221
|
+
// doing the work rather than the reclaim being inert. No real destination
|
|
2222
|
+
// exists here, so the attempt surfaces as the drop-failure warning.
|
|
2223
|
+
await reclaim([entry("daily_v2")], []);
|
|
2224
|
+
|
|
2225
|
+
expect(said(infoLog, /Keeping a table from a failed run/)).toBe(false);
|
|
2226
|
+
expect(
|
|
2227
|
+
said(warnLog, /Failed to reclaim/) ||
|
|
2228
|
+
said(infoLog, /Reclaimed a table/),
|
|
2229
|
+
).toBe(true);
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
it("ignores a superseded run's entries when deciding", async () => {
|
|
2233
|
+
// Only MANIFEST_FILE_READY runs count as live. A superseded run naming the
|
|
2234
|
+
// same table must not pin it forever.
|
|
2235
|
+
await reclaim(
|
|
2236
|
+
[entry("daily_v3")],
|
|
2237
|
+
[
|
|
2238
|
+
{
|
|
2239
|
+
status: "SUPERSEDED",
|
|
2240
|
+
manifest: {
|
|
2241
|
+
entries: {
|
|
2242
|
+
old: {
|
|
2243
|
+
storageConnectionName: "lake",
|
|
2244
|
+
physicalTableName: "daily_v3",
|
|
2245
|
+
},
|
|
2246
|
+
},
|
|
2247
|
+
},
|
|
2248
|
+
},
|
|
2249
|
+
],
|
|
2250
|
+
);
|
|
2251
|
+
|
|
2252
|
+
expect(said(infoLog, /Keeping a table from a failed run/)).toBe(false);
|
|
2253
|
+
});
|
|
2254
|
+
});
|