@malloy-publisher/server 0.0.231 → 0.0.233

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 (101) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +135 -13
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  14. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  15. package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
  16. package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
  17. package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
  18. package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +22627 -1260
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/controller/query.controller.ts +26 -21
  32. package/src/errors.ts +19 -0
  33. package/src/json_utils.spec.ts +51 -0
  34. package/src/json_utils.ts +33 -0
  35. package/src/logger.spec.ts +18 -1
  36. package/src/logger.ts +3 -1
  37. package/src/materialization_metrics.spec.ts +89 -4
  38. package/src/materialization_metrics.ts +155 -5
  39. package/src/mcp/query_envelope.spec.ts +229 -0
  40. package/src/mcp/query_envelope.ts +230 -0
  41. package/src/mcp/server.protocol.spec.ts +128 -16
  42. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  43. package/src/mcp/skills/skills_bundle.json +1 -1
  44. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  45. package/src/mcp/tool_response.spec.ts +108 -0
  46. package/src/mcp/tool_response.ts +138 -0
  47. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  48. package/src/mcp/tools/compile_tool.ts +61 -30
  49. package/src/mcp/tools/docs_search_tool.ts +6 -16
  50. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  51. package/src/mcp/tools/embedding_index.ts +808 -0
  52. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  53. package/src/mcp/tools/execute_query_tool.ts +90 -151
  54. package/src/mcp/tools/get_context_eval.ts +194 -45
  55. package/src/mcp/tools/get_context_tool.spec.ts +358 -5
  56. package/src/mcp/tools/get_context_tool.ts +202 -56
  57. package/src/mcp/tools/reload_package_tool.ts +3 -29
  58. package/src/pg_helpers.spec.ts +201 -0
  59. package/src/pg_helpers.ts +44 -5
  60. package/src/server.ts +24 -0
  61. package/src/service/build_plan.spec.ts +128 -2
  62. package/src/service/build_plan.ts +239 -17
  63. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  64. package/src/service/connection.spec.ts +371 -1
  65. package/src/service/connection.ts +339 -20
  66. package/src/service/connection_config.spec.ts +108 -0
  67. package/src/service/connection_config.ts +47 -8
  68. package/src/service/connection_federation.spec.ts +184 -0
  69. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  70. package/src/service/embedding_provider.spec.ts +329 -0
  71. package/src/service/embedding_provider.ts +236 -0
  72. package/src/service/environment.ts +274 -12
  73. package/src/service/environment_store.spec.ts +678 -3
  74. package/src/service/environment_store.ts +449 -33
  75. package/src/service/environment_store_clone.spec.ts +350 -0
  76. package/src/service/manifest_loader.spec.ts +68 -13
  77. package/src/service/manifest_loader.ts +67 -19
  78. package/src/service/materialization_build_session.spec.ts +435 -0
  79. package/src/service/materialization_build_session.ts +681 -0
  80. package/src/service/materialization_eligibility.spec.ts +158 -0
  81. package/src/service/materialization_eligibility.ts +305 -0
  82. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  83. package/src/service/materialization_serve_transform.ts +779 -0
  84. package/src/service/materialization_service.spec.ts +774 -7
  85. package/src/service/materialization_service.ts +1107 -42
  86. package/src/service/materialization_test_fixtures.ts +7 -0
  87. package/src/service/model.spec.ts +207 -0
  88. package/src/service/model.ts +569 -59
  89. package/src/service/model_limits.spec.ts +28 -0
  90. package/src/service/model_limits.ts +21 -0
  91. package/src/service/model_storage_serve.spec.ts +193 -0
  92. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  93. package/src/service/package.spec.ts +196 -0
  94. package/src/service/package.ts +385 -17
  95. package/src/service/persistence_policy.spec.ts +109 -0
  96. package/src/storage/duckdb/schema.ts +37 -0
  97. package/tests/fixtures/xlsx/database.xlsx +0 -0
  98. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  99. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  100. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
  101. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
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.231",
4
+ "version": "0.0.233",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -36,17 +36,17 @@
36
36
  "@azure/storage-blob": "^12.26.0",
37
37
  "@duckdb/node-api": "1.5.3-r.2",
38
38
  "@google-cloud/storage": "^7.16.0",
