@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,435 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { BadRequestError } from "../errors";
|
|
7
|
+
import type { components } from "../api";
|
|
8
|
+
import {
|
|
9
|
+
assertStorageServeShapeCompiles,
|
|
10
|
+
buildDownstreamIntoStorage,
|
|
11
|
+
buildSourceIntoStorage,
|
|
12
|
+
createIsolatedBuildSession,
|
|
13
|
+
createTableAndDescribe,
|
|
14
|
+
dropStorageTable,
|
|
15
|
+
dropStorageTableSql,
|
|
16
|
+
passthroughSourceType,
|
|
17
|
+
wrapPassthrough,
|
|
18
|
+
} from "./materialization_build_session";
|
|
19
|
+
import {
|
|
20
|
+
buildChainedStorageBuildModel,
|
|
21
|
+
buildVirtualMap,
|
|
22
|
+
type ServeBinding,
|
|
23
|
+
} from "./materialization_serve_transform";
|
|
24
|
+
|
|
25
|
+
type ApiConnection = components["schemas"]["Connection"];
|
|
26
|
+
|
|
27
|
+
describe("createIsolatedBuildSession (per-build instance isolation)", () => {
|
|
28
|
+
// Multi-tenant safety regression guard. @malloydata/db-duckdb pools :memory:
|
|
29
|
+
// DuckDB instances in a process-global cache keyed by a share key that
|
|
30
|
+
// EXCLUDES the connection name, so build sessions built with identical config
|
|
31
|
+
// would otherwise be pooled onto ONE shared instance and collide on their
|
|
32
|
+
// transient ATTACH aliases (cross-tenant read/write). createIsolatedBuildSession
|
|
33
|
+
// gives each session a unique workingDirectory so it lands its OWN instance.
|
|
34
|
+
// If a library change ever re-pools them (drops workingDirectory from the
|
|
35
|
+
// share key), this test fails LOUD rather than letting a silent cross-tenant
|
|
36
|
+
// leak ship. No warehouse needed: a plain table proves catalog isolation.
|
|
37
|
+
it("gives each session its own catalog (a table in one is invisible to another)", async () => {
|
|
38
|
+
const a = createIsolatedBuildSession("build_lake");
|
|
39
|
+
const b = createIsolatedBuildSession("build_lake");
|
|
40
|
+
try {
|
|
41
|
+
await a.session.runSQL("CREATE TABLE isolated_probe(x INT)");
|
|
42
|
+
await a.session.runSQL("INSERT INTO isolated_probe VALUES (1)");
|
|
43
|
+
// b must NOT see a's table — separate instances, separate catalogs.
|
|
44
|
+
await expect(
|
|
45
|
+
b.session.runSQL("SELECT * FROM isolated_probe"),
|
|
46
|
+
).rejects.toThrow();
|
|
47
|
+
} finally {
|
|
48
|
+
await a.dispose();
|
|
49
|
+
await b.dispose();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stays in-memory (dispose leaves no primary DB file) and is idempotent", async () => {
|
|
54
|
+
const { session, dispose } = createIsolatedBuildSession("build_lake");
|
|
55
|
+
await session.runSQL("CREATE TABLE t(x INT)");
|
|
56
|
+
await dispose();
|
|
57
|
+
// Second dispose must not throw (idempotent teardown).
|
|
58
|
+
await dispose();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("assertStorageServeShapeCompiles (build-time servability gate)", () => {
|
|
63
|
+
it("passes for a well-formed captured schema (compiles the serve shape in DuckDB)", async () => {
|
|
64
|
+
await expect(
|
|
65
|
+
assertStorageServeShapeCompiles({
|
|
66
|
+
destinationName: "lake",
|
|
67
|
+
sourceName: "daily_orders",
|
|
68
|
+
virtualHandle: "se_1",
|
|
69
|
+
physicalTableName: "daily_orders__mabc123",
|
|
70
|
+
schema: [
|
|
71
|
+
{ name: "order_date", type: "DATE" },
|
|
72
|
+
{ name: "order_count", type: "BIGINT" },
|
|
73
|
+
{ name: "total_amount", type: "DOUBLE" },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("serve-shape gate shares one session across builds", () => {
|
|
81
|
+
// The gate compiles against ONE process-wide session rather than a fresh
|
|
82
|
+
// DuckDB instance per build (~5.9MB/build of unreclaimed RSS on the
|
|
83
|
+
// production image). Sharing is safe on paper — the gate does no ATTACH and
|
|
84
|
+
// establishes no credentials — but a shared session means a shared CATALOG,
|
|
85
|
+
// and every compile declares a virtual source into it.
|
|
86
|
+
//
|
|
87
|
+
// The failure that would matter is a false PASS: a later shape satisfied by
|
|
88
|
+
// declarations an earlier compile left behind. No end-to-end scenario reaches
|
|
89
|
+
// it, because each hammer scenario gets a fresh publisher and so never
|
|
90
|
+
// compiles many shapes through one session.
|
|
91
|
+
const valid = (i: number) => ({
|
|
92
|
+
destinationName: "lake",
|
|
93
|
+
sourceName: `daily_${i}`,
|
|
94
|
+
virtualHandle: `se_${i}`,
|
|
95
|
+
physicalTableName: `daily_${i}__mabc`,
|
|
96
|
+
schema: [
|
|
97
|
+
{ name: "order_date", type: "DATE" },
|
|
98
|
+
{ name: "total_amount", type: "DOUBLE" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("still refuses an unservable shape after many successful compiles", async () => {
|
|
103
|
+
for (let i = 0; i < 25; i++) {
|
|
104
|
+
await assertStorageServeShapeCompiles(valid(i));
|
|
105
|
+
}
|
|
106
|
+
await expect(
|
|
107
|
+
assertStorageServeShapeCompiles({ ...valid(99), schema: [] }),
|
|
108
|
+
).rejects.toThrow(/cannot be served/i);
|
|
109
|
+
await expect(
|
|
110
|
+
assertStorageServeShapeCompiles({
|
|
111
|
+
...valid(98),
|
|
112
|
+
schema: [{ name: "a", type: "NOT_A_REAL_TYPE" }],
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow(/cannot be served/i);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a refusal does not poison the session for later valid shapes", async () => {
|
|
118
|
+
await expect(
|
|
119
|
+
assertStorageServeShapeCompiles({ ...valid(31), schema: [] }),
|
|
120
|
+
).rejects.toThrow(/cannot be served/i);
|
|
121
|
+
await expect(
|
|
122
|
+
assertStorageServeShapeCompiles(valid(32)),
|
|
123
|
+
).resolves.toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reusing one handle with a DIFFERENT schema sees the new schema", async () => {
|
|
127
|
+
// The sharpest false-pass shape: if the first declaration lingered, the
|
|
128
|
+
// second would compile against stale columns and wrongly pass.
|
|
129
|
+
await assertStorageServeShapeCompiles({
|
|
130
|
+
...valid(41),
|
|
131
|
+
schema: [{ name: "kept", type: "BIGINT" }],
|
|
132
|
+
});
|
|
133
|
+
await expect(
|
|
134
|
+
assertStorageServeShapeCompiles({ ...valid(41), schema: [] }),
|
|
135
|
+
).rejects.toThrow(/cannot be served/i);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("dropStorageTableSql", () => {
|
|
140
|
+
it("drops the content-addressed table, catalog-qualified and quoted", () => {
|
|
141
|
+
expect(dropStorageTableSql("lake", "daily__mabc123")).toBe(
|
|
142
|
+
'DROP TABLE IF EXISTS "lake"."daily__mabc123"',
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
it("qualifies a dotted schema.table name", () => {
|
|
146
|
+
expect(dropStorageTableSql("lake", "analytics.daily__mabc123")).toBe(
|
|
147
|
+
'DROP TABLE IF EXISTS "lake"."analytics"."daily__mabc123"',
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("dropStorageTable gating (no session I/O before the gate)", () => {
|
|
153
|
+
it("refuses a non-DuckDB-family destination before creating a session", async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
dropStorageTable({
|
|
156
|
+
destinationName: "bq_dest",
|
|
157
|
+
destinationConnection: {
|
|
158
|
+
name: "bq_dest",
|
|
159
|
+
type: "bigquery",
|
|
160
|
+
} as ApiConnection,
|
|
161
|
+
physicalTableName: "t__mabc",
|
|
162
|
+
environmentPath: "/tmp/env",
|
|
163
|
+
}),
|
|
164
|
+
).rejects.toThrow(/must be one of/i);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("wrapPassthrough", () => {
|
|
169
|
+
const SQL = "SELECT a, b FROM t WHERE s = 'x'";
|
|
170
|
+
|
|
171
|
+
it("bigquery: bigquery_query(handle, sql)", () => {
|
|
172
|
+
expect(wrapPassthrough("bigquery", "my-project", SQL)).toBe(
|
|
173
|
+
"SELECT * FROM bigquery_query('my-project', 'SELECT a, b FROM t WHERE s = ''x''')",
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("postgres: postgres_query(handle, sql)", () => {
|
|
178
|
+
expect(wrapPassthrough("postgres", "src_pg", SQL)).toBe(
|
|
179
|
+
"SELECT * FROM postgres_query('src_pg', 'SELECT a, b FROM t WHERE s = ''x''')",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("snowflake: snowflake_query(sql, handle) — SQL FIRST", () => {
|
|
184
|
+
expect(wrapPassthrough("snowflake", "secret_snowflake_src", SQL)).toBe(
|
|
185
|
+
"SELECT * FROM snowflake_query('SELECT a, b FROM t WHERE s = ''x''', 'secret_snowflake_src')",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("escapes single quotes in both the handle and the inner SQL", () => {
|
|
190
|
+
expect(wrapPassthrough("postgres", "a'b", "x'y")).toBe(
|
|
191
|
+
"SELECT * FROM postgres_query('a''b', 'x''y')",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("passthroughSourceType", () => {
|
|
197
|
+
it.each(["bigquery", "snowflake", "postgres"])("accepts %s", (type) => {
|
|
198
|
+
expect(
|
|
199
|
+
passthroughSourceType({ name: "s", type } as ApiConnection) as string,
|
|
200
|
+
).toBe(type);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it.each(["duckdb", "ducklake", "mysql", "trino"])(
|
|
204
|
+
"refuses %s with a clean 422",
|
|
205
|
+
(type) => {
|
|
206
|
+
expect(() =>
|
|
207
|
+
passthroughSourceType({ name: "s", type } as ApiConnection),
|
|
208
|
+
).toThrow(BadRequestError);
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("buildSourceIntoStorage gating (no session I/O before the gates)", () => {
|
|
214
|
+
const okSource = { name: "wh", type: "postgres" } as ApiConnection;
|
|
215
|
+
|
|
216
|
+
it("refuses a non-DuckDB-family destination before creating a session", async () => {
|
|
217
|
+
await expect(
|
|
218
|
+
buildSourceIntoStorage({
|
|
219
|
+
destinationName: "bq_dest",
|
|
220
|
+
destinationConnection: {
|
|
221
|
+
name: "bq_dest",
|
|
222
|
+
type: "bigquery",
|
|
223
|
+
} as ApiConnection,
|
|
224
|
+
sourceConnection: okSource,
|
|
225
|
+
buildSQL: "SELECT 1",
|
|
226
|
+
physicalTableName: "t",
|
|
227
|
+
environmentPath: "/tmp/env",
|
|
228
|
+
}),
|
|
229
|
+
).rejects.toThrow(/must be one of/i);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("refuses an unsupported source type before creating a session", async () => {
|
|
233
|
+
await expect(
|
|
234
|
+
buildSourceIntoStorage({
|
|
235
|
+
destinationName: "lake",
|
|
236
|
+
destinationConnection: {
|
|
237
|
+
name: "lake",
|
|
238
|
+
type: "duckdb",
|
|
239
|
+
} as ApiConnection,
|
|
240
|
+
sourceConnection: {
|
|
241
|
+
name: "md",
|
|
242
|
+
type: "motherduck",
|
|
243
|
+
} as ApiConnection,
|
|
244
|
+
buildSQL: "SELECT 1",
|
|
245
|
+
physicalTableName: "t",
|
|
246
|
+
environmentPath: "/tmp/env",
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/query-passthrough build supports/i);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Skipped on Windows: these tests seed the parent table via one DuckDBConnection
|
|
253
|
+
// to a plain-DuckDB *file*, then buildDownstreamIntoStorage ATTACHes the SAME
|
|
254
|
+
// file in the same process. On Windows, DuckDB keeps an exclusive/cached handle
|
|
255
|
+
// on a file database even after close(), so the re-open fails ("file is already
|
|
256
|
+
// open in this process") — a same-process file-reopen limitation of the dev-only
|
|
257
|
+
// duckdb-file destination (production materializes into DuckLake, a Postgres
|
|
258
|
+
// catalog, with no file reopen). POSIX releases the handle promptly. The
|
|
259
|
+
// stack-on-parent rebind mechanism's cross-platform correctness is pinned in-memory (no file) by
|
|
260
|
+
// the "runs the downstream over the parent's stored rows" test in
|
|
261
|
+
// materialization_serve_transform.spec.ts.
|
|
262
|
+
describe.skipIf(process.platform === "win32")(
|
|
263
|
+
"buildDownstreamIntoStorage (stack-on-parent, real DuckDB)",
|
|
264
|
+
() => {
|
|
265
|
+
const parent: ServeBinding = {
|
|
266
|
+
sourceName: "daily_orders",
|
|
267
|
+
connectionName: "lake",
|
|
268
|
+
virtualHandle: "daily_h",
|
|
269
|
+
tablePath: "lake.daily_orders__mabc",
|
|
270
|
+
schema: [
|
|
271
|
+
{ name: "order_date", type: "DATE" },
|
|
272
|
+
{ name: "total", type: "DOUBLE" },
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
const downstreamDefText =
|
|
276
|
+
"monthly_orders is daily_orders -> {\n" +
|
|
277
|
+
" group_by: order_month is order_date.month\n" +
|
|
278
|
+
" aggregate: monthly_total is total.sum()\n}";
|
|
279
|
+
|
|
280
|
+
/** Seed a plain-DuckDB "lake" file with the parent's content-addressed table. */
|
|
281
|
+
async function seedLake(dir: string): Promise<void> {
|
|
282
|
+
const seed = new DuckDBConnection("lake", join(dir, "lake.duckdb"));
|
|
283
|
+
try {
|
|
284
|
+
await seed.runSQL(
|
|
285
|
+
'CREATE TABLE "daily_orders__mabc" AS ' +
|
|
286
|
+
"SELECT CAST('2026-01-01' AS DATE) AS order_date, 150.0 AS total " +
|
|
287
|
+
"UNION ALL SELECT CAST('2026-01-02' AS DATE), 225.0 " +
|
|
288
|
+
"UNION ALL SELECT CAST('2026-02-01' AS DATE), 99.0",
|
|
289
|
+
);
|
|
290
|
+
} finally {
|
|
291
|
+
await seed.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
it("compiles the rebind model, runs the downstream over the parent, and captures the rolled-up schema", async () => {
|
|
296
|
+
// Drives the full build-session path against a real plain-DuckDB file
|
|
297
|
+
// destination: attach read-write → compile the transient rebind model →
|
|
298
|
+
// getSQL over the rebound parent → CTAS into the lake → DESCRIBE. A clean
|
|
299
|
+
// return proves the downstream's SQL compiled AND executed against the
|
|
300
|
+
// attached parent table, and the captured schema is exactly the roll-up's
|
|
301
|
+
// output columns (the parent's raw columns are gone — it aggregated them).
|
|
302
|
+
// Row VALUES are pinned in-memory in the serve-transform spec (a file
|
|
303
|
+
// reopened by a second DuckDB instance is not reliably visible under load;
|
|
304
|
+
// production serves via a transactional catalog, not a file reopen).
|
|
305
|
+
const dir = mkdtempSync(join(tmpdir(), "t3-build-"));
|
|
306
|
+
try {
|
|
307
|
+
await seedLake(dir);
|
|
308
|
+
const result = await buildDownstreamIntoStorage({
|
|
309
|
+
destinationName: "lake",
|
|
310
|
+
destinationConnection: {
|
|
311
|
+
name: "lake",
|
|
312
|
+
type: "duckdb",
|
|
313
|
+
} as ApiConnection,
|
|
314
|
+
transientModel: buildChainedStorageBuildModel({
|
|
315
|
+
upstreams: [parent],
|
|
316
|
+
downstreamName: "monthly_orders",
|
|
317
|
+
downstreamDefText,
|
|
318
|
+
destinationName: "lake",
|
|
319
|
+
}),
|
|
320
|
+
downstreamName: "monthly_orders",
|
|
321
|
+
virtualMap: buildVirtualMap([parent]),
|
|
322
|
+
physicalTableName: "monthly_orders__mdef456",
|
|
323
|
+
environmentPath: dir,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(result.storageConnectionName).toBe("lake");
|
|
327
|
+
expect(result.schema.map((c) => c.name).sort()).toEqual([
|
|
328
|
+
"monthly_total",
|
|
329
|
+
"order_month",
|
|
330
|
+
]);
|
|
331
|
+
} finally {
|
|
332
|
+
rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("throws (⇒ caller falls back) when the downstream references a source the rebind model does not provide", async () => {
|
|
337
|
+
const dir = mkdtempSync(join(tmpdir(), "t3-miss-"));
|
|
338
|
+
try {
|
|
339
|
+
await seedLake(dir);
|
|
340
|
+
await expect(
|
|
341
|
+
buildDownstreamIntoStorage({
|
|
342
|
+
destinationName: "lake",
|
|
343
|
+
destinationConnection: {
|
|
344
|
+
name: "lake",
|
|
345
|
+
type: "duckdb",
|
|
346
|
+
} as ApiConnection,
|
|
347
|
+
// The downstream reads `weekly_orders`, which is NOT rebound here —
|
|
348
|
+
// the transient model can't compile, mirroring the mixed-engine /
|
|
349
|
+
// uncarried-parent case that must fall back to recompute-from-raw.
|
|
350
|
+
transientModel: buildChainedStorageBuildModel({
|
|
351
|
+
upstreams: [parent],
|
|
352
|
+
downstreamName: "monthly_orders",
|
|
353
|
+
downstreamDefText:
|
|
354
|
+
"monthly_orders is weekly_orders -> { group_by: x is 1 }",
|
|
355
|
+
destinationName: "lake",
|
|
356
|
+
}),
|
|
357
|
+
downstreamName: "monthly_orders",
|
|
358
|
+
virtualMap: buildVirtualMap([parent]),
|
|
359
|
+
physicalTableName: "monthly_orders__mdef456",
|
|
360
|
+
environmentPath: dir,
|
|
361
|
+
}),
|
|
362
|
+
).rejects.toThrow();
|
|
363
|
+
} finally {
|
|
364
|
+
rmSync(dir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
describe("createTableAndDescribe (schema read-back window)", () => {
|
|
371
|
+
/** A session that CTASs fine and fails the read-back, recording every stmt. */
|
|
372
|
+
const sessionThatFailsDescribe = (opts: { dropFails?: boolean } = {}) => {
|
|
373
|
+
const issued: string[] = [];
|
|
374
|
+
const session = {
|
|
375
|
+
runSQL: async (sql: string) => {
|
|
376
|
+
issued.push(sql);
|
|
377
|
+
if (/^DESCRIBE/i.test(sql.trim())) throw new Error("describe boom");
|
|
378
|
+
if (/^DROP TABLE/i.test(sql.trim()) && opts.dropFails) {
|
|
379
|
+
throw new Error("drop boom");
|
|
380
|
+
}
|
|
381
|
+
return { rows: [] };
|
|
382
|
+
},
|
|
383
|
+
} as unknown as DuckDBConnection;
|
|
384
|
+
return { session, issued };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
it("drops the just-created table when the schema read-back fails", async () => {
|
|
388
|
+
// The CTAS has committed by now and the caller records nothing until this
|
|
389
|
+
// returns — so without the drop the table is reachable by neither the
|
|
390
|
+
// failed-run reclaim nor manifest GC, which only drop names they recorded.
|
|
391
|
+
const { session, issued } = sessionThatFailsDescribe();
|
|
392
|
+
|
|
393
|
+
await expect(
|
|
394
|
+
createTableAndDescribe(session, `"lake"."t"`, "SELECT 1"),
|
|
395
|
+
).rejects.toThrow(/describe boom/);
|
|
396
|
+
|
|
397
|
+
expect(issued.some((s) => /^CREATE OR REPLACE TABLE/i.test(s))).toBe(
|
|
398
|
+
true,
|
|
399
|
+
);
|
|
400
|
+
expect(
|
|
401
|
+
issued.some((s) => /^DROP TABLE IF EXISTS "lake"\."t"/i.test(s)),
|
|
402
|
+
).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("surfaces the read-back failure, not a failure of the cleanup drop", async () => {
|
|
406
|
+
// The DESCRIBE error is the diagnosis; a drop that also fails must not
|
|
407
|
+
// replace it (the leak is then logged and still surfaced by the warning).
|
|
408
|
+
const { session } = sessionThatFailsDescribe({ dropFails: true });
|
|
409
|
+
|
|
410
|
+
await expect(
|
|
411
|
+
createTableAndDescribe(session, `"lake"."t"`, "SELECT 1"),
|
|
412
|
+
).rejects.toThrow(/describe boom/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("issues no drop on the happy path", async () => {
|
|
416
|
+
const issued: string[] = [];
|
|
417
|
+
const session = {
|
|
418
|
+
runSQL: async (sql: string) => {
|
|
419
|
+
issued.push(sql);
|
|
420
|
+
return {
|
|
421
|
+
rows: [{ column_name: "a", column_type: "VARCHAR" }],
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
} as unknown as DuckDBConnection;
|
|
425
|
+
|
|
426
|
+
const schema = await createTableAndDescribe(
|
|
427
|
+
session,
|
|
428
|
+
`"lake"."t"`,
|
|
429
|
+
"SELECT 1",
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(schema).toEqual([{ name: "a", type: "VARCHAR" }]);
|
|
433
|
+
expect(issued.some((s) => /^DROP TABLE/i.test(s))).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
});
|