@malloy-publisher/server 0.0.227 → 0.0.229

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 (106) hide show
  1. package/README.docker.md +8 -8
  2. package/dist/app/api-doc.yaml +84 -1
  3. package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
  4. package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
  5. package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
  6. package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
  7. package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
  8. package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
  9. package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
  10. package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
  11. package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
  12. package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
  13. package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
  14. package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
  15. package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
  16. package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
  17. package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
  18. package/dist/app/assets/index-DxArlgRD.js +529 -0
  19. package/dist/app/index.html +1 -1
  20. package/dist/default-publisher.config.json +7 -7
  21. package/dist/instrumentation.mjs +1 -1
  22. package/dist/package_load_worker.mjs +1 -1
  23. package/dist/runtime/publisher.js +5 -0
  24. package/dist/server.mjs +7675 -899
  25. package/package.json +4 -2
  26. package/publisher.config.example.bigquery.json +7 -7
  27. package/publisher.config.example.duckdb.json +7 -7
  28. package/publisher.config.json +7 -11
  29. package/src/config.spec.ts +89 -3
  30. package/src/config.ts +102 -1
  31. package/src/controller/materialization.controller.spec.ts +9 -0
  32. package/src/controller/materialization.controller.ts +15 -0
  33. package/src/controller/package.controller.ts +62 -31
  34. package/src/default-publisher.config.json +7 -7
  35. package/src/materialization_metrics.ts +33 -0
  36. package/src/mcp/handler_utils.spec.ts +108 -0
  37. package/src/mcp/handler_utils.ts +98 -4
  38. package/src/mcp/server.protocol.spec.ts +58 -0
  39. package/src/mcp/server.ts +29 -1
  40. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  41. package/src/mcp/skills/skills_bundle.json +1 -1
  42. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  43. package/src/mcp/tools/compile_tool.spec.ts +207 -0
  44. package/src/mcp/tools/compile_tool.ts +177 -0
  45. package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
  46. package/src/mcp/tools/execute_query_tool.ts +37 -4
  47. package/src/mcp/tools/get_context_tool.ts +1 -1
  48. package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
  49. package/src/mcp/tools/reload_package_tool.ts +158 -0
  50. package/src/query_param_utils.ts +11 -0
  51. package/src/runtime/publisher.js +5 -0
  52. package/src/server.ts +74 -14
  53. package/src/service/connection.spec.ts +8 -7
  54. package/src/service/cron_evaluator.spec.ts +79 -0
  55. package/src/service/cron_evaluator.ts +105 -0
  56. package/src/service/environment.ts +188 -19
  57. package/src/service/environment_store.spec.ts +372 -2
  58. package/src/service/environment_store.ts +149 -17
  59. package/src/service/environment_store_anchoring.spec.ts +107 -0
  60. package/src/service/manifest_loader.spec.ts +3 -1
  61. package/src/service/manifest_loader.ts +9 -5
  62. package/src/service/materialization_scheduler.spec.ts +260 -0
  63. package/src/service/materialization_scheduler.ts +304 -0
  64. package/src/service/materialization_service.spec.ts +167 -4
  65. package/src/service/materialization_service.ts +121 -8
  66. package/src/service/materialization_test_fixtures.ts +10 -1
  67. package/src/service/model.spec.ts +92 -0
  68. package/src/service/model.ts +58 -7
  69. package/src/service/package.ts +121 -14
  70. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  71. package/src/service/package_reload_safety.spec.ts +193 -0
  72. package/src/service/persistence_policy.spec.ts +31 -0
  73. package/src/service/quoting.spec.ts +57 -1
  74. package/src/service/quoting.ts +33 -0
  75. package/src/storage/DatabaseInterface.ts +15 -0
  76. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  77. package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
  78. package/src/storage/duckdb/MaterializationRepository.ts +56 -0
  79. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  80. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  81. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  82. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  83. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  84. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  85. package/tests/fixtures/query-givens/data/orders.csv +7 -0
  86. package/tests/fixtures/query-givens/model.malloy +34 -0
  87. package/tests/fixtures/query-givens/publisher.json +5 -0
  88. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  89. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  90. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  91. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  92. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  93. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  94. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  95. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
  96. package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
  97. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
  98. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
  99. package/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
  100. package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
  101. package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
  102. package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
  103. package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
  104. package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
  105. package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
  106. package/dist/app/assets/index-DNUZpnaa.js +0 -527
