@malloy-publisher/server 0.0.207 → 0.0.209

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 (59) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/index-CCcHdeew.js +1760 -0
  12. package/dist/app/assets/index-CNFX-CGL.js +40 -0
  13. package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +26182 -16839
  17. package/package.json +14 -11
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/health.ts +6 -0
  21. package/src/materialization_metrics.ts +117 -34
  22. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  23. package/src/mcp/agent_server.spec.ts +18 -0
  24. package/src/mcp/agent_server.ts +144 -0
  25. package/src/mcp/server.ts +3 -0
  26. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  27. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -0
  29. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  30. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  31. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  32. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  33. package/src/mcp/tools/docs_search_tool.ts +138 -0
  34. package/src/mcp/tools/get_context_eval.ts +126 -0
  35. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  36. package/src/mcp/tools/get_context_tool.ts +341 -0
  37. package/src/server-old.ts +2 -11
  38. package/src/server.ts +16 -10
  39. package/src/service/build_plan.spec.ts +116 -0
  40. package/src/service/build_plan.ts +238 -0
  41. package/src/service/connection.ts +4 -0
  42. package/src/service/connection_config.spec.ts +182 -1
  43. package/src/service/connection_config.ts +70 -0
  44. package/src/service/db_utils.spec.ts +159 -1
  45. package/src/service/db_utils.ts +131 -0
  46. package/src/service/materialization_service.spec.ts +388 -184
  47. package/src/service/materialization_service.ts +156 -442
  48. package/src/service/materialization_test_fixtures.ts +119 -0
  49. package/src/service/package.ts +41 -1
  50. package/src/storage/DatabaseInterface.ts +5 -13
  51. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  52. package/src/storage/duckdb/schema.ts +4 -5
  53. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  54. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  55. package/dist/app/assets/HomePage-CGedji_w.js +0 -1
  56. package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
  57. package/dist/app/assets/index-DGGe8UpP.js +0 -40
  58. package/dist/app/assets/index-uu6UpHd2.js +0 -1812
  59. package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
@@ -1,12 +1,8 @@
1
1
  import type {
2
- AtomicField,
3
- BuildGraph as MalloyBuildGraph,
4
- MalloyConfig,
5
2
  Connection as MalloyConnection,
6
3
  PersistSource,
7
4
  } from "@malloydata/malloy";
8
5
  import { Manifest } from "@malloydata/malloy";
