@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.
Files changed (94) hide show
  1. package/dist/app/api-doc.yaml +102 -10
  2. package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
  7. package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
  8. package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
  11. package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
  13. package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/index-C_tJstcx.js +529 -0
  16. package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +109 -8
  21. package/dist/server.mjs +7504 -80
  22. package/package.json +15 -13
  23. package/scripts/bake-duckdb-extensions.js +5 -2
  24. package/src/config.spec.ts +87 -1
  25. package/src/config.ts +142 -1
  26. package/src/controller/materialization.controller.spec.ts +9 -0
  27. package/src/controller/materialization.controller.ts +15 -0
  28. package/src/ducklake_version.spec.ts +163 -0
  29. package/src/ducklake_version.ts +153 -0
  30. package/src/errors.ts +12 -0
  31. package/src/malloy_pin_prereqs.spec.ts +25 -0
  32. package/src/materialization_metrics.ts +33 -0
  33. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  36. package/src/query_param_utils.ts +11 -0
  37. package/src/server.ts +74 -9
  38. package/src/service/authorize.spec.ts +22 -0
  39. package/src/service/authorize.ts +267 -10
  40. package/src/service/authorize_integration.spec.ts +1068 -26
  41. package/src/service/connection.spec.ts +79 -7
  42. package/src/service/connection.ts +312 -25
  43. package/src/service/connection_config.spec.ts +21 -0
  44. package/src/service/connection_config.ts +15 -1
  45. package/src/service/cron_evaluator.spec.ts +79 -0
  46. package/src/service/cron_evaluator.ts +105 -0
  47. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  48. package/src/service/environment.ts +156 -17
  49. package/src/service/environment_store.spec.ts +569 -14
  50. package/src/service/environment_store.ts +237 -31
  51. package/src/service/environment_store_anchoring.spec.ts +107 -0
  52. package/src/service/extension_fetch_policy.spec.ts +256 -0
  53. package/src/service/manifest_loader.spec.ts +3 -1
  54. package/src/service/manifest_loader.ts +9 -5
  55. package/src/service/materialization_scheduler.spec.ts +289 -0
  56. package/src/service/materialization_scheduler.ts +304 -0
  57. package/src/service/materialization_service.spec.ts +286 -5
  58. package/src/service/materialization_service.ts +121 -8
  59. package/src/service/materialization_test_fixtures.ts +10 -1
  60. package/src/service/model.spec.ts +67 -0
  61. package/src/service/model.ts +656 -31
  62. package/src/service/package.spec.ts +38 -0
  63. package/src/service/package.ts +102 -2
  64. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  65. package/src/service/persistence_policy.spec.ts +31 -0
  66. package/src/service/quoting.spec.ts +57 -1
  67. package/src/service/quoting.ts +33 -0
  68. package/src/storage/DatabaseInterface.ts +15 -0
  69. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  70. package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
  71. package/src/storage/duckdb/MaterializationRepository.ts +68 -0
  72. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  73. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  74. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  75. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  76. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  77. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  78. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  79. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  80. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  81. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  82. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  83. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  84. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  85. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  86. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  87. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  88. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  89. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  90. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  91. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  92. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  93. package/dist/app/assets/index-DDMrjIT3.js +0 -18
  94. 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 () => {
@@ -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 { recordBuildPlanComputeDuration } from "../materialization_metrics";
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
- return base.lookupConnection(name);
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 { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
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
+ });
@@ -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
  }