39
- "@malloydata/db-bigquery": "^0.0.423",
40
- "@malloydata/db-databricks": "^0.0.423",
41
- "@malloydata/db-duckdb": "^0.0.423",
42
- "@malloydata/db-mysql": "^0.0.423",
43
- "@malloydata/db-postgres": "^0.0.423",
44
- "@malloydata/db-publisher": "^0.0.423",
45
- "@malloydata/db-snowflake": "^0.0.423",
46
- "@malloydata/db-trino": "^0.0.423",
47
- "@malloydata/malloy": "^0.0.423",
48
- "@malloydata/malloy-sql": "^0.0.423",
49
- "@malloydata/render-validator": "^0.0.423",
39
+ "@malloydata/db-bigquery": "^0.0.426",
40
+ "@malloydata/db-databricks": "^0.0.426",
41
+ "@malloydata/db-duckdb": "^0.0.426",
42
+ "@malloydata/db-mysql": "^0.0.426",
43
+ "@malloydata/db-postgres": "^0.0.426",
44
+ "@malloydata/db-publisher": "^0.0.426",
45
+ "@malloydata/db-snowflake": "^0.0.426",
46
+ "@malloydata/db-trino": "^0.0.426",
47
+ "@malloydata/malloy": "^0.0.426",
48
+ "@malloydata/malloy-sql": "^0.0.426",
49
+ "@malloydata/render-validator": "^0.0.426",
50
50
  "@modelcontextprotocol/sdk": "^1.13.2",
51
51
  "@opentelemetry/api": "^1.9.0",
52
52
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
@@ -25,7 +25,9 @@ import { DuckDBInstance } from "@duckdb/node-api";
25
25
  // Every extension the connection layer (packages/server/src/service/connection.ts)
26
26
  // and storage manager INSTALL/LOAD at runtime, for cloud attach, the per-package
27
27
  // sandbox, federated-database attach, and the materialization catalog. Keep this
28
- // in sync with the install sites in those files.
28
+ // in sync with the install sites in those files. `excel` is the exception: it
29
+ // has no INSTALL site because DuckDB autoloads it when a package reads an
30
+ // .xlsx file, so baking is what makes those reads work offline.
29
31
  //
30
32
  // `community: true` marks the community-repo extensions (bigquery, snowflake);
31
33
  // the rest are core extensions installed by name. The bake deliberately uses
@@ -40,6 +42,7 @@ const EXTENSIONS = [
40
42
  { name: "azure", community: false }, // azure blob storage
41
43
  { name: "postgres", community: false, registered: "postgres_scanner" }, // postgres attach + ducklake postgres catalog
42
44
  { name: "ducklake", community: false }, // materialization catalog
45
+ { name: "excel", community: false }, // .xlsx reads in per-package sandboxes (autoloaded on demand)
43
46
  { name: "bigquery", community: true },
44
47
  { name: "snowflake", community: true },
45
48
  ];
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import {
5
+ getPersistCollisionEnforce,
5
6
  getPublisherConfig,
6
7
  getPublisherConfigDir,
7
8
  type PublisherConfig,
@@ -1494,3 +1495,41 @@ describe("getPublisherConfigDir", () => {
1494
1495
  expect(getPublisherConfigDir(testRoot)).toBeNull();
1495
1496
  });
1496
1497
  });
1498
+
1499
+ describe("PERSIST_COLLISION_ENFORCE", () => {
1500
+ // The flag decides whether a persist-target collision blocks a publish, so
1501
+ // mis-parsing it leaves the check warn-only — failing open in exactly the
1502
+ // direction it exists to prevent. An ad-hoc `=== "true"` did that for `1`,
1503
+ // `yes` and `on`.
1504
+ const prev = process.env.PERSIST_COLLISION_ENFORCE;
1505
+ afterEach(() => {
1506
+ if (prev === undefined) delete process.env.PERSIST_COLLISION_ENFORCE;
1507
+ else process.env.PERSIST_COLLISION_ENFORCE = prev;
1508
+ });
1509
+
1510
+ it("defaults to warn-only when unset or empty", () => {
1511
+ delete process.env.PERSIST_COLLISION_ENFORCE;
1512
+ expect(getPersistCollisionEnforce()).toBe(false);
1513
+ process.env.PERSIST_COLLISION_ENFORCE = " ";
1514
+ expect(getPersistCollisionEnforce()).toBe(false);
1515
+ });
1516
+
1517
+ it("enforces for every spelling of true an operator might use", () => {
1518
+ for (const raw of ["true", "TRUE", " True ", "1", "yes", "on"]) {
1519
+ process.env.PERSIST_COLLISION_ENFORCE = raw;
1520
+ expect(getPersistCollisionEnforce()).toBe(true);
1521
+ }
1522
+ });
1523
+
1524
+ it("stays warn-only for every spelling of false", () => {
1525
+ for (const raw of ["false", "FALSE", "0", "no", "off"]) {
1526
+ process.env.PERSIST_COLLISION_ENFORCE = raw;
1527
+ expect(getPersistCollisionEnforce()).toBe(false);
1528
+ }
1529
+ });
1530
+
1531
+ it("throws on a value that is neither, rather than guessing", () => {
1532
+ process.env.PERSIST_COLLISION_ENFORCE = "enabled";
1533
+ expect(() => getPersistCollisionEnforce()).toThrow(/expected a boolean/i);
1534
+ });
1535
+ });
package/src/config.ts CHANGED
@@ -262,6 +262,73 @@ export const getMemoryGovernorConfig = (): MemoryGovernorConfig | null => {
262
262
  };
