@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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_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
|
+
}
|