@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,304 @@
1
+ import type { MaterializationSchedulerConfig } from "../config";
2
+ import { MaterializationConflictError } from "../errors";
3
+ import { logger } from "../logger";
4
+ import { recordScheduledFire } from "../materialization_metrics";
5
+ import { CronEvaluator } from "./cron_evaluator";
6
+ import type { EnvironmentStore } from "./environment_store";
7
+ import type { MaterializationService } from "./materialization_service";
8
+
9
+ /** Per-package arming state, keyed by `${environmentName}::${packageName}`. */
10
+ interface ScheduleState {
11
+ /** The cron the state was armed from; a change re-arms `nextFireAtMs`. */
12
+ schedule: string;
13
+ /** Epoch ms of the next due fire (`cron.nextAfter`, strictly future when armed). */
14
+ nextFireAtMs: number;
15
+ /** Epoch ms of the last fire attempt, or null if never fired. */
16
+ lastFiredAtMs: number | null;
17
+ }
18
+
19
+ /**
20
+ * Standalone materialization scheduler: fires a loaded package's
21
+ * `materialization.schedule` cron so an open-source publisher (no control plane)
22
+ * can rebuild on a cadence. Modeled on {@link PackageMemoryGovernor} — a
23
+ * config-driven `setInterval` with `start`/`stop` and one `tick`.
24
+ *
25
+ * **Orchestration safety (two independent guards):**
26
+ * 1. Disabled by default — only constructed when
27
+ * {@link getMaterializationSchedulerConfig} returns non-null
28
+ * (`PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER`), which an orchestrated
29
+ * deployment never sets, so the scheduler never runs there.
30
+ * 2. Even when enabled, a package with a configured `manifestLocation` (i.e.
31
+ * control-plane-driven) is skipped — the CP already fires it, so we never
32
+ * double-drive.
33
+ *
34
+ * These guards are **independent and both load-bearing — never collapse them to
35
+ * just the `manifestLocation` check.** A control-plane-loaded package that is
36
+ * *serving live* has `manifestLocation === null`, so Guard 2 does not cover it;
37
+ * only Guard 1 (the opt-in flag, which an orchestrated worker must never set —
38
+ * see {@link getMaterializationSchedulerConfig}) keeps the scheduler off there.
39
+ *
40
+ * It only sweeps **already-loaded** packages (never forces a load), fires the
41
+ * existing `createMaterialization` auto-run path tagged `trigger=SCHEDULER`, and
42
+ * only fires packages whose persistence policy is valid (`scope: version`, no
43
+ * `freshness` — the same rule a publish enforces). Arming state is in memory,
44
+ * but on the first arm after a (re)start it re-anchors from the newest recorded
45
+ * `SCHEDULER` fire (see {@link arm}), so an occurrence that came due during
46
+ * downtime fires exactly once and then jumps forward — recovery matches the
47
+ * control plane's persisted-`next_fire_at` behavior rather than silently
48
+ * skipping. A never-fired schedule still does not fire on the arming tick
49
+ * (`nextAfter(now)` is strictly future).
50
+ */
51
+ export class MaterializationScheduler {
52
+ private timer: ReturnType<typeof setInterval> | null = null;
53
+ private readonly cron: CronEvaluator;
54
+ private readonly state = new Map<string, ScheduleState>();
55
+
56
+ constructor(
57
+ private readonly environmentStore: EnvironmentStore,
58
+ private readonly materializationService: MaterializationService,
59
+ private readonly config: MaterializationSchedulerConfig,
60
+ cron?: CronEvaluator,
61
+ ) {
62
+ this.cron = cron ?? new CronEvaluator();
63
+ }
64
+
65
+ /** Begin the periodic sweep. Idempotent; the interval is `unref`'d. */
66
+ public start(): void {
67
+ if (this.timer !== null) return;
68
+ this.timer = setInterval(() => {
69
+ void this.tick();
70
+ }, this.config.tickIntervalMs);
71
+ (
72
+ this.timer as ReturnType<typeof setInterval> & { unref?: () => void }
73
+ ).unref?.();
74
+ logger.info(
75
+ `MaterializationScheduler started (interval=${this.config.tickIntervalMs}ms, maxFiresPerTick=${this.config.maxFiresPerTick})`,
76
+ );
77
+ }
78
+
79
+ /** Stop the periodic sweep. Idempotent. */
80
+ public stop(): void {
81
+ if (this.timer !== null) {
82
+ clearInterval(this.timer);
83
+ this.timer = null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * One sweep: arm newly-seen schedules, fire due ones (capped per tick), and
89
+ * prune state for packages no longer loaded/scheduled. Exposed so tests can
90
+ * drive it synchronously. Never throws — per-package errors are isolated.
91
+ */
92
+ public async tick(nowMs: number = Date.now()): Promise<void> {
93
+ const seen = new Set<string>();
94
+ let fired = 0;
95
+
96
+ for (const env of this.environmentStore.getLoadedEnvironments()) {
97
+ const environmentName = env.getEnvironmentName();
98
+ for (const pkg of env.getLoadedPackages()) {
99
+ const packageName = pkg.getPackageName();
100
+ const key = `${environmentName}::${packageName}`;
101
+ try {
102
+ const schedule = this.schedulableCron(pkg);
103
+ if (schedule === null) {
104
+ // Not a standalone-schedulable package (no cron, orchestrated,
105
+ // or invalid policy/cron) — drop any stale arming state.
106
+ this.state.delete(key);
107
+ continue;
108
+ }
109
+ seen.add(key);
110
+
111
+ const armed = await this.arm(
112
+ environmentName,
113
+ packageName,
114
+ key,
115
+ schedule,
116
+ nowMs,
117
+ );
118
+ if (nowMs < armed.nextFireAtMs) continue; // not due yet
119
+
120
+ if (fired >= this.config.maxFiresPerTick) {
121
+ // Stampede guard: leave it due; it fires on a later tick.
122
+ continue;
123
+ }
124
+ await this.fire(environmentName, packageName);
125
+ fired++;
126
+ // Advance regardless of outcome so a persistent failure retries on
127
+ // the next cron occurrence (cron cadence), never every tick.
128
+ armed.nextFireAtMs = this.cron
129
+ .nextAfter(schedule, new Date(nowMs))
130
+ .getTime();
131
+ armed.lastFiredAtMs = nowMs;
132
+ } catch (err) {
133
+ logger.warn("MaterializationScheduler: package sweep failed", {
134
+ environmentName,
135
+ packageName,
136
+ error: err instanceof Error ? err.message : String(err),
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const key of [...this.state.keys()]) {
143
+ if (!seen.has(key)) this.state.delete(key);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The package's schedulable cron, or null when it must be skipped: no
149
+ * `materialization.schedule`; a `manifestLocation` (control-plane-driven —
150
+ * Guard 2); an invalid persistence policy (e.g. schedule without
151
+ * `scope: version`, or schedule + freshness — the publish-gate rules); or an
152
+ * unparseable cron. Skips are debug-logged (the policy issues are already
153
+ * warned at package load), not warned each tick.
154
+ */
155
+ private schedulableCron(pkg: {
156
+ getPackageMetadata(): {
157
+ materialization?: { schedule?: string | null } | null;
158
+ manifestLocation?: string | null;
159
+ };
160
+ persistencePolicyWarnings(): string[];
161
+ getPackageName(): string;
162
+ }): string | null {
163
+ const metadata = pkg.getPackageMetadata();
164
+ const schedule = metadata.materialization?.schedule ?? null;
165
+ if (!schedule) return null;
166
+ if (metadata.manifestLocation) return null; // orchestrated — CP fires it
167
+ if (pkg.persistencePolicyWarnings().length > 0) {
168
+ logger.debug(
169
+ "MaterializationScheduler: skipping package with invalid persistence policy",
170
+ { packageName: pkg.getPackageName() },
171
+ );
172
+ return null;
173
+ }
174
+ if (!this.cron.isValid(schedule)) {
175
+ logger.debug("MaterializationScheduler: skipping invalid cron", {
176
+ packageName: pkg.getPackageName(),
177
+ schedule,
178
+ });
179
+ return null;
180
+ }
181
+ return schedule;
182
+ }
183
+
184
+ /**
185
+ * Return the package's arming state, (re)arming when it is new or its cron
186
+ * changed.
187
+ *
188
+ * **First arm of a package this process** (no in-memory state — a fresh start
189
+ * or a newly-loaded package): anchor `nextFireAt` to the newest recorded
190
+ * `SCHEDULER` fire, `nextAfter(lastScheduledFire)`. If that instant is already
191
+ * past (an occurrence came due while the publisher was down), it fires once
192
+ * on this tick and then jumps forward to `nextAfter(now)` — exactly one
193
+ * catch-up, matching the control plane's persisted-`next_fire_at` recovery
194
+ * contract, never a burst. With no prior `SCHEDULER` fire on record we fall
195
+ * back to `nextAfter(now)` (strictly future), so a never-fired schedule does
196
+ * not fire on the arming tick. (Residual divergence: a schedule set while the
197
+ * scheduler was disabled has no prior fire, so it is not caught up on first
198
+ * enable.)
199
+ *
200
+ * **Cron changed**: re-arm from `nextAfter(now)` — an edited cadence starts
201
+ * fresh, with no catch-up for the old cron.
202
+ */
203
+ private async arm(
204
+ environmentName: string,
205
+ packageName: string,
206
+ key: string,
207
+ schedule: string,
208
+ nowMs: number,
209
+ ): Promise<ScheduleState> {
210
+ const existing = this.state.get(key);
211
+ if (existing && existing.schedule === schedule) {
212
+ return existing;
213
+ }
214
+
215
+ const anchor =
216
+ existing === undefined
217
+ ? await this.recoveryAnchor(environmentName, packageName, nowMs)
218
+ : new Date(nowMs);
219
+
220
+ const armed: ScheduleState = {
221
+ schedule,
222
+ nextFireAtMs: this.cron.nextAfter(schedule, anchor).getTime(),
223
+ lastFiredAtMs: existing?.lastFiredAtMs ?? null,
224
+ };
225
+ this.state.set(key, armed);
226
+ return armed;
227
+ }
228
+
229
+ /**
230
+ * The instant to compute the first `nextFireAt` from after a (re)start: the
231
+ * newest recorded `SCHEDULER` fire if any, else `now`. Any lookup failure
232
+ * degrades to `now` (no catch-up) so arming always succeeds.
233
+ */
234
+ private async recoveryAnchor(
235
+ environmentName: string,
236
+ packageName: string,
237
+ nowMs: number,
238
+ ): Promise<Date> {
239
+ try {
240
+ const last =
241
+ await this.materializationService.getLatestScheduledFireAt(
242
+ environmentName,
243
+ packageName,
244
+ );
245
+ return last ?? new Date(nowMs);
246
+ } catch (err) {
247
+ logger.warn(
248
+ "MaterializationScheduler: could not read last scheduled fire; arming from now",
249
+ {
250
+ environmentName,
251
+ packageName,
252
+ error: err instanceof Error ? err.message : String(err),
253
+ },
254
+ );
255
+ return new Date(nowMs);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Fire a whole-package scheduled rebuild via the existing auto-run path. An
261
+ * already-active materialization coalesces (the in-flight build covers this
262
+ * occurrence); any other error is logged and metered but never propagated.
263
+ *
264
+ * **Standalone fires are tables-only.** This path runs a single-phase build
265
+ * that materializes the package's persist sources into tables. A hosted
266
+ * (control-plane-driven) deployment runs a two-phase job — tables, then a
267
+ * second pass over indexed dimensions — so index behavior is a hosted-only
268
+ * concern that cannot be exercised by the standalone scheduler. Anything a
269
+ * schedule fires locally is exactly what the `createMaterialization`
270
+ * auto-run produces on demand; only the `trigger` label differs.
271
+ */
272
+ private async fire(
273
+ environmentName: string,
274
+ packageName: string,
275
+ ): Promise<void> {
276
+ try {
277
+ await this.materializationService.createMaterialization(
278
+ environmentName,
279
+ packageName,
280
+ { forceRefresh: true, trigger: "SCHEDULER" },
281
+ );
282
+ recordScheduledFire("fired");
283
+ logger.info("MaterializationScheduler: fired scheduled rebuild", {
284
+ environmentName,
285
+ packageName,
286
+ });
287
+ } catch (err) {
288
+ if (err instanceof MaterializationConflictError) {
289
+ recordScheduledFire("conflict");
290
+ logger.debug(
291
+ "MaterializationScheduler: skipped fire; a materialization is already active",
292
+ { environmentName, packageName },
293
+ );
294
+ return;
295
+ }
296
+ recordScheduledFire("error");
297
+ logger.warn("MaterializationScheduler: scheduled fire failed", {
298
+ environmentName,
299
+ packageName,
300
+ error: err instanceof Error ? err.message : String(err),
301
+ });
302
+ }
303
+ }
304
+ }
@@ -166,6 +166,7 @@ describe("MaterializationService", () => {
166
166
  forceRefresh: true,
167
167
  sourceNames: ["orders"],
168
168
  mode: "auto",
169
+ trigger: "ON_DEMAND",
169
170
  },
170
171
  ]);
171
172
  });
@@ -183,10 +184,27 @@ describe("MaterializationService", () => {
183
184
  forceRefresh: false,
184
185
  sourceNames: null,
185
186
  mode: "auto",
187
+ trigger: "ON_DEMAND",
186
188
  },
187
189
  );
188
190
  });
189
191
 
192
+ it("records trigger=SCHEDULER when the scheduler fires the run", async () => {
193
+ ctx.repository.getActiveMaterialization.resolves(null);
194
+ ctx.repository.createMaterialization.resolves(
195
+ makeMaterialization({ status: "PENDING" }),
196
+ );
197
+
198
+ await ctx.service.createMaterialization("my-env", "pkg", {
199
+ forceRefresh: true,
200
+ trigger: "SCHEDULER",
201
+ });
202
+
203
+ expect(
204
+ ctx.repository.createMaterialization.firstCall.args[3],
205
+ ).toMatchObject({ mode: "auto", trigger: "SCHEDULER" });
206
+ });
207
+
190
208
  it("rejects when an active materialization already exists", async () => {
191
209
  ctx.repository.getActiveMaterialization.resolves(
192
210
  makeMaterialization({
@@ -788,6 +806,110 @@ describe("executeInstructedBuild", () => {
788
806
  expect(rootIdx).toBeGreaterThan(midIdx);
789
807
  });
790
808
 
809
+ it("seeds a downstream build with the QUOTED upstream reference (case-folding dialect)", async () => {
810
+ // A carried/seeded upstream (built in a prior run or unit, e.g. via
811
+ // referenceManifest) must reach the downstream's SQL generation as the
812
+ // SAME quoted path the builder CREATEd it with; otherwise the downstream
813
+ // CREATE misses the case-preserved table on a case-folding engine
814
+ // (Snowflake). Capture the buildManifest Malloy is handed for the
815
+ // downstream to prove the seed was quoted for its connection's dialect
816
+ // before it becomes a FROM.
817
+ let seen:
818
+ | {
819
+ buildManifest?: {
820
+ entries?: Record<string, { tableName?: string }>;
821
+ };
822
+ }
823
+ | undefined;
824
+ const runSQL = sinon.stub().resolves();
825
+ const connection = {
826
+ runSQL,
827
+ dialectName: "snowflake",
828
+ } as unknown as MalloyConnection;
829
+ const down = fakeSource({
830
+ name: "down",
831
+ sourceEntityId: "bdownaaaaaaaaaa",
832
+ dialectName: "snowflake",
833
+ onGetSQL: (o) => {
834
+ seen = o as typeof seen;
835
+ },
836
+ });
837
+ const compiled = compiledWith(
838
+ { down },
839
+ [["down"]],
840
+ new Map([["duckdb", connection]]),
841
+ );
842
+
843
+ await callExecute(
844
+ compiled,
845
+ [
846
+ {
847
+ sourceEntityId: "bdownaaaaaaaaaa",
848
+ materializedTableId: "mt-d",
849
+ physicalTableName: "down_v1",
850
+ realization: "COPY",
851
+ },
852
+ ],
853
+ {
854
+ up: {
855
+ sourceEntityId: "up",
856
+ physicalTableName: "schema.orders_base",
857
+ connectionName: "duckdb",
858
+ },
859
+ },
860
+ );
861
+
862
+ expect(seen?.buildManifest?.entries?.up?.tableName).toBe(
863
+ '"schema"."orders_base"',
864
+ );
865
+ });
866
+
867
+ it("seeds unquoted when the upstream's connection is absent from the build (older CP / cross-connection)", async () => {
868
+ // No connectionName on the seed (an older control plane) -> bind verbatim,
869
+ // exactly the pre-change behavior. Non-case-folding engines are unaffected;
870
+ // a case-folding engine self-signals by failing the downstream CREATE.
871
+ let seen:
872
+ | {
873
+ buildManifest?: {
874
+ entries?: Record<string, { tableName?: string }>;
875
+ };
876
+ }
877
+ | undefined;
878
+ const runSQL = sinon.stub().resolves();
879
+ const connection = {
880
+ runSQL,
881
+ dialectName: "snowflake",
882
+ } as unknown as MalloyConnection;
883
+ const down = fakeSource({
884
+ name: "down",
885
+ sourceEntityId: "bdownaaaaaaaaaa",
886
+ dialectName: "snowflake",
887
+ onGetSQL: (o) => {
888
+ seen = o as typeof seen;
889
+ },
890
+ });
891
+ const compiled = compiledWith(
892
+ { down },
893
+ [["down"]],
894
+ new Map([["duckdb", connection]]),
895
+ );
896
+
897
+ await callExecute(
898
+ compiled,
899
+ [
900
+ {
901
+ sourceEntityId: "bdownaaaaaaaaaa",
902
+ materializedTableId: "mt-d",
903
+ physicalTableName: "down_v1",
904
+ realization: "COPY",
905
+ },
906
+ ],
907
+ { up: { sourceEntityId: "up", physicalTableName: "orders_base" } },
908
+ );
909
+
910
+ expect(seen?.buildManifest?.entries?.up?.tableName).toBe("orders_base");
911
+ });
912
+
791
913
  it("throws when an instructed graph's connection is missing", async () => {
792
914
  const s1 = fakeSource({ name: "s1", sourceEntityId: "b1aaaaaaaaaaaaaa" });
793
915
  const compiled = compiledWith({ s1 }, [["s1"]], new Map());
@@ -819,6 +941,7 @@ describe("buildOneSource", () => {
819
941
  connection: { runSQL: sinon.SinonStub },
820
942
  physicalTableName: string,
821
943
  dialectName?: string,
944
+ manifest: Manifest = new Manifest(),
822
945
  ): Promise<{ sourceEntityId: string; physicalTableName: string }> {
823
946
  const source = fakeSource({
824
947
  name: "orders",
@@ -847,7 +970,7 @@ describe("buildOneSource", () => {
847
970
  instruction,
848
971
  connection,
849
972
  { duckdb: "dig" },
850
- new Manifest(),
973
+ manifest,
851
974
  );
852
975
  }
853
976
 
@@ -898,6 +1021,33 @@ describe("buildOneSource", () => {
898
1021
  ]);
899
1022
  expect(entry.physicalTableName).toBe("ds.orders_v1");
900
1023
  });
1024
+
1025
+ it("records the QUOTED just-built name in the build manifest (matches the CREATE)", async () => {
1026
+ // The build manifest feeds a downstream persist's FROM verbatim, so the
1027
+ // recorded name must be the SAME quoted path the CREATE used — else a
1028
+ // downstream built later in this run misses the case-preserved table on a
1029
+ // case-folding engine. buildManifest also passes through Malloy's canonical
1030
+ // path validation (requireCanonicalTablePathAnyDialect), so this proves the
1031
+ // quoted form is accepted, not just produced.
1032
+ const runSQL = sinon.stub().resolves();
1033
+ const manifest = new Manifest();
1034
+ await callBuildOneSource(
1035
+ { runSQL },
1036
+ "schema.orders_base",
1037
+ "snowflake",
1038
+ manifest,
1039
+ );
1040
+ expect(manifest.buildManifest.entries["abcdef1234567890"].tableName).toBe(
1041
+ '"schema"."orders_base"',
1042
+ );
1043
+ // The logical (unquoted) name is what the CREATE was told to produce, and
1044
+ // is exactly what quoteTablePath quoted for the DDL — read mirrors write.
1045
+ const created = runSQL
1046
+ .getCalls()
1047
+ .map((c) => c.args[0] as string)
1048
+ .find((s) => s.startsWith("CREATE TABLE"))!;
1049
+ expect(created).toContain('"schema"."orders_base_abcdef123456"');
1050
+ });
901
1051
  });
902
1052
 
903
1053
  describe("runBuild (branch behavior)", () => {
@@ -922,7 +1072,11 @@ describe("runBuild (branch behavior)", () => {
922
1072
  forceRefresh: boolean;
923
1073
  buildInstructions: BuildInstruction[] | undefined;
924
1074
  referenceManifest?:
925
- | { sourceEntityId: string; physicalTableName: string }[]
1075
+ | {
1076
+ sourceEntityId: string;
1077
+ physicalTableName: string;
1078
+ connectionName?: string;
1079
+ }[]
926
1080
  | undefined;
927
1081
  strictUpstreams?: boolean | undefined;
928
1082
  },
@@ -989,7 +1143,11 @@ describe("runBuild (branch behavior)", () => {
989
1143
  forceRefresh: false,
990
1144
  buildInstructions: instructions,
991
1145
  referenceManifest: [
992
- { sourceEntityId: "up-1", physicalTableName: "upstream_tbl" },
1146
+ {
1147
+ sourceEntityId: "up-1",
1148
+ physicalTableName: "upstream_tbl",
1149
+ connectionName: "sf",
1150
+ },
993
1151
  ],
994
1152
  strictUpstreams: true,
995
1153
  },
@@ -998,10 +1156,13 @@ describe("runBuild (branch behavior)", () => {
998
1156
 
999
1157
  // The reference manifest becomes the seed (carried) entries so a
1000
1158
  // downstream build resolves its upstream to the existing physical table.
1159
+ // connectionName is carried through so the seed can be quoted for the
1160
+ // upstream's dialect (see quoteSeedTablePath).
1001
1161
  expect(svc.executeInstructedBuild.firstCall.args[2]).toEqual({
1002
1162
  "up-1": {
1003
1163
  sourceEntityId: "up-1",
1004
1164
  physicalTableName: "upstream_tbl",
1165
+ connectionName: "sf",
1005
1166
  },
1006
1167
  });
1007
1168
  // strictUpstreams flows through as the strict flag (5th arg).
@@ -1065,9 +1226,11 @@ describe("autoLoadManifest", () => {
1065
1226
  ).autoLoadManifest({ reloadAllModelsForPackage }, "pkg", entries);
1066
1227
 
1067
1228
  expect(reloadAllModelsForPackage.calledOnce).toBe(true);
1229
+ // connectionName is carried alongside tableName so the bind step
1230
+ // (Package.quoteBoundTableNames) can quote the path for its dialect.
1068
1231
  expect(reloadAllModelsForPackage.firstCall.args).toEqual([
1069
1232
  "pkg",
1070
- { b1: { tableName: "orders_v1" } },
1233
+ { b1: { tableName: "orders_v1", connectionName: "duckdb" } },
1071
1234
  ]);
1072
1235
  });
1073
1236