@malloy-publisher/server 0.0.206 → 0.0.208

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 (46) hide show
  1. package/dist/app/api-doc.yaml +31 -0
  2. package/dist/app/assets/{EnvironmentPage-BYwBeC2F.js → EnvironmentPage-DDRxo015.js} +1 -1
  3. package/dist/app/assets/{HomePage-ivu4vdpj.js → HomePage-BeIoPOVO.js} +1 -1
  4. package/dist/app/assets/{MainPage-B2DnHEDU.js → MainPage-DHVFRXPc.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-BZEuwF9P.js → MaterializationsPage-BYnr56IV.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Dpu3bfPg.js → ModelPage-B8tF_hYc.js} +1 -1
  7. package/dist/app/assets/{PackagePage-B8PwcRHt.js → PackagePage-LzaaviPn.js} +1 -1
  8. package/dist/app/assets/{RouteError-BhbywAeC.js → RouteError-vAYvRAi3.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-C-JXsJG0.js → WorkbookPage-CTjs2hXr.js} +1 -1
  10. package/dist/app/assets/{core-pPlPr7jK.es-CNEOlxKB.js → core-AOmIKwkc.es-B29cca-e.js} +1 -1
  11. package/dist/app/assets/index-CVGIZdxd.js +40 -0
  12. package/dist/app/assets/{index-ChR1fKR2.js → index-Db2wvjL3.js} +3 -3
  13. package/dist/app/assets/index-Dj4uKosi.js +1760 -0
  14. package/dist/app/assets/index.umd-BRRXibWA.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +10537 -6626
  17. package/package.json +13 -11
  18. package/src/health.ts +6 -0
  19. package/src/materialization_metrics.ts +55 -0
  20. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  21. package/src/mcp/agent_server.spec.ts +18 -0
  22. package/src/mcp/agent_server.ts +144 -0
  23. package/src/mcp/server.ts +3 -0
  24. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  25. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  26. package/src/mcp/skills/skills_bundle.json +1 -0
  27. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  28. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  29. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  30. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  31. package/src/mcp/tools/docs_search_tool.ts +138 -0
  32. package/src/mcp/tools/get_context_eval.ts +126 -0
  33. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  34. package/src/mcp/tools/get_context_tool.ts +341 -0
  35. package/src/server.ts +14 -0
  36. package/src/service/environment.ts +59 -1
  37. package/src/service/materialization_service.spec.ts +272 -0
  38. package/src/service/materialization_service.ts +67 -45
  39. package/src/service/model.ts +19 -2
  40. package/src/service/package.ts +92 -22
  41. package/tests/integration/materialization/manifest_binding.integration.spec.ts +25 -6
  42. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +94 -0
  43. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  44. package/dist/app/assets/index-BHEm8Egc.js +0 -40
  45. package/dist/app/assets/index-BsvDrV14.js +0 -1812
  46. package/dist/app/assets/index.umd-BVLPYNuj.js +0 -2469
package/src/server.ts CHANGED
@@ -39,6 +39,7 @@ import { checkHeapConfiguration } from "./heap_check";
39
39
  import { queryConcurrency } from "./query_concurrency";
40
40
  import { MaterializationController } from "./controller/materialization.controller";
41
41
  import { initializeMcpServer } from "./mcp/server";
42
+ import { startAgentMcpServer } from "./mcp/agent_server";
42
43
  import { registerLegacyRoutes } from "./server-old";
43
44
  import { EnvironmentStore } from "./service/environment_store";
44
45
  import { MaterializationService } from "./service/materialization_service";
@@ -160,6 +161,7 @@ parseArgs();
160
161
  const PUBLISHER_PORT = Number(process.env.PUBLISHER_PORT || 4000);
161
162
  const PUBLISHER_HOST = process.env.PUBLISHER_HOST || "0.0.0.0";
162
163
  const MCP_PORT = Number(process.env.MCP_PORT || 4040);
164
+ const AGENT_MCP_PORT = Number(process.env.AGENT_MCP_PORT || 4041);
163
165
  const MCP_ENDPOINT = "/mcp";
