@malloy-publisher/server 0.0.210 → 0.0.212

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 (32) hide show
  1. package/dist/app/api-doc.yaml +20 -3
  2. package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
  3. package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
  4. package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
  8. package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
  10. package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
  11. package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
  12. package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
  13. package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
  14. package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +3 -0
  17. package/dist/server.mjs +160 -140
  18. package/package.json +1 -1
  19. package/src/materialization_metrics.spec.ts +84 -0
  20. package/src/materialization_metrics.ts +78 -119
  21. package/src/service/build_plan.spec.ts +152 -0
  22. package/src/service/build_plan.ts +81 -2
  23. package/src/service/materialization_service.spec.ts +186 -8
  24. package/src/service/materialization_service.ts +96 -83
  25. package/src/service/materialization_test_fixtures.ts +2 -0
  26. package/src/service/package.ts +14 -2
  27. package/src/service/persist_annotation_validation.spec.ts +77 -0
  28. package/src/service/persist_annotation_validation.ts +54 -0
  29. package/src/service/quoting.spec.ts +79 -0
  30. package/src/service/quoting.ts +40 -16
  31. package/src/utils.ts +5 -0
  32. package/dist/app/assets/HomePage-DQzgkiMI.js +0 -1
@@ -7,8 +7,8 @@
7
7
  * the run-duration histogram carries `mode` so a dashboard can render auto-run
8
8
  * vs orchestrated build latency side by side.
9
9
  *
