@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.
- package/dist/app/api-doc.yaml +84 -66
- package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/index-CCcHdeew.js +1760 -0
- package/dist/app/assets/index-CNFX-CGL.js +40 -0
- package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +26182 -16839
- package/package.json +14 -11
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/health.ts +6 -0
- package/src/materialization_metrics.ts +117 -34
- package/src/mcp/agent_server.protocol.spec.ts +78 -0
- package/src/mcp/agent_server.spec.ts +18 -0
- package/src/mcp/agent_server.ts +144 -0
- package/src/mcp/server.ts +3 -0
- package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
- package/src/mcp/skills/build_skills_bundle.ts +66 -0
- package/src/mcp/skills/skills_bundle.json +1 -0
- package/src/mcp/skills/skills_bundle.spec.ts +33 -0
- package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
- package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
- package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
- package/src/mcp/tools/docs_search_tool.ts +138 -0
- package/src/mcp/tools/get_context_eval.ts +126 -0
- package/src/mcp/tools/get_context_tool.spec.ts +44 -0
- package/src/mcp/tools/get_context_tool.ts +341 -0
- package/src/server-old.ts +2 -11
- package/src/server.ts +16 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/package.ts +41 -1
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
- package/dist/app/assets/HomePage-CGedji_w.js +0 -1
- package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
- package/dist/app/assets/index-DGGe8UpP.js +0 -40
- package/dist/app/assets/index-uu6UpHd2.js +0 -1812
- package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
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 === "
|
|
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 '
|
|
957
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
|
|
967
958
|
);
|
|
968
959
|
}
|
|
969
960
|
} catch (error) {
|
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,
|
|
@@ -1590,15 +1592,7 @@ app.post(
|
|
|
1590
1592
|
async (req, res) => {
|
|
1591
1593
|
try {
|
|
1592
1594
|
const action = req.query.action;
|
|
1593
|
-
if (action === "
|
|
1594
|
-
const build = await materializationController.buildMaterialization(
|
|
1595
|
-
req.params.environmentName,
|
|
1596
|
-
req.params.packageName,
|
|
1597
|
-
req.params.materializationId,
|
|
1598
|
-
req.body || {},
|
|
1599
|
-
);
|
|
1600
|
-
res.status(202).json(build);
|
|
1601
|
-
} else if (action === "stop") {
|
|
1595
|
+
if (action === "stop") {
|
|
1602
1596
|
const build = await materializationController.stopMaterialization(
|
|
1603
1597
|
req.params.environmentName,
|
|
1604
1598
|
req.params.packageName,
|
|
@@ -1607,7 +1601,7 @@ app.post(
|
|
|
1607
1601
|
res.status(200).json(build);
|
|
1608
1602
|
} else {
|
|
1609
1603
|
throw new BadRequestError(
|
|
1610
|
-
`Unsupported action '${String(action ?? "")}'. Expected '
|
|
1604
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
|
|
1611
1605
|
);
|
|
1612
1606
|
}
|
|
1613
1607
|
} catch (error) {
|
|
@@ -1765,9 +1759,21 @@ mcpServer.timeout = 600000;
|
|
|
1765
1759
|
mcpServer.keepAliveTimeout = 600000;
|
|
1766
1760
|
mcpServer.headersTimeout = 600000;
|
|
1767
1761
|
|
|
1762
|
+
// Separate, isolated MCP server for the agent retrieval tools (get_context,
|
|
1763
|
+
// search_docs) on its own listener. Kept apart from the core MCP server above.
|
|
1764
|
+
const agentMcpServer = startAgentMcpServer(
|
|
1765
|
+
environmentStore,
|
|
1766
|
+
PUBLISHER_HOST,
|
|
1767
|
+
AGENT_MCP_PORT,
|
|
1768
|
+
);
|
|
1769
|
+
agentMcpServer.timeout = 600000;
|
|
1770
|
+
agentMcpServer.keepAliveTimeout = 600000;
|
|
1771
|
+
agentMcpServer.headersTimeout = 600000;
|
|
1772
|
+
|
|
1768
1773
|
registerSignalHandlers(
|
|
1769
1774
|
mainServer,
|
|
1770
1775
|
mcpServer,
|
|
1771
1776
|
SHUTDOWN_DRAIN_DURATION_SECONDS,
|
|
1772
1777
|
SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS,
|
|
1778
|
+
agentMcpServer,
|
|
1773
1779
|
);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AtomicField,
|
|
3
|
+
BuildGraph as MalloyBuildGraph,
|
|
4
|
+
MalloyConfig,
|
|
5
|
+
Connection as MalloyConnection,
|
|
6
|
+
PersistSource,
|
|
7
|
+
} from "@malloydata/malloy";
|
|
8
|
+
import { components } from "../api";
|
|
9
|
+
import { logger } from "../logger";
|
|
10
|
+
import { recordConnectionDigestSkipped } from "../materialization_metrics";
|
|
11
|
+
import { Model } from "./model";
|
|
12
|
+
|
|
13
|
+
type WireBuildGraph = components["schemas"]["BuildGraph"];
|
|
14
|
+
type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
|
|
15
|
+
type WireColumn = components["schemas"]["Column"];
|
|
16
|
+
type BuildPlan = components["schemas"]["BuildPlan"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal surface a package must expose to compile its build plan. Both
|
|
20
|
+
* {@link Package} (for the read-only `Package.buildPlan`) and the
|
|
21
|
+
* materialization service (for auto-run and orchestrated builds) satisfy it.
|
|
22
|
+
*/
|
|
23
|
+
export interface BuildPlanPackage {
|
|
24
|
+
getModelPaths(): string[];
|
|
25
|
+
getPackagePath(): string;
|
|
26
|
+
getMalloyConfig(): MalloyConfig;
|
|
27
|
+
getMalloyConnection(name: string): Promise<MalloyConnection>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Result of compiling a package's persist sources: the dependency-ordered
|
|
32
|
+
* build graphs, the persist sources keyed by sourceID, the per-connection
|
|
33
|
+
* digests, and the resolved live connections. The wire {@link BuildPlan} is a
|
|
34
|
+
* projection of this (see {@link deriveBuildPlan}); the build engine consumes
|
|
35
|
+
* the full structure.
|
|
36
|
+
*/
|
|
37
|
+
export interface CompiledBuildPlan {
|
|
38
|
+
graphs: MalloyBuildGraph[];
|
|
39
|
+
sources: Record<string, PersistSource>;
|
|
40
|
+
connectionDigests: Record<string, string>;
|
|
41
|
+
connections: Map<string, MalloyConnection>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Output columns of a persist source, degrading to [] if unavailable. */
|
|
45
|
+
export function deriveColumns(persistSource: PersistSource): WireColumn[] {
|
|
46
|
+
try {
|
|
47
|
+
return persistSource._explore.intrinsicFields
|
|
48
|
+
.filter((f) => f.isAtomicField())
|
|
49
|
+
.map((f) => ({
|
|
50
|
+
name: f.name,
|
|
51
|
+
type: String((f as AtomicField).type),
|
|
52
|
+
}));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.warn("Failed to derive columns for persist source", {
|
|
55
|
+
sourceID: persistSource.sourceID,
|
|
56
|
+
error: err instanceof Error ? err.message : String(err),
|
|
57
|
+
});
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Flatten Malloy's nested BuildNode.dependsOn into a list of sourceIDs. */
|
|
63
|
+
export function flattenDependsOn(node: {
|
|
64
|
+
dependsOn: { sourceID: string }[];
|
|
65
|
+
}): string[] {
|
|
66
|
+
return node.dependsOn.map((d) => d.sourceID);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The buildId for a persist source: a stable digest of its connection identity
|
|
71
|
+
* and canonical SQL. Centralizes the (source, connectionDigests) call shape so
|
|
72
|
+
* planning, self-instruction, and build all agree on the same id.
|
|
73
|
+
*/
|
|
74
|
+
export function computeBuildId(
|
|
75
|
+
source: PersistSource,
|
|
76
|
+
connectionDigests: Record<string, string>,
|
|
77
|
+
): string {
|
|
78
|
+
return source.makeBuildId(
|
|
79
|
+
connectionDigests[source.connectionName],
|
|
80
|
+
source.getSQL(),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a Map<name, Connection> for the names a step is about to touch.
|
|
86
|
+
* The package's MalloyConfig caches each lookup, so repeated calls are cheap.
|
|
87
|
+
* A failed lookup is logged and omitted; downstream code reports the missing
|
|
88
|
+
* connection explicitly.
|
|
89
|
+
*/
|
|
90
|
+
export async function resolvePackageConnections(
|
|
91
|
+
pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
|
|
92
|
+
names: Iterable<string>,
|
|
93
|
+
): Promise<Map<string, MalloyConnection>> {
|
|
94
|
+
const map = new Map<string, MalloyConnection>();
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
if (!name || seen.has(name)) continue;
|
|
98
|
+
seen.add(name);
|
|
99
|
+
try {
|
|
100
|
+
map.set(name, await pkg.getMalloyConnection(name));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn(`Failed to resolve connection ${name}`, {
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return map;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compile every model in the package and collect the dependency-ordered
|
|
112
|
+
* build graphs, persist sources, connection digests, and resolved
|
|
113
|
+
* connections. The build plan is a pure function of the compiled model plus
|
|
114
|
+
* connection config (no warehouse access).
|
|
115
|
+
*/
|
|
116
|
+
export async function compilePackageBuildPlan(
|
|
117
|
+
pkg: BuildPlanPackage,
|
|
118
|
+
signal?: AbortSignal,
|
|
119
|
+
): Promise<CompiledBuildPlan> {
|
|
120
|
+
const allGraphs: MalloyBuildGraph[] = [];
|
|
121
|
+
const allSources: Record<string, PersistSource> = {};
|
|
122
|
+
|
|
123
|
+
for (const modelPath of pkg.getModelPaths()) {
|
|
124
|
+
if (signal?.aborted) throw new Error("Build cancelled");
|
|
125
|
+
|
|
126
|
+
const { runtime, modelURL, importBaseURL } = await Model.getModelRuntime(
|
|
127
|
+
pkg.getPackagePath(),
|
|
128
|
+
modelPath,
|
|
129
|
+
pkg.getMalloyConfig(),
|
|
130
|
+
);
|
|
131
|
+
const malloyModel = await runtime
|
|
132
|
+
.loadModel(modelURL, { importBaseURL })
|
|
133
|
+
.getModel();
|
|
134
|
+
|
|
135
|
+
// getBuildPlan() returns empty graphs for models with no #@ persist
|
|
136
|
+
// sources, so non-persist models are simply skipped below.
|
|
137
|
+
const buildPlan = malloyModel.getBuildPlan();
|
|
138
|
+
for (const msg of buildPlan.tagParseLog) {
|
|
139
|
+
logger.warn("Persist annotation issue", {
|
|
140
|
+
modelPath,
|
|
141
|
+
message: msg.message,
|
|
142
|
+
severity: msg.severity,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (buildPlan.graphs.length === 0) continue;
|
|
146
|
+
|
|
147
|
+
allGraphs.push(...buildPlan.graphs);
|
|
148
|
+
for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
|
|
149
|
+
allSources[sourceID] = source;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const connections = await resolvePackageConnections(
|
|
154
|
+
pkg,
|
|
155
|
+
allGraphs.map((g) => g.connectionName),
|
|
156
|
+
);
|
|
157
|
+
const connectionDigests: Record<string, string> = {};
|
|
158
|
+
for (const graph of allGraphs) {
|
|
159
|
+
const conn = connections.get(graph.connectionName);
|
|
160
|
+
if (!conn) {
|
|
161
|
+
// The connection failed to resolve (already warned in
|
|
162
|
+
// resolvePackageConnections). Its buildIds will be computed without a
|
|
163
|
+
// digest, so surface it as a discrete correctness signal rather than
|
|
164
|
+
// skipping silently.
|
|
165
|
+
recordConnectionDigestSkipped();
|
|
166
|
+
logger.warn("Skipping connection digest; connection did not resolve", {
|
|
167
|
+
connectionName: graph.connectionName,
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!connectionDigests[graph.connectionName]) {
|
|
172
|
+
connectionDigests[graph.connectionName] = await conn.getDigest();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
graphs: allGraphs,
|
|
178
|
+
sources: allSources,
|
|
179
|
+
connectionDigests,
|
|
180
|
+
connections,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Project the Malloy build plan into the trimmed wire BuildPlan. */
|
|
185
|
+
export function deriveBuildPlan(
|
|
186
|
+
graphs: MalloyBuildGraph[],
|
|
187
|
+
sources: Record<string, PersistSource>,
|
|
188
|
+
connectionDigests: Record<string, string>,
|
|
189
|
+
sourceNames?: string[],
|
|
190
|
+
): BuildPlan {
|
|
191
|
+
const include = sourceNames ? new Set(sourceNames) : null;
|
|
192
|
+
|
|
193
|
+
const wireGraphs: WireBuildGraph[] = graphs.map((graph) => ({
|
|
194
|
+
connectionName: graph.connectionName,
|
|
195
|
+
nodes: graph.nodes.map((level) =>
|
|
196
|
+
level.map((node) => ({
|
|
197
|
+
sourceID: node.sourceID,
|
|
198
|
+
dependsOn: flattenDependsOn(node),
|
|
199
|
+
})),
|
|
200
|
+
),
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
const wireSources: Record<string, WirePersistSourcePlan> = {};
|
|
204
|
+
for (const [sourceID, source] of Object.entries(sources)) {
|
|
205
|
+
if (include && !include.has(source.name)) continue;
|
|
206
|
+
wireSources[sourceID] = {
|
|
207
|
+
name: source.name,
|
|
208
|
+
sourceID: source.sourceID,
|
|
209
|
+
connectionName: source.connectionName,
|
|
210
|
+
dialect: source.dialectName,
|
|
211
|
+
buildId: computeBuildId(source, connectionDigests),
|
|
212
|
+
sql: source.getSQL(),
|
|
213
|
+
columns: deriveColumns(source),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { graphs: wireGraphs, sources: wireSources };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Compile and project a package's build plan, or null when the package
|
|
222
|
+
* declares no persist source. Convenience for the read-only `Package.buildPlan`
|
|
223
|
+
* field, which is a deterministic property of the compiled package.
|
|
224
|
+
*/
|
|
225
|
+
export async function computePackageBuildPlan(
|
|
226
|
+
pkg: BuildPlanPackage,
|
|
227
|
+
signal?: AbortSignal,
|
|
228
|
+
): Promise<BuildPlan | null> {
|
|
229
|
+
const compiled = await compilePackageBuildPlan(pkg, signal);
|
|
230
|
+
if (compiled.graphs.length === 0) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return deriveBuildPlan(
|
|
234
|
+
compiled.graphs,
|
|
235
|
+
compiled.sources,
|
|
236
|
+
compiled.connectionDigests,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -7,6 +7,10 @@ import "@malloydata/db-mysql";
|
|
|
7
7
|
import type { MySQLConnection } from "@malloydata/db-mysql";
|
|
8
8
|
import "@malloydata/db-postgres";
|
|
9
9
|
import type { PostgresConnection } from "@malloydata/db-postgres";
|
|
10
|
+
// Registers the "publisher" connection type (proxies SQL to a remote Publisher
|
|
11
|
+
// dataplane). No live-class branch is needed in lookupConnection — the default
|
|
12
|
+
// path resolves it through the registry like any other registered type.
|
|
13
|
+
import "@malloydata/db-publisher";
|
|
10
14
|
import {
|
|
11
15
|
buildPoolOptions,
|
|
12
16
|
SnowflakeConnection,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import { generateKeyPairSync } from "crypto";
|
|
3
3
|
import { components } from "../api";
|
|
4
4
|
import {
|
|
@@ -168,3 +168,184 @@ describe("normalizeSnowflakePrivateKey", () => {
|
|
|
168
168
|
expect(result.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("assembleEnvironmentConnections — publisher", () => {
|
|
173
|
+
const validBase: ApiConnection = {
|
|
174
|
+
name: "analytics",
|
|
175
|
+
type: "publisher",
|
|
176
|
+
publisherConnection: {
|
|
177
|
+
connectionUri:
|
|
178
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
179
|
+
accessToken: "jwt-token",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// publisher connections are default-deny (SSRF gate); these assembly tests
|
|
184
|
+
// exercise the enabled path, so opt in for the block and restore after.
|
|
185
|
+
const priorFlag = process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "true";
|
|
188
|
+
});
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
if (priorFlag === undefined) {
|
|
191
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
192
|
+
} else {
|
|
193
|
+
process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = priorFlag;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("emits a publisher core entry proxying to the remote dataplane", () => {
|
|
198
|
+
const { pojo } = assembleEnvironmentConnections([validBase]);
|
|
199
|
+
|
|
200
|
+
const entry = pojo.connections["analytics"];
|
|
201
|
+
expect(entry.is).toBe("publisher");
|
|
202
|
+
expect(entry.connectionUri).toBe(
|
|
203
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
204
|
+
);
|
|
205
|
+
expect(entry.accessToken).toBe("jwt-token");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not populate static connection attributes (dialect is resolved at runtime)", () => {
|
|
209
|
+
const { apiConnections } = assembleEnvironmentConnections([validBase]);
|
|
210
|
+
expect(apiConnections).toHaveLength(1);
|
|
211
|
+
expect(apiConnections[0].attributes).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("assembles without an accessToken (optional)", () => {
|
|
215
|
+
const conn: ApiConnection = {
|
|
216
|
+
name: "analytics",
|
|
217
|
+
type: "publisher",
|
|
218
|
+
publisherConnection: {
|
|
219
|
+
connectionUri:
|
|
220
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const { pojo } = assembleEnvironmentConnections([conn]);
|
|
224
|
+
const entry = pojo.connections["analytics"];
|
|
225
|
+
expect(entry.is).toBe("publisher");
|
|
226
|
+
expect(entry.accessToken).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects a publisher connection missing connectionUri with an actionable error", () => {
|
|
230
|
+
const conn: ApiConnection = {
|
|
231
|
+
name: "analytics",
|
|
232
|
+
type: "publisher",
|
|
233
|
+
publisherConnection:
|
|
234
|
+
{} as components["schemas"]["PublisherConnection"],
|
|
235
|
+
};
|
|
236
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
237
|
+
"Invalid publisher connection 'analytics': missing connectionUri.",
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rejects a publisher connection missing the publisherConnection block", () => {
|
|
242
|
+
const conn: ApiConnection = {
|
|
243
|
+
name: "analytics",
|
|
244
|
+
type: "publisher",
|
|
245
|
+
};
|
|
246
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
247
|
+
"Invalid publisher connection 'analytics': missing connectionUri.",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("rejects a publisher connection whose connectionUri is not a valid URL", () => {
|
|
252
|
+
const conn: ApiConnection = {
|
|
253
|
+
name: "analytics",
|
|
254
|
+
type: "publisher",
|
|
255
|
+
publisherConnection: { connectionUri: "not a url" },
|
|
256
|
+
};
|
|
257
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
258
|
+
"Invalid publisher connection 'analytics': connectionUri is not a valid URL.",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("rejects a publisher connection whose connectionUri uses a non-http(s) scheme", () => {
|
|
263
|
+
const conn: ApiConnection = {
|
|
264
|
+
name: "analytics",
|
|
265
|
+
type: "publisher",
|
|
266
|
+
publisherConnection: { connectionUri: "file:///etc/passwd" },
|
|
267
|
+
};
|
|
268
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
269
|
+
"connectionUri must use http or https (got 'file:')",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("does not echo a credential-bearing connectionUri in the validation error", () => {
|
|
274
|
+
const conn: ApiConnection = {
|
|
275
|
+
name: "analytics",
|
|
276
|
+
type: "publisher",
|
|
277
|
+
// Userinfo present but the URI is otherwise malformed (space in host)
|
|
278
|
+
// so it fails to parse and must not be reflected back verbatim.
|
|
279
|
+
publisherConnection: {
|
|
280
|
+
connectionUri: "https://user:s3cret@bad host/connections/x",
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
284
|
+
"connectionUri is not a valid URL",
|
|
285
|
+
);
|
|
286
|
+
expect(() => assembleEnvironmentConnections([conn])).not.toThrow(
|
|
287
|
+
/s3cret/,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("still rejects the reserved 'duckdb' name for a publisher connection", () => {
|
|
292
|
+
const conn: ApiConnection = {
|
|
293
|
+
name: "duckdb",
|
|
294
|
+
type: "publisher",
|
|
295
|
+
publisherConnection: { connectionUri: "https://x/connections/duckdb" },
|
|
296
|
+
};
|
|
297
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
298
|
+
"Connection name 'duckdb' is reserved",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("still rejects a publisher connection with no name", () => {
|
|
303
|
+
const conn = {
|
|
304
|
+
type: "publisher",
|
|
305
|
+
publisherConnection: { connectionUri: "https://x/connections/y" },
|
|
306
|
+
} as ApiConnection;
|
|
307
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
308
|
+
"Invalid connection configuration. No name.",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("SSRF gate (PUBLISHER_ALLOW_PROXY_CONNECTIONS)", () => {
|
|
313
|
+
it("denies a valid publisher connection when the flag is unset (default-deny)", () => {
|
|
314
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
315
|
+
expect(() => assembleEnvironmentConnections([validBase])).toThrow(
|
|
316
|
+
"Publisher proxy connection 'analytics' is disabled in this deployment",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("error names the env var to flip", () => {
|
|
321
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
322
|
+
expect(() => assembleEnvironmentConnections([validBase])).toThrow(
|
|
323
|
+
"Fix: set the environment variable PUBLISHER_ALLOW_PROXY_CONNECTIONS=true",
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("denies for any non-'true' value (fail-closed)", () => {
|
|
328
|
+
process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "1";
|
|
329
|
+
expect(() => assembleEnvironmentConnections([validBase])).toThrow(
|
|
330
|
+
"is disabled in this deployment",
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("gate fires before the connectionUri shape check", () => {
|
|
335
|
+
// A publisher connection missing connectionUri still surfaces the
|
|
336
|
+
// disabled error first when the gate is closed — the type is refused
|
|
337
|
+
// outright, not validated.
|
|
338
|
+
delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
|
|
339
|
+
const conn: ApiConnection = { name: "analytics", type: "publisher" };
|
|
340
|
+
expect(() => assembleEnvironmentConnections([conn])).toThrow(
|
|
341
|
+
"is disabled in this deployment",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("allows a valid publisher connection when the flag is 'true'", () => {
|
|
346
|
+
process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = "true";
|
|
347
|
+
const { pojo } = assembleEnvironmentConnections([validBase]);
|
|
348
|
+
expect(pojo.connections["analytics"].is).toBe("publisher");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|