@malloy-publisher/server 0.0.230 → 0.0.232

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 (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  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 +3186 -433
  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/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. 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.230",
4
+ "version": "0.0.232",
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");
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 }`,
@@ -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",
package/src/logger.ts CHANGED
@@ -91,7 +91,7 @@ export function formatDuration(durationMs: number): string {
91
91
  // headers (Authorization, Cookie). Those values are masked by key name before
92
92
  // they reach a log transport. Matching is case-insensitive so header casing
93
93
  // (Authorization vs authorization) does not slip through.
94
- const SENSITIVE_KEY_NAMES = [
94
+ export const SENSITIVE_KEY_NAMES = [
95
95
  // connection config credentials
96
96
  "password",
97
97
  "connectionString",
@@ -107,6 +107,8 @@ const SENSITIVE_KEY_NAMES = [
107
107
  "peakaKey",
108
108
  "token",
109
109
  "accessToken",
110
+ "apiKey",
111
+ "api_key",
110
112
  // credential-bearing HTTP headers
111
113
  "authorization",
112
114
  "proxy-authorization",
@@ -1,10 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
 
3
3
  import {
4
+ recordChainedStorageBuild,
4
5
  recordConnectionDigestSkipped,
5
6
  recordDropTables,
7
+ recordEligibilityRefused,
6
8
  recordMaterializationRun,
9
+ recordServeShapeTierDrop,
10
+ recordServeShapeTypeFallback,
7
11
  recordSourcesOutcome,
12
+ recordStorageBuildFailure,
13
+ recordStorageServeRouting,
8
14
  resetMaterializationTelemetryForTesting,
9
15
  } from "./materialization_metrics";
10
16
  import {
@@ -64,15 +70,21 @@ describe("materialization_metrics", () => {
64
70
  ).toBe(2);
65
71
  });
66
72
 
67
- it("counts drop-table outcomes and connection-digest skips", async () => {
68
- recordDropTables("success");
69
- recordDropTables("failure");
73
+ it("counts drop-table outcomes (labeled by engine) and connection-digest skips", async () => {
74
+ recordDropTables("success", "storage");
75
+ recordDropTables("failure", "in_warehouse");
70
76
  recordConnectionDigestSkipped();
71
77
 
72
78
  expect(
73
79
  await harness.collectCounter(
74
80
  "publisher_materialization_drop_tables_total",
75
- { outcome: "failure" },
81
+ { outcome: "failure", engine: "in_warehouse" },
82
+ ),
83
+ ).toBe(1);
84
+ expect(
85
+ await harness.collectCounter(
86
+ "publisher_materialization_drop_tables_total",
87
+ { outcome: "success", engine: "storage" },
76
88
  ),
77
89
  ).toBe(1);
78
90
  expect(
@@ -81,4 +93,77 @@ describe("materialization_metrics", () => {
81
93
  ),
82
94
  ).toBe(1);
83
95
  });
96
+
97
+ it("counts storage= serve-routing decisions labeled by outcome", async () => {
98
+ recordStorageServeRouting("storage");
99
+ recordStorageServeRouting("storage");
100
+ recordStorageServeRouting("live_fallback");
101
+
102
+ expect(
103
+ await harness.collectCounter("publisher_storage_serve_routing_total", {
104
+ outcome: "storage",
105
+ }),
106
+ ).toBe(2);
107
+ expect(
108
+ await harness.collectCounter("publisher_storage_serve_routing_total", {
109
+ outcome: "live_fallback",
110
+ }),
111
+ ).toBe(1);
112
+ });
113
+
114
+ it("counts chained storage builds labeled by outcome", async () => {
115
+ recordChainedStorageBuild("parent_reuse");
116
+ recordChainedStorageBuild("parent_reuse");
117
+ recordChainedStorageBuild("inline_fallback");
118
+ recordChainedStorageBuild("strict_refused");
119
+
120
+ expect(
121
+ await harness.collectCounter("publisher_storage_chained_build_total", {
122
+ outcome: "parent_reuse",
123
+ }),
124
+ ).toBe(2);
125
+ expect(
126
+ await harness.collectCounter("publisher_storage_chained_build_total", {
127
+ outcome: "inline_fallback",
128
+ }),
129
+ ).toBe(1);
130
+ expect(
131
+ await harness.collectCounter("publisher_storage_chained_build_total", {
132
+ outcome: "strict_refused",
133
+ }),
134
+ ).toBe(1);
135
+ });
136
+
137
+ it("counts storage build failures, eligibility refusals, tier drops, and type fallbacks", async () => {
138
+ recordStorageBuildFailure("lake");
139
+ recordEligibilityRefused("given");
140
+ recordEligibilityRefused("free_parameter");
141
+ recordServeShapeTierDrop(0);
142
+ recordServeShapeTypeFallback("array");
143
+
144
+ expect(
145
+ await harness.collectCounter(
146
+ "publisher_storage_build_failures_total",
147
+ { destination: "lake" },
148
+ ),
149
+ ).toBe(1);
150
+ expect(
151
+ await harness.collectCounter(
152
+ "publisher_materialization_eligibility_refused_total",
153
+ { reason: "given" },
154
+ ),
155
+ ).toBe(1);
156
+ expect(
157
+ await harness.collectCounter(
158
+ "publisher_storage_serve_shape_tier_drop_total",
159
+ { tier: "0" },
160
+ ),
161
+ ).toBe(1);
162
+ expect(
163
+ await harness.collectCounter(
164
+ "publisher_storage_serve_shape_type_fallback_total",
165
+ { kind: "array" },
166
+ ),
167
+ ).toBe(1);
168
+ });
84
169
  });