@malloy-publisher/server 0.0.231 → 0.0.233
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 +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- 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 +22627 -1260
- 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/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -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/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- 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/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -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 +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- 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_execute_query_tool.integration.spec.ts +37 -12
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { Environment } from "./environment";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fragment-checking advice in malloy_compile's tool description, pinned
|
|
9
|
+
* against the REAL compiler.
|
|
10
|
+
*
|
|
11
|
+
* That description tells an agent how to validate part of a source: send a view
|
|
12
|
+
* body as a top-level `query:`, or wrap a field in a throwaway `extend`, and do
|
|
13
|
+
* not resubmit the source being edited. None of that is enforced by code, so
|
|
14
|
+
* nothing but a test stops it from quietly becoming false. Asserting it against
|
|
15
|
+
* a hand-written checker would be worthless: only Malloy decides what compiles.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PUBLISHER_JSON = JSON.stringify({
|
|
19
|
+
name: "pkg",
|
|
20
|
+
description: "fragments",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// `secret` is private on the base source, so nothing downstream may read it.
|
|
24
|
+
const MODEL = `##! experimental.access_modifiers
|
|
25
|
+
source: base is duckdb.sql("SELECT 1.5 as price, 90 as points, 'shh' as secret") include {
|
|
26
|
+
public: price, points
|
|
27
|
+
private: secret
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
source: sales is base extend {
|
|
31
|
+
measure: record_count is count()
|
|
32
|
+
measure: avg_price is avg(price)
|
|
33
|
+
view: overview is { aggregate: record_count }
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
describe("malloy_compile: checking part of a source", () => {
|
|
38
|
+
let rootDir: string;
|
|
39
|
+
let env: Environment;
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-frag-"));
|
|
43
|
+
const envPath = path.join(rootDir, "env");
|
|
44
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
45
|
+
env = await Environment.create("testEnv", envPath, []);
|
|
46
|
+
await env.installPackage("pkg", async (stagingPath) => {
|
|
47
|
+
await fs.mkdir(stagingPath, { recursive: true });
|
|
48
|
+
await fs.writeFile(
|
|
49
|
+
path.join(stagingPath, "publisher.json"),
|
|
50
|
+
PUBLISHER_JSON,
|
|
51
|
+
);
|
|
52
|
+
await fs.writeFile(path.join(stagingPath, "model.malloy"), MODEL);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Error-severity messages from compiling `source` against the fixture. */
|
|
61
|
+
async function errorsFor(source: string): Promise<string[]> {
|
|
62
|
+
const result = await env.compileSource("pkg", "model.malloy", source);
|
|
63
|
+
return (result.problems ?? [])
|
|
64
|
+
.filter((p) => p.severity === "error")
|
|
65
|
+
.map((p) => p.message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("the two failures the description exists to explain", () => {
|
|
69
|
+
it("rejects a bare view: fragment, naming only the symptom", async () => {
|
|
70
|
+
const errors = await errorsFor(
|
|
71
|
+
"view: by_pts is { group_by: points, aggregate: record_count }",
|
|
72
|
+
);
|
|
73
|
+
expect(errors.join(" ")).toContain("view:");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects resubmitting the source being edited", async () => {
|
|
77
|
+
// The natural move when validating an edit, and it fails for a reason
|
|
78
|
+
// that has nothing to do with the edit.
|
|
79
|
+
const errors = await errorsFor(`source: sales is base extend {
|
|
80
|
+
measure: record_count is count()
|
|
81
|
+
view: by_pts is { group_by: points }
|
|
82
|
+
}`);
|
|
83
|
+
expect(errors.join(" ")).toContain("Cannot redefine 'sales'");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("the top-level query: form", () => {
|
|
88
|
+
it("compiles a view body with no wrapper at all", async () => {
|
|
89
|
+
expect(
|
|
90
|
+
await errorsFor(
|
|
91
|
+
"query: check is sales -> { group_by: points, aggregate: record_count }",
|
|
92
|
+
),
|
|
93
|
+
).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("still resolves measures the source inherits", async () => {
|
|
97
|
+
expect(
|
|
98
|
+
await errorsFor(
|
|
99
|
+
"query: check is sales -> { aggregate: avg_price }",
|
|
100
|
+
),
|
|
101
|
+
).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("the throwaway extend form", () => {
|
|
106
|
+
const wrap = (fragment: string, into = "sales") =>
|
|
107
|
+
`source: check is ${into} extend {\n${fragment}\n}`;
|
|
108
|
+
|
|
109
|
+
it("compiles bare view, measure, and dimension fragments", async () => {
|
|
110
|
+
expect(
|
|
111
|
+
await errorsFor(
|
|
112
|
+
wrap(
|
|
113
|
+
"view: by_pts is { group_by: points, aggregate: record_count }",
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
).toEqual([]);
|
|
117
|
+
expect(
|
|
118
|
+
await errorsFor(wrap("measure: max_price is max(price)")),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
expect(
|
|
121
|
+
await errorsFor(wrap("dimension: pricey is price > 1")),
|
|
122
|
+
).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("resolves measures the source inherits", async () => {
|
|
126
|
+
expect(
|
|
127
|
+
await errorsFor(wrap("view: v is { aggregate: avg_price }")),
|
|
128
|
+
).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("hides a private field exactly as an in-place extension does", async () => {
|
|
132
|
+
// The fidelity claim in the description: the wrapper must not widen or
|
|
133
|
+
// narrow visibility versus the edit it stands in for.
|
|
134
|
+
const wrapped = await errorsFor(wrap("dimension: s is secret"));
|
|
135
|
+
const inPlace = await errorsFor(
|
|
136
|
+
"source: probe is base extend { dimension: s is secret }",
|
|
137
|
+
);
|
|
138
|
+
expect(wrapped.join(" ")).toContain("private");
|
|
139
|
+
expect(inPlace.join(" ")).toContain("private");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reports a redefinition when the fragment reuses an existing name", async () => {
|
|
143
|
+
// The documented caveat: an extension adds to the namespace rather
|
|
144
|
+
// than overriding it, so checking an EDIT needs the fragment renamed.
|
|
145
|
+
expect(
|
|
146
|
+
await errorsFor(wrap("view: overview is { aggregate: avg_price }")),
|
|
147
|
+
).toEqual(["Cannot redefine 'overview'"]);
|
|
148
|
+
|
|
149
|
+
expect(
|
|
150
|
+
await errorsFor(
|
|
151
|
+
wrap("view: overview__check is { aggregate: avg_price }"),
|
|
152
|
+
),
|
|
153
|
+
).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
afterAll,
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
} from "bun:test";
|
|
3
10
|
import fs from "fs/promises";
|
|
4
11
|
import os from "os";
|
|
5
12
|
import path from "path";
|
|
6
13
|
import sinon from "sinon";
|
|
7
14
|
import { components } from "../api";
|
|
8
15
|
import {
|
|
16
|
+
attachDuckLakeReadWrite,
|
|
9
17
|
buildProxiedSslQuery,
|
|
10
18
|
createEnvironmentConnections,
|
|
11
19
|
resolveProxiedTls,
|
|
12
20
|
testConnectionConfig,
|
|
13
21
|
} from "./connection";
|
|
14
22
|
import { assembleEnvironmentConnections } from "./connection_config";
|
|
23
|
+
import { UnsupportedCatalogFormatError } from "../errors";
|
|
15
24
|
import { EnvironmentStore } from "./environment_store";
|
|
16
25
|
|
|
17
26
|
type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -1337,6 +1346,367 @@ describe("connection integration tests", () => {
|
|
|
1337
1346
|
/DuckLake connection configuration is missing/,
|
|
1338
1347
|
);
|
|
1339
1348
|
});
|
|
1349
|
+
|
|
1350
|
+
// ── catalog.metadataSchema ──────────────────────────────────────
|
|
1351
|
+
//
|
|
1352
|
+
// Several independent DuckLake catalogs may share ONE catalog
|
|
1353
|
+
// database when each attaches its own metadata schema. These use a
|
|
1354
|
+
// LOCAL directory as DATA_PATH rather than S3/GCS, so they need only
|
|
1355
|
+
// the Postgres service and are not skipped for want of object-store
|
|
1356
|
+
// credentials.
|
|
1357
|
+
describe("catalog.metadataSchema", () => {
|
|
1358
|
+
const pgCatalog = () => ({
|
|
1359
|
+
host: process.env.POSTGRES_TEST_HOST,
|
|
1360
|
+
port: parseInt(process.env.POSTGRES_TEST_PORT || "5432"),
|
|
1361
|
+
userName: process.env.POSTGRES_TEST_USER!,
|
|
1362
|
+
password: process.env.POSTGRES_TEST_PASSWORD!,
|
|
1363
|
+
databaseName: process.env.POSTGRES_TEST_DATABASE,
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
const pgConnString = () => {
|
|
1367
|
+
const pg = pgCatalog();
|
|
1368
|
+
return (
|
|
1369
|
+
`host=${pg.host} port=${pg.port} user=${pg.userName} ` +
|
|
1370
|
+
`password=${pg.password}` +
|
|
1371
|
+
(pg.databaseName ? ` dbname=${pg.databaseName}` : "")
|
|
1372
|
+
);
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
// A unique schema per test AND per run: the catalog database is
|
|
1376
|
+
// shared across the specs in this file, and (locally) across runs,
|
|
1377
|
+
// so a fixed name would let one test observe another's metadata.
|
|
1378
|
+
const runId = Math.random().toString(36).slice(2, 8);
|
|
1379
|
+
let schemaSeq = 0;
|
|
1380
|
+
const createdSchemas: string[] = [];
|
|
1381
|
+
const uniqueSchema = (label: string) => {
|
|
1382
|
+
const schema = `dl_meta_${label}_${runId}_${++schemaSeq}`;
|
|
1383
|
+
createdSchemas.push(schema);
|
|
1384
|
+
return schema;
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// Leave the catalog database as we found it. These schemas are
|
|
1388
|
+
// created by DuckLake inside a database shared with the other
|
|
1389
|
+
// specs in this file, and enough of them makes a sibling test's
|
|
1390
|
+
// schema enumeration miss `public`.
|
|
1391
|
+
afterAll(async () => {
|
|
1392
|
+
if (
|
|
1393
|
+
!hasPostgresCredentials() ||
|
|
1394
|
+
createdSchemas.length === 0
|
|
1395
|
+
) {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const workDir = await fs.mkdtemp(
|
|
1399
|
+
path.join(os.tmpdir(), "dl-cleanup-"),
|
|
1400
|
+
);
|
|
1401
|
+
const conn = new DuckDBConnection(
|
|
1402
|
+
"dl_cleanup",
|
|
1403
|
+
":memory:",
|
|
1404
|
+
workDir,
|
|
1405
|
+
);
|
|
1406
|
+
try {
|
|
1407
|
+
await conn.runSQL(`INSTALL postgres`);
|
|
1408
|
+
await conn.runSQL(`LOAD postgres`);
|
|
1409
|
+
await conn.runSQL(
|
|
1410
|
+
`ATTACH '${pgConnString()}' AS cleanup (TYPE postgres);`,
|
|
1411
|
+
);
|
|
1412
|
+
for (const schema of createdSchemas) {
|
|
1413
|
+
// Quoted, because at least one test registers a mixed-case
|
|
1414
|
+
// schema and an unquoted identifier would not match it. And
|
|
1415
|
+
// reported rather than swallowed: a silently failing drop
|
|
1416
|
+
// leaks one schema per run into the shared catalog database,
|
|
1417
|
+
// which is the accumulation this hook exists to prevent.
|
|
1418
|
+
await conn
|
|
1419
|
+
.runSQL(`DROP SCHEMA cleanup."${schema}" CASCADE;`)
|
|
1420
|
+
.catch((error: unknown) =>
|
|
1421
|
+
console.warn(
|
|
1422
|
+
`Failed to drop test schema "${schema}":`,
|
|
1423
|
+
error,
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
console.warn("DuckLake schema cleanup failed:", error);
|
|
1429
|
+
} finally {
|
|
1430
|
+
await conn.close().catch(() => undefined);
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
const duckLakeConfig = async (
|
|
1435
|
+
name: string,
|
|
1436
|
+
metadataSchema?: string,
|
|
1437
|
+
) => {
|
|
1438
|
+
const dataDir = path.join(
|
|
1439
|
+
testEnvironmentPath,
|
|
1440
|
+
`${name}_data`,
|
|
1441
|
+
);
|
|
1442
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
1443
|
+
return {
|
|
1444
|
+
catalog: {
|
|
1445
|
+
postgresConnection: pgCatalog(),
|
|
1446
|
+
...(metadataSchema ? { metadataSchema } : {}),
|
|
1447
|
+
},
|
|
1448
|
+
storage: { bucketUrl: `${dataDir}/` },
|
|
1449
|
+
} as components["schemas"]["DucklakeConnection"];
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
// Bootstrap a catalog the way a BUILD does — a read-WRITE attach.
|
|
1453
|
+
// The read-only serve attach cannot create one ("creating a new
|
|
1454
|
+
// DuckLake is explicitly disabled"), so anything exercising the
|
|
1455
|
+
// serve path needs this first. Also the natural place to assert
|
|
1456
|
+
// that the build path carries METADATA_SCHEMA.
|
|
1457
|
+
const bootstrapCatalog = async (
|
|
1458
|
+
dbName: string,
|
|
1459
|
+
cfg: components["schemas"]["DucklakeConnection"],
|
|
1460
|
+
): Promise<DuckDBConnection> => {
|
|
1461
|
+
const workDir = await fs.mkdtemp(
|
|
1462
|
+
path.join(os.tmpdir(), `dl-boot-${dbName}-`),
|
|
1463
|
+
);
|
|
1464
|
+
const conn = new DuckDBConnection(
|
|
1465
|
+
`bootstrap_${dbName}`,
|
|
1466
|
+
":memory:",
|
|
1467
|
+
workDir,
|
|
1468
|
+
);
|
|
1469
|
+
createdConnections.push(conn);
|
|
1470
|
+
await attachDuckLakeReadWrite(conn, dbName, cfg);
|
|
1471
|
+
return conn;
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
const connect = async (defs: ApiConnection[]) => {
|
|
1475
|
+
const { malloyConnections } =
|
|
1476
|
+
await createEnvironmentConnections(
|
|
1477
|
+
defs,
|
|
1478
|
+
testEnvironmentPath,
|
|
1479
|
+
);
|
|
1480
|
+
const out = new Map<string, DuckDBConnection>();
|
|
1481
|
+
for (const def of defs) {
|
|
1482
|
+
const c = malloyConnections.get(
|
|
1483
|
+
def.name!,
|
|
1484
|
+
) as DuckDBConnection;
|
|
1485
|
+
createdConnections.push(c);
|
|
1486
|
+
out.set(def.name!, c);
|
|
1487
|
+
}
|
|
1488
|
+
return out;
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1491
|
+
const firstValue = (rows: Record<string, unknown>[]) =>
|
|
1492
|
+
Number(Object.values(rows[0])[0]);
|
|
1493
|
+
|
|
1494
|
+
it(
|
|
1495
|
+
"puts DuckLake metadata in the configured schema, creating it",
|
|
1496
|
+
async () => {
|
|
1497
|
+
if (!hasPostgresCredentials()) {
|
|
1498
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const schema = uniqueSchema("lands");
|
|
1502
|
+
const cfg = await duckLakeConfig("dl_lands", schema);
|
|
1503
|
+
const c = await bootstrapCatalog("dl_lands", cfg);
|
|
1504
|
+
await c.runSQL(
|
|
1505
|
+
`CREATE OR REPLACE TABLE dl_lands.t AS SELECT 1 AS x`,
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
// Read the catalog database directly: the ducklake_*
|
|
1509
|
+
// bookkeeping tables must be in `schema`, which DuckLake
|
|
1510
|
+
// created on attach (this test never created the schema).
|
|
1511
|
+
await c.runSQL(
|
|
1512
|
+
`ATTACH '${pgConnString()}' AS insp (TYPE postgres, READ_ONLY);`,
|
|
1513
|
+
);
|
|
1514
|
+
const found = await c.runSQL(
|
|
1515
|
+
`SELECT count(*) AS n FROM insp.${schema}.ducklake_metadata;`,
|
|
1516
|
+
);
|
|
1517
|
+
expect(firstValue(found.rows)).toBeGreaterThan(0);
|
|
1518
|
+
},
|
|
1519
|
+
{ timeout: 60000 },
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
it(
|
|
1523
|
+
"isolates two catalogs sharing one catalog database",
|
|
1524
|
+
async () => {
|
|
1525
|
+
if (!hasPostgresCredentials()) {
|
|
1526
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const a = await bootstrapCatalog(
|
|
1530
|
+
"dl_a",
|
|
1531
|
+
await duckLakeConfig("dl_a", uniqueSchema("a")),
|
|
1532
|
+
);
|
|
1533
|
+
const b = await bootstrapCatalog(
|
|
1534
|
+
"dl_b",
|
|
1535
|
+
await duckLakeConfig("dl_b", uniqueSchema("b")),
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
await a.runSQL(
|
|
1539
|
+
`CREATE OR REPLACE TABLE dl_a.only_in_a AS SELECT 1`,
|
|
1540
|
+
);
|
|
1541
|
+
await b.runSQL(
|
|
1542
|
+
`CREATE OR REPLACE TABLE dl_b.only_in_b AS SELECT 2`,
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1545
|
+
const tablesOf = async (
|
|
1546
|
+
conn: DuckDBConnection,
|
|
1547
|
+
db: string,
|
|
1548
|
+
) => {
|
|
1549
|
+
const r = await conn.runSQL(
|
|
1550
|
+
`SELECT table_name FROM duckdb_tables() WHERE database_name = '${db}';`,
|
|
1551
|
+
);
|
|
1552
|
+
return r.rows.map((row) => Object.values(row)[0]);
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
// Each catalog sees its own table and NOT the other's —
|
|
1556
|
+
// the whole point of a per-catalog metadata schema. Both
|
|
1557
|
+
// live in ONE catalog database.
|
|
1558
|
+
expect(await tablesOf(a, "dl_a")).toEqual(["only_in_a"]);
|
|
1559
|
+
expect(await tablesOf(b, "dl_b")).toEqual(["only_in_b"]);
|
|
1560
|
+
},
|
|
1561
|
+
{ timeout: 60000 },
|
|
1562
|
+
);
|
|
1563
|
+
|
|
1564
|
+
it(
|
|
1565
|
+
"serves read-only from a catalog in a non-default schema",
|
|
1566
|
+
async () => {
|
|
1567
|
+
if (!hasPostgresCredentials()) {
|
|
1568
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
// The read-only serve attach must pass METADATA_SCHEMA too:
|
|
1572
|
+
// without it the attach looks in the catalog's default
|
|
1573
|
+
// schema, finds nothing, and fails outright, because a
|
|
1574
|
+
// read-only attach may not create a catalog.
|
|
1575
|
+
const schema = uniqueSchema("serve");
|
|
1576
|
+
const cfg = await duckLakeConfig("dl_serve", schema);
|
|
1577
|
+
const boot = await bootstrapCatalog("dl_serve", cfg);
|
|
1578
|
+
await boot.runSQL(
|
|
1579
|
+
`CREATE OR REPLACE TABLE dl_serve.t AS SELECT 7 AS x`,
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
const conns = await connect([
|
|
1583
|
+
{
|
|
1584
|
+
name: "dl_serve",
|
|
1585
|
+
type: "ducklake",
|
|
1586
|
+
ducklakeConnection: cfg,
|
|
1587
|
+
} as ApiConnection,
|
|
1588
|
+
]);
|
|
1589
|
+
const served = conns.get("dl_serve")!;
|
|
1590
|
+
const r = await served.runSQL(`SELECT x FROM dl_serve.t;`);
|
|
1591
|
+
expect(firstValue(r.rows)).toBe(7);
|
|
1592
|
+
},
|
|
1593
|
+
{ timeout: 60000 },
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
it(
|
|
1597
|
+
"keeps the catalog-format preflight working for a non-default schema",
|
|
1598
|
+
async () => {
|
|
1599
|
+
if (!hasPostgresCredentials()) {
|
|
1600
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// The preflight reads `ducklake_metadata`, which lives in
|
|
1604
|
+
// the configured schema. Read unqualified it would MISS,
|
|
1605
|
+
// and the preflight fails SOFT (logs and returns) — so the
|
|
1606
|
+
// range check would silently stop protecting exactly the
|
|
1607
|
+
// catalogs setting this option. Poisoning the recorded
|
|
1608
|
+
// version proves the read resolves: an unqualified read
|
|
1609
|
+
// finds nothing and so raises nothing.
|
|
1610
|
+
const schema = uniqueSchema("preflight");
|
|
1611
|
+
const cfg = await duckLakeConfig("dl_pf", schema);
|
|
1612
|
+
const boot = await bootstrapCatalog("dl_pf", cfg);
|
|
1613
|
+
await boot.runSQL(
|
|
1614
|
+
`ATTACH '${pgConnString()}' AS poison (TYPE postgres);`,
|
|
1615
|
+
);
|
|
1616
|
+
await boot.runSQL(
|
|
1617
|
+
`UPDATE poison.${schema}.ducklake_metadata ` +
|
|
1618
|
+
`SET value = '9999.0' WHERE key = 'version';`,
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
// A fresh attach of the same catalog must now refuse with
|
|
1622
|
+
// the clean typed range error rather than proceeding.
|
|
1623
|
+
let err: unknown;
|
|
1624
|
+
try {
|
|
1625
|
+
await bootstrapCatalog("dl_pf_again", cfg);
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
err = e;
|
|
1628
|
+
}
|
|
1629
|
+
// The TYPE matters: converting a deep DuckDB failure into
|
|
1630
|
+
// this typed refusal is the preflight's whole job, so a
|
|
1631
|
+
// message match alone could not tell the two apart.
|
|
1632
|
+
expect(err).toBeInstanceOf(UnsupportedCatalogFormatError);
|
|
1633
|
+
// And it must be THIS catalog's poisoned format. Asserting
|
|
1634
|
+
// the value keeps the test honest if some other schema in
|
|
1635
|
+
// the shared catalog database holds a valid catalog: an
|
|
1636
|
+
// unqualified read would report that one's format instead,
|
|
1637
|
+
// which is exactly the bug being guarded against.
|
|
1638
|
+
expect((err as Error).message).toContain("9999.0");
|
|
1639
|
+
},
|
|
1640
|
+
{ timeout: 60000 },
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
it(
|
|
1644
|
+
"round-trips a mixed-case metadataSchema through the preflight",
|
|
1645
|
+
async () => {
|
|
1646
|
+
if (!hasPostgresCredentials()) {
|
|
1647
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
// The validator accepts mixed case but nothing exercised it, so
|
|
1651
|
+
// this covers the create -> preflight -> attach round-trip for
|
|
1652
|
+
// such a name. Honest scope: it does NOT discriminate on the
|
|
1653
|
+
// preflight's identifier quoting — measured, unquoted resolves
|
|
1654
|
+
// too, because DuckDB matches identifiers case-insensitively.
|
|
1655
|
+
// Poisoning the version is what proves the preflight read THIS
|
|
1656
|
+
// catalog's metadata rather than missing silently.
|
|
1657
|
+
const schema = `DlMixedCase_${runId}`;
|
|
1658
|
+
createdSchemas.push(schema);
|
|
1659
|
+
const cfg = await duckLakeConfig("dl_mixed", schema);
|
|
1660
|
+
const boot = await bootstrapCatalog("dl_mixed", cfg);
|
|
1661
|
+
await boot.runSQL(
|
|
1662
|
+
`ATTACH '${pgConnString()}' AS poison_mixed (TYPE postgres);`,
|
|
1663
|
+
);
|
|
1664
|
+
await boot.runSQL(
|
|
1665
|
+
`UPDATE poison_mixed."${schema}".ducklake_metadata ` +
|
|
1666
|
+
`SET value = '9999.0' WHERE key = 'version';`,
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1669
|
+
let err: unknown;
|
|
1670
|
+
try {
|
|
1671
|
+
await bootstrapCatalog("dl_mixed_again", cfg);
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
err = e;
|
|
1674
|
+
}
|
|
1675
|
+
expect(err).toBeInstanceOf(UnsupportedCatalogFormatError);
|
|
1676
|
+
expect((err as Error).message).toContain("9999.0");
|
|
1677
|
+
},
|
|
1678
|
+
{ timeout: 60000 },
|
|
1679
|
+
);
|
|
1680
|
+
|
|
1681
|
+
it(
|
|
1682
|
+
"attaches without a metadataSchema exactly as before",
|
|
1683
|
+
async () => {
|
|
1684
|
+
if (!hasPostgresCredentials()) {
|
|
1685
|
+
console.log("Skipping: PostgreSQL not configured");
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
// Back-compat: the option is a conditional append, so an
|
|
1689
|
+
// absent value emits the pre-existing ATTACH verbatim and
|
|
1690
|
+
// DuckLake uses the catalog connection's default schema.
|
|
1691
|
+
const cfg = await duckLakeConfig("dl_default");
|
|
1692
|
+
const c = await bootstrapCatalog("dl_default", cfg);
|
|
1693
|
+
await c.runSQL(
|
|
1694
|
+
`CREATE OR REPLACE TABLE dl_default.t AS SELECT 7 AS x`,
|
|
1695
|
+
);
|
|
1696
|
+
const r = await c.runSQL(`SELECT x FROM dl_default.t;`);
|
|
1697
|
+
expect(firstValue(r.rows)).toBe(7);
|
|
1698
|
+
|
|
1699
|
+
await c.runSQL(
|
|
1700
|
+
`ATTACH '${pgConnString()}' AS insp2 (TYPE postgres, READ_ONLY);`,
|
|
1701
|
+
);
|
|
1702
|
+
const inPublic = await c.runSQL(
|
|
1703
|
+
`SELECT count(*) AS n FROM insp2.public.ducklake_metadata;`,
|
|
1704
|
+
);
|
|
1705
|
+
expect(firstValue(inPublic.rows)).toBeGreaterThan(0);
|
|
1706
|
+
},
|
|
1707
|
+
{ timeout: 60000 },
|
|
1708
|
+
);
|
|
1709
|
+
});
|
|
1340
1710
|
});
|
|
1341
1711
|
|
|
1342
1712
|
it("should throw error if DuckDB connection name conflicts with attached database", async () => {
|