@malloy-publisher/server 0.0.205 → 0.0.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/app/api-doc.yaml +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1272 -1299
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +121 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +270 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. 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, Postgres, etc.) and per-environment
120
- * manifest stores. Environments without `materializationStorage` config use
121
- * the default DuckDB manifest store. Environments with the config get a
122
- * DuckLake-backed store attached lazily on first access.
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 dbPath = this.config.duckdb?.path;
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 { Materialization, MaterializationStatus } from "../DatabaseInterface";
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
- "SUCCESS",
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
- "SELECT * FROM materializations WHERE environment_id = ? AND package_name = ? AND status IN ('PENDING', 'RUNNING')",
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
- let metadata: Record<string, unknown> | null = null;
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