@malloy-publisher/server 0.0.205 → 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/dist/app/api-doc.yaml +363 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-ivu4vdpj.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-BZEuwF9P.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-Dpu3bfPg.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-DHHAcY5o.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-ChR1fKR2.js} +127 -127
- package/dist/app/assets/{index.umd-D2WH3D-f.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/server.mjs +1151 -1264
- package/package.json +1 -1
- 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/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -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/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/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +212 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- 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 +400 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +254 -14
- 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/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- 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,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
|
}
|
|
@@ -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
|