@malloy-publisher/server 0.0.204 → 0.0.206
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 +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -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/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- 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/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- 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/legacy_schema_migration.test.ts +7 -4
- 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
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BuildManifestResult,
|
|
3
|
+
BuildPlan,
|
|
4
|
+
Materialization,
|
|
5
|
+
MaterializationStatus,
|
|
6
|
+
MaterializationUpdate,
|
|
7
|
+
} from "../DatabaseInterface";
|
|
2
8
|
import { DuckDBConnection } from "./DuckDBConnection";
|
|
3
9
|
|
|
4
10
|
const TERMINAL_STATUSES: ReadonlySet<MaterializationStatus> = new Set([
|
|
5
|
-
"
|
|
11
|
+
"MANIFEST_FILE_READY",
|
|
6
12
|
"FAILED",
|
|
7
13
|
"CANCELLED",
|
|
8
14
|
]);
|
|
9
15
|
|
|
16
|
+
/** Non-terminal statuses count as "active" for the single-active guard. */
|
|
17
|
+
const ACTIVE_STATUSES: readonly MaterializationStatus[] = [
|
|
18
|
+
"PENDING",
|
|
19
|
+
"BUILD_PLAN_READY",
|
|
20
|
+
"MANIFEST_ROWS_READY",
|
|
21
|
+
];
|
|
22
|
+
|
|
10
23
|
function activeKeyFor(environmentId: string, packageName: string): string {
|
|
11
24
|
return `${environmentId}|${packageName}`;
|
|
12
25
|
}
|
|
@@ -73,9 +86,10 @@ export class MaterializationRepository {
|
|
|
73
86
|
environmentId: string,
|
|
74
87
|
packageName: string,
|
|
75
88
|
): Promise<Materialization | null> {
|
|
89
|
+
const placeholders = ACTIVE_STATUSES.map(() => "?").join(", ");
|
|
76
90
|
const row = await this.db.get<Record<string, unknown>>(
|
|
77
|
-
|
|
78
|
-
[environmentId, packageName],
|
|
91
|
+
`SELECT * FROM materializations WHERE environment_id = ? AND package_name = ? AND status IN (${placeholders})`,
|
|
92
|
+
[environmentId, packageName, ...ACTIVE_STATUSES],
|
|
79
93
|
);
|
|
80
94
|
return row ? this.mapRow(row) : null;
|
|
81
95
|
}
|
|
@@ -100,8 +114,8 @@ export class MaterializationRepository {
|
|
|
100
114
|
|
|
101
115
|
try {
|
|
102
116
|
const rows = await this.db.all<Record<string, unknown>>(
|
|
103
|
-
`INSERT INTO materializations (id, environment_id, package_name, status, active_key, metadata, created_at, updated_at)
|
|
104
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
117
|
+
`INSERT INTO materializations (id, environment_id, package_name, status, active_key, metadata, build_plan, manifest, created_at, updated_at)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
|
|
105
119
|
RETURNING *`,
|
|
106
120
|
[
|
|
107
121
|
id,
|
|
@@ -128,13 +142,7 @@ export class MaterializationRepository {
|
|
|
128
142
|
|
|
129
143
|
async update(
|
|
130
144
|
id: string,
|
|
131
|
-
updates:
|
|
132
|
-
status?: MaterializationStatus;
|
|
133
|
-
startedAt?: Date;
|
|
134
|
-
completedAt?: Date;
|
|
135
|
-
error?: string | null;
|
|
136
|
-
metadata?: Record<string, unknown> | null;
|
|
137
|
-
},
|
|
145
|
+
updates: MaterializationUpdate,
|
|
138
146
|
): Promise<Materialization> {
|
|
139
147
|
const now = this.now();
|
|
140
148
|
const setClauses: string[] = [];
|
|
@@ -173,6 +181,18 @@ export class MaterializationRepository {
|
|
|
173
181
|
updates.metadata ? JSON.stringify(updates.metadata) : null,
|
|
174
182
|
);
|
|
175
183
|
}
|
|
184
|
+
if (updates.buildPlan !== undefined) {
|
|
185
|
+
setClauses.push(`build_plan = ?`);
|
|
186
|
+
params.push(
|
|
187
|
+
updates.buildPlan ? JSON.stringify(updates.buildPlan) : null,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (updates.manifest !== undefined) {
|
|
191
|
+
setClauses.push(`manifest = ?`);
|
|
192
|
+
params.push(
|
|
193
|
+
updates.manifest ? JSON.stringify(updates.manifest) : null,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
176
196
|
|
|
177
197
|
setClauses.push(`updated_at = ?`);
|
|
178
198
|
params.push(now.toISOString());
|
|
@@ -190,6 +210,10 @@ export class MaterializationRepository {
|
|
|
190
210
|
return updated;
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
async deleteById(id: string): Promise<void> {
|
|
214
|
+
await this.db.run("DELETE FROM materializations WHERE id = ?", [id]);
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
async deleteByEnvironmentId(environmentId: string): Promise<void> {
|
|
194
218
|
await this.db.run(
|
|
195
219
|
"DELETE FROM materializations WHERE environment_id = ?",
|
|
@@ -197,10 +221,6 @@ export class MaterializationRepository {
|
|
|
197
221
|
);
|
|
198
222
|
}
|
|
199
223
|
|
|
200
|
-
async deleteById(id: string): Promise<void> {
|
|
201
|
-
await this.db.run("DELETE FROM materializations WHERE id = ?", [id]);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
224
|
async deleteByPackage(
|
|
205
225
|
environmentId: string,
|
|
206
226
|
packageName: string,
|
|
@@ -212,32 +232,37 @@ export class MaterializationRepository {
|
|
|
212
232
|
}
|
|
213
233
|
|
|
214
234
|
private mapRow(row: Record<string, unknown>): Materialization {
|
|
215
|
-
|
|
216
|
-
if (row.metadata) {
|
|
217
|
-
try {
|
|
218
|
-
metadata = JSON.parse(row.metadata as string);
|
|
219
|
-
} catch {
|
|
220
|
-
metadata = null;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
235
|
+
const metadata = parseJsonColumn<Record<string, unknown>>(row.metadata);
|
|
224
236
|
return {
|
|
225
237
|
id: row.id as string,
|
|
226
238
|
environmentId: row.environment_id as string,
|
|
227
239
|
packageName: row.package_name as string,
|
|
240
|
+
pauseBetweenPhases: metadata?.pauseBetweenPhases === true,
|
|
228
241
|
status: row.status as MaterializationStatus,
|
|
242
|
+
buildPlan: parseJsonColumn<BuildPlan>(row.build_plan),
|
|
243
|
+
manifest: parseJsonColumn<BuildManifestResult>(row.manifest),
|
|
244
|
+
metadata,
|
|
229
245
|
startedAt: row.started_at ? new Date(row.started_at as string) : null,
|
|
230
246
|
completedAt: row.completed_at
|
|
231
247
|
? new Date(row.completed_at as string)
|
|
232
248
|
: null,
|
|
233
249
|
error: row.error != null ? (row.error as string) : null,
|
|
234
|
-
metadata,
|
|
235
250
|
createdAt: new Date(row.created_at as string),
|
|
236
251
|
updatedAt: new Date(row.updated_at as string),
|
|
237
252
|
};
|
|
238
253
|
}
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
/** Parse a nullable JSON text column, returning null on absence or parse error. */
|
|
257
|
+
function parseJsonColumn<T>(value: unknown): T | null {
|
|
258
|
+
if (value == null) return null;
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(value as string) as T;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
241
266
|
/**
|
|
242
267
|
* DuckDB surfaces unique-constraint violations as plain Errors whose message
|
|
243
268
|
* mentions the violated index. We match on the index name rather than a
|
|
@@ -82,6 +82,8 @@ export async function initializeSchema(
|
|
|
82
82
|
// `active_key` (see below) makes the insert-then-check race impossible —
|
|
83
83
|
// a second concurrent create fails with a constraint violation, which the
|
|
84
84
|
// service layer translates to `MaterializationConflictError`.
|
|
85
|
+
// `build_plan` (Round 1) and `manifest` (Round 2) are JSON blobs holding
|
|
86
|
+
// the two-round protocol payloads returned inline on the resource.
|
|
85
87
|
await db.run(`
|
|
86
88
|
CREATE TABLE IF NOT EXISTS materializations (
|
|
87
89
|
id VARCHAR PRIMARY KEY,
|
|
@@ -93,29 +95,14 @@ export async function initializeSchema(
|
|
|
93
95
|
completed_at TIMESTAMP,
|
|
94
96
|
error TEXT,
|
|
95
97
|
metadata JSON,
|
|
98
|
+
build_plan JSON,
|
|
99
|
+
manifest JSON,
|
|
96
100
|
created_at TIMESTAMP NOT NULL,
|
|
97
101
|
updated_at TIMESTAMP NOT NULL,
|
|
98
102
|
FOREIGN KEY (environment_id) REFERENCES environments(id)
|
|
99
103
|
)
|
|
100
104
|
`);
|
|
101
105
|
|
|
102
|
-
// Build manifests table
|
|
103
|
-
await db.run(`
|
|
104
|
-
CREATE TABLE IF NOT EXISTS build_manifests (
|
|
105
|
-
id VARCHAR PRIMARY KEY,
|
|
106
|
-
environment_id VARCHAR NOT NULL,
|
|
107
|
-
package_name VARCHAR NOT NULL,
|
|
108
|
-
build_id VARCHAR NOT NULL,
|
|
109
|
-
table_name VARCHAR NOT NULL,
|
|
110
|
-
source_name VARCHAR NOT NULL,
|
|
111
|
-
connection_name VARCHAR NOT NULL,
|
|
112
|
-
created_at TIMESTAMP NOT NULL,
|
|
113
|
-
updated_at TIMESTAMP NOT NULL,
|
|
114
|
-
FOREIGN KEY (environment_id) REFERENCES environments(id),
|
|
115
|
-
UNIQUE (environment_id, package_name, build_id)
|
|
116
|
-
)
|
|
117
|
-
`);
|
|
118
|
-
|
|
119
106
|
// Create indexes for better query performance
|
|
120
107
|
await db.run(
|
|
121
108
|
"CREATE INDEX IF NOT EXISTS idx_packages_environment_id ON packages(environment_id)",
|
|
@@ -129,9 +116,6 @@ export async function initializeSchema(
|
|
|
129
116
|
await db.run(
|
|
130
117
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_materializations_active_key ON materializations(active_key)",
|
|
131
118
|
);
|
|
132
|
-
await db.run(
|
|
133
|
-
"CREATE INDEX IF NOT EXISTS idx_build_manifests_environment_package ON build_manifests(environment_id, package_name)",
|
|
134
|
-
);
|
|
135
119
|
}
|
|
136
120
|
|
|
137
121
|
// TODO: Remove this during projects cleanup
|
|
@@ -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
|
+
});
|