@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.
Files changed (33) hide show
  1. package/dist/app/api-doc.yaml +51 -1
  2. package/dist/app/assets/{EnvironmentPage-BqLiaatL.js → EnvironmentPage-D6G5n6mY.js} +1 -1
  3. package/dist/app/assets/{HomePage-G_xpt9XE.js → HomePage-DgZluD8u.js} +1 -1
  4. package/dist/app/assets/{LightMode-s1PDRIsF.js → LightMode-w9v0pEbg.js} +1 -1
  5. package/dist/app/assets/{MainPage-C6TfpC92.js → MainPage-Bi_Tukvr.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-BMO1afhm.js → MaterializationsPage-BVERjnmG.js} +1 -1
  7. package/dist/app/assets/{ModelPage-zHOJwMnU.js → ModelPage-2OjS259-.js} +1 -1
  8. package/dist/app/assets/{PackagePage-GCaWmELQ.js → PackagePage-C8L2On4z.js} +1 -1
  9. package/dist/app/assets/{RouteError-CIC588k4.js → RouteError-DJoNC_Vl.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-BdbIfIGO.js → ThemeEditorPage-B-hJ1zgd.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-C8ucK_H8.js → WorkbookPage-CTbDgGeJ.js} +1 -1
  12. package/dist/app/assets/{core-DFvqRVqM.es-NtfPVC7h.js → core-D9Hl0IDY.es-CmgzHn4c.js} +1 -1
  13. package/dist/app/assets/{index-aYtt-ovi.js → index-BEjJCJjX.js} +1 -1
  14. package/dist/app/assets/{index-aYNf0kTZ.js → index-BF8PkFm8.js} +1 -1
  15. package/dist/app/assets/{index-BxDMCn3s.js → index-Cr-asgoV.js} +4 -4
  16. package/dist/app/assets/{index-B3NcDPbp.js → index-DcCw_qxr.js} +1 -1
  17. package/dist/app/index.html +1 -1
  18. package/dist/package_load_worker.mjs +20 -2
  19. package/dist/server.mjs +35 -5
  20. package/package.json +1 -1
  21. package/src/controller/package.controller.spec.ts +89 -1
  22. package/src/controller/package.controller.ts +28 -3
  23. package/src/package_load/package_load_pool.ts +2 -1
  24. package/src/package_load/package_load_worker.ts +5 -2
  25. package/src/package_load/protocol.ts +2 -1
  26. package/src/service/build_plan.spec.ts +44 -0
  27. package/src/service/build_plan.ts +10 -1
  28. package/src/service/materialization_cron_gate.spec.ts +137 -0
  29. package/src/service/materialization_schedule_surface.spec.ts +30 -1
  30. package/src/service/materialization_test_fixtures.ts +17 -1
  31. package/src/service/package.ts +46 -0
  32. package/src/service/package_manifest.spec.ts +42 -4
  33. 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({ schedule: "0 6 * * *" });
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: () => ({ tag: { text: () => undefined } }),
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
  }
@@ -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
- freshness: { window: "24h" },
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 = (raw as { schedule?: unknown }).schedule;
27
- return { schedule: typeof schedule === "string" ? schedule : null };
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
  }