10
- * Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
11
- * created before `setGlobalMeterProvider` bind to a NoOp meter
10
+ * Instruments are created lazily for the same reason as {@link ./query_cap_metrics}:
11
+ * one created before `setGlobalMeterProvider` binds to a NoOp meter
12
12
  * (https://github.com/open-telemetry/opentelemetry-js/issues/3505).
13
13
  */
14
14
 
@@ -19,49 +19,83 @@ export type MaterializationOutcome = "success" | "failed" | "cancelled";
19
19
  /** Manifest bind outcome: timeout is split out from generic failure on purpose. */
20
20
  export type ManifestBindOutcome = "success" | "failure" | "timeout";
21
21
 
22
- let runCounter: Counter | null = null;
23
- let runDuration: Histogram | null = null;
24
- let sourcesCounter: Counter | null = null;
25
- let buildPlanComputeDuration: Histogram | null = null;
26
- let autoLoadCounter: Counter | null = null;
27
- let connectionDigestSkipCounter: Counter | null = null;
28
- let manifestBindCounter: Counter | null = null;
29
- let sourceBuildDuration: Histogram | null = null;
30
- let dropTablesCounter: Counter | null = null;
22
+ const resetHooks: (() => void)[] = [];
31
23
 
32
- function ensureTelemetry(): { counter: Counter; duration: Histogram } {
33
- if (runCounter && runDuration) {
34
- return { counter: runCounter, duration: runDuration };
35
- }
36
- const meter = metrics.getMeter("publisher");
37
- if (!runCounter) {
38
- runCounter = meter.createCounter("publisher_materialization_runs_total", {
39
- description:
40
- "Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'failed'|'cancelled').",
41
- });
42
- }
43
- if (!runDuration) {
44
- runDuration = meter.createHistogram(
45
- "publisher_materialization_run_duration_ms",
46
- {
47
- description:
48
- "Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
49
- unit: "ms",
50
- },
51
- );
52
- }
53
- return { counter: runCounter, duration: runDuration };
24
+ /**
25
+ * A lazily-created instrument: built from the global meter on first use (so it
26
+ * binds to the real MeterProvider, not the NoOp default) and memoized. Each
27
+ * registers a reset hook so a test can swap in a fresh MeterProvider.
28
+ */
29
+ function lazyCounter(name: string, description: string): () => Counter {
30
+ let instrument: Counter | null = null;
31
+ resetHooks.push(() => (instrument = null));
32
+ return () =>
33
+ (instrument ??= metrics
34
+ .getMeter("publisher")
35
+ .createCounter(name, { description }));
54
36
  }
55
37
 
38
+ function lazyHistogram(
39
+ name: string,
40
+ description: string,
41
+ unit: string,
42
+ ): () => Histogram {
43
+ let instrument: Histogram | null = null;
44
+ resetHooks.push(() => (instrument = null));
45
+ return () =>
46
+ (instrument ??= metrics
47
+ .getMeter("publisher")
48
+ .createHistogram(name, { description, unit }));
49
+ }
50
+
51
+ const runCounter = lazyCounter(
52
+ "publisher_materialization_runs_total",
53
+ "Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'failed'|'cancelled').",
54
+ );
55
+ const runDuration = lazyHistogram(
56
+ "publisher_materialization_run_duration_ms",
57
+ "Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
58
+ "ms",
59
+ );
60
+ const sourcesCounter = lazyCounter(
61
+ "publisher_materialization_sources_total",
62
+ "Persist sources processed by a materialization run. Label: outcome ('built'|'reused').",
63
+ );
64
+ const buildPlanComputeDuration = lazyHistogram(
65
+ "publisher_materialization_build_plan_compute_duration_ms",
66
+ "Wall-clock duration of compiling a package's build plan (Package.buildPlan).",
67
+ "ms",
68
+ );
69
+ const autoLoadCounter = lazyCounter(
70
+ "publisher_materialization_auto_load_total",
71
+ "Auto-run manifest auto-load attempts. Label: outcome ('success'|'failure').",
72
+ );
73
+ const connectionDigestSkipCounter = lazyCounter(
74
+ "publisher_materialization_connection_digest_skipped_total",
75
+ "Connection digests skipped during build-plan compile because the connection did not resolve.",
76
+ );
77
+ const manifestBindCounter = lazyCounter(
78
+ "publisher_materialization_manifest_bind_total",
79
+ "Manifest bind attempts. Label: outcome ('success'|'failure'|'timeout').",
80
+ );
81
+ const sourceBuildDuration = lazyHistogram(
82
+ "publisher_materialization_source_build_duration_ms",
83
+ "Wall-clock duration of building a single persist source.",
84
+ "ms",
85
+ );
86
+ const dropTablesCounter = lazyCounter(
87
+ "publisher_materialization_drop_tables_total",
88
+ "Physical tables dropped on delete. Label: outcome ('success'|'failure').",
89
+ );
90
+
56
91
  /** Record the outcome and duration of a materialization build. */
57
92
  export function recordMaterializationRun(
58
93
  mode: MaterializationMode,
59
94
  outcome: MaterializationOutcome,
60
95
  durationMs: number,
61
96
  ): void {
62
- const { counter, duration } = ensureTelemetry();
63
- counter.add(1, { mode, outcome });
64
- duration.record(durationMs, { mode });
97
+ runCounter().add(1, { mode, outcome });
98
+ runDuration().record(durationMs, { mode });
65
99
  }
66
100
 
67
101
  /**
@@ -74,15 +108,7 @@ export function recordSourcesOutcome(
74
108
  count: number,
75
109
  ): void {
76
110
  if (count <= 0) return;
77
- if (!sourcesCounter) {
78
- sourcesCounter = metrics
79
- .getMeter("publisher")
80
- .createCounter("publisher_materialization_sources_total", {
81
- description:
82
- "Persist sources processed by a materialization run. Label: outcome ('built'|'reused').",
83
- });
84
- }
85
- sourcesCounter.add(count, { outcome });
111
+ sourcesCounter().add(count, { outcome });
86
112
  }
87
113
 
88
114
  /**
@@ -91,19 +117,7 @@ export function recordSourcesOutcome(
91
117
  * tracking as a discrete cost separate from the build itself.
92
118
  */
93
119
  export function recordBuildPlanComputeDuration(durationMs: number): void {
94
- if (!buildPlanComputeDuration) {
95
- buildPlanComputeDuration = metrics
96
- .getMeter("publisher")
97
- .createHistogram(
98
- "publisher_materialization_build_plan_compute_duration_ms",
99
- {
100
- description:
101
- "Wall-clock duration of compiling a package's build plan (Package.buildPlan).",
102
- unit: "ms",
103
- },
104
- );
105
- }
106
- buildPlanComputeDuration.record(durationMs);
120
+ buildPlanComputeDuration().record(durationMs);
107
121
  }
108
122
 
109
123
  /**
@@ -112,15 +126,7 @@ export function recordBuildPlanComputeDuration(durationMs: number): void {
112
126
  * the run is MANIFEST_FILE_READY but queries still resolve to the old tables.
113
127
  */
114
128
  export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
115
- if (!autoLoadCounter) {
116
- autoLoadCounter = metrics
117
- .getMeter("publisher")
118
- .createCounter("publisher_materialization_auto_load_total", {
119
- description:
120
- "Auto-run manifest auto-load attempts. Label: outcome ('success'|'failure').",
121
- });
122
- }
123
- autoLoadCounter.add(1, { outcome });
129
+ autoLoadCounter().add(1, { outcome });
124
130
  }
125
131
 
126
132
  /**
@@ -129,18 +135,7 @@ export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
129
135
  * are computed without a digest, so this is a correctness signal, not just noise.
130
136
  */
131
137
  export function recordConnectionDigestSkipped(): void {
132
- if (!connectionDigestSkipCounter) {
133
- connectionDigestSkipCounter = metrics
134
- .getMeter("publisher")
135
- .createCounter(
136
- "publisher_materialization_connection_digest_skipped_total",
137
- {
138
- description:
139
- "Connection digests skipped during build-plan compile because the connection did not resolve.",
140
- },
141
- );
142
- }
143
- connectionDigestSkipCounter.add(1);
138
+ connectionDigestSkipCounter().add(1);
144
139
  }
145
140
 
146
141
  /**
@@ -149,56 +144,20 @@ export function recordConnectionDigestSkipped(): void {
149
144
  * tell an unreachable/slow manifest store from a malformed manifest.
150
145
  */
151
146
  export function recordManifestBind(outcome: ManifestBindOutcome): void {
152
- if (!manifestBindCounter) {
153
- manifestBindCounter = metrics
154
- .getMeter("publisher")
155
- .createCounter("publisher_materialization_manifest_bind_total", {
156
- description:
157
- "Manifest bind attempts. Label: outcome ('success'|'failure'|'timeout').",
158
- });
159
- }
160
- manifestBindCounter.add(1, { outcome });
147
+ manifestBindCounter().add(1, { outcome });
161
148
  }
162
149
 
163
150
  /** Record the wall-clock duration of building one persist source's table. */
164
151
  export function recordSourceBuildDuration(durationMs: number): void {
165
- if (!sourceBuildDuration) {
166
- sourceBuildDuration = metrics
167
- .getMeter("publisher")
168
- .createHistogram(
169
- "publisher_materialization_source_build_duration_ms",
170
- {
171
- description:
172
- "Wall-clock duration of building a single persist source.",
173
- unit: "ms",
174
- },
175
- );
176
- }
177
- sourceBuildDuration.record(durationMs);
152
+ sourceBuildDuration().record(durationMs);
178
153
  }
179
154
 
180
155
  /** Record a best-effort physical-table drop on materialization delete. */
181
156
  export function recordDropTables(outcome: "success" | "failure"): void {
182
- if (!dropTablesCounter) {
183
- dropTablesCounter = metrics
184
- .getMeter("publisher")
185
- .createCounter("publisher_materialization_drop_tables_total", {
186
- description:
187
- "Physical tables dropped on delete. Label: outcome ('success'|'failure').",
188
- });
189
- }
190
- dropTablesCounter.add(1, { outcome });
157
+ dropTablesCounter().add(1, { outcome });
191
158
  }
192
159
 
193
160
  /** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
194
161
  export function resetMaterializationTelemetryForTesting(): void {
195
- runCounter = null;
196
- runDuration = null;
197
- sourcesCounter = null;
198
- buildPlanComputeDuration = null;
199
- autoLoadCounter = null;
200
- connectionDigestSkipCounter = null;
201
- manifestBindCounter = null;
202
- sourceBuildDuration = null;
203
- dropTablesCounter = null;
162
+ for (const reset of resetHooks) reset();
204
163
  }
@@ -1,14 +1,19 @@
1
1
  import type { PersistSource } from "@malloydata/malloy";
2
2
  import { describe, expect, it } from "bun:test";
3
3
  import * as sinon from "sinon";
4
+ import type { BuildGraph as MalloyBuildGraph } from "@malloydata/malloy";
4
5
  import {
6
+ compilePackageBuildPlan,
5
7
  computeBuildId,
6
8
  computePackageBuildPlan,
9
+ deriveAnnotationFields,
7
10
  deriveBuildPlan,
8
11
  flattenDependsOn,
12
+ iterGraphSources,
9
13
  resolvePackageConnections,
10
14
  } from "./build_plan";
11
15
  import { fakeSource } from "./materialization_test_fixtures";
16
+ import { Model } from "./model";
12
17
 
13
18
  describe("flattenDependsOn", () => {
14
19
  it("maps nested dependsOn entries to a flat sourceID list", () => {
@@ -20,6 +25,130 @@ describe("flattenDependsOn", () => {
20
25
  });
21
26
  });
22
27
 
28
+ describe("iterGraphSources", () => {
29
+ it("yields resolvable sources in dependency order, skipping missing ones", () => {
30
+ const a = fakeSource({ name: "a", buildId: "ba" });
31
+ const b = fakeSource({ name: "b", buildId: "bb" });
32
+ const graph = {
33
+ connectionName: "duckdb",
34
+ nodes: [
35
+ [{ sourceID: "a@m", dependsOn: [] }],
36
+ [
37
+ { sourceID: "missing@m", dependsOn: [] },
38
+ { sourceID: "b@m", dependsOn: [] },
39
+ ],
40
+ ],
41
+ } as unknown as MalloyBuildGraph;
42
+
43
+ const names = [...iterGraphSources(graph, { "a@m": a, "b@m": b })].map(
44
+ (s) => s.name,
45
+ );
46
+ expect(names).toEqual(["a", "b"]);
47
+ });
48
+
49
+ it("walks each root's nested dependsOn tree, deps before dependents", () => {
50
+ // root -> mid -> leaf, with only `root` at the graph's node level — this
51
+ // mirrors malloy getBuildPlan(): terminal persist sources are the nodes,
52
+ // and every transitive persist dependency is nested in dependsOn. All
53
+ // three must be yielded (so all get built), leaf-first so a downstream
54
+ // build reads its upstream's freshly materialized table.
55
+ const root = fakeSource({ name: "root", buildId: "br" });
56
+ const mid = fakeSource({ name: "mid", buildId: "bm" });
57
+ const leaf = fakeSource({ name: "leaf", buildId: "bl" });
58
+ const graph = {
59
+ connectionName: "duckdb",
60
+ nodes: [
61
+ [
62
+ {
63
+ sourceID: "root@m",
64
+ dependsOn: [
65
+ {
66
+ sourceID: "mid@m",
67
+ dependsOn: [{ sourceID: "leaf@m", dependsOn: [] }],
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ ],
73
+ } as unknown as MalloyBuildGraph;
74
+
75
+ const names = [
76
+ ...iterGraphSources(graph, {
77
+ "root@m": root,
78
+ "mid@m": mid,
79
+ "leaf@m": leaf,
80
+ }),
81
+ ].map((s) => s.name);
82
+ expect(names).toEqual(["leaf", "mid", "root"]);
83
+ });
84
+
85
+ it("deduplicates a shared (diamond) dependency across roots", () => {
86
+ // r1 and r2 both depend on `shared`; it must be yielded exactly once and
87
+ // before both dependents.
88
+ const r1 = fakeSource({ name: "r1", buildId: "b1" });
89
+ const r2 = fakeSource({ name: "r2", buildId: "b2" });
90
+ const shared = fakeSource({ name: "shared", buildId: "bs" });
91
+ const graph = {
92
+ connectionName: "duckdb",
93
+ nodes: [
94
+ [
95
+ {
96
+ sourceID: "r1@m",
97
+ dependsOn: [{ sourceID: "shared@m", dependsOn: [] }],
98
+ },
99
+ {
100
+ sourceID: "r2@m",
101
+ dependsOn: [{ sourceID: "shared@m", dependsOn: [] }],
102
+ },
103
+ ],
104
+ ],
105
+ } as unknown as MalloyBuildGraph;
106
+
107
+ const names = [
108
+ ...iterGraphSources(graph, {
109
+ "r1@m": r1,
110
+ "r2@m": r2,
111
+ "shared@m": shared,
112
+ }),
113
+ ].map((s) => s.name);
114
+ expect(names).toEqual(["shared", "r1", "r2"]);
115
+ });
116
+ });
117
+
118
+ describe("deriveAnnotationFields", () => {
119
+ it("returns all key=value fields of the #@ persist annotation", () => {
120
+ const source = {
121
+ annotations: {
122
+ parseAsTag: () => ({
123
+ tag: {
124
+ *entries() {
125
+ yield ["name", { text: () => "engaged_events" }];
126
+ yield ["realization", { text: () => "COPY" }];
127
+ },
128
+ },
129
+ }),
130
+ },
131
+ } as unknown as PersistSource;
132
+
133
+ expect(deriveAnnotationFields(source)).toEqual({
134
+ name: "engaged_events",
135
+ realization: "COPY",
136
+ });
137
+ });
138
+
139
+ it("degrades to {} when the annotation is absent or unparseable", () => {
140
+ const source = {
141
+ annotations: {
142
+ parseAsTag: () => {
143
+ throw new Error("no @ annotation");
144
+ },
145
+ },
146
+ } as unknown as PersistSource;
147
+
148
+ expect(deriveAnnotationFields(source)).toEqual({});
149
+ });
150
+ });
151
+
23
152
  describe("computeBuildId", () => {
24
153
  it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
25
154
  const makeBuildId = sinon.stub().returns("computed-id");
@@ -102,6 +231,29 @@ describe("deriveBuildPlan", () => {
102
231
  });
103
232
  });
104
233
 
234
+ describe("compilePackageBuildPlan", () => {
235
+ it("skips .malloynb notebooks without compiling them", async () => {
236
+ // A notebook would throw on its `>>>` cell delimiter if compiled as a
237
+ // flat model, aborting the whole package plan; it must be skipped.
238
+ const getModelRuntime = sinon.stub(Model, "getModelRuntime");
239
+ try {
240
+ const pkg = {
241
+ getModelPaths: () => ["notes.malloynb"],
242
+ getPackagePath: () => "/test",
243
+ getMalloyConfig: () => ({}),
244
+ getMalloyConnection: async () => ({}),
245
+ } as unknown as Parameters<typeof compilePackageBuildPlan>[0];
246
+
247
+ const compiled = await compilePackageBuildPlan(pkg);
248
+
249
+ expect(compiled.graphs).toEqual([]);
250
+ expect(getModelRuntime.called).toBe(false);
251
+ } finally {
252
+ getModelRuntime.restore();
253
+ }
254
+ });
255
+ });
256
+
105
257
  describe("computePackageBuildPlan", () => {
106
258
  it("returns null when the package declares no persist sources", async () => {
107
259
  const pkg = {
@@ -1,13 +1,16 @@
1
1
  import type {
2
2
  AtomicField,
3
3
  BuildGraph as MalloyBuildGraph,
4
+ BuildNode,
4
5
  MalloyConfig,
5
6
  Connection as MalloyConnection,
6
7
  PersistSource,
7
8
  } from "@malloydata/malloy";
8
9
  import { components } from "../api";
10
+ import { MODEL_FILE_SUFFIX } from "../constants";
9
11
  import { logger } from "../logger";
10
12
  import { recordConnectionDigestSkipped } from "../materialization_metrics";
13
+ import { errMessage } from "../utils";
11
14
  import { Model } from "./model";
12
15
 
13
16
  type WireBuildGraph = components["schemas"]["BuildGraph"];
@@ -53,12 +56,37 @@ export function deriveColumns(persistSource: PersistSource): WireColumn[] {
53
56
  } catch (err) {
54
57
  logger.warn("Failed to derive columns for persist source", {
55
58
  sourceID: persistSource.sourceID,
56
- error: err instanceof Error ? err.message : String(err),
59
+ error: errMessage(err),
57
60
  });
58
61
  return [];
59
62
  }
60
63
  }
61
64
 
65
+ /**
66
+ * All key=value fields of a source's `#@ persist` annotation (e.g.
67
+ * `{ name: "engaged_events", realization: "COPY" }`), degrading to {} if the
68
+ * annotation is absent or unparseable. The control plane consumes these — most
69
+ * importantly `name`, which it uses as the materialized table name (and which
70
+ * may carry a dialect-style container path like `dataset.table`). Returning the
71
+ * full set rather than a fixed subset means new persist directives flow through
72
+ * without a publisher change.
73
+ */
74
+ export function deriveAnnotationFields(
75
+ persistSource: PersistSource,
76
+ ): Record<string, string> {
77
+ const out: Record<string, string> = {};
78
+ try {
79
+ const tag = persistSource.annotations.parseAsTag("@").tag;
80
+ for (const [key, value] of tag.entries()) {
81
+ const text = value.text();
82
+ if (text !== undefined) out[key] = text;
83
+ }
84
+ } catch {
85
+ // Degrade to {} — mirrors deriveColumns / selfAssignTableName.
86
+ }
87
+ return out;
88
+ }
89
+
62
90
  /** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
63
91
  export function flattenDependsOn(node: {
64
92
  dependsOn: { sourceID: string }[];
@@ -66,6 +94,51 @@ export function flattenDependsOn(node: {
66
94
  return node.dependsOn.map((d) => d.sourceID);
67
95
  }
68
96
 
97
+ /**
98
+ * Yield every persist source of one graph in dependency order — each source's
99
+ * persist dependencies before the source itself — resolving each node's
100
+ * sourceID against the sources map and skipping nodes whose source is absent.
101
+ * Centralizes the graph→source walk shared by the planning and build loops.
102
+ *
103
+ * <p>Malloy's {@code getBuildPlan()} puts only the <em>root</em> persist sources
104
+ * (the terminals nothing else consumes) in {@code graph.nodes}; every transitive
105
+ * persist dependency is nested under a root in its recursive {@code dependsOn}
106
+ * tree (and present in {@code sources}). Walking only {@code graph.nodes} —
107
+ * which this used to do — therefore silently skips every intermediate persist
108
+ * source, so it never gets materialized (it only got a table by coincidence when
109
+ * it shared a buildId with a root). We post-order DFS the {@code dependsOn} tree
110
+ * so dependencies are built first (a downstream build can then read its upstream
111
+ * source's freshly materialized table), deduplicating shared (diamond)
112
+ * dependencies by sourceID so each is yielded once. This mirrors the canonical
113
+ * malloy-cli reference build loop (its {@code flattenBuildNodes} +
114
+ * dedup-by-sourceID; see malloydata/malloy-cli src/malloy/build_graph.ts).
115
+ */
116
+ export function* iterGraphSources(
117
+ graph: MalloyBuildGraph,
118
+ sources: Record<string, PersistSource>,
119
+ ): Iterable<PersistSource> {
120
+ const seen = new Set<string>();
121
+
122
+ function* visit(node: BuildNode): Iterable<PersistSource> {
123
+ if (seen.has(node.sourceID)) return;
124
+ // Dependencies first: a source built later in the run resolves its
125
+ // upstream references against the tables built earlier (see the Manifest
126
+ // threading in executeInstructedBuild).
127
+ for (const dep of node.dependsOn) {
128
+ yield* visit(dep);
129
+ }
130
+ seen.add(node.sourceID);
131
+ const source = sources[node.sourceID];
132
+ if (source) yield source;
133
+ }
134
+
135
+ for (const level of graph.nodes) {
136
+ for (const node of level) {
137
+ yield* visit(node);
138
+ }
139
+ }
140
+ }
141
+
69
142
  /**
70
143
  * The buildId for a persist source: a stable digest of its connection identity
71
144
  * and canonical SQL. Centralizes the (source, connectionDigests) call shape so
@@ -100,7 +173,7 @@ export async function resolvePackageConnections(
100
173
  map.set(name, await pkg.getMalloyConnection(name));
101
174
  } catch (err) {
102
175
  logger.warn(`Failed to resolve connection ${name}`, {
103
- error: err instanceof Error ? err.message : String(err),
176
+ error: errMessage(err),
104
177
  });
105
178
  }
106
179
  }
@@ -121,6 +194,11 @@ export async function compilePackageBuildPlan(
121
194
  const allSources: Record<string, PersistSource> = {};
122
195
 
123
196
  for (const modelPath of pkg.getModelPaths()) {
197
+ // Only `.malloy` models declare persist sources. Skip `.malloynb`
198
+ // notebooks: getModel() parses a model file as a flat model and throws on
199
+ // the notebook's `>>>` cell delimiter, which would abort the entire
200
+ // package build plan and silently drop every persist source in it.
201
+ if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) continue;
124
202
  if (signal?.aborted) throw new Error("Build cancelled");
125
203
 
126
204
  const { runtime, modelURL, importBaseURL } = await Model.getModelRuntime(
@@ -211,6 +289,7 @@ export function deriveBuildPlan(
211
289
  buildId: computeBuildId(source, connectionDigests),
212
290
  sql: source.getSQL(),
213
291
  columns: deriveColumns(source),
292
+ annotationFields: deriveAnnotationFields(source),
214
293
  };
215
294
  }
216
295