@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,35 +1,11 @@
|
|
|
1
|
-
import { Mutex } from "async-mutex";
|
|
2
|
-
import * as crypto from "crypto";
|
|
3
|
-
import {
|
|
4
|
-
isCatalogVersionSupported,
|
|
5
|
-
SUPPORTED_CATALOG_VERSIONS,
|
|
6
|
-
} from "../ducklake_version";
|
|
7
|
-
import { ConnectionAuthError } from "../errors";
|
|
8
1
|
import { logger } from "../logger";
|
|
9
|
-
import {
|
|
10
|
-
handlePgAttachError,
|
|
11
|
-
pgConnectTimeoutSeconds,
|
|
12
|
-
redactPgSecrets,
|
|
13
|
-
withPgConnectTimeout,
|
|
14
|
-
} from "../pg_helpers";
|
|
15
|
-
import {
|
|
16
|
-
DatabaseConnection,
|
|
17
|
-
ManifestStore,
|
|
18
|
-
ResourceRepository,
|
|
19
|
-
} from "./DatabaseInterface";
|
|
2
|
+
import { DatabaseConnection, ResourceRepository } from "./DatabaseInterface";
|
|
20
3
|
import { DuckDBConnection } from "./duckdb/DuckDBConnection";
|
|
21
|
-
import { DuckDBManifestStore } from "./duckdb/DuckDBManifestStore";
|
|
22
4
|
import { DuckDBRepository } from "./duckdb/DuckDBRepository";
|
|
23
5
|
import { initializeSchema } from "./duckdb/schema";
|
|
24
|
-
import { DuckLakeManifestStore } from "./ducklake/DuckLakeManifestStore";
|
|
25
6
|
|
|
26
7
|
export type StorageType = "duckdb" | "postgres" | "mysql";
|
|
27
8
|
|
|
28
|
-
export interface DuckLakeManifestConfig {
|
|
29
|
-
catalogUrl: string;
|
|
30
|
-
dataPath: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
9
|
export interface StorageConfig {
|
|
34
10
|
type: StorageType;
|
|
35
11
|
duckdb?: {
|
|
@@ -51,98 +27,16 @@ export interface StorageConfig {
|
|
|
51
27
|
};
|
|
52
28
|
}
|
|
53
29
|
|
|
54
|
-
function escapeSQL(value: string): string {
|
|
55
|
-
return value.replace(/'/g, "''");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function configKey(c: DuckLakeManifestConfig): string {
|
|
59
|
-
return `${c.catalogUrl}|${c.dataPath}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function catalogNameForConfig(c: DuckLakeManifestConfig): string {
|
|
63
|
-
const hash = crypto
|
|
64
|
-
.createHash("sha256")
|
|
65
|
-
.update(configKey(c))
|
|
66
|
-
.digest("hex")
|
|
67
|
-
.slice(0, 8);
|
|
68
|
-
return `manifest_lake_${hash}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Read the catalog's recorded DuckLake format version from its
|
|
72
|
-
// `ducklake_metadata` table via a plain postgres ATTACH (does NOT invoke
|
|
73
|
-
// the DuckLake extension on the catalog). Returns the version string on
|
|
74
|
-
// success, or `undefined` on any failure (missing table, query timeout,
|
|
75
|
-
// connect failure) so the main ATTACH path stays the source of truth for
|
|
76
|
-
// unrelated errors. Only meaningful for postgres-backed catalogs; the
|
|
77
|
-
// caller must guard with `isPostgres`.
|
|
78
|
-
async function readDuckLakeCatalogVersion(
|
|
79
|
-
connection: DuckDBConnection,
|
|
80
|
-
catalogUrl: string,
|
|
81
|
-
catalogName: string,
|
|
82
|
-
): Promise<string | undefined> {
|
|
83
|
-
if (!catalogUrl.startsWith("postgres:")) {
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
86
|
-
const pgConnString = catalogUrl.slice("postgres:".length);
|
|
87
|
-
const tempDb = `${catalogName}_preflight`;
|
|
88
|
-
const escaped = escapeSQL(pgConnString);
|
|
89
|
-
try {
|
|
90
|
-
await connection.run(
|
|
91
|
-
`ATTACH '${escaped}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
|
|
92
|
-
);
|
|
93
|
-
const rows = await connection.all<{ value: string }>(
|
|
94
|
-
`SELECT value FROM ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
|
|
95
|
-
);
|
|
96
|
-
const value = rows[0]?.value;
|
|
97
|
-
return typeof value === "string" ? value : undefined;
|
|
98
|
-
} catch (error) {
|
|
99
|
-
logger.warn(
|
|
100
|
-
"DuckLake catalog version preflight failed; falling back to ATTACH",
|
|
101
|
-
{
|
|
102
|
-
catalogName,
|
|
103
|
-
error: redactPgSecrets(
|
|
104
|
-
error instanceof Error ? error.message : String(error),
|
|
105
|
-
),
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
return undefined;
|
|
109
|
-
} finally {
|
|
110
|
-
try {
|
|
111
|
-
await connection.run(`DETACH ${tempDb};`);
|
|
112
|
-
} catch {
|
|
113
|
-
// ATTACH may have failed, so DETACH may have nothing to do.
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
30
|
/**
|
|
119
|
-
* Manages the storage backend (DuckDB
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
31
|
+
* Manages the storage backend (DuckDB today) and exposes the resource
|
|
32
|
+
* repository. The publisher persists only its own control records
|
|
33
|
+
* (environments, packages, connections, materializations) here; build
|
|
34
|
+
* manifests are returned inline on the materialization resource and persisted
|
|
35
|
+
* by the control plane.
|
|
123
36
|
*/
|
|
124
37
|
export class StorageManager {
|
|
125
38
|
private connection: DatabaseConnection | null = null;
|
|
126
|
-
private duckDbConnection: DuckDBConnection | null = null;
|
|
127
39
|
private repository: ResourceRepository | null = null;
|
|
128
|
-
private defaultManifestStore: ManifestStore | null = null;
|
|
129
|
-
|
|
130
|
-
/** Per-environment DuckLake manifest stores, keyed by environmentId. */
|
|
131
|
-
private environmentManifestStores = new Map<string, ManifestStore>();
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Tracks attached DuckLake catalogs as `configKey -> catalogName`. Each
|
|
135
|
-
* unique materializationStorage config gets its own ATTACHment under a
|
|
136
|
-
* deterministic catalog name, so multiple configs can coexist on one worker.
|
|
137
|
-
*/
|
|
138
|
-
private attachedCatalogs = new Map<string, string>();
|
|
139
|
-
|
|
140
|
-
// Serializes DuckLake catalog attaches. Concurrent POST /environments calls
|
|
141
|
-
// hitting the same DuckDB connection would otherwise race on extension
|
|
142
|
-
// autoload (httpfs/azure/etc.), where multiple connections download the
|
|
143
|
-
// extension to `.tmp-<uuid>` files in parallel; only one wins the rename
|
|
144
|
-
// and the rest crash with "Could not remove file ... No such file or directory".
|
|
145
|
-
private duckLakeAttachMutex: Mutex = new Mutex();
|
|
146
40
|
|
|
147
41
|
private config: StorageConfig;
|
|
148
42
|
|
|
@@ -173,166 +67,12 @@ export class StorageManager {
|
|
|
173
67
|
}
|
|
174
68
|
|
|
175
69
|
private async initializeDuckDB(reinit: boolean): Promise<void> {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
const connection = new DuckDBConnection(dbPath);
|
|
179
|
-
|
|
70
|
+
const connection = new DuckDBConnection(this.config.duckdb?.path);
|
|
180
71
|
await connection.initialize();
|
|
181
|
-
|
|
182
72
|
await initializeSchema(connection, reinit);
|
|
183
73
|
|
|
184
74
|
this.connection = connection;
|
|
185
|
-
this.duckDbConnection = connection;
|
|
186
75
|
this.repository = new DuckDBRepository(connection);
|
|
187
|
-
this.defaultManifestStore = new DuckDBManifestStore(this.repository);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Lazily initializes a DuckLake manifest store for an environment.
|
|
192
|
-
*
|
|
193
|
-
* One shared catalog per materializationStorage config: every environment
|
|
194
|
-
* pointing at the same (catalogUrl, dataPath) shares one `build_manifests`
|
|
195
|
-
* table inside it, partitioned by `environment_id` (set to the environment's name
|
|
196
|
-
* so it's stable across worker replicas — required for cross-pod manifest
|
|
197
|
-
* visibility in orchestrated mode). Different configs (e.g. different
|
|
198
|
-
* orgs) attach as separate catalogs under distinct deterministic aliases.
|
|
199
|
-
*/
|
|
200
|
-
async initializeDuckLakeForEnvironment(
|
|
201
|
-
environmentId: string,
|
|
202
|
-
environmentName: string,
|
|
203
|
-
config: DuckLakeManifestConfig,
|
|
204
|
-
): Promise<void> {
|
|
205
|
-
if (!this.duckDbConnection) {
|
|
206
|
-
throw new Error("Storage not initialized. Call initialize() first.");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const key = configKey(config);
|
|
210
|
-
const catalogName = await this.duckLakeAttachMutex.runExclusive(
|
|
211
|
-
async () => {
|
|
212
|
-
const existing = this.attachedCatalogs.get(key);
|
|
213
|
-
if (existing) return existing;
|
|
214
|
-
// Catalog name derived from the config so multiple configs can coexist as
|
|
215
|
-
// separate ATTACHments without colliding on the name.
|
|
216
|
-
const name = catalogNameForConfig(config);
|
|
217
|
-
await this.attachDuckLakeCatalog(config, name);
|
|
218
|
-
this.attachedCatalogs.set(key, name);
|
|
219
|
-
return name;
|
|
220
|
-
},
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
const store = new DuckLakeManifestStore(
|
|
224
|
-
this.duckDbConnection,
|
|
225
|
-
catalogName,
|
|
226
|
-
environmentName,
|
|
227
|
-
);
|
|
228
|
-
await store.bootstrapSchema();
|
|
229
|
-
|
|
230
|
-
this.environmentManifestStores.set(environmentId, store);
|
|
231
|
-
logger.info("DuckLake manifest store initialized for environment", {
|
|
232
|
-
environmentId,
|
|
233
|
-
environmentName,
|
|
234
|
-
catalogName,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private async attachDuckLakeCatalog(
|
|
239
|
-
config: DuckLakeManifestConfig,
|
|
240
|
-
catalogName: string,
|
|
241
|
-
): Promise<void> {
|
|
242
|
-
const connection = this.duckDbConnection!;
|
|
243
|
-
|
|
244
|
-
await connection.run("INSTALL ducklake; LOAD ducklake;");
|
|
245
|
-
|
|
246
|
-
const isPostgres = config.catalogUrl.startsWith("postgres:");
|
|
247
|
-
if (isPostgres) {
|
|
248
|
-
await connection.run("INSTALL postgres; LOAD postgres;");
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// For PG-backed catalogs, inject connect_timeout so a wedged libpq
|
|
252
|
-
// handshake fails the caller in seconds rather than hanging the
|
|
253
|
-
// worker until the K8s liveness probe trips (the 2026-05 incident).
|
|
254
|
-
// Non-PG catalogs (e.g. SQLite, MySQL) pass through unchanged.
|
|
255
|
-
const catalogUrl = isPostgres
|
|
256
|
-
? withPgConnectTimeout(config.catalogUrl, pgConnectTimeoutSeconds())
|
|
257
|
-
: config.catalogUrl;
|
|
258
|
-
|
|
259
|
-
const escapedCatalogUrl = escapeSQL(catalogUrl);
|
|
260
|
-
const escapedDataPath = escapeSQL(config.dataPath);
|
|
261
|
-
const isCloudStorage =
|
|
262
|
-
config.dataPath.startsWith("gs://") ||
|
|
263
|
-
config.dataPath.startsWith("s3://");
|
|
264
|
-
|
|
265
|
-
// Pre-install httpfs explicitly so the ATTACH below doesn't trigger
|
|
266
|
-
// DuckDB's autoloader. The autoloader downloads extensions to
|
|
267
|
-
// `<ext>.tmp-<uuid>` and races when multiple connections within the
|
|
268
|
-
// same process hit it concurrently — losers fail with
|
|
269
|
-
// "Could not remove file ... No such file or directory" on cleanup
|
|
270
|
-
// of their .tmp file. INSTALL/LOAD here is idempotent and serialized
|
|
271
|
-
// by the caller's mutex.
|
|
272
|
-
if (isCloudStorage) {
|
|
273
|
-
await connection.run("INSTALL httpfs; LOAD httpfs;");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Preflight: read the catalog's recorded format version via the
|
|
277
|
-
// postgres extension (not DuckLake) and fail fast with a non-retryable
|
|
278
|
-
// 422 if the baked DuckLake extension can't read it. Without this,
|
|
279
|
-
// an unsupported catalog would surface as a generic DuckDB error
|
|
280
|
-
// from the ATTACH below, which retry loops misclassify as transient.
|
|
281
|
-
if (isPostgres) {
|
|
282
|
-
const catalogVersion = await readDuckLakeCatalogVersion(
|
|
283
|
-
connection,
|
|
284
|
-
catalogUrl,
|
|
285
|
-
catalogName,
|
|
286
|
-
);
|
|
287
|
-
if (catalogVersion && !isCatalogVersionSupported(catalogVersion)) {
|
|
288
|
-
const supportedMax =
|
|
289
|
-
SUPPORTED_CATALOG_VERSIONS[
|
|
290
|
-
SUPPORTED_CATALOG_VERSIONS.length - 1
|
|
291
|
-
];
|
|
292
|
-
throw new ConnectionAuthError(
|
|
293
|
-
`DuckLake catalog version ${catalogVersion} is newer than this Publisher's extension supports (max ${supportedMax}). Upgrade the Publisher image or downgrade the catalog.`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
|
|
299
|
-
const attachOpts: string[] = [
|
|
300
|
-
`DATA_PATH '${escapedDataPath}'`,
|
|
301
|
-
// The manifest table is small relational metadata (one row per build).
|
|
302
|
-
// Set a high inlining limit so writes always land transactionally in
|
|
303
|
-
// the postgres catalog rather than as parquet files in object storage,
|
|
304
|
-
// sidestepping object-storage auth issues entirely for this path.
|
|
305
|
-
"DATA_INLINING_ROW_LIMIT 100000",
|
|
306
|
-
];
|
|
307
|
-
|
|
308
|
-
if (isCloudStorage) {
|
|
309
|
-
attachOpts.push("OVERRIDE_DATA_PATH true");
|
|
310
|
-
}
|
|
311
|
-
attachCmd += ` (${attachOpts.join(", ")});`;
|
|
312
|
-
|
|
313
|
-
logger.info(
|
|
314
|
-
`Attaching DuckLake manifest catalog: ${redactPgSecrets(attachCmd)}`,
|
|
315
|
-
);
|
|
316
|
-
try {
|
|
317
|
-
await connection.run(attachCmd);
|
|
318
|
-
} catch (error) {
|
|
319
|
-
const outcome = handlePgAttachError(
|
|
320
|
-
error,
|
|
321
|
-
`DuckLake catalog credentials rejected for ${catalogName}`,
|
|
322
|
-
);
|
|
323
|
-
if (outcome.action === "swallow") {
|
|
324
|
-
logger.info(
|
|
325
|
-
`DuckLake catalog ${catalogName} is already attached, skipping`,
|
|
326
|
-
);
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
if (outcome.error instanceof ConnectionAuthError) {
|
|
330
|
-
logger.warn("DuckLake catalog credentials rejected", {
|
|
331
|
-
catalogName,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
throw outcome.error;
|
|
335
|
-
}
|
|
336
76
|
}
|
|
337
77
|
|
|
338
78
|
getRepository(): ResourceRepository {
|
|
@@ -342,34 +82,11 @@ export class StorageManager {
|
|
|
342
82
|
return this.repository;
|
|
343
83
|
}
|
|
344
84
|
|
|
345
|
-
/**
|
|
346
|
-
* Returns the manifest store for an environment. If the environment has a
|
|
347
|
-
* DuckLake store configured, returns that; otherwise returns the
|
|
348
|
-
* default DuckDB-backed store.
|
|
349
|
-
*/
|
|
350
|
-
getManifestStore(environmentId?: string): ManifestStore {
|
|
351
|
-
if (environmentId) {
|
|
352
|
-
const environmentStore =
|
|
353
|
-
this.environmentManifestStores.get(environmentId);
|
|
354
|
-
if (environmentStore) {
|
|
355
|
-
return environmentStore;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (!this.defaultManifestStore) {
|
|
359
|
-
throw new Error("Storage not initialized. Call initialize() first.");
|
|
360
|
-
}
|
|
361
|
-
return this.defaultManifestStore;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
85
|
async close(): Promise<void> {
|
|
365
86
|
if (this.connection) {
|
|
366
87
|
await this.connection.close();
|
|
367
88
|
this.connection = null;
|
|
368
|
-
this.duckDbConnection = null;
|
|
369
89
|
this.repository = null;
|
|
370
|
-
this.defaultManifestStore = null;
|
|
371
|
-
this.environmentManifestStores.clear();
|
|
372
|
-
this.attachedCatalogs.clear();
|
|
373
90
|
}
|
|
374
91
|
}
|
|
375
92
|
|
|
@@ -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
|
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Connection,
|
|
3
3
|
Environment,
|
|
4
|
-
ManifestEntry,
|
|
5
4
|
Materialization,
|
|
6
5
|
MaterializationStatus,
|
|
6
|
+
MaterializationUpdate,
|
|
7
7
|
Package,
|
|
8
8
|
ResourceRepository,
|
|
9
9
|
} from "../DatabaseInterface";
|
|
10
10
|
import { ConnectionRepository } from "./ConnectionRepository";
|
|
11
11
|
import { DuckDBConnection } from "./DuckDBConnection";
|
|
12
12
|
import { EnvironmentRepository } from "./EnvironmentRepository";
|
|
13
|
-
import { ManifestRepository } from "./ManifestRepository";
|
|
14
13
|
import { MaterializationRepository } from "./MaterializationRepository";
|
|
15
14
|
import { PackageRepository } from "./PackageRepository";
|
|
16
15
|
|
|
@@ -19,14 +18,12 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
19
18
|
private packageRepo: PackageRepository;
|
|
20
19
|
private connectionRepo: ConnectionRepository;
|
|
21
20
|
private materializationRepo: MaterializationRepository;
|
|
22
|
-
private manifestRepo: ManifestRepository;
|
|
23
21
|
|
|
24
22
|
constructor(public db: DuckDBConnection) {
|
|
25
23
|
this.environmentRepo = new EnvironmentRepository(db);
|
|
26
24
|
this.packageRepo = new PackageRepository(db);
|
|
27
25
|
this.connectionRepo = new ConnectionRepository(db);
|
|
28
26
|
this.materializationRepo = new MaterializationRepository(db);
|
|
29
|
-
this.manifestRepo = new ManifestRepository(db);
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
// ==================== ENVIRONMENTS ====================
|
|
@@ -57,7 +54,6 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
async deleteEnvironment(id: string): Promise<void> {
|
|
60
|
-
await this.manifestRepo.deleteEntriesByEnvironmentId(id);
|
|
61
57
|
await this.materializationRepo.deleteByEnvironmentId(id);
|
|
62
58
|
await this.connectionRepo.deleteConnectionsByEnvironmentId(id);
|
|
63
59
|
await this.packageRepo.deletePackagesByEnvironmentId(id);
|
|
@@ -97,10 +93,6 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
97
93
|
async deletePackage(id: string): Promise<void> {
|
|
98
94
|
const pkg = await this.packageRepo.getPackageById(id);
|
|
99
95
|
if (pkg) {
|
|
100
|
-
await this.manifestRepo.deleteEntriesByPackage(
|
|
101
|
-
pkg.environmentId,
|
|
102
|
-
pkg.name,
|
|
103
|
-
);
|
|
104
96
|
await this.materializationRepo.deleteByPackage(
|
|
105
97
|
pkg.environmentId,
|
|
106
98
|
pkg.name,
|
|
@@ -188,13 +180,7 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
188
180
|
|
|
189
181
|
async updateMaterialization(
|
|
190
182
|
id: string,
|
|
191
|
-
updates:
|
|
192
|
-
status?: MaterializationStatus;
|
|
193
|
-
startedAt?: Date;
|
|
194
|
-
completedAt?: Date;
|
|
195
|
-
error?: string | null;
|
|
196
|
-
metadata?: Record<string, unknown> | null;
|
|
197
|
-
},
|
|
183
|
+
updates: MaterializationUpdate,
|
|
198
184
|
): Promise<Materialization> {
|
|
199
185
|
return this.materializationRepo.update(id, updates);
|
|
200
186
|
}
|
|
@@ -202,23 +188,4 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
202
188
|
async deleteMaterialization(id: string): Promise<void> {
|
|
203
189
|
return this.materializationRepo.deleteById(id);
|
|
204
190
|
}
|
|
205
|
-
|
|
206
|
-
// ==================== BUILD MANIFESTS ====================
|
|
207
|
-
|
|
208
|
-
async listManifestEntries(
|
|
209
|
-
environmentId: string,
|
|
210
|
-
packageName: string,
|
|
211
|
-
): Promise<ManifestEntry[]> {
|
|
212
|
-
return this.manifestRepo.listEntries(environmentId, packageName);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async upsertManifestEntry(
|
|
216
|
-
entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
|
|
217
|
-
): Promise<ManifestEntry> {
|
|
218
|
-
return this.manifestRepo.upsertEntry(entry);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async deleteManifestEntry(id: string): Promise<void> {
|
|
222
|
-
return this.manifestRepo.deleteEntry(id);
|
|
223
|
-
}
|
|
224
191
|
}
|