@malloy-publisher/server 0.0.231 → 0.0.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. 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
- expect(svc.executeInstructedBuild.firstCall.args[1]).toEqual(
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[2]).toEqual({});
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
- // connectionName is carried through so the seed can be quoted for the
1278
- // upstream's dialect (see quoteSeedTablePath).
1279
- expect(svc.executeInstructedBuild.firstCall.args[2]).toEqual({
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 (5th arg).
1287
- expect(svc.executeInstructedBuild.firstCall.args[4]).toBe(true);
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
+ });