@malloy-publisher/server 0.0.225 → 0.0.227

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 (98) hide show
  1. package/README.md +2 -11
  2. package/dist/app/api-doc.yaml +29 -24
  3. package/dist/app/assets/{EnvironmentPage-DYTeXDll.js → EnvironmentPage-DvOJ7L_b.js} +1 -1
  4. package/dist/app/assets/{HomePage-pDK2BPJY.js → HomePage-CXguJsXS.js} +1 -1
  5. package/dist/app/assets/{LightMode-C2bwGPY1.js → LightMode-ZsshUznu.js} +1 -1
  6. package/dist/app/assets/{MainPage-WtBulMH_.js → MainPage-BIe0VwBa.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-hMgOtflG.js → MaterializationsPage-BuZ6UJVx.js} +1 -1
  8. package/dist/app/assets/{ModelPage-B2N5kYII.js → ModelPage-DsPf-s8B.js} +1 -1
  9. package/dist/app/assets/{PackagePage-CEN90nQG.js → PackagePage-CEVNAKZa.js} +1 -1
  10. package/dist/app/assets/{RouteError-BG2c5Zf0.js → RouteError-Chn7lL96.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DNfeUwEZ.js → ThemeEditorPage-DWC_FdNU.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-NKI1BhFS.js → WorkbookPage-CGrsFz8p.js} +1 -1
  13. package/dist/app/assets/{core-C6anj5c0.es-DDLHqpzt.js → core-vVgoO8IR.es-BD_THWs_.js} +1 -1
  14. package/dist/app/assets/{index-DMQtnaf4.js → index-BioohWQj.js} +1 -1
  15. package/dist/app/assets/{index-CzNqKMVl.js → index-D6YtyiJ0.js} +1 -1
  16. package/dist/app/assets/{index-C6gZ6sSY.js → index-DNUZpnaa.js} +4 -4
  17. package/dist/app/assets/{index-JXgvyZna.js → index-gEWxu09x.js} +1 -1
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +71 -15
  20. package/dist/package_load_worker.mjs +169 -40
  21. package/dist/server.mjs +782 -7050
  22. package/package.json +1 -4
  23. package/src/controller/package.controller.spec.ts +17 -17
  24. package/src/controller/package.controller.ts +7 -6
  25. package/src/health.ts +3 -8
  26. package/src/logger.spec.ts +193 -0
  27. package/src/logger.ts +115 -18
  28. package/src/mcp/error_messages.ts +8 -37
  29. package/src/mcp/handler_utils.ts +10 -129
  30. package/src/mcp/mcp_constants.ts +0 -16
  31. package/src/mcp/{agent_server.protocol.spec.ts → server.protocol.spec.ts} +14 -12
  32. package/src/mcp/server.ts +29 -37
  33. package/src/mcp/skills/build_skills_bundle.ts +1 -1
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/tools/docs_search_tool.ts +1 -1
  36. package/src/mcp/tools/execute_query_tool.ts +2 -2
  37. package/src/mcp/tools/get_context_eval.ts +3 -3
  38. package/src/mcp/tools/get_context_tool.spec.ts +196 -1
  39. package/src/mcp/tools/get_context_tool.ts +165 -67
  40. package/src/package_load/package_load_pool.ts +8 -1
  41. package/src/package_load/package_load_worker.ts +75 -24
  42. package/src/package_load/protocol.ts +21 -1
  43. package/src/package_load/rpc_wait_accountant.spec.ts +109 -0
  44. package/src/package_load/rpc_wait_accountant.ts +76 -0
  45. package/src/package_load_metrics.spec.ts +114 -0
  46. package/src/package_load_metrics.ts +127 -0
  47. package/src/pg_helpers.spec.ts +2 -206
  48. package/src/pg_helpers.ts +4 -120
  49. package/src/server.ts +0 -16
  50. package/src/service/build_plan.spec.ts +42 -60
  51. package/src/service/build_plan.ts +37 -61
  52. package/src/service/environment.ts +5 -1
  53. package/src/service/materialization_test_fixtures.ts +6 -8
  54. package/src/service/package.ts +165 -86
  55. package/src/service/package_manifest.spec.ts +22 -1
  56. package/src/service/package_manifest.ts +29 -0
  57. package/src/service/persistence_policy.spec.ts +234 -0
  58. package/src/test_helpers/metrics_harness.ts +40 -0
  59. package/tests/harness/mcp_test_setup.ts +1 -1
  60. package/tests/integration/mcp/mcp_transport.integration.spec.ts +7 -31
  61. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -6
  62. package/dxt/malloy_bridge.py +0 -354
  63. package/dxt/manifest.json +0 -22
  64. package/src/dto/connection.dto.spec.ts +0 -186
  65. package/src/dto/connection.dto.ts +0 -308
  66. package/src/dto/index.ts +0 -2
  67. package/src/dto/package.dto.spec.ts +0 -42
  68. package/src/dto/package.dto.ts +0 -27
  69. package/src/dto/validate.spec.ts +0 -76
  70. package/src/dto/validate.ts +0 -31
  71. package/src/ducklake_version.spec.ts +0 -43
  72. package/src/ducklake_version.ts +0 -26
  73. package/src/mcp/agent_server.spec.ts +0 -18
  74. package/src/mcp/agent_server.ts +0 -144
  75. package/src/mcp/prompts/handlers.ts +0 -84
  76. package/src/mcp/prompts/index.ts +0 -11
  77. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  78. package/src/mcp/prompts/prompt_service.ts +0 -67
  79. package/src/mcp/prompts/utils.ts +0 -62
  80. package/src/mcp/resource_metadata.ts +0 -47
  81. package/src/mcp/resources/environment_resource.ts +0 -187
  82. package/src/mcp/resources/model_resource.ts +0 -155
  83. package/src/mcp/resources/notebook_resource.ts +0 -137
  84. package/src/mcp/resources/package_resource.ts +0 -373
  85. package/src/mcp/resources/query_resource.ts +0 -122
  86. package/src/mcp/resources/source_resource.ts +0 -141
  87. package/src/mcp/resources/view_resource.ts +0 -136
  88. package/src/mcp/tools/discovery_tools.ts +0 -280
  89. package/src/service/materialization_cron_gate.spec.ts +0 -181
  90. package/src/storage/BaseRepository.ts +0 -31
  91. package/src/storage/StorageManager.mock.ts +0 -50
  92. package/tests/harness/e2e.ts +0 -96
  93. package/tests/harness/mocks.ts +0 -39
  94. package/tests/harness/uris.ts +0 -31
  95. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -655
  96. package/tests/integration/mcp/setup.spec.ts +0 -5
  97. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  98. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { Environment } from "./environment";
