@malloy-publisher/server 0.0.228 → 0.0.230
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/dist/app/api-doc.yaml +102 -10
- package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
- package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
- package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
- package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
- package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
- package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
- package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
- package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
- package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/index-C_tJstcx.js +529 -0
- package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +109 -8
- package/dist/server.mjs +7504 -80
- package/package.json +15 -13
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.spec.ts +87 -1
- package/src/config.ts +142 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/ducklake_version.spec.ts +163 -0
- package/src/ducklake_version.ts +153 -0
- package/src/errors.ts +12 -0
- package/src/malloy_pin_prereqs.spec.ts +25 -0
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/skills/build_skills_bundle.ts +29 -9
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +31 -2
- package/src/query_param_utils.ts +11 -0
- package/src/server.ts +74 -9
- package/src/service/authorize.spec.ts +22 -0
- package/src/service/authorize.ts +267 -10
- package/src/service/authorize_integration.spec.ts +1068 -26
- package/src/service/connection.spec.ts +79 -7
- package/src/service/connection.ts +312 -25
- package/src/service/connection_config.spec.ts +21 -0
- package/src/service/connection_config.ts +15 -1
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +156 -17
- package/src/service/environment_store.spec.ts +569 -14
- package/src/service/environment_store.ts +237 -31
- package/src/service/environment_store_anchoring.spec.ts +107 -0
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/manifest_loader.spec.ts +3 -1
- package/src/service/manifest_loader.ts +9 -5
- package/src/service/materialization_scheduler.spec.ts +289 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +286 -5
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +67 -0
- package/src/service/model.ts +656 -31
- package/src/service/package.spec.ts +38 -0
- package/src/service/package.ts +102 -2
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/persistence_policy.spec.ts +31 -0
- package/src/service/quoting.spec.ts +57 -1
- package/src/service/quoting.ts +33 -0
- package/src/storage/DatabaseInterface.ts +15 -0
- package/src/storage/duckdb/DuckDBRepository.ts +17 -0
- package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
- package/src/storage/duckdb/MaterializationRepository.ts +68 -0
- package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
- package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
- package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
- package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
- package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
- package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
- package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
- package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
- package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
- package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
- package/dist/app/assets/index-DDMrjIT3.js +0 -18
- package/dist/app/assets/index-EqslXZ44.js +0 -527
|
@@ -95,6 +95,44 @@ describe("service/package", () => {
|
|
|
95
95
|
expect(pkg).toBeInstanceOf(Package);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
describe("per-package sandbox extension pinning", () => {
|
|
99
|
+
const originalPolicy = process.env.EXTENSION_FETCH_POLICY;
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
if (originalPolicy === undefined) {
|
|
102
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
103
|
+
} else {
|
|
104
|
+
process.env.EXTENSION_FETCH_POLICY = originalPolicy;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it(
|
|
109
|
+
"pins autoinstall off on the :memory: sandbox under local-only",
|
|
110
|
+
async () => {
|
|
111
|
+
// Regression guard for the sandbox bypass: the per-package sandbox
|
|
112
|
+
// has no attached databases, so the environment attach paths never
|
|
113
|
+
// touch it. Under local-only its session must still be pinned
|
|
114
|
+
// against DuckDB's implicit auto-install, or a sandbox query
|
|
115
|
+
// referencing an unbaked extension could silently fetch from the
|
|
116
|
+
// CDN. Uses a spy (not a stub) so real DuckDB runs the pragma.
|
|
117
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
118
|
+
const spy = sinon.spy(DuckDBConnection.prototype, "runSQL");
|
|
119
|
+
await Package.create(
|
|
120
|
+
"testProject",
|
|
121
|
+
"testPackage",
|
|
122
|
+
testPackageDirectory,
|
|
123
|
+
new Map(),
|
|
124
|
+
);
|
|
125
|
+
const issued = spy.getCalls().map((c) => String(c.args[0]));
|
|
126
|
+
expect(
|
|
127
|
+
issued.some((s) =>
|
|
128
|
+
/autoinstall_known_extensions\s*=\s*false/i.test(s),
|
|
129
|
+
),
|
|
130
|
+
).toBe(true);
|
|
131
|
+
},
|
|
132
|
+
{ timeout: 30000 },
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
98
136
|
describe("instance methods", () => {
|
|
99
137
|
describe("create", () => {
|
|
100
138
|
it("should throw PackageNotFoundError if the package manifest does not exist", async () => {
|
package/src/service/package.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs/promises";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
|
|
4
4
|
import "@malloydata/db-duckdb/native";
|
|
5
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
5
6
|
import {
|
|
6
7
|
Connection,
|
|
7
8
|
ConnectionRuntime,
|
|
@@ -27,8 +28,12 @@ import {
|
|
|
27
28
|
PackageNotFoundError,
|
|
28
29
|
ServiceUnavailableError,
|
|
29
30
|
} from "../errors";
|
|
31
|
+
import { applyExtensionSessionSettings } from "./connection";
|
|
30
32
|
import { formatDuration, logger } from "../logger";
|
|
31
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
recordBuildPlanComputeDuration,
|
|
35
|
+
recordManifestBindDegraded,
|
|
36
|
+
} from "../materialization_metrics";
|
|
32
37
|
import {
|
|
33
38
|
LOAD_DURATION_BUCKETS_MS,
|
|
34
39
|
recordPackageLoadPhases,
|
|
@@ -42,7 +47,9 @@ import {
|
|
|
42
47
|
} from "../storage/DatabaseInterface";
|
|
43
48
|
import { errMessage, ignoreDotfiles } from "../utils";
|
|
44
49
|
import { computePackageBuildPlan } from "./build_plan";
|
|
50
|
+
import { CronEvaluator } from "./cron_evaluator";
|
|
45
51
|
import { filterFreshManifest } from "./freshness";
|
|
52
|
+
import { isQuotedIdentifierPath, quoteManifestTablePath } from "./quoting";
|
|
46
53
|
import { Model } from "./model";
|
|
47
54
|
import { assertPersistNamesQuoted } from "./persist_annotation_validation";
|
|
48
55
|
|
|
@@ -865,6 +872,20 @@ export class Package {
|
|
|
865
872
|
}
|
|
866
873
|
}
|
|
867
874
|
|
|
875
|
+
// Rule 4: the cron must be a valid 5-field UNIX expression (no L/W/#/?
|
|
876
|
+
// extensions — see CronEvaluator). Enforced here so publish (strict),
|
|
877
|
+
// PATCH (strict), package load (warn), and the standalone scheduler all
|
|
878
|
+
// apply the identical rule — a garbage cron can no longer pass publish
|
|
879
|
+
// and then silently never arm.
|
|
880
|
+
if (packageSchedule && !new CronEvaluator().isValid(packageSchedule)) {
|
|
881
|
+
warnings.push(
|
|
882
|
+
`materialization.schedule in ${PACKAGE_MANIFEST_NAME} is not a valid ` +
|
|
883
|
+
`5-field UNIX cron: ${JSON.stringify(packageSchedule)}. Use ` +
|
|
884
|
+
`"minute hour day-of-month month day-of-week" (no L/W/#/? ` +
|
|
885
|
+
`extensions).`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
868
889
|
return warnings;
|
|
869
890
|
}
|
|
870
891
|
|
|
@@ -928,6 +949,71 @@ export class Package {
|
|
|
928
949
|
return this.malloyConfig.connections.lookupConnection(connectionName);
|
|
929
950
|
}
|
|
930
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Quote each manifest entry's physical table path for its connection's
|
|
954
|
+
* dialect, mirroring the build side: the builder CREATEs the table with
|
|
955
|
+
* {@link quoteTablePath} (per-segment, case-preserved), so on a case-folding
|
|
956
|
+
* engine (Snowflake uppercases unquoted identifiers) the stored name is only
|
|
957
|
+
* reachable through the same quoting. Malloy pastes a manifest `tableName`
|
|
958
|
+
* into `FROM` verbatim by contract (a bare name means "let the engine
|
|
959
|
+
* fold"), so the case-preserving producer — us — must hand it the quoted
|
|
960
|
+
* form. Same module quotes CREATE and read: the two sides cannot drift.
|
|
961
|
+
*
|
|
962
|
+
* A name already carrying a quote character is passed through verbatim (it
|
|
963
|
+
* is already canonical SQL; control-plane-assigned names are sanitized to
|
|
964
|
+
* `[A-Za-z0-9_\-.]` and can never contain one).
|
|
965
|
+
*
|
|
966
|
+
* Two cases bind verbatim, and they are NOT the same signal:
|
|
967
|
+
* - No `connectionName`: a bare/engine-folding producer that never recorded
|
|
968
|
+
* a connection (simple_builder / malloy-cli). Expected and benign — the
|
|
969
|
+
* pre-change behavior, bound silently, nothing regresses.
|
|
970
|
+
* - `connectionName` present but unresolvable: a genuine misconfiguration
|
|
971
|
+
* (the connection was renamed/removed, or the manifest is out of sync with
|
|
972
|
+
* this package's config). This one entry is degraded — but the source
|
|
973
|
+
* would not serve regardless of quoting, since Malloy needs the connection
|
|
974
|
+
* to run any query against it, so we degrade just this entry rather than
|
|
975
|
+
* fail the whole package bind, and log at ERROR with a fix.
|
|
976
|
+
*/
|
|
977
|
+
private async quoteBoundTableNames(
|
|
978
|
+
entries: FreshnessManifest,
|
|
979
|
+
): Promise<FreshnessManifest> {
|
|
980
|
+
const out: FreshnessManifest = {};
|
|
981
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
982
|
+
let tableName = entry.tableName;
|
|
983
|
+
if (entry.connectionName && !isQuotedIdentifierPath(tableName)) {
|
|
984
|
+
try {
|
|
985
|
+
const connection = await this.getMalloyConnection(
|
|
986
|
+
entry.connectionName,
|
|
987
|
+
);
|
|
988
|
+
tableName = quoteManifestTablePath(
|
|
989
|
+
tableName,
|
|
990
|
+
connection.dialectName,
|
|
991
|
+
);
|
|
992
|
+
} catch (err) {
|
|
993
|
+
recordManifestBindDegraded();
|
|
994
|
+
logger.error(
|
|
995
|
+
`Manifest entry '${sourceEntityId}' names connection ` +
|
|
996
|
+
`'${entry.connectionName}', which this package cannot ` +
|
|
997
|
+
`resolve: binding its table path unquoted, so this source ` +
|
|
998
|
+
`will not serve on a case-folding engine (and queries ` +
|
|
999
|
+
`against it fail regardless, since Malloy needs the ` +
|
|
1000
|
+
`connection to run them). Fix: ensure a connection named ` +
|
|
1001
|
+
`'${entry.connectionName}' exists in this package's ` +
|
|
1002
|
+
`config, or rebuild the manifest against the current config.`,
|
|
1003
|
+
{
|
|
1004
|
+
packageName: this.packageName,
|
|
1005
|
+
sourceEntityId,
|
|
1006
|
+
connectionName: entry.connectionName,
|
|
1007
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1008
|
+
},
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
out[sourceEntityId] = { ...entry, tableName };
|
|
1013
|
+
}
|
|
1014
|
+
return out;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
931
1017
|
public getMalloyConfig(): MalloyConfig {
|
|
932
1018
|
return this.malloyConfig;
|
|
933
1019
|
}
|
|
@@ -956,6 +1042,11 @@ export class Package {
|
|
|
956
1042
|
* — the caller (manifest service) decides how to retry.
|
|
957
1043
|
*/
|
|
958
1044
|
public async reloadAllModels(entries: FreshnessManifest): Promise<void> {
|
|
1045
|
+
// Quote each bound physical name for its connection's dialect BEFORE any
|
|
1046
|
+
// projection: everything downstream (model hydration, the per-query
|
|
1047
|
+
// freshness gate, /compile, /status) reads the entries recorded here, so
|
|
1048
|
+
// this is the one place the write side's quoting is mirrored onto reads.
|
|
1049
|
+
entries = await this.quoteBoundTableNames(entries);
|
|
959
1050
|
// Models are hydrated against the tableName-only projection; the freshness
|
|
960
1051
|
// fields gate the serve path per query (via getFreshBuildManifest), not
|
|
961
1052
|
// model hydration.
|
|
@@ -1167,7 +1258,16 @@ export class Package {
|
|
|
1167
1258
|
malloyConfig.wrapConnections((base) => ({
|
|
1168
1259
|
lookupConnection: async (name?: string) => {
|
|
1169
1260
|
if (!name || name === "duckdb") {
|
|
1170
|
-
|
|
1261
|
+
const connection = await base.lookupConnection(name);
|
|
1262
|
+
// The per-package :memory: sandbox is a Publisher-owned DuckDB
|
|
1263
|
+
// session too. Pin it against implicit auto-install so
|
|
1264
|
+
// EXTENSION_FETCH_POLICY covers it — otherwise local-only's
|
|
1265
|
+
// no-network guarantee had a hole here (the sandbox has no
|
|
1266
|
+
// attached databases, so the env attach paths never touch it).
|
|
1267
|
+
if (connection instanceof DuckDBConnection) {
|
|
1268
|
+
await applyExtensionSessionSettings(connection);
|
|
1269
|
+
}
|
|
1270
|
+
return connection;
|
|
1171
1271
|
}
|
|
1172
1272
|
// Resolve against the *current* environment MalloyConfig so a
|
|
1173
1273
|
// connection-generation swap on Environment propagates without a
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import sinon from "sinon";
|
|
3
|
+
import type { Connection } from "@malloydata/malloy";
|
|
4
|
+
import { logger } from "../logger";
|
|
5
|
+
import type { FreshnessManifest } from "../storage/DatabaseInterface";
|
|
6
|
+
import { Model } from "./model";
|
|
7
|
+
import { Package } from "./package";
|
|
8
|
+
|
|
9
|
+
// Unit coverage for Package.quoteBoundTableNames — the bind-time step that
|
|
10
|
+
// mirrors the CREATE side's quoting onto manifest table paths (PR #904). The
|
|
11
|
+
// integration test proves quote chars appear in real DuckDB SQL, but DuckDB is
|
|
12
|
+
// not case-folding and never exhibited the bug; these tests pin the actual
|
|
13
|
+
// quoted contract per dialect and exercise every branch (dialect selection,
|
|
14
|
+
// no-connection passthrough, already-quoted passthrough, and the
|
|
15
|
+
// unresolvable-connection fallback) without needing a live warehouse. The
|
|
16
|
+
// quoting itself runs through the real `quoteTablePath`, so this is not a
|
|
17
|
+
// re-implementation of the oracle — it drives the shipping code.
|
|
18
|
+
describe("service/package quoteBoundTableNames", () => {
|
|
19
|
+
afterEach(() => sinon.restore());
|
|
20
|
+
|
|
21
|
+
function makePackage(): Package {
|
|
22
|
+
return new Package(
|
|
23
|
+
"testEnv",
|
|
24
|
+
"testPackage",
|
|
25
|
+
"/tmp/does-not-matter",
|
|
26
|
+
{ name: "testPackage", description: "Test package" },
|
|
27
|
+
[],
|
|
28
|
+
new Map<string, Model>(),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reach the private method the same way package.spec.ts reaches private
|
|
33
|
+
// statics — this is the smallest seam that drives the real branch logic.
|
|
34
|
+
function quote(
|
|
35
|
+
pkg: Package,
|
|
36
|
+
entries: FreshnessManifest,
|
|
37
|
+
): Promise<FreshnessManifest> {
|
|
38
|
+
// @ts-expect-error Accessing private method for testing
|
|
39
|
+
return pkg.quoteBoundTableNames(entries);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stubDialect(pkg: Package, dialectName: string): void {
|
|
43
|
+
sinon
|
|
44
|
+
.stub(pkg, "getMalloyConnection")
|
|
45
|
+
.resolves({ dialectName } as Connection);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it("double-quotes each path segment for a case-folding dialect (Snowflake)", async () => {
|
|
49
|
+
const pkg = makePackage();
|
|
50
|
+
stubDialect(pkg, "snowflake");
|
|
51
|
+
|
|
52
|
+
const out = await quote(pkg, {
|
|
53
|
+
s1: { tableName: "schema.order_summary__g0", connectionName: "sf" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(out.s1.tableName).toBe('"schema"."order_summary__g0"');
|
|
57
|
+
// connectionName and any other fields survive the rewrite.
|
|
58
|
+
expect(out.s1.connectionName).toBe("sf");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("double-quotes for other double-quote dialects (Postgres)", async () => {
|
|
62
|
+
const pkg = makePackage();
|
|
63
|
+
stubDialect(pkg, "postgres");
|
|
64
|
+
|
|
65
|
+
const out = await quote(pkg, {
|
|
66
|
+
s1: { tableName: "public.daily", connectionName: "pg" },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(out.s1.tableName).toBe('"public"."daily"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("backticks each segment for a backtick dialect (BigQuery)", async () => {
|
|
73
|
+
const pkg = makePackage();
|
|
74
|
+
stubDialect(pkg, "standardsql");
|
|
75
|
+
|
|
76
|
+
const out = await quote(pkg, {
|
|
77
|
+
s1: { tableName: "my-proj.ds.events", connectionName: "bq" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(out.s1.tableName).toBe("`my-proj`.`ds`.`events`");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("binds verbatim when the producer recorded no connectionName", async () => {
|
|
84
|
+
const pkg = makePackage();
|
|
85
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
86
|
+
|
|
87
|
+
const out = await quote(pkg, {
|
|
88
|
+
s1: { tableName: "schema.bare_builder_table" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(out.s1.tableName).toBe("schema.bare_builder_table");
|
|
92
|
+
// No connection lookup attempted — this is the benign default path.
|
|
93
|
+
expect(spy.called).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("passes an already-quoted name through without double-quoting", async () => {
|
|
97
|
+
const pkg = makePackage();
|
|
98
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
99
|
+
|
|
100
|
+
const out = await quote(pkg, {
|
|
101
|
+
s1: {
|
|
102
|
+
tableName: '"schema"."already_canonical"',
|
|
103
|
+
connectionName: "sf",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(out.s1.tableName).toBe('"schema"."already_canonical"');
|
|
108
|
+
expect(spy.called).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("passes an already-backticked name through without re-quoting", async () => {
|
|
112
|
+
const pkg = makePackage();
|
|
113
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
114
|
+
|
|
115
|
+
const out = await quote(pkg, {
|
|
116
|
+
s1: { tableName: "`ds`.`events`", connectionName: "bq" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(out.s1.tableName).toBe("`ds`.`events`");
|
|
120
|
+
expect(spy.called).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("degrades one entry (unquoted) and logs an actionable error when its connection is unresolvable", async () => {
|
|
124
|
+
const pkg = makePackage();
|
|
125
|
+
sinon
|
|
126
|
+
.stub(pkg, "getMalloyConnection")
|
|
127
|
+
.rejects(new Error("no connection named 'gone'"));
|
|
128
|
+
const errSpy = sinon.stub(logger, "error");
|
|
129
|
+
|
|
130
|
+
const out = await quote(pkg, {
|
|
131
|
+
s1: { tableName: "schema.orphaned", connectionName: "gone" },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The one entry degrades to the unquoted name rather than throwing out
|
|
135
|
+
// of the loop and failing the whole package bind.
|
|
136
|
+
expect(out.s1.tableName).toBe("schema.orphaned");
|
|
137
|
+
// ...but the misconfiguration is a distinct, loud, actionable signal —
|
|
138
|
+
// not folded silently into the benign no-connection default.
|
|
139
|
+
expect(errSpy.calledOnce).toBe(true);
|
|
140
|
+
// winston's error() is overloaded, so widen the recorded args to assert
|
|
141
|
+
// the (message, context) contract this branch emits.
|
|
142
|
+
const [message, ctx] = errSpy.firstCall.args as unknown as [
|
|
143
|
+
string,
|
|
144
|
+
Record<string, unknown>,
|
|
145
|
+
];
|
|
146
|
+
expect(message).toContain("gone");
|
|
147
|
+
expect(message).toContain("Fix:");
|
|
148
|
+
expect(ctx).toMatchObject({
|
|
149
|
+
sourceEntityId: "s1",
|
|
150
|
+
connectionName: "gone",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("quotes resolvable entries and degrades an unresolvable one in the same bind", async () => {
|
|
155
|
+
const pkg = makePackage();
|
|
156
|
+
const lookup = sinon.stub(pkg, "getMalloyConnection");
|
|
157
|
+
lookup
|
|
158
|
+
.withArgs("sf")
|
|
159
|
+
.resolves({ dialectName: "snowflake" } as Connection);
|
|
160
|
+
lookup.withArgs("gone").rejects(new Error("missing"));
|
|
161
|
+
sinon.stub(logger, "error");
|
|
162
|
+
|
|
163
|
+
const out = await quote(pkg, {
|
|
164
|
+
good: { tableName: "schema.kept", connectionName: "sf" },
|
|
165
|
+
bad: { tableName: "schema.orphaned", connectionName: "gone" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(out.good.tableName).toBe('"schema"."kept"');
|
|
169
|
+
expect(out.bad.tableName).toBe("schema.orphaned");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -209,6 +209,37 @@ source: f is duckdb.sql("SELECT 1 as x")
|
|
|
209
209
|
{ timeout: 30000 },
|
|
210
210
|
);
|
|
211
211
|
|
|
212
|
+
// ── Rule 4: the cron must be a valid 5-field UNIX expression ──────────
|
|
213
|
+
|
|
214
|
+
it(
|
|
215
|
+
"rejects an unparseable schedule cron",
|
|
216
|
+
async () => {
|
|
217
|
+
const pkg = await loadPackage(PLAIN_MODEL, {
|
|
218
|
+
scope: "version",
|
|
219
|
+
materialization: { schedule: "not a cron" },
|
|
220
|
+
});
|
|
221
|
+
const joined = pkg.formatInvalidPersistencePolicy();
|
|
222
|
+
expect(joined).toContain("valid");
|
|
223
|
+
expect(joined).toContain("UNIX cron");
|
|
224
|
+
},
|
|
225
|
+
{ timeout: 30000 },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
it(
|
|
229
|
+
"rejects a cron-parser extension the UNIX grammar lacks (L)",
|
|
230
|
+
async () => {
|
|
231
|
+
// Guards config-parity: cron-parser accepts `0 6 L * *` but the control
|
|
232
|
+
// plane's UNIX parser does not, so publish must reject it too rather
|
|
233
|
+
// than let it silently never arm in production.
|
|
234
|
+
const pkg = await loadPackage(PLAIN_MODEL, {
|
|
235
|
+
scope: "version",
|
|
236
|
+
materialization: { schedule: "0 6 L * *" },
|
|
237
|
+
});
|
|
238
|
+
expect(pkg.formatInvalidPersistencePolicy()).toContain("UNIX cron");
|
|
239
|
+
},
|
|
240
|
+
{ timeout: 30000 },
|
|
241
|
+
);
|
|
242
|
+
|
|
212
243
|
// ── inert when nothing is declared ────────────────────────────────────
|
|
213
244
|
|
|
214
245
|
it(
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
bareTableName,
|
|
4
|
+
isQuotedIdentifierPath,
|
|
5
|
+
quoteIdentifier,
|
|
6
|
+
quoteManifestTablePath,
|
|
7
|
+
quoteTablePath,
|
|
8
|
+
} from "./quoting";
|
|
3
9
|
|
|
4
10
|
describe("bareTableName", () => {
|
|
5
11
|
it("returns the segment after the last dot for a qualified name", () => {
|
|
@@ -77,3 +83,53 @@ describe("quoteTablePath", () => {
|
|
|
77
83
|
expect(quoteTablePath("t_ab12_v0", "postgres")).toBe('"t_ab12_v0"');
|
|
78
84
|
});
|
|
79
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("isQuotedIdentifierPath", () => {
|
|
88
|
+
it("is false for a logical, unquoted path (the control-plane form)", () => {
|
|
89
|
+
expect(isQuotedIdentifierPath("schema.order_summary__g0")).toBe(false);
|
|
90
|
+
expect(isQuotedIdentifierPath("order_summary")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("is true once any segment carries a dialect quote char", () => {
|
|
94
|
+
expect(isQuotedIdentifierPath('"schema"."order_summary"')).toBe(true);
|
|
95
|
+
expect(isQuotedIdentifierPath("`ds`.`events`")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// quoteManifestTablePath is the single quoting authority both the serve-side
|
|
100
|
+
// bind and the build-side manifest route through. It runs the real
|
|
101
|
+
// quoteTablePath (not a re-implementation), so this pins the exact per-dialect
|
|
102
|
+
// contract that keeps read (a Malloy FROM) byte-identical to write (the CREATE
|
|
103
|
+
// DDL, which also quotes via quoteTablePath) on every dialect — case-folding or
|
|
104
|
+
// not.
|
|
105
|
+
describe("quoteManifestTablePath", () => {
|
|
106
|
+
it("double-quotes each segment on a case-folding double-quote dialect (Snowflake)", () => {
|
|
107
|
+
expect(
|
|
108
|
+
quoteManifestTablePath("schema.order_summary__g0", "snowflake"),
|
|
109
|
+
).toBe('"schema"."order_summary__g0"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("double-quotes on Postgres and DuckDB (unchanged for lowercase names, correct for mixed case)", () => {
|
|
113
|
+
expect(quoteManifestTablePath("public.daily", "postgres")).toBe(
|
|
114
|
+
'"public"."daily"',
|
|
115
|
+
);
|
|
116
|
+
expect(quoteManifestTablePath("main.orders_mz", "duckdb")).toBe(
|
|
117
|
+
'"main"."orders_mz"',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("backticks each segment on BigQuery (standardsql) — required for hyphenated ids", () => {
|
|
122
|
+
expect(quoteManifestTablePath("my-proj.ds.events", "standardsql")).toBe(
|
|
123
|
+
"`my-proj`.`ds`.`events`",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("passes an already-quoted name through unchanged (any dialect)", () => {
|
|
128
|
+
expect(quoteManifestTablePath('"schema"."already"', "snowflake")).toBe(
|
|
129
|
+
'"schema"."already"',
|
|
130
|
+
);
|
|
131
|
+
expect(quoteManifestTablePath("`ds`.`events`", "standardsql")).toBe(
|
|
132
|
+
"`ds`.`events`",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/service/quoting.ts
CHANGED
|
@@ -43,3 +43,36 @@ export function quoteTablePath(tableName: string, dialect: string): string {
|
|
|
43
43
|
.map((segment) => quoteIdentifier(segment, dialect))
|
|
44
44
|
.join(".");
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a table path already carries a dialect quote character, in which case
|
|
49
|
+
* it is treated as canonical SQL that must not be re-quoted. The single
|
|
50
|
+
* definition of "already quoted" shared by every bind site.
|
|
51
|
+
*/
|
|
52
|
+
export function isQuotedIdentifierPath(tableName: string): boolean {
|
|
53
|
+
return tableName.includes('"') || tableName.includes("`");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Quote a physical table path for a manifest entry so a Malloy `FROM` resolves
|
|
58
|
+
* it correctly, unless it is already canonical SQL (passed through unchanged).
|
|
59
|
+
* This is the single quoting authority for the two places a physical name is
|
|
60
|
+
* bound into a `FROM`: the serve-side bind ({@link Package.quoteBoundTableNames})
|
|
61
|
+
* and the build-side manifest that stitches chained persist sources together
|
|
62
|
+
* (materialization_service). Both must mirror the CREATE side's
|
|
63
|
+
* {@link quoteTablePath} so a case-folding engine (Snowflake uppercases
|
|
64
|
+
* unquoted identifiers) can resolve the case-preserved table the builder wrote.
|
|
65
|
+
*
|
|
66
|
+
* Passing an already-quoted name through is safe for the names this handles:
|
|
67
|
+
* control-plane physical names are logical/unquoted (never contain a quote),
|
|
68
|
+
* and a self-assigned `#@ persist name=` value is the author's own canonical
|
|
69
|
+
* SQL (they own quoting it for the dialect).
|
|
70
|
+
*/
|
|
71
|
+
export function quoteManifestTablePath(
|
|
72
|
+
tableName: string,
|
|
73
|
+
dialect: string,
|
|
74
|
+
): string {
|
|
75
|
+
return isQuotedIdentifierPath(tableName)
|
|
76
|
+
? tableName
|
|
77
|
+
: quoteTablePath(tableName, dialect);
|
|
78
|
+
}
|
|
@@ -59,6 +59,14 @@ export interface ResourceRepository {
|
|
|
59
59
|
packageName: string,
|
|
60
60
|
options?: { limit?: number; offset?: number },
|
|
61
61
|
): Promise<Materialization[]>;
|
|
62
|
+
listMaterializationsByEnvironment(
|
|
63
|
+
environmentId: string,
|
|
64
|
+
options?: { limit?: number; offset?: number },
|
|
65
|
+
): Promise<Materialization[]>;
|
|
66
|
+
getLatestScheduledFireAt(
|
|
67
|
+
environmentId: string,
|
|
68
|
+
packageName: string,
|
|
69
|
+
): Promise<Date | null>;
|
|
62
70
|
getMaterializationById(id: string): Promise<Materialization | null>;
|
|
63
71
|
getActiveMaterialization(
|
|
64
72
|
environmentId: string,
|
|
@@ -174,6 +182,13 @@ export interface BuildManifest {
|
|
|
174
182
|
*/
|
|
175
183
|
export interface FreshnessAwareManifestEntry {
|
|
176
184
|
tableName: string;
|
|
185
|
+
/**
|
|
186
|
+
* The connection the physical table lives on, carried from the wire
|
|
187
|
+
* manifest so the bind step can quote `tableName` for that connection's
|
|
188
|
+
* dialect (see Package.quoteBoundTableNames). Absent on entries whose
|
|
189
|
+
* producer didn't record it; those bind verbatim.
|
|
190
|
+
*/
|
|
191
|
+
connectionName?: string;
|
|
177
192
|
dataAsOf?: string;
|
|
178
193
|
freshnessWindowSeconds?: number;
|
|
179
194
|
freshnessFallback?: "live" | "stale_ok" | "fail";
|
|
@@ -153,6 +153,23 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
153
153
|
return this.materializationRepo.list(environmentId, packageName, options);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
async listMaterializationsByEnvironment(
|
|
157
|
+
environmentId: string,
|
|
158
|
+
options?: { limit?: number; offset?: number },
|
|
159
|
+
): Promise<Materialization[]> {
|
|
160
|
+
return this.materializationRepo.listByEnvironment(environmentId, options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getLatestScheduledFireAt(
|
|
164
|
+
environmentId: string,
|
|
165
|
+
packageName: string,
|
|
166
|
+
): Promise<Date | null> {
|
|
167
|
+
return this.materializationRepo.getLatestScheduledFireAt(
|
|
168
|
+
environmentId,
|
|
169
|
+
packageName,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
async getMaterializationById(id: string): Promise<Materialization | null> {
|
|
157
174
|
return this.materializationRepo.getById(id);
|
|
158
175
|
}
|