263
263
  };
264
264
 
265
+ /**
266
+ * Settings for the optional embedding provider behind semantic
267
+ * `malloy_getContext` retrieval. See {@link getEmbeddingConfig}.
268
+ */
269
+ export interface EmbeddingConfig {
270
+ /** Bearer token sent to the embedding endpoint. */
271
+ apiKey: string;
272
+ /** Embedding model name, e.g. "text-embedding-3-small". */
273
+ model: string;
274
+ /** Base URL of an OpenAI-compatible API (no trailing slash). */
275
+ baseUrl: string;
276
+ /**
277
+ * Optional `dimensions` request parameter. Omitted from requests when
278
+ * unset; the vector length then comes from the provider's response.
279
+ */
280
+ dimensions?: number;
281
+ }
282
+
283
+ const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
284
+ const DEFAULT_EMBEDDING_API_BASE = "https://api.openai.com/v1";
285
+
286
+ /**
287
+ * Embedding-provider settings for semantic `malloy_getContext` retrieval,
288
+ * or `null` when the feature is disabled. The feature is enabled iff
289
+ * `EMBEDDING_API_KEY` is set and non-empty; without it the tool keeps its
290
+ * lexical (lunr) ranking unchanged.
291
+ *
292
+ * The key must be set explicitly. An ambient provider key (for example
293
+ * `OPENAI_API_KEY`) is deliberately NOT read: enabling this feature sends
294
+ * entity names, `#(doc)` text, and query strings to the configured
295
+ * endpoint, and that egress must never switch on just because a commonly
296
+ * exported variable happens to be present.
297
+ *
298
+ * Throws on malformed companion values (bad URL, bad integer) so a typo
299
+ * surfaces loudly in the log rather than silently degrading to lexical.
300
+ */
301
+ export const getEmbeddingConfig = (): EmbeddingConfig | null => {
302
+ const apiKey = process.env.EMBEDDING_API_KEY?.trim();
303
+ if (!apiKey) {
304
+ return null;
305
+ }
306
+
307
+ const rawBase = process.env.EMBEDDING_API_BASE;
308
+ const baseUrl = (rawBase?.trim() || DEFAULT_EMBEDDING_API_BASE).replace(
309
+ /\/+$/,
310
+ "",
311
+ );
312
+ try {
313
+ new URL(baseUrl);
314
+ } catch {
315
+ throw new Error(
316
+ `Invalid value for EMBEDDING_API_BASE: expected a URL, got "${rawBase}"`,
317
+ );
318
+ }
319
+
320
+ const model = process.env.EMBEDDING_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL;
321
+
322
+ const dimensions = parseIntEnv("EMBEDDING_DIMENSIONS");
323
+ if (dimensions !== undefined && dimensions <= 0) {
324
+ throw new Error(
325
+ `EMBEDDING_DIMENSIONS must be a positive integer (got ${dimensions})`,
326
+ );
327
+ }
328
+
329
+ return { apiKey, model, baseUrl, dimensions };
330
+ };
331
+
265
332
  /**
266
333
  * Tunables for the standalone {@link MaterializationScheduler}. Sourced from
267
334
  * environment variables at startup; see {@link getMaterializationSchedulerConfig}.
@@ -480,6 +547,74 @@ export const getExtensionFetchPolicy = (): ExtensionFetchPolicy => {
480
547
  );
481
548
  };
482
549
 
550
+ /**
551
+ * The three `#@ persist storage=<conn>` deployment modes, read from
552
+ * `PERSIST_STORAGE_MODE`. This is a runtime kill switch — flipping DOWN must
553
+ * never fail an already-loaded package, only change how `storage=` is honored:
554
+ *
555
+ * - `off` (default): `storage=` is inert. Sources build into (colocated) and serve
556
+ * from their own warehouse exactly as before the feature existed; a source
557
+ * that declares `storage=` is served live and surfaced as a package warning.
558
+ * The safe resting state and the incident kill switch.
559
+ * - `write-only`: builds materialize into the storage destination (so operators
560
+ * can measure and inspect the tables), but the serve path still ignores
561
+ * `storage=` and serves live. The de-risking / measurement rung.
562
+ * - `on`: full end to end — build into storage AND serve via the virtual-source
563
+ * transform, with a per-query fallback to live for anything the transform
564
+ * cannot yet serve.
565
+ *
566
+ * The publisher NEVER hard-fails a package on `storage=` in any mode; any
567
+ * stricter "refuse a new package that uses storage= while off" policy is the
568
+ * caller's, not here (the mechanism/policy split).
569
+ */
570
+ export type PersistStorageMode = "off" | "write-only" | "on";
571
+
572
+ const PERSIST_STORAGE_MODES: readonly PersistStorageMode[] = [
573
+ "off",
574
+ "write-only",
575
+ "on",
576
+ ];
577
+
578
+ /**
579
+ * Resolve the `storage=` deployment mode from `PERSIST_STORAGE_MODE`. Defaults
580
+ * to `off` (feature dark) when unset/empty; loud-fails on an unrecognized value
581
+ * so a typo can't silently leave the fleet in a surprising mode. Case-insensitive,
582
+ * like the sibling `PERSIST_COLLISION_ENFORCE`.
583
+ */
584
+ export const getPersistStorageMode = (): PersistStorageMode => {
585
+ const raw = process.env.PERSIST_STORAGE_MODE;
586
+ if (raw === undefined || raw.trim() === "") return "off";
587
+ const value = raw.trim().toLowerCase();
588
+ if ((PERSIST_STORAGE_MODES as readonly string[]).includes(value)) {
589
+ return value as PersistStorageMode;
590
+ }
591
+ throw new Error(
592
+ `PERSIST_STORAGE_MODE must be one of ${PERSIST_STORAGE_MODES.join(
593
+ " | ",
594
+ )} (got ${JSON.stringify(raw)})`,
595
+ );
596
+ };
597
+
598
+ /**
599
+ * Whether a within-package persist-target COLLISION (two distinct persist
600
+ * sources resolving to the same physical table in the same destination) is a
601
+ * hard publish rejection, from `PERSIST_COLLISION_ENFORCE` (default `false`).
602
+ *
603
+ * Staged on purpose: a package published BEFORE this check existed may carry a
604
+ * latent collision, so the check ships warn-only — surfaced at load and publish
605
+ * (so operators can find and remediate) but NOT blocking a re-publish. Flip this
606
+ * to `true` only after auditing and remediating known collisions, so the
607
+ * transition to reject-at-publish is deliberate and doesn't break routine
608
+ * re-publishes of existing packages. Load is ALWAYS warn-only regardless — the
609
+ * flag only governs whether publish rejects.
610
+ */
611
+ export const getPersistCollisionEnforce = (): boolean =>
612
+ // parseBoolEnv, not an ad-hoc === "true": an operator who writes `1` or `yes`
613
+ // has asked for the check to block, and an ad-hoc compare would silently leave
614
+ // it warn-only — the flag failing open in exactly the direction it exists to
615
+ // prevent. A typo throws at startup, like every other flag here.
616
+ parseBoolEnv("PERSIST_COLLISION_ENFORCE") ?? false;
617
+
483
618
  function substituteEnvVars(value: string): string {
484
619
  const envVarPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
485
620
 
@@ -128,6 +128,68 @@ describe("MaterializationController.createMaterialization validation", () => {
128
128
  });
129
129
  });
