@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
@@ -7,17 +7,20 @@ import {
7
7
  BadRequestError,
8
8
  InvalidStateTransitionError,
9
9
  MaterializationConflictError,
10
+ MaterializationEligibilityError,
10
11
  MaterializationNotFoundError,
11
12
  } from "../errors";
12
13
  import { logger } from "../logger";
13
14
  import {
14
15
  MaterializationMode,
15
16
  recordAutoLoadOutcome,
17
+ recordChainedStorageBuild,
16
18
  recordDropTables,
17
19
  recordManifestBindDegraded,
18
20
  recordMaterializationRun,
19
21
  recordSourceBuildDuration,
20
22
  recordSourcesOutcome,
23
+ recordStorageBuildFailure,
21
24
  } from "../materialization_metrics";
22
25
  import {
23
26
  BuildInstruction,
@@ -38,9 +41,32 @@ import {
38
41
  compilePackageBuildPlan,
39
42
  computeSourceEntityId,
40
43
  deriveAnnotationFields,
44
+ projectToPublicColumns,
41
45
  iterGraphSources,
42
46
  } from "./build_plan";
47
+ import { getPersistStorageMode } from "../config";
43
48
  import { EnvironmentStore } from "./environment_store";
49
+ import { assertMaterializationEligible } from "./materialization_eligibility";
50
+ import {
51
+ assertStorageServeShapeCompiles,
52
+ buildDownstreamIntoStorage,
53
+ buildSourceIntoStorage,
54
+ dropStorageTable,
55
+ type StorageBuildResult,
56
+ } from "./materialization_build_session";
57
+ import { escapeSQL } from "./connection";
58
+ import {
59
+ buildChainedStorageBuildModel,
60
+ buildVirtualMap,
61
+ deriveServeBindings,
62
+ type ServeBinding,
63
+ type SourceLocation,
64
+ sliceSourceRange,
65
+ } from "./materialization_serve_transform";
66
+ import type { ApiConnection } from "./model";
67
+ import { fetchManifestEntries, splitManifestEntries } from "./manifest_loader";
68
+ import { readFileSync } from "fs";
69
+ import { fileURLToPath } from "url";
44
70
  import {
45
71
  bareTableName,
46
72
  quoteIdentifier,
@@ -48,6 +74,19 @@ import {
48
74
  quoteTablePath,
49
75
  } from "./quoting";
50
76
  import { resolveEnvironmentId } from "./resolve_environment";
77
+ import { redactPgSecrets } from "../pg_helpers";
78
+
79
+ /**
80
+ * The narrow environment surface the build path needs to materialize into a
81
+ * `storage=` destination: resolve a connection's config by name (source creds to
82
+ * federate, destination catalog to attach) and the environment root (to derive a
83
+ * plain-DuckDB destination's file path). Kept minimal to avoid coupling the
84
+ * materialization service to the full Environment type.
85
+ */
86
+ interface BuildEnvironment {
87
+ getApiConnection(connectionName: string): ApiConnection;
88
+ getEnvironmentPath(): string;
89
+ }
51
90
 
52
91
  /**
53
92
  * Length of the sourceEntityId prefix used when synthesizing staging table
@@ -72,6 +111,117 @@ function selfAssignTableName(persistSource: PersistSource): string {
72
111
  return deriveAnnotationFields(persistSource).name || persistSource.name;
73
112
  }
74
113
 
114
+ /**
115
+ * The build manifest for a `storage=` build, with storage-materialized entries
116
+ * removed. A storage build runs in the source warehouse (passthrough), so it
117
+ * cannot reference an upstream that landed in a DuckDB/DuckLake store — dropping
118
+ * those entries makes the compiler INLINE the upstream (non-strict) or raise a
119
+ * clean strict-miss (strict), instead of emitting a cross-engine table
120
+ * reference. colocated entries are kept — the warehouse build can
121
+ * reference them, carrying forward the DIALECT-QUOTED table path the seed loop
122
+ * already stamped (publisher #904's quoteSeedTablePath), so the downstream FROM
123
+ * resolves a case-preserved upstream on a case-folding engine. Preserves the
124
+ * manifest's `strict` flag.
125
+ */
126
+ export function manifestExcludingStorage(
127
+ manifest: Manifest,
128
+ builtEntries: Record<string, ManifestEntry>,
129
+ ): Manifest["buildManifest"] {
130
+ const reduced = new Manifest();
131
+ reduced.strict = manifest.strict;
132
+ // The source manifest's entries are already dialect-quoted (#904); reuse that
133
+ // quoting rather than the raw physical name so the kept colocated references
134
+ // stay canonical for the downstream FROM.
135
+ const quoted = manifest.buildManifest.entries;
136
+ for (const [id, entry] of Object.entries(builtEntries)) {
137
+ if (!entry.storageConnectionName && quoted[id]) {
138
+ reduced.update(id, { tableName: quoted[id].tableName });
139
+ }
140
+ }
141
+ return reduced.buildManifest;
142
+ }
143
+
144
+ /**
145
+ * The `storage=` destination a source DECLARES (external-tier intent), or
146
+ * undefined. Independent of `PERSIST_STORAGE_MODE` — reflects author intent, so
147
+ * the build can tell a `storage=` source apart from a plain colocated
148
+ * `#@ persist` even when the tier is off.
149
+ */
150
+ function declaredStorage(persistSource: PersistSource): string | undefined {
151
+ return deriveAnnotationFields(persistSource).storage?.trim() || undefined;
152
+ }
153
+
154
+ /**
155
+ * Resolve a persist source's `#@ persist storage=<ref>` to the EFFECTIVE
156
+ * destination connection name for a build, or undefined for the default
157
+ * colocated path (the source materializes into its own warehouse). Read
158
+ * publisher-side from the compiled annotation (the same `annotationFields` map
159
+ * the plan echoes); the reference resolves generically against registered
160
+ * connections. Absent `storage=` ⇒ undefined (colocated); any value names a
161
+ * registered connection to materialize into. Any managed-tier alias is resolved
162
+ * by the host upstream and set on the wire instruction's `destination` — it
163
+ * never reaches this publisher-side generic resolution.
164
+ *
165
+ * When `PERSIST_STORAGE_MODE=off` this returns undefined regardless of the
166
+ * annotation, so the feature is a runtime kill switch that never fails a
167
+ * package (the ignored `storage=` is surfaced as a package warning, not an
168
+ * error). Undefined here does NOT mean "build it colocated" for a source that
169
+ * declared `storage=` — `deriveSelfInstructions` skips such a source entirely so
170
+ * it serves live; see {@link declaredStorage}.
171
+ */
172
+ function resolveStorageDestination(
173
+ persistSource: PersistSource,
174
+ ): string | undefined {
175
+ if (getPersistStorageMode() === "off") return undefined;
176
+ return declaredStorage(persistSource);
177
+ }
178
+
179
+ /** Connection-config keys whose string values are credentials to redact. */
180
+ const SENSITIVE_KEY =
181
+ /pass(word)?|secret|private_?key|service_?account|access_?key|token|connection_?string|account/i;
182
+
183
+ /** Collect credential string values (from sensitively-named keys) in a config. */
184
+ function collectSensitiveValues(value: unknown, out: Set<string>): void {
185
+ if (value === null || typeof value !== "object") return;
186
+ if (Array.isArray(value)) {
187
+ for (const v of value) collectSensitiveValues(v, out);
188
+ return;
189
+ }
190
+ for (const [key, v] of Object.entries(value)) {
191
+ if (typeof v === "string" && v.length >= 4 && SENSITIVE_KEY.test(key)) {
192
+ out.add(v);
193
+ } else {
194
+ collectSensitiveValues(v, out);
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Redact the actual credential values (from the given connection configs) out
201
+ * of an error message, then surface the message. This keeps a build error
202
+ * legible — a "schema not found" or "table does not exist" tells the operator
203
+ * exactly what to fix — while never leaking the passwords / secrets / service
204
+ * account JSON / connection strings a federation or attach error can echo. Only
205
+ * the concrete secret values are removed, not the message structure.
206
+ */
207
+ export function redactConnectionSecrets(
208
+ message: string,
209
+ ...connections: unknown[]
210
+ ): string {
211
+ const secrets = new Set<string>();
212
+ for (const c of connections) collectSensitiveValues(c, secrets);
213
+ let redacted = redactPgSecrets(message);
214
+ for (const s of secrets) {
215
+ redacted = redacted.split(s).join("***");
216
+ // A DuckDB error often echoes the offending SQL statement, in which a
217
+ // secret containing a single quote appears single-quote-escaped (`''`) —
218
+ // so the raw value won't match. Also redact the escaped form.
219
+ const escaped = escapeSQL(s);
220
+ if (escaped !== s) redacted = redacted.split(escaped).join("***");
221
+ }
222
+ return redacted;
223
+ }
224
+
75
225
  /** Classify a thrown build error as cancelled (cooperative abort) or failed. */
76
226
  function outcomeFor(
77
227
  _err: unknown,
@@ -384,6 +534,29 @@ export class MaterializationService {
384
534
  compilePackageBuildPlan(pkg, signal),
385
535
  );
386
536
 
537
+ // Backstop: refuse loudly if the package annotated a `#@ persist` source
538
+ // that Malloy's getBuildPlan() silently dropped (a shape it doesn't
539
+ // treat as a materializable root — see detectDroppedPersistSources).
540
+ // Without this the build would report success with an empty manifest and
541
+ // the source would serve live, contradicting the "hard refuse, never a
542
+ // silent fallback" contract. Scoped to the sources this build targets so
543
+ // a build of unrelated sources isn't blocked by a dropped sibling.
544
+ const relevantDropped = (compiled.droppedPersistSources ?? []).filter(
545
+ (d) => !opts.sourceNames || opts.sourceNames.includes(d.name),
546
+ );
547
+ if (relevantDropped.length > 0) {
548
+ const names = relevantDropped.map((d) => `'${d.name}'`).join(", ");
549
+ throw new MaterializationEligibilityError({
550
+ message:
551
+ `Source(s) ${names} are annotated '#@ persist' but were not ` +
552
+ `recognized as a materializable source, so nothing would be ` +
553
+ `built (they would be served live). Only query/aggregate ` +
554
+ `sources materialize; a filtered pass-through does not. Persist ` +
555
+ `a query source, or invoke a parameterized source with a bound ` +
556
+ `argument, or drop the annotation to serve live.`,
557
+ });
558
+ }
559
+
387
560
  let instructions: BuildInstruction[];
388
561
  let carried: Record<string, ManifestEntry>;
389
562
  if (orchestrated) {
@@ -394,6 +567,31 @@ export class MaterializationService {
394
567
  // table instead of recomputing live. The reference key is the
395
568
  // compiler's manifest-lookup sourceEntityId (see ManifestReference).
396
569
  carried = this.referenceManifestToEntries(opts.referenceManifest);
570
+ // Upstream resolution for an orchestrated build draws on three
571
+ // sources, in DESCENDING precedence — a reference is an IDENTITY, not
572
+ // a copy the caller must fully courier:
573
+ // 1. The explicit `referenceManifest` couriered in THIS build call.
574
+ // 2. The package's BOUND manifest (`manifestLocation`) — the set the
575
+ // orchestrator distributed to this worker. This is what makes the
576
+ // cross-worker flow work: a worker that never built the upstream
577
+ // still holds its full entry via the refreshed manifest, so a
578
+ // downstream can reuse the upstream's materialized table.
579
+ // 3. This worker's own most-recent local manifest (skip-if-unchanged
580
+ // cache) — same-worker reuse.
581
+ // Each fills the storage fields (sourceName, storageConnectionName,
582
+ // schema) a thin reference can't carry — required for a `storage=`
583
+ // upstream's stack-on-the-parent rebind. Higher-precedence
584
+ // fields win; each step is best-effort (a fetch/read failure leaves
585
+ // the entries as they are).
586
+ await this.seedFromBoundManifest(carried, pkg, instructions);
587
+ if (Object.keys(carried).length > 0) {
588
+ await this.resolveReferencesFromStore(
589
+ carried,
590
+ environmentId,
591
+ packageName,
592
+ id,
593
+ );
594
+ }
397
595
  } else {
398
596
  // Skip-if-unchanged: reuse tables from the most recent successful
399
597
  // manifest for sources whose sourceEntityId is unchanged, unless
@@ -414,10 +612,14 @@ export class MaterializationService {
414
612
 
415
613
  const entries = await this.executeInstructedBuild(
416
614
  compiled,
615
+ environment,
417
616
  instructions,
418
617
  carried,
419
618
  signal,
420
619
  opts.strictUpstreams ?? false,
620
+ // Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
621
+ // reclaimStorageTablesFromFailedRun.
622
+ orchestrated ? { environmentId, packageName } : undefined,
421
623
  );
422
624
 
423
625
  const sourcesBuilt = instructions.length;
@@ -484,6 +686,33 @@ export class MaterializationService {
484
686
  )) {
485
687
  if (include && !include.has(persistSource.name)) continue;
486
688
 
689
+ // Safety: a source that DECLARES `storage=` must never silently
690
+ // downgrade to a colocated build when the external tier is disabled
691
+ // (PERSIST_STORAGE_MODE=off). A colocated build writes a CTAS into the
692
+ // source's OWN warehouse — which the author did not intend (they asked
693
+ // for external storage; production grants this server read-only
694
+ // warehouse access) and which could fail or land in an unexpected
695
+ // schema. Skip it: the source is not materialized and serves LIVE, and
696
+ // the mode warning (Package.storageWarnings) surfaces the degraded
697
+ // state. A plain `#@ persist` (no `storage=`) is unaffected —
698
+ // colocated IS its author's intent (the v0 path, ungated by the
699
+ // storage kill switch).
700
+ if (
701
+ getPersistStorageMode() === "off" &&
702
+ declaredStorage(persistSource)
703
+ ) {
704
+ continue;
705
+ }
706
+
707
+ const destination = resolveStorageDestination(persistSource);
708
+ if (destination) {
709
+ // Gate BEFORE computeSourceEntityId: an unbound parameter or a
710
+ // given makes getSQL() (called inside computeSourceEntityId)
711
+ // throw opaquely, so the eligibility refusal must fire first to
712
+ // give a clean, actionable 422.
713
+ assertMaterializationEligible(persistSource);
714
+ }
715
+
487
716
  const sourceEntityId = computeSourceEntityId(
488
717
  persistSource,
489
718
  compiled.connectionDigests,
@@ -492,19 +721,39 @@ export class MaterializationService {
492
721
  seen.add(sourceEntityId);
493
722
 
494
723
  const prior = priorEntries[sourceEntityId];
495
- if (prior && prior.physicalTableName) {
724
+ // Destination-scoped reuse: carry a prior table forward only when it
725
+ // landed in the SAME destination. sourceEntityId is a pure content
726
+ // address and does NOT encode the destination, so a source that adds,
727
+ // drops, or switches `storage=` must rebuild — otherwise a
728
+ // warehouse-landed (colocated) table would be silently reused for a
729
+ // DuckLake serve that cannot resolve it.
730
+ if (
731
+ prior &&
732
+ prior.physicalTableName &&
733
+ (prior.storageConnectionName ?? undefined) === destination
734
+ ) {
496
735
  carried[sourceEntityId] = prior;
497
736
  continue;
498
737
  }
499
738
 
739
+ // Self-assign the physical name from `name=` (or the source name)
740
+ // verbatim for BOTH the colocated and storage
741
+ // destinations — the only difference between the two is which
742
+ // connection the table lands in. A storage build replaces the table
743
+ // atomically (`CREATE OR REPLACE`), so no generational decoration is
744
+ // needed to make a rebuild safe. An orchestrated build ignores this
745
+ // and trusts the host-supplied `physicalTableName`; the host owns any
746
+ // generational, ownership-scoped naming.
747
+ const logicalName = selfAssignTableName(persistSource);
500
748
  instructions.push({
501
749
  sourceEntityId,
502
750
  materializedTableId: `local-${sourceEntityId.substring(
503
751
  0,
504
752
  STAGING_ID_LEN,
505
753
  )}`,
506
- physicalTableName: selfAssignTableName(persistSource),
754
+ physicalTableName: logicalName,
507
755
  realization: "COPY",
756
+ ...(destination ? { destination } : {}),
508
757
  });
509
758
  }
510
759
  }
@@ -537,6 +786,122 @@ export class MaterializationService {
537
786
  return entries;
538
787
  }
539
788
 
789
+ /**
790
+ * Resolve upstream references against this publisher's OWN most-recent
791
+ * manifest, keyed by sourceEntityId (the same cache skip-if-unchanged reads).
792
+ * A reference is fundamentally an IDENTITY, not a copy: for each one that
793
+ * matches a locally persisted entry, fill in the fields a thin reference
794
+ * can't carry (sourceName, storageConnectionName, schema — needed for a
795
+ * `storage=` upstream's stack-on-the-parent rebind) from the local entry, while
796
+ * letting any caller-supplied field WIN (the orchestrator is authoritative across a
797
+ * stateless fleet). Mutates `carried` in place. Best-effort: a lookup failure
798
+ * leaves the references exactly as supplied, so the cross-worker courier path
799
+ * (a worker that never built the upstream, fed the full entry) is unaffected.
800
+ */
801
+ private async resolveReferencesFromStore(
802
+ carried: Record<string, ManifestEntry>,
803
+ environmentId: string,
804
+ packageName: string,
805
+ excludeId: string,
806
+ ): Promise<void> {
807
+ let cached: Record<string, ManifestEntry>;
808
+ try {
809
+ cached = await this.getMostRecentManifestEntries(
810
+ environmentId,
811
+ packageName,
812
+ excludeId,
813
+ );
814
+ } catch (err) {
815
+ logger.warn(
816
+ "Reference resolve-local lookup failed; using references as supplied",
817
+ {
818
+ packageName,
819
+ error: err instanceof Error ? err.message : String(err),
820
+ },
821
+ );
822
+ return;
823
+ }
824
+ for (const [sourceEntityId, ref] of Object.entries(carried)) {
825
+ const local = cached[sourceEntityId];
826
+ if (!local) continue;
827
+ // Local entry is the base; caller-supplied (defined) fields override it.
828
+ const merged: ManifestEntry = { ...local };
829
+ for (const [key, value] of Object.entries(ref)) {
830
+ if (value !== undefined) {
831
+ (merged as Record<string, unknown>)[key] = value;
832
+ }
833
+ }
834
+ carried[sourceEntityId] = merged;
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Seed upstream reuse from the package's BOUND manifest — the set the
840
+ * orchestrator distributed to this worker via `manifestLocation`. This is the
841
+ * cross-worker path: a worker that never built an upstream still holds its full
842
+ * entry here (`storageConnectionName` + `schema` + `sourceName`), so a
843
+ * downstream can reuse the upstream's materialized table instead of recomputing
844
+ * it from raw. Adds any bound upstream not already carried (so a build needs no
845
+ * explicit reference when the manifest is refreshed), and fills gaps in a
846
+ * thin explicit reference — but an explicit `referenceManifest` field always
847
+ * wins (it targets THIS build). Sources this build is producing are skipped
848
+ * (built fresh, not reused). Best-effort: a fetch failure is ignored (the build
849
+ * falls back to the local store / inline recompute). Mutates `carried`.
850
+ */
851
+ private async seedFromBoundManifest(
852
+ carried: Record<string, ManifestEntry>,
853
+ pkg: { getPackageMetadata(): { manifestLocation?: string | null } },
854
+ instructions: BuildInstruction[],
855
+ ): Promise<void> {
856
+ const manifestLocation = pkg.getPackageMetadata().manifestLocation;
857
+ if (!manifestLocation) return;
858
+ let fetched;
859
+ try {
860
+ fetched = await fetchManifestEntries(manifestLocation);
861
+ } catch (err) {
862
+ logger.warn(
863
+ "Build upstream resolution: bound manifest fetch failed; ignoring",
864
+ {
865
+ manifestLocation,
866
+ error: err instanceof Error ? err.message : String(err),
867
+ },
868
+ );
869
+ return;
870
+ }
871
+ // Reconstruct a ManifestEntry map from both tiers of the fetched manifest:
872
+ // storage entries carry their full shape; colocated entries reconstruct from
873
+ // the tableName manifest.
874
+ const bound: Record<string, ManifestEntry> = {
875
+ ...fetched.storageEntries,
876
+ };
877
+ for (const [eid, e] of Object.entries(fetched.tableNameManifest)) {
878
+ if (!bound[eid]) {
879
+ bound[eid] = {
880
+ sourceEntityId: eid,
881
+ physicalTableName: e.tableName,
882
+ connectionName: e.connectionName,
883
+ };
884
+ }
885
+ }
886
+ const building = new Set(instructions.map((i) => i.sourceEntityId));
887
+ for (const [eid, entry] of Object.entries(bound)) {
888
+ if (building.has(eid)) continue;
889
+ const existing = carried[eid];
890
+ if (!existing) {
891
+ carried[eid] = entry;
892
+ continue;
893
+ }
894
+ // Explicit reference wins; the bound entry fills the gaps it left.
895
+ const merged: ManifestEntry = { ...entry };
896
+ for (const [key, value] of Object.entries(existing)) {
897
+ if (value !== undefined) {
898
+ (merged as Record<string, unknown>)[key] = value;
899
+ }
900
+ }
901
+ carried[eid] = merged;
902
+ }
903
+ }
904
+
540
905
  /**
541
906
  * Entries of the most recent successful (MANIFEST_FILE_READY) materialization
542
907
  * for this package, used for skip-if-unchanged. Excludes the in-flight run.
@@ -546,10 +911,11 @@ export class MaterializationService {
546
911
  packageName: string,
547
912
  excludeId: string,
548
913
  ): Promise<Record<string, ManifestEntry>> {
549
- const list = await this.repository.listMaterializations(
550
- environmentId,
551
- packageName,
552
- );
914
+ const list =
915
+ (await this.repository.listMaterializations(
916
+ environmentId,
917
+ packageName,
918
+ )) ?? [];
553
919
  for (const m of list) {
554
920
  if (m.id === excludeId) continue;
555
921
  if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
@@ -570,6 +936,10 @@ export class MaterializationService {
570
936
  packageName: string,
571
937
  manifest: FreshnessManifest,
572
938
  ): Promise<void>;
939
+ bindPackageStorageServeBindings(
940
+ packageName: string,
941
+ entries: Record<string, ManifestEntry>,
942
+ ): Promise<void>;
573
943
  },
574
944
  packageName: string,
575
945
  entries: Record<string, ManifestEntry>,
@@ -580,7 +950,12 @@ export class MaterializationService {
580
950
  // bound un-gated (always serve the freshly-built table).
581
951
  const manifestEntries: FreshnessManifest = {};
582
952
  for (const [sourceEntityId, entry] of Object.entries(entries)) {
583
- if (entry.physicalTableName) {
953
+ // Storage entries serve cross-connection via the virtual-source
954
+ // bindings (below), NOT the same-connection manifest substitution —
955
+ // putting one here would make the original model try to substitute the
956
+ // source with a table on its OWN (source) connection, which doesn't
957
+ // exist there. Only colocated entries go into the tableName manifest.
958
+ if (entry.physicalTableName && !entry.storageConnectionName) {
584
959
  manifestEntries[sourceEntityId] = {
585
960
  tableName: entry.physicalTableName,
586
961
  // Carried so the bind step can quote the physical path for the
@@ -597,6 +972,14 @@ export class MaterializationService {
597
972
  packageName,
598
973
  manifestEntries,
599
974
  );
975
+ // Separately bind the FULL entries as storage serve bindings — sources
976
+ // materialized into a storage destination serve cross-connection via
977
+ // the virtual-source transform, not the tableName manifest above. No-op
978
+ // for a package with no storage= sources (deriveServeBindings → []).
979
+ await environment.bindPackageStorageServeBindings(
980
+ packageName,
981
+ entries,
982
+ );
600
983
  recordAutoLoadOutcome("success");
601
984
  logger.info("Auto-run: loaded manifest into package models", {
602
985
  packageName,
@@ -692,10 +1075,14 @@ export class MaterializationService {
692
1075
  */
693
1076
  private async executeInstructedBuild(
694
1077
  compiled: CompiledBuildPlan,
1078
+ environment: BuildEnvironment,
695
1079
  instructions: BuildInstruction[],
696
1080
  seedEntries: Record<string, ManifestEntry>,
697
1081
  signal: AbortSignal,
698
1082
  strict = false,
1083
+ // Identity of the run, used only to reclaim storage tables this run created
1084
+ // if it fails part-way (see reclaimStorageTablesFromFailedRun).
1085
+ owner?: { environmentId: string; packageName: string },
699
1086
  ): Promise<Record<string, ManifestEntry>> {
700
1087
  const { graphs, sources, connectionDigests, connections } = compiled;
701
1088
 
@@ -745,45 +1132,201 @@ export class MaterializationService {
745
1132
  entries[sourceEntityId] = entry;
746
1133
  }
747
1134
 
748
- for (const graph of graphs) {
749
- const connection = connections.get(graph.connectionName);
750
- if (!connection) {
751
- throw new BadRequestError(
752
- `Connection '${graph.connectionName}' not found`,
753
- );
1135
+ // Entries this run actually CREATED, as opposed to the seeded/carried ones
1136
+ // above. Only these are eligible for failure-path reclaim: a carried entry
1137
+ // names a table an earlier successful run built and a live manifest may still
1138
+ // serve, so dropping one would be data loss rather than cleanup.
1139
+ const builtThisRun: ManifestEntry[] = [];
1140
+ try {
1141
+ for (const graph of graphs) {
1142
+ const connection = connections.get(graph.connectionName);
1143
+ if (!connection) {
1144
+ throw new BadRequestError(
1145
+ `Connection '${graph.connectionName}' not found`,
1146
+ );
1147
+ }
1148
+ for (const persistSource of iterGraphSources(graph, sources)) {
1149
+ if (signal.aborted) throw new Error("Build cancelled");
1150
+
1151
+ // Prefer sourceID matching (so the caller's sourceEntityId scheme
1152
+ // stays opaque to the build); the sourceEntityId lookup below is the
1153
+ // fallback for instructions without a sourceID (auto-run). Resolved
1154
+ // before computeSourceEntityId so the eligibility gate wins: that
1155
+ // call invokes getSQL(), which throws opaquely for a free-parameter
1156
+ // or given source, losing the clean 422.
1157
+ const orchestratedInstruction = bySourceID.get(
1158
+ persistSource.sourceID,
1159
+ );
1160
+
1161
+ // Enforce the eligibility gate for any storage-targeted build,
1162
+ // including orchestrated (host-supplied) instructions — the publisher
1163
+ // refuses an ineligible source into the tier itself, not on trust.
1164
+ // Skipped when the mode is off: the kill switch ignores a
1165
+ // host-supplied destination too and does a colocated build.
1166
+ if (
1167
+ orchestratedInstruction?.destination &&
1168
+ getPersistStorageMode() !== "off"
1169
+ ) {
1170
+ assertMaterializationEligible(persistSource);
1171
+ }
1172
+
1173
+ // The manifest is keyed by the content sourceEntityId — what Malloy
1174
+ // recomputes to resolve upstream persist references during SQL
1175
+ // generation — independent of the instruction's identity sourceEntityId.
1176
+ const sourceEntityId = computeSourceEntityId(
1177
+ persistSource,
1178
+ connectionDigests,
1179
+ );
1180
+ const instruction =
1181
+ orchestratedInstruction ??
1182
+ bySourceEntityId.get(sourceEntityId);
1183
+ if (!instruction) continue;
1184
+
1185
+ // Auto-run already gated pre-getSQL in deriveSelfInstructions;
1186
+ // re-assert (idempotent) so no path into a storage build is ungated.
1187
+ if (
1188
+ !orchestratedInstruction &&
1189
+ instruction.destination &&
1190
+ getPersistStorageMode() !== "off"
1191
+ ) {
1192
+ assertMaterializationEligible(persistSource);
1193
+ }
1194
+
1195
+ const entry = await this.buildOneSource(
1196
+ persistSource,
1197
+ instruction,
1198
+ connection,
1199
+ connectionDigests,
1200
+ manifest,
1201
+ environment,
1202
+ entries,
1203
+ );
1204
+ entries[sourceEntityId] = entry;
1205
+ if (entry.storageConnectionName) builtThisRun.push(entry);
1206
+ }
754
1207
  }
755
- for (const persistSource of iterGraphSources(graph, sources)) {
756
- if (signal.aborted) throw new Error("Build cancelled");
757
-
758
- // The manifest is keyed by the content sourceEntityId what Malloy
759
- // recomputes to resolve upstream persist references during SQL
760
- // generation independent of the instruction's identity sourceEntityId.
761
- const sourceEntityId = computeSourceEntityId(
762
- persistSource,
763
- connectionDigests,
764
- );
765
- // Prefer sourceID matching (so the caller's sourceEntityId scheme stays
766
- // opaque to the build); fall back to sourceEntityId for instructions
767
- // without a sourceID (auto-run).
768
- const instruction =
769
- bySourceID.get(persistSource.sourceID) ??
770
- bySourceEntityId.get(sourceEntityId);
771
- if (!instruction) continue;
772
-
773
- const entry = await this.buildOneSource(
774
- persistSource,
775
- instruction,
776
- connection,
777
- connectionDigests,
778
- manifest,
1208
+ } catch (err) {
1209
+ // A run that fails part-way commits NO manifest, and manifest-driven GC
1210
+ // only drops names a manifest records — so a table an earlier source in
1211
+ // this run already wrote would be unreachable forever. Reclaim those
1212
+ // before rethrowing. Best-effort and non-fatal: the build's own failure is
1213
+ // what the caller needs to see.
1214
+ if (owner) {
1215
+ await this.reclaimStorageTablesFromFailedRun(
1216
+ builtThisRun,
1217
+ environment,
1218
+ owner,
779
1219
  );
780
- entries[sourceEntityId] = entry;
781
1220
  }
1221
+ throw err;
782
1222
  }
783
1223
 
784
1224
  return entries;
785
1225
  }
786
1226
 
1227
+ /**
1228
+ * Drop the storage tables a FAILED run created, so a partial build does not
1229
+ * leak an unreferenced table. For DuckLake that is data plus Parquet files at
1230
+ * rest, and nothing else will ever name them: the run commits no manifest, and
1231
+ * GC reclaims only what a manifest records.
1232
+ *
1233
+ * Three guards make this safe, and each closes a real way to destroy live data:
1234
+ *
1235
+ * - ORCHESTRATED runs only (the caller passes no `owner` for auto-run). Those
1236
+ * names are host-assigned and generational, so unique by construction — which
1237
+ * is both where the leak actually bites and the only case where a drop cannot
1238
+ * hit something another run owns. Auto-run's STABLE names are overwritten in
1239
+ * place by the next build, so skipping them forgoes little.
1240
+ *
1241
+ * This gate is what bounds the cross-environment hazard. The
1242
+ * still-referenced check below reads THIS environment and package only, and a
1243
+ * BuildID carries no environment input, so two environments sharing a
1244
+ * destination can resolve a source to the SAME physical name. A reclaim that
1245
+ * trusted a per-environment check could then drop a table another environment
1246
+ * is actively serving — the failure mode behind a real cross-environment
1247
+ * data-loss incident on the hosted side. Generational names remove the
1248
+ * collision rather than racing it. The durable fix is refusing a colliding
1249
+ * persist target at validation time; until then, do not widen this.
1250
+ *
1251
+ * - Only entries this run CREATED (never a carried-forward one).
1252
+ * - Only names no other MANIFEST_FILE_READY run references, the same
1253
+ * destination-and-name check {@link dropMaterializedTables} applies.
1254
+ *
1255
+ * Scoped to `storage=` entries. A colocated failure is left alone: those names
1256
+ * are stable and in the customer's own warehouse, so the next successful build
1257
+ * overwrites in place and a failure-path DROP there would be a far larger
1258
+ * blast radius for no reclaim.
1259
+ */
1260
+ private async reclaimStorageTablesFromFailedRun(
1261
+ builtThisRun: ManifestEntry[],
1262
+ environment: BuildEnvironment,
1263
+ owner: { environmentId: string; packageName: string },
1264
+ ): Promise<void> {
1265
+ if (builtThisRun.length === 0) return;
1266
+ try {
1267
+ const tableKey = (dest: string, table: string) => `${dest}:${table}`;
1268
+ const stillReferenced = new Set<string>();
1269
+ const others =
1270
+ (await this.repository.listMaterializations(
1271
+ owner.environmentId,
1272
+ owner.packageName,
1273
+ )) ?? [];
1274
+ for (const other of others) {
1275
+ if (other.status !== "MANIFEST_FILE_READY") continue;
1276
+ for (const e of Object.values(other.manifest?.entries ?? {})) {
1277
+ const dest = e.storageConnectionName ?? e.connectionName;
1278
+ if (dest && e.physicalTableName) {
1279
+ stillReferenced.add(tableKey(dest, e.physicalTableName));
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ for (const entry of builtThisRun) {
1285
+ const dest = entry.storageConnectionName;
1286
+ const table = entry.physicalTableName;
1287
+ if (!dest || !table) continue;
1288
+ if (stillReferenced.has(tableKey(dest, table))) {
1289
+ logger.info(
1290
+ "Keeping a table from a failed run: a live manifest still serves it",
1291
+ { destinationName: dest, physicalTableName: table },
1292
+ );
1293
+ continue;
1294
+ }
1295
+ try {
1296
+ await dropStorageTable({
1297
+ destinationName: dest,
1298
+ destinationConnection: environment.getApiConnection(dest),
1299
+ physicalTableName: table,
1300
+ environmentPath: environment.getEnvironmentPath(),
1301
+ });
1302
+ recordDropTables("success", "storage");
1303
+ logger.info("Reclaimed a table stranded by a failed build", {
1304
+ destinationName: dest,
1305
+ physicalTableName: table,
1306
+ });
1307
+ } catch (dropErr) {
1308
+ recordDropTables("failure", "storage");
1309
+ logger.warn(
1310
+ "Failed to reclaim a table stranded by a failed build",
1311
+ {
1312
+ destinationName: dest,
1313
+ physicalTableName: table,
1314
+ error: redactConnectionSecrets(
1315
+ errMessage(dropErr),
1316
+ environment.getApiConnection(dest),
1317
+ ),
1318
+ },
1319
+ );
1320
+ }
1321
+ }
1322
+ } catch (err) {
1323
+ // Never let cleanup mask the build failure that triggered it.
1324
+ logger.warn("Failed-run table reclaim did not complete", {
1325
+ error: errMessage(err),
1326
+ });
1327
+ }
1328
+ }
1329
+
787
1330
  /**
788
1331
  * Build a single instructed source into its assigned physical table.
789
1332
  * COPY uses a staging table + atomic rename for crash-safety; the staging
@@ -795,14 +1338,72 @@ export class MaterializationService {
795
1338
  connection: MalloyConnection,
796
1339
  connectionDigests: Record<string, string>,
797
1340
  manifest: Manifest,
1341
+ environment: BuildEnvironment,
1342
+ builtEntries: Record<string, ManifestEntry>,
798
1343
  ): Promise<ManifestEntry> {
799
1344
  const sourceEntityId = instruction.sourceEntityId;
800
1345
  const physicalTableName = instruction.physicalTableName;
1346
+ const isStorageBuild =
1347
+ !!instruction.destination && getPersistStorageMode() !== "off";
1348
+ // ANY warehouse-executed build SQL — a colocated CTAS or a storage build's
1349
+ // native passthrough — runs against the SOURCE warehouse, which cannot see
1350
+ // a storage-materialized upstream's DuckDB/DuckLake table (a different
1351
+ // engine). Substituting that upstream's lake table name into warehouse SQL
1352
+ // would reference a table the warehouse can't resolve (a confusing "table
1353
+ // not found"), whether the downstream is a storage build OR a colocated
1354
+ // source reading a storage upstream. So exclude storage-materialized
1355
+ // upstreams from the build manifest in BOTH cases: non-strict, they INLINE
1356
+ // (recompute from raw against the warehouse) so a chained source still
1357
+ // materializes; under `strictUpstreams` the excluded reference becomes a
1358
+ // clean strict-miss error (the orchestrated contract — don't silently
1359
+ // recompute). A stack-on-the-parent build reads the parent's lake table
1360
+ // instead, but via a separate DuckDB recompile that does NOT use this
1361
+ // warehouse buildSQL — this remains its recompute-from-raw fallback.
1362
+ const buildManifest = manifestExcludingStorage(manifest, builtEntries);
801
1363
  const buildSQL = persistSource.getSQL({
802
- buildManifest: manifest.buildManifest,
1364
+ buildManifest,
803
1365
  connectionDigests,
804
1366
  });
805
1367
 
1368
+ // `storage=` build: materialize into a DuckDB/DuckLake destination via a
1369
+ // build-scoped session (never on the source or serve connection). Diverges
1370
+ // fully from the in-warehouse CTAS below — different engine, credential
1371
+ // federation, and a captured authoritative schema for the serve transform.
1372
+ // Gated by the kill switch: when off, ignore a destination and do a colocated build.
1373
+ if (isStorageBuild) {
1374
+ // Stack-on-the-parent detection: does the source's SQL change when storage upstreams
1375
+ // are PRESENT in the manifest (mapped to their lake tables) vs EXCLUDED
1376
+ // (inlined, the buildSQL above)? If so it reads a storage-materialized
1377
+ // upstream, so it can be built by reading the parent's lake table
1378
+ // ("stack on the parent") instead of recomputing from raw. The compare
1379
+ // is graph-free and self-contained; a single-source build's two SQLs are
1380
+ // identical, so it skips straight to the passthrough below.
1381
+ const dependsOnStorageUpstream =
1382
+ persistSource.getSQL({
1383
+ buildManifest: manifest.buildManifest,
1384
+ connectionDigests,
1385
+ }) !== buildSQL;
1386
+ // Materialize ONLY the source's PUBLIC columns. `getSQL` projects every
1387
+ // underlying column, including ones the source hides (`except:`, non-public
1388
+ // access modifiers). Query reachability is bounded by the declared
1389
+ // ::Shape, which the serve transform narrows to the public surface
1390
+ // (proven by the shape-bounds-physical-columns scenario) — so what this
1391
+ // prevents is the hidden column's VALUES sitting at rest in the
1392
+ // destination store, reachable by direct catalog access and possibly
1393
+ // across a trust boundary the source's visibility was meant to hold.
1394
+ // Refuses the build (422) if the public surface can't be determined.
1395
+ const publicBuildSQL = projectToPublicColumns(persistSource, buildSQL);
1396
+ return this.buildOneSourceIntoStorage(
1397
+ persistSource,
1398
+ instruction,
1399
+ manifest,
1400
+ environment,
1401
+ publicBuildSQL,
1402
+ builtEntries,
1403
+ dependsOnStorageUpstream,
1404
+ );
1405
+ }
1406
+
806
1407
  const bareName = bareTableName(physicalTableName);
807
1408
  const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
808
1409
  // The control plane sends the logical (unquoted) physical name; dialect-
@@ -849,7 +1450,7 @@ export class MaterializationService {
849
1450
  manifest.update(sourceEntityId, { tableName: quotedPhysical });
850
1451
 
851
1452
  const durationMs = Math.round(performance.now() - startTime);
852
- recordSourceBuildDuration(durationMs);
1453
+ recordSourceBuildDuration(durationMs, "in_warehouse");
853
1454
  logger.info(`Built materialized source ${persistSource.name}`, {
854
1455
  physicalTableName,
855
1456
  durationMs,
@@ -866,6 +1467,315 @@ export class MaterializationService {
866
1467
  };
867
1468
  }
868
1469
 
1470
+ /**
1471
+ * Materialize a source into a `storage=` destination (a DuckDB/DuckLake
1472
+ * connection) via a native query-passthrough CTAS on a build-scoped session.
1473
+ * Records the destination connection and the captured authoritative DuckDB
1474
+ * schema on the manifest entry so the source can later be served
1475
+ * cross-dialect from the destination (the serve transform declares that
1476
+ * schema). `connectionName` still names the SOURCE warehouse (where data is
1477
+ * read from); `storageConnectionName` names where the table now lives.
1478
+ */
1479
+ private async buildOneSourceIntoStorage(
1480
+ persistSource: PersistSource,
1481
+ instruction: BuildInstruction,
1482
+ manifest: Manifest,
1483
+ environment: BuildEnvironment,
1484
+ buildSQL: string,
1485
+ builtEntries: Record<string, ManifestEntry>,
1486
+ dependsOnStorageUpstream: boolean,
1487
+ ): Promise<ManifestEntry> {
1488
+ const sourceEntityId = instruction.sourceEntityId;
1489
+ const physicalTableName = instruction.physicalTableName;
1490
+ const destinationName = instruction.destination!;
1491
+ const sourceConnection = environment.getApiConnection(
1492
+ persistSource.connectionName,
1493
+ );
1494
+ const destinationConnection =
1495
+ environment.getApiConnection(destinationName);
1496
+
1497
+ const startTime = performance.now();
1498
+ let result;
1499
+
1500
+ // Stack on the parent: a source that reads a storage-materialized
1501
+ // upstream is built by reading the parent's STORED lake table instead of
1502
+ // recomputing it from raw against the warehouse. This reuses the
1503
+ // parent's work and is consistent-by-construction (the downstream is a pure
1504
+ // function of the parent's stored rows). Attempted only when the source
1505
+ // actually reads a storage upstream; on any ineligibility (a parent
1506
+ // refinement not carried into the rebind, a live-warehouse join, a
1507
+ // cross-catalog parent) it throws and we fall back: recompute-from-raw when
1508
+ // non-strict, or a loud refusal under `strictUpstreams` (the orchestrated
1509
+ // contract — never silently recompute).
1510
+ if (dependsOnStorageUpstream) {
1511
+ try {
1512
+ result = await this.buildDownstreamViaParents(
1513
+ persistSource,
1514
+ destinationName,
1515
+ destinationConnection,
1516
+ builtEntries,
1517
+ environment,
1518
+ physicalTableName,
1519
+ );
1520
+ recordChainedStorageBuild("parent_reuse");
1521
+ } catch (err) {
1522
+ // Same redaction contract as the recompute-from-raw path below: this
1523
+ // branch's read-write ATTACH or CTAS can fail with the offending SQL
1524
+ // echoed back, catalog `password=` included. Redact before the
1525
+ // message reaches the thrown run `error` or the log.
1526
+ const safeDetail = redactConnectionSecrets(
1527
+ errMessage(err),
1528
+ sourceConnection,
1529
+ destinationConnection,
1530
+ );
1531
+ // Only a SHAPE failure justifies recomputing from raw: the downstream
1532
+ // could not be expressed over its rebound parents, so building it the
1533
+ // other way is a genuinely different attempt. An INFRA failure (the
1534
+ // read-write attach, the CTAS, the destination being down) is not —
1535
+ // recompute-from-raw writes to the SAME destination and fails the same
1536
+ // way, and metering it as `inline_fallback` files an outage in the same
1537
+ // bucket as a legitimate shape miss.
1538
+ if (!(err instanceof MaterializationEligibilityError)) {
1539
+ recordChainedStorageBuild("infra_failure");
1540
+ recordStorageBuildFailure(destinationName);
1541
+ throw new Error(
1542
+ `Failed to materialize chained source '${persistSource.name}' ` +
1543
+ `into storage destination '${destinationName}': ${safeDetail}`,
1544
+ );
1545
+ }
1546
+ if (manifest.strict) {
1547
+ recordChainedStorageBuild("strict_refused");
1548
+ recordStorageBuildFailure(destinationName);
1549
+ throw new Error(
1550
+ `Failed to materialize chained source '${persistSource.name}' ` +
1551
+ `into storage destination '${destinationName}' by reading ` +
1552
+ `its materialized upstream, and strict upstreams forbid ` +
1553
+ `recomputing it from raw: ${safeDetail}`,
1554
+ );
1555
+ }
1556
+ recordChainedStorageBuild("inline_fallback");
1557
+ logger.warn(
1558
+ "Chained storage build could not reuse the parent table; " +
1559
+ "recomputing the upstream from raw",
1560
+ {
1561
+ sourceName: persistSource.name,
1562
+ destinationName,
1563
+ reason: safeDetail,
1564
+ },
1565
+ );
1566
+ }
1567
+ }
1568
+
1569
+ // Recompute from raw (the single-source passthrough): materialize `buildSQL`
1570
+ // (with storage upstreams inlined) in the source warehouse and CTAS the result
1571
+ // into the destination. Skipped when stacking on the parent already produced
1572
+ // the table above.
1573
+ if (!result) {
1574
+ try {
1575
+ result = await buildSourceIntoStorage({
1576
+ destinationName,
1577
+ destinationConnection,
1578
+ sourceConnection,
1579
+ buildSQL,
1580
+ physicalTableName,
1581
+ environmentPath: environment.getEnvironmentPath(),
1582
+ });
1583
+ } catch (err) {
1584
+ // Redaction: a failed federation / passthrough / attach
1585
+ // error can echo source- or catalog-connection detail (connstrings,
1586
+ // account names, service-account JSON) from the DuckDB engine. Strip
1587
+ // the actual credential VALUES but keep the message, so an operator
1588
+ // sees a legible, actionable error (e.g. "schema 'analytics' not
1589
+ // found" when a `name=schema.table` target's schema wasn't
1590
+ // provisioned) rather than an opaque failure — without leaking
1591
+ // secrets into the user-visible run `error` column.
1592
+ const safeDetail = redactConnectionSecrets(
1593
+ errMessage(err),
1594
+ sourceConnection,
1595
+ destinationConnection,
1596
+ );
1597
+ recordStorageBuildFailure(destinationName);
1598
+ logger.warn("Storage materialization build failed", {
1599
+ sourceName: persistSource.name,
1600
+ destinationName,
1601
+ error: safeDetail,
1602
+ });
1603
+ throw new Error(
1604
+ `Failed to materialize source '${persistSource.name}' into ` +
1605
+ `storage destination '${destinationName}': ${safeDetail}`,
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ // Build-time servability gate: the serve-shape must compile in DuckDB
1611
+ // against the authoritative post-build schema, or the build is refused
1612
+ // (HTTP 422) — a serve-time execution error turned into a fail-loud
1613
+ // build-time refusal. Outside the redaction try above so the eligibility
1614
+ // error surfaces as-is (it carries no connection secrets). Runs here, in
1615
+ // stage→validate, because it needs the captured schema.
1616
+ try {
1617
+ await assertStorageServeShapeCompiles({
1618
+ destinationName,
1619
+ sourceName: persistSource.name,
1620
+ virtualHandle: sourceEntityId,
1621
+ physicalTableName,
1622
+ schema: result.schema,
1623
+ });
1624
+ } catch (gateErr) {
1625
+ // The table was already CTAS'd before this post-build gate, and no
1626
+ // manifest entry records it yet — so a refusal would strand it where
1627
+ // manifest-driven GC (which only drops names it recorded building) can
1628
+ // never see it. Best-effort drop of the just-built table so a refused
1629
+ // build doesn't leak an orphaned table.
1630
+ //
1631
+ // Note (in-place naming): the CTAS above already replaced any prior
1632
+ // generation at this name, so a failed-gate rebuild has no earlier
1633
+ // table to fall back to — the source reverts to serving live until a
1634
+ // subsequent successful build. Rollback-safe regeneration (keep the
1635
+ // prior generation until the new one is validated) requires the
1636
+ // host-generational orchestrated path, not the auto-run server.
1637
+ try {
1638
+ await dropStorageTable({
1639
+ destinationName,
1640
+ destinationConnection,
1641
+ physicalTableName,
1642
+ environmentPath: environment.getEnvironmentPath(),
1643
+ });
1644
+ } catch (dropErr) {
1645
+ logger.warn(
1646
+ "Failed to drop a storage table stranded by a serve-shape gate " +
1647
+ "refusal (physical leak)",
1648
+ {
1649
+ sourceName: persistSource.name,
1650
+ destinationName,
1651
+ physicalTableName,
1652
+ // The drop runs on a read-write attach, so a failure can echo
1653
+ // the catalog connstring.
1654
+ error: redactConnectionSecrets(
1655
+ errMessage(dropErr),
1656
+ sourceConnection,
1657
+ destinationConnection,
1658
+ ),
1659
+ },
1660
+ );
1661
+ }
1662
+ throw gateErr;
1663
+ }
1664
+
1665
+ // Make this table visible to downstream sources built later in this run,
1666
+ // so a chained storage source can stack on it (above).
1667
+ manifest.update(sourceEntityId, { tableName: physicalTableName });
1668
+
1669
+ const durationMs = Math.round(performance.now() - startTime);
1670
+ recordSourceBuildDuration(durationMs, "storage");
1671
+ logger.info(
1672
+ `Built materialized source ${persistSource.name} into storage`,
1673
+ {
1674
+ physicalTableName,
1675
+ storageConnectionName: result.storageConnectionName,
1676
+ columns: result.schema.length,
1677
+ durationMs,
1678
+ },
1679
+ );
1680
+
1681
+ return {
1682
+ sourceEntityId,
1683
+ sourceName: persistSource.name,
1684
+ materializedTableId: instruction.materializedTableId,
1685
+ physicalTableName,
1686
+ connectionName: persistSource.connectionName,
1687
+ storageConnectionName: result.storageConnectionName,
1688
+ schema: result.schema,
1689
+ realization: instruction.realization,
1690
+ rowCount: null,
1691
+ };
1692
+ }
1693
+
1694
+ /**
1695
+ * Stack on the parent: materialize a chained storage source by reading its
1696
+ * already-materialized upstream(s) from the SAME destination store. Rebinds
1697
+ * every same-destination materialized upstream to a virtual source (base-only,
1698
+ * the captured schema), re-declares the downstream over them (its definition
1699
+ * text lifted from the author's model), and hands the assembled transient
1700
+ * model to {@link buildDownstreamIntoStorage}, which compiles it against the
1701
+ * build session and CTASes the downstream's SQL — now reading the parents'
1702
+ * lake tables — into the destination.
1703
+ *
1704
+ * Throws (⇒ the caller falls back to recompute-from-raw) when there is no
1705
+ * same-destination upstream to build on, the definition text can't be lifted,
1706
+ * or the transient model doesn't compile (a parent refinement not carried, a
1707
+ * live-warehouse join, a cross-catalog parent).
1708
+ */
1709
+ private async buildDownstreamViaParents(
1710
+ persistSource: PersistSource,
1711
+ destinationName: string,
1712
+ destinationConnection: ApiConnection,
1713
+ builtEntries: Record<string, ManifestEntry>,
1714
+ environment: BuildEnvironment,
1715
+ physicalTableName: string,
1716
+ ): Promise<StorageBuildResult> {
1717
+ // Rebind every upstream materialized into THIS destination. A parent in a
1718
+ // DIFFERENT destination is absent here, so the downstream def fails to
1719
+ // compile against the rebind model and the caller falls back — cross-catalog
1720
+ // parent reuse is out of scope for the spike.
1721
+ const upstreams: ServeBinding[] = deriveServeBindings(
1722
+ builtEntries,
1723
+ ).filter((b) => b.connectionName === destinationName);
1724
+ if (upstreams.length === 0) {
1725
+ throw new MaterializationEligibilityError({
1726
+ message:
1727
+ "no materialized upstream is available in this destination to build on",
1728
+ });
1729
+ }
1730
+ const downstreamDefText = this.liftDownstreamDefText(persistSource);
1731
+ if (!downstreamDefText) {
1732
+ throw new MaterializationEligibilityError({
1733
+ message:
1734
+ "could not recover the downstream source definition text from the model",
1735
+ });
1736
+ }
1737
+ const transientModel = buildChainedStorageBuildModel({
1738
+ upstreams,
1739
+ downstreamName: persistSource.name,
1740
+ downstreamDefText,
1741
+ destinationName,
1742
+ });
1743
+ return buildDownstreamIntoStorage({
1744
+ destinationName,
1745
+ destinationConnection,
1746
+ transientModel,
1747
+ downstreamName: persistSource.name,
1748
+ virtualMap: buildVirtualMap(upstreams),
1749
+ physicalTableName,
1750
+ environmentPath: environment.getEnvironmentPath(),
1751
+ });
1752
+ }
1753
+
1754
+ /**
1755
+ * Lift the verbatim RHS of a persist source's `source: <name> is …`
1756
+ * declaration from the author's model file (same technique as the serve
1757
+ * transform's join/view lift): read the file named by the source's compiled
1758
+ * `location`, slice the covered range. A top-level source's range starts just
1759
+ * after the `source: ` keyword, so the result is `<name> is <def>` and the
1760
+ * transient-model assembler prepends `source: `. Returns undefined (⇒ fall
1761
+ * back) when there is no file-backed location or the file can't be read/sliced.
1762
+ */
1763
+ private liftDownstreamDefText(
1764
+ persistSource: PersistSource,
1765
+ ): string | undefined {
1766
+ const location = (
1767
+ persistSource._explore as unknown as { location?: SourceLocation }
1768
+ ).location;
1769
+ if (!location?.url?.startsWith("file:")) return undefined;
1770
+ let text: string;
1771
+ try {
1772
+ text = readFileSync(fileURLToPath(location.url), "utf8");
1773
+ } catch {
1774
+ return undefined;
1775
+ }
1776
+ return sliceSourceRange(text, location.range);
1777
+ }
1778
+
869
1779
  // ==================== CANCELLATION ====================
870
1780
 
871
1781
  /** Cancel a non-terminal materialization. */
@@ -932,6 +1842,74 @@ export class MaterializationService {
932
1842
  }
933
1843
 
934
1844
  await this.repository.deleteMaterialization(id);
1845
+
1846
+ // Re-derive the package's serve routing from the latest REMAINING
1847
+ // successful materialization — BOTH tiers. The deleted run may have been
1848
+ // the one bound for serving — and with `dropTables` its tables are now gone
1849
+ // — so without this a query would keep routing (schema-on-faith, no
1850
+ // run-time fallback) to a deleted/dropped table and error until the next
1851
+ // reload or build. Rebinding picks the next-latest generation, or CLEARS
1852
+ // the bindings when none remain (empty ⇒ serve live).
1853
+ await this.rebindServeBindingsAfterDelete(environmentName, packageName);
1854
+ }
1855
+
1856
+ /**
1857
+ * Re-derive a package's serve routing from its latest remaining successful
1858
+ * materialization and push it onto the loaded models — BOTH tiers, mirroring
1859
+ * the load-time {@link Environment.rebindServeBindingsFromLocalStore}. Called
1860
+ * after a delete so serving never points at a removed table; picks the
1861
+ * next-latest generation, or clears the bindings when none remain.
1862
+ * Best-effort (a failure logs and leaves the current bindings — a later
1863
+ * reload/build re-derives).
1864
+ *
1865
+ * The manifest is split by tier:
1866
+ * - **colocated** (same-connection) → re-derived regardless of
1867
+ * `PERSIST_STORAGE_MODE`: colocated is the v0 path and is not gated by the
1868
+ * storage kill switch, so a reclaimed colocated table must not be left
1869
+ * routed even when the tier is off.
1870
+ * - **storage=** (cross-connection) → re-derived only when the tier is not
1871
+ * `off` (its serve routing requires the tier on; an off deployment does no
1872
+ * extra work here).
1873
+ */
1874
+ private async rebindServeBindingsAfterDelete(
1875
+ environmentName: string,
1876
+ packageName: string,
1877
+ ): Promise<void> {
1878
+ try {
1879
+ const environmentId = await this.resolveEnvironmentId(environmentName);
1880
+ // "" excludes nothing — the deleted record is already gone from the repo.
1881
+ const entries = await this.getMostRecentManifestEntries(
1882
+ environmentId,
1883
+ packageName,
1884
+ "",
1885
+ );
1886
+ const environment = await this.environmentStore.getEnvironment(
1887
+ environmentName,
1888
+ false,
1889
+ );
1890
+ const { tableNameManifest, storageEntries } = splitManifestEntries(
1891
+ entries,
1892
+ `post-delete rebind (package ${packageName})`,
1893
+ );
1894
+ // Colocated: re-derive (or clear) regardless of mode.
1895
+ await environment.bindPackageColocatedServeManifest(
1896
+ packageName,
1897
+ tableNameManifest,
1898
+ );
1899
+ // Storage=: only meaningful when the tier is not off.
1900
+ if (getPersistStorageMode() !== "off") {
1901
+ await environment.bindPackageStorageServeBindings(
1902
+ packageName,
1903
+ storageEntries,
1904
+ );
1905
+ }
1906
+ } catch (err) {
1907
+ logger.warn(
1908
+ "Failed to rebind serve bindings after delete (leaving current " +
1909
+ "bindings; a reload/build will re-derive)",
1910
+ { packageName, error: errMessage(err) },
1911
+ );
1912
+ }
935
1913
  }
936
1914
 
937
1915
  /**
@@ -940,6 +1918,14 @@ export class MaterializationService {
940
1918
  * issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
941
1919
  * leftover staging table. Failures are logged and swallowed so a partial
942
1920
  * cleanup never blocks deletion of the record.
1921
+ *
1922
+ * A physical name is dropped only when NO other remaining MANIFEST_FILE_READY
1923
+ * run references it. Physical names are the source's `name=` verbatim (or a
1924
+ * host-assigned name), so multiple generations of a source share one physical
1925
+ * name — dropping a superseded record's table would otherwise take out the
1926
+ * table the current generation still serves. The skip incidentally also
1927
+ * protects the colocated case, where generations likewise share a
1928
+ * name.
943
1929
  */
944
1930
  private async dropMaterializedTables(
945
1931
  environmentName: string,
@@ -958,6 +1944,29 @@ export class MaterializationService {
958
1944
  const pkg = await environment.getPackage(packageName, false);
959
1945
  const connectionCache = new Map<string, MalloyConnection>();
960
1946
 
1947
+ // Physical names still referenced by ANOTHER MANIFEST_FILE_READY run for
1948
+ // this package (keyed destination-and-name), so a shared name is never
1949
+ // dropped out from under a live generation. `m` is still in the repo at
1950
+ // this point (deletion happens after this sweep), so exclude it by id.
1951
+ const tableKey = (dest: string, table: string) => `${dest}:${table}`;
1952
+ const stillReferenced = new Set<string>();
1953
+ const environmentId = await this.resolveEnvironmentId(environmentName);
1954
+ const others =
1955
+ (await this.repository.listMaterializations(
1956
+ environmentId,
1957
+ packageName,
1958
+ )) ?? [];
1959
+ for (const other of others) {
1960
+ if (other.id === m.id) continue;
1961
+ if (other.status !== "MANIFEST_FILE_READY") continue;
1962
+ for (const e of Object.values(other.manifest?.entries ?? {})) {
1963
+ const dest = e.storageConnectionName ?? e.connectionName;
1964
+ if (dest && e.physicalTableName) {
1965
+ stillReferenced.add(tableKey(dest, e.physicalTableName));
1966
+ }
1967
+ }
1968
+ }
1969
+
961
1970
  for (const entry of Object.values(entries)) {
962
1971
  const connectionName = entry.connectionName;
963
1972
  const physicalTableName = entry.physicalTableName;
@@ -969,6 +1978,62 @@ export class MaterializationService {
969
1978
  continue;
970
1979
  }
971
1980
 
1981
+ // Do not drop a table another live generation still serves (shared
1982
+ // physical name — see the method doc).
1983
+ const destForKey = entry.storageConnectionName ?? connectionName;
1984
+ if (stillReferenced.has(tableKey(destForKey, physicalTableName))) {
1985
+ logger.info(
1986
+ "Skipping drop: table still referenced by another materialization",
1987
+ {
1988
+ materializationId: m.id,
1989
+ physicalTableName,
1990
+ destination: destForKey,
1991
+ },
1992
+ );
1993
+ continue;
1994
+ }
1995
+
1996
+ // A storage= table lives in `storageConnectionName` (a DuckDB/DuckLake
1997
+ // destination), not in `connectionName` (the source warehouse), and
1998
+ // dropping it needs a build-scoped READ-WRITE attach — the serve attach
1999
+ // is read-only — so it is dropped on its own RW session rather than on
2000
+ // the (wrong-engine, read-only) source connection. Best-effort: a
2001
+ // failure is logged and the sweep continues, so one unreachable
2002
+ // destination never blocks reclaiming the rest.
2003
+ if (entry.storageConnectionName) {
2004
+ const destinationConnection = environment.getApiConnection(
2005
+ entry.storageConnectionName,
2006
+ );
2007
+ try {
2008
+ await dropStorageTable({
2009
+ destinationName: entry.storageConnectionName,
2010
+ destinationConnection,
2011
+ physicalTableName,
2012
+ environmentPath: environment.getEnvironmentPath(),
2013
+ });
2014
+ recordDropTables("success", "storage");
2015
+ logger.info("Dropped materialized storage table on delete", {
2016
+ materializationId: m.id,
2017
+ physicalTableName,
2018
+ storageConnectionName: entry.storageConnectionName,
2019
+ });
2020
+ } catch (err) {
2021
+ recordDropTables("failure", "storage");
2022
+ logger.warn("Failed to drop a storage-materialized table", {
2023
+ materializationId: m.id,
2024
+ physicalTableName,
2025
+ storageConnectionName: entry.storageConnectionName,
2026
+ // The drop attaches read-write, so a failure can echo the
2027
+ // catalog connstring.
2028
+ error: redactConnectionSecrets(
2029
+ errMessage(err),
2030
+ destinationConnection,
2031
+ ),
2032
+ });
2033
+ }
2034
+ continue;
2035
+ }
2036
+
972
2037
  try {
973
2038
  let connection = connectionCache.get(connectionName);
974
2039
  if (!connection) {
@@ -994,14 +2059,14 @@ export class MaterializationService {
994
2059
  dialect,
995
2060
  )}`,
996
2061
  );
997
- recordDropTables("success");
2062
+ recordDropTables("success", "in_warehouse");
998
2063
  logger.info("Dropped materialized table on delete", {
999
2064
  materializationId: m.id,
1000
2065
  physicalTableName,
1001
2066
  connectionName,
1002
2067
  });
1003
2068
  } catch (err) {
1004
- recordDropTables("failure");
2069
+ recordDropTables("failure", "in_warehouse");
1005
2070
  logger.warn("Failed to drop materialized table on delete", {
1006
2071
  materializationId: m.id,
1007
2072
  physicalTableName,