@malloy-publisher/server 0.0.208 → 0.0.210

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 (41) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
  12. package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
  13. package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +5986 -470
  17. package/package.json +2 -1
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/materialization_metrics.ts +117 -34
  21. package/src/server-old.ts +2 -11
  22. package/src/server.ts +2 -10
  23. package/src/service/build_plan.spec.ts +116 -0
  24. package/src/service/build_plan.ts +238 -0
  25. package/src/service/connection.ts +4 -0
  26. package/src/service/connection_config.spec.ts +182 -1
  27. package/src/service/connection_config.ts +70 -0
  28. package/src/service/db_utils.spec.ts +159 -1
  29. package/src/service/db_utils.ts +131 -0
  30. package/src/service/materialization_service.spec.ts +388 -184
  31. package/src/service/materialization_service.ts +156 -442
  32. package/src/service/materialization_test_fixtures.ts +119 -0
  33. package/src/service/model.ts +25 -25
  34. package/src/service/package.ts +46 -6
  35. package/src/service/package_worker_path.spec.ts +65 -55
  36. package/src/storage/DatabaseInterface.ts +5 -13
  37. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  38. package/src/storage/duckdb/schema.ts +4 -5
  39. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  40. package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
  41. package/dist/app/assets/MaterializationsPage-BYnr56IV.js +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.208",
4
+ "version": "0.0.210",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -41,6 +41,7 @@
41
41
  "@malloydata/db-duckdb": "^0.0.415",
42
42
  "@malloydata/db-mysql": "^0.0.415",
43
43
  "@malloydata/db-postgres": "^0.0.415",
44
+ "@malloydata/db-publisher": "^0.0.415",
44
45
  "@malloydata/db-snowflake": "^0.0.415",
45
46
  "@malloydata/db-trino": "^0.0.415",
46
47
  "@malloydata/malloy": "^0.0.415",
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as sinon from "sinon";
3
+ import { BadRequestError } from "../errors";
4
+ import type { MaterializationService } from "../service/materialization_service";
5
+ import { MaterializationController } from "./materialization.controller";
6
+
7
+ /**
8
+ * Unit tests for {@link MaterializationController.createMaterialization}'s body
9
+ * validation. The service is stubbed so each test asserts the parsed options
10
+ * the controller forwards (or the rejection for a malformed body).
11
+ */
12
+ function build() {
13
+ const createMaterialization = sinon.stub().resolves({ id: "m1" });
14
+ const service = {
15
+ createMaterialization,
16
+ } as unknown as MaterializationService;
17
+ const controller = new MaterializationController(service);
18
+ return { controller, createMaterialization };
19
+ }
20
+
21
+ /** Parsed options the controller forwards for a given request body. */
22
+ async function parse(body: Record<string, unknown>) {
23
+ const { controller, createMaterialization } = build();
24
+ await controller.createMaterialization("env", "pkg", body);
25
+ return createMaterialization.firstCall.args[2];
26
+ }
27
+
28
+ describe("MaterializationController.createMaterialization validation", () => {
29
+ it("forwards an empty body as empty options", async () => {
30
+ expect(await parse({})).toEqual({});
31
+ });
32
+
33
+ it("passes through forceRefresh and sourceNames", async () => {
34
+ expect(
35
+ await parse({ forceRefresh: true, sourceNames: ["a", "b"] }),
36
+ ).toEqual({ forceRefresh: true, sourceNames: ["a", "b"] });
37
+ });
38
+
39
+ it("rejects a non-boolean forceRefresh", async () => {
40
+ const { controller } = build();
41
+ await expect(
42
+ controller.createMaterialization("env", "pkg", {
43
+ forceRefresh: "yes",
44
+ }),
45
+ ).rejects.toThrow(BadRequestError);
46
+ });
47
+
48
+ it("rejects sourceNames that is not an array of strings", async () => {
49
+ const { controller } = build();
50
+ await expect(
51
+ controller.createMaterialization("env", "pkg", {
52
+ sourceNames: [1, 2],
53
+ }),
54
+ ).rejects.toThrow(BadRequestError);
55
+ });
56
+
57
+ it("flattens buildInstructions.sources into the instruction list", async () => {
58
+ const parsed = await parse({
59
+ buildInstructions: {
60
+ sources: [
61
+ {
62
+ buildId: "b1",
63
+ sourceID: "orders@m",
64
+ materializedTableId: "mt-1",
65
+ physicalTableName: "orders_v1",
66
+ realization: "COPY",
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ expect(parsed).toEqual({
72
+ buildInstructions: [
73
+ {
74
+ buildId: "b1",
75
+ sourceID: "orders@m",
76
+ materializedTableId: "mt-1",
77
+ physicalTableName: "orders_v1",
78
+ realization: "COPY",
79
+ },
80
+ ],
81
+ });
82
+ });
83
+
84
+ it("treats null buildInstructions as absent (auto-run)", async () => {
85
+ expect(await parse({ buildInstructions: null })).toEqual({});
86
+ });
87
+
88
+ it("rejects buildInstructions without a non-empty sources array", async () => {
89
+ const { controller } = build();
90
+ await expect(
91
+ controller.createMaterialization("env", "pkg", {
92
+ buildInstructions: { sources: [] },
93
+ }),
94
+ ).rejects.toThrow(BadRequestError);
95
+ });
96
+
97
+ it("rejects an instruction missing a required field", async () => {
98
+ const { controller } = build();
99
+ await expect(
100
+ controller.createMaterialization("env", "pkg", {
101
+ buildInstructions: {
102
+ sources: [{ buildId: "b1", materializedTableId: "mt-1" }],
103
+ },
104
+ }),
105
+ ).rejects.toThrow(BadRequestError);
106
+ });
107
+
108
+ it("rejects an unrecognized realization", async () => {
109
+ const { controller } = build();
110
+ await expect(
111
+ controller.createMaterialization("env", "pkg", {
112
+ buildInstructions: {
113
+ sources: [
114
+ {
115
+ buildId: "b1",
116
+ materializedTableId: "mt-1",
117
+ physicalTableName: "orders_v1",
118
+ realization: "MERGE",
119
+ },
120
+ ],
121
+ },
122
+ }),
123
+ ).rejects.toThrow(BadRequestError);
124
+ });
125
+ });
@@ -20,18 +20,20 @@ export class MaterializationController {
20
20
  private validateCreateBody(body: Record<string, unknown>): {
21
21
  forceRefresh?: boolean;
22
22
  sourceNames?: string[];
23
- pauseBetweenPhases?: boolean;
23
+ buildInstructions?: BuildInstruction[];
24
24
  } {
25
25
  const result: {
26
26
  forceRefresh?: boolean;
27
27
  sourceNames?: string[];
28
- pauseBetweenPhases?: boolean;
28
+ buildInstructions?: BuildInstruction[];
29
29
  } = {};
30
- if (body.pauseBetweenPhases !== undefined) {
31
- if (typeof body.pauseBetweenPhases !== "boolean") {
32
- throw new BadRequestError("pauseBetweenPhases must be a boolean");
33
- }
34
- result.pauseBetweenPhases = body.pauseBetweenPhases;
30
+ if (
31
+ body.buildInstructions !== undefined &&
32
+ body.buildInstructions !== null
33
+ ) {
34
+ result.buildInstructions = this.validateBuildInstructions(
35
+ body.buildInstructions,
36
+ );
35
37
  }
36
38
  if (body.forceRefresh !== undefined) {
37
39
  if (typeof body.forceRefresh !== "boolean") {
@@ -53,30 +55,24 @@ export class MaterializationController {
53
55
  return result;
54
56
  }
55
57
 
56
- async buildMaterialization(
57
- environmentName: string,
58
- packageName: string,
59
- materializationId: string,
60
- body: Record<string, unknown>,
61
- ) {
62
- return this.materializationService.buildMaterialization(
63
- environmentName,
64
- packageName,
65
- materializationId,
66
- this.validateBuildBody(body),
67
- );
68
- }
69
-
70
- private validateBuildBody(
71
- body: Record<string, unknown>,
72
- ): BuildInstruction[] {
73
- const sources = body.sources;
58
+ /**
59
+ * Validate the orchestrated `buildInstructions` payload (BuildInstructions:
60
+ * `{ sources: BuildInstruction[] }`) and flatten it to the instruction list
61
+ * the service consumes.
62
+ */
63
+ private validateBuildInstructions(raw: unknown): BuildInstruction[] {
64
+ if (typeof raw !== "object" || raw === null) {
65
+ throw new BadRequestError("buildInstructions must be an object");
66
+ }
67
+ const sources = (raw as Record<string, unknown>).sources;
74
68
  if (!Array.isArray(sources) || sources.length === 0) {
75
69
  throw new BadRequestError(
76
- "build requires a non-empty 'sources' array of BuildInstruction",
70
+ "buildInstructions requires a non-empty 'sources' array of BuildInstruction",
77
71
  );
78
72
  }
79
- return sources.map((raw) => this.validateInstruction(raw));
73
+ return sources.map((instruction) =>
74
+ this.validateInstruction(instruction),
75
+ );
80
76
  }
81
77
 
82
78
  private validateInstruction(raw: unknown): BuildInstruction {
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Centralized telemetry for the two-round materialization protocol.
2
+ * Centralized telemetry for materialization builds.
3
3
  *
4
- * Operators need to answer "are builds completing, how long do the two
5
- * rounds take, and where are they failing?" without grepping logs. The
6
- * counter carries `round` (`round1`|`round2`) and `outcome`
7
- * (`success`|`failed`|`cancelled`); the histogram carries `round` so a
8
- * dashboard can render Round 1 (compile+plan) vs Round 2 (build) latency
9
- * side by side.
4
+ * Operators need to answer "are builds completing, how long do they take, and
5
+ * where are they failing?" without grepping logs. The run counter carries
6
+ * `mode` (`auto`|`orchestrated`) and `outcome` (`success`|`failed`|`cancelled`);
7
+ * the run-duration histogram carries `mode` so a dashboard can render auto-run
8
+ * vs orchestrated build latency side by side.
10
9
  *
11
10
  * Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
12
11
  * created before `setGlobalMeterProvider` bind to a NoOp meter
@@ -15,58 +14,138 @@
15
14
 
16
15
  import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
17
16
 
18
- export type MaterializationRound = "round1" | "round2" | "auto";
17
+ export type MaterializationMode = "auto" | "orchestrated";
19
18
  export type MaterializationOutcome = "success" | "failed" | "cancelled";
20
19
  /** Manifest bind outcome: timeout is split out from generic failure on purpose. */
21
20
  export type ManifestBindOutcome = "success" | "failure" | "timeout";
22
21
 
23
- let roundCounter: Counter | null = null;
24
- let roundDuration: Histogram | null = null;
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;
25
28
  let manifestBindCounter: Counter | null = null;
26
29
  let sourceBuildDuration: Histogram | null = null;
27
30
  let dropTablesCounter: Counter | null = null;
28
31
 
29
32
  function ensureTelemetry(): { counter: Counter; duration: Histogram } {
30
- if (roundCounter && roundDuration) {
31
- return { counter: roundCounter, duration: roundDuration };
33
+ if (runCounter && runDuration) {
34
+ return { counter: runCounter, duration: runDuration };
32
35
  }
33
36
  const meter = metrics.getMeter("publisher");
34
- if (!roundCounter) {
35
- roundCounter = meter.createCounter(
36
- "publisher_materialization_rounds_total",
37
- {
38
- description:
39
- "Materialization rounds completed. Labels: round ('round1'|'round2'|'auto'), outcome ('success'|'failed'|'cancelled').",
40
- },
41
- );
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
42
  }
43
- if (!roundDuration) {
44
- roundDuration = meter.createHistogram(
45
- "publisher_materialization_round_duration_ms",
43
+ if (!runDuration) {
44
+ runDuration = meter.createHistogram(
45
+ "publisher_materialization_run_duration_ms",
46
46
  {
47
47
  description:
48
- "Wall-clock duration of a materialization round. Label: round ('round1'|'round2'|'auto').",
48
+ "Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
49
49
  unit: "ms",
50
50
  },
51
51
  );
52
52
  }
53
- return { counter: roundCounter, duration: roundDuration };
53
+ return { counter: runCounter, duration: runDuration };
54
54
  }
55
55
 
56
- /** Record the outcome and duration of a materialization round. */
57
- export function recordMaterializationRound(
58
- round: MaterializationRound,
56
+ /** Record the outcome and duration of a materialization build. */
57
+ export function recordMaterializationRun(
58
+ mode: MaterializationMode,
59
59
  outcome: MaterializationOutcome,
60
60
  durationMs: number,
61
61
  ): void {
62
62
  const { counter, duration } = ensureTelemetry();
63
- counter.add(1, { round, outcome });
64
- duration.record(durationMs, { round });
63
+ counter.add(1, { mode, outcome });
64
+ duration.record(durationMs, { mode });
65
+ }
66
+
67
+ /**
68
+ * Record how many persist sources a run built vs. reused (carried forward
69
+ * unchanged via skip-if-unchanged). Lets a dashboard show the reuse ratio,
70
+ * the main lever on materialization cost.
71
+ */
72
+ export function recordSourcesOutcome(
73
+ outcome: "built" | "reused",
74
+ count: number,
75
+ ): void {
76
+ 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 });
86
+ }
87
+
88
+ /**
89
+ * Record the wall-clock cost of compiling a package's build plan
90
+ * (`Package.buildPlan`). This recompiles models at load, so it is worth
91
+ * tracking as a discrete cost separate from the build itself.
92
+ */
93
+ 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);
107
+ }
108
+
109
+ /**
110
+ * Record an auto-run manifest auto-load attempt. Auto-load is best-effort (a
111
+ * failure does not fail the run), so a failure here is otherwise invisible:
112
+ * the run is MANIFEST_FILE_READY but queries still resolve to the old tables.
113
+ */
114
+ 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 });
124
+ }
125
+
126
+ /**
127
+ * Record that a connection digest could not be computed during build-plan
128
+ * compilation because the connection failed to resolve. The resulting buildIds
129
+ * are computed without a digest, so this is a correctness signal, not just noise.
130
+ */
131
+ 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);
65
144
  }
66
145
 
67
146
  /**
68
- * Record a manifest-bind attempt (publisher binding a control-plane manifest to
69
- * a package at load). `timeout` is distinguished from `failure` so operators can
147
+ * Record a manifest-bind attempt (publisher binding a configured manifest to a
148
+ * package at load). `timeout` is distinguished from `failure` so operators can
70
149
  * tell an unreachable/slow manifest store from a malformed manifest.
71
150
  */
72
151
  export function recordManifestBind(outcome: ManifestBindOutcome): void {
@@ -113,8 +192,12 @@ export function recordDropTables(outcome: "success" | "failure"): void {
113
192
 
114
193
  /** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
115
194
  export function resetMaterializationTelemetryForTesting(): void {
116
- roundCounter = null;
117
- roundDuration = null;
195
+ runCounter = null;
196
+ runDuration = null;
197
+ sourcesCounter = null;
198
+ buildPlanComputeDuration = null;
199
+ autoLoadCounter = null;
200
+ connectionDigestSkipCounter = null;
118
201
  manifestBindCounter = null;
119
202
  sourceBuildDuration = null;
120
203
  dropTablesCounter = null;
package/src/server-old.ts CHANGED
@@ -944,16 +944,7 @@ export function registerLegacyRoutes(
944
944
  async (req, res) => {
945
945
  try {
946
946
  const action = req.query.action;
947
- if (action === "build") {
948
- const build =
949
- await materializationController.buildMaterialization(
950
- req.params.projectName,
951
- req.params.packageName,
952
- req.params.materializationId,
953
- req.body || {},
954
- );
955
- res.status(202).json(remapMaterializationResponse(build));
956
- } else if (action === "stop") {
947
+ if (action === "stop") {
957
948
  const build =
958
949
  await materializationController.stopMaterialization(
959
950
  req.params.projectName,
@@ -963,7 +954,7 @@ export function registerLegacyRoutes(
963
954
  res.status(200).json(remapMaterializationResponse(build));
964
955
  } else {
965
956
  throw new BadRequestError(
966
- `Unsupported action '${String(action ?? "")}'. Expected 'build' or 'stop'.`,
957
+ `Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
967
958
  );
968
959
  }
969
960
  } catch (error) {
package/src/server.ts CHANGED
@@ -1592,15 +1592,7 @@ app.post(
1592
1592
  async (req, res) => {
1593
1593
  try {
1594
1594
  const action = req.query.action;
1595
- if (action === "build") {
1596
- const build = await materializationController.buildMaterialization(
1597
- req.params.environmentName,
1598
- req.params.packageName,
1599
- req.params.materializationId,
1600
- req.body || {},
1601
- );
1602
- res.status(202).json(build);
1603
- } else if (action === "stop") {
1595
+ if (action === "stop") {
1604
1596
  const build = await materializationController.stopMaterialization(
1605
1597
  req.params.environmentName,
1606
1598
  req.params.packageName,
@@ -1609,7 +1601,7 @@ app.post(
1609
1601
  res.status(200).json(build);
1610
1602
  } else {
1611
1603
  throw new BadRequestError(
1612
- `Unsupported action '${String(action ?? "")}'. Expected 'build' or 'stop'.`,
1604
+ `Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
1613
1605
  );
1614
1606
  }
1615
1607
  } catch (error) {
@@ -0,0 +1,116 @@
1
+ import type { PersistSource } from "@malloydata/malloy";
2
+ import { describe, expect, it } from "bun:test";
3
+ import * as sinon from "sinon";
4
+ import {
5
+ computeBuildId,
6
+ computePackageBuildPlan,
7
+ deriveBuildPlan,
8
+ flattenDependsOn,
9
+ resolvePackageConnections,
10
+ } from "./build_plan";
11
+ import { fakeSource } from "./materialization_test_fixtures";
12
+
13
+ describe("flattenDependsOn", () => {
14
+ it("maps nested dependsOn entries to a flat sourceID list", () => {
15
+ expect(
16
+ flattenDependsOn({
17
+ dependsOn: [{ sourceID: "a" }, { sourceID: "b" }],
18
+ }),
19
+ ).toEqual(["a", "b"]);
20
+ });
21
+ });
22
+
23
+ describe("computeBuildId", () => {
24
+ it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
25
+ const makeBuildId = sinon.stub().returns("computed-id");
26
+ const source = {
27
+ connectionName: "duckdb",
28
+ makeBuildId,
29
+ getSQL: () => "SELECT 7",
30
+ } as unknown as PersistSource;
31
+
32
+ const id = computeBuildId(source, { duckdb: "dig-1" });
33
+
34
+ expect(id).toBe("computed-id");
35
+ expect(makeBuildId.calledOnceWithExactly("dig-1", "SELECT 7")).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("resolvePackageConnections", () => {
40
+ it("resolves each unique name once and omits failures", async () => {
41
+ const getMalloyConnection = sinon.stub();
42
+ getMalloyConnection.withArgs("ok").resolves({ id: "ok-conn" });
43
+ getMalloyConnection.withArgs("bad").rejects(new Error("nope"));
44
+
45
+ const map = await resolvePackageConnections({ getMalloyConnection }, [
46
+ "ok",
47
+ "ok",
48
+ "bad",
49
+ ]);
50
+
51
+ expect(map.has("ok")).toBe(true);
52
+ expect(map.has("bad")).toBe(false);
53
+ // "ok" requested twice but resolved once (dedupe).
54
+ expect(getMalloyConnection.withArgs("ok").callCount).toBe(1);
55
+ });
56
+ });
57
+
58
+ describe("deriveBuildPlan", () => {
59
+ it("projects graphs and sources into the wire build plan", () => {
60
+ const orders = fakeSource({
61
+ name: "orders",
62
+ buildId: "bid-orders",
63
+ sql: "SELECT 1",
64
+ });
65
+ const plan = deriveBuildPlan(
66
+ [
67
+ {
68
+ connectionName: "duckdb",
69
+ nodes: [[{ sourceID: "orders@m", dependsOn: [] }]],
70
+ },
71
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
72
+ { "orders@m": orders },
73
+ { duckdb: "dig" },
74
+ );
75
+
76
+ expect(plan.graphs[0].connectionName).toBe("duckdb");
77
+ expect(plan.sources["orders@m"]).toMatchObject({
78
+ name: "orders",
79
+ connectionName: "duckdb",
80
+ buildId: "bid-orders",
81
+ sql: "SELECT 1",
82
+ columns: [],
83
+ });
84
+ });
85
+
86
+ it("honors the sourceNames filter", () => {
87
+ const a = fakeSource({ name: "a", buildId: "bid-a" });
88
+ const b = fakeSource({ name: "b", buildId: "bid-b" });
89
+ const plan = deriveBuildPlan(
90
+ [
91
+ {
92
+ connectionName: "duckdb",
93
+ nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
94
+ },
95
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
96
+ { "a@m": a, "b@m": b },
97
+ { duckdb: "dig" },
98
+ ["a"],
99
+ );
100
+
101
+ expect(Object.keys(plan.sources)).toEqual(["a@m"]);
102
+ });
103
+ });
104
+
105
+ describe("computePackageBuildPlan", () => {
106
+ it("returns null when the package declares no persist sources", async () => {
107
+ const pkg = {
108
+ getModelPaths: () => [],
109
+ getPackagePath: () => "/test",
110
+ getMalloyConfig: () => ({}),
111
+ getMalloyConnection: async () => ({}),
112
+ } as unknown as Parameters<typeof computePackageBuildPlan>[0];
113
+
114
+ expect(await computePackageBuildPlan(pkg)).toBeNull();
115
+ });
116
+ });