164
166
  const SHUTDOWN_DRAIN_DURATION_SECONDS = Number(
165
167
  process.env.SHUTDOWN_DRAIN_DURATION_SECONDS || 0,
@@ -1765,9 +1767,21 @@ mcpServer.timeout = 600000;
1765
1767
  mcpServer.keepAliveTimeout = 600000;
1766
1768
  mcpServer.headersTimeout = 600000;
1767
1769
 
1770
+ // Separate, isolated MCP server for the agent retrieval tools (get_context,
1771
+ // search_docs) on its own listener. Kept apart from the core MCP server above.
1772
+ const agentMcpServer = startAgentMcpServer(
1773
+ environmentStore,
1774
+ PUBLISHER_HOST,
1775
+ AGENT_MCP_PORT,
1776
+ );
1777
+ agentMcpServer.timeout = 600000;
1778
+ agentMcpServer.keepAliveTimeout = 600000;
1779
+ agentMcpServer.headersTimeout = 600000;
1780
+
1768
1781
  registerSignalHandlers(
1769
1782
  mainServer,
1770
1783
  mcpServer,
1771
1784
  SHUTDOWN_DRAIN_DURATION_SECONDS,
1772
1785
  SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS,
1786
+ agentMcpServer,
1773
1787
  );
@@ -21,6 +21,7 @@ import {
21
21
  ServiceUnavailableError,
22
22
  } from "../errors";
23
23
  import { logger } from "../logger";
24
+ import { recordManifestBind } from "../materialization_metrics";
24
25
  import {
25
26
  assertSafeEnvironmentPath,
26
27
  assertSafePackageName,
@@ -56,6 +57,11 @@ import type { PackageMemoryGovernor } from "./package_memory_governor";
56
57
  const STAGING_DIR_NAME = ".staging";
57
58
  const RETIRED_DIR_NAME = ".retired";
58
59
 
60
+ // How long to wait for a control-plane manifest fetch during (re)bind before
61
+ // giving up and serving live. Binding happens before a package is marked
62
+ // SERVING, so an unreachable/slow manifest store must not block the package.
63
+ const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
64
+
59
65
  export enum PackageStatus {
60
66
  LOADING = "loading",
61
67
  SERVING = "serving",
@@ -393,9 +399,17 @@ export class Environment {
393
399
  // Initialize Runtime with the package's active MalloyConfig so compile
394
400
  // checks see the same package-scoped duckdb as execution. This runtime
395
401
  // borrows the package config; the package/environment lifecycle owns release.
402
+ // Thread the package's bound build manifest (when present) so the
403
+ // /compile preview routes persist sources to their materialized tables
404
+ // exactly like execution does — otherwise includeSql=true would always
405
+ // show base-table SQL and diverge from what executeQuery actually runs.
406
+ const boundManifestEntries = pkg.getBuildManifestEntries();
396
407
  const runtime = new Runtime({
397
408
  urlReader: interceptingReader,
398
409
  config: pkg.getMalloyConfig(),
410
+ buildManifest: boundManifestEntries
411
+ ? { entries: boundManifestEntries, strict: false }
412
+ : undefined,
399
413
  });
400
414
 
401
415
  // Attempt to compile
@@ -1140,8 +1154,15 @@ export class Environment {
1140
1154
  ): Promise<void> {
1141
1155
  const packageName = pkg.getPackageName();
1142
1156
  try {
1143
- const entries = await fetchManifestEntries(manifestLocation);
1157
+ // Bind runs before a package is marked SERVING, so a slow/unreachable
1158
+ // manifest store must not block serving indefinitely — bound is the
1159
+ // intended state, live is the degraded fallback. Race the fetch against
1160
+ // a timeout and fall back to live on either failure.
1161
+ const entries =
1162
+ await this.fetchManifestEntriesWithTimeout(manifestLocation);
1144
1163
  await pkg.reloadAllModels(entries);
1164
+ pkg.setBoundManifestUri(manifestLocation);
1165
+ recordManifestBind("success");
1145
1166
  logger.info("Bound build manifest to package", {
1146
1167
  environmentName: this.environmentName,
1147
1168
  packageName,
@@ -1149,15 +1170,52 @@ export class Environment {
1149
1170
  entryCount: Object.keys(entries).length,
1150
1171
  });
1151
1172
  } catch (err) {
1173
+ pkg.markManifestBindFailed();
1174
+ const timedOut =
1175
+ err instanceof Error && err.message.includes("Timed out after");
1176
+ recordManifestBind(timedOut ? "timeout" : "failure");
1152
1177
  logger.warn("Failed to bind build manifest; serving live", {
1153
1178
  environmentName: this.environmentName,
1154
1179
  packageName,
1155
1180
  manifestLocation,
1181
+ timedOut,
1156
1182
  error: err instanceof Error ? err.message : String(err),
1157
1183
  });
1158
1184
  }
1159
1185
  }
1160
1186
 
1187
+ /**
1188
+ * Fetch manifest entries, rejecting if the fetch exceeds
1189
+ * {@link MANIFEST_FETCH_TIMEOUT_MS}. Keeps {@link bindManifest}'s bind-before-
1190
+ * serve guarantee from stalling on an unreachable manifest store.
1191
+ */
1192
+ private async fetchManifestEntriesWithTimeout(
1193
+ manifestLocation: string,
1194
+ ): Promise<BuildManifest["entries"]> {
1195
+ let timer: ReturnType<typeof setTimeout> | undefined;
1196
+ const timeout = new Promise<never>((_, reject) => {
1197
+ timer = setTimeout(
1198
+ () =>
1199
+ reject(
1200
+ new Error(
1201
+ `Timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms fetching manifest ${manifestLocation}`,
1202
+ ),
1203
+ ),
1204
+ MANIFEST_FETCH_TIMEOUT_MS,
1205
+ );
1206
+ });
1207
+ try {
1208
+ return await Promise.race([
1209
+ fetchManifestEntries(manifestLocation),
1210
+ timeout,
1211
+ ]);
1212
+ } finally {
1213
+ if (timer) {
1214
+ clearTimeout(timer);
1215
+ }
1216
+ }
1217
+ }
1218
+
1161
1219
  /**
1162
1220
  * Read a model's source text from disk, holding the per-package mutex
1163
1221
  * so the read is serialized against {@link installPackage} /
@@ -1,3 +1,4 @@
1
+ import { Manifest } from "@malloydata/malloy";
1
2
  import { beforeEach, describe, expect, it } from "bun:test";
2
3
  import * as sinon from "sinon";
3
4
  import {
@@ -498,3 +499,274 @@ describe("stagingSuffix", () => {
498
499
  expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
499
500
  });
500
501
  });
502
+
503
+ // Characterization (Pass 0): lock in the auto-run instruction-derivation and
504
+ // the single-source build SQL sequence before the simplify pass moves them.
505
+ // deriveSelfInstructions / buildOneSource are private; we exercise them via a
506
+ // typed cast rather than the heavyweight runtime path.
507
+
508
+ // A minimal stand-in for a Malloy PersistSource exposing only what the build
509
+ // internals touch.
510
+ function fakeSource(opts: {
511
+ name: string;
512
+ buildId: string;
513
+ sql?: string;
514
+ connectionName?: string;
515
+ }): unknown {
516
+ return {
517
+ name: opts.name,
518
+ sourceID: opts.name,
519
+ connectionName: opts.connectionName ?? "duckdb",
520
+ makeBuildId: () => opts.buildId,
521
+ getSQL: () => opts.sql ?? "SELECT 1",
522
+ annotations: {
523
+ parseAsTag: () => ({ tag: { text: () => undefined } }),
524
+ },
525
+ };
526
+ }
527
+
528
+ describe("deriveSelfInstructions (characterization)", () => {
529
+ let ctx: ReturnType<typeof createMocks>;
530
+ beforeEach(() => {
531
+ ctx = createMocks();
532
+ });
533
+
534
+ function compiledWith(sources: Record<string, unknown>, levels: string[][]) {
535
+ return {
536
+ graphs: [
537
+ {
538
+ connectionName: "duckdb",
539
+ nodes: levels.map((level) =>
540
+ level.map((sourceID) => ({ sourceID, dependsOn: [] })),
541
+ ),
542
+ },
543
+ ],
544
+ sources,
545
+ connectionDigests: { duckdb: "dig" },
546
+ };
547
+ }
548
+
549
+ it("carries forward unchanged buildIds and builds the rest (deduping repeats)", () => {
550
+ const compiled = compiledWith(
551
+ {
552
+ s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
553
+ s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
554
+ },
555
+ [["s1", "s2"], ["s2"]],
556
+ );
557
+ const priorEntries = {
558
+ b1aaaaaaaaaaaaaa: {
559
+ buildId: "b1aaaaaaaaaaaaaa",
560
+ physicalTableName: "s1_prev",
561
+ connectionName: "duckdb",
562
+ },
563
+ };
564
+
565
+ const { instructions, carried } = (
566
+ ctx.service as unknown as {
567
+ deriveSelfInstructions: (
568
+ c: unknown,
569
+ n: string[] | undefined,
570
+ p: unknown,
571
+ ) => { instructions: BuildInstruction[]; carried: unknown };
572
+ }
573
+ ).deriveSelfInstructions(compiled, undefined, priorEntries);
574
+
575
+ expect(instructions).toHaveLength(1);
576
+ expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
577
+ expect(instructions[0].physicalTableName).toBe("s2");
578
+ expect(instructions[0].realization).toBe("COPY");
579
+ expect(instructions[0].materializedTableId).toBe("local-b2bbbbbbbbbb");
580
+ expect(
581
+ (carried as Record<string, unknown>)["b1aaaaaaaaaaaaaa"],
582
+ ).toBeDefined();
583
+ });
584
+
585
+ it("honors the sourceNames filter (excluded sources are neither built nor carried)", () => {
586
+ const compiled = compiledWith(
587
+ {
588
+ s1: fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" }),
589
+ s2: fakeSource({ name: "s2", buildId: "b2bbbbbbbbbbbbbb" }),
590
+ },
591
+ [["s1", "s2"]],
592
+ );
593
+ const { instructions, carried } = (
594
+ ctx.service as unknown as {
595
+ deriveSelfInstructions: (
596
+ c: unknown,
597
+ n: string[] | undefined,
598
+ p: unknown,
599
+ ) => { instructions: BuildInstruction[]; carried: unknown };
600
+ }
601
+ ).deriveSelfInstructions(compiled, ["s2"], {});
602
+
603
+ expect(instructions).toHaveLength(1);
604
+ expect(instructions[0].buildId).toBe("b2bbbbbbbbbbbbbb");
605
+ expect(Object.keys(carried as Record<string, unknown>)).toHaveLength(0);
606
+ });
607
+ });
608
+
609
+ describe("getMostRecentManifestEntries (skip-if-unchanged)", () => {
610
+ let ctx: ReturnType<typeof createMocks>;
611
+ beforeEach(() => {
612
+ ctx = createMocks();
613
+ });
614
+
615
+ it("returns entries from the most recent successful run, excluding the in-flight one", async () => {
616
+ const entries = {
617
+ b1: {
618
+ buildId: "b1",
619
+ physicalTableName: "orders_v1",
620
+ connectionName: "duckdb",
621
+ },
622
+ };
623
+ ctx.repository.listMaterializations.resolves([
624
+ makeMaterialization({
625
+ id: "in-flight",
626
+ status: "MANIFEST_ROWS_READY",
627
+ }),
628
+ makeMaterialization({
629
+ id: "old-success",
630
+ status: "MANIFEST_FILE_READY",
631
+ manifest: { builtAt: "t", strict: false, entries },
632
+ }),
633
+ ]);
634
+
635
+ const result = await (
636
+ ctx.service as unknown as {
637
+ getMostRecentManifestEntries: (
638
+ e: string,
639
+ p: string,
640
+ x: string,
641
+ ) => Promise<unknown>;
642
+ }
643
+ ).getMostRecentManifestEntries("env-1", "pkg", "in-flight");
644
+
645
+ expect(result).toEqual(entries);
646
+ });
647
+
648
+ it("returns {} when the only successful run is the excluded one", async () => {
649
+ ctx.repository.listMaterializations.resolves([
650
+ makeMaterialization({
651
+ id: "self",
652
+ status: "MANIFEST_FILE_READY",
653
+ manifest: {
654
+ builtAt: "t",
655
+ strict: false,
656
+ entries: { b1: { buildId: "b1", physicalTableName: "t1" } },
657
+ },
658
+ }),
659
+ ]);
660
+
661
+ const result = await (
662
+ ctx.service as unknown as {
663
+ getMostRecentManifestEntries: (
664
+ e: string,
665
+ p: string,
666
+ x: string,
667
+ ) => Promise<unknown>;
668
+ }
669
+ ).getMostRecentManifestEntries("env-1", "pkg", "self");
670
+
671
+ expect(result).toEqual({});
672
+ });
673
+ });
674
+
675
+ describe("stopMaterialization (in-flight)", () => {
676
+ let ctx: ReturnType<typeof createMocks>;
677
+ beforeEach(() => {
678
+ ctx = createMocks();
679
+ });
680
+
681
+ it("aborts a running build cooperatively without forcing a transition", async () => {
682
+ ctx.repository.getMaterializationById.resolves(
683
+ makeMaterialization({ status: "MANIFEST_ROWS_READY" }),
684
+ );
685
+ const controller = new AbortController();
686
+ (
687
+ ctx.service as unknown as {
688
+ runningAbortControllers: Map<string, AbortController>;
689
+ }
690
+ ).runningAbortControllers.set("mat-1", controller);
691
+
692
+ const result = await ctx.service.stopMaterialization(
693
+ "my-env",
694
+ "pkg",
695
+ "mat-1",
696
+ );
697
+
698
+ expect(controller.signal.aborted).toBe(true);
699
+ // The background run records the terminal state; stop must not also write.
700
+ expect(ctx.repository.updateMaterialization.called).toBe(false);
701
+ expect(result.status).toBe("MANIFEST_ROWS_READY");
702
+ });
703
+ });
704
+
705
+ describe("buildOneSource (characterization)", () => {
706
+ let ctx: ReturnType<typeof createMocks>;
707
+ beforeEach(() => {
708
+ ctx = createMocks();
709
+ });
710
+
711
+ function callBuildOneSource(
712
+ connection: { runSQL: sinon.SinonStub },
713
+ physicalTableName: string,
714
+ ): Promise<{ buildId: string; physicalTableName: string }> {
715
+ const source = fakeSource({
716
+ name: "orders",
717
+ buildId: "abcdef1234567890",
718
+ sql: "SELECT * FROM t",
719
+ });
720
+ const instruction: BuildInstruction = {
721
+ buildId: "abcdef1234567890",
722
+ materializedTableId: "mt-1",
723
+ physicalTableName,
724
+ realization: "COPY",
725
+ };
726
+ return (
727
+ ctx.service as unknown as {
728
+ buildOneSource: (
729
+ s: unknown,
730
+ i: BuildInstruction,
731
+ c: unknown,
732
+ d: Record<string, string>,
733
+ m: Manifest,
734
+ ) => Promise<{ buildId: string; physicalTableName: string }>;
735
+ }
736
+ ).buildOneSource(
737
+ source,
738
+ instruction,
739
+ connection,
740
+ { duckdb: "dig" },
741
+ new Manifest(),
742
+ );
743
+ }
744
+
745
+ it("stages, swaps, and renames in a crash-safe order", async () => {
746
+ const runSQL = sinon.stub().resolves();
747
+ const entry = await callBuildOneSource({ runSQL }, "orders_v1");
748
+
749
+ const sql = runSQL.getCalls().map((c) => c.args[0] as string);
750
+ expect(sql).toEqual([
751
+ "DROP TABLE IF EXISTS orders_v1_abcdef123456",
752
+ "CREATE TABLE orders_v1_abcdef123456 AS (SELECT * FROM t)",
753
+ "DROP TABLE IF EXISTS orders_v1",
754
+ "ALTER TABLE orders_v1_abcdef123456 RENAME TO orders_v1",
755
+ ]);
756
+ expect(entry.physicalTableName).toBe("orders_v1");
757
+ expect(entry.buildId).toBe("abcdef1234567890");
758
+ });
759
+
760
+ it("drops the staging table and rethrows when the build SQL fails", async () => {
761
+ const runSQL = sinon.stub();
762
+ runSQL.onCall(0).resolves(); // initial staging drop
763
+ runSQL.onCall(1).rejects(new Error("create boom")); // CREATE TABLE AS
764
+ runSQL.onCall(2).resolves(); // cleanup staging drop
765
+ await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
766
+ "create boom",
767
+ );
768
+ expect(runSQL.lastCall.args[0]).toBe(
769
+ "DROP TABLE IF EXISTS orders_v1_abcdef123456",
770
+ );
771
+ });
772
+ });
@@ -16,7 +16,9 @@ import {
16
16
  import { logger } from "../logger";
17
17
  import {
18
18
  MaterializationRound,
19
+ recordDropTables,
19
20
  recordMaterializationRound,
21
+ recordSourceBuildDuration,
20
22
  } from "../materialization_metrics";
21
23
  import {
22
24
  BuildInstruction,
@@ -77,6 +79,21 @@ function flattenDependsOn(node: {
77
79
  return node.dependsOn.map((d) => d.sourceID);
78
80
  }
79
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
+
80
97
  /**
81
98
  * Physical table name the publisher self-assigns in auto-run mode: the
82
99
  * `#@ persist name=<table>` value if present, else the Malloy source name.
@@ -193,6 +210,11 @@ export class MaterializationService {
193
210
  );
194
211
  }
195
212
  this.validateTransition(current.status, next);
213
+ logger.info("Materialization transition", {
214
+ materializationId: id,
215
+ from: current.status,
216
+ to: next,
217
+ });
196
218
  return this.repository.updateMaterialization(id, {
197
219
  status: next,
198
220
  ...extra,
@@ -435,25 +457,14 @@ export class MaterializationService {
435
457
  signal,
436
458
  );
437
459
 
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
460
  const durationMs = Date.now() - startedAt;
446
- await this.transition(id, "MANIFEST_FILE_READY", {
447
- completedAt: new Date(),
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
- },
461
+ await this.commitManifest(id, entries, {
462
+ forceRefresh,
463
+ sourceNames: sourceNames ?? null,
464
+ pauseBetweenPhases: false,
465
+ sourcesBuilt: instructions.length,
466
+ sourcesReused: Object.keys(carried).length,
467
+ durationMs,
457
468
  });
458
469
 
459
470
  await this.autoLoadManifest(environment, packageName, entries);
@@ -503,9 +514,9 @@ export class MaterializationService {
503
514
  if (!persistSource) continue;
504
515
  if (include && !include.has(persistSource.name)) continue;
505
516
 
506
- const buildId = persistSource.makeBuildId(
507
- compiled.connectionDigests[persistSource.connectionName],
508
- persistSource.getSQL(),
517
+ const buildId = computeBuildId(
518
+ persistSource,
519
+ compiled.connectionDigests,
509
520
  );
510
521
  if (seen.has(buildId)) continue;
511
522
  seen.add(buildId);
@@ -689,21 +700,10 @@ export class MaterializationService {
689
700
  signal,
690
701
  );
691
702
 
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
703
  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
- },
704
+ await this.commitManifest(id, entries, {
705
+ sourcesBuilt: Object.keys(entries).length,
706
+ durationMs,
707
707
  });
708
708
 
709
709
  this.recordRound("round2", "success", startedAt);
@@ -769,10 +769,7 @@ export class MaterializationService {
769
769
  const persistSource = sources[node.sourceID];
770
770
  if (!persistSource) continue;
771
771
 
772
- const buildId = persistSource.makeBuildId(
773
- connectionDigests[persistSource.connectionName],
774
- persistSource.getSQL(),
775
- );
772
+ const buildId = computeBuildId(persistSource, connectionDigests);
776
773
  const instruction = byBuildId.get(buildId);
777
774
  if (!instruction) continue;
778
775
 
@@ -845,9 +842,12 @@ export class MaterializationService {
845
842
  // Make this table visible to downstream sources built later this round.
846
843
  manifest.update(buildId, { tableName: physicalTableName });
847
844
 
848
- logger.info(`Round 2 built source ${persistSource.name}`, {
845
+ const durationMs = Math.round(performance.now() - startTime);
846
+ recordSourceBuildDuration(durationMs);
847
+ // Shared by auto-run and Round 2, so the message is path-neutral.
848
+ logger.info(`Built materialized source ${persistSource.name}`, {
849
849
  physicalTableName,
850
- durationMs: Math.round(performance.now() - startTime),
850
+ durationMs,
851
851
  });
852
852
 
853
853
  return {
@@ -980,12 +980,14 @@ export class MaterializationService {
980
980
  entry.buildId,
981
981
  )}`,
982
982
  );
983
+ recordDropTables("success");
983
984
  logger.info("Dropped materialized table on delete", {
984
985
  materializationId: m.id,
985
986
  physicalTableName,
986
987
  connectionName,
987
988
  });
988
989
  } catch (err) {
990
+ recordDropTables("failure");
989
991
  logger.warn("Failed to drop materialized table on delete", {
990
992
  materializationId: m.id,
991
993
  physicalTableName,
@@ -996,6 +998,29 @@ export class MaterializationService {
996
998
  }
997
999
  }
998
1000
 
1001
+ /**
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.
1005
+ */
1006
+ private async commitManifest(
1007
+ id: string,
1008
+ entries: Record<string, ManifestEntry>,
1009
+ metadata: Record<string, unknown>,
1010
+ ): Promise<void> {
1011
+ await this.transition(id, "MANIFEST_ROWS_READY");
1012
+ const manifest: BuildManifestResult = {
1013
+ builtAt: new Date().toISOString(),
1014
+ entries,
1015
+ strict: false,
1016
+ };
1017
+ await this.transition(id, "MANIFEST_FILE_READY", {
1018
+ completedAt: new Date(),
1019
+ manifest,
1020
+ metadata,
1021
+ });
1022
+ }
1023
+
999
1024
  // ==================== BUILD PLAN COMPILATION ====================
1000
1025
 
1001
1026
  /**
@@ -1098,10 +1123,7 @@ export class MaterializationService {
1098
1123
  sourceID: source.sourceID,
1099
1124
  connectionName: source.connectionName,
1100
1125
  dialect: source.dialectName,
1101
- buildId: source.makeBuildId(
1102
- connectionDigests[source.connectionName],
1103
- source.getSQL(),
1104
- ),
1126
+ buildId: computeBuildId(source, connectionDigests),
1105
1127
  sql: source.getSQL(),
1106
1128
  columns: deriveColumns(source),
1107
1129
  };
@@ -606,6 +606,7 @@ export class Model {
606
606
  _packagePath: string,
607
607
  malloyConfig: ModelConnectionInput,
608
608
  data: SerializedModel,
609
+ options?: { buildManifest?: BuildManifest["entries"] },
609
610
  ): Model {
610
611
  const modelDef = data.modelDef as ModelDef | undefined;
611
612
  const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
@@ -645,7 +646,10 @@ export class Model {
645
646
  );
646
647
  }
647
648
 
648
- const runtime = makeHydrationRuntime(malloyConfig);
649
+ const runtime = makeHydrationRuntime(
650
+ malloyConfig,
651
+ options?.buildManifest,
652
+ );
649
653
  const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
650
654
  const runnableNotebookCells =
651
655
  data.modelType === "notebook"
@@ -1921,6 +1925,7 @@ type HydrationMaterializer = ModelMaterializer & {
1921
1925
 
1922
1926
  function makeHydrationRuntime(
1923
1927
  malloyConfig: ModelConnectionInput,
1928
+ buildManifest?: BuildManifest["entries"],
1924
1929
  ): HydrationRuntime {
1925
1930
  const urlReader = new HackyDataStylesAccumulator(URL_READER);
1926
1931
  const config =
@@ -1933,7 +1938,19 @@ function makeHydrationRuntime(
1933
1938
  );
1934
1939
  return c;
1935
1940
  })();
1936
- return new Runtime({ urlReader, config }) as HydrationRuntime;
1941
+ // Thread the package's bound build manifest into the *serve* runtime. Malloy
1942
+ // substitutes a persisted source for its materialized table at query
1943
+ // (getSQL) time, gated on `prepareResultOptions.buildManifest`; without this
1944
+ // the hydrated model always recomputes from the base tables even though the
1945
+ // manifest was bound at load. `strict: false` keeps serving live for any
1946
+ // source whose buildId is absent from the manifest.
1947
+ return new Runtime({
1948
+ urlReader,
1949
+ config,
1950
+ buildManifest: buildManifest
1951
+ ? { entries: buildManifest, strict: false }
1952
+ : undefined,
1953
+ }) as HydrationRuntime;
1937
1954
  }
1938
1955
 
1939
1956
  /**