@malloy-publisher/server 0.0.204 → 0.0.205
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/build.ts +10 -1
- package/dist/app/api-doc.yaml +133 -4
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-CAge6UHD.js} +1 -1
- package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-CeTxxGex.js} +2 -2
- package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
- package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-LRqQWrFY.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-xT6kuCNw.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-DsIh9svZ.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BdOZDcce.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-DHHAcY5o.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-RX3QOTde.js} +121 -121
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-D2WH3D-f.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +567 -194
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/errors.spec.ts +21 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/runtime/publisher.js +318 -0
- package/src/server.ts +479 -2
- package/src/service/authorize_integration.spec.ts +96 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +63 -5
- package/src/service/environment_store.ts +142 -11
- package/src/service/model.ts +44 -0
- package/src/service/package.ts +17 -6
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { Mutex } from "async-mutex";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
DuckDBConnection as NeoConnection,
|
|
4
|
+
DuckDBInstance,
|
|
5
|
+
type DuckDBValue,
|
|
6
|
+
} from "@duckdb/node-api";
|
|
3
7
|
import * as path from "path";
|
|
4
8
|
import { DatabaseConnection } from "../DatabaseInterface";
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Embedded persistence layer for the publisher's own metadata (environments,
|
|
12
|
+
* packages, connections, materializations, build manifests) in `publisher.db`.
|
|
13
|
+
*
|
|
14
|
+
* This is a plain DAO over a durable, exclusively-owned DuckDB handle -- it is
|
|
15
|
+
* deliberately NOT Malloy's `@malloydata/db-duckdb` connection, which is an
|
|
16
|
+
* analytical query connection (no prepared-statement parameter binding, a
|
|
17
|
+
* `:memory:` primary with ATTACH/DETACH/idle lifecycle, pooled/shared
|
|
18
|
+
* instances, and a poison-pill close). Those semantics are wrong for a
|
|
19
|
+
* source-of-truth store that must hold one handle open for the server's
|
|
20
|
+
* lifetime and run parameterized CRUD.
|
|
21
|
+
*
|
|
22
|
+
* It wraps `@duckdb/node-api` (the same DuckDB engine Malloy pulls in), so the
|
|
23
|
+
* repo carries a single DuckDB engine rather than a second, redundant driver.
|
|
24
|
+
*/
|
|
6
25
|
export class DuckDBConnection implements DatabaseConnection {
|
|
7
|
-
private
|
|
8
|
-
private connection:
|
|
26
|
+
private instance: DuckDBInstance | null = null;
|
|
27
|
+
private connection: NeoConnection | null = null;
|
|
9
28
|
private dbPath: string;
|
|
10
29
|
private mutex: Mutex = new Mutex();
|
|
11
30
|
|
|
@@ -15,94 +34,47 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
async initialize(): Promise<void> {
|
|
18
|
-
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.db as duckdb.Database & { connect(): duckdb.Connection }
|
|
29
|
-
).connect();
|
|
30
|
-
|
|
31
|
-
if (!this.connection) {
|
|
32
|
-
reject(new Error("Failed to create connection object"));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Verify connection works
|
|
37
|
-
this.connection.all("SELECT 42 as answer", (testErr, _rows) => {
|
|
38
|
-
if (testErr) {
|
|
39
|
-
console.error("Connection test failed:", testErr);
|
|
40
|
-
reject(
|
|
41
|
-
new Error(
|
|
42
|
-
`Failed to verify DuckDB connection: ${testErr.message}`,
|
|
43
|
-
),
|
|
44
|
-
);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
resolve();
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
});
|
|
37
|
+
try {
|
|
38
|
+
this.instance = await DuckDBInstance.create(this.dbPath);
|
|
39
|
+
this.connection = await this.instance.connect();
|
|
40
|
+
// Verify the connection works
|
|
41
|
+
await this.connection.run("SELECT 42 as answer");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.error("Failed to create DuckDB database:", err);
|
|
45
|
+
throw new Error(`Failed to initialize DuckDB: ${message}`);
|
|
46
|
+
}
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
async close(): Promise<void> {
|
|
55
|
-
|
|
50
|
+
try {
|
|
56
51
|
if (this.connection) {
|
|
57
|
-
this.connection.
|
|
58
|
-
|
|
59
|
-
reject(
|
|
60
|
-
new Error(
|
|
61
|
-
`Failed to close DuckDB connection: ${err.message}`,
|
|
62
|
-
),
|
|
63
|
-
);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (this.db) {
|
|
68
|
-
this.db.close((dbErr) => {
|
|
69
|
-
if (dbErr) {
|
|
70
|
-
reject(
|
|
71
|
-
new Error(
|
|
72
|
-
`Failed to close DuckDB: ${dbErr.message}`,
|
|
73
|
-
),
|
|
74
|
-
);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
console.log("DuckDB connection closed");
|
|
78
|
-
resolve();
|
|
79
|
-
});
|
|
80
|
-
} else {
|
|
81
|
-
resolve();
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
} else {
|
|
85
|
-
resolve();
|
|
52
|
+
this.connection.closeSync();
|
|
53
|
+
this.connection = null;
|
|
86
54
|
}
|
|
87
|
-
|
|
55
|
+
if (this.instance) {
|
|
56
|
+
this.instance.closeSync();
|
|
57
|
+
this.instance = null;
|
|
58
|
+
}
|
|
59
|
+
console.log("DuckDB connection closed");
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new Error(`Failed to close DuckDB connection: ${message}`);
|
|
63
|
+
}
|
|
88
64
|
}
|
|
89
65
|
|
|
90
66
|
async isInitialized(): Promise<boolean> {
|
|
91
67
|
if (!this.connection) return false;
|
|
92
68
|
|
|
93
69
|
return this.mutex.runExclusive(async () => {
|
|
94
|
-
|
|
95
|
-
this.connection!.
|
|
70
|
+
try {
|
|
71
|
+
const reader = await this.connection!.runAndReadAll(
|
|
96
72
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='environments'",
|
|
97
|
-
(err, rows) => {
|
|
98
|
-
if (err) {
|
|
99
|
-
resolve(false);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
resolve(rows && rows.length > 0);
|
|
103
|
-
},
|
|
104
73
|
);
|
|
105
|
-
|
|
74
|
+
return reader.getRowObjectsJS().length > 0;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
106
78
|
});
|
|
107
79
|
}
|
|
108
80
|
|
|
@@ -112,26 +84,14 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
112
84
|
}
|
|
113
85
|
|
|
114
86
|
return this.mutex.runExclusive(async () => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
resolve();
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// Pass params directly without the params argument if empty
|
|
129
|
-
if (params && params.length > 0) {
|
|
130
|
-
this.connection!.run(query, ...params, callback);
|
|
131
|
-
} else {
|
|
132
|
-
this.connection!.run(query, callback);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
87
|
+
try {
|
|
88
|
+
await this.connection!.run(query, params as DuckDBValue[]);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Query execution failed: ${message}\nQuery: ${query}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
135
95
|
});
|
|
136
96
|
}
|
|
137
97
|
|
|
@@ -141,25 +101,18 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
141
101
|
}
|
|
142
102
|
|
|
143
103
|
return this.mutex.runExclusive(async () => {
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (params && params.length > 0) {
|
|
158
|
-
this.connection!.all(query, ...params, callback);
|
|
159
|
-
} else {
|
|
160
|
-
this.connection!.all(query, callback);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
104
|
+
try {
|
|
105
|
+
const reader = await this.connection!.runAndReadAll(
|
|
106
|
+
query,
|
|
107
|
+
params as DuckDBValue[],
|
|
108
|
+
);
|
|
109
|
+
return reader.getRowObjectsJS() as T[];
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Query execution failed: ${message}\nQuery: ${query}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
163
116
|
});
|
|
164
117
|
}
|
|
165
118
|
|
|
@@ -167,11 +120,4 @@ export class DuckDBConnection implements DatabaseConnection {
|
|
|
167
120
|
const rows = await this.all<T>(query, params);
|
|
168
121
|
return rows.length > 0 ? rows[0] : null;
|
|
169
122
|
}
|
|
170
|
-
|
|
171
|
-
getConnection(): duckdb.Connection {
|
|
172
|
-
if (!this.connection) {
|
|
173
|
-
throw new Error("Database not initialized");
|
|
174
|
-
}
|
|
175
|
-
return this.connection;
|
|
176
|
-
}
|
|
177
123
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
source: nums is duckdb.sql("SELECT 1 as n")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "ok": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
source: report is duckdb.sql("SELECT 1 as n")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { type RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
5
|
+
|
|
6
|
+
// HTTP end-to-end for the /compile authorize gate: proves AccessDeniedError
|
|
7
|
+
// surfaces as a real 403 body through the express route + internalErrorToHttpError,
|
|
8
|
+
// and that the 403 names only the source (never the gate expression).
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PROJECT = "authorize-compile-test-project";
|
|
12
|
+
const PKG = "authorize-compile";
|
|
13
|
+
const MODEL = "model.malloy";
|
|
14
|
+
|
|
15
|
+
describe("compile authorize gate (HTTP E2E)", () => {
|
|
16
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
17
|
+
let baseUrl: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
env = await startRestE2E();
|
|
21
|
+
baseUrl = env.baseUrl;
|
|
22
|
+
const fixtureDir = path.resolve(
|
|
23
|
+
__dirname,
|
|
24
|
+
"../../fixtures/authorize-compile",
|
|
25
|
+
);
|
|
26
|
+
const createRes = await fetch(`${baseUrl}/api/v0/projects`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
name: PROJECT,
|
|
31
|
+
packages: [{ name: PKG, location: fixtureDir }],
|
|
32
|
+
connections: [],
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
if (!createRes.ok) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Failed to create test project (${createRes.status}): ${await createRes.text()}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const deadline = Date.now() + 30_000;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
const res = await fetch(
|
|
43
|
+
`${baseUrl}/api/v0/projects/${PROJECT}/packages/${PKG}`,
|
|
44
|
+
);
|
|
45
|
+
if (res.ok) break;
|
|
46
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await env?.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const compile = (body: unknown) =>
|
|
55
|
+
fetch(
|
|
56
|
+
`${baseUrl}/api/v0/environments/${PROJECT}/packages/${PKG}/models/${MODEL}/compile`,
|
|
57
|
+
{
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it("returns 403 with a source-only message when the gate is not satisfied", async () => {
|
|
65
|
+
const res = await compile({
|
|
66
|
+
source: "run: gated -> { aggregate: c }",
|
|
67
|
+
givens: {},
|
|
68
|
+
});
|
|
69
|
+
expect(res.status).toBe(403);
|
|
70
|
+
const json = (await res.json()) as { code: number; message: string };
|
|
71
|
+
expect(json.code).toBe(403);
|
|
72
|
+
expect(json.message).toBe('Access denied for source "gated".');
|
|
73
|
+
// Redaction: the runtime 403 must NOT leak the authorize expression.
|
|
74
|
+
expect(json.message).not.toContain("ROLE");
|
|
75
|
+
expect(json.message).not.toContain("analyst");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns 200 when a satisfying given is supplied", async () => {
|
|
79
|
+
const res = await compile({
|
|
80
|
+
source: "run: gated -> { aggregate: c }",
|
|
81
|
+
givens: { ROLE: "analyst" },
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("compiles an ungated source without any given", async () => {
|
|
87
|
+
const res = await compile({
|
|
88
|
+
source: "run: open_src -> { aggregate: c }",
|
|
89
|
+
});
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
// Integration test for the DuckDB storage stack end-to-end:
|
|
4
|
+
// StorageManager -> ResourceRepository -> DuckDBConnection (@duckdb/node-api)
|
|
5
|
+
// -> a real on-disk publisher.db
|
|
6
|
+
//
|
|
7
|
+
// Unlike the repository unit tests (which construct a single repo against a bare
|
|
8
|
+
// connection) this drives the same path the server uses -- StorageManager.initialize()
|
|
9
|
+
// wires the schema + repositories over the migrated connection, and getRepository()
|
|
10
|
+
// is what the service layer calls. It also reopens a second StorageManager against
|
|
11
|
+
// the same file to prove data persists across a restart, which is the real
|
|
12
|
+
// end-to-end guarantee for the migration.
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
15
|
+
import fs from "fs/promises";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import { StorageManager } from "../../../src/storage/StorageManager";
|
|
19
|
+
|
|
20
|
+
describe("DuckDB storage stack (integration)", () => {
|
|
21
|
+
let dbDir: string;
|
|
22
|
+
let dbPath: string;
|
|
23
|
+
let sm: StorageManager;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
dbDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckdb-storage-int-"));
|
|
27
|
+
dbPath = path.join(dbDir, "publisher.db");
|
|
28
|
+
sm = new StorageManager({ type: "duckdb", duckdb: { path: dbPath } });
|
|
29
|
+
await sm.initialize();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
try {
|
|
34
|
+
await sm.close();
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
await fs.rm(dbDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("initializes the schema and reports ready", () => {
|
|
42
|
+
expect(sm.isInitialized()).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("creates an environment with a package and connection through the repository", async () => {
|
|
46
|
+
const repo = sm.getRepository();
|
|
47
|
+
|
|
48
|
+
const env = await repo.createEnvironment({
|
|
49
|
+
name: "prod",
|
|
50
|
+
path: "/srv/prod",
|
|
51
|
+
metadata: { owner: "platform" },
|
|
52
|
+
});
|
|
53
|
+
expect(env.id).toBeTruthy();
|
|
54
|
+
|
|
55
|
+
const pkg = await repo.createPackage({
|
|
56
|
+
environmentId: env.id,
|
|
57
|
+
name: "sales",
|
|
58
|
+
manifestPath: "/srv/prod/sales/publisher.json",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const conn = await repo.createConnection({
|
|
62
|
+
environmentId: env.id,
|
|
63
|
+
name: "warehouse",
|
|
64
|
+
type: "snowflake",
|
|
65
|
+
config: { account: "acct", warehouse: "wh" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Read everything back through the same repository.
|
|
69
|
+
expect((await repo.getEnvironmentByName("prod"))?.id).toBe(env.id);
|
|
70
|
+
expect((await repo.listPackages(env.id)).map((p) => p.name)).toEqual([
|
|
71
|
+
"sales",
|
|
72
|
+
]);
|
|
73
|
+
const conns = await repo.listConnections(env.id);
|
|
74
|
+
expect(conns.map((c) => c.name)).toEqual(["warehouse"]);
|
|
75
|
+
expect(conns[0].config).toEqual({ account: "acct", warehouse: "wh" });
|
|
76
|
+
expect(pkg.environmentId).toBe(env.id);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("persists data across a StorageManager restart (reopen the same file)", async () => {
|
|
80
|
+
const repo = sm.getRepository();
|
|
81
|
+
const env = await repo.createEnvironment({
|
|
82
|
+
name: "persisted",
|
|
83
|
+
path: "/srv/persisted",
|
|
84
|
+
metadata: { keep: true },
|
|
85
|
+
});
|
|
86
|
+
await repo.createConnection({
|
|
87
|
+
environmentId: env.id,
|
|
88
|
+
name: "pg",
|
|
89
|
+
type: "postgres",
|
|
90
|
+
config: { host: "db.internal", port: 5432 },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Close the manager (releases the file), then open a fresh one on the
|
|
94
|
+
// same path -- the data must still be there.
|
|
95
|
+
await sm.close();
|
|
96
|
+
|
|
97
|
+
const reopened = new StorageManager({
|
|
98
|
+
type: "duckdb",
|
|
99
|
+
duckdb: { path: dbPath },
|
|
100
|
+
});
|
|
101
|
+
await reopened.initialize();
|
|
102
|
+
try {
|
|
103
|
+
const repo2 = reopened.getRepository();
|
|
104
|
+
const env2 = await repo2.getEnvironmentByName("persisted");
|
|
105
|
+
expect(env2).not.toBeNull();
|
|
106
|
+
expect(env2!.metadata).toEqual({ keep: true });
|
|
107
|
+
expect(env2!.createdAt).toBeInstanceOf(Date);
|
|
108
|
+
|
|
109
|
+
const conns = await repo2.listConnections(env2!.id);
|
|
110
|
+
expect(conns.map((c) => c.name)).toEqual(["pg"]);
|
|
111
|
+
expect(conns[0].config).toEqual({ host: "db.internal", port: 5432 });
|
|
112
|
+
} finally {
|
|
113
|
+
await reopened.close();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Point afterEach's close() at the already-closed original without error.
|
|
117
|
+
sm = reopened;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("deleteEnvironment also removes the environment's connections", async () => {
|
|
121
|
+
const repo = sm.getRepository();
|
|
122
|
+
const env = await repo.createEnvironment({
|
|
123
|
+
name: "to-delete",
|
|
124
|
+
path: "/srv/del",
|
|
125
|
+
});
|
|
126
|
+
await repo.createConnection({
|
|
127
|
+
environmentId: env.id,
|
|
128
|
+
name: "c1",
|
|
129
|
+
type: "duckdb",
|
|
130
|
+
config: {},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await repo.deleteEnvironment(env.id);
|
|
134
|
+
|
|
135
|
+
expect(await repo.getEnvironmentById(env.id)).toBeNull();
|
|
136
|
+
expect(await repo.listConnections(env.id)).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|