@malloy-publisher/server 0.0.231 → 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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- 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.
|
|
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.
|
|
40
|
-
"@malloydata/db-databricks": "^0.0.
|
|
41
|
-
"@malloydata/db-duckdb": "^0.0.
|
|
42
|
-
"@malloydata/db-mysql": "^0.0.
|
|
43
|
-
"@malloydata/db-postgres": "^0.0.
|
|
44
|
-
"@malloydata/db-publisher": "^0.0.
|
|
45
|
-
"@malloydata/db-snowflake": "^0.0.
|
|
46
|
-
"@malloydata/db-trino": "^0.0.
|
|
47
|
-
"@malloydata/malloy": "^0.0.
|
|
48
|
-
"@malloydata/malloy-sql": "^0.0.
|
|
49
|
-
"@malloydata/render-validator": "^0.0.
|
|
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
|
];
|
package/src/config.spec.ts
CHANGED
|
@@ -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)
|
|
22
|
-
*
|
|
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 }`,
|
package/src/logger.spec.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { AxiosError } from "axios";
|
|
3
|
-
import {
|
|
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
|
});
|