@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.
- package/README.docker.md +8 -8
- package/dist/app/api-doc.yaml +84 -1
- package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
- package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
- package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
- package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
- package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
- package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
- package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
- package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
- package/dist/app/assets/index-DxArlgRD.js +529 -0
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +7675 -899
- package/package.json +4 -2
- package/publisher.config.example.bigquery.json +7 -7
- package/publisher.config.example.duckdb.json +7 -7
- package/publisher.config.json +7 -11
- package/src/config.spec.ts +89 -3
- package/src/config.ts +102 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +98 -4
- package/src/mcp/server.protocol.spec.ts +58 -0
- package/src/mcp/server.ts +29 -1
- 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/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +37 -4
- package/src/mcp/tools/get_context_tool.ts +1 -1
- package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/query_param_utils.ts +11 -0
- package/src/runtime/publisher.js +5 -0
- package/src/server.ts +74 -14
- package/src/service/connection.spec.ts +8 -7
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/environment.ts +188 -19
- package/src/service/environment_store.spec.ts +372 -2
- package/src/service/environment_store.ts +149 -17
- package/src/service/environment_store_anchoring.spec.ts +107 -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 +260 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +167 -4
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +121 -14
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/package_reload_safety.spec.ts +193 -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 +154 -0
- package/src/storage/duckdb/MaterializationRepository.ts +56 -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/fixtures/query-givens/data/orders.csv +7 -0
- package/tests/fixtures/query-givens/model.malloy +34 -0
- package/tests/fixtures/query-givens/publisher.json +5 -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_wiring.integration.spec.ts +143 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
- package/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
- package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
- package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
- package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
- package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
- package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
- package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
- 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
|
-
|
|
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
|
-
| {
|
|
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
|
-
{
|
|
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
|
|