@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.
Files changed (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -1,10 +1,12 @@
1
1
  import type {
2
- BuildGraph,
2
+ AtomicField,
3
+ BuildGraph as MalloyBuildGraph,
3
4
  MalloyConfig,
4
5
  Connection as MalloyConnection,
5
6
  PersistSource,
6
7
  } from "@malloydata/malloy";
7
8
  import { Manifest } from "@malloydata/malloy";
9
+ import { components } from "../api";
8
10
  import {
9
11
  BadRequestError,
10
12
  InvalidStateTransitionError,
@@ -12,48 +14,98 @@ import {
12
14
  MaterializationNotFoundError,
13
15
  } from "../errors";
14
16
  import { logger } from "../logger";
15
- import type { ManifestEntry } from "../storage/DatabaseInterface";
16
17
  import {
18
+ MaterializationRound,
19
+ recordMaterializationRound,
20
+ } from "../materialization_metrics";
21
+ import {
22
+ BuildInstruction,
23
+ BuildManifest,
24
+ BuildManifestResult,
25
+ BuildPlan,
17
26
  Materialization,
18
27
  MaterializationStatus,
28
+ MaterializationUpdate,
29
+ ManifestEntry,
19
30
  ResourceRepository,
20
31
  } from "../storage/DatabaseInterface";
21
32
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
22
33
  import { EnvironmentStore } from "./environment_store";
23
- import { ManifestService } from "./manifest_service";
24
- import {
25
- dropManifestEntries,
26
- GcResult,
27
- liveTableKey,
28
- } from "./materialized_table_gc";
29
34
  import { Model } from "./model";
30
35
  import { splitTablePath } from "./quoting";
31
36
  import { resolveEnvironmentId } from "./resolve_environment";
32
37
 
38
+ type WireBuildGraph = components["schemas"]["BuildGraph"];
39
+ type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
40
+ type WireColumn = components["schemas"]["Column"];
41
+
33
42
  /**
34
- * Length of the BuildID prefix used when synthesizing staging table names.
35
- * BuildID is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of entropy
36
- * plenty of uniqueness per source, and keeps the final identifier well
37
- * inside every dialect's limit (Postgres is the tightest at 63).
43
+ * Length of the buildId prefix used when synthesizing staging table names.
44
+ * buildId is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of
45
+ * entropy, well inside every dialect's identifier limit (Postgres is the
46
+ * tightest at 63).
38
47
  */
39
48
  const STAGING_BUILD_ID_LEN = 12;
40
49
 
41
- /**
42
- * Return the staging suffix `_<truncatedBuildId>` appended to a table name
43
- * while it is being built. The suffix is fully derivable from a manifest
44
- * entry's `buildId`, which is how GC finds and drops orphaned staging tables.
45
- */
50
+ /** Staging suffix appended to a table name while it is being built. */
46
51
  export function stagingSuffix(buildId: string): string {
47
52
  return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
48
53
  }
49
54
 
55
+ /** Output columns of a persist source, degrading to [] if unavailable. */
56
+ function deriveColumns(persistSource: PersistSource): WireColumn[] {
57
+ try {
58
+ return persistSource._explore.intrinsicFields
59
+ .filter((f) => f.isAtomicField())
60
+ .map((f) => ({
61
+ name: f.name,
62
+ type: String((f as AtomicField).type),
63
+ }));
64
+ } catch (err) {
65
+ logger.warn("Failed to derive columns for persist source", {
66
+ sourceID: persistSource.sourceID,
67
+ error: err instanceof Error ? err.message : String(err),
68
+ });
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
74
+ function flattenDependsOn(node: {
75
+ dependsOn: { sourceID: string }[];
76
+ }): string[] {
77
+ return node.dependsOn.map((d) => d.sourceID);
78
+ }
79
+
50
80
  /**
51
- * Resolve a Map<name, Connection> for just the names a materialization
52
- * step is about to touch. The package's MalloyConfig caches each lookup,
53
- * so subsequent calls with overlapping names are cheap. A failed lookup
54
- * is logged and the name is omitted from the result — downstream code
55
- * (`forceDeleteRowOnMissingConnection` in teardown, or the explicit
56
- * "connection X not found" check in build) handles a missing entry.
81
+ * Physical table name the publisher self-assigns in auto-run mode: the
82
+ * `#@ persist name=<table>` value if present, else the Malloy source name.
83
+ * The author owns quoting the `name=` value for the dialect.
84
+ */
85
+ function selfAssignTableName(persistSource: PersistSource): string {
86
+ try {
87
+ return (
88
+ persistSource.annotations.parseAsTag("@").tag.text("name") ||
89
+ persistSource.name
90
+ );
91
+ } catch {
92
+ return persistSource.name;
93
+ }
94
+ }
95
+
96
+ /** Classify a thrown round error as cancelled (cooperative abort) or failed. */
97
+ function outcomeFor(
98
+ _err: unknown,
99
+ signal: AbortSignal | undefined,
100
+ ): "failed" | "cancelled" {
101
+ return signal?.aborted ? "cancelled" : "failed";
102
+ }
103
+
104
+ /**
105
+ * Resolve a Map<name, Connection> for the names a step is about to touch.
106
+ * The package's MalloyConfig caches each lookup, so repeated calls are cheap.
107
+ * A failed lookup is logged and omitted; downstream code reports the missing
108
+ * connection explicitly.
57
109
  */
58
110
  async function resolvePackageConnections(
59
111
  pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
@@ -76,87 +128,41 @@ async function resolvePackageConnections(
76
128
  }
77
129
 
78
130
  /**
79
- * Build a stable key for a `(connectionName, tableName)` pair.
80
- * Used to check whether a persist target was created by a previous build.
81
- */
82
- export function manifestTableKey(
83
- connectionName: string,
84
- tableName: string,
85
- ): string {
86
- return `${connectionName}::${tableName}`;
87
- }
88
-
89
- /**
90
- * Probe whether a table physically exists on the given connection by
91
- * running a zero-row SELECT. Returns `true` if the table resolves,
92
- * `false` if the query fails (assumed "table not found").
93
- */
94
- /**
95
- * `tableName` is interpolated verbatim into the probe SQL — the caller
96
- * supplies it already quoted for the dialect (the `#@ persist name=…`
97
- * contract), matching how Malloy substitutes the name on the read side.
98
- */
99
- export async function tablePhysicallyExists(
100
- connection: MalloyConnection,
101
- tableName: string,
102
- ): Promise<boolean> {
103
- try {
104
- await connection.runSQL(`SELECT 1 FROM ${tableName} WHERE 1=0`);
105
- return true;
106
- } catch {
107
- return false;
108
- }
109
- }
110
-
111
- /**
112
- * Allowed execution status transitions. SUCCESS, FAILED, and CANCELLED are
113
- * terminal — once an execution reaches one of these states it is immutable.
131
+ * Allowed status transitions for the two-round protocol. MANIFEST_FILE_READY,
132
+ * FAILED, and CANCELLED are terminal.
114
133
  */
115
134
  const VALID_TRANSITIONS: Record<
116
135
  MaterializationStatus,
117
136
  MaterializationStatus[]
118
137
  > = {
119
- PENDING: ["RUNNING", "CANCELLED"],
120
- RUNNING: ["SUCCESS", "FAILED", "CANCELLED"],
121
- SUCCESS: [],
138
+ PENDING: ["BUILD_PLAN_READY", "FAILED", "CANCELLED"],
139
+ BUILD_PLAN_READY: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
140
+ MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
141
+ MANIFEST_FILE_READY: [],
122
142
  FAILED: [],
123
143
  CANCELLED: [],
124
144
  };
125
145
 
126
146
  /**
127
- * Orchestrates package-level materialization builds: triggering builds,
128
- * cancellation, and the actual Malloy build that materializes persist
129
- * sources into database tables.
130
- *
131
- * A build targets an entire package — all models are compiled and all
132
- * persist sources across all models are processed in dependency order.
133
- * The manifest is optionally activated after a successful build so
134
- * subsequent queries resolve persist references to materialized tables.
147
+ * Orchestrates the two-round materialization protocol.
135
148
  *
136
- * Enforces at-most-one concurrent build per (environment, package) via a
137
- * DB-level unique index on `materializations.active_key` (see
138
- * `MaterializationRepository`), and supports cooperative cancellation
139
- * through `AbortController`.
149
+ * Round 1 (on create): compile the package, compute a build plan (per-source
150
+ * buildId, columns, lineage inputs, connection capability) and pause at
151
+ * BUILD_PLAN_READY without writing any tables. Round 2 (action=build): build
152
+ * only the control-plane-instructed sources into their assigned physical
153
+ * names and assemble the manifest, which is returned inline for the control
154
+ * plane to persist.
140
155
  *
141
- * **Multi-worker caveat:** the `materializations` table lives in each
142
- * worker's *local* DuckDB, so the active-materialization lock is only
143
- * enforced within a single Publisher process. In orchestrated deployments
144
- * (shared DuckLake manifest catalog), builds must be externally
145
- * single-writer until a shared lease is added — see the scope note on
146
- * `DuckLakeManifestStore`.
156
+ * At most one active materialization per (environment, package) is enforced
157
+ * by the DB-level unique index on `materializations.active_key` (see
158
+ * {@link MaterializationRepository}). Cancellation is cooperative via
159
+ * AbortController.
147
160
  */
148
161
  export class MaterializationService {
149
- /**
150
- * Tracks in-flight executions so they can be cancelled. This map only
151
- * lives in-process memory — entries are lost on server restart, which is
152
- * why `stopMaterialization` has an orphaned-execution fallback path.
153
- */
162
+ /** In-flight runs, so they can be cancelled. In-process only. */
154
163
  private runningAbortControllers = new Map<string, AbortController>();
155
164
 
156
- constructor(
157
- private environmentStore: EnvironmentStore,
158
- private manifestService: ManifestService,
159
- ) {}
165
+ constructor(private environmentStore: EnvironmentStore) {}
160
166
 
161
167
  private get repository(): ResourceRepository {
162
168
  return this.environmentStore.storageManager.getRepository();
@@ -168,39 +174,32 @@ export class MaterializationService {
168
174
  current: MaterializationStatus,
169
175
  next: MaterializationStatus,
170
176
  ): void {
171
- const allowed = VALID_TRANSITIONS[current];
172
- if (!allowed.includes(next)) {
177
+ if (!VALID_TRANSITIONS[current].includes(next)) {
173
178
  throw new InvalidStateTransitionError(
174
179
  `Cannot transition from ${current} to ${next}`,
175
180
  );
176
181
  }
177
182
  }
178
183
 
179
- private async transitionExecution(
180
- executionId: string,
181
- newStatus: MaterializationStatus,
182
- extra?: {
183
- startedAt?: Date;
184
- completedAt?: Date;
185
- error?: string | null;
186
- metadata?: Record<string, unknown> | null;
187
- },
184
+ private async transition(
185
+ id: string,
186
+ next: MaterializationStatus,
187
+ extra?: Omit<MaterializationUpdate, "status">,
188
188
  ): Promise<Materialization> {
189
- const execution =
190
- await this.repository.getMaterializationById(executionId);
191
- if (!execution) {
189
+ const current = await this.repository.getMaterializationById(id);
190
+ if (!current) {
192
191
  throw new MaterializationNotFoundError(
193
- `Execution ${executionId} not found`,
192
+ `Materialization ${id} not found`,
194
193
  );
195
194
  }
196
- this.validateTransition(execution.status, newStatus);
197
- return this.repository.updateMaterialization(executionId, {
198
- status: newStatus,
195
+ this.validateTransition(current.status, next);
196
+ return this.repository.updateMaterialization(id, {
197
+ status: next,
199
198
  ...extra,
200
199
  });
201
200
  }
202
201
 
203
- // ==================== BUILD QUERIES ====================
202
+ // ==================== QUERIES ====================
204
203
 
205
204
  async listMaterializations(
206
205
  environmentName: string,
@@ -218,45 +217,45 @@ export class MaterializationService {
218
217
  async getMaterialization(
219
218
  environmentName: string,
220
219
  packageName: string,
221
- buildId: string,
220
+ id: string,
222
221
  ): Promise<Materialization> {
223
222
  const environmentId = await this.resolveEnvironmentId(environmentName);
224
- const execution = await this.repository.getMaterializationById(buildId);
223
+ const m = await this.repository.getMaterializationById(id);
225
224
  if (
226
- !execution ||
227
- execution.environmentId !== environmentId ||
228
- execution.packageName !== packageName
225
+ !m ||
226
+ m.environmentId !== environmentId ||
227
+ m.packageName !== packageName
229
228
  ) {
230
229
  throw new MaterializationNotFoundError(
231
- `Materialization ${buildId} not found for package ${packageName}`,
230
+ `Materialization ${id} not found for package ${packageName}`,
232
231
  );
233
232
  }
234
- return execution;
233
+ return m;
235
234
  }
236
235
 
237
- // ==================== BUILD LIFECYCLE ====================
236
+ // ==================== ROUND 1: CREATE + PLAN ====================
238
237
 
239
238
  /**
240
- * Creates a new build in PENDING state. Build options are stored in
241
- * metadata so `startMaterialization` can read them back.
239
+ * Create a materialization and start Round 1 (compile + plan) in the
240
+ * background. Returns the PENDING record immediately.
242
241
  */
243
242
  async createMaterialization(
244
243
  environmentName: string,
245
244
  packageName: string,
246
- options: { forceRefresh?: boolean; autoLoadManifest?: boolean } = {},
245
+ options: {
246
+ forceRefresh?: boolean;
247
+ sourceNames?: string[];
248
+ pauseBetweenPhases?: boolean;
249
+ } = {},
247
250
  ): Promise<Materialization> {
248
251
  const environmentId = await this.resolveEnvironmentId(environmentName);
249
252
 
250
- // Verify the package exists.
251
253
  const environment = await this.environmentStore.getEnvironment(
252
254
  environmentName,
253
255
  false,
254
256
  );
255
257
  await environment.getPackage(packageName, false);
256
258
 
257
- // A non-atomic probe for a helpful error message. The DB-level unique
258
- // index on active_key is the actual race-free guard — see the catch
259
- // block below.
260
259
  const active = await this.repository.getActiveMaterialization(
261
260
  environmentId,
262
261
  packageName,
@@ -267,13 +266,17 @@ export class MaterializationService {
267
266
  );
268
267
  }
269
268
 
269
+ const pauseBetweenPhases = options.pauseBetweenPhases ?? false;
270
+ const forceRefresh = options.forceRefresh ?? false;
270
271
  const metadata = {
271
- forceRefresh: options.forceRefresh ?? false,
272
- autoLoadManifest: options.autoLoadManifest ?? false,
272
+ forceRefresh,
273
+ sourceNames: options.sourceNames ?? null,
274
+ pauseBetweenPhases,
273
275
  };
274
276
 
277
+ let created: Materialization;
275
278
  try {
276
- return await this.repository.createMaterialization(
279
+ created = await this.repository.createMaterialization(
277
280
  environmentId,
278
281
  packageName,
279
282
  "PENDING",
@@ -281,8 +284,6 @@ export class MaterializationService {
281
284
  );
282
285
  } catch (err) {
283
286
  if (err instanceof DuplicateActiveMaterializationError) {
284
- // Lost the race with a concurrent create. Re-read to report the
285
- // winner's id for parity with the non-racy error above.
286
287
  const winner = await this.repository.getActiveMaterialization(
287
288
  environmentId,
288
289
  packageName,
@@ -295,343 +296,465 @@ export class MaterializationService {
295
296
  }
296
297
  throw err;
297
298
  }
299
+
300
+ // Default (auto-run): compile, self-assign names, build, and auto-load in
301
+ // one pass. Opt-in two-round: pause at BUILD_PLAN_READY for the control
302
+ // plane to drive Round 2.
303
+ this.runInBackground(created.id, (signal) =>
304
+ pauseBetweenPhases
305
+ ? this.runRound1(
306
+ created.id,
307
+ environmentName,
308
+ packageName,
309
+ options.sourceNames,
310
+ signal,
311
+ )
312
+ : this.runAuto(
313
+ created.id,
314
+ environmentName,
315
+ packageName,
316
+ options.sourceNames,
317
+ forceRefresh,
318
+ signal,
319
+ ),
320
+ );
321
+
322
+ return created;
298
323
  }
299
324
 
300
- /**
301
- * Transitions a PENDING build to RUNNING and starts execution in the
302
- * background. Returns the RUNNING execution immediately.
303
- */
304
- async startMaterialization(
325
+ private async runRound1(
326
+ id: string,
305
327
  environmentName: string,
306
328
  packageName: string,
307
- buildId: string,
308
- ): Promise<Materialization> {
309
- const environmentId = await this.resolveEnvironmentId(environmentName);
310
- const execution = await this.getMaterialization(
311
- environmentName,
329
+ sourceNames: string[] | undefined,
330
+ signal?: AbortSignal,
331
+ ): Promise<void> {
332
+ logger.info("Materialization Round 1: compile + plan", {
333
+ materializationId: id,
312
334
  packageName,
313
- buildId,
314
- );
315
-
316
- if (execution.status !== "PENDING") {
317
- throw new InvalidStateTransitionError(
318
- `Materialization ${buildId} is ${execution.status}, expected PENDING`,
319
- );
320
- }
335
+ });
336
+ const startedAt = Date.now();
321
337
 
322
- // Check for a *different* active materialization on this package.
323
- const active = await this.repository.getActiveMaterialization(
324
- environmentId,
325
- packageName,
326
- );
327
- if (active && active.id !== execution.id) {
328
- throw new MaterializationConflictError(
329
- `Package ${packageName} already has an active materialization (${active.id})`,
338
+ try {
339
+ const environment = await this.environmentStore.getEnvironment(
340
+ environmentName,
341
+ false,
330
342
  );
331
- }
343
+ const pkg = await environment.getPackage(packageName, false);
332
344
 
333
- const running = await this.transitionExecution(execution.id, "RUNNING", {
334
- startedAt: new Date(),
335
- });
345
+ const { graphs, sources, connectionDigests } =
346
+ await environment.withPackageLock(packageName, () =>
347
+ this.compilePackageBuildPlan(pkg, signal),
348
+ );
336
349
 
337
- const metadata = (execution.metadata ?? {}) as Record<string, unknown>;
350
+ const buildPlan = this.deriveBuildPlan(
351
+ graphs,
352
+ sources,
353
+ connectionDigests,
354
+ sourceNames,
355
+ );
338
356
 
339
- // Fire-and-forget: run the build in the background.
340
- this.runMaterialization(
341
- execution.id,
342
- environmentName,
343
- environmentId,
344
- packageName,
345
- metadata,
346
- ).catch((err) => {
347
- logger.error("Unhandled error in background build", {
348
- executionId: execution.id,
349
- error: err instanceof Error ? err.message : String(err),
357
+ await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
358
+ this.recordRound("round1", "success", startedAt);
359
+ logger.info("Materialization Round 1 complete", {
360
+ materializationId: id,
361
+ packageName,
362
+ sourceCount: Object.keys(buildPlan.sources).length,
363
+ durationMs: Date.now() - startedAt,
350
364
  });
351
- });
352
-
353
- return running;
365
+ } catch (err) {
366
+ this.recordRound("round1", outcomeFor(err, signal), startedAt);
367
+ throw err;
368
+ }
354
369
  }
355
370
 
356
- private async runMaterialization(
357
- executionId: string,
371
+ // ==================== AUTO-RUN (DEFAULT) ====================
372
+
373
+ /**
374
+ * Auto-run (default, pauseBetweenPhases=false). Compile + plan, then
375
+ * self-assign physical table names (the `#@ persist name=` value, else the
376
+ * source name) with realization=COPY, skip sources whose buildId is
377
+ * unchanged since the most recent successful manifest (unless forceRefresh),
378
+ * build the rest, assemble the manifest, and auto-load it into the package
379
+ * models so subsequent queries resolve to the materialized tables. No pause,
380
+ * no second round.
381
+ */
382
+ private async runAuto(
383
+ id: string,
358
384
  environmentName: string,
359
- environmentId: string,
360
385
  packageName: string,
361
- metadata: Record<string, unknown>,
386
+ sourceNames: string[] | undefined,
387
+ forceRefresh: boolean,
388
+ signal: AbortSignal,
362
389
  ): Promise<void> {
363
- const abortController = new AbortController();
364
- this.runningAbortControllers.set(executionId, abortController);
390
+ logger.info("Materialization auto-run: compile + build + load", {
391
+ materializationId: id,
392
+ packageName,
393
+ });
394
+ const startedAt = Date.now();
365
395
 
366
396
  try {
367
- const buildMetadata = await this.executeBuild(
397
+ const environmentId = await this.resolveEnvironmentId(environmentName);
398
+ const environment = await this.environmentStore.getEnvironment(
368
399
  environmentName,
369
- environmentId,
370
- packageName,
371
- !!metadata.forceRefresh,
372
- abortController.signal,
400
+ false,
373
401
  );
402
+ const pkg = await environment.getPackage(packageName, false);
374
403
 
375
- if (metadata.autoLoadManifest) {
376
- const updatedManifest = await this.manifestService.getManifest(
377
- environmentId,
378
- packageName,
379
- );
380
- const environment = await this.environmentStore.getEnvironment(
381
- environmentName,
382
- false,
383
- );
384
- // Ensure the package is loaded, then reload models under the
385
- // per-package mutex so the disk reads are serialized against
386
- // installPackage / deletePackage.
387
- await environment.getPackage(packageName, false);
388
- await environment.reloadAllModelsForPackage(
389
- packageName,
390
- updatedManifest.entries,
391
- );
392
- }
404
+ const compiled = await environment.withPackageLock(packageName, () =>
405
+ this.compilePackageBuildPlan(pkg, signal),
406
+ );
393
407
 
394
- await this.transitionExecution(executionId, "SUCCESS", {
408
+ const buildPlan = this.deriveBuildPlan(
409
+ compiled.graphs,
410
+ compiled.sources,
411
+ compiled.connectionDigests,
412
+ sourceNames,
413
+ );
414
+ await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
415
+
416
+ // Skip-if-unchanged: reuse tables from the most recent successful
417
+ // manifest for sources whose buildId is unchanged, unless forceRefresh.
418
+ const priorEntries = forceRefresh
419
+ ? {}
420
+ : await this.getMostRecentManifestEntries(
421
+ environmentId,
422
+ packageName,
423
+ id,
424
+ );
425
+ const { instructions, carried } = this.deriveSelfInstructions(
426
+ compiled,
427
+ sourceNames,
428
+ priorEntries,
429
+ );
430
+
431
+ const entries = await this.executeInstructedBuild(
432
+ compiled,
433
+ instructions,
434
+ carried,
435
+ signal,
436
+ );
437
+
438
+ await this.transition(id, "MANIFEST_ROWS_READY");
439
+
440
+ const manifestResult: BuildManifestResult = {
441
+ builtAt: new Date().toISOString(),
442
+ entries,
443
+ strict: false,
444
+ };
445
+ const durationMs = Date.now() - startedAt;
446
+ await this.transition(id, "MANIFEST_FILE_READY", {
395
447
  completedAt: new Date(),
396
- metadata: { ...metadata, ...buildMetadata },
448
+ manifest: manifestResult,
449
+ metadata: {
450
+ forceRefresh,
451
+ sourceNames: sourceNames ?? null,
452
+ pauseBetweenPhases: false,
453
+ sourcesBuilt: instructions.length,
454
+ sourcesReused: Object.keys(carried).length,
455
+ durationMs,
456
+ },
457
+ });
458
+
459
+ await this.autoLoadManifest(environment, packageName, entries);
460
+
461
+ this.recordRound("auto", "success", startedAt);
462
+ logger.info("Materialization auto-run complete", {
463
+ materializationId: id,
464
+ packageName,
465
+ sourcesBuilt: instructions.length,
466
+ sourcesReused: Object.keys(carried).length,
467
+ durationMs,
397
468
  });
398
469
  } catch (err) {
399
- const errorMessage = err instanceof Error ? err.message : String(err);
470
+ this.recordRound("auto", outcomeFor(err, signal), startedAt);
471
+ throw err;
472
+ }
473
+ }
400
474
 
401
- try {
402
- if (abortController.signal.aborted) {
403
- await this.transitionExecution(executionId, "CANCELLED", {
404
- completedAt: new Date(),
405
- error: "Build cancelled",
406
- });
407
- } else {
408
- await this.transitionExecution(executionId, "FAILED", {
409
- completedAt: new Date(),
410
- error: errorMessage,
475
+ /**
476
+ * Derive the publisher's own build instructions for auto-run. Each persist
477
+ * source (respecting the optional sourceNames filter) gets a self-assigned
478
+ * physical table name and COPY realization, unless its buildId is unchanged
479
+ * since `priorEntries` — those are carried forward (reused) instead of
480
+ * rebuilt.
481
+ */
482
+ private deriveSelfInstructions(
483
+ compiled: {
484
+ graphs: MalloyBuildGraph[];
485
+ sources: Record<string, PersistSource>;
486
+ connectionDigests: Record<string, string>;
487
+ },
488
+ sourceNames: string[] | undefined,
489
+ priorEntries: Record<string, ManifestEntry>,
490
+ ): {
491
+ instructions: BuildInstruction[];
492
+ carried: Record<string, ManifestEntry>;
493
+ } {
494
+ const include = sourceNames ? new Set(sourceNames) : null;
495
+ const instructions: BuildInstruction[] = [];
496
+ const carried: Record<string, ManifestEntry> = {};
497
+ const seen = new Set<string>();
498
+
499
+ for (const graph of compiled.graphs) {
500
+ for (const level of graph.nodes) {
501
+ for (const node of level) {
502
+ const persistSource = compiled.sources[node.sourceID];
503
+ if (!persistSource) continue;
504
+ if (include && !include.has(persistSource.name)) continue;
505
+
506
+ const buildId = persistSource.makeBuildId(
507
+ compiled.connectionDigests[persistSource.connectionName],
508
+ persistSource.getSQL(),
509
+ );
510
+ if (seen.has(buildId)) continue;
511
+ seen.add(buildId);
512
+
513
+ const prior = priorEntries[buildId];
514
+ if (prior && prior.physicalTableName) {
515
+ carried[buildId] = prior;
516
+ continue;
517
+ }
518
+
519
+ instructions.push({
520
+ buildId,
521
+ materializedTableId: `local-${buildId.substring(
522
+ 0,
523
+ STAGING_BUILD_ID_LEN,
524
+ )}`,
525
+ physicalTableName: selfAssignTableName(persistSource),
526
+ realization: "COPY",
411
527
  });
412
528
  }
413
- } catch (transitionErr) {
414
- logger.error("Failed to transition execution after build error", {
415
- executionId,
416
- originalError: errorMessage,
417
- transitionError:
418
- transitionErr instanceof Error
419
- ? transitionErr.message
420
- : String(transitionErr),
421
- });
422
529
  }
423
- } finally {
424
- this.runningAbortControllers.delete(executionId);
425
530
  }
531
+
532
+ return { instructions, carried };
426
533
  }
427
534
 
428
535
  /**
429
- * Cancels a running build. Takes a specific buildId.
536
+ * Entries of the most recent successful (MANIFEST_FILE_READY) materialization
537
+ * for this package, used for skip-if-unchanged. Excludes the in-flight run.
430
538
  */
431
- async stopMaterialization(
432
- environmentName: string,
539
+ private async getMostRecentManifestEntries(
540
+ environmentId: string,
433
541
  packageName: string,
434
- buildId: string,
435
- ): Promise<Materialization> {
436
- const execution = await this.getMaterialization(
437
- environmentName,
542
+ excludeId: string,
543
+ ): Promise<Record<string, ManifestEntry>> {
544
+ const list = await this.repository.listMaterializations(
545
+ environmentId,
438
546
  packageName,
439
- buildId,
440
547
  );
441
-
442
- if (execution.status !== "RUNNING" && execution.status !== "PENDING") {
443
- throw new InvalidStateTransitionError(
444
- `Materialization ${buildId} is ${execution.status}, cannot stop`,
445
- );
446
- }
447
-
448
- if (execution.status === "PENDING") {
449
- return this.transitionExecution(execution.id, "CANCELLED", {
450
- completedAt: new Date(),
451
- error: "Build cancelled before starting",
452
- });
453
- }
454
-
455
- const abortController = this.runningAbortControllers.get(execution.id);
456
- if (abortController) {
457
- abortController.abort();
458
- return execution;
459
- } else {
460
- return this.transitionExecution(execution.id, "CANCELLED", {
461
- completedAt: new Date(),
462
- error: "Force cancelled: execution was orphaned",
463
- });
548
+ for (const m of list) {
549
+ if (m.id === excludeId) continue;
550
+ if (m.status === "MANIFEST_FILE_READY" && m.manifest?.entries) {
551
+ return m.manifest.entries;
552
+ }
464
553
  }
554
+ return {};
465
555
  }
466
556
 
467
557
  /**
468
- * Deletes a materialization record. Only terminal materializations
469
- * (SUCCESS, FAILED, CANCELLED) can be deleted.
558
+ * Load a freshly produced manifest into the package's models so persist
559
+ * references resolve to the materialized tables. Best-effort: a load failure
560
+ * is logged, not fatal (the run already reached MANIFEST_FILE_READY).
470
561
  */
471
- async deleteMaterialization(
472
- environmentName: string,
562
+ private async autoLoadManifest(
563
+ environment: {
564
+ reloadAllModelsForPackage(
565
+ packageName: string,
566
+ manifest: BuildManifest["entries"],
567
+ ): Promise<void>;
568
+ },
473
569
  packageName: string,
474
- materializationId: string,
570
+ entries: Record<string, ManifestEntry>,
475
571
  ): Promise<void> {
476
- const execution = await this.getMaterialization(
477
- environmentName,
478
- packageName,
479
- materializationId,
480
- );
481
-
482
- if (execution.status === "PENDING" || execution.status === "RUNNING") {
483
- throw new InvalidStateTransitionError(
484
- `Cannot delete materialization ${materializationId} while it is ${execution.status}`,
572
+ const manifestEntries: BuildManifest["entries"] = {};
573
+ for (const [buildId, entry] of Object.entries(entries)) {
574
+ if (entry.physicalTableName) {
575
+ manifestEntries[buildId] = { tableName: entry.physicalTableName };
576
+ }
577
+ }
578
+ try {
579
+ await environment.reloadAllModelsForPackage(
580
+ packageName,
581
+ manifestEntries,
485
582
  );
583
+ logger.info("Auto-run: loaded manifest into package models", {
584
+ packageName,
585
+ entryCount: Object.keys(manifestEntries).length,
586
+ });
587
+ } catch (err) {
588
+ logger.warn("Auto-run: failed to load manifest into package models", {
589
+ packageName,
590
+ error: err instanceof Error ? err.message : String(err),
591
+ });
486
592
  }
487
-
488
- await this.repository.deleteMaterialization(execution.id);
489
593
  }
490
594
 
491
- // ==================== PACKAGE TEARDOWN ====================
595
+ // ==================== ROUND 2: BUILD ====================
492
596
 
493
597
  /**
494
- * Drop every materialized table and manifest row for a package.
495
- *
496
- * This is the only out-of-band teardown surface exposed by the
497
- * publisher and is intended for a single caller: the controlplane,
498
- * invoking it on the truly destructive path before the package/environment
499
- * is torn down. The publisher's `DELETE` endpoints are *not* wired into
500
- * teardown because the controlplane invokes them for non-destructive
501
- * unload too (down-replicate, drain, archive) where the entity still
502
- * exists on other replicas, and dropping shared tables there would
503
- * corrupt surviving workers.
504
- *
505
- * Reconciliation of stale rows against the package's live source code
506
- * happens inline at the end of every successful build (see
507
- * {@link executeBuild} Step 5) — that is where "active" vs. "orphan"
508
- * is authoritatively determined, using the manifest's own `touch()`
509
- * bookkeeping. This endpoint does not re-derive that information; it
510
- * drops the manifest in its entirety, which is the correct behavior
511
- * for the pre-deletion use case and avoids pulling the package's
512
- * source through the compiler just to reach a foregone conclusion.
513
- *
514
- * Refuses to run while a materialization is active for the package
515
- * (same serialization the inline GC gets by piggy-backing on the
516
- * build).
517
- *
518
- * `dryRun` returns what would be dropped without issuing any DROP or
519
- * deleting manifest rows.
598
+ * Round 2. Validates the instructions against the stored build plan,
599
+ * transitions out of BUILD_PLAN_READY, and builds the instructed sources
600
+ * in the background. Returns the accepted record (202).
520
601
  */
521
- async teardownPackage(
602
+ async buildMaterialization(
522
603
  environmentName: string,
523
604
  packageName: string,
524
- options: { dryRun?: boolean } = {},
525
- ): Promise<GcResult> {
526
- const environmentId = await this.resolveEnvironmentId(environmentName);
605
+ id: string,
606
+ instructions: BuildInstruction[],
607
+ ): Promise<Materialization> {
608
+ const m = await this.getMaterialization(environmentName, packageName, id);
527
609
 
528
- const active = await this.repository.getActiveMaterialization(
529
- environmentId,
530
- packageName,
531
- );
532
- if (active) {
533
- throw new MaterializationConflictError(
534
- `Package ${packageName} has an active materialization (${active.id}); cannot tear down`,
610
+ if (!m.pauseBetweenPhases) {
611
+ throw new InvalidStateTransitionError(
612
+ `Materialization ${id} is an auto-run materialization; action=build does not apply (set pauseBetweenPhases=true for the two-round flow)`,
613
+ );
614
+ }
615
+ if (m.status !== "BUILD_PLAN_READY") {
616
+ throw new InvalidStateTransitionError(
617
+ `Materialization ${id} is ${m.status}, expected BUILD_PLAN_READY`,
618
+ );
619
+ }
620
+ const plan = m.buildPlan;
621
+ if (!plan) {
622
+ throw new InvalidStateTransitionError(
623
+ `Materialization ${id} has no build plan`,
535
624
  );
536
625
  }
537
626
 
538
- const environment = await this.environmentStore.getEnvironment(
539
- environmentName,
540
- false,
541
- );
542
- const pkg = await environment.getPackage(packageName, false);
543
-
544
- const entries = await this.manifestService.listEntries(
545
- environmentId,
546
- packageName,
547
- );
627
+ this.validateInstructions(plan, instructions);
548
628
 
549
- const connections = await resolvePackageConnections(
550
- pkg,
551
- entries.map((e) => e.connectionName),
629
+ this.runInBackground(id, (signal) =>
630
+ this.runRound2(id, environmentName, packageName, instructions, signal),
552
631
  );
553
632
 
554
- // `forceDeleteRowOnMissingConnection`: teardown is the one place
555
- // where we'd rather lose the manifest row than leave it pointing at
556
- // a vanished connection. We also deliberately omit `liveTables`:
557
- // in teardown everything is stale, nothing is live.
558
- return dropManifestEntries(entries, {
559
- connections,
560
- manifestService: this.manifestService,
561
- environmentId,
562
- dryRun: options.dryRun,
563
- forceDeleteRowOnMissingConnection: true,
564
- });
633
+ return m;
565
634
  }
566
635
 
567
- // ==================== BUILD LOGIC ====================
636
+ private validateInstructions(
637
+ plan: BuildPlan,
638
+ instructions: BuildInstruction[],
639
+ ): void {
640
+ const plannedBuildIds = new Set<string>();
641
+ for (const source of Object.values(plan.sources)) {
642
+ plannedBuildIds.add(source.buildId);
643
+ }
568
644
 
569
- /**
570
- * Core build pipeline (5 steps):
571
- * 1. LOAD — Load existing manifest.
572
- * 2. COMPILE & PLAN — Compile all models, collect dependency graphs.
573
- * 3. BUILD — Walk graphs in dependency order, materialize each source.
574
- * 4. GC — Drop stale physical tables + prune manifest rows.
575
- *
576
- * Build success is not gated on GC — failures are surfaced in
577
- * `gcErrors` metadata so the controller/UI can show them.
578
- */
579
- private async executeBuild(
645
+ for (const instruction of instructions) {
646
+ if (!plannedBuildIds.has(instruction.buildId)) {
647
+ throw new BadRequestError(
648
+ `Instruction references unknown buildId '${instruction.buildId}'`,
649
+ );
650
+ }
651
+ // v0 is COPY-only; SNAPSHOT lands once clone semantics are defined.
652
+ if (instruction.realization === "SNAPSHOT") {
653
+ throw new BadRequestError(
654
+ "realization=SNAPSHOT is not supported in v0 (COPY only)",
655
+ );
656
+ }
657
+ }
658
+ }
659
+
660
+ private async runRound2(
661
+ id: string,
580
662
  environmentName: string,
581
- environmentId: string,
582
663
  packageName: string,
583
- forceRefresh: boolean,
664
+ instructions: BuildInstruction[],
584
665
  signal: AbortSignal,
585
- ): Promise<Record<string, unknown>> {
586
- logger.info("Starting materialization build", {
587
- environmentName,
666
+ ): Promise<void> {
667
+ logger.info("Materialization Round 2: build", {
668
+ materializationId: id,
588
669
  packageName,
670
+ sourceCount: instructions.length,
589
671
  });
672
+ const startedAt = Date.now();
590
673
 
591
- const environment = await this.environmentStore.getEnvironment(
592
- environmentName,
593
- false,
594
- );
595
- const pkg = await environment.getPackage(packageName, false);
596
-
597
- // ── STEP 1: LOAD ───────────────────────────────────────────────
598
- const manifest = new Manifest();
599
- const existingManifest = await this.manifestService.getManifest(
600
- environmentId,
601
- packageName,
602
- );
603
- manifest.loadText(JSON.stringify(existingManifest));
604
-
605
- const existingEntries = await this.manifestService.listEntries(
606
- environmentId,
607
- packageName,
608
- );
609
- const knownMaterializedTables = new Set(
610
- existingEntries.map((e: ManifestEntry) =>
611
- manifestTableKey(e.connectionName, e.tableName),
612
- ),
613
- );
674
+ try {
675
+ const environment = await this.environmentStore.getEnvironment(
676
+ environmentName,
677
+ false,
678
+ );
679
+ const pkg = await environment.getPackage(packageName, false);
614
680
 
615
- // ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
616
- // `connections` is built lazily from the connection names the plan
617
- // actually targets — no upfront ATTACH on every environment connection.
618
- // Hold the per-package mutex for the duration of the compile so the
619
- // `fs.stat` + `runtime.loadModel` calls inside `compilePackageBuildPlan`
620
- // are serialized against `installPackage` / `deletePackage`.
621
- const { graphs, sources, connectionDigests, connections } =
622
- await environment.withPackageLock(packageName, () =>
681
+ const compiled = await environment.withPackageLock(packageName, () =>
623
682
  this.compilePackageBuildPlan(pkg, signal),
624
683
  );
625
684
 
626
- if (graphs.length === 0) {
627
- logger.info("No persist sources to build");
628
- return { sourcesBuilt: 0, sourcesSkipped: 0 };
685
+ const entries = await this.executeInstructedBuild(
686
+ compiled,
687
+ instructions,
688
+ {},
689
+ signal,
690
+ );
691
+
692
+ await this.transition(id, "MANIFEST_ROWS_READY");
693
+
694
+ const manifestResult: BuildManifestResult = {
695
+ builtAt: new Date().toISOString(),
696
+ entries,
697
+ strict: false,
698
+ };
699
+ const durationMs = Date.now() - startedAt;
700
+ await this.transition(id, "MANIFEST_FILE_READY", {
701
+ completedAt: new Date(),
702
+ manifest: manifestResult,
703
+ metadata: {
704
+ sourcesBuilt: Object.keys(entries).length,
705
+ durationMs,
706
+ },
707
+ });
708
+
709
+ this.recordRound("round2", "success", startedAt);
710
+ logger.info("Materialization Round 2 complete", {
711
+ materializationId: id,
712
+ packageName,
713
+ sourcesBuilt: Object.keys(entries).length,
714
+ durationMs,
715
+ });
716
+ } catch (err) {
717
+ this.recordRound("round2", outcomeFor(err, signal), startedAt);
718
+ throw err;
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Shared build loop for both auto-run and Round 2. Seeds the manifest with
724
+ * carried-forward (reused) upstream entries so downstream references resolve,
725
+ * then builds each instructed source in dependency order. Returns the full
726
+ * entry map (carried + freshly built). The package was compiled by the
727
+ * caller; this runs outside the package lock.
728
+ */
729
+ private async executeInstructedBuild(
730
+ compiled: {
731
+ graphs: MalloyBuildGraph[];
732
+ sources: Record<string, PersistSource>;
733
+ connectionDigests: Record<string, string>;
734
+ connections: Map<string, MalloyConnection>;
735
+ },
736
+ instructions: BuildInstruction[],
737
+ seedEntries: Record<string, ManifestEntry>,
738
+ signal: AbortSignal,
739
+ ): Promise<Record<string, ManifestEntry>> {
740
+ const { graphs, sources, connectionDigests, connections } = compiled;
741
+
742
+ const byBuildId = new Map<string, BuildInstruction>();
743
+ for (const instruction of instructions) {
744
+ byBuildId.set(instruction.buildId, instruction);
629
745
  }
630
746
 
631
- // ── STEP 3: BUILD ──────────────────────────────────────────────
632
- let sourcesBuilt = 0;
633
- let sourcesSkipped = 0;
634
- const sourceResults: Record<string, unknown>[] = [];
747
+ // Accumulates physical names as sources are built so downstream sources
748
+ // resolve their upstream references to the freshly-assigned tables. Seed
749
+ // it with carried-forward entries so reused upstreams resolve too.
750
+ const manifest = new Manifest();
751
+ const entries: Record<string, ManifestEntry> = {};
752
+ for (const [buildId, entry] of Object.entries(seedEntries)) {
753
+ if (entry.physicalTableName) {
754
+ manifest.update(buildId, { tableName: entry.physicalTableName });
755
+ }
756
+ entries[buildId] = entry;
757
+ }
635
758
 
636
759
  for (const graph of graphs) {
637
760
  const connection = connections.get(graph.connectionName);
@@ -640,66 +763,245 @@ export class MaterializationService {
640
763
  `Connection '${graph.connectionName}' not found`,
641
764
  );
642
765
  }
643
-
644
766
  for (const level of graph.nodes) {
645
767
  for (const node of level) {
646
768
  if (signal.aborted) throw new Error("Build cancelled");
647
-
648
769
  const persistSource = sources[node.sourceID];
649
- if (!persistSource) {
650
- logger.warn(
651
- `Source ${node.sourceID} not found in build plan, skipping`,
652
- );
653
- continue;
654
- }
770
+ if (!persistSource) continue;
655
771
 
656
- const result = await this.buildOneSource(
772
+ const buildId = persistSource.makeBuildId(
773
+ connectionDigests[persistSource.connectionName],
774
+ persistSource.getSQL(),
775
+ );
776
+ const instruction = byBuildId.get(buildId);
777
+ if (!instruction) continue;
778
+
779
+ const entry = await this.buildOneSource(
657
780
  persistSource,
658
- manifest,
781
+ instruction,
659
782
  connection,
660
783
  connectionDigests,
661
- forceRefresh,
662
- environmentId,
663
- packageName,
664
- knownMaterializedTables,
784
+ manifest,
665
785
  );
666
-
667
- sourceResults.push(result);
668
- if (result.status === "built") sourcesBuilt++;
669
- else sourcesSkipped++;
786
+ entries[buildId] = entry;
670
787
  }
671
788
  }
672
789
  }
673
790
 
674
- // ── STEP 4: GC ─────────────────────────────────────────────────
675
- const gcResult = await this.runPostBuildGc(
676
- manifest,
677
- environmentId,
678
- packageName,
679
- connections,
680
- );
791
+ return entries;
792
+ }
793
+
794
+ /**
795
+ * Build a single instructed source into its assigned physical table.
796
+ * COPY uses a staging table + atomic rename for crash-safety; the staging
797
+ * name derives from the buildId. Records and returns the manifest entry.
798
+ */
799
+ private async buildOneSource(
800
+ persistSource: PersistSource,
801
+ instruction: BuildInstruction,
802
+ connection: MalloyConnection,
803
+ connectionDigests: Record<string, string>,
804
+ manifest: Manifest,
805
+ ): Promise<ManifestEntry> {
806
+ const buildId = instruction.buildId;
807
+ const physicalTableName = instruction.physicalTableName;
808
+ const buildSQL = persistSource.getSQL({
809
+ buildManifest: manifest.buildManifest,
810
+ connectionDigests,
811
+ });
812
+
813
+ const { bareName } = splitTablePath(physicalTableName);
814
+ const stagingTableName = `${physicalTableName}${stagingSuffix(buildId)}`;
815
+
816
+ const startTime = performance.now();
817
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
818
+ try {
819
+ await connection.runSQL(
820
+ `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
821
+ );
822
+ await connection.runSQL(`DROP TABLE IF EXISTS ${physicalTableName}`);
823
+ await connection.runSQL(
824
+ `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
825
+ );
826
+ } catch (err) {
827
+ try {
828
+ await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
829
+ } catch (cleanupErr) {
830
+ logger.warn(
831
+ "Round 2: failed to clean up staging table after a failed build; physical leak",
832
+ {
833
+ stagingTableName,
834
+ connectionName: persistSource.connectionName,
835
+ cleanupError:
836
+ cleanupErr instanceof Error
837
+ ? cleanupErr.message
838
+ : String(cleanupErr),
839
+ },
840
+ );
841
+ }
842
+ throw err;
843
+ }
681
844
 
682
- logger.info("Materialization build complete", {
683
- sourcesBuilt,
684
- sourcesSkipped,
685
- gcDropped: gcResult.dropped.length,
686
- gcErrors: gcResult.errors.length,
845
+ // Make this table visible to downstream sources built later this round.
846
+ manifest.update(buildId, { tableName: physicalTableName });
847
+
848
+ logger.info(`Round 2 built source ${persistSource.name}`, {
849
+ physicalTableName,
850
+ durationMs: Math.round(performance.now() - startTime),
687
851
  });
688
852
 
689
853
  return {
690
- sourcesBuilt,
691
- sourcesSkipped,
692
- sources: sourceResults,
693
- gcDropped: gcResult.dropped,
694
- gcErrors: gcResult.errors,
854
+ buildId,
855
+ sourceName: persistSource.name,
856
+ materializedTableId: instruction.materializedTableId,
857
+ physicalTableName,
858
+ connectionName: persistSource.connectionName,
859
+ realization: instruction.realization,
860
+ rowCount: null,
695
861
  };
696
862
  }
697
863
 
698
- // ==================== BUILD HELPERS ====================
864
+ // ==================== CANCELLATION ====================
865
+
866
+ /** Cancel a non-terminal materialization. */
867
+ async stopMaterialization(
868
+ environmentName: string,
869
+ packageName: string,
870
+ id: string,
871
+ ): Promise<Materialization> {
872
+ const m = await this.getMaterialization(environmentName, packageName, id);
873
+
874
+ const cancellable: MaterializationStatus[] = [
875
+ "PENDING",
876
+ "BUILD_PLAN_READY",
877
+ "MANIFEST_ROWS_READY",
878
+ ];
879
+ if (!cancellable.includes(m.status)) {
880
+ throw new InvalidStateTransitionError(
881
+ `Materialization ${id} is ${m.status}, cannot stop`,
882
+ );
883
+ }
884
+
885
+ const abortController = this.runningAbortControllers.get(id);
886
+ if (abortController) {
887
+ abortController.abort();
888
+ return m;
889
+ }
890
+ return this.transition(id, "CANCELLED", {
891
+ completedAt: new Date(),
892
+ error: "Cancelled",
893
+ });
894
+ }
895
+
896
+ /**
897
+ * Delete a materialization record. Only terminal materializations
898
+ * (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
899
+ * be stopped first.
900
+ *
901
+ * By default this removes the publisher's record only — the control plane
902
+ * owns table GC. When `dropTables` is set, the publisher additionally drops
903
+ * the physical tables recorded in this run's manifest as a best-effort
904
+ * cleanup (a drop failure is logged, not fatal, so the record still deletes).
905
+ */
906
+ async deleteMaterialization(
907
+ environmentName: string,
908
+ packageName: string,
909
+ id: string,
910
+ options: { dropTables?: boolean } = {},
911
+ ): Promise<void> {
912
+ const m = await this.getMaterialization(environmentName, packageName, id);
913
+
914
+ const terminal: MaterializationStatus[] = [
915
+ "MANIFEST_FILE_READY",
916
+ "FAILED",
917
+ "CANCELLED",
918
+ ];
919
+ if (!terminal.includes(m.status)) {
920
+ throw new InvalidStateTransitionError(
921
+ `Cannot delete materialization ${id} while it is ${m.status}`,
922
+ );
923
+ }
924
+
925
+ if (options.dropTables) {
926
+ await this.dropMaterializedTables(environmentName, packageName, m);
927
+ }
928
+
929
+ await this.repository.deleteMaterialization(id);
930
+ }
931
+
932
+ /**
933
+ * Best-effort drop of every physical table this run produced, read from the
934
+ * materialization's manifest. Resolves each entry's connection by name and
935
+ * issues `DROP TABLE IF EXISTS` for the physical table and its (possible)
936
+ * leftover staging table. Failures are logged and swallowed so a partial
937
+ * cleanup never blocks deletion of the record.
938
+ */
939
+ private async dropMaterializedTables(
940
+ environmentName: string,
941
+ packageName: string,
942
+ m: Materialization,
943
+ ): Promise<void> {
944
+ const entries = m.manifest?.entries;
945
+ if (!entries || Object.keys(entries).length === 0) {
946
+ return;
947
+ }
948
+
949
+ const environment = await this.environmentStore.getEnvironment(
950
+ environmentName,
951
+ false,
952
+ );
953
+ const pkg = await environment.getPackage(packageName, false);
954
+ const connectionCache = new Map<string, MalloyConnection>();
955
+
956
+ for (const entry of Object.values(entries)) {
957
+ const connectionName = entry.connectionName;
958
+ const physicalTableName = entry.physicalTableName;
959
+ if (!connectionName || !physicalTableName) {
960
+ logger.warn("Skipping manifest entry with no connection/table", {
961
+ materializationId: m.id,
962
+ buildId: entry.buildId,
963
+ });
964
+ continue;
965
+ }
966
+
967
+ try {
968
+ let connection = connectionCache.get(connectionName);
969
+ if (!connection) {
970
+ connection = await pkg.getMalloyConnection(connectionName);
971
+ connectionCache.set(connectionName, connection);
972
+ }
973
+ await connection.runSQL(
974
+ `DROP TABLE IF EXISTS ${physicalTableName}`,
975
+ );
976
+ // A crash between staging-create and rename can leave the staging
977
+ // table behind; clean it up too while we hold the connection.
978
+ await connection.runSQL(
979
+ `DROP TABLE IF EXISTS ${physicalTableName}${stagingSuffix(
980
+ entry.buildId,
981
+ )}`,
982
+ );
983
+ logger.info("Dropped materialized table on delete", {
984
+ materializationId: m.id,
985
+ physicalTableName,
986
+ connectionName,
987
+ });
988
+ } catch (err) {
989
+ logger.warn("Failed to drop materialized table on delete", {
990
+ materializationId: m.id,
991
+ physicalTableName,
992
+ connectionName,
993
+ error: err instanceof Error ? err.message : String(err),
994
+ });
995
+ }
996
+ }
997
+ }
998
+
999
+ // ==================== BUILD PLAN COMPILATION ====================
699
1000
 
700
1001
  /**
701
1002
  * Compile every model in the package and collect the dependency-ordered
702
- * build graphs, persist sources, and pre-computed connection digests.
1003
+ * build graphs, persist sources, connection digests, and resolved
1004
+ * connections.
703
1005
  */
704
1006
  private async compilePackageBuildPlan(
705
1007
  pkg: {
@@ -708,19 +1010,18 @@ export class MaterializationService {
708
1010
  getMalloyConfig(): MalloyConfig;
709
1011
  getMalloyConnection(name: string): Promise<MalloyConnection>;
710
1012
  },
711
- signal: AbortSignal,
1013
+ signal?: AbortSignal,
712
1014
  ): Promise<{
713
- graphs: BuildGraph[];
1015
+ graphs: MalloyBuildGraph[];
714
1016
  sources: Record<string, PersistSource>;
715
1017
  connectionDigests: Record<string, string>;
716
1018
  connections: Map<string, MalloyConnection>;
717
1019
  }> {
718
- const modelPaths = pkg.getModelPaths();
719
- const allGraphs: BuildGraph[] = [];
1020
+ const allGraphs: MalloyBuildGraph[] = [];
720
1021
  const allSources: Record<string, PersistSource> = {};
721
1022
 
722
- for (const modelPath of modelPaths) {
723
- if (signal.aborted) throw new Error("Build cancelled");
1023
+ for (const modelPath of pkg.getModelPaths()) {
1024
+ if (signal?.aborted) throw new Error("Build cancelled");
724
1025
 
725
1026
  const { runtime, modelURL, importBaseURL } =
726
1027
  await Model.getModelRuntime(
@@ -728,72 +1029,28 @@ export class MaterializationService {
728
1029
  modelPath,
729
1030
  pkg.getMalloyConfig(),
730
1031
  );
1032
+ const malloyModel = await runtime
1033
+ .loadModel(modelURL, { importBaseURL })
1034
+ .getModel();
731
1035
 
732
- const modelMaterializer = runtime.loadModel(modelURL, {
733
- importBaseURL,
734
- });
735
- const malloyModel = await modelMaterializer.getModel();
736
-
737
- // getBuildPlan() throws if the tag is missing, so check first to
738
- // keep plain models in the same package buildable.
739
- const modelTag = malloyModel.annotations.parseAsTag("!").tag;
740
- if (!modelTag.has("experimental", "persistence")) {
741
- logger.debug(
742
- "Model has no ##! experimental.persistence tag, skipping",
743
- { modelPath },
744
- );
745
- continue;
746
- }
747
-
1036
+ // getBuildPlan() returns empty graphs for models with no #@ persist
1037
+ // sources, so non-persist models are simply skipped below.
748
1038
  const buildPlan = malloyModel.getBuildPlan();
749
-
750
1039
  for (const msg of buildPlan.tagParseLog) {
751
1040
  logger.warn("Persist annotation issue", {
752
1041
  modelPath,
753
1042
  message: msg.message,
754
1043
  severity: msg.severity,
755
- code: msg.code,
756
1044
  });
757
1045
  }
1046
+ if (buildPlan.graphs.length === 0) continue;
758
1047
 
759
- if (buildPlan.graphs.length > 0) {
760
- allGraphs.push(...buildPlan.graphs);
761
- for (const [sourceID, source] of Object.entries(
762
- buildPlan.sources,
763
- )) {
764
- if (allSources[sourceID]) {
765
- logger.warn(
766
- `Duplicate sourceID "${sourceID}" from model ${modelPath}, overwriting previous definition`,
767
- );
768
- }
769
- allSources[sourceID] = source;
770
- }
1048
+ allGraphs.push(...buildPlan.graphs);
1049
+ for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
1050
+ allSources[sourceID] = source;
771
1051
  }
772
1052
  }
773
1053
 
774
- logger.info("Build plan", {
775
- sourceCount: Object.keys(allSources).length,
776
- graphCount: allGraphs.length,
777
- });
778
-
779
- // Fail fast if two persist sources target the same (connection, table).
780
- const tableOwners = new Map<string, string>();
781
- for (const [sourceID, source] of Object.entries(allSources)) {
782
- const tableName =
783
- source.annotations.parseAsTag("@").tag.text("name") || source.name;
784
- const key = `${source.connectionName}::${tableName}`;
785
- const existing = tableOwners.get(key);
786
- if (existing) {
787
- throw new BadRequestError(
788
- `Persist target collision: sources '${existing}' and '${sourceID}' both resolve to table '${tableName}' on connection '${source.connectionName}'. Disambiguate with '#@ persist name=...'.`,
789
- );
790
- }
791
- tableOwners.set(key, sourceID);
792
- }
793
-
794
- // Resolve only the connections this build plan actually targets;
795
- // the package's MalloyConfig caches each lookup so the build phase
796
- // sees the same Connection instance and avoids re-resolving.
797
1054
  const connections = await resolvePackageConnections(
798
1055
  pkg,
799
1056
  allGraphs.map((g) => g.connectionName),
@@ -814,187 +1071,90 @@ export class MaterializationService {
814
1071
  };
815
1072
  }
816
1073
 
817
- /**
818
- * Materialize a single persist source: skip if up-to-date, otherwise
819
- * build via staging table (CREATE → DROP old → RENAME), then write
820
- * the manifest entry. Stale entries are cleaned up by post-build GC.
821
- */
822
- private async buildOneSource(
823
- persistSource: PersistSource,
824
- manifest: Manifest,
825
- connection: MalloyConnection,
1074
+ /** Project the Malloy build plan into the trimmed v0 wire BuildPlan. */
1075
+ private deriveBuildPlan(
1076
+ graphs: MalloyBuildGraph[],
1077
+ sources: Record<string, PersistSource>,
826
1078
  connectionDigests: Record<string, string>,
827
- forceRefresh: boolean,
828
- environmentId: string,
829
- packageName: string,
830
- knownMaterializedTables: Set<string>,
831
- ): Promise<Record<string, unknown>> {
832
- const buildIdSQL = persistSource.getSQL();
833
- const digest = connectionDigests[persistSource.connectionName];
834
- const buildId = persistSource.makeBuildId(digest, buildIdSQL);
835
-
836
- // Already built — mark active so it survives GC.
837
- if (manifest.buildManifest.entries[buildId] && !forceRefresh) {
838
- manifest.touch(buildId);
839
- logger.info(`Source ${persistSource.name} up to date, skipping`, {
840
- buildId,
841
- });
842
- return { name: persistSource.name, status: "skipped", buildId };
843
- }
844
-
845
- const buildSQL = persistSource.getSQL({
846
- buildManifest: manifest.buildManifest,
847
- connectionDigests,
848
- });
849
-
850
- const connectionName = persistSource.connectionName;
851
- const tableName =
852
- persistSource.annotations.parseAsTag("@").tag.text("name") ||
853
- persistSource.name;
854
- const { bareName } = splitTablePath(tableName);
855
- const stagingTableName = `${tableName}${stagingSuffix(buildId)}`;
856
-
857
- // Table names go into DDL verbatim. Malloy assumes a table name handed
858
- // to it (here, via the build manifest) is already quoted for the
859
- // dialect and substitutes it into generated SQL as-is; our DDL has to
860
- // match that exact identifier or the CREATE and the read diverge. The
861
- // model author owns quoting the `#@ persist name=...` value.
862
-
863
- // Guard: refuse to overwrite a pre-existing table that was not
864
- // created by a previous materialization build. Without this check a
865
- // model author could accidentally target a table name that already
866
- // holds real data (e.g. `#@ persist name=customers`), and the
867
- // DROP TABLE below would silently destroy it.
868
- const tableKey = manifestTableKey(connectionName, tableName);
869
- if (!knownMaterializedTables.has(tableKey)) {
870
- if (await tablePhysicallyExists(connection, tableName)) {
871
- throw new BadRequestError(
872
- `Refusing to materialize source '${persistSource.name}': ` +
873
- `target table '${tableName}' already exists on connection ` +
874
- `'${connectionName}' but was not created by a previous ` +
875
- `materialization build. Use '#@ persist name=...' to ` +
876
- `choose a different table name, or drop the existing ` +
877
- `table manually if it is no longer needed.`,
878
- );
879
- }
880
- }
881
-
882
- logger.info(`Building source ${persistSource.name}`, {
883
- tableName,
884
- connectionName,
885
- });
886
-
887
- const startTime = performance.now();
888
-
889
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
890
-
891
- // If any step after CREATE throws we must best-effort drop the
892
- // staging table, else it orphans under a name that GC will never
893
- // find (no manifest row is written for a failed build).
894
- try {
895
- await connection.runSQL(
896
- `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
897
- );
898
- await connection.runSQL(`DROP TABLE IF EXISTS ${tableName}`);
899
- await connection.runSQL(
900
- `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
901
- );
902
- } catch (err) {
903
- try {
904
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
905
- } catch (cleanupErr) {
906
- logger.warn(
907
- "Build: failed to clean up staging table after a failed rebuild; physical leak",
908
- {
909
- stagingTableName,
910
- connectionName,
911
- cleanupError:
912
- cleanupErr instanceof Error
913
- ? cleanupErr.message
914
- : String(cleanupErr),
915
- },
916
- );
917
- }
918
- throw err;
1079
+ sourceNames: string[] | undefined,
1080
+ ): BuildPlan {
1081
+ const include = sourceNames ? new Set(sourceNames) : null;
1082
+
1083
+ const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
1084
+ connectionName: graph.connectionName,
1085
+ nodes: graph.nodes.map((level) =>
1086
+ level.map((node) => ({
1087
+ sourceID: node.sourceID,
1088
+ dependsOn: flattenDependsOn(node),
1089
+ })),
1090
+ ),
1091
+ }));
1092
+
1093
+ const wireSources: Record<string, WirePersistSourcePlan> = {};
1094
+ for (const [sourceID, source] of Object.entries(sources)) {
1095
+ if (include && !include.has(source.name)) continue;
1096
+ wireSources[sourceID] = {
1097
+ name: source.name,
1098
+ sourceID: source.sourceID,
1099
+ connectionName: source.connectionName,
1100
+ dialect: source.dialectName,
1101
+ buildId: source.makeBuildId(
1102
+ connectionDigests[source.connectionName],
1103
+ source.getSQL(),
1104
+ ),
1105
+ sql: source.getSQL(),
1106
+ columns: deriveColumns(source),
1107
+ };
919
1108
  }
920
1109
 
921
- const duration = performance.now() - startTime;
922
-
923
- knownMaterializedTables.add(tableKey);
924
- manifest.update(buildId, { tableName });
925
-
926
- await this.manifestService.writeEntry(
927
- environmentId,
928
- packageName,
929
- buildId,
930
- tableName,
931
- persistSource.name,
932
- connectionName,
933
- );
934
-
935
- logger.info(`Built source ${persistSource.name}`, {
936
- tableName,
937
- durationMs: Math.round(duration),
938
- });
939
-
940
- return {
941
- name: persistSource.name,
942
- status: "built",
943
- buildId,
944
- tableName,
945
- durationMs: Math.round(duration),
946
- };
1110
+ return { graphs: wireGraphs, sources: wireSources };
947
1111
  }
948
1112
 
949
- /**
950
- * Post-build GC: drop physical tables + manifest rows for entries whose
951
- * BuildID is no longer produced by an active persist source.
952
- *
953
- * `liveTables` prevents a fresh build from having its table dropped when
954
- * a stale row still references the same `(connection, tableName)` pair.
955
- */
956
- private async runPostBuildGc(
957
- manifest: Manifest,
958
- environmentId: string,
959
- packageName: string,
960
- connections: Map<string, MalloyConnection>,
961
- ): Promise<GcResult> {
962
- const activeManifest = manifest.activeEntries;
963
- const allDbEntries = await this.manifestService.listEntries(
964
- environmentId,
965
- packageName,
966
- );
967
-
968
- const liveTables = new Set<string>();
969
- for (const entry of allDbEntries) {
970
- if (activeManifest.entries[entry.buildId]) {
971
- liveTables.add(liveTableKey(entry.connectionName, entry.tableName));
972
- }
973
- }
974
-
975
- const staleEntries = allDbEntries.filter(
976
- (entry) => !activeManifest.entries[entry.buildId],
977
- );
1113
+ // ==================== HELPERS ====================
978
1114
 
979
- const gcResult = await dropManifestEntries(staleEntries, {
980
- connections,
981
- manifestService: this.manifestService,
982
- environmentId,
983
- liveTables,
984
- });
1115
+ private recordRound(
1116
+ round: MaterializationRound,
1117
+ outcome: "success" | "failed" | "cancelled",
1118
+ startedAtMs: number,
1119
+ ): void {
1120
+ recordMaterializationRound(round, outcome, Date.now() - startedAtMs);
1121
+ }
985
1122
 
986
- if (gcResult.errors.length > 0) {
987
- logger.warn("Materialization GC surfaced errors", {
988
- errorCount: gcResult.errors.length,
989
- droppedCount: gcResult.dropped.length,
1123
+ private runInBackground(
1124
+ id: string,
1125
+ run: (signal: AbortSignal) => Promise<void>,
1126
+ ): void {
1127
+ const abortController = new AbortController();
1128
+ this.runningAbortControllers.set(id, abortController);
1129
+
1130
+ run(abortController.signal)
1131
+ .catch(async (err) => {
1132
+ const message = err instanceof Error ? err.message : String(err);
1133
+ const next = abortController.signal.aborted
1134
+ ? "CANCELLED"
1135
+ : "FAILED";
1136
+ try {
1137
+ await this.repository.updateMaterialization(id, {
1138
+ status: next,
1139
+ completedAt: new Date(),
1140
+ error: abortController.signal.aborted ? "Cancelled" : message,
1141
+ });
1142
+ } catch (transitionErr) {
1143
+ logger.error("Failed to record materialization failure", {
1144
+ materializationId: id,
1145
+ originalError: message,
1146
+ transitionError:
1147
+ transitionErr instanceof Error
1148
+ ? transitionErr.message
1149
+ : String(transitionErr),
1150
+ });
1151
+ }
1152
+ })
1153
+ .finally(() => {
1154
+ this.runningAbortControllers.delete(id);
990
1155
  });
991
- }
992
-
993
- return gcResult;
994
1156
  }
995
1157
 
996
- // ==================== HELPERS ====================
997
-
998
1158
  private resolveEnvironmentId(environmentName: string): Promise<string> {
999
1159
  return resolveEnvironmentId(this.repository, environmentName);
1000
1160
  }