9
- import { components } from "../api";
10
6
  import {
11
7
  BadRequestError,
12
8
  InvalidStateTransitionError,
@@ -15,10 +11,12 @@ import {
15
11
  } from "../errors";
16
12
  import { logger } from "../logger";
17
13
  import {
18
- MaterializationRound,
14
+ MaterializationMode,
15
+ recordAutoLoadOutcome,
19
16
  recordDropTables,
20
- recordMaterializationRound,
17
+ recordMaterializationRun,
21
18
  recordSourceBuildDuration,
19
+ recordSourcesOutcome,
22
20
  } from "../materialization_metrics";
23
21
  import {
24
22
  BuildInstruction,
@@ -32,15 +30,15 @@ import {
32
30
  ResourceRepository,
33
31
  } from "../storage/DatabaseInterface";
34
32
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
33
+ import {
34
+ CompiledBuildPlan,
35
+ compilePackageBuildPlan,
36
+ computeBuildId,
37
+ } from "./build_plan";
35
38
  import { EnvironmentStore } from "./environment_store";
36
- import { Model } from "./model";
37
39
  import { splitTablePath } from "./quoting";
38
40
  import { resolveEnvironmentId } from "./resolve_environment";
39
41
 
40
- type WireBuildGraph = components["schemas"]["BuildGraph"];
41
- type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
42
- type WireColumn = components["schemas"]["Column"];
43
-
44
42
  /**
45
43
  * Length of the buildId prefix used when synthesizing staging table names.
46
44
  * buildId is a 64-char SHA-256 hex string; 12 hex chars is 48 bits of
@@ -54,46 +52,6 @@ export function stagingSuffix(buildId: string): string {
54
52
  return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
55
53
  }
56
54
 
57
- /** Output columns of a persist source, degrading to [] if unavailable. */
58
- function deriveColumns(persistSource: PersistSource): WireColumn[] {
59
- try {
60
- return persistSource._explore.intrinsicFields
61
- .filter((f) => f.isAtomicField())
62
- .map((f) => ({
63
- name: f.name,
64
- type: String((f as AtomicField).type),
65
- }));
66
- } catch (err) {
67
- logger.warn("Failed to derive columns for persist source", {
68
- sourceID: persistSource.sourceID,
69
- error: err instanceof Error ? err.message : String(err),
70
- });
71
- return [];
72
- }
73
- }
74
-
75
- /** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
76
- function flattenDependsOn(node: {
77
- dependsOn: { sourceID: string }[];
78
- }): string[] {
79
- return node.dependsOn.map((d) => d.sourceID);
80
- }
81
-
82
- /**
83
- * The buildId for a persist source: a stable digest of its connection identity
84
- * and canonical SQL. Centralizes the (source, connectionDigests) call shape so
85
- * planning, self-instruction, and build all agree on the same id.
86
- */
87
- function computeBuildId(
88
- source: PersistSource,
89
- connectionDigests: Record<string, string>,
90
- ): string {
91
- return source.makeBuildId(
92
- connectionDigests[source.connectionName],
93
- source.getSQL(),
94
- );
95
- }
96
-
97
55
  /**
98
56
  * Physical table name the publisher self-assigns in auto-run mode: the
99
57
  * `#@ persist name=<table>` value if present, else the Malloy source name.
@@ -110,7 +68,7 @@ function selfAssignTableName(persistSource: PersistSource): string {
110
68
  }
111
69
  }
112
70
 
113
- /** Classify a thrown round error as cancelled (cooperative abort) or failed. */
71
+ /** Classify a thrown build error as cancelled (cooperative abort) or failed. */
114
72
  function outcomeFor(
115
73
  _err: unknown,
116
74
  signal: AbortSignal | undefined,
@@ -119,41 +77,16 @@ function outcomeFor(
119
77
  }
120
78
 
121
79
  /**
122
- * Resolve a Map<name, Connection> for the names a step is about to touch.
123
- * The package's MalloyConfig caches each lookup, so repeated calls are cheap.
124
- * A failed lookup is logged and omitted; downstream code reports the missing
125
- * connection explicitly.
126
- */
127
- async function resolvePackageConnections(
128
- pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
129
- names: Iterable<string>,
130
- ): Promise<Map<string, MalloyConnection>> {
131
- const map = new Map<string, MalloyConnection>();
132
- const seen = new Set<string>();
133
- for (const name of names) {
134
- if (!name || seen.has(name)) continue;
135
- seen.add(name);
136
- try {
137
- map.set(name, await pkg.getMalloyConnection(name));
138
- } catch (err) {
139
- logger.warn(`Failed to resolve connection ${name}`, {
140
- error: err instanceof Error ? err.message : String(err),
141
- });
142
- }
143
- }
144
- return map;
145
- }
146
-
147
- /**
148
- * Allowed status transitions for the two-round protocol. MANIFEST_FILE_READY,
149
- * FAILED, and CANCELLED are terminal.
80
+ * Allowed status transitions. The build runs without an intermediate
81
+ * plan-ready pause: PENDING advances straight to MANIFEST_ROWS_READY once the
82
+ * tables are built, then to MANIFEST_FILE_READY. MANIFEST_FILE_READY, FAILED,
83
+ * and CANCELLED are terminal.
150
84
  */
151
85
  const VALID_TRANSITIONS: Record<
152
86
  MaterializationStatus,
153
87
  MaterializationStatus[]
154
88
  > = {
155
- PENDING: ["BUILD_PLAN_READY", "FAILED", "CANCELLED"],
156
- BUILD_PLAN_READY: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
89
+ PENDING: ["MANIFEST_ROWS_READY", "FAILED", "CANCELLED"],
157
90
  MANIFEST_ROWS_READY: ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"],
158
91
  MANIFEST_FILE_READY: [],
159
92
  FAILED: [],
@@ -161,14 +94,16 @@ const VALID_TRANSITIONS: Record<
161
94
  };
162
95
 
163
96
  /**
164
- * Orchestrates the two-round materialization protocol.
97
+ * Orchestrates single-call materialization builds.
165
98
  *
166
- * Round 1 (on create): compile the package, compute a build plan (per-source
167
- * buildId, columns, lineage inputs, connection capability) and pause at
168
- * BUILD_PLAN_READY without writing any tables. Round 2 (action=build): build
169
- * only the control-plane-instructed sources into their assigned physical
170
- * names and assemble the manifest, which is returned inline for the control
171
- * plane to persist.
99
+ * The build plan is a deterministic property of the compiled package
100
+ * (`Package.buildPlan`), so there is no separate plan round-trip. On create
101
+ * the publisher either auto-runs (self-assigns physical names from the
102
+ * `#@ persist name=` annotation and builds + auto-loads every persist source)
103
+ * or, when the caller supplies `buildInstructions` derived from
104
+ * `Package.buildPlan`, builds directly into the caller-assigned names without
105
+ * auto-loading the manifest. Both paths build in the background and return the
106
+ * PENDING record immediately.
172
107
  *
173
108
  * At most one active materialization per (environment, package) is enforced
174
109
  * by the DB-level unique index on `materializations.active_key` (see
@@ -210,8 +145,15 @@ export class MaterializationService {
210
145
  );
211
146
  }
212
147
  this.validateTransition(current.status, next);
213
- logger.info("Materialization transition", {
148
+ // Terminal transitions are operationally interesting (info); the
149
+ // intermediate MANIFEST_ROWS_READY hop is routine bookkeeping (debug).
150
+ const terminal =
151
+ next === "MANIFEST_FILE_READY" ||
152
+ next === "FAILED" ||
153
+ next === "CANCELLED";
154
+ logger[terminal ? "info" : "debug"]("Materialization transition", {
214
155
  materializationId: id,
156
+ packageName: current.packageName,
215
157
  from: current.status,
216
158
  to: next,
217
159
  });
@@ -255,11 +197,19 @@ export class MaterializationService {
255
197
  return m;
256
198
  }
257
199
 
258
- // ==================== ROUND 1: CREATE + PLAN ====================
200
+ // ==================== CREATE + BUILD ====================
259
201
 
260
202
  /**
261
- * Create a materialization and start Round 1 (compile + plan) in the
262
- * background. Returns the PENDING record immediately.
203
+ * Create a materialization and build it in the background. Returns the
204
+ * PENDING record immediately.
205
+ *
206
+ * Auto-run (default, no `buildInstructions`): self-assign physical names and
207
+ * build + auto-load every persist source. Orchestrated (`buildInstructions`
208
+ * present): build directly into the caller-assigned names from the package's
209
+ * build plan, without auto-loading the manifest. When `buildInstructions` is
210
+ * present it is validated synchronously against the package's compiled build
211
+ * plan so a bad instruction is rejected at create time rather than failing
212
+ * the background run.
263
213
  */
264
214
  async createMaterialization(
265
215
  environmentName: string,
@@ -267,7 +217,7 @@ export class MaterializationService {
267
217
  options: {
268
218
  forceRefresh?: boolean;
269
219
  sourceNames?: string[];
270
- pauseBetweenPhases?: boolean;
220
+ buildInstructions?: BuildInstruction[];
271
221
  } = {},
272
222
  ): Promise<Materialization> {
273
223
  const environmentId = await this.resolveEnvironmentId(environmentName);
@@ -276,7 +226,13 @@ export class MaterializationService {
276
226
  environmentName,
277
227
  false,
278
228
  );
279
- await environment.getPackage(packageName, false);
229
+ const pkg = await environment.getPackage(packageName, false);
230
+
231
+ const buildInstructions = options.buildInstructions;
232
+ const orchestrated = buildInstructions !== undefined;
233
+ if (orchestrated) {
234
+ this.validateInstructions(pkg.getBuildPlan(), buildInstructions);
235
+ }
280
236
 
281
237
  const active = await this.repository.getActiveMaterialization(
282
238
  environmentId,
@@ -288,12 +244,11 @@ export class MaterializationService {
288
244
  );
289
245
  }
290
246
 
291
- const pauseBetweenPhases = options.pauseBetweenPhases ?? false;
292
247
  const forceRefresh = options.forceRefresh ?? false;
293
248
  const metadata = {
294
249
  forceRefresh,
295
250
  sourceNames: options.sourceNames ?? null,
296
- pauseBetweenPhases,
251
+ mode: orchestrated ? "orchestrated" : "auto",
297
252
  };
298
253
 
299
254
  let created: Materialization;
@@ -319,99 +274,48 @@ export class MaterializationService {
319
274
  throw err;
320
275
  }
321
276
 
322
- // Default (auto-run): compile, self-assign names, build, and auto-load in
323
- // one pass. Opt-in two-round: pause at BUILD_PLAN_READY for the control
324
- // plane to drive Round 2.
325
277
  this.runInBackground(created.id, (signal) =>
326
- pauseBetweenPhases
327
- ? this.runRound1(
328
- created.id,
329
- environmentName,
330
- packageName,
331
- options.sourceNames,
332
- signal,
333
- )
334
- : this.runAuto(
335
- created.id,
336
- environmentName,
337
- packageName,
338
- options.sourceNames,
339
- forceRefresh,
340
- signal,
341
- ),
278
+ this.runBuild(
279
+ created.id,
280
+ environmentName,
281
+ packageName,
282
+ {
283
+ sourceNames: options.sourceNames,
284
+ forceRefresh,
285
+ buildInstructions,
286
+ },
287
+ signal,
288
+ ),
342
289
  );
343
290
 
344
291
  return created;
345
292
  }
346
293
 
347
- private async runRound1(
348
- id: string,
349
- environmentName: string,
350
- packageName: string,
351
- sourceNames: string[] | undefined,
352
- signal?: AbortSignal,
353
- ): Promise<void> {
354
- logger.info("Materialization Round 1: compile + plan", {
355
- materializationId: id,
356
- packageName,
357
- });
358
- const startedAt = Date.now();
359
-
360
- try {
361
- const environment = await this.environmentStore.getEnvironment(
362
- environmentName,
363
- false,
364
- );
365
- const pkg = await environment.getPackage(packageName, false);
366
-
367
- const { graphs, sources, connectionDigests } =
368
- await environment.withPackageLock(packageName, () =>
369
- this.compilePackageBuildPlan(pkg, signal),
370
- );
371
-
372
- const buildPlan = this.deriveBuildPlan(
373
- graphs,
374
- sources,
375
- connectionDigests,
376
- sourceNames,
377
- );
378
-
379
- await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
380
- this.recordRound("round1", "success", startedAt);
381
- logger.info("Materialization Round 1 complete", {
382
- materializationId: id,
383
- packageName,
384
- sourceCount: Object.keys(buildPlan.sources).length,
385
- durationMs: Date.now() - startedAt,
386
- });
387
- } catch (err) {
388
- this.recordRound("round1", outcomeFor(err, signal), startedAt);
389
- throw err;
390
- }
391
- }
392
-
393
- // ==================== AUTO-RUN (DEFAULT) ====================
394
-
395
294
  /**
396
- * Auto-run (default, pauseBetweenPhases=false). Compile + plan, then
397
- * self-assign physical table names (the `#@ persist name=` value, else the
398
- * source name) with realization=COPY, skip sources whose buildId is
399
- * unchanged since the most recent successful manifest (unless forceRefresh),
400
- * build the rest, assemble the manifest, and auto-load it into the package
401
- * models so subsequent queries resolve to the materialized tables. No pause,
402
- * no second round.
295
+ * Single-call build, shared by auto-run and orchestrated mode. Compiles the
296
+ * package build plan, derives the build instructions (self-assigned for
297
+ * auto-run; caller-supplied for orchestrated), builds the instructed sources
298
+ * into their physical tables, and commits the manifest. Auto-run additionally
299
+ * loads the fresh manifest into the package models; orchestrated leaves
300
+ * distribution to the caller.
403
301
  */
404
- private async runAuto(
302
+ private async runBuild(
405
303
  id: string,
406
304
  environmentName: string,
407
305
  packageName: string,
408
- sourceNames: string[] | undefined,
409
- forceRefresh: boolean,
306
+ opts: {
307
+ sourceNames: string[] | undefined;
308
+ forceRefresh: boolean;
309
+ buildInstructions: BuildInstruction[] | undefined;
310
+ },
410
311
  signal: AbortSignal,
411
312
  ): Promise<void> {
412
- logger.info("Materialization auto-run: compile + build + load", {
313
+ const orchestrated = opts.buildInstructions !== undefined;
314
+ const mode: MaterializationMode = orchestrated ? "orchestrated" : "auto";
315
+ logger.info("Materialization build started", {
413
316
  materializationId: id,
414
317
  packageName,
318
+ mode,
415
319
  });
416
320
  const startedAt = Date.now();
417
321
 
@@ -424,31 +328,31 @@ export class MaterializationService {
424
328
  const pkg = await environment.getPackage(packageName, false);
425
329
 
426
330
  const compiled = await environment.withPackageLock(packageName, () =>
427
- this.compilePackageBuildPlan(pkg, signal),
331
+ compilePackageBuildPlan(pkg, signal),
428
332
  );
429
333
 
430
- const buildPlan = this.deriveBuildPlan(
431
- compiled.graphs,
432
- compiled.sources,
433
- compiled.connectionDigests,
434
- sourceNames,
435
- );
436
- await this.transition(id, "BUILD_PLAN_READY", { buildPlan });
437
-
438
- // Skip-if-unchanged: reuse tables from the most recent successful
439
- // manifest for sources whose buildId is unchanged, unless forceRefresh.
440
- const priorEntries = forceRefresh
441
- ? {}
442
- : await this.getMostRecentManifestEntries(
443
- environmentId,
444
- packageName,
445
- id,
446
- );
447
- const { instructions, carried } = this.deriveSelfInstructions(
448
- compiled,
449
- sourceNames,
450
- priorEntries,
451
- );
334
+ let instructions: BuildInstruction[];
335
+ let carried: Record<string, ManifestEntry>;
336
+ if (orchestrated) {
337
+ instructions = opts.buildInstructions!;
338
+ carried = {};
339
+ } else {
340
+ // Skip-if-unchanged: reuse tables from the most recent successful
341
+ // manifest for sources whose buildId is unchanged, unless
342
+ // forceRefresh.
343
+ const priorEntries = opts.forceRefresh
344
+ ? {}
345
+ : await this.getMostRecentManifestEntries(
346
+ environmentId,
347
+ packageName,
348
+ id,
349
+ );
350
+ ({ instructions, carried } = this.deriveSelfInstructions(
351
+ compiled,
352
+ opts.sourceNames,
353
+ priorEntries,
354
+ ));
355
+ }
452
356
 
453
357
  const entries = await this.executeInstructedBuild(
454
358
  compiled,
@@ -457,28 +361,38 @@ export class MaterializationService {
457
361
  signal,
458
362
  );
459
363
 
364
+ const sourcesBuilt = instructions.length;
365
+ const sourcesReused = Object.keys(carried).length;
460
366
  const durationMs = Date.now() - startedAt;
461
367
  await this.commitManifest(id, entries, {
462
- forceRefresh,
463
- sourceNames: sourceNames ?? null,
464
- pauseBetweenPhases: false,
465
- sourcesBuilt: instructions.length,
466
- sourcesReused: Object.keys(carried).length,
368
+ forceRefresh: opts.forceRefresh,
369
+ sourceNames: opts.sourceNames ?? null,
370
+ mode,
371
+ sourcesBuilt,
372
+ sourcesReused,
467
373
  durationMs,
468
374
  });
469
375
 
470
- await this.autoLoadManifest(environment, packageName, entries);
376
+ // Auto-run owns distribution: load the fresh manifest into the package
377
+ // models so subsequent queries resolve to the materialized tables.
378
+ // Orchestrated leaves distribution to the caller (manifestLocation).
379
+ if (!orchestrated) {
380
+ await this.autoLoadManifest(environment, packageName, entries);
381
+ }
471
382
 
472
- this.recordRound("auto", "success", startedAt);
473
- logger.info("Materialization auto-run complete", {
383
+ recordSourcesOutcome("built", sourcesBuilt);
384
+ recordSourcesOutcome("reused", sourcesReused);
385
+ this.recordRun(mode, "success", startedAt);
386
+ logger.info("Materialization build complete", {
474
387
  materializationId: id,
475
388
  packageName,
476
- sourcesBuilt: instructions.length,
477
- sourcesReused: Object.keys(carried).length,
389
+ mode,
390
+ sourcesBuilt,
391
+ sourcesReused,
478
392
  durationMs,
479
393
  });
480
394
  } catch (err) {
481
- this.recordRound("auto", outcomeFor(err, signal), startedAt);
395
+ this.recordRun(mode, outcomeFor(err, signal), startedAt);
482
396
  throw err;
483
397
  }
484
398
  }
@@ -491,11 +405,7 @@ export class MaterializationService {
491
405
  * rebuilt.
492
406
  */
493
407
  private deriveSelfInstructions(
494
- compiled: {
495
- graphs: MalloyBuildGraph[];
496
- sources: Record<string, PersistSource>;
497
- connectionDigests: Record<string, string>;
498
- },
408
+ compiled: CompiledBuildPlan,
499
409
  sourceNames: string[] | undefined,
500
410
  priorEntries: Record<string, ManifestEntry>,
501
411
  ): {
@@ -591,11 +501,13 @@ export class MaterializationService {
591
501
  packageName,
592
502
  manifestEntries,
593
503
  );
504
+ recordAutoLoadOutcome("success");
594
505
  logger.info("Auto-run: loaded manifest into package models", {
595
506
  packageName,
596
507
  entryCount: Object.keys(manifestEntries).length,
597
508
  });
598
509
  } catch (err) {
510
+ recordAutoLoadOutcome("failure");
599
511
  logger.warn("Auto-run: failed to load manifest into package models", {
600
512
  packageName,
601
513
  error: err instanceof Error ? err.message : String(err),
@@ -603,51 +515,21 @@ export class MaterializationService {
603
515
  }
604
516
  }
605
517
 
606
- // ==================== ROUND 2: BUILD ====================
607
-
608
518
  /**
609
- * Round 2. Validates the instructions against the stored build plan,
610
- * transitions out of BUILD_PLAN_READY, and builds the instructed sources
611
- * in the background. Returns the accepted record (202).
519
+ * Validate caller-supplied build instructions against the package's compiled
520
+ * build plan: every instructed buildId must be a planned source, and only
521
+ * COPY realization is supported. Throws when the package declares no persist
522
+ * source (no plan to build against).
612
523
  */
613
- async buildMaterialization(
614
- environmentName: string,
615
- packageName: string,
616
- id: string,
524
+ private validateInstructions(
525
+ plan: BuildPlan | null,
617
526
  instructions: BuildInstruction[],
618
- ): Promise<Materialization> {
619
- const m = await this.getMaterialization(environmentName, packageName, id);
620
-
621
- if (!m.pauseBetweenPhases) {
622
- throw new InvalidStateTransitionError(
623
- `Materialization ${id} is an auto-run materialization; action=build does not apply (set pauseBetweenPhases=true for the two-round flow)`,
624
- );
625
- }
626
- if (m.status !== "BUILD_PLAN_READY") {
627
- throw new InvalidStateTransitionError(
628
- `Materialization ${id} is ${m.status}, expected BUILD_PLAN_READY`,
629
- );
630
- }
631
- const plan = m.buildPlan;
527
+ ): void {
632
528
  if (!plan) {
633
- throw new InvalidStateTransitionError(
634
- `Materialization ${id} has no build plan`,
529
+ throw new BadRequestError(
530
+ "Package has no persist sources; buildInstructions cannot be applied",
635
531
  );
636
532
  }
637
-
638
- this.validateInstructions(plan, instructions);
639
-
640
- this.runInBackground(id, (signal) =>
641
- this.runRound2(id, environmentName, packageName, instructions, signal),
642
- );
643
-
644
- return m;
645
- }
646
-
647
- private validateInstructions(
648
- plan: BuildPlan,
649
- instructions: BuildInstruction[],
650
- ): void {
651
533
  const plannedBuildIds = new Set<string>();
652
534
  for (const source of Object.values(plan.sources)) {
653
535
  plannedBuildIds.add(source.buildId);
@@ -659,80 +541,24 @@ export class MaterializationService {
659
541
  `Instruction references unknown buildId '${instruction.buildId}'`,
660
542
  );
661
543
  }
662
- // v0 is COPY-only; SNAPSHOT lands once clone semantics are defined.
544
+ // COPY-only for now; SNAPSHOT lands once clone semantics are defined.
663
545
  if (instruction.realization === "SNAPSHOT") {
664
546
  throw new BadRequestError(
665
- "realization=SNAPSHOT is not supported in v0 (COPY only)",
547
+ "realization=SNAPSHOT is not supported (COPY only)",
666
548
  );
667
549
  }
668
550
  }
669
551
  }
670
552
 
671
- private async runRound2(
672
- id: string,
673
- environmentName: string,
674
- packageName: string,
675
- instructions: BuildInstruction[],
676
- signal: AbortSignal,
677
- ): Promise<void> {
678
- logger.info("Materialization Round 2: build", {
679
- materializationId: id,
680
- packageName,
681
- sourceCount: instructions.length,
682
- });
683
- const startedAt = Date.now();
684
-
685
- try {
686
- const environment = await this.environmentStore.getEnvironment(
687
- environmentName,
688
- false,
689
- );
690
- const pkg = await environment.getPackage(packageName, false);
691
-
692
- const compiled = await environment.withPackageLock(packageName, () =>
693
- this.compilePackageBuildPlan(pkg, signal),
694
- );
695
-
696
- const entries = await this.executeInstructedBuild(
697
- compiled,
698
- instructions,
699
- {},
700
- signal,
701
- );
702
-
703
- const durationMs = Date.now() - startedAt;
704
- await this.commitManifest(id, entries, {
705
- sourcesBuilt: Object.keys(entries).length,
706
- durationMs,
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
553
  /**
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.
554
+ * Shared build loop for both auto-run and orchestrated builds. Seeds the
555
+ * manifest with carried-forward (reused) upstream entries so downstream
556
+ * references resolve, then builds each instructed source in dependency
557
+ * order. Returns the full entry map (carried + freshly built). The package
558
+ * was compiled by the caller; this runs outside the package lock.
728
559
  */
729
560
  private async executeInstructedBuild(
730
- compiled: {
731
- graphs: MalloyBuildGraph[];
732
- sources: Record<string, PersistSource>;
733
- connectionDigests: Record<string, string>;
734
- connections: Map<string, MalloyConnection>;
735
- },
561
+ compiled: CompiledBuildPlan,
736
562
  instructions: BuildInstruction[],
737
563
  seedEntries: Record<string, ManifestEntry>,
738
564
  signal: AbortSignal,
@@ -825,7 +651,7 @@ export class MaterializationService {
825
651
  await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
826
652
  } catch (cleanupErr) {
827
653
  logger.warn(
828
- "Round 2: failed to clean up staging table after a failed build; physical leak",
654
+ "Failed to clean up staging table after a failed build; physical leak",
829
655
  {
830
656
  stagingTableName,
831
657
  connectionName: persistSource.connectionName,
@@ -839,12 +665,11 @@ export class MaterializationService {
839
665
  throw err;
840
666
  }
841
667
 
842
- // Make this table visible to downstream sources built later this round.
668
+ // Make this table visible to downstream sources built later in this run.
843
669
  manifest.update(buildId, { tableName: physicalTableName });
844
670
 
845
671
  const durationMs = Math.round(performance.now() - startTime);
846
672
  recordSourceBuildDuration(durationMs);
847
- // Shared by auto-run and Round 2, so the message is path-neutral.
848
673
  logger.info(`Built materialized source ${persistSource.name}`, {
849
674
  physicalTableName,
850
675
  durationMs,
@@ -873,7 +698,6 @@ export class MaterializationService {
873
698
 
874
699
  const cancellable: MaterializationStatus[] = [
875
700
  "PENDING",
876
- "BUILD_PLAN_READY",
877
701
  "MANIFEST_ROWS_READY",
878
702
  ];
879
703
  if (!cancellable.includes(m.status)) {
@@ -898,10 +722,11 @@ export class MaterializationService {
898
722
  * (MANIFEST_FILE_READY, FAILED, CANCELLED) can be deleted; an active run must
899
723
  * be stopped first.
900
724
  *
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).
725
+ * By default this removes the publisher's record only — physical-table GC is
726
+ * the caller's responsibility. When `dropTables` is set, the publisher
727
+ * additionally drops the physical tables recorded in this run's manifest as a
728
+ * best-effort cleanup (a drop failure is logged, not fatal, so the record
729
+ * still deletes).
905
730
  */
906
731
  async deleteMaterialization(
907
732
  environmentName: string,
@@ -999,9 +824,9 @@ export class MaterializationService {
999
824
  }
1000
825
 
1001
826
  /**
1002
- * Finalize a successful build: advance MANIFEST_ROWS_READY -> FILE_READY and
1003
- * persist the assembled manifest. Shared by auto-run and Round 2; the caller
1004
- * supplies the per-path metadata. The build itself happens before this.
827
+ * Finalize a successful build: advance to MANIFEST_ROWS_READY then
828
+ * MANIFEST_FILE_READY and persist the assembled manifest. The caller
829
+ * supplies the per-run metadata. The build itself happens before this.
1005
830
  */
1006
831
  private async commitManifest(
1007
832
  id: string,
@@ -1021,125 +846,14 @@ export class MaterializationService {
1021
846
  });
1022
847
  }
1023
848
 
1024
- // ==================== BUILD PLAN COMPILATION ====================
1025
-
1026
- /**
1027
- * Compile every model in the package and collect the dependency-ordered
1028
- * build graphs, persist sources, connection digests, and resolved
1029
- * connections.
1030
- */
1031
- private async compilePackageBuildPlan(
1032
- pkg: {
1033
- getModelPaths(): string[];
1034
- getPackagePath(): string;
1035
- getMalloyConfig(): MalloyConfig;
1036
- getMalloyConnection(name: string): Promise<MalloyConnection>;
1037
- },
1038
- signal?: AbortSignal,
1039
- ): Promise<{
1040
- graphs: MalloyBuildGraph[];
1041
- sources: Record<string, PersistSource>;
1042
- connectionDigests: Record<string, string>;
1043
- connections: Map<string, MalloyConnection>;
1044
- }> {
1045
- const allGraphs: MalloyBuildGraph[] = [];
1046
- const allSources: Record<string, PersistSource> = {};
1047
-
1048
- for (const modelPath of pkg.getModelPaths()) {
1049
- if (signal?.aborted) throw new Error("Build cancelled");
1050
-
1051
- const { runtime, modelURL, importBaseURL } =
1052
- await Model.getModelRuntime(
1053
- pkg.getPackagePath(),
1054
- modelPath,
1055
- pkg.getMalloyConfig(),
1056
- );
1057
- const malloyModel = await runtime
1058
- .loadModel(modelURL, { importBaseURL })
1059
- .getModel();
1060
-
1061
- // getBuildPlan() returns empty graphs for models with no #@ persist
1062
- // sources, so non-persist models are simply skipped below.
1063
- const buildPlan = malloyModel.getBuildPlan();
1064
- for (const msg of buildPlan.tagParseLog) {
1065
- logger.warn("Persist annotation issue", {
1066
- modelPath,
1067
- message: msg.message,
1068
- severity: msg.severity,
1069
- });
1070
- }
1071
- if (buildPlan.graphs.length === 0) continue;
1072
-
1073
- allGraphs.push(...buildPlan.graphs);
1074
- for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
1075
- allSources[sourceID] = source;
1076
- }
1077
- }
1078
-
1079
- const connections = await resolvePackageConnections(
1080
- pkg,
1081
- allGraphs.map((g) => g.connectionName),
1082
- );
1083
- const connectionDigests: Record<string, string> = {};
1084
- for (const graph of allGraphs) {
1085
- const conn = connections.get(graph.connectionName);
1086
- if (conn && !connectionDigests[graph.connectionName]) {
1087
- connectionDigests[graph.connectionName] = await conn.getDigest();
1088
- }
1089
- }
1090
-
1091
- return {
1092
- graphs: allGraphs,
1093
- sources: allSources,
1094
- connectionDigests,
1095
- connections,
1096
- };
1097
- }
1098
-
1099
- /** Project the Malloy build plan into the trimmed v0 wire BuildPlan. */
1100
- private deriveBuildPlan(
1101
- graphs: MalloyBuildGraph[],
1102
- sources: Record<string, PersistSource>,
1103
- connectionDigests: Record<string, string>,
1104
- sourceNames: string[] | undefined,
1105
- ): BuildPlan {
1106
- const include = sourceNames ? new Set(sourceNames) : null;
1107
-
1108
- const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
1109
- connectionName: graph.connectionName,
1110
- nodes: graph.nodes.map((level) =>
1111
- level.map((node) => ({
1112
- sourceID: node.sourceID,
1113
- dependsOn: flattenDependsOn(node),
1114
- })),
1115
- ),
1116
- }));
1117
-
1118
- const wireSources: Record<string, WirePersistSourcePlan> = {};
1119
- for (const [sourceID, source] of Object.entries(sources)) {
1120
- if (include && !include.has(source.name)) continue;
1121
- wireSources[sourceID] = {
1122
- name: source.name,
1123
- sourceID: source.sourceID,
1124
- connectionName: source.connectionName,
1125
- dialect: source.dialectName,
1126
- buildId: computeBuildId(source, connectionDigests),
1127
- sql: source.getSQL(),
1128
- columns: deriveColumns(source),
1129
- };
1130
- }
1131
-
1132
- return { graphs: wireGraphs, sources: wireSources };
1133
- }
1134
-
1135
849
  // ==================== HELPERS ====================
1136
850
 
1137
- private recordRound(
1138
- round: MaterializationRound,
851
+ private recordRun(
852
+ mode: MaterializationMode,
1139
853
  outcome: "success" | "failed" | "cancelled",
1140
854
  startedAtMs: number,
1141
855
  ): void {
1142
- recordMaterializationRound(round, outcome, Date.now() - startedAtMs);
856
+ recordMaterializationRun(mode, outcome, Date.now() - startedAtMs);
1143
857
  }
1144
858
 
1145
859
  private runInBackground(