@malloy-publisher/server 0.0.206 → 0.0.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/app/api-doc.yaml +31 -0
  2. package/dist/app/assets/{EnvironmentPage-BYwBeC2F.js → EnvironmentPage-DDRxo015.js} +1 -1
  3. package/dist/app/assets/{HomePage-ivu4vdpj.js → HomePage-BeIoPOVO.js} +1 -1
  4. package/dist/app/assets/{MainPage-B2DnHEDU.js → MainPage-DHVFRXPc.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-BZEuwF9P.js → MaterializationsPage-BYnr56IV.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Dpu3bfPg.js → ModelPage-B8tF_hYc.js} +1 -1
  7. package/dist/app/assets/{PackagePage-B8PwcRHt.js → PackagePage-LzaaviPn.js} +1 -1
  8. package/dist/app/assets/{RouteError-BhbywAeC.js → RouteError-vAYvRAi3.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-C-JXsJG0.js → WorkbookPage-CTjs2hXr.js} +1 -1
  10. package/dist/app/assets/{core-pPlPr7jK.es-CNEOlxKB.js → core-AOmIKwkc.es-B29cca-e.js} +1 -1
  11. package/dist/app/assets/index-CVGIZdxd.js +40 -0
  12. package/dist/app/assets/{index-ChR1fKR2.js → index-Db2wvjL3.js} +3 -3
  13. package/dist/app/assets/index-Dj4uKosi.js +1760 -0
  14. package/dist/app/assets/index.umd-BRRXibWA.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +10537 -6626
  17. package/package.json +13 -11
  18. package/src/health.ts +6 -0
  19. package/src/materialization_metrics.ts +55 -0
  20. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  21. package/src/mcp/agent_server.spec.ts +18 -0
  22. package/src/mcp/agent_server.ts +144 -0
  23. package/src/mcp/server.ts +3 -0
  24. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  25. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  26. package/src/mcp/skills/skills_bundle.json +1 -0
  27. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  28. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  29. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  30. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  31. package/src/mcp/tools/docs_search_tool.ts +138 -0
  32. package/src/mcp/tools/get_context_eval.ts +126 -0
  33. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  34. package/src/mcp/tools/get_context_tool.ts +341 -0
  35. package/src/server.ts +14 -0
  36. package/src/service/environment.ts +59 -1
  37. package/src/service/materialization_service.spec.ts +272 -0
  38. package/src/service/materialization_service.ts +67 -45
  39. package/src/service/model.ts +19 -2
  40. package/src/service/package.ts +92 -22
  41. package/tests/integration/materialization/manifest_binding.integration.spec.ts +25 -6
  42. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +94 -0
  43. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  44. package/dist/app/assets/index-BHEm8Egc.js +0 -40
  45. package/dist/app/assets/index-BsvDrV14.js +0 -1812
  46. package/dist/app/assets/index.umd-BVLPYNuj.js +0 -2469
@@ -48,8 +48,6 @@ type PackageConnectionInput =
48
48
  | Map<string, Connection>
49
49
  | (() => MalloyConfig);
50
50
 