130
130
 
131
+ it("preserves the storage `destination` on a build instruction", async () => {
132
+ // Regression: `destination` is the orchestrated `storage=` axis. Dropping it
133
+ // here silently downgrades an orchestrated build to a colocated
134
+ // build, so it never materializes into the storage destination.
135
+ const parsed = await parse({
136
+ buildInstructions: {
137
+ sources: [
138
+ {
139
+ sourceEntityId: "b2",
140
+ materializedTableId: "mt-2",
141
+ physicalTableName: "downstream_v1",
142
+ realization: "COPY",
143
+ destination: "lake",
144
+ },
145
+ ],
146
+ },
147
+ });
148
+ expect(parsed.buildInstructions).toEqual([
149
+ {
150
+ sourceEntityId: "b2",
151
+ sourceID: undefined,
152
+ materializedTableId: "mt-2",
153
+ physicalTableName: "downstream_v1",
154
+ realization: "COPY",
155
+ destination: "lake",
156
+ },
157
+ ]);
158
+ });
159
+
160
+ it("preserves the optional `connectionName` on a manifest reference", async () => {
161
+ // Regression: `connectionName` (added by #904) lets the seed loop dialect-
162
+ // quote the referenced upstream for a case-folding engine. Dropping it here
163
+ // silently reverts to an unquoted seed — the same manual-copy drift that
164
+ // dropped BuildInstruction.destination.
165
+ const parsed = await parse({
166
+ buildInstructions: {
167
+ sources: [
168
+ {
169
+ sourceEntityId: "b2",
170
+ materializedTableId: "mt-2",
171
+ physicalTableName: "downstream_v1",
172
+ realization: "COPY",
173
+ },
174
+ ],
175
+ referenceManifest: [
176
+ {
177
+ sourceEntityId: "b1",
178
+ physicalTableName: "upstream_table",
179
+ connectionName: "sf",
180
+ },
181
+ ],
182
+ },
183
+ });
184
+ expect(parsed.referenceManifest).toEqual([
185
+ {
186
+ sourceEntityId: "b1",
187
+ physicalTableName: "upstream_table",
188
+ connectionName: "sf",
189
+ },
190
+ ]);
191
+ });
192
+
131
193
  it("rejects a referenceManifest entry missing a required field", async () => {
132
194
  const { controller } = build();
133
195
  await expect(
@@ -141,6 +141,14 @@ export class MaterializationController {
141
141
  return {
142
142
  sourceEntityId: ref.sourceEntityId as string,
143
143
  physicalTableName: ref.physicalTableName as string,
144
+ // The connection the upstream table lives on. Optional; carried through
145
+ // so the seed loop can dialect-quote the reference for a case-folding
146
+ // engine (#904). Dropping it silently reverts to an unquoted seed, which
147
+ // fails a downstream build on such an engine — the same manual-copy drift
148
+ // that dropped BuildInstruction.destination.
149
+ ...(typeof ref.connectionName === "string"
150
+ ? { connectionName: ref.connectionName }
151
+ : {}),
144
152
  };
145
153
  }
146
154
 
@@ -179,6 +187,13 @@ export class MaterializationController {
179
187
  materializedTableId: instruction.materializedTableId as string,
180
188
  physicalTableName: instruction.physicalTableName as string,
181
189
  realization: instruction.realization,
190
+ // The `storage=` destination the orchestrator targets. Optional in the
191
+ // schema; must be carried through, or an orchestrated build silently
192
+ // falls back to a colocated build (isStorageBuild=false) and
193
+ // never materializes into the storage destination.
194
+ ...(typeof instruction.destination === "string"
195
+ ? { destination: instruction.destination }
196
+ : {}),
182
197
  };
183
198
  }
184
199
 
@@ -19,6 +19,7 @@ describe("PackageController.addPackage explores validation", () => {
19
19
  const mockPackage = {
20
20
  formatInvalidExplores: () => invalidMsg,
21
21
  formatInvalidPersistencePolicy: () => "",
22
+ formatPersistenceCollisionRejections: () => "",
22
23
  };
23
24
  const unloadPackage = sinon.stub().resolves(undefined);
24
25
  const deletePackage = sinon.stub().resolves(undefined);
@@ -56,6 +57,7 @@ describe("PackageController.addPackage explores validation", () => {
56
57
  const mockPackage = {
57
58
  formatInvalidExplores: () => invalidMsg,
58
59
  formatInvalidPersistencePolicy: () => "",
60
+ formatPersistenceCollisionRejections: () => "",
59
61
  };
60
62
  // installPackage mimics the real contract: invoke the validator and, if it
61
63
  // returns a message, throw BadRequestError (after its internal rollback).
@@ -105,6 +107,7 @@ describe("PackageController.addPackage explores validation", () => {
105
107
  const mockPackage = {
106
108
  formatInvalidExplores: () => "",
107
109
  formatInvalidPersistencePolicy: () => "",
110
+ formatPersistenceCollisionRejections: () => "",
108
111
  };
109
112
  const addPackage = sinon.stub().resolves(mockPackage);
110
113
  const getEnvironment = sinon.stub().resolves({ addPackage });
@@ -143,6 +146,7 @@ describe("PackageController.addPackage persistence policy validation", () => {
143
146
  const mockPackage = {
144
147
  formatInvalidExplores: () => "",
145
148
  formatInvalidPersistencePolicy: () => cronMsg,
149
+ formatPersistenceCollisionRejections: () => "",
146
150
  };
147
151
  const unloadPackage = sinon.stub().resolves(undefined);
148
152
  const addPackage = sinon.stub().resolves(mockPackage);
@@ -172,6 +176,7 @@ describe("PackageController.addPackage persistence policy validation", () => {
172
176
  const mockPackage = {
173
177
  formatInvalidExplores: () => "",
174
178
  formatInvalidPersistencePolicy: () => cronMsg,
179
+ formatPersistenceCollisionRejections: () => "",
175
180
  };
176
181
  const installPackage = sinon
177
182
  .stub()
@@ -226,6 +231,7 @@ describe("PackageController.updatePackage explores validation", () => {
226
231
  formatInvalidExplores: (override?: string[]) =>
227
232
  override?.includes("nope.malloy") ? invalidMsg : "",
228
233
  formatInvalidPersistencePolicy: () => "",
234
+ formatPersistenceCollisionRejections: () => "",
229
235
  };
230
236
  const installPackage = sinon
231
237
  .stub()
@@ -18,19 +18,24 @@ export type PackageReloadMode = "in-place" | "reinstalled";
18
18
  * the Malloy Persistence policy gate (scope is package-level; a
19
19
  * `materialization.schedule` is package-root-only + version-scope-only and
20
20
  * mutually exclusive with freshness; per-source `sharing`/`schedule` are
21
- * retired — see Package.persistencePolicyWarnings). At startup/reload both are
22
- * warn-only instead (fail-safe; see Package.loadViaWorker).
21
+ * retired — see Package.persistencePolicyWarnings), plus persist-target
22
+ * collisions ONLY when `PERSIST_COLLISION_ENFORCE` is set (otherwise those are
23
+ * surfaced warn-only so a pre-existing latent collision doesn't block a routine
24
+ * re-publish — see Package.formatPersistenceCollisionRejections). At
25
+ * startup/reload all are warn-only instead (fail-safe; see Package.loadViaWorker).
23
26
  */
24
27
  function formatPublishRejections(
25
28
  pkg: {
26
29
  formatInvalidExplores(exploresOverride?: string[]): string;
27
30
  formatInvalidPersistencePolicy(): string;
31
+ formatPersistenceCollisionRejections(): string;
28
32
  },
29
33
  exploresOverride?: string[],
30
34
  ): string | undefined {
31
35
  const message = [
32
36
  pkg.formatInvalidExplores(exploresOverride),
33
37
  pkg.formatInvalidPersistencePolicy(),
38
+ pkg.formatPersistenceCollisionRejections(),
34
39
  ]
35
40
  .filter(Boolean)
36
41
  .join("\n");
@@ -3,6 +3,7 @@ import { components } from "../api";
3
3
  import { getQueryTimeoutMs } from "../config";
4
4
  import { API_PREFIX } from "../constants";
5
5
  import { ModelNotFoundError } from "../errors";
6
+ import { bigIntReplacer } from "../json_utils";
6
7
  import { runWithQueryTimeout } from "../query_timeout";
7
8
  import { EnvironmentStore } from "../service/environment_store";
8
9
  import type { FilterParams } from "../service/filter";
@@ -10,14 +11,6 @@ import type { GivenValue } from "@malloydata/malloy";
10
11
 
11
12
  type ApiQuery = components["schemas"]["QueryResult"];
12
13
 
13
- // Replacer function to handle BigInt serialization
14
- function bigIntReplacer(_key: string, value: unknown): unknown {
15
- if (typeof value === "bigint") {
16
- return Number(value);
17
- }
18
- return value;
19
- }
20
-
21
14
  export class QueryController {
22
15
  private environmentStore: EnvironmentStore;
23
16
 
@@ -53,19 +46,20 @@ export class QueryController {
53
46
  if (!model) {
54
47
  throw new ModelNotFoundError(`${modelPath} does not exist`);
55
48
  } else {
56
- const { result, compactResult } = await runWithQueryTimeout(
57
- (abortSignal) =>
58
- model.getQueryResults(
59
- sourceName,
60
- queryName,
61
- query,
62
- filterParams,
63
- bypassFilters,
64
- givens,
65
- abortSignal,
66
- ),
67
- getQueryTimeoutMs(),
68
- );
49
+ const { result, compactResult, rowLimit, rowLimitSource } =
50
+ await runWithQueryTimeout(
51
+ (abortSignal) =>
52
+ model.getQueryResults(
53
+ sourceName,
54
+ queryName,
55
+ query,
56
+ filterParams,
57
+ bypassFilters,
58
+ givens,
59
+ abortSignal,
60
+ ),
61
+ getQueryTimeoutMs(),
62
+ );
69
63
  const renderLogs = validateRenderTags(result);
70
64
  return {
71
65
  result: compactJson
@@ -73,6 +67,17 @@ export class QueryController {
73
67
  : JSON.stringify(result),
74
68
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
75
69
  renderLogs: renderLogs.length > 0 ? renderLogs : undefined,
70
+ // The cap the database applied. A caller counting the rows it got
71
+ // back cannot otherwise tell a complete result from one the limit
72
+ // cut off: a query with no LIMIT of its own silently gets the
73
+ // server default, which is well under the hard ceiling, so nothing
74
+ // raises. Deriving it client-side is not possible, since it depends
75
+ // on server config and on the query's own LIMIT.
76
+ queryRowLimit: rowLimit,
77
+ // Which of the two that cap was. Without it a caller cannot tell a
78
+ // deliberate `limit:`/`top:` from the silently-applied default, and
79
+ // so cannot reproduce the MCP envelope's _limit_hit.
80
+ queryRowLimitSource: rowLimitSource,
76
81
  } as ApiQuery;
77
82
  }
78
83
  }
package/src/errors.ts CHANGED
@@ -24,6 +24,8 @@ export function internalErrorToHttpError(error: Error) {
24
24
  return httpError(422, error.message);
25
25
  } else if (error instanceof UnsupportedCatalogFormatError) {
26
26
  return httpError(422, error.message);
27
+ } else if (error instanceof MaterializationEligibilityError) {
28
+ return httpError(422, error.message);
27
29
  } else if (error instanceof ModelCompilationError) {
28
30
  return httpError(424, error.message);
29
31
  } else if (error instanceof ConnectionError) {
@@ -122,6 +124,23 @@ export class ModelCompilationError extends Error {
122
124
  }
123
125
  }
124
126
 
127
+ /**
128
+ * A persist source was asked to materialize into a `storage=` destination (the
129
+ * DuckDB/DuckLake tier) but is ineligible: it has an unbound free parameter, it
130
+ * references a given (an RLAC/tenant-isolation refusal), or its served shape
131
+ * does not compile in DuckDB. Mapped to HTTP **422** (the request is
132
+ * well-formed, but the source cannot be processed into a materialized artifact)
133
+ * — a hard refuse, never a silent fallback. Kept a distinct class so the
134
+ * givens/RLAC refusal is greppable for security review. Accepts a
135
+ * message-bearing object to match {@link ModelCompilationError}'s ergonomics.
136
+ */
137
+ export class MaterializationEligibilityError extends Error {
138
+ constructor(error: { message: string }) {
139
+ super(error.message);
140
+ this.name = "MaterializationEligibilityError";
141
+ }
142
+ }
143
+
125
144
  export class FrozenConfigError extends Error {
126
145
  constructor(
127
146
  message = `Publisher config can't be updated when ${PUBLISHER_CONFIG_NAME} has { "frozenConfig": true }`,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { bigIntReplacer } from "./json_utils";
3
+
4
+ const ser = (v: unknown) => JSON.stringify(v, bigIntReplacer);
5
+
6
+ describe("bigIntReplacer", () => {
7
+ it("still renders ordinary BigInt ids as numbers", () => {
8
+ // The common case, and the one that must not change: a DuckDB BIGINT
9
+ // column of ordinary ids has always reached callers as JSON numbers, and
10
+ // the MCP rows are byte-compared against the REST payload.
11
+ expect(ser({ id: 100001n })).toBe('{"id":100001}');
12
+ expect(ser({ c: 0n })).toBe('{"c":0}');
13
+ expect(ser({ c: -42n })).toBe('{"c":-42}');
14
+ });
15
+
16
+ it("keeps the largest exactly-representable values as numbers", () => {
17
+ expect(ser({ v: 9007199254740991n })).toBe('{"v":9007199254740991}');
18
+ expect(ser({ v: -9007199254740991n })).toBe('{"v":-9007199254740991}');
19
+ });
20
+
21
+ /**
22
+ * The regression this guards. Number() rounds past 2^53, so an id came back
23
+ * altered with no error: an agent filtering or joining on it gets a wrong or
24
+ * empty answer. Malloy's own wrapResult keeps an exact string for the same
25
+ * case, so a string here matches what the typed-cell path already did.
26
+ */
27
+ it("preserves values past the safe range exactly, as strings", () => {
28
+ expect(ser({ id: 9007199254740993n })).toBe('{"id":"9007199254740993"}');
29
+ expect(ser({ id: 1234567890123456789n })).toBe(
30
+ '{"id":"1234567890123456789"}',
31
+ );
32
+ expect(ser({ id: -9007199254740993n })).toBe(
33
+ '{"id":"-9007199254740993"}',
34
+ );
35
+ });
36
+
37
+ it("round-trips a large id without losing a digit", () => {
38
+ const exact = "9223372036854775807"; // int64 max
39
+ expect(JSON.parse(ser({ id: BigInt(exact) })).id).toBe(exact);
40
+ });
41
+
42
+ it("leaves non-BigInt values alone", () => {
43
+ expect(ser({ a: 1, b: "x", c: null, d: 1.5 })).toBe(
44
+ '{"a":1,"b":"x","c":null,"d":1.5}',
45
+ );
46
+ });
47
+
48
+ it("does not throw on the BigInt a plain stringify would reject", () => {
49
+ expect(() => ser({ c: 150930n })).not.toThrow();
50
+ });
51
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * JSON.stringify replacer for raw driver output, which can carry BigInt.
3
+ *
4
+ * Load-bearing wherever `compactResult` is serialized: it is
5
+ * `queryResults.data.value` straight from the connector, and a DuckDB BIGINT
6
+ * column arrives as a JS BigInt, so a plain JSON.stringify throws
7
+ * `TypeError: Do not know how to serialize a BigInt`. The wrapped
8
+ * `Malloy.Result` path does not need this, because API.util.wrapResult has
9
+ * already normalized values into typed cells.
10
+ *
11
+ * Values inside the safe integer range become numbers, which is what callers
12
+ * have always received and what keeps the MCP and REST payloads byte-identical.
13
+ * Outside it `Number()` rounds silently: 9007199254740993 becomes ...992, and an
14
+ * agent that filters or joins on that id gets a wrong or empty answer with no
15
+ * error. Those are emitted as strings instead, which is what Malloy itself does
16
+ * for the same case (API.util.wrapResult attaches an exact `string_value`
17
+ * beside the lossy `number_value` whenever the subtype is bigint).
18
+ *
19
+ * So the JSON type varies with magnitude. That is deliberate: a column only
20
+ * yields a string for a value a JSON number cannot represent, and every such
21
+ * value was previously being corrupted, so nothing a caller correctly receives
22
+ * today changes.
23
+ */
24
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
25
+
26
+ export function bigIntReplacer(_key: string, value: unknown): unknown {
27
+ if (typeof value === "bigint") {
28
+ return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT
29
+ ? value.toString()
30
+ : Number(value);
31
+ }
32
+ return value;
33
+ }
@@ -1,8 +1,25 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { AxiosError } from "axios";
3
- import { buildAxiosErrorLog, redactSensitive } from "./logger";
3
+ import {
4
+ SENSITIVE_KEY_NAMES,
5
+ buildAxiosErrorLog,
6
+ redactSensitive,
7
+ } from "./logger";
4
8
 
5
9
  describe("redactSensitive", () => {
10
+ it("redacts every name in SENSITIVE_KEY_NAMES (reflective lock)", () => {
11
+ // Iterates the list itself so a newly-added credential field cannot
12
+ // be introduced without redaction, and pins apiKey/api_key.
13
+ const input: Record<string, string> = {};
14
+ for (const name of SENSITIVE_KEY_NAMES) input[name] = "leak-me";
15
+ const out = redactSensitive(input) as Record<string, unknown>;
16
+ for (const name of SENSITIVE_KEY_NAMES) {
17
+ expect(out[name]).toBe("[REDACTED]");
18
+ }
19
+ expect(SENSITIVE_KEY_NAMES).toContain("apiKey");
20
+ expect(SENSITIVE_KEY_NAMES).toContain("api_key");
21
+ });
22
+
6
23
  it("masks every known credential field at the top level", () => {
7
24
  const input = {
8
25
  password: "hunter2",