@malloy-publisher/server 0.0.224 → 0.0.225

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.
@@ -521,6 +521,17 @@ export class Package {
521
521
  return this.buildPlan;
522
522
  }
523
523
 
524
+ /**
525
+ * The package-level `materialization` config (from malloy-publisher.json),
526
+ * the least-specific layer for resolving per-source freshness/schedule in the
527
+ * build plan. Null when the package declares no policy.
528
+ */
529
+ public getMaterializationConfig(): NonNullable<
530
+ ApiPackage["materialization"]
531
+ > | null {
532
+ return this.packageMetadata.materialization ?? null;
533
+ }
534
+
524
535
  public getPackageMetadata(): ApiPackage {
525
536
  // Overlay the server-computed fields onto the stored metadata: the
526
537
  // explores misconfig warnings (loading is fail-safe — the package still
@@ -706,27 +717,61 @@ export class Package {
706
717
  }
707
718
 
708
719
  /**
709
- * Publish-gate for the package-level materialization cron. The package-level
710
- * cron (`materialization.schedule`) is replaced outright at Phase B by
711
- * `materialization.freshness.window` (persistence.md §9.4), so any declared
712
- * cron is rejected. The `sharing=private` carve-out — a cron may legitimately
713
- * own a package's own private artifacts arrives whole with Phase A; until
714
- * `sharing=private` is declarable there is no private artifact for a cron to
715
- * own, so the B→A rule is to reject any declared cron outright
716
- * (persistence-phase-b-design.md §3.4). One actionable message pointing at
717
- * `materialization.freshness.window` (empty when no cron is declared). Strict
718
- * at publish (package.controller), warn-only at load/reload (loadViaWorker)
719
- * same split as explores.
720
+ * Publish-gate for materialization crons (Phase A carve-out, persistence.md
721
+ * §9.4). A cron is the power tier of artifact-anchored scheduling and is
722
+ * valid only over `sharing="private"` artifacts; a cron on a shared (or unset
723
+ * shared) artifact is rejected, pointing at `freshness.window` as the
724
+ * shared-tier replacement. Two grains, both enforced off the resolved build
725
+ * plan (per-source `sharing`/`schedule` are effective, most-specific-wins):
726
+ *
727
+ * - Per-source: a source's own `schedule` is valid only when that source
728
+ * resolves to explicit `sharing="private"`.
729
+ * - Package: `materialization.schedule` governs every persist source, so it
730
+ * is valid only when every governed source resolves to explicit
731
+ * `sharing="private"` (a mixed/shared/unset package cannot carry it).
732
+ *
733
+ * Strict at publish (package.controller), warn-only at load/reload
734
+ * (loadViaWorker) — same split as explores.
720
735
  */
721
736
  public scheduleWarnings(): string[] {
722
- const schedule = this.packageMetadata.materialization?.schedule;
723
- if (!schedule) return [];
724
- return [
725
- `materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is not accepted: ` +
726
- `package-level crons are rejected in Phase B. Declare ` +
727
- `'materialization.freshness.window' instead (the control plane schedules ` +
728
- `refreshes from it).`,
729
- ];
737
+ const warnings: string[] = [];
738
+ const sources = this.buildPlan?.sources
739
+ ? Object.values(this.buildPlan.sources)
740
+ : [];
741
+
742
+ for (const source of sources) {
743
+ if (source.schedule && source.sharing !== "private") {
744
+ warnings.push(
745
+ `#@ persist source "${source.name}" declares a schedule (cron) but ` +
746
+ `resolves to sharing="${
747
+ source.sharing ?? "shared (unset default)"
748
+ }": a per-source cron is valid only for sharing="private". ` +
749
+ `Declare 'freshness.window' instead (the control plane schedules ` +
750
+ `refreshes from it).`,
751
+ );
752
+ }
753
+ }
754
+
755
+ const packageSchedule = this.packageMetadata.materialization?.schedule;
756
+ if (packageSchedule) {
757
+ const nonPrivate = sources.filter((s) => s.sharing !== "private");
758
+ if (sources.length === 0 || nonPrivate.length > 0) {
759
+ const detail =
760
+ sources.length === 0
761
+ ? "the package declares no persist source for it to govern"
762
+ : `sources [${nonPrivate
763
+ .map((s) => s.name)
764
+ .join(", ")}] resolve to shared/unset`;
765
+ warnings.push(
766
+ `materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is valid ` +
767
+ `only when every persist source resolves to sharing="private": ` +
768
+ `${detail}. Declare 'materialization.freshness.window' instead ` +
769
+ `(the control plane schedules refreshes from it).`,
770
+ );
771
+ }
772
+ }
773
+
774
+ return warnings;
730
775
  }
731
776
 
732
777
  /** The {@link scheduleWarnings} joined into one string, or "" if none. */
@@ -116,6 +116,7 @@ export type BuildPlan = components["schemas"]["BuildPlan"];
116
116
  export type BuildManifestResult = components["schemas"]["BuildManifest"];
117
117
  export type ManifestEntry = components["schemas"]["ManifestEntry"];
118
118
  export type BuildInstruction = components["schemas"]["BuildInstruction"];
119
+ export type ManifestReference = components["schemas"]["ManifestReference"];
119
120
  export type Realization = components["schemas"]["Realization"];
120
121
 
121
122
  export interface Materialization {
@@ -0,0 +1,5 @@
1
+ category,amount
2
+ electronics,100
3
+ clothing,50
4
+ electronics,200
5
+ clothing,75
@@ -0,0 +1,18 @@
1
+ ##! experimental.persistence
2
+
3
+ source: raw_orders is duckdb.table('data/orders.csv')
4
+
5
+ // Upstream persist source: an aggregate over the base CSV.
6
+ #@ persist name="orders_base"
7
+ source: orders_base is raw_orders -> {
8
+ group_by: category
9
+ aggregate: total_amount is amount.sum()
10
+ }
11
+
12
+ // Downstream persist source: references the persist upstream `orders_base`.
13
+ // A per-unit build of `orders_rollup` alone must REFERENCE orders_base's
14
+ // materialized table (via referenceManifest) rather than recompute it live.
15
+ #@ persist name="orders_rollup"
16
+ source: orders_rollup is orders_base -> {
17
+ aggregate: category_count is count()
18
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "persist-multi-level",
3
+ "version": "1.0.0",
4
+ "description": "DuckDB two-level persist DAG fixture (orders_base -> orders_rollup)"
5
+ }
@@ -0,0 +1,251 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ const PROJECT_NAME = "test-project-refmanifest";
12
+ const PACKAGE_NAME = "persist-multi-level";
13
+ const API = `/api/v0/environments/${PROJECT_NAME}/packages/${PACKAGE_NAME}`;
14
+
15
+ const TERMINAL_STATUSES = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"];
16
+
17
+ /**
18
+ * WI-1 (Phase D): the orchestrated build seeds its build Manifest from
19
+ * `referenceManifest` and honors `strictUpstreams`. Proven end-to-end over a
20
+ * two-level persist DAG (`orders_base` -> `orders_rollup`):
21
+ *
22
+ * - Build the downstream `orders_rollup` ALONE with a `referenceManifest`
23
+ * pointing at the already-materialized `orders_base` table and
24
+ * `strictUpstreams=true` -> the build SUCCEEDS, which under strict mode is
25
+ * only possible if the upstream reference resolved to the physical table
26
+ * (a strict miss would throw).
27
+ * - Build `orders_rollup` ALONE under `strictUpstreams=true` with NO
28
+ * `referenceManifest` -> the build FAILS loudly (runtime-manifest-strict-miss)
29
+ * instead of silently recomputing the upstream live.
30
+ */
31
+ describe("Materialization reference manifest + strictUpstreams (E2E)", () => {
32
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
33
+ let baseUrl: string;
34
+
35
+ beforeAll(async () => {
36
+ env = await startRestE2E();
37
+ baseUrl = env.baseUrl;
38
+
39
+ const fixtureDir = path.resolve(
40
+ __dirname,
41
+ "../../fixtures/persist-multi-level",
42
+ );
43
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({
47
+ name: PROJECT_NAME,
48
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
49
+ connections: [],
50
+ }),
51
+ });
52
+ if (!createRes.ok) {
53
+ const body = await createRes.text();
54
+ throw new Error(
55
+ `Failed to create test project (${createRes.status}): ${body}`,
56
+ );
57
+ }
58
+
59
+ const deadline = Date.now() + 30_000;
60
+ let pkgReady = false;
61
+ while (!pkgReady && Date.now() < deadline) {
62
+ try {
63
+ const res = await fetch(`${baseUrl}${API}`);
64
+ if (res.ok) {
65
+ pkgReady = true;
66
+ break;
67
+ }
68
+ } catch {
69
+ // not ready yet
70
+ }
71
+ await new Promise((r) => setTimeout(r, 500));
72
+ }
73
+ if (!pkgReady) {
74
+ throw new Error("Test package did not become available in time");
75
+ }
76
+ });
77
+
78
+ afterAll(async () => {
79
+ if (baseUrl) {
80
+ try {
81
+ await fetch(`${baseUrl}/api/v0/environments/${PROJECT_NAME}`, {
82
+ method: "DELETE",
83
+ });
84
+ } catch {
85
+ // best-effort cleanup
86
+ }
87
+ }
88
+ await env?.stop();
89
+ env = null;
90
+ });
91
+
92
+ function url(p: string): string {
93
+ return `${baseUrl}${API}${p}`;
94
+ }
95
+
96
+ async function createMaterialization(
97
+ body: Record<string, unknown> = {},
98
+ ): Promise<Response> {
99
+ return fetch(url("/materializations"), {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify(body),
103
+ });
104
+ }
105
+
106
+ async function pollUntilTerminal(
107
+ id: string,
108
+ timeoutMs = 90_000,
109
+ ): Promise<Record<string, unknown>> {
110
+ const deadline = Date.now() + timeoutMs;
111
+ while (Date.now() < deadline) {
112
+ const res = await fetch(url(`/materializations/${id}`));
113
+ expect(res.status).toBe(200);
114
+ const data = (await res.json()) as Record<string, unknown>;
115
+ if (TERMINAL_STATUSES.includes(data.status as string)) return data;
116
+ await new Promise((r) => setTimeout(r, 250));
117
+ }
118
+ throw new Error(`Materialization ${id} did not reach a terminal state`);
119
+ }
120
+
121
+ /** Delete a terminal materialization record (optionally dropping its tables). */
122
+ async function deleteRun(id: string, dropTables = false): Promise<void> {
123
+ await fetch(
124
+ url(`/materializations/${id}${dropTables ? "?dropTables=true" : ""}`),
125
+ { method: "DELETE" },
126
+ );
127
+ }
128
+
129
+ /** Build one source alone and drive it to a terminal state. */
130
+ async function buildOneAlone(
131
+ body: Record<string, unknown>,
132
+ ): Promise<Record<string, unknown>> {
133
+ const createRes = await createMaterialization(body);
134
+ expect(createRes.status).toBe(201);
135
+ const { id } = (await createRes.json()) as { id: string };
136
+ const settled = await pollUntilTerminal(id);
137
+ return { id, ...settled };
138
+ }
139
+
140
+ /** The planned sources keyed by name (off Package.buildPlan). */
141
+ async function planSourcesByName(): Promise<
142
+ Record<string, Record<string, unknown>>
143
+ > {
144
+ const res = await fetch(url(""));
145
+ expect(res.status).toBe(200);
146
+ const pkg = (await res.json()) as Record<string, unknown>;
147
+ const plan = pkg.buildPlan as Record<string, unknown>;
148
+ expect(plan).toBeDefined();
149
+ const sources = plan.sources as Record<string, Record<string, unknown>>;
150
+ const byName: Record<string, Record<string, unknown>> = {};
151
+ for (const s of Object.values(sources)) {
152
+ byName[s.name as string] = s;
153
+ }
154
+ return byName;
155
+ }
156
+
157
+ const UPSTREAM_TABLE = "orders_base_built";
158
+
159
+ it(
160
+ "builds a downstream source alone, referencing the upstream's physical table under strict",
161
+ async () => {
162
+ const sources = await planSourcesByName();
163
+ const base = sources["orders_base"];
164
+ const rollup = sources["orders_rollup"];
165
+ expect(base).toBeDefined();
166
+ expect(rollup).toBeDefined();
167
+
168
+ // 1) Materialize the upstream `orders_base` into a known physical table.
169
+ const baseRun = await buildOneAlone({
170
+ buildInstructions: {
171
+ sources: [
172
+ {
173
+ sourceEntityId: base.sourceEntityId,
174
+ sourceID: base.sourceID,
175
+ materializedTableId: "mt-base",
176
+ physicalTableName: UPSTREAM_TABLE,
177
+ realization: "COPY",
178
+ },
179
+ ],
180
+ },
181
+ });
182
+ expect(baseRun.status).toBe("MANIFEST_FILE_READY");
183
+ await deleteRun(baseRun.id as string); // keep the physical table
184
+
185
+ // 2) Build the downstream `orders_rollup` ALONE, referencing the
186
+ // upstream's physical table under strictUpstreams. Success is only
187
+ // possible if the reference resolved (strict forbids the live fallback).
188
+ const rollupRun = await buildOneAlone({
189
+ buildInstructions: {
190
+ sources: [
191
+ {
192
+ sourceEntityId: rollup.sourceEntityId,
193
+ sourceID: rollup.sourceID,
194
+ materializedTableId: "mt-rollup",
195
+ physicalTableName: "orders_rollup_built",
196
+ realization: "COPY",
197
+ },
198
+ ],
199
+ referenceManifest: [
200
+ {
201
+ sourceEntityId: base.sourceEntityId,
202
+ physicalTableName: UPSTREAM_TABLE,
203
+ },
204
+ ],
205
+ strictUpstreams: true,
206
+ },
207
+ });
208
+ expect(rollupRun.status).toBe("MANIFEST_FILE_READY");
209
+
210
+ // Cleanup both physical tables and records.
211
+ await deleteRun(rollupRun.id as string, true);
212
+ // Drop the upstream table via a fresh no-op build+drop is overkill;
213
+ // the environment teardown removes the project. Best-effort direct drop
214
+ // is unnecessary for correctness here.
215
+ },
216
+ { timeout: 120_000 },
217
+ );
218
+
219
+ it(
220
+ "fails loudly when a strict upstream is neither built nor referenced",
221
+ async () => {
222
+ const sources = await planSourcesByName();
223
+ const rollup = sources["orders_rollup"];
224
+ expect(rollup).toBeDefined();
225
+
226
+ // Build `orders_rollup` ALONE under strictUpstreams with NO
227
+ // referenceManifest for its upstream -> the compile throws
228
+ // runtime-manifest-strict-miss and the run FAILS.
229
+ const run = await buildOneAlone({
230
+ buildInstructions: {
231
+ sources: [
232
+ {
233
+ sourceEntityId: rollup.sourceEntityId,
234
+ sourceID: rollup.sourceID,
235
+ materializedTableId: "mt-rollup-strict",
236
+ physicalTableName: "orders_rollup_strict",
237
+ realization: "COPY",
238
+ },
239
+ ],
240
+ strictUpstreams: true,
241
+ },
242
+ });
243
+
244
+ expect(run.status).toBe("FAILED");
245
+ expect(String(run.error ?? "")).toContain("manifest");
246
+
247
+ await deleteRun(run.id as string, true);
248
+ },
249
+ { timeout: 120_000 },
250
+ );
251
+ });