51
- const ENABLE_LIST_MODEL_COMPILATION = true;
52
-
53
51
  export class Package {
54
52
  private environmentName: string;
55
53
  private packageName: string;
@@ -58,6 +56,17 @@ export class Package {
58
56
  private models: Map<string, Model> = new Map();
59
57
  private packagePath: string;
60
58
  private malloyConfig: MalloyConfig;
59
+ // Build-manifest binding state (Malloy Persistence v0). When bound, these
60
+ // entries (buildId -> { tableName }) are what served queries use to route
61
+ // persist sources to their materialized physical tables; they are also reused
62
+ // by the /compile preview so previewed SQL matches executed SQL. Surfaced on
63
+ // /status (via getPackageMetadata) so the control plane can confirm a worker
64
+ // actually bound the distributed manifest instead of inferring it from logs.
65
+ private buildManifestEntries: BuildManifest["entries"] | undefined;
66
+ private manifestBindingStatus: "unbound" | "bound" | "live_fallback" =
67
+ "unbound";
68
+ private manifestEntryCount = 0;
69
+ private boundManifestUri: string | null = null;
61
70
  private static meter = metrics.getMeter("publisher");
62
71
  private static packageLoadHistogram = this.meter.createHistogram(
63
72
  "malloy_package_load_duration",
@@ -389,14 +398,76 @@ export class Package {
389
398
  }
390
399
 
391
400
  public getPackageMetadata(): ApiPackage {
392
- // Surface explores misconfig so consumers/UI can show it (loading is
393
- // fail-safe — the package still serves with the bad entry hidden — so this
394
- // is the only non-log signal that it's broken). Computed fresh against the
395
- // current models; absent when everything resolves. Returns a copy in that
396
- // case so the added field never mutates the stored metadata.
401
+ // Overlay the server-computed fields onto the stored metadata: the
402
+ // explores misconfig warnings (loading is fail-safe — the package still
403
+ // serves with the bad entry hidden so this is the only non-log signal
404
+ // that it's broken) and the manifest-binding state (so /status reflects
405
+ // whether persist sources are actually routed to materialized tables).
406
+ // Always returns a copy so these overlays never mutate the stored
407
+ // metadata; the binding fields are authoritative from the private state.
408
+ //
409
+ // `name` is the registered package name (the environment's identity for
410
+ // this package), not the value read from the package's own manifest —
411
+ // those can differ (e.g. a package installed under a different name than
412
+ // its publisher.json declares). `listPackages` already overrides it to
413
+ // the registered name; surfacing it here keeps the single-package GET
414
+ // consistent without relying on a returned-reference mutation.
415
+ const metadata: ApiPackage = {
416
+ ...this.packageMetadata,
417
+ name: this.packageName,
418
+ manifestBindingStatus: this.manifestBindingStatus,
419
+ manifestEntryCount: this.manifestEntryCount,
420
+ boundManifestUri: this.boundManifestUri,
421
+ };
397
422
  const warnings = this.exploreWarnings();
398
- if (warnings.length === 0) return this.packageMetadata;
399
- return { ...this.packageMetadata, exploresWarnings: warnings };
423
+ if (warnings.length > 0) {
424
+ metadata.exploresWarnings = warnings;
425
+ }
426
+ return metadata;
427
+ }
428
+
429
+ /**
430
+ * The currently-bound build-manifest entries (buildId -> { tableName }), or
431
+ * undefined when the package is serving live. Reused by the /compile preview
432
+ * so previewed SQL gets the same persist-source -> physical-table routing as
433
+ * execution.
434
+ */
435
+ public getBuildManifestEntries(): BuildManifest["entries"] | undefined {
436
+ return this.buildManifestEntries;
437
+ }
438
+
439
+ /**
440
+ * Record the URI whose manifest is currently bound to the served models. May
441
+ * differ from `manifestLocation` after an in-memory auto-load following a
442
+ * materialization build (no URI), in which case it stays null.
443
+ */
444
+ public setBoundManifestUri(uri: string | null): void {
445
+ this.boundManifestUri = uri;
446
+ }
447
+
448
+ /**
449
+ * Mark that a configured `manifestLocation` could not be fetched/bound, so
450
+ * the package is serving live despite intending to be materialized-routed.
451
+ */
452
+ public markManifestBindFailed(): void {
453
+ this.manifestBindingStatus = "live_fallback";
454
+ }
455
+
456
+ /**
457
+ * Store the entries applied by the latest (re)bind for reuse (/compile) and
458
+ * observability (/status). An empty map means the manifest was cleared, so
459
+ * the package reverts to live (unbound).
460
+ */
461
+ private recordManifestBinding(
462
+ buildManifest: BuildManifest["entries"],
463
+ ): void {
464
+ const count = Object.keys(buildManifest).length;
465
+ this.buildManifestEntries = count > 0 ? buildManifest : undefined;
466
+ this.manifestEntryCount = count;
467
+ this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
468
+ if (count === 0) {
469
+ this.boundManifestUri = null;
470
+ }
400
471
  }
401
472
 
402
473
  /**
@@ -595,6 +666,7 @@ export class Package {
595
666
  this.packagePath,
596
667
  this.malloyConfig,
597
668
  sm,
669
+ { buildManifest },
598
670
  );
599
671
  // Validate renderer tags here too (loadViaWorker does it for the
600
672
  // create path). Reload keeps per-model placeholders rather than
@@ -636,6 +708,9 @@ export class Package {
636
708
  outcome.packageMetadata.manifestLocation ?? null;
637
709
  this.applyDiscoveryPolicyToModels();
638
710
  this.applyQueryBoundaryToModels();
711
+ // Remember what we just bound so /compile can route identically and
712
+ // /status can report the binding. An empty map reverts to live (unbound).
713
+ this.recordManifestBinding(buildManifest);
639
714
  // Re-run the fail-safe warning against the refreshed model set: an edit
640
715
  // to publisher.json that introduces a bad entry should surface in the
641
716
  // logs on reload too, not only at initial load (loadViaWorker).
@@ -672,15 +747,13 @@ export class Package {
672
747
  })
673
748
  .map(async (modelPath) => {
674
749
  let error: string | undefined;
675
- if (ENABLE_LIST_MODEL_COMPILATION) {
676
- try {
677
- await this.models.get(modelPath)?.getModel();
678
- } catch (modelError) {
679
- error =
680
- modelError instanceof Error
681
- ? modelError.message
682
- : undefined;
683
- }
750
+ try {
751
+ await this.models.get(modelPath)?.getModel();
752
+ } catch (modelError) {
753
+ error =
754
+ modelError instanceof Error
755
+ ? modelError.message
756
+ : undefined;
684
757
  }
685
758
  return {
686
759
  environmentName: this.environmentName,
@@ -700,10 +773,7 @@ export class Package {
700
773
  return modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
701
774
  })
702
775
  .map(async (modelPath) => {
703
- let error: Error | undefined;
704
- if (ENABLE_LIST_MODEL_COMPILATION) {
705
- error = this.models.get(modelPath)?.getNotebookError();
706
- }
776
+ const error = this.models.get(modelPath)?.getNotebookError();
707
777
  return {
708
778
  environmentName: this.environmentName,
709
779
  packageName: this.packageName,
@@ -133,25 +133,38 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
133
133
  async () => {
134
134
  const manifestFile = await writeManifest();
135
135
 
136
- // Accept on update; the response echoes the bound location.
136
+ // Accept on update; the response echoes the bound location and the
137
+ // server-computed binding observability fields (surfaced on /status so
138
+ // the control plane can confirm the worker actually bound the manifest).
137
139
  const patchRes = await patchPackage({
138
140
  manifestLocation: manifestFile,
139
141
  });
140
142
  expect(patchRes.status).toBe(200);
141
- expect((await patchRes.json()).manifestLocation).toBe(manifestFile);
143
+ const patched = await patchRes.json();
144
+ expect(patched.manifestLocation).toBe(manifestFile);
145
+ expect(patched.manifestBindingStatus).toBe("bound");
146
+ expect(patched.manifestEntryCount).toBe(1);
147
+ expect(patched.boundManifestUri).toBe(manifestFile);
142
148
 
143
149
  // In-memory metadata reflects it; binding did not break serving.
144
- expect((await getPackage()).manifestLocation).toBe(manifestFile);
150
+ const bound = await getPackage();
151
+ expect(bound.manifestLocation).toBe(manifestFile);
152
+ expect(bound.manifestBindingStatus).toBe("bound");
153
+ expect(bound.manifestEntryCount).toBe(1);
145
154
  expect(await queryOrderSummaryStatus()).toBe(200);
146
155
 
147
156
  // Persisted to publisher.json: a reload re-reads and re-binds it.
148
157
  expect((await getPackage(true)).manifestLocation).toBe(manifestFile);
149
158
  expect(await queryOrderSummaryStatus()).toBe(200);
150
159
 
151
- // Clearing reverts to live and survives a reload.
160
+ // Clearing reverts to live (unbound) and survives a reload.
152
161
  const clearRes = await patchPackage({ manifestLocation: null });
153
162
  expect(clearRes.status).toBe(200);
154
- expect((await clearRes.json()).manifestLocation ?? null).toBeNull();
163
+ const cleared = await clearRes.json();
164
+ expect(cleared.manifestLocation ?? null).toBeNull();
165
+ expect(cleared.manifestBindingStatus).toBe("unbound");
166
+ expect(cleared.manifestEntryCount).toBe(0);
167
+ expect(cleared.boundManifestUri ?? null).toBeNull();
155
168
  expect((await getPackage(true)).manifestLocation ?? null).toBeNull();
156
169
  expect(await queryOrderSummaryStatus()).toBe(200);
157
170
  },
@@ -163,7 +176,13 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
163
176
 
164
177
  const patchRes = await patchPackage({ manifestLocation: missing });
165
178
  expect(patchRes.status).toBe(200);
166
- expect((await patchRes.json()).manifestLocation).toBe(missing);
179
+ const patched = await patchRes.json();
180
+ expect(patched.manifestLocation).toBe(missing);
181
+ // The configured location is retained, but the binding is reported as a
182
+ // degraded live fallback (not "bound") with nothing actually bound.
183
+ expect(patched.manifestBindingStatus).toBe("live_fallback");
184
+ expect(patched.manifestEntryCount).toBe(0);
185
+ expect(patched.boundManifestUri ?? null).toBeNull();
167
186
 
168
187
  // A fetch failure must not brick the package — it still serves live.
169
188
  expect(await queryOrderSummaryStatus()).toBe(200);
@@ -342,6 +342,100 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
342
342
  );
343
343
  });
344
344
 
345
+ // ── Group A1: Serve-time routing (the v0 payoff) ─────────────────
346
+ //
347
+ // Materialization only pays off if *served* queries scan the materialized
348
+ // table instead of recomputing from the base table. The auto-run path builds
349
+ // the table and auto-loads the manifest into the serving models in one
350
+ // process, so routing is provable in-process: capture the live SQL (scans the
351
+ // base CSV), run an auto-run materialization, then assert both the executed
352
+ // query SQL and the /compile preview SQL now scan the physical table and no
353
+ // longer touch the base CSV. The /compile half guards the parity fix
354
+ // (Environment.compileSource threads the package's bound manifest).
355
+ describe("serve-time routing (auto-load)", () => {
356
+ const MODEL_PATH = "persist_test.malloy";
357
+ const QUERY = "run: order_summary -> { aggregate: c is count() }";
358
+
359
+ async function executedSql(): Promise<string> {
360
+ const res = await fetch(
361
+ `${baseUrl}${API}/models/${MODEL_PATH}/query`,
362
+ {
363
+ method: "POST",
364
+ headers: { "Content-Type": "application/json" },
365
+ body: JSON.stringify({ query: QUERY }),
366
+ },
367
+ );
368
+ expect(res.status).toBe(200);
369
+ const body = (await res.json()) as { result: string };
370
+ return (JSON.parse(body.result) as { sql: string }).sql;
371
+ }
372
+
373
+ async function compiledSql(): Promise<string> {
374
+ const res = await fetch(
375
+ `${baseUrl}${API}/models/${MODEL_PATH}/compile`,
376
+ {
377
+ method: "POST",
378
+ headers: { "Content-Type": "application/json" },
379
+ body: JSON.stringify({ source: QUERY, includeSql: true }),
380
+ },
381
+ );
382
+ expect(res.status).toBe(200);
383
+ const body = (await res.json()) as { status: string; sql?: string };
384
+ expect(body.status).toBe("success");
385
+ expect(body.sql).toBeDefined();
386
+ return body.sql as string;
387
+ }
388
+
389
+ it(
390
+ "routes served + compiled queries to the materialized table after auto-load",
391
+ async () => {
392
+ // Reset to live: a prior group may have left an in-memory binding.
393
+ // Auto-load never writes manifestLocation to publisher.json, so a
394
+ // reload re-reads the live model.
395
+ await fetch(`${baseUrl}${API}?reload=true`);
396
+
397
+ // Baseline: with nothing materialized, both paths recompute from the
398
+ // base CSV.
399
+ expect(await executedSql()).toContain("data/orders.csv");
400
+ expect(await compiledSql()).toContain("data/orders.csv");
401
+
402
+ // Build + auto-load in one pass (self-assigns physicalTableName =
403
+ // "order_summary" from `#@ persist name="order_summary"`).
404
+ const createRes = await createMaterialization({
405
+ pauseBetweenPhases: false,
406
+ });
407
+ expect(createRes.status).toBe(201);
408
+ const { id } = (await createRes.json()) as { id: string };
409
+ const built = await pollUntil(
410
+ id,
411
+ (s) => TERMINAL_STATUSES.includes(s),
412
+ 90_000,
413
+ );
414
+ expect(built.status).toBe("MANIFEST_FILE_READY");
415
+
416
+ // The payoff: the served query now scans the materialized table and
417
+ // no longer recomputes from the base CSV, and /compile previews the
418
+ // same routed SQL it would execute.
419
+ const routedExecuted = await executedSql();
420
+ const routedCompiled = await compiledSql();
421
+ expect(routedExecuted).not.toContain("data/orders.csv");
422
+ expect(routedExecuted).toContain("order_summary");
423
+ expect(routedCompiled).not.toContain("data/orders.csv");
424
+ expect(routedCompiled).toContain("order_summary");
425
+
426
+ // Cleanup: drop the table + record, then reload back to live so the
427
+ // dangling binding doesn't leak into later groups.
428
+ const deleteRes = await fetch(
429
+ url(`/materializations/${id}?dropTables=true`),
430
+ { method: "DELETE" },
431
+ );
432
+ expect(deleteRes.status).toBe(204);
433
+ await fetch(`${baseUrl}${API}?reload=true`);
434
+ },
435
+ { timeout: 120_000 },
436
+ );
437
+ });
438
+
345
439
  // ── Group B: State machine and error cases ───────────────────────
346
440
 
347
441
  describe("state machine and errors", () => {
@@ -116,6 +116,11 @@ 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();
119
124
  const baseUrl = `http://127.0.0.1:${port}`;
120
125
 
121
126
  const env: NodeJS.ProcessEnv = {
@@ -124,6 +129,7 @@ async function startServer(opts: { watch: boolean }): Promise<TestServer> {
124
129
  PUBLISHER_HOST: "127.0.0.1",
125
130
  PUBLISHER_PORT: String(port),
126
131
  MCP_PORT: String(mcpPort),
132
+ AGENT_MCP_PORT: String(agentMcpPort),
127
133
  };
128
134
  if (opts.watch) env.PUBLISHER_WATCH = ENV_NAME;
129
135