@malloy-publisher/server 0.0.230 → 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
@@ -0,0 +1,681 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import {
3
+ FixedConnectionMap,
4
+ InMemoryURLReader,
5
+ type PersistSource,
6
+ Runtime,
7
+ } from "@malloydata/malloy";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import type { components } from "../api";
12
+ import { BadRequestError, MaterializationEligibilityError } from "../errors";
13
+ import { logger } from "../logger";
14
+ import { errMessage } from "../utils";
15
+ import { quoteIdentifier, quoteManifestTablePath } from "./quoting";
16
+ import { projectToPublicColumns } from "./build_plan";
17
+ import {
18
+ attachDuckLakeReadWrite,
19
+ escapeSQL,
20
+ federateSourceForPassthrough,
21
+ type FederatedSourceType,
22
+ } from "./connection";
23
+ import {
24
+ assertServesInDuckDB,
25
+ type ServeBinding,
26
+ } from "./materialization_serve_transform";
27
+
28
+ type ApiConnection = components["schemas"]["Connection"];
29
+
30
+ /**
31
+ * The process-wide session the build-time servability gate compiles against.
32
+ * Created on first use and deliberately never disposed: it holds no attach and
33
+ * no credentials, and recreating it per build was the single largest source of
34
+ * per-build memory growth.
35
+ */
36
+ let sharedGateSession: DuckDBConnection | undefined;
37
+ type WireColumn = components["schemas"]["Column"];
38
+
39
+ /** Source warehouse types the native query-passthrough build supports. */
40
+ const PASSTHROUGH_SOURCE_TYPES: readonly FederatedSourceType[] = [
41
+ "bigquery",
42
+ "snowflake",
43
+ "postgres",
44
+ ];
45
+
46
+ /** Destination (storage) connection types the build can materialize INTO. */
47
+ const STORAGE_DESTINATION_TYPES = ["ducklake", "duckdb"] as const;
48
+
49
+ /**
50
+ * Wrap a source-dialect SELECT as a native per-engine query-passthrough call so
51
+ * a build-scoped DuckDB session can execute it against the source warehouse and
52
+ * stream back only result-sized rows. The compiled SELECT is embedded verbatim
53
+ * as a SQL string literal (single-quote-escaped) — the warehouse computes; the
54
+ * base tables never enter DuckDB. `handle` is the per-engine reference
55
+ * {@link federateSourceForPassthrough} established on the session (a projectId,
56
+ * a secret name, or an ATTACH alias). Argument ORDER differs per engine, which
57
+ * is the whole reason this is centralized.
58
+ */
59
+ export function wrapPassthrough(
60
+ sourceType: FederatedSourceType,
61
+ handle: string,
62
+ innerSQL: string,
63
+ ): string {
64
+ const sql = `'${escapeSQL(innerSQL)}'`;
65
+ const h = `'${escapeSQL(handle)}'`;
66
+ switch (sourceType) {
67
+ case "bigquery":
68
+ // bigquery_query(<project/db>, <sql>) — secret resolved by bq:// scope.
69
+ return `SELECT * FROM bigquery_query(${h}, ${sql})`;
70
+ case "postgres":
71
+ // postgres_query(<attached-db alias>, <sql>).
72
+ return `SELECT * FROM postgres_query(${h}, ${sql})`;
73
+ case "snowflake":
74
+ // snowflake_query(<sql>, <secret name>) — SQL FIRST for this engine.
75
+ return `SELECT * FROM snowflake_query(${sql}, ${h})`;
76
+ default: {
77
+ // Exhaustiveness: a new FederatedSourceType must add a case above.
78
+ const exhaustive: never = sourceType;
79
+ throw new BadRequestError(
80
+ `Unsupported passthrough source type: ${String(exhaustive)}`,
81
+ );
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Narrow a connection's declared type to a supported passthrough source type. */
87
+ export function passthroughSourceType(
88
+ sourceConnection: ApiConnection,
89
+ ): FederatedSourceType {
90
+ const type = sourceConnection.type;
91
+ if ((PASSTHROUGH_SOURCE_TYPES as readonly string[]).includes(type ?? "")) {
92
+ return type as FederatedSourceType;
93
+ }
94
+ throw new BadRequestError(
95
+ `Cannot materialize a '${type}' source into a storage destination: the ` +
96
+ `native query-passthrough build supports source connections of type ` +
97
+ `${PASSTHROUGH_SOURCE_TYPES.join(", ")} only.`,
98
+ );
99
+ }
100
+
101
+ /**
102
+ * A build-scoped DuckDB session on its OWN in-memory instance, plus a disposer
103
+ * that closes it and removes the throwaway working directory that pins its
104
+ * instance identity.
105
+ *
106
+ * Isolation is load-bearing for multi-tenant safety. `@malloydata/db-duckdb`
107
+ * pools DuckDB instances in a process-global cache keyed by a "share key" that
108
+ * deliberately EXCLUDES the connection name, and it never gives a `:memory:`
109
+ * primary a private instance — so build sessions built with identical default
110
+ * config get pooled onto ONE shared in-memory instance whenever their lifetimes
111
+ * overlap (notably with the long-lived serve connection). Pooled together, they
112
+ * collide on their transient ATTACH aliases: two builds (or two environments
113
+ * with same-named-but-different-credential source/destination connections) both
114
+ * `ATTACH … AS orders_pg` / `AS lake` on the shared instance and one clobbers
115
+ * the other — a cross-tenant read (source) or WRITE (read-write destination).
116
+ * DuckDB itself isolates SEPARATE instances cleanly; the collision is purely an
117
+ * artifact of the shared pool.
118
+ *
119
+ * Fix: give each session a UNIQUE `workingDirectory`, which is part of the share
120
+ * key, so it lands its OWN in-memory instance — its own catalog, secrets, and
121
+ * attaches, torn down on close. Crucially `databasePath` stays exactly
122
+ * `:memory:` (NOT a temp file): a `:memory:` primary lets a DuckLake attach
123
+ * auto-initialize a fresh catalog, whereas a file primary does not. The working
124
+ * directory is only there to make the share key unique — nothing is written to
125
+ * it (every real path the build uses is absolute), so it stays empty and is
126
+ * removed on dispose (best-effort; a leftover empty dir is benign).
127
+ */
128
+ export function createIsolatedBuildSession(sessionName: string): {
129
+ session: DuckDBConnection;
130
+ dispose: () => Promise<void>;
131
+ } {
132
+ const workDir = mkdtempSync(path.join(os.tmpdir(), "malloy-build-"));
133
+ // The disposer that owns removing workDir does not exist until this function
134
+ // returns, so a throw from the constructor would strand the directory with
135
+ // nothing left holding a reference to it.
136
+ let session: DuckDBConnection;
137
+ try {
138
+ session = new DuckDBConnection(sessionName, ":memory:", workDir);
139
+ } catch (err) {
140
+ rmSync(workDir, { recursive: true, force: true });
141
+ throw err;
142
+ }
143
+ const dispose = async () => {
144
+ // `close()` alone does not end the session. It refcount-decrements the
145
+ // DuckDBInstance, but the node-api Connection holds a C++ ClientContext that
146
+ // keeps the refcount above zero — so the in-memory database survives, and with
147
+ // it the destination's ATTACH and the source's federated credentials. Measured:
148
+ // 2 idle Postgres backends stranded per build, freed only at process exit,
149
+ // where the whole point of a per-build session is that they exist only for
150
+ // the build.
151
+ //
152
+ // db-duckdb cannot do this itself: 0.0.389 disconnected unconditionally and
153
+ // had to be reverted (PR #2793) because malloy's translate layer holds
154
+ // weak_ptrs to the C++ Connection and the language server segfaulted. A build
155
+ // session is different in the way that matters — server-owned, single-use, and
156
+ // nothing outside this function ever sees it — so it can release what a
157
+ // general-purpose connection cannot. Reached through a cast because the handle
158
+ // is library-internal; if db-duckdb ever grows an explicit hard-close, use it.
159
+ const nodeConnection = (
160
+ session as unknown as { connection?: { disconnectSync?: () => void } }
161
+ ).connection;
162
+ try {
163
+ await session.close();
164
+ } catch (err) {
165
+ logger.warn("Failed to close build session (leaked session)", {
166
+ sessionName,
167
+ error: err instanceof Error ? err.message : String(err),
168
+ });
169
+ }
170
+ try {
171
+ nodeConnection?.disconnectSync?.();
172
+ } catch (err) {
173
+ logger.warn("Failed to disconnect build session (leaked connection)", {
174
+ sessionName,
175
+ error: err instanceof Error ? err.message : String(err),
176
+ });
177
+ }
178
+ try {
179
+ rmSync(workDir, { recursive: true, force: true });
180
+ } catch (err) {
181
+ logger.warn("Failed to remove build session working directory", {
182
+ sessionName,
183
+ workDir,
184
+ error: err instanceof Error ? err.message : String(err),
185
+ });
186
+ }
187
+ };
188
+ return { session, dispose };
189
+ }
190
+
191
+ /** Result of building one source into a storage destination. */
192
+ export interface StorageBuildResult {
193
+ /** The connection the physical table now lives in (the destination). */
194
+ storageConnectionName: string;
195
+ /** Authoritative DuckDB column schema, captured post-build via DESCRIBE. */
196
+ schema: WireColumn[];
197
+ }
198
+
199
+ /**
200
+ * Build one persist source into a `storage=` destination via native
201
+ * query-passthrough CTAS, on a *build-scoped* DuckDB session that is created,
202
+ * used, and disposed here — so the read-write destination attach and the source
203
+ * credential federation exist ONLY for the build's duration and NEVER on the
204
+ * serve connection (the least-privilege boundary the design requires).
205
+ *
206
+ * Steps: create a private build session → attach the destination read-write
207
+ * → federate the source's credentials on demand → `CREATE OR REPLACE TABLE`
208
+ * (qualified with the destination catalog) whose FROM is the source passthrough
209
+ * → capture the built table's authoritative schema (DESCRIBE) → dispose the
210
+ * session (releasing every credential/attach). DuckLake's catalog swap is
211
+ * transactional, so the replace is atomic. The compiled SELECT (source dialect)
212
+ * is unchanged; only where it executes and where the result lands move.
213
+ *
214
+ * @returns the destination connection name and the captured authoritative
215
+ * schema, both recorded on the manifest entry for the serve transform.
216
+ */
217
+ export async function buildSourceIntoStorage(params: {
218
+ destinationName: string;
219
+ destinationConnection: ApiConnection;
220
+ sourceConnection: ApiConnection;
221
+ /** The source-dialect compiled SELECT (v0's getSQL output), verbatim. */
222
+ buildSQL: string;
223
+ /** Logical, unquoted physical table path (may carry a container path). */
224
+ physicalTableName: string;
225
+ environmentPath: string;
226
+ }): Promise<StorageBuildResult> {
227
+ const {
228
+ destinationName,
229
+ destinationConnection,
230
+ sourceConnection,
231
+ buildSQL,
232
+ physicalTableName,
233
+ environmentPath,
234
+ } = params;
235
+
236
+ assertSupportedDestination(destinationName, destinationConnection);
237
+ const sourceType = passthroughSourceType(sourceConnection);
238
+
239
+ // A dedicated, disposable build session on its OWN in-memory instance, so its
240
+ // read-write destination attach and federated source credentials cannot be
241
+ // pooled onto — or collide with — any other build/serve connection (see
242
+ // createIsolatedBuildSession).
243
+ const { session, dispose } = createIsolatedBuildSession(
244
+ `build_${destinationName}`,
245
+ );
246
+ try {
247
+ await attachDestinationReadWrite(
248
+ session,
249
+ destinationName,
250
+ destinationConnection,
251
+ environmentPath,
252
+ );
253
+
254
+ if (destinationConnection.type === "ducklake") {
255
+ // Write-path: disable DuckLake row inlining for this build session so
256
+ // materialized rows land as Parquet in the data store, NOT inlined into
257
+ // the catalog database. Materializations are generally large, and in a
258
+ // shared/multitenant catalog inlining would push tenant data into the
259
+ // catalog Postgres. Session-scoped (this build only) — it does not
260
+ // mutate the shared catalog's persisted options.
261
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
262
+ }
263
+
264
+ const federated = await federateSourceForPassthrough(
265
+ session,
266
+ sourceType,
267
+ sourceFederationConfig(sourceConnection),
268
+ );
269
+
270
+ // The CTAS target MUST be qualified with the destination catalog (the
271
+ // attach alias) — an unqualified name lands in the build session's own
272
+ // (private, throwaway) in-memory catalog, not the DuckLake/DuckDB store,
273
+ // so the rows would be captured for the schema and then vanish with the
274
+ // session. DuckLake's
275
+ // catalog swap is transactional, so CREATE OR REPLACE is atomic — no
276
+ // staging/rename dance needed on this path.
277
+ // quoteManifestTablePath, NOT quoteTablePath: the serve side renders this same
278
+ // path with the manifest rule, which passes an already-quoted name through
279
+ // unchanged. Quoting unconditionally here would create a table whose NAME
280
+ // contains the author's quote characters while the serve side asks for the
281
+ // unquoted-inner form — and because that mismatch surfaces at shape RUN time,
282
+ // past the compile-time live fallback, every query on the source 400s. For an
283
+ // unquoted name the two functions are identical, so nothing else moves.
284
+ const target = quoteManifestTablePath(
285
+ `${destinationName}.${physicalTableName}`,
286
+ "duckdb",
287
+ );
288
+ const passthrough = wrapPassthrough(
289
+ sourceType,
290
+ federated.handle,
291
+ buildSQL,
292
+ );
293
+
294
+ // Capture the authoritative schema from the freshly-built table — the
295
+ // serve transform declares exactly this, and the compiler does not
296
+ // type-check a virtual source's declared columns.
297
+ const schema = await createTableAndDescribe(session, target, passthrough);
298
+
299
+ return { storageConnectionName: destinationName, schema };
300
+ } finally {
301
+ // Dispose closes the private instance (releasing every secret + attach —
302
+ // nothing federated or read-write survives the build) and removes its
303
+ // throwaway working directory.
304
+ await dispose();
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Build a CHAINED `storage=` source by reading its already-materialized
310
+ * upstream(s) from the destination store — the "stack on the parent" path —
311
+ * instead of recomputing the upstream from raw against the source
312
+ * warehouse. Reuses the parent's work and is consistent-by-construction
313
+ * (the downstream is a pure function of the parent's STORED rows).
314
+ *
315
+ * The `transientModel` (assembled by the caller from the serve-shape rebind
316
+ * machinery, see {@link buildChainedStorageBuildModel}) rebinds every upstream to
317
+ * a virtual source on THIS destination and re-declares the downstream as a
318
+ * persist source over them. We compile it against the build session (the only
319
+ * connection it references is the attached destination), read the downstream
320
+ * persist source's `getSQL({ virtualMap })` — the exact mirror of the warehouse
321
+ * build's `getSQL`, only the SQL now reads the attached lake tables — and CTAS
322
+ * the result into the destination. No source federation, no passthrough: nothing
323
+ * leaves DuckDB. The `virtualMap` maps each upstream's handle to its (quoted)
324
+ * physical lake path.
325
+ *
326
+ * Session lifecycle mirrors {@link buildSourceIntoStorage} exactly (create →
327
+ * attach read-write → build → dispose), so no read-write attach survives the
328
+ * build, and the same authoritative-schema capture applies.
329
+ *
330
+ * @returns the destination connection name and the captured authoritative schema.
331
+ */
332
+ export async function buildDownstreamIntoStorage(params: {
333
+ destinationName: string;
334
+ destinationConnection: ApiConnection;
335
+ /** Transient rebind model: upstream virtuals + persist-annotated downstream. */
336
+ transientModel: string;
337
+ /** The downstream source's Malloy name, to locate it in the transient plan. */
338
+ downstreamName: string;
339
+ /** connectionName → handle → quoted physical path for the upstream virtuals. */
340
+ virtualMap: Map<string, Map<string, string>>;
341
+ /** Logical, unquoted physical table path for the downstream's own table. */
342
+ physicalTableName: string;
343
+ environmentPath: string;
344
+ }): Promise<StorageBuildResult> {
345
+ const {
346
+ destinationName,
347
+ destinationConnection,
348
+ transientModel,
349
+ downstreamName,
350
+ virtualMap,
351
+ physicalTableName,
352
+ environmentPath,
353
+ } = params;
354
+
355
+ assertSupportedDestination(destinationName, destinationConnection);
356
+
357
+ const { session, dispose } = createIsolatedBuildSession(
358
+ `build_${destinationName}`,
359
+ );
360
+ try {
361
+ await attachDestinationReadWrite(
362
+ session,
363
+ destinationName,
364
+ destinationConnection,
365
+ environmentPath,
366
+ );
367
+ if (destinationConnection.type === "ducklake") {
368
+ // Same rationale as buildSourceIntoStorage: keep materialized rows out
369
+ // of the shared catalog database.
370
+ await session.runSQL("SET ducklake_default_data_inlining_row_limit=0");
371
+ }
372
+
373
+ // Compile the transient rebind model against the build session. Its only
374
+ // connection is the attached destination; the upstream virtual sources
375
+ // declare their captured schema, so compiling reads no tables (schema-on-
376
+ // faith) — the CTAS below runs the real SQL against the attached lake.
377
+ const root = "file:///chained-build/";
378
+ const url = `${root}m.malloy`;
379
+ const runtime = new Runtime({
380
+ urlReader: new InMemoryURLReader(new Map([[url, transientModel]])),
381
+ connections: new FixedConnectionMap(
382
+ new Map([[destinationName, session]]),
383
+ destinationName,
384
+ ),
385
+ });
386
+ // Compiling the transient model is the only SHAPE step in this function:
387
+ // a failure means the downstream cannot be expressed over its rebound
388
+ // parents, which is a legitimate reason to fall back and recompute from
389
+ // raw. Everything around it — the attach, the CTAS, the DESCRIBE — is
390
+ // infrastructure, where falling back would just fail the same way against
391
+ // the same destination. Marking the shape failures lets the caller tell
392
+ // them apart.
393
+ let model;
394
+ try {
395
+ model = await runtime
396
+ .loadModel(new URL(url), { importBaseURL: new URL(root) })
397
+ .getModel();
398
+ } catch (err) {
399
+ throw new MaterializationEligibilityError({
400
+ message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`,
401
+ });
402
+ }
403
+ const plan = model.getBuildPlan();
404
+ let downstream: PersistSource | undefined;
405
+ for (const ps of Object.values(plan.sources)) {
406
+ if (ps.name === downstreamName) {
407
+ downstream = ps;
408
+ break;
409
+ }
410
+ }
411
+ if (!downstream) {
412
+ // The downstream didn't survive as a persist source — its definition
413
+ // references something the rebind model doesn't provide (a parent
414
+ // refinement not carried, a live leaf). The caller falls back.
415
+ throw new MaterializationEligibilityError({
416
+ message:
417
+ `Chained build model did not yield a persist source named ` +
418
+ `'${downstreamName}' (the downstream references something the ` +
419
+ `rebound parents don't provide).`,
420
+ });
421
+ }
422
+ // The downstream's materialization SQL, over the rebound parents — DuckDB
423
+ // dialect, reading the attached lake tables via the virtualMap. Project to
424
+ // the public columns so a hidden (`except:` / access-restricted) downstream
425
+ // column is not materialized — same rationale as the single-source build:
426
+ // keeping hidden values out of the store at rest.
427
+ const sql = projectToPublicColumns(
428
+ downstream,
429
+ downstream.getSQL({ virtualMap }),
430
+ );
431
+
432
+ // Same write/read mirror as the single-source build above.
433
+ const target = quoteManifestTablePath(
434
+ `${destinationName}.${physicalTableName}`,
435
+ "duckdb",
436
+ );
437
+ const schema = await createTableAndDescribe(session, target, sql);
438
+
439
+ return { storageConnectionName: destinationName, schema };
440
+ } finally {
441
+ await dispose();
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Build-time servability gate (the portable-DuckDB eligibility check, deferred
447
+ * from the pre-build pass because it needs the POST-build schema): compile the
448
+ * source's serve-shape in DuckDB against the captured schema and REFUSE the
449
+ * build if it does not compile. The served table lives in DuckDB, so a source
450
+ * authored against a warehouse must have a DuckDB-portable served shape — this
451
+ * turns a serve-time execution error into a build-time refusal (HTTP 422),
452
+ * running as part of stage→validate, the same step that captured the schema.
453
+ *
454
+ * The compile is schema-on-faith (the compiler does not type-check a virtual
455
+ * source's columns and no SQL runs), so a throwaway in-memory DuckDB connection
456
+ * suffices — no attach, no table read, nothing federated.
457
+ *
458
+ * @throws {MaterializationEligibilityError} (HTTP 422) if the shape can't compile.
459
+ */
460
+ export async function assertStorageServeShapeCompiles(params: {
461
+ destinationName: string;
462
+ sourceName: string;
463
+ virtualHandle: string;
464
+ physicalTableName: string;
465
+ schema: WireColumn[];
466
+ }): Promise<void> {
467
+ const { destinationName, sourceName, virtualHandle, physicalTableName } =
468
+ params;
469
+ const binding: ServeBinding = {
470
+ sourceName,
471
+ connectionName: destinationName,
472
+ virtualHandle,
473
+ tablePath: `${destinationName}.${physicalTableName}`,
474
+ schema: params.schema
475
+ .filter((c) => c.name && c.type)
476
+ .map((c) => ({ name: c.name as string, type: c.type as string })),
477
+ };
478
+ // ONE session for the process, not one per build. The build and GC sessions
479
+ // need their own instance because they ATTACH a destination read-write and
480
+ // federate customer credentials; this gate does neither — it compiles a
481
+ // throwaway serve shape against a captured schema — so it has no cross-tenant
482
+ // collision surface to isolate. A fresh instance per build cost ~5.9MB of RSS
483
+ // per build on the production image, roughly three quarters of the build
484
+ // path's total growth, and it is never reclaimed.
485
+ //
486
+ // The risk of sharing is a shared CATALOG: every compile declares a virtual
487
+ // source into it, so a later shape could in principle pass on declarations
488
+ // left by an earlier one. Pinned in the spec — refusals still refuse after 25
489
+ // successful compiles, a refusal does not poison the session, and the same
490
+ // handle recompiled with a different schema sees the new one.
491
+ sharedGateSession ??= createIsolatedBuildSession("gate_shared").session;
492
+ await assertServesInDuckDB(
493
+ sourceName,
494
+ binding,
495
+ new FixedConnectionMap(
496
+ new Map([[destinationName, sharedGateSession]]),
497
+ destinationName,
498
+ ),
499
+ );
500
+ }
501
+
502
+ /** DDL to drop a storage table by its recorded name, catalog-qualified for DuckDB. */
503
+ export function dropStorageTableSql(
504
+ destinationName: string,
505
+ physicalTableName: string,
506
+ ): string {
507
+ // Must name the table the way the build CREATEd it (see the CTAS sites).
508
+ return `DROP TABLE IF EXISTS ${quoteManifestTablePath(
509
+ `${destinationName}.${physicalTableName}`,
510
+ "duckdb",
511
+ )}`;
512
+ }
513
+
514
+ /**
515
+ * Drop one materialized table from a `storage=` destination, on a build-scoped
516
+ * read-write session with its OWN in-memory instance (the SERVE attach is
517
+ * read-only, so GC cannot run there; and an isolated instance keeps this GC's
518
+ * read-write destination attach from colliding with another tenant's same-named
519
+ * destination — see {@link createIsolatedBuildSession}). Mirrors
520
+ * {@link buildSourceIntoStorage}'s lifecycle: attach read-write → drop → dispose.
521
+ *
522
+ * Only the recorded physical table (`physicalTableName`) is dropped — a
523
+ * destination-aware drop of a name the publisher recorded building, never a
524
+ * catalog scan — so GC can never take out a table it did not create.
525
+ */
526
+ export async function dropStorageTable(params: {
527
+ destinationName: string;
528
+ destinationConnection: ApiConnection;
529
+ physicalTableName: string;
530
+ environmentPath: string;
531
+ }): Promise<void> {
532
+ const {
533
+ destinationName,
534
+ destinationConnection,
535
+ physicalTableName,
536
+ environmentPath,
537
+ } = params;
538
+ // Fail fast (pre-session, pre-attach) on a destination the build can't target.
539
+ assertSupportedDestination(destinationName, destinationConnection);
540
+
541
+ const { session, dispose } = createIsolatedBuildSession(
542
+ `gc_${destinationName}`,
543
+ );
544
+ try {
545
+ await attachDestinationReadWrite(
546
+ session,
547
+ destinationName,
548
+ destinationConnection,
549
+ environmentPath,
550
+ );
551
+ await session.runSQL(
552
+ dropStorageTableSql(destinationName, physicalTableName),
553
+ );
554
+ } finally {
555
+ await dispose();
556
+ }
557
+ }
558
+
559
+ /** Attach the destination connection read-write on the build session. */
560
+ async function attachDestinationReadWrite(
561
+ session: DuckDBConnection,
562
+ destinationName: string,
563
+ destinationConnection: ApiConnection,
564
+ environmentPath: string,
565
+ ): Promise<void> {
566
+ if (destinationConnection.type === "ducklake") {
567
+ const cfg = destinationConnection.ducklakeConnection;
568
+ if (!cfg) {
569
+ throw new BadRequestError(
570
+ `Storage destination '${destinationName}' is type 'ducklake' but ` +
571
+ `has no ducklakeConnection config.`,
572
+ );
573
+ }
574
+ await attachDuckLakeReadWrite(session, destinationName, cfg);
575
+ return;
576
+ }
577
+ // Plain DuckDB destination: attach its database file read-write. The file
578
+ // path is derived the same way connection assembly derives it.
579
+ const dbPath = path.join(environmentPath, `${destinationName}.duckdb`);
580
+ await session.runSQL(
581
+ `ATTACH '${escapeSQL(dbPath)}' AS ${quoteIdentifier(destinationName, "duckdb")}`,
582
+ );
583
+ }
584
+
585
+ /** Throw a clean 422 unless the destination is a supported storage type. */
586
+ function assertSupportedDestination(
587
+ destinationName: string,
588
+ destinationConnection: ApiConnection,
589
+ ): void {
590
+ const type = destinationConnection.type ?? "";
591
+ if (!(STORAGE_DESTINATION_TYPES as readonly string[]).includes(type)) {
592
+ throw new BadRequestError(
593
+ `Storage destination '${destinationName}' is type '${type}', but ` +
594
+ `'storage=' destinations must be one of ` +
595
+ `${STORAGE_DESTINATION_TYPES.join(", ")}.`,
596
+ );
597
+ }
598
+ }
599
+
600
+ /**
601
+ * The subset of a source connection's config the passthrough federation needs,
602
+ * plus a sanitized `name` base for the secret/alias it creates on the session.
603
+ */
604
+ function sourceFederationConfig(sourceConnection: ApiConnection): {
605
+ name: string;
606
+ bigqueryConnection?: components["schemas"]["BigqueryConnection"];
607
+ snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
608
+ postgresConnection?: components["schemas"]["PostgresConnection"];
609
+ } {
610
+ return {
611
+ name: sourceConnection.name ?? "src",
612
+ bigqueryConnection: sourceConnection.bigqueryConnection,
613
+ snowflakeConnection: sourceConnection.snowflakeConnection,
614
+ postgresConnection: sourceConnection.postgresConnection,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * Read the authoritative column schema of a just-built table with `DESCRIBE`,
620
+ * mapping DuckDB's `column_name`/`column_type` rows to the wire {@link Column}
621
+ * shape the manifest carries. This is the schema the serve transform declares.
622
+ */
623
+ /**
624
+ * CTAS the table, then read back its authoritative schema — dropping the table
625
+ * again if that read-back fails.
626
+ *
627
+ * The window this closes: the CTAS has committed by the time DESCRIBE runs, and
628
+ * the caller records nothing until this function RETURNS. So a DESCRIBE failure
629
+ * propagates past a committed table that no manifest entry names and that
630
+ * `builtThisRun` never saw — leaving it unreachable by the failed-run reclaim
631
+ * and by manifest-driven GC alike, which only drop names they recorded building.
632
+ * Same class the post-build serve-shape gate already closes with its own
633
+ * targeted drop (see materialization_service), and closed the same way.
634
+ *
635
+ * Best-effort: a failed drop is logged, never raised, and never replaces the
636
+ * DESCRIBE error that is the actual failure. The drop runs on the session that
637
+ * created the table, whose read-write attach is still open.
638
+ */
639
+ export async function createTableAndDescribe(
640
+ session: DuckDBConnection,
641
+ quotedTablePath: string,
642
+ selectSQL: string,
643
+ ): Promise<WireColumn[]> {
644
+ await session.runSQL(
645
+ `CREATE OR REPLACE TABLE ${quotedTablePath} AS (${selectSQL})`,
646
+ );
647
+ try {
648
+ return await describeTable(session, quotedTablePath);
649
+ } catch (describeErr) {
650
+ try {
651
+ await session.runSQL(`DROP TABLE IF EXISTS ${quotedTablePath}`);
652
+ } catch (dropErr) {
653
+ logger.warn(
654
+ "Failed to drop a storage table stranded by a schema read-back " +
655
+ "failure (physical leak)",
656
+ {
657
+ table: quotedTablePath,
658
+ error: errMessage(dropErr),
659
+ },
660
+ );
661
+ }
662
+ throw describeErr;
663
+ }
664
+ }
665
+
666
+ async function describeTable(
667
+ session: DuckDBConnection,
668
+ quotedTablePath: string,
669
+ ): Promise<WireColumn[]> {
670
+ const result = await session.runSQL(`DESCRIBE ${quotedTablePath}`);
671
+ const rows = Array.isArray(result) ? result : (result.rows ?? []);
672
+ const columns: WireColumn[] = [];
673
+ for (const row of rows as Record<string, unknown>[]) {
674
+ const name = row.column_name;
675
+ const type = row.column_type;
676
+ if (typeof name === "string" && typeof type === "string") {
677
+ columns.push({ name, type });
678
+ }
679
+ }
680
+ return columns;
681
+ }