@malloy-publisher/server 0.0.198-dev → 0.0.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.docker.md +135 -20
  2. package/README.md +15 -0
  3. package/build.ts +16 -0
  4. package/dist/app/assets/EnvironmentPage-C7rtH4mC.js +1 -0
  5. package/dist/app/assets/HomePage-DwkH7OrS.js +1 -0
  6. package/dist/app/assets/MainPage-D38LtZDV.js +2 -0
  7. package/dist/app/assets/ModelPage-DOol8Mz7.js +1 -0
  8. package/dist/app/assets/PackagePage-0tgzA_kO.js +1 -0
  9. package/dist/app/assets/RouteError-BaMsOSly.js +1 -0
  10. package/dist/app/assets/WorkbookPage-Cx4SePkx.js +1 -0
  11. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-CbsC6R_Y.es-Cwf6asf3.js} +14 -14
  12. package/dist/app/assets/index-DL6BZTuw.js +1803 -0
  13. package/dist/app/assets/{index-C513UodQ.js → index-DNofXMxi.js} +15 -15
  14. package/dist/app/assets/index-U38AyjJL.js +451 -0
  15. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-B68wGGkM.js} +1 -1
  16. package/dist/app/index.html +2 -3
  17. package/dist/default-publisher.config.json +23 -0
  18. package/dist/instrumentation.mjs +1 -3
  19. package/dist/server.mjs +1104 -567
  20. package/package.json +11 -12
  21. package/publisher.config.example.bigquery.json +33 -0
  22. package/publisher.config.example.duckdb.json +23 -0
  23. package/publisher.config.json +1 -11
  24. package/src/config.spec.ts +306 -0
  25. package/src/config.ts +222 -2
  26. package/src/controller/connection.controller.ts +1 -1
  27. package/src/controller/package.controller.ts +70 -29
  28. package/src/default-publisher.config.json +23 -0
  29. package/src/errors.spec.ts +42 -0
  30. package/src/errors.ts +21 -0
  31. package/src/logger.ts +1 -3
  32. package/src/mcp/tools/discovery_tools.ts +6 -2
  33. package/src/path_safety.spec.ts +158 -0
  34. package/src/path_safety.ts +140 -0
  35. package/src/pg_helpers.spec.ts +226 -0
  36. package/src/pg_helpers.ts +129 -0
  37. package/src/server-old.ts +3 -23
  38. package/src/server.ts +33 -0
  39. package/src/service/connection.spec.ts +6 -4
  40. package/src/service/connection.ts +8 -3
  41. package/src/service/connection_config.ts +2 -2
  42. package/src/service/environment.ts +619 -175
  43. package/src/service/environment_admission.spec.ts +180 -0
  44. package/src/service/environment_store.ts +22 -0
  45. package/src/service/manifest_service.spec.ts +7 -2
  46. package/src/service/manifest_service.ts +8 -2
  47. package/src/service/materialization_service.ts +14 -3
  48. package/src/service/package.ts +4 -3
  49. package/src/service/package_memory_governor.spec.ts +173 -0
  50. package/src/service/package_memory_governor.ts +233 -0
  51. package/src/service/package_race.spec.ts +208 -0
  52. package/src/storage/StorageManager.ts +71 -11
  53. package/src/storage/duckdb/schema.ts +41 -0
  54. package/src/utils.ts +11 -0
  55. package/tests/harness/rest_e2e.ts +2 -2
  56. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  57. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  58. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  59. package/tests/unit/storage/StorageManager.test.ts +166 -0
  60. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  61. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  62. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  63. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  64. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  65. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  66. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  67. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  68. package/dist/app/assets/index-DIgzgp69.js +0 -1742
@@ -0,0 +1,259 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ // TODO: Remove this during projects cleanup
4
+ /**
5
+ * Smoke tests for the legacy `/api/v0/projects/...` REST surface registered
6
+ * by `server-old.ts`. These routes exist purely to keep pre-rename SDK
7
+ * clients (e.g. `@malloydata/db-publisher`) working after the
8
+ * projects→environments rename.
9
+ *
10
+ * One test per route group: projects CRUD, packages, connections, models,
11
+ * notebooks, databases, queries, materializations, manifest. The
12
+ * materialization test additionally asserts the response field rename
13
+ * (`projectId` not `environmentId`). `/status` is no longer in the legacy
14
+ * surface — both old and new clients hit the single `/api/v0/status`
15
+ * handler in server.ts, which returns `environments`.
16
+ *
17
+ * This file is intentionally separate from the regular integration suite so
18
+ * it can be deleted in one motion when legacy support is dropped.
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
22
+ import path from "path";
23
+ import { fileURLToPath } from "url";
24
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+
29
+ // Use a distinct project name so this suite doesn't collide with the
30
+ // materialization integration suite (which also uses "test-project") if
31
+ // they ever run in the same DB instance.
32
+ const PROJECT_NAME = "legacy-routes-test-project";
33
+ const PACKAGE_NAME = "persist-test";
34
+
35
+ describe("Legacy /api/v0/projects/* REST routes (E2E)", () => {
36
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
37
+ let baseUrl: string;
38
+
39
+ beforeAll(async () => {
40
+ env = await startRestE2E();
41
+ baseUrl = env.baseUrl;
42
+
43
+ // Create the test environment via the LEGACY route — proves POST
44
+ // /projects works end-to-end.
45
+ const fixtureDir = path.resolve(__dirname, "../../fixtures/persist-test");
46
+ const createRes = await fetch(`${baseUrl}/api/v0/projects`, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ name: PROJECT_NAME,
51
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
52
+ connections: [],
53
+ }),
54
+ });
55
+ if (!createRes.ok) {
56
+ const body = await createRes.text();
57
+ throw new Error(
58
+ `Failed to create test project via legacy route (${createRes.status}): ${body}`,
59
+ );
60
+ }
61
+
62
+ // Wait for the package to finish loading via the legacy GET path.
63
+ const deadline = Date.now() + 30_000;
64
+ let pkgReady = false;
65
+ while (!pkgReady && Date.now() < deadline) {
66
+ try {
67
+ const res = await fetch(
68
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`,
69
+ );
70
+ if (res.ok) {
71
+ pkgReady = true;
72
+ break;
73
+ }
74
+ } catch {
75
+ // not ready yet
76
+ }
77
+ await new Promise((r) => setTimeout(r, 500));
78
+ }
79
+ if (!pkgReady) {
80
+ throw new Error("Test package did not become available in time");
81
+ }
82
+ });
83
+
84
+ afterAll(async () => {
85
+ if (baseUrl) {
86
+ try {
87
+ // Clean up via the legacy DELETE route.
88
+ await fetch(`${baseUrl}/api/v0/projects/${PROJECT_NAME}`, {
89
+ method: "DELETE",
90
+ });
91
+ } catch {
92
+ // best-effort cleanup
93
+ }
94
+ }
95
+ await env?.stop();
96
+ env = null;
97
+ });
98
+
99
+ describe("projects CRUD", () => {
100
+ it("GET /projects lists environments under the legacy URL", async () => {
101
+ const res = await fetch(`${baseUrl}/api/v0/projects`);
102
+ expect(res.status).toBe(200);
103
+ const body = (await res.json()) as Array<{ name?: string }>;
104
+ expect(Array.isArray(body)).toBe(true);
105
+ expect(body.some((e) => e.name === PROJECT_NAME)).toBe(true);
106
+ });
107
+
108
+ it("GET /projects/:projectName returns the project", async () => {
109
+ const res = await fetch(`${baseUrl}/api/v0/projects/${PROJECT_NAME}`);
110
+ expect(res.status).toBe(200);
111
+ const body = (await res.json()) as { name?: string };
112
+ expect(body.name).toBe(PROJECT_NAME);
113
+ });
114
+ });
115
+
116
+ describe("packages", () => {
117
+ it("GET /projects/:projectName/packages returns the package list", async () => {
118
+ const res = await fetch(
119
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages`,
120
+ );
121
+ expect(res.status).toBe(200);
122
+ const body = (await res.json()) as Array<{ name?: string }>;
123
+ expect(Array.isArray(body)).toBe(true);
124
+ expect(body.some((p) => p.name === PACKAGE_NAME)).toBe(true);
125
+ });
126
+
127
+ it("GET /projects/:projectName/packages/:packageName returns the package", async () => {
128
+ const res = await fetch(
129
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`,
130
+ );
131
+ expect(res.status).toBe(200);
132
+ const body = (await res.json()) as { name?: string };
133
+ expect(body.name).toBe(PACKAGE_NAME);
134
+ });
135
+ });
136
+
137
+ describe("connections", () => {
138
+ it("GET /projects/:projectName/connections returns 200 (may be empty)", async () => {
139
+ const res = await fetch(
140
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/connections`,
141
+ );
142
+ expect(res.status).toBe(200);
143
+ const body = await res.json();
144
+ expect(Array.isArray(body)).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe("models", () => {
149
+ it("GET /projects/:projectName/packages/:packageName/models returns the model list", async () => {
150
+ const res = await fetch(
151
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/models`,
152
+ );
153
+ expect(res.status).toBe(200);
154
+ const body = await res.json();
155
+ expect(Array.isArray(body)).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe("notebooks", () => {
160
+ it("GET /projects/:projectName/packages/:packageName/notebooks returns 200", async () => {
161
+ const res = await fetch(
162
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/notebooks`,
163
+ );
164
+ expect(res.status).toBe(200);
165
+ const body = await res.json();
166
+ expect(Array.isArray(body)).toBe(true);
167
+ });
168
+ });
169
+
170
+ describe("databases", () => {
171
+ it("GET /projects/:projectName/packages/:packageName/databases returns 200", async () => {
172
+ const res = await fetch(
173
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/databases`,
174
+ );
175
+ expect(res.status).toBe(200);
176
+ const body = await res.json();
177
+ expect(Array.isArray(body)).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe("queries", () => {
182
+ it("POST /projects/:projectName/packages/:packageName/models/.../query reaches the handler", async () => {
183
+ // Hit the route with a bogus model name. We only need to prove the
184
+ // legacy URL is wired up to the controller — a structured JSON
185
+ // error (not Express's HTML fall-through 404) is sufficient signal.
186
+ const res = await fetch(
187
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/models/does-not-exist.malloy/query`,
188
+ {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ query: "run: nothing" }),
192
+ },
193
+ );
194
+ expect(res.status).toBeGreaterThanOrEqual(400);
195
+ expect(res.status).toBeLessThan(600);
196
+ // Controller errors come back as JSON with a `message` field.
197
+ // An unhandled Express 404 returns HTML — that would fail here.
198
+ const body = (await res.json()) as Record<string, unknown>;
199
+ expect(typeof body.message).toBe("string");
200
+ });
201
+ });
202
+
203
+ describe("materializations", () => {
204
+ it("GET list and POST create return 'projectId' (not 'environmentId') under the legacy URL", async () => {
205
+ const listRes = await fetch(
206
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations`,
207
+ );
208
+ expect(listRes.status).toBe(200);
209
+ const list = (await listRes.json()) as unknown;
210
+ expect(Array.isArray(list)).toBe(true);
211
+
212
+ // Create one so we can assert the field rename on a populated payload.
213
+ const createRes = await fetch(
214
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations`,
215
+ {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify({ autoLoadManifest: true }),
219
+ },
220
+ );
221
+ expect(createRes.status).toBe(201);
222
+ const created = (await createRes.json()) as Record<string, unknown>;
223
+
224
+ // Legacy contract: materialization payloads expose `projectId`, not
225
+ // `environmentId`. This is the response remapper in server-old.ts.
226
+ expect(created).toHaveProperty("projectId");
227
+ expect(created).not.toHaveProperty("environmentId");
228
+
229
+ const id = created.id as string;
230
+ // Best-effort cleanup so we don't leak a PENDING materialization
231
+ // into other tests. We don't poll-to-terminal; the suite teardown
232
+ // of the project will mop up.
233
+ try {
234
+ await fetch(
235
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations/${id}?action=stop`,
236
+ { method: "POST" },
237
+ );
238
+ await fetch(
239
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/materializations/${id}`,
240
+ { method: "DELETE" },
241
+ );
242
+ } catch {
243
+ // ignore
244
+ }
245
+ });
246
+ });
247
+
248
+ describe("manifest", () => {
249
+ it("GET /projects/:projectName/packages/:packageName/manifest returns 200 or a structured 4xx", async () => {
250
+ const res = await fetch(
251
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}/manifest`,
252
+ );
253
+ // Without a built materialization the manifest may be empty/404 —
254
+ // we only assert the legacy URL reaches the handler, not 404 from
255
+ // Express's catch-all.
256
+ expect([200, 400, 404]).toContain(res.status);
257
+ });
258
+ });
259
+ });
@@ -855,7 +855,7 @@ describe("createEnvironmentConnections - DuckDB", () => {
855
855
 
856
856
  await expect(
857
857
  createEnvironmentConnections(connections, PROJECT_TEST_DIR),
858
- ).rejects.toThrow("DuckDB connection name cannot be 'duckdb'");
858
+ ).rejects.toThrow(/'duckdb' is reserved/);
859
859
  });
860
860
 
861
861
  it("should throw when DuckDB connection name is 'duckdb' with attached databases", async () => {
@@ -885,10 +885,12 @@ describe("createEnvironmentConnections - DuckDB", () => {
885
885
 
886
886
  await expect(
887
887
  createEnvironmentConnections(connections, PROJECT_TEST_DIR),
888
- ).rejects.toThrow("DuckDB connection name cannot be 'duckdb'");
888
+ ).rejects.toThrow(/'duckdb' is reserved/);
889
889
  });
890
890
 
891
891
  it("should throw when DuckDB connection has no attached databases", async () => {
892
+ // Env-level DuckDB requires at least one attached foreign db;
893
+ // the per-package "duckdb" sandbox covers the plain-in-memory case.
892
894
  const connections: ApiConnection[] = [
893
895
  {
894
896
  name: "no_attached_db",
@@ -901,9 +903,7 @@ describe("createEnvironmentConnections - DuckDB", () => {
901
903
 
902
904
  await expect(
903
905
  createEnvironmentConnections(connections, PROJECT_TEST_DIR),
904
- ).rejects.toThrow(
905
- "DuckDB connection must have at least one attached database",
906
- );
906
+ ).rejects.toThrow(/has no attached databases/);
907
907
  });
908
908
 
909
909
  it("should throw on unsupported connection type", async () => {
@@ -0,0 +1,194 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ // TODO: Remove this during projects cleanup
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
6
+ import fs from "fs/promises";
7
+ import os from "os";
8
+ import path from "path";
9
+ import { DuckDBConnection } from "../../../src/storage/duckdb/DuckDBConnection";
10
+ import { initializeSchema } from "../../../src/storage/duckdb/schema";
11
+
12
+ const TEST_DB_DIR = path.join(os.tmpdir(), "duckdb-legacy-migration-tests");
13
+
14
+ // Seed a pre-rename schema on an *already-open* connection. We deliberately
15
+ // avoid opening, closing, and reopening the same DuckDB file within a test:
16
+ // on Windows runners the second `duckdb.Database(path)` call sometimes fails
17
+ // with `Invalid Error` because the OS hasn't released the file handle yet.
18
+ // Sharing one connection between seed and assertion sidesteps that entirely.
19
+ async function seedLegacySchema(db: DuckDBConnection): Promise<void> {
20
+ // Seed a pre-rename schema: parent table named `projects` and child
21
+ // tables with `project_id` foreign-key columns. Mirrors what an existing
22
+ // installation looked like before the projects→environments rename.
23
+ await db.run(`
24
+ CREATE TABLE projects (
25
+ id VARCHAR PRIMARY KEY,
26
+ name VARCHAR NOT NULL UNIQUE,
27
+ path VARCHAR NOT NULL,
28
+ description VARCHAR,
29
+ metadata JSON,
30
+ created_at TIMESTAMP NOT NULL,
31
+ updated_at TIMESTAMP NOT NULL
32
+ )
33
+ `);
34
+ await db.run(`
35
+ CREATE TABLE packages (
36
+ id VARCHAR PRIMARY KEY,
37
+ project_id VARCHAR NOT NULL,
38
+ name VARCHAR NOT NULL,
39
+ description VARCHAR,
40
+ manifest_path VARCHAR NOT NULL,
41
+ metadata JSON,
42
+ created_at TIMESTAMP NOT NULL,
43
+ updated_at TIMESTAMP NOT NULL,
44
+ FOREIGN KEY (project_id) REFERENCES projects(id)
45
+ )
46
+ `);
47
+ await db.run(`
48
+ CREATE TABLE connections (
49
+ id VARCHAR PRIMARY KEY,
50
+ project_id VARCHAR NOT NULL,
51
+ name VARCHAR NOT NULL,
52
+ type VARCHAR NOT NULL,
53
+ config JSON NOT NULL,
54
+ created_at TIMESTAMP NOT NULL,
55
+ updated_at TIMESTAMP NOT NULL,
56
+ FOREIGN KEY (project_id) REFERENCES projects(id)
57
+ )
58
+ `);
59
+ await db.run(`
60
+ CREATE TABLE materializations (
61
+ id VARCHAR PRIMARY KEY,
62
+ project_id VARCHAR NOT NULL,
63
+ package_name VARCHAR NOT NULL,
64
+ status VARCHAR NOT NULL,
65
+ active_key VARCHAR,
66
+ started_at TIMESTAMP,
67
+ completed_at TIMESTAMP,
68
+ error TEXT,
69
+ metadata JSON,
70
+ created_at TIMESTAMP NOT NULL,
71
+ updated_at TIMESTAMP NOT NULL,
72
+ FOREIGN KEY (project_id) REFERENCES projects(id)
73
+ )
74
+ `);
75
+ await db.run(`
76
+ CREATE TABLE build_manifests (
77
+ id VARCHAR PRIMARY KEY,
78
+ project_id VARCHAR NOT NULL,
79
+ package_name VARCHAR NOT NULL,
80
+ build_id VARCHAR NOT NULL,
81
+ table_name VARCHAR NOT NULL,
82
+ source_name VARCHAR NOT NULL,
83
+ connection_name VARCHAR NOT NULL,
84
+ created_at TIMESTAMP NOT NULL,
85
+ updated_at TIMESTAMP NOT NULL,
86
+ FOREIGN KEY (project_id) REFERENCES projects(id)
87
+ )
88
+ `);
89
+
90
+ await db.run(
91
+ `INSERT INTO projects VALUES ('p1', 'proj-one', '/p1', 'd1', NULL,
92
+ TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:00:00')`,
93
+ );
94
+ await db.run(
95
+ `INSERT INTO packages VALUES ('pkg1', 'p1', 'pkg-one', NULL, '/m', NULL,
96
+ TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:00:00')`,
97
+ );
98
+ }
99
+
100
+ describe("DuckDB legacy projects schema cleanup", () => {
101
+ beforeEach(async () => {
102
+ await fs.mkdir(TEST_DB_DIR, { recursive: true });
103
+ });
104
+
105
+ afterEach(async () => {
106
+ try {
107
+ await fs.rm(TEST_DB_DIR, { recursive: true, force: true });
108
+ } catch {
109
+ // ignore
110
+ }
111
+ });
112
+
113
+ it("drops legacy projects schema and creates the new environments schema cleanly", async () => {
114
+ const dbPath = path.join(TEST_DB_DIR, "legacy.duckdb");
115
+ const db = new DuckDBConnection(dbPath);
116
+ await db.initialize();
117
+
118
+ // Seed the legacy schema on the same connection, then run the
119
+ // production schema-init path. This mirrors a server upgrade
120
+ // (legacy data on disk, new code starting up) without forcing a
121
+ // close+reopen, which is unreliable on Windows runners.
122
+ await seedLegacySchema(db);
123
+ await initializeSchema(db);
124
+
125
+ // Legacy parent table is gone.
126
+ const legacyProjects = await db.all<{ name: string }>(
127
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='projects'",
128
+ );
129
+ expect(legacyProjects.length).toBe(0);
130
+
131
+ // New `environments` table exists and is empty (legacy data dropped).
132
+ const envs = await db.all<{ id: string }>("SELECT id FROM environments");
133
+ expect(envs.length).toBe(0);
134
+
135
+ // Child tables are queryable by `environment_id` (the new column),
136
+ // proving they were recreated with the new schema rather than left on
137
+ // the old `project_id` column.
138
+ const pkgs = await db.all<{ id: string }>(
139
+ "SELECT id FROM packages WHERE environment_id = ?",
140
+ ["p1"],
141
+ );
142
+ expect(pkgs.length).toBe(0);
143
+ const conns = await db.all<{ id: string }>(
144
+ "SELECT id FROM connections WHERE environment_id = ?",
145
+ ["p1"],
146
+ );
147
+ expect(conns.length).toBe(0);
148
+ const mats = await db.all<{ id: string }>(
149
+ "SELECT id FROM materializations WHERE environment_id = ?",
150
+ ["p1"],
151
+ );
152
+ expect(mats.length).toBe(0);
153
+ const manifests = await db.all<{ id: string }>(
154
+ "SELECT id FROM build_manifests WHERE environment_id = ?",
155
+ ["p1"],
156
+ );
157
+ expect(manifests.length).toBe(0);
158
+
159
+ await db.close();
160
+ });
161
+
162
+ it("is idempotent: running initializeSchema twice on a migrated DB is a no-op", async () => {
163
+ const dbPath = path.join(TEST_DB_DIR, "legacy_idempotent.duckdb");
164
+ const db = new DuckDBConnection(dbPath);
165
+ await db.initialize();
166
+
167
+ await seedLegacySchema(db);
168
+ await initializeSchema(db);
169
+ // Second call should hit the early-return path (isInitialized() === true).
170
+ await initializeSchema(db);
171
+
172
+ const envs = await db.all<{ id: string }>("SELECT id FROM environments");
173
+ expect(envs.length).toBe(0);
174
+
175
+ await db.close();
176
+ });
177
+
178
+ it("creates a fresh schema unchanged when no legacy projects table is present", async () => {
179
+ const dbPath = path.join(TEST_DB_DIR, "fresh.duckdb");
180
+ const db = new DuckDBConnection(dbPath);
181
+ await db.initialize();
182
+ await initializeSchema(db);
183
+
184
+ const envs = await db.all<{ id: string }>("SELECT id FROM environments");
185
+ expect(envs.length).toBe(0);
186
+
187
+ const legacy = await db.all<{ name: string }>(
188
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='projects'",
189
+ );
190
+ expect(legacy.length).toBe(0);
191
+
192
+ await db.close();
193
+ });
194
+ });
@@ -0,0 +1,166 @@
1
+ // Unit tests for the catch-block wiring in
2
+ // StorageManager.attachDuckLakeCatalog. The pure helpers from
3
+ // `pg_helpers.ts` are tested directly in `pg_helpers.spec.ts`; this file
4
+ // covers the integration — that the helpers are invoked in the right
5
+ // order and the right places inside `initializeDuckLakeForEnvironment`.
6
+ //
7
+ // Stubs DuckDBConnection.run instead of using a real DuckDB so we can
8
+ // inject libpq-style errors at the ATTACH boundary without standing up a
9
+ // real Postgres.
10
+ //
11
+ // Lives under `tests/unit/` (not `src/`) on purpose: the `src/` unit-spec
12
+ // process imports `service/environment_store.spec.ts`, which calls
13
+ // `mock.module("../storage/StorageManager", ...)`. Bun's module mocks
14
+ // persist process-wide across spec files, so a sibling spec in `src/` that
15
+ // `import`s the real StorageManager would get the mock instead. Running
16
+ // here puts us in the separate `test:integration` process with a clean
17
+ // module cache.
18
+ import { describe, expect, it } from "bun:test";
19
+ import { ConnectionAuthError } from "../../../src/errors";
20
+ import { StorageManager } from "../../../src/storage/StorageManager";
21
+
22
+ interface PrivateStorageManager {
23
+ duckDbConnection: { run: (sql: string) => Promise<unknown> } | null;
24
+ }
25
+
26
+ const PG_CONFIG = {
27
+ catalogUrl: "postgres:host=h user=u password=hunter2 dbname=catalog",
28
+ dataPath: "gs://bucket/path",
29
+ };
30
+
31
+ function setupWithStubbedConn(runHandler: (sql: string) => Promise<unknown>): {
32
+ sm: StorageManager;
33
+ calls: string[];
34
+ } {
35
+ const sm = new StorageManager({ type: "duckdb" });
36
+ const calls: string[] = [];
37
+ const stub = {
38
+ run: async (sql: string): Promise<unknown> => {
39
+ calls.push(sql);
40
+ return runHandler(sql);
41
+ },
42
+ };
43
+ (sm as unknown as PrivateStorageManager).duckDbConnection = stub;
44
+ return { sm, calls };
45
+ }
46
+
47
+ describe("StorageManager.attachDuckLakeCatalog wiring", () => {
48
+ it("classifies libpq auth failure on ATTACH as ConnectionAuthError", async () => {
49
+ const { sm } = setupWithStubbedConn(async (sql) => {
50
+ if (sql.startsWith("ATTACH")) {
51
+ throw new Error(
52
+ 'IO Error: Unable to connect to Postgres at "host=h user=u password=hunter2 ...": FATAL: password authentication failed for user "u"',
53
+ );
54
+ }
55
+ return undefined;
56
+ });
57
+
58
+ await expect(
59
+ sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG),
60
+ ).rejects.toBeInstanceOf(ConnectionAuthError);
61
+ });
62
+
63
+ it("redacts the embedded password in the classified error", async () => {
64
+ const { sm } = setupWithStubbedConn(async (sql) => {
65
+ if (sql.startsWith("ATTACH")) {
66
+ throw new Error(
67
+ "password authentication failed: tried host=h password=hunter2",
68
+ );
69
+ }
70
+ return undefined;
71
+ });
72
+
73
+ try {
74
+ await sm.initializeDuckLakeForEnvironment(
75
+ "env-1",
76
+ "env-name",
77
+ PG_CONFIG,
78
+ );
79
+ throw new Error("expected ATTACH to throw");
80
+ } catch (e) {
81
+ expect(e).toBeInstanceOf(ConnectionAuthError);
82
+ expect((e as Error).message).toContain("password=***");
83
+ expect((e as Error).message).not.toContain("hunter2");
84
+ }
85
+ });
86
+
87
+ it("injects connect_timeout into PG catalogUrl before ATTACH", async () => {
88
+ const { sm, calls } = setupWithStubbedConn(async () => undefined);
89
+
90
+ await sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG);
91
+
92
+ const attachSql = calls.find((s) => s.startsWith("ATTACH"));
93
+ expect(attachSql).toBeDefined();
94
+ expect(attachSql).toContain("connect_timeout=");
95
+ });
96
+
97
+ it("honors PG_CONNECT_TIMEOUT_SECONDS env override in the emitted SQL", async () => {
98
+ const original = process.env.PG_CONNECT_TIMEOUT_SECONDS;
99
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = "17";
100
+ try {
101
+ const { sm, calls } = setupWithStubbedConn(async () => undefined);
102
+ await sm.initializeDuckLakeForEnvironment(
103
+ "env-1",
104
+ "env-name",
105
+ PG_CONFIG,
106
+ );
107
+ const attachSql = calls.find((s) => s.startsWith("ATTACH"));
108
+ expect(attachSql).toContain("connect_timeout=17");
109
+ } finally {
110
+ if (original === undefined) {
111
+ delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
112
+ } else {
113
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = original;
114
+ }
115
+ }
116
+ });
117
+
118
+ it("leaves a non-PG catalogUrl untouched (no connect_timeout)", async () => {
119
+ const { sm, calls } = setupWithStubbedConn(async () => undefined);
120
+ const sqliteConfig = {
121
+ catalogUrl: "sqlite:/tmp/x.db",
122
+ dataPath: "/tmp/data",
123
+ };
124
+
125
+ await sm.initializeDuckLakeForEnvironment(
126
+ "env-1",
127
+ "env-name",
128
+ sqliteConfig,
129
+ );
130
+
131
+ const attachSql = calls.find((s) => s.startsWith("ATTACH"));
132
+ expect(attachSql).toBeDefined();
133
+ expect(attachSql).not.toContain("connect_timeout");
134
+ });
135
+
136
+ it("rethrows non-auth errors from ATTACH unchanged (preserves cause)", async () => {
137
+ const original = new Error("disk I/O error: read failed");
138
+ const { sm } = setupWithStubbedConn(async (sql) => {
139
+ if (sql.startsWith("ATTACH")) throw original;
140
+ return undefined;
141
+ });
142
+
143
+ await expect(
144
+ sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG),
145
+ ).rejects.toBe(original);
146
+ });
147
+
148
+ it("does not call connect_timeout injection when catalogUrl lacks postgres: prefix", async () => {
149
+ // Sanity: the isPostgres branch is detected purely by string prefix.
150
+ // A keyword-form string without the prefix shouldn't be misclassified.
151
+ const { sm, calls } = setupWithStubbedConn(async () => undefined);
152
+ const ambiguousConfig = {
153
+ catalogUrl: "host=h dbname=d", // no scheme prefix at all
154
+ dataPath: "/tmp/data",
155
+ };
156
+
157
+ await sm.initializeDuckLakeForEnvironment(
158
+ "env-1",
159
+ "env-name",
160
+ ambiguousConfig,
161
+ );
162
+
163
+ const attachSql = calls.find((s) => s.startsWith("ATTACH"));
164
+ expect(attachSql).not.toContain("connect_timeout");
165
+ });
166
+ });
@@ -1 +0,0 @@
1
- import{d as r,t as a,j as e,E as i,J as o}from"./index-5K9YjIxF.js";function m(){const s=r(),{environmentName:n}=a();if(n){const t=i({environmentName:n});return e.jsx(o,{onSelectPackage:s,resourceUri:t})}else return e.jsx("div",{children:e.jsx("h2",{children:"Missing environment name"})})}export{m as default};
@@ -1 +0,0 @@
1
- import{d as n,j as t,a as o}from"./index-5K9YjIxF.js";function s(){const a=n();return t.jsx(o,{onClickEnvironment:a})}export{s as default};