7
+ import type { Package } from "./package";
8
+
9
+ /**
10
+ * The publish gate for the package's Malloy Persistence policy (persistence.md
11
+ * §3.1, §9.2–§9.5). Scope is a single package-level mode (`Package.scope`) and a
12
+ * materialization cron is package-root-only + version-scope-only:
13
+ *
14
+ * - per-source `#@ persist ... sharing=`/`schedule=` are retired (declaring
15
+ * either on a source is a manifest error);
16
+ * - `materialization.schedule` is legal only when `scope: version`;
17
+ * - `materialization.schedule` and freshness (package or source) are mutually
18
+ * exclusive.
19
+ *
20
+ * These tests run a real `Environment` + `Package.create` over temp dirs, so
21
+ * they also prove end-to-end that the pinned compiler still ACCEPTS the
22
+ * `#@ persist` annotation keys (`sharing`/`schedule`/`freshness.*`) at compile
23
+ * — the rejection is the publisher's manifest-level gate, not a compile error —
24
+ * and that `scope` is parsed from the manifest root and surfaced on the package.
25
+ */
26
+ describe("persistence policy gate", () => {
27
+ let rootDir: string;
28
+ let envPath: string;
29
+
30
+ async function loadPackage(
31
+ model: string,
32
+ manifest: Record<string, unknown> = {},
33
+ ): Promise<Package> {
34
+ const dir = path.join(envPath, "pkg");
35
+ await fs.mkdir(dir, { recursive: true });
36
+ await fs.writeFile(
37
+ path.join(dir, "publisher.json"),
38
+ JSON.stringify({ name: "pkg", description: "fixture", ...manifest }),
39
+ );
40
+ await fs.writeFile(path.join(dir, "model.malloy"), model);
41
+ const env = await Environment.create("testEnv", envPath, []);
42
+ await env.addPackage("pkg");
43
+ return env.getPackage("pkg", false);
44
+ }
45
+
46
+ function sourceByName(pkg: Package, name: string) {
47
+ const sources = pkg.getBuildPlan()?.sources ?? {};
48
+ const found = Object.values(sources).find((s) => s.name === name);
49
+ if (!found) throw new Error(`persist source '${name}' not in build plan`);
50
+ return found;
51
+ }
52
+
53
+ beforeEach(async () => {
54
+ rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-policy-"));
55
+ envPath = path.join(rootDir, "env");
56
+ await fs.mkdir(envPath, { recursive: true });
57
+ });
58
+
59
+ afterEach(async () => {
60
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
61
+ });
62
+
63
+ const PLAIN_MODEL = `##! experimental.persistence
64
+
65
+ #@ persist name="a_table"
66
+ source: a is duckdb.sql("SELECT 1 as x")
67
+
68
+ #@ persist name="b_table" refresh=full
69
+ source: b is duckdb.sql("SELECT 2 as x")
70
+ `;
71
+
72
+ const SHARING_SOURCE_MODEL = `##! experimental.persistence
73
+
74
+ #@ persist name="s_table" sharing=private
75
+ source: s is duckdb.sql("SELECT 1 as x")
76
+ `;
77
+
78
+ const SCHEDULE_SOURCE_MODEL = `##! experimental.persistence
79
+
80
+ #@ persist name="s_table" schedule="0 */6 * * *"
81
+ source: s is duckdb.sql("SELECT 1 as x")
82
+ `;
83
+
84
+ const FRESHNESS_SOURCE_MODEL = `##! experimental.persistence
85
+
86
+ #@ persist name="f_table" freshness.window="1h"
87
+ source: f is duckdb.sql("SELECT 1 as x")
88
+ `;
89
+
90
+ // ── scope parsing + surfacing ─────────────────────────────────────────
91
+
92
+ it(
93
+ "defaults scope to 'package' when the manifest omits it",
94
+ async () => {
95
+ const pkg = await loadPackage(PLAIN_MODEL);
96
+ expect(pkg.getPackageMetadata().scope).toBe("package");
97
+ },
98
+ { timeout: 30000 },
99
+ );
100
+
101
+ it(
102
+ "surfaces an explicit scope: version from the manifest root",
103
+ async () => {
104
+ const pkg = await loadPackage(PLAIN_MODEL, { scope: "version" });
105
+ expect(pkg.getPackageMetadata().scope).toBe("version");
106
+ },
107
+ { timeout: 30000 },
108
+ );
109
+
110
+ it(
111
+ "fails the load on an invalid scope value",
112
+ async () => {
113
+ await expect(
114
+ loadPackage(PLAIN_MODEL, { scope: "shared" }),
115
+ ).rejects.toThrow(/scope/i);
116
+ },
117
+ { timeout: 30000 },
118
+ );
119
+
120
+ // ── Rule 1: per-source sharing/schedule retired ───────────────────────
121
+
122
+ it(
123
+ "rejects a per-source sharing= annotation, pointing at root scope",
124
+ async () => {
125
+ const pkg = await loadPackage(SHARING_SOURCE_MODEL, {
126
+ scope: "version",
127
+ });
128
+ const joined = pkg.formatInvalidPersistencePolicy();
129
+ expect(joined).toContain('"s"');
130
+ expect(joined).toContain("sharing");
131
+ expect(joined).toContain('"scope"');
132
+ },
133
+ { timeout: 30000 },
134
+ );
135
+
136
+ it(
137
+ "rejects a per-source schedule= annotation, pointing at the root schedule",
138
+ async () => {
139
+ const pkg = await loadPackage(SCHEDULE_SOURCE_MODEL, {
140
+ scope: "version",
141
+ });
142
+ const joined = pkg.formatInvalidPersistencePolicy();
143
+ expect(joined).toContain('"s"');
144
+ expect(joined).toContain("schedule");
145
+ expect(joined).toContain("materialization.schedule");
146
+ // The retired field is not emitted on the wire plan.
147
+ const source = sourceByName(pkg, "s") as Record<string, unknown>;
148
+ expect(source.schedule).toBeUndefined();
149
+ expect(source.sharing).toBeUndefined();
150
+ },
151
+ { timeout: 30000 },
152
+ );
153
+
154
+ // ── Rule 2: schedule requires scope: version ──────────────────────────
155
+
156
+ it(
157
+ "rejects a package schedule when scope is package (the default)",
158
+ async () => {
159
+ const pkg = await loadPackage(PLAIN_MODEL, {
160
+ materialization: { schedule: "0 6 * * *" },
161
+ });
162
+ const joined = pkg.formatInvalidPersistencePolicy();
163
+ expect(joined).toContain("materialization.schedule");
164
+ expect(joined).toContain('"scope": "version"');
165
+ },
166
+ { timeout: 30000 },
167
+ );
168
+
169
+ it(
170
+ "accepts a package schedule when scope is version and no freshness is set",
171
+ async () => {
172
+ const pkg = await loadPackage(PLAIN_MODEL, {
173
+ scope: "version",
174
+ materialization: { schedule: "0 6 * * *" },
175
+ });
176
+ expect(pkg.persistencePolicyWarnings()).toEqual([]);
177
+ },
178
+ { timeout: 30000 },
179
+ );
180
+
181
+ // ── Rule 3: schedule XOR freshness ────────────────────────────────────
182
+
183
+ it(
184
+ "rejects a schedule declared alongside package-level freshness",
185
+ async () => {
186
+ const pkg = await loadPackage(PLAIN_MODEL, {
187
+ scope: "version",
188
+ materialization: {
189
+ schedule: "0 6 * * *",
190
+ freshness: { window: "24h" },
191
+ },
192
+ });
193
+ const joined = pkg.formatInvalidPersistencePolicy();
194
+ expect(joined).toContain("mutually exclusive");
195
+ },
196
+ { timeout: 30000 },
197
+ );
198
+
199
+ it(
200
+ "rejects a schedule declared alongside a source-level freshness",
201
+ async () => {
202
+ const pkg = await loadPackage(FRESHNESS_SOURCE_MODEL, {
203
+ scope: "version",
204
+ materialization: { schedule: "0 6 * * *" },
205
+ });
206
+ const joined = pkg.formatInvalidPersistencePolicy();
207
+ expect(joined).toContain("mutually exclusive");
208
+ },
209
+ { timeout: 30000 },
210
+ );
211
+
212
+ // ── inert when nothing is declared ────────────────────────────────────
213
+
214
+ it(
215
+ "is inert for a plain package with no schedule and no forbidden knobs",
216
+ async () => {
217
+ const pkg = await loadPackage(PLAIN_MODEL, { scope: "package" });
218
+ expect(pkg.persistencePolicyWarnings()).toEqual([]);
219
+ },
220
+ { timeout: 30000 },
221
+ );
222
+
223
+ it(
224
+ "accepts source-level freshness without a schedule in either scope",
225
+ async () => {
226
+ const pkg = await loadPackage(FRESHNESS_SOURCE_MODEL, {
227
+ scope: "package",
228
+ });
229
+ expect(pkg.persistencePolicyWarnings()).toEqual([]);
230
+ expect(sourceByName(pkg, "f").freshness).toEqual({ window: "1h" });
231
+ },
232
+ { timeout: 30000 },
233
+ );
234
+ });
@@ -62,6 +62,15 @@ export interface MetricsHarness {
62
62
  * fired for this gauge yet.
63
63
  */
64
64
  collectGauge(name: string): Promise<number | undefined>;
65
+ /**
66
+ * Force a collection cycle and return the aggregate `count`/`sum` across all
67
+ * data points for the named histogram (optionally scoped to one label set).
68
+ * `count` is 0 when the histogram has not been emitted yet.
69
+ */
70
+ collectHistogram(
71
+ name: string,
72
+ attributeFilter?: Record<string, string | number | boolean>,
73
+ ): Promise<{ count: number; sum: number; boundaries: number[] }>;
65
74
  shutdown(): Promise<void>;
66
75
  }
67
76
 
@@ -100,6 +109,37 @@ export async function startMetricsHarness(): Promise<MetricsHarness> {
100
109
  }
101
110
  return total;
102
111
  },
112
+ async collectHistogram(
113
+ name: string,
114
+ attributeFilter?: Record<string, string | number | boolean>,
115
+ ): Promise<{ count: number; sum: number; boundaries: number[] }> {
116
+ const result = await reader.collect();
117
+ let count = 0;
118
+ let sum = 0;
119
+ let boundaries: number[] = [];
120
+ for (const rm of result.resourceMetrics.scopeMetrics) {
121
+ for (const metric of rm.metrics) {
122
+ if (metric.descriptor.name !== name) continue;
123
+ for (const dp of metric.dataPoints) {
124
+ if (attributeFilter) {
125
+ const allMatch = Object.entries(attributeFilter).every(
126
+ ([k, v]) => dp.attributes?.[k] === v,
127
+ );
128
+ if (!allMatch) continue;
129
+ }
130
+ const point = dp.value as {
131
+ count: number;
132
+ sum?: number;
133
+ buckets?: { boundaries: number[] };
134
+ };
135
+ count += point.count;
136
+ sum += point.sum ?? 0;
137
+ if (point.buckets) boundaries = point.buckets.boundaries;
138
+ }
139
+ }
140
+ }
141
+ return { count, sum, boundaries };
142
+ },
103
143
  async collectGauge(name: string): Promise<number | undefined> {
104
144
  const result = await reader.collect();
105
145
  for (const rm of result.resourceMetrics.scopeMetrics) {
@@ -30,7 +30,7 @@ let portCounter = 0;
30
30
 
31
31
  // True mutex: the previous Promise-based lock had a TOCTOU race where two beforeAll hooks
32
32
  // could both pass the `if (initializationLock)` check and run setup concurrently, sharing
33
- // one publisher.db / server singleton and causing flaky listResources and port collisions.
33
+ // one publisher.db / server singleton and causing flaky tool calls and port collisions.
34
34
  const e2eSetupMutex = new Mutex();
35
35
 
36
36
  /**
@@ -34,32 +34,20 @@ describe.serial("MCP Transport Tests (E2E Integration)", () => {
34
34
 
35
35
  describe("Basic Protocol", () => {
36
36
  it(
37
- "should handle a simple listResources request",
37
+ "should handle a simple listTools request",
38
38
  async () => {
39
39
  if (!env) throw new Error("Test environment not initialized");
40
- const result = await mcpClient.listResources();
40
+ const result = await mcpClient.listTools();
41
41
  // Assert based on actual successful response structure
42
- expect(result).toHaveProperty("resources");
43
- expect(Array.isArray(result.resources)).toBe(true);
42
+ expect(result).toHaveProperty("tools");
43
+ expect(Array.isArray(result.tools)).toBe(true);
44
+ expect(
45
+ result.tools.some((t) => t.name === "malloy_executeQuery"),
46
+ ).toBe(true);
44
47
  },
45
48
  { timeout: 30000 },
46
49
  );
47
50
 
48
- it("should receive InvalidParams error when calling a known method with invalid params", async () => {
49
- if (!env) throw new Error("Test environment not initialized");
50
- expect.assertions(2);
51
- try {
52
- // Use the client from the test environment
53
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
- await mcpClient.readResource({} as any); // Force invalid params
55
- } catch (error) {
56
- expect(error).toBeInstanceOf(Error);
57
- // Check error code and message
58
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
- expect((error as any).code).toBe(ErrorCode.InternalError);
60
- }
61
- });
62
-
63
51
  it("should receive MethodNotFound error for unknown methods", async () => {
64
52
  if (!env) throw new Error("Test environment not initialized");
65
53
  expect.assertions(2); // Expecting error instance and specific code
@@ -84,18 +72,6 @@ describe.serial("MCP Transport Tests (E2E Integration)", () => {
84
72
  });
85
73
 
86
74
  describe("Connection Management & Bridge Logic", () => {
87
- // DELETED - Test irrelevant in stateless mode
88
- // it("should maintain session across multiple sequential requests", async () => {
89
- // ...
90
- // });
91
-
92
- // DELETED - Test irrelevant in stateless mode
93
- // it("should handle reconnection after close", async () => {
94
- // ...
95
- // });
96
-
97
- // --- Bridge Error Handling ---
98
-
99
75
  it("should return 405 Method Not Allowed for GET requests", async () => {
100
76
  if (!env) throw new Error("Test environment not initialized");
101
77
  const getUrl = new URL(`${env.serverUrl}/mcp`); // No session ID needed
@@ -116,11 +116,6 @@ async function startServer(opts: { watch: boolean }): Promise<TestServer> {
116
116
  const port = await getFreePort();
117
117
  let mcpPort = await getFreePort();
118
118
  while (mcpPort === port) mcpPort = await getFreePort();
119
- // The agent MCP server binds its own listener; give it a distinct free port
120
- // too, otherwise every spawned child binds the fixed default and collides.
121
- let agentMcpPort = await getFreePort();
122
- while (agentMcpPort === port || agentMcpPort === mcpPort)
123
- agentMcpPort = await getFreePort();
124
119
  const baseUrl = `http://127.0.0.1:${port}`;
125
120
 
126
121
  const env: NodeJS.ProcessEnv = {
@@ -129,7 +124,6 @@ async function startServer(opts: { watch: boolean }): Promise<TestServer> {
129
124
  PUBLISHER_HOST: "127.0.0.1",
130
125
  PUBLISHER_PORT: String(port),
131
126
  MCP_PORT: String(mcpPort),
132
- AGENT_MCP_PORT: String(agentMcpPort),
133
127
  };
134
128
  if (opts.watch) env.PUBLISHER_WATCH = ENV_NAME;
135
129