@@ -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
+ });
@@ -0,0 +1,193 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { Environment, PackageStatus } from "./environment";
7
+
8
+ /**
9
+ * Regression tests for RELOAD data loss.
10
+ *
11
+ * `Package.create`'s failure cleanup was written for a fresh install, where the
12
+ * half-built tree is Publisher's to remove. A reload runs the same code against
13
+ * a directory that already exists and is already serving, so a model that
14
+ * failed to compile deleted the user's package directory and evicted the
15
+ * last-good compiled model, taking the package offline until it was
16
+ * re-provisioned. That is reachable from the reload endpoint and the
17
+ * malloy_reloadPackage MCP tool by any caller that saves a broken model and
18
+ * reloads, which is the ordinary authoring mistake.
19
+ *
20
+ * Directory cleanup is now opt-in and only `installPackage` asks for it, so
21
+ * these two guard the reload path. Run against a real `Environment` and a real
22
+ * `Package.create` over temp dirs, the same way package_rollback.spec.ts does.
23
+ */
24
+ describe("failed reload does not destroy a serving package", () => {
25
+ let rootDir: string;
26
+ let envPath: string;
27
+
28
+ const GOOD_MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
29
+ const BROKEN_MODEL = `source: broken is @@@ not valid malloy !!!\n`;
30
+
31
+ async function writePackageDir(dir: string, model: string): Promise<void> {
32
+ await fs.mkdir(dir, { recursive: true });
33
+ await fs.writeFile(
34
+ path.join(dir, "publisher.json"),
35
+ JSON.stringify({ name: "pkg", description: "reload fixture" }),
36
+ );
37
+ await fs.writeFile(path.join(dir, "model.malloy"), model);
38
+ }
39
+
40
+ async function copyDir(src: string, dst: string): Promise<void> {
41
+ await fs.mkdir(dst, { recursive: true });
42
+ await fs.cp(src, dst, { recursive: true });
43
+ }
44
+
45
+ beforeEach(async () => {
46
+ rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-reload-"));
47
+ envPath = path.join(rootDir, "env");
48
+ await fs.mkdir(envPath, { recursive: true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
53
+ });
54
+
55
+ it("keeps the package directory on disk when the reloaded model does not compile", async () => {
56
+ const env = await Environment.create("testEnv", envPath, []);
57
+ const pkgDir = path.join(envPath, "pkg");
58
+ await writePackageDir(pkgDir, GOOD_MODEL);
59
+ await env.addPackage("pkg");
60
+
61
+ // The ordinary authoring mistake: save something that does not compile,
62
+ // then reload.
63
+ await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
64
+ await expect(env.getPackage("pkg", true)).rejects.toThrow();
65
+
66
+ // The directory and the file the user was editing both survive.
67
+ const modelText = await fs.readFile(
68
+ path.join(pkgDir, "model.malloy"),
69
+ "utf-8",
70
+ );
71
+ expect(modelText).toBe(BROKEN_MODEL);
72
+ });
73
+
74
+ it("keeps serving the last good model after a failed reload", async () => {
75
+ const env = await Environment.create("testEnv", envPath, []);
76
+ const pkgDir = path.join(envPath, "pkg");
77
+ await writePackageDir(pkgDir, GOOD_MODEL);
78
+ await env.addPackage("pkg");
79
+
80
+ const servingBefore = await env.getPackage("pkg", false);
81
+
82
+ await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
83
+ await expect(env.getPackage("pkg", true)).rejects.toThrow();
84
+
85
+ // The package is still loaded and answerable: a failed reload reports the
86
+ // compile error, it does not take the package down. Assert the exact
87
+ // state, not just that something is there: a status stranded at LOADING
88
+ // would satisfy toBeDefined() while listPackages skips it, so the package
89
+ // would answer getPackage and be invisible to listings and discovery.
90
+ const stillServing = await env.getPackage("pkg", false);
91
+ expect(stillServing).toBe(servingBefore);
92
+ expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
93
+ expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
94
+ });
95
+
96
+ it("keeps a rolled-back reinstall listed and serving", async () => {
97
+ // The other reload path. A package with an install location reloads via
98
+ // installPackage, whose rollback restores the previous tree but used to
99
+ // drop the status while `packages` kept the old package: it answered
100
+ // getPackage but vanished from listPackages and discovery until a
101
+ // restart. Both maps must agree that it is still serving.
102
+ const env = await Environment.create("testEnv", envPath, []);
103
+ const goodFixture = path.join(rootDir, "good");
104
+ const brokenFixture = path.join(rootDir, "broken");
105
+ await writePackageDir(goodFixture, GOOD_MODEL);
106
+ await writePackageDir(brokenFixture, BROKEN_MODEL);
107
+
108
+ await env.installPackage("pkg", (stagingPath) =>
109
+ copyDir(goodFixture, stagingPath),
110
+ );
111
+ const servingBefore = await env.getPackage("pkg", false);
112
+
113
+ await expect(
114
+ env.installPackage("pkg", (stagingPath) =>
115
+ copyDir(brokenFixture, stagingPath),
116
+ ),
117
+ ).rejects.toThrow();
118
+
119
+ expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
120
+ expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
121
+ expect(await env.getPackage("pkg", false)).toBe(servingBefore);
122
+ // The on-disk half of the same claim: the rejected tree is gone and the
123
+ // user's is back. Without this, nothing pins "your files are left alone".
124
+ expect(
125
+ await fs.readFile(path.join(envPath, "pkg", "model.malloy"), "utf-8"),
126
+ ).toBe(GOOD_MODEL);
127
+ });
128
+
129
+ it("does not advertise a package as serving when the rollback could not restore it", async () => {
130
+ // Keeping the last-good package served is only honest when the rollback
131
+ // actually put its tree back. If nothing was restored, the cached package
132
+ // no longer matches disk, and claiming SERVING would advertise a package
133
+ // over a canonical path that is missing or still holds rejected content.
134
+ const env = await Environment.create("testEnv", envPath, []);
135
+ const pkgDir = path.join(envPath, "pkg");
136
+ await writePackageDir(pkgDir, GOOD_MODEL);
137
+ await env.addPackage("pkg");
138
+ expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
139
+
140
+ // Record retirements rather than scheduling the real drain, which is a
141
+ // 30s timer this test has no reason to wait on.
142
+ const retiredLabels: string[] = [];
143
+ (
144
+ env as unknown as {
145
+ retireConnectionGeneration: (label: string) => void;
146
+ }
147
+ ).retireConnectionGeneration = (label: string) => {
148
+ retiredLabels.push(label);
149
+ };
150
+
151
+ // The tree disappears from under Publisher, so the reinstall has nothing
152
+ // to retire and the rollback has nothing to restore.
153
+ await fs.rm(pkgDir, { recursive: true, force: true });
154
+
155
+ const brokenFixture = path.join(rootDir, "broken-nofallback");
156
+ await writePackageDir(brokenFixture, BROKEN_MODEL);
157
+ await expect(
158
+ env.installPackage("pkg", (stagingPath) =>
159
+ copyDir(brokenFixture, stagingPath),
160
+ ),
161
+ ).rejects.toThrow();
162
+
163
+ // Eviction is the honest outcome here, and both maps must agree on it.
164
+ expect(env.getPackageStatus("pkg")).toBeUndefined();
165
+ expect((await env.listPackages()).map((p) => p.name)).not.toContain(
166
+ "pkg",
167
+ );
168
+ // Assert the packages map too, not just the status: asserting only the
169
+ // status leaves the half that actually strands a package untested.
170
+ await expect(env.getPackage("pkg", false)).rejects.toThrow();
171
+
172
+ // Evicting must retire the old package's connections. Once it leaves
173
+ // this.packages, closeAllConnections cannot reach its MalloyConfig, so
174
+ // skipping the retire orphans its native handles for the process's life.
175
+ // The drain is timer-based, so assert it was scheduled, not that it ran.
176
+ expect(retiredLabels).toContain("package pkg");
177
+ });
178
+
179
+ it("recovers on the next reload once the model compiles again", async () => {
180
+ const env = await Environment.create("testEnv", envPath, []);
181
+ const pkgDir = path.join(envPath, "pkg");
182
+ await writePackageDir(pkgDir, GOOD_MODEL);
183
+ await env.addPackage("pkg");
184
+
185
+ await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
186
+ await expect(env.getPackage("pkg", true)).rejects.toThrow();
187
+
188
+ // Because nothing was deleted, fixing the model and reloading is enough.
189
+ await fs.writeFile(path.join(pkgDir, "model.malloy"), GOOD_MODEL);
190
+ const reloaded = await env.getPackage("pkg", true);
191
+ expect(reloaded).toBeDefined();
192
+ });
193
+ });
@@ -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
  }