@malloy-publisher/server 0.0.220 → 0.0.222
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/dist/app/api-doc.yaml +51 -1
- package/dist/app/assets/{EnvironmentPage-BqLiaatL.js → EnvironmentPage-D6G5n6mY.js} +1 -1
- package/dist/app/assets/{HomePage-G_xpt9XE.js → HomePage-DgZluD8u.js} +1 -1
- package/dist/app/assets/{LightMode-s1PDRIsF.js → LightMode-w9v0pEbg.js} +1 -1
- package/dist/app/assets/{MainPage-C6TfpC92.js → MainPage-Bi_Tukvr.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-BMO1afhm.js → MaterializationsPage-BVERjnmG.js} +1 -1
- package/dist/app/assets/{ModelPage-zHOJwMnU.js → ModelPage-2OjS259-.js} +1 -1
- package/dist/app/assets/{PackagePage-GCaWmELQ.js → PackagePage-C8L2On4z.js} +1 -1
- package/dist/app/assets/{RouteError-CIC588k4.js → RouteError-DJoNC_Vl.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-BdbIfIGO.js → ThemeEditorPage-B-hJ1zgd.js} +1 -1
- package/dist/app/assets/{WorkbookPage-C8ucK_H8.js → WorkbookPage-CTbDgGeJ.js} +1 -1
- package/dist/app/assets/{core-DFvqRVqM.es-NtfPVC7h.js → core-D9Hl0IDY.es-CmgzHn4c.js} +1 -1
- package/dist/app/assets/{index-aYtt-ovi.js → index-BEjJCJjX.js} +1 -1
- package/dist/app/assets/{index-aYNf0kTZ.js → index-BF8PkFm8.js} +1 -1
- package/dist/app/assets/{index-BxDMCn3s.js → index-Cr-asgoV.js} +4 -4
- package/dist/app/assets/{index-B3NcDPbp.js → index-DcCw_qxr.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +20 -2
- package/dist/server.mjs +35 -5
- package/package.json +1 -1
- package/src/controller/package.controller.spec.ts +89 -1
- package/src/controller/package.controller.ts +28 -3
- package/src/package_load/package_load_pool.ts +2 -1
- package/src/package_load/package_load_worker.ts +5 -2
- package/src/package_load/protocol.ts +2 -1
- package/src/service/build_plan.spec.ts +44 -0
- package/src/service/build_plan.ts +10 -1
- package/src/service/materialization_cron_gate.spec.ts +137 -0
- package/src/service/materialization_schedule_surface.spec.ts +30 -1
- package/src/service/materialization_test_fixtures.ts +17 -1
- package/src/service/package.ts +46 -0
- package/src/service/package_manifest.spec.ts +42 -4
- package/src/service/package_manifest.ts +53 -4
|
@@ -53,6 +53,30 @@ describe("materialization schedule surfacing", () => {
|
|
|
53
53
|
const pkg = await env.getPackage("pkg", false);
|
|
54
54
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
55
55
|
schedule: "0 6 * * *",
|
|
56
|
+
freshness: null,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
{ timeout: 20000 },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it(
|
|
63
|
+
"surfaces the manifest's materialization.freshness on load",
|
|
64
|
+
async () => {
|
|
65
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
66
|
+
await writePackageDir({
|
|
67
|
+
materialization: {
|
|
68
|
+
freshness: { window: "24h", fallback: "stale_ok" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
await env.addPackage("pkg");
|
|
72
|
+
|
|
73
|
+
// Freshness rides the same channel as schedule: present-and-verbatim
|
|
74
|
+
// when declared, null when unset — the control plane owns the
|
|
75
|
+
// scheduling/gating logic and only needs the values surfaced.
|
|
76
|
+
const pkg = await env.getPackage("pkg", false);
|
|
77
|
+
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
78
|
+
schedule: null,
|
|
79
|
+
freshness: { window: "24h", fallback: "stale_ok" },
|
|
56
80
|
});
|
|
57
81
|
},
|
|
58
82
|
{ timeout: 20000 },
|
|
@@ -71,6 +95,7 @@ describe("materialization schedule surfacing", () => {
|
|
|
71
95
|
const pkg = await env.getPackage("pkg", false);
|
|
72
96
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
73
97
|
schedule: null,
|
|
98
|
+
freshness: null,
|
|
74
99
|
});
|
|
75
100
|
},
|
|
76
101
|
{ timeout: 20000 },
|
|
@@ -91,11 +116,15 @@ describe("materialization schedule surfacing", () => {
|
|
|
91
116
|
name: "pkg",
|
|
92
117
|
description: "updated",
|
|
93
118
|
});
|
|
94
|
-
expect(updated.materialization).toEqual({
|
|
119
|
+
expect(updated.materialization).toEqual({
|
|
120
|
+
schedule: "0 6 * * *",
|
|
121
|
+
freshness: null,
|
|
122
|
+
});
|
|
95
123
|
|
|
96
124
|
const pkg = await env.getPackage("pkg", false);
|
|
97
125
|
expect(pkg.getPackageMetadata().materialization).toEqual({
|
|
98
126
|
schedule: "0 6 * * *",
|
|
127
|
+
freshness: null,
|
|
99
128
|
});
|
|
100
129
|
},
|
|
101
130
|
{ timeout: 20000 },
|
|
@@ -81,7 +81,10 @@ export function fakeSource(opts: {
|
|
|
81
81
|
sql?: string;
|
|
82
82
|
connectionName?: string;
|
|
83
83
|
dialectName?: string;
|
|
84
|
+
/** key=value fields of the `#@ persist` annotation (e.g. sharing). */
|
|
85
|
+
annotationFields?: Record<string, string>;
|
|
84
86
|
}): PersistSource {
|
|
87
|
+
const fields = opts.annotationFields;
|
|
85
88
|
return {
|
|
86
89
|
name: opts.name,
|
|
87
90
|
sourceID: opts.name,
|
|
@@ -90,7 +93,20 @@ export function fakeSource(opts: {
|
|
|
90
93
|
makeBuildId: () => opts.sourceEntityId,
|
|
91
94
|
getSQL: () => opts.sql ?? "SELECT 1",
|
|
92
95
|
annotations: {
|
|
93
|
-
parseAsTag: () =>
|
|
96
|
+
parseAsTag: () =>
|
|
97
|
+
fields
|
|
98
|
+
? {
|
|
99
|
+
tag: {
|
|
100
|
+
*entries() {
|
|
101
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
102
|
+
yield [key, { text: () => value }];
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
: // No annotation fields: entries() is absent, and
|
|
108
|
+
// deriveAnnotationFields degrades to {}.
|
|
109
|
+
{ tag: { text: () => undefined } },
|
|
94
110
|
},
|
|
95
111
|
} as unknown as PersistSource;
|
|
96
112
|
}
|
package/src/service/package.ts
CHANGED
|
@@ -347,6 +347,7 @@ export class Package {
|
|
|
347
347
|
// absence as a schedule removal. See `parsePackageMaterialization`.
|
|
348
348
|
materialization: outcome.packageMetadata.materialization ?? {
|
|
349
349
|
schedule: null,
|
|
350
|
+
freshness: null,
|
|
350
351
|
},
|
|
351
352
|
};
|
|
352
353
|
|
|
@@ -450,6 +451,16 @@ export class Package {
|
|
|
450
451
|
detail: invalidMsg,
|
|
451
452
|
});
|
|
452
453
|
}
|
|
454
|
+
// Same fail-safe split for the materialization cron gate: an existing
|
|
455
|
+
// package whose manifest violates the private-only cron rule still loads
|
|
456
|
+
// (warn), but a publish of it is rejected (see package.controller).
|
|
457
|
+
const invalidSchedule = pkg.formatInvalidSchedule();
|
|
458
|
+
if (invalidSchedule) {
|
|
459
|
+
logger.warn(`Package ${packageName} has an invalid cron schedule`, {
|
|
460
|
+
packageName,
|
|
461
|
+
detail: invalidSchedule,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
453
464
|
pkg.logEmptyDiscoveryWarnings();
|
|
454
465
|
|
|
455
466
|
return pkg;
|
|
@@ -603,6 +614,41 @@ export class Package {
|
|
|
603
614
|
return this.exploreWarnings(exploresOverride).join("\n");
|
|
604
615
|
}
|
|
605
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Publish-gate for the package-level materialization cron (the power tier
|
|
619
|
+
* of artifact-anchored scheduling): a declared `materialization.schedule`
|
|
620
|
+
* governs every persist source in the package, so it is valid only when
|
|
621
|
+
* each source in the compiled build plan resolves to an explicit
|
|
622
|
+
* `sharing=private`. A cron over any shared/unset source would let one
|
|
623
|
+
* package dictate the refresh cadence of an artifact other packages share —
|
|
624
|
+
* those packages declare `materialization.freshness.window` instead and the
|
|
625
|
+
* control plane schedules the shared artifact from the tightest window.
|
|
626
|
+
* One actionable message per offending source (empty when the cron is
|
|
627
|
+
* valid or no cron is declared). Strict at publish (package.controller),
|
|
628
|
+
* warn-only at load/reload (loadViaWorker) — same split as explores.
|
|
629
|
+
*/
|
|
630
|
+
public scheduleWarnings(): string[] {
|
|
631
|
+
const schedule = this.packageMetadata.materialization?.schedule;
|
|
632
|
+
if (!schedule) return [];
|
|
633
|
+
const sources = Object.values(this.buildPlan?.sources ?? {});
|
|
634
|
+
return sources
|
|
635
|
+
.filter((source) => source.sharing !== "private")
|
|
636
|
+
.map(
|
|
637
|
+
(source) =>
|
|
638
|
+
`materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} requires every ` +
|
|
639
|
+
`persist source to declare '#@ persist ... sharing=private'; source ` +
|
|
640
|
+
`'${source.name}' resolves to ${
|
|
641
|
+
source.sharing ? `'${source.sharing}'` : "unset"
|
|
642
|
+
}. Declare 'materialization.freshness.window' instead (the control plane ` +
|
|
643
|
+
`schedules refreshes from it), or mark every persist source sharing=private.`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The {@link scheduleWarnings} joined into one string, or "" if none. */
|
|
648
|
+
public formatInvalidSchedule(): string {
|
|
649
|
+
return this.scheduleWarnings().join("\n");
|
|
650
|
+
}
|
|
651
|
+
|
|
606
652
|
/**
|
|
607
653
|
* One message per LISTED model whose discovery surface is empty because it
|
|
608
654
|
* is import-only (imports other files, declares/re-exports nothing). Such a
|
|
@@ -5,7 +5,7 @@ describe("service/package_manifest", () => {
|
|
|
5
5
|
describe("parsePackageMaterialization", () => {
|
|
6
6
|
it("extracts a string schedule", () => {
|
|
7
7
|
expect(parsePackageMaterialization({ schedule: "0 6 * * *" })).toEqual(
|
|
8
|
-
{ schedule: "0 6 * * *" },
|
|
8
|
+
{ schedule: "0 6 * * *", freshness: null },
|
|
9
9
|
);
|
|
10
10
|
});
|
|
11
11
|
|
|
@@ -18,21 +18,59 @@ describe("service/package_manifest", () => {
|
|
|
18
18
|
expect(
|
|
19
19
|
parsePackageMaterialization({
|
|
20
20
|
schedule: "*/15 * * * *",
|
|
21
|
-
|
|
21
|
+
retries: 3,
|
|
22
22
|
}),
|
|
23
|
-
).toEqual({ schedule: "*/15 * * * *" });
|
|
23
|
+
).toEqual({ schedule: "*/15 * * * *", freshness: null });
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it("degrades a non-string schedule to null", () => {
|
|
27
27
|
expect(parsePackageMaterialization({ schedule: 42 })).toEqual({
|
|
28
28
|
schedule: null,
|
|
29
|
+
freshness: null,
|
|
30
|
+
});
|
|
31
|
+
expect(parsePackageMaterialization({})).toEqual({
|
|
32
|
+
schedule: null,
|
|
33
|
+
freshness: null,
|
|
29
34
|
});
|
|
30
|
-
expect(parsePackageMaterialization({})).toEqual({ schedule: null });
|
|
31
35
|
});
|
|
32
36
|
|
|
33
37
|
it("returns null for non-object input", () => {
|
|
34
38
|
expect(parsePackageMaterialization("0 6 * * *")).toBeNull();
|
|
35
39
|
expect(parsePackageMaterialization(7)).toBeNull();
|
|
36
40
|
});
|
|
41
|
+
|
|
42
|
+
it("extracts a full freshness block verbatim", () => {
|
|
43
|
+
expect(
|
|
44
|
+
parsePackageMaterialization({
|
|
45
|
+
freshness: { window: "24h", fallback: "stale_ok" },
|
|
46
|
+
}),
|
|
47
|
+
).toEqual({
|
|
48
|
+
schedule: null,
|
|
49
|
+
freshness: { window: "24h", fallback: "stale_ok" },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps a partial freshness block (window only)", () => {
|
|
54
|
+
expect(
|
|
55
|
+
parsePackageMaterialization({ freshness: { window: "1h" } }),
|
|
56
|
+
).toEqual({ schedule: null, freshness: { window: "1h" } });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("drops invalid freshness fields rather than defaulting them", () => {
|
|
60
|
+
// A bad value is reported as absent — never substituted — so the
|
|
61
|
+
// control plane sees exactly what was (validly) declared.
|
|
62
|
+
expect(
|
|
63
|
+
parsePackageMaterialization({
|
|
64
|
+
freshness: { window: 24, fallback: "retry" },
|
|
65
|
+
}),
|
|
66
|
+
).toEqual({ schedule: null, freshness: {} });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("degrades a non-object freshness to null", () => {
|
|
70
|
+
expect(parsePackageMaterialization({ freshness: "24h" })).toEqual({
|
|
71
|
+
schedule: null,
|
|
72
|
+
freshness: null,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
37
75
|
});
|
|
38
76
|
});
|
|
@@ -4,18 +4,61 @@
|
|
|
4
4
|
* testable in isolation from the package-load worker that consumes it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const FRESHNESS_FALLBACKS = ["live", "stale_ok", "fail"] as const;
|
|
8
|
+
export type FreshnessFallback = (typeof FRESHNESS_FALLBACKS)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The manifest's `materialization.freshness` block, surfaced verbatim for the
|
|
12
|
+
* control plane (which owns the scheduling and query-time gating logic).
|
|
13
|
+
* Fields are kept only when valid — an invalid value is dropped, never
|
|
14
|
+
* defaulted, so absence on the wire always means "not declared".
|
|
15
|
+
*/
|
|
16
|
+
export interface PackageFreshnessConfig {
|
|
17
|
+
/** Maximum acceptable staleness, as a duration string (e.g. "24h"). */
|
|
18
|
+
window?: string;
|
|
19
|
+
/** Declared query-time behavior when the window is missed. */
|
|
20
|
+
fallback?: FreshnessFallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
export interface PackageMaterializationConfig {
|
|
8
24
|
/**
|
|
9
25
|
* 5-field UNIX cron the control plane uses to schedule version-level
|
|
10
|
-
* re-materialization. Null when absent or not a string.
|
|
26
|
+
* re-materialization. Null when absent or not a string. Publish-gated:
|
|
27
|
+
* only valid when every persist source resolves to explicit
|
|
28
|
+
* `sharing=private` (see Package.scheduleWarnings).
|
|
11
29
|
*/
|
|
12
30
|
schedule: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Freshness policy ({ window, fallback }). Null when the manifest declares
|
|
33
|
+
* none — distinct from an empty object, so the control plane can tell
|
|
34
|
+
* "no policy" from "policy with no valid fields".
|
|
35
|
+
*/
|
|
36
|
+
freshness: PackageFreshnessConfig | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseFreshness(raw: unknown): PackageFreshnessConfig | null {
|
|
40
|
+
if (!raw || typeof raw !== "object") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const { window, fallback } = raw as { window?: unknown; fallback?: unknown };
|
|
44
|
+
const freshness: PackageFreshnessConfig = {};
|
|
45
|
+
if (typeof window === "string") {
|
|
46
|
+
freshness.window = window;
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
typeof fallback === "string" &&
|
|
50
|
+
(FRESHNESS_FALLBACKS as readonly string[]).includes(fallback)
|
|
51
|
+
) {
|
|
52
|
+
freshness.fallback = fallback as FreshnessFallback;
|
|
53
|
+
}
|
|
54
|
+
return freshness;
|
|
13
55
|
}
|
|
14
56
|
|
|
15
57
|
/**
|
|
16
58
|
* Read the manifest's `materialization` object, keeping only recognized fields.
|
|
17
59
|
* Returns null when the block is absent so the API field is null rather than an
|
|
18
|
-
* empty object; a non-string schedule degrades to null
|
|
60
|
+
* empty object; a non-string schedule degrades to null, and an absent or
|
|
61
|
+
* non-object freshness degrades to null.
|
|
19
62
|
*/
|
|
20
63
|
export function parsePackageMaterialization(
|
|
21
64
|
raw: unknown,
|
|
@@ -23,6 +66,12 @@ export function parsePackageMaterialization(
|
|
|
23
66
|
if (!raw || typeof raw !== "object") {
|
|
24
67
|
return null;
|
|
25
68
|
}
|
|
26
|
-
const schedule =
|
|
27
|
-
|
|
69
|
+
const { schedule, freshness } = raw as {
|
|
70
|
+
schedule?: unknown;
|
|
71
|
+
freshness?: unknown;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
schedule: typeof schedule === "string" ? schedule : null,
|
|
75
|
+
freshness: parseFreshness(freshness),
|
|
76
|
+
};
|
|
28
77
|
}
|