@malloy-publisher/server 0.0.223 → 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.
Files changed (43) hide show
  1. package/dist/app/api-doc.yaml +75 -0
  2. package/dist/app/assets/{EnvironmentPage-3wCdljov.js → EnvironmentPage-DYTeXDll.js} +1 -1
  3. package/dist/app/assets/{HomePage-K0GHwqq2.js → HomePage-pDK2BPJY.js} +1 -1
  4. package/dist/app/assets/{LightMode-CwU3kN4I.js → LightMode-C2bwGPY1.js} +1 -1
  5. package/dist/app/assets/{MainPage-CTncHE5T.js → MainPage-WtBulMH_.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-DziAOD6-.js → MaterializationsPage-hMgOtflG.js} +1 -1
  7. package/dist/app/assets/{ModelPage-XrS2jXEc.js → ModelPage-B2N5kYII.js} +1 -1
  8. package/dist/app/assets/{PackagePage-BkwgFxG8.js → PackagePage-CEN90nQG.js} +1 -1
  9. package/dist/app/assets/{RouteError-9cIQga6p.js → RouteError-BG2c5Zf0.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-DWiCRfEe.js → ThemeEditorPage-DNfeUwEZ.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-Ce7FM_Po.js → WorkbookPage-NKI1BhFS.js} +1 -1
  12. package/dist/app/assets/{core-D9Hl0IDY.es-CrO01m4X.js → core-C6anj5c0.es-DDLHqpzt.js} +1 -1
  13. package/dist/app/assets/{index-D2sm5RBA.js → index-C6gZ6sSY.js} +5 -5
  14. package/dist/app/assets/{index-CtQm7kvp.js → index-CzNqKMVl.js} +1 -1
  15. package/dist/app/assets/{index-wJU_kzl2.js → index-DMQtnaf4.js} +2 -2
  16. package/dist/app/assets/{index-Dz8hgkmy.js → index-JXgvyZna.js} +1 -1
  17. package/dist/app/assets/{index-8E2uLeV9.js → index-OEjKNSYb.js} +2 -2
  18. package/dist/app/index.html +1 -1
  19. package/dist/server.mjs +411 -26
  20. package/dist/sshcrypto-8m50vnmb.node +0 -0
  21. package/package.json +12 -12
  22. package/src/controller/materialization.controller.spec.ts +72 -0
  23. package/src/controller/materialization.controller.ts +75 -11
  24. package/src/mcp/skills/skills_bundle.json +1 -1
  25. package/src/service/build_plan.spec.ts +119 -0
  26. package/src/service/build_plan.ts +143 -0
  27. package/src/service/environment.ts +3 -3
  28. package/src/service/freshness.spec.ts +183 -0
  29. package/src/service/freshness.ts +112 -0
  30. package/src/service/manifest_loader.spec.ts +33 -0
  31. package/src/service/manifest_loader.ts +17 -8
  32. package/src/service/materialization_cron_gate.spec.ts +61 -17
  33. package/src/service/materialization_service.spec.ts +42 -0
  34. package/src/service/materialization_service.ts +46 -3
  35. package/src/service/materialization_test_fixtures.ts +47 -14
  36. package/src/service/model.ts +54 -4
  37. package/src/service/package.ts +173 -35
  38. package/src/storage/DatabaseInterface.ts +24 -0
  39. package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
  40. package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
  41. package/tests/fixtures/persist-multi-level/publisher.json +5 -0
  42. package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
  43. package/tests/integration/materialization/reference_manifest.integration.spec.ts +251 -0
@@ -4,7 +4,7 @@ import * as fs from "fs/promises";
4
4
  import { fileURLToPath } from "url";
5
5
  import { components } from "../api";
6
6
  import { logger } from "../logger";
7
- import { BuildManifest } from "../storage/DatabaseInterface";
7
+ import { FreshnessManifest } from "../storage/DatabaseInterface";
8
8
 
9
9
  type WireBuildManifest = components["schemas"]["BuildManifest"];
10
10
 
@@ -58,14 +58,18 @@ async function readManifestBytes(uri: string): Promise<string> {
58
58
 
59
59
  /**
60
60
  * Fetch and parse the control-plane-computed build manifest at `uri`, returning
61
- * the Malloy-runtime binding map (`sourceEntityId -> { tableName }`). The wire manifest
62
- * keys physical tables under `physicalTableName`; the Malloy runtime consumes
63
- * `tableName`, so we translate here. Entries missing a physical table are
64
- * skipped. Throws if the URI can't be read or parsed.
61
+ * the full wire binding map (`sourceEntityId -> { tableName, dataAsOf,
62
+ * freshnessWindowSeconds, freshnessFallback }`). The wire manifest keys physical
63
+ * tables under `physicalTableName`; the Malloy runtime consumes `tableName`, so
64
+ * we translate that field here but otherwise carry the control-plane freshness
65
+ * fields verbatim so the serve path can gate `age vs window` per query. Entries
66
+ * missing a physical table are skipped. This is a pure fetch + parse: it does
67
+ * **not** filter on freshness (that happens per query on the serve path). Throws
68
+ * if the URI can't be read or parsed.
65
69
  */
66
70
  export async function fetchManifestEntries(
67
71
  uri: string,
68
- ): Promise<BuildManifest["entries"]> {
72
+ ): Promise<FreshnessManifest> {
69
73
  const raw = await readManifestBytes(uri);
70
74
 
71
75
  let parsed: WireBuildManifest;
@@ -79,7 +83,7 @@ export async function fetchManifestEntries(
79
83
  );
80
84
  }
81
85
 
82
- const entries: BuildManifest["entries"] = {};
86
+ const entries: FreshnessManifest = {};
83
87
  for (const [sourceEntityId, entry] of Object.entries(parsed.entries ?? {})) {
84
88
  const physicalTableName = entry?.physicalTableName;
85
89
  if (!physicalTableName) {
@@ -89,7 +93,12 @@ export async function fetchManifestEntries(
89
93
  });
90
94
  continue;
91
95
  }
92
- entries[sourceEntityId] = { tableName: physicalTableName };
96
+ entries[sourceEntityId] = {
97
+ tableName: physicalTableName,
98
+ dataAsOf: entry.dataAsOf,
99
+ freshnessWindowSeconds: entry.freshnessWindowSeconds,
100
+ freshnessFallback: entry.freshnessFallback,
101
+ };
93
102
  }
94
103
  return entries;
95
104
  }
@@ -7,14 +7,22 @@ import { Environment } from "./environment";
7
7
  import type { Package } from "./package";
8
8
 
9
9
  /**
10
- * The publish gate for the package-level materialization cron (§9.4
11
- * artifact-anchored scheduling): a declared `materialization.schedule` is the
12
- * power tier and is valid only when every persist source in the compiled
13
- * build plan resolves to an explicit `sharing=private`. These tests run a real
14
- * `Environment` + `Package.create` over temp dirs, so they also prove
15
- * end-to-end that the pinned compiler ACCEPTS `sharing=` / `refresh=` keys in
16
- * the `#@ persist` annotation and that the values survive to the wire build
17
- * plan verbatim (unset stays null — never defaulted to "shared").
10
+ * The publish gate for materialization crons (Phase A carve-out,
11
+ * persistence.md §9.4). A cron is the power tier of artifact-anchored
12
+ * scheduling and is valid only over `sharing="private"` artifacts:
13
+ *
14
+ * - a package-level cron (`materialization.schedule`) is valid only when
15
+ * EVERY governed persist source resolves to explicit `sharing="private"`;
16
+ * - a per-source cron (`#@ persist ... schedule=`) is valid only when THAT
17
+ * source resolves to explicit `sharing="private"`;
18
+ * - a cron on a shared/unset artifact is rejected, pointing at
19
+ * `freshness.window` as the shared-tier replacement.
20
+ *
21
+ * These tests run a real `Environment` + `Package.create` over temp dirs, so
22
+ * they also prove end-to-end that the pinned compiler ACCEPTS `sharing=` /
23
+ * `refresh=` / `schedule=` / `freshness.*` keys in the `#@ persist` annotation
24
+ * and that the values survive to the wire build plan verbatim (unset stays null
25
+ * — never defaulted to "shared").
18
26
  */
19
27
  describe("materialization cron gate", () => {
20
28
  let rootDir: string;
@@ -73,6 +81,20 @@ source: a is duckdb.sql("SELECT 1 as x")
73
81
 
74
82
  #@ persist name="b_table" sharing=private refresh=full
75
83
  source: b is duckdb.sql("SELECT 2 as x")
84
+ `;
85
+
86
+ // Per-source crons: valid on the private source, rejected on the shared and
87
+ // the unset (⇒ shared) sources.
88
+ const PER_SOURCE_CRON_MODEL = `##! experimental.persistence
89
+
90
+ #@ persist name="p_table" sharing=private schedule="0 */6 * * *"
91
+ source: p is duckdb.sql("SELECT 1 as x")
92
+
93
+ #@ persist name="s_table" sharing=shared schedule="0 0 * * *"
94
+ source: s is duckdb.sql("SELECT 2 as x")
95
+
96
+ #@ persist name="u_table" schedule="0 0 * * *"
97
+ source: u is duckdb.sql("SELECT 3 as x")
76
98
  `;
77
99
 
78
100
  it(
@@ -99,29 +121,51 @@ source: b is duckdb.sql("SELECT 2 as x")
99
121
  );
100
122
 
101
123
  it(
102
- "rejects a cron over shared/unset persist sources, pointing at freshness.window",
124
+ "rejects a package cron on a mixed package, pointing at freshness.window",
103
125
  async () => {
126
+ // Phase A: a package cron governs every persist source, so it is valid
127
+ // only when all resolve to explicit private. MIXED has a shared and an
128
+ // unset source, so the package cron is rejected.
104
129
  const pkg = await loadPackage(MIXED_MODEL, "0 6 * * *");
105
130
 
106
131
  const warnings = pkg.scheduleWarnings();
107
- // One actionable message per offending source; the compliant private
108
- // source is not flagged.
109
- expect(warnings).toHaveLength(2);
132
+ expect(warnings).toHaveLength(1);
110
133
  const joined = pkg.formatInvalidSchedule();
111
- expect(joined).toContain("'open' resolves to 'shared'");
112
- expect(joined).toContain("'unspecified' resolves to unset");
113
- expect(joined).not.toContain("'priv'");
134
+ expect(joined).toContain("materialization.schedule");
114
135
  expect(joined).toContain("materialization.freshness.window");
115
136
  },
116
137
  { timeout: 30000 },
117
138
  );
118
139
 
119
140
  it(
120
- "allows a cron when every persist source is explicitly private",
141
+ "accepts a package cron when every persist source is private",
121
142
  async () => {
143
+ // Phase A carve-out: an all-private package legitimately carries a
144
+ // package-level cron (the power tier for its own private artifacts).
122
145
  const pkg = await loadPackage(ALL_PRIVATE_MODEL, "0 6 * * *");
123
146
  expect(pkg.scheduleWarnings()).toEqual([]);
124
- expect(pkg.formatInvalidSchedule()).toBe("");
147
+ },
148
+ { timeout: 30000 },
149
+ );
150
+
151
+ it(
152
+ "rejects a per-source cron unless that source resolves to private",
153
+ async () => {
154
+ // No package cron here — only per-source `schedule=` declarations. The
155
+ // private source's cron is accepted; the shared and unset sources' are
156
+ // rejected, each pointing at freshness.window.
157
+ const pkg = await loadPackage(PER_SOURCE_CRON_MODEL);
158
+
159
+ const warnings = pkg.scheduleWarnings();
160
+ expect(warnings).toHaveLength(2);
161
+ const joined = warnings.join("\n");
162
+ expect(joined).toContain('"s"');
163
+ expect(joined).toContain('"u"');
164
+ expect(joined).not.toContain('"p"');
165
+ expect(joined).toContain("freshness.window");
166
+
167
+ // The resolved per-source schedule is surfaced verbatim on the plan.
168
+ expect(sourceByName(pkg, "p").schedule).toBe("0 */6 * * *");
125
169
  },
126
170
  { timeout: 30000 },
127
171
  );
@@ -921,6 +921,10 @@ describe("runBuild (branch behavior)", () => {
921
921
  sourceNames: string[] | undefined;
922
922
  forceRefresh: boolean;
923
923
  buildInstructions: BuildInstruction[] | undefined;
924
+ referenceManifest?:
925
+ | { sourceEntityId: string; physicalTableName: string }[]
926
+ | undefined;
927
+ strictUpstreams?: boolean | undefined;
924
928
  },
925
929
  signal: AbortSignal,
926
930
  ) => Promise<void>;
@@ -972,6 +976,44 @@ describe("runBuild (branch behavior)", () => {
972
976
  expect(svc.autoLoadManifest.called).toBe(false);
973
977
  });
974
978
 
979
+ it("orchestrated: seeds the build from referenceManifest and honors strictUpstreams", async () => {
980
+ const svc = stubEngine();
981
+ const instructions = [makeInstruction()];
982
+
983
+ await svc.runBuild(
984
+ "mat-1",
985
+ "my-env",
986
+ "pkg",
987
+ {
988
+ sourceNames: undefined,
989
+ forceRefresh: false,
990
+ buildInstructions: instructions,
991
+ referenceManifest: [
992
+ { sourceEntityId: "up-1", physicalTableName: "upstream_tbl" },
993
+ ],
994
+ strictUpstreams: true,
995
+ },
996
+ new AbortController().signal,
997
+ );
998
+
999
+ // The reference manifest becomes the seed (carried) entries so a
1000
+ // downstream build resolves its upstream to the existing physical table.
1001
+ expect(svc.executeInstructedBuild.firstCall.args[2]).toEqual({
1002
+ "up-1": {
1003
+ sourceEntityId: "up-1",
1004
+ physicalTableName: "upstream_tbl",
1005
+ },
1006
+ });
1007
+ // strictUpstreams flows through as the strict flag (5th arg).
1008
+ expect(svc.executeInstructedBuild.firstCall.args[4]).toBe(true);
1009
+ // Reused upstreams are counted as carried, not built.
1010
+ expect(svc.commitManifest.firstCall.args[2]).toMatchObject({
1011
+ mode: "orchestrated",
1012
+ sourcesBuilt: 1,
1013
+ sourcesReused: 1,
1014
+ });
1015
+ });
1016
+
975
1017
  it("auto-run: derives instructions and auto-loads the manifest", async () => {
976
1018
  const svc = stubEngine();
977
1019
 
@@ -23,10 +23,12 @@ import {
23
23
  BuildManifest,
24
24
  BuildManifestResult,
25
25
  BuildPlan,
26
+ FreshnessManifest,
26
27
  Materialization,
27
28
  MaterializationStatus,
28
29
  MaterializationUpdate,
29
30
  ManifestEntry,
31
+ ManifestReference,
30
32
  ResourceRepository,
31
33
  } from "../storage/DatabaseInterface";
32
34
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
@@ -215,6 +217,8 @@ export class MaterializationService {
215
217
  forceRefresh?: boolean;
216
218
  sourceNames?: string[];
217
219
  buildInstructions?: BuildInstruction[];
220
+ referenceManifest?: ManifestReference[];
221
+ strictUpstreams?: boolean;
218
222
  } = {},
219
223
  ): Promise<Materialization> {
220
224
  const environmentId = await this.resolveEnvironmentId(environmentName);
@@ -274,6 +278,8 @@ export class MaterializationService {
274
278
  sourceNames: options.sourceNames,
275
279
  forceRefresh,
276
280
  buildInstructions,
281
+ referenceManifest: options.referenceManifest,
282
+ strictUpstreams: options.strictUpstreams,
277
283
  },
278
284
  signal,
279
285
  ),
@@ -298,6 +304,8 @@ export class MaterializationService {
298
304
  sourceNames: string[] | undefined;
299
305
  forceRefresh: boolean;
300
306
  buildInstructions: BuildInstruction[] | undefined;
307
+ referenceManifest: ManifestReference[] | undefined;
308
+ strictUpstreams: boolean | undefined;
301
309
  },
302
310
  signal: AbortSignal,
303
311
  ): Promise<void> {
@@ -332,7 +340,12 @@ export class MaterializationService {
332
340
  let carried: Record<string, ManifestEntry>;
333
341
  if (orchestrated) {
334
342
  instructions = opts.buildInstructions!;
335
- carried = {};
343
+ // Seed the build Manifest with the caller-supplied upstream
344
+ // references so a downstream source's persist upstream (built in a
345
+ // prior run, not rebuilt here) resolves to its existing physical
346
+ // table instead of recomputing live. The reference key is the
347
+ // compiler's manifest-lookup sourceEntityId (see ManifestReference).
348
+ carried = this.referenceManifestToEntries(opts.referenceManifest);
336
349
  } else {
337
350
  // Skip-if-unchanged: reuse tables from the most recent successful
338
351
  // manifest for sources whose sourceEntityId is unchanged, unless
@@ -356,6 +369,7 @@ export class MaterializationService {
356
369
  instructions,
357
370
  carried,
358
371
  signal,
372
+ opts.strictUpstreams ?? false,
359
373
  );
360
374
 
361
375
  const sourcesBuilt = instructions.length;
@@ -449,6 +463,26 @@ export class MaterializationService {
449
463
  return { instructions, carried };
450
464
  }
451
465
 
466
+ /**
467
+ * Project the caller-supplied upstream reference manifest into the seed
468
+ * entry map `executeInstructedBuild` consumes. Each reference is keyed by the
469
+ * compiler's manifest-lookup sourceEntityId and carries only the physical
470
+ * table name — enough for the build Manifest to resolve a downstream persist
471
+ * reference to the existing table without rebuilding the upstream.
472
+ */
473
+ private referenceManifestToEntries(
474
+ referenceManifest: ManifestReference[] | undefined,
475
+ ): Record<string, ManifestEntry> {
476
+ const entries: Record<string, ManifestEntry> = {};
477
+ for (const ref of referenceManifest ?? []) {
478
+ entries[ref.sourceEntityId] = {
479
+ sourceEntityId: ref.sourceEntityId,
480
+ physicalTableName: ref.physicalTableName,
481
+ };
482
+ }
483
+ return entries;
484
+ }
485
+
452
486
  /**
453
487
  * Entries of the most recent successful (MANIFEST_FILE_READY) materialization
454
488
  * for this package, used for skip-if-unchanged. Excludes the in-flight run.
@@ -480,12 +514,16 @@ export class MaterializationService {
480
514
  environment: {
481
515
  reloadAllModelsForPackage(
482
516
  packageName: string,
483
- manifest: BuildManifest["entries"],
517
+ manifest: FreshnessManifest,
484
518
  ): Promise<void>;
485
519
  },
486
520
  packageName: string,
487
521
  entries: Record<string, ManifestEntry>,
488
522
  ): Promise<void> {
523
+ // The post-build auto-load binds tableName-only entries: the control plane
524
+ // stamps freshness (dataAsOf/window/fallback) on the wire manifest it
525
+ // distributes, not on this in-memory post-build load, so these sources are
526
+ // bound un-gated (always serve the freshly-built table).
489
527
  const manifestEntries: BuildManifest["entries"] = {};
490
528
  for (const [sourceEntityId, entry] of Object.entries(entries)) {
491
529
  if (entry.physicalTableName) {
@@ -560,6 +598,7 @@ export class MaterializationService {
560
598
  instructions: BuildInstruction[],
561
599
  seedEntries: Record<string, ManifestEntry>,
562
600
  signal: AbortSignal,
601
+ strict = false,
563
602
  ): Promise<Record<string, ManifestEntry>> {
564
603
  const { graphs, sources, connectionDigests, connections } = compiled;
565
604
 
@@ -582,8 +621,12 @@ export class MaterializationService {
582
621
 
583
622
  // Accumulates physical names as sources are built so downstream sources
584
623
  // resolve their upstream references to the freshly-assigned tables. Seed
585
- // it with carried-forward entries so reused upstreams resolve too.
624
+ // it with carried-forward entries so reused upstreams resolve too. In
625
+ // strict mode, an upstream persist reference that is neither built here
626
+ // nor seeded fails the compile (runtime-manifest-strict-miss) instead of
627
+ // silently recomputing live.
586
628
  const manifest = new Manifest();
629
+ manifest.strict = strict;
587
630
  const entries: Record<string, ManifestEntry> = {};
588
631
  for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
589
632
  if (entry.physicalTableName) {
@@ -75,6 +75,41 @@ export function makeInstruction(
75
75
  * build internals touch (name/id, deterministic sourceEntityId, SQL, and the
76
76
  * `#@ persist name=` annotation reader, defaulted to "unset").
77
77
  */
78
+ /** A freshness/schedule layer for the fake source's `#@` or `##` tag. */
79
+ interface FakeFreshnessSchedule {
80
+ freshness?: { window?: string; fallback?: string };
81
+ schedule?: string;
82
+ }
83
+
84
+ /**
85
+ * Build a fake Malloy `Tag` supporting both readers the build plan uses:
86
+ * `entries()` (scalar `#@ persist` key=value pairs, for deriveAnnotationFields)
87
+ * and the path-based `text(...at)` (dotted `freshness.window` / `schedule`, for
88
+ * resolveFreshnessSchedule).
89
+ */
90
+ function fakeTag(
91
+ fields: Record<string, string> | undefined,
92
+ fs: FakeFreshnessSchedule | undefined,
93
+ ) {
94
+ return {
95
+ *entries() {
96
+ for (const [key, value] of Object.entries(fields ?? {})) {
97
+ yield [key, { text: () => value }];
98
+ }
99
+ },
100
+ text(...at: string[]): string | undefined {
101
+ if (at.length === 1) {
102
+ if (at[0] === "schedule") return fs?.schedule;
103
+ return fields?.[at[0]];
104
+ }
105
+ if (at.length === 2 && at[0] === "freshness") {
106
+ return fs?.freshness?.[at[1] as "window" | "fallback"];
107
+ }
108
+ return undefined;
109
+ },
110
+ };
111
+ }
112
+
78
113
  export function fakeSource(opts: {
79
114
  name: string;
80
115
  sourceEntityId: string;
@@ -83,6 +118,10 @@ export function fakeSource(opts: {
83
118
  dialectName?: string;
84
119
  /** key=value fields of the `#@ persist` annotation (e.g. sharing). */
85
120
  annotationFields?: Record<string, string>;
121
+ /** Source-level (`#@`) freshness/schedule (dotted keys). */
122
+ freshnessSchedule?: FakeFreshnessSchedule;
123
+ /** Model-file-level (`##`) freshness/schedule default. */
124
+ modelFreshnessSchedule?: FakeFreshnessSchedule;
86
125
  }): PersistSource {
87
126
  const fields = opts.annotationFields;
88
127
  return {
@@ -93,20 +132,14 @@ export function fakeSource(opts: {
93
132
  makeBuildId: () => opts.sourceEntityId,
94
133
  getSQL: () => opts.sql ?? "SELECT 1",
95
134
  annotations: {
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 } },
135
+ parseAsTag: () => ({
136
+ tag: fakeTag(fields, opts.freshnessSchedule),
137
+ }),
138
+ },
139
+ modelAnnotations: {
140
+ parseAsTag: () => ({
141
+ tag: fakeTag(undefined, opts.modelFreshnessSchedule),
142
+ }),
110
143
  },
111
144
  } as unknown as PersistSource;
112
145
  }
@@ -164,6 +164,14 @@ export class Model {
164
164
  exploresDeclared: boolean;
165
165
  isQueryEntryPoint: boolean;
166
166
  } = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
167
+ /** Per-query freshness resolver, pushed down by the owning Package (see
168
+ * Package.wireFreshnessResolvers). Returns the freshness-filtered build
169
+ * manifest for the serve path — threaded into Malloy's per-query
170
+ * `buildManifest` override so a persist source only routes to its
171
+ * materialized table while within its declared freshness window. Undefined
172
+ * (or returning undefined) means no override: the runtime-baked manifest
173
+ * applies, which serves live when unbound. */
174
+ private freshnessResolver?: () => BuildManifest["entries"] | undefined;
167
175
  private meter = publisherMeter();
168
176
  private queryExecutionHistogram = this.meter.createHistogram(
169
177
  "malloy_model_query_duration",
@@ -743,6 +751,29 @@ export class Model {
743
751
  this.discoveryCurationEnabled = enabled;
744
752
  }
745
753
 
754
+ /**
755
+ * Set by the owning Package (see Package.wireFreshnessResolvers). Supplies
756
+ * the freshness-filtered build manifest the serve path threads into Malloy's
757
+ * per-query `buildManifest` override so stale persist sources fall back per
758
+ * their declared policy. See {@link resolveFreshBuildManifest}.
759
+ */
760
+ public setFreshnessResolver(
761
+ resolver: () => BuildManifest["entries"] | undefined,
762
+ ): void {
763
+ this.freshnessResolver = resolver;
764
+ }
765
+
766
+ /**
767
+ * The freshness-filtered build manifest for this query, or undefined when the
768
+ * package is unbound / has no resolver (⇒ no per-query override; the runtime
769
+ * serves live). Evaluated per call so a table that crosses its window while
770
+ * the package stays loaded is gated on the very next query.
771
+ */
772
+ private resolveFreshBuildManifest(): BuildManifest | undefined {
773
+ const entries = this.freshnessResolver?.();
774
+ return entries ? { entries, strict: false } : undefined;
775
+ }
776
+
746
777
  public getSources(): ApiSource[] | undefined {
747
778
  return this.curateForDiscovery(this.sources);
748
779
  }
@@ -1273,8 +1304,14 @@ export class Model {
1273
1304
 
1274
1305
  const maxRows = getMaxQueryRows();
1275
1306
  const maxBytes = getMaxResponseBytes();
1307
+ // Per-query freshness gate (persistence.md §9.3): resolve the
1308
+ // freshness-filtered manifest once and thread it into both the prepare
1309
+ // (for the row limit) and the run so a stale persist source falls back per
1310
+ // its declared policy — and prep/run agree on the same substitution.
1311
+ const buildManifest = this.resolveFreshBuildManifest();
1276
1312
  const rowLimit = resolveModelQueryRowLimit(
1277
- (await runnable.getPreparedResult({ givens })).resultExplore.limit,
1313
+ (await runnable.getPreparedResult({ givens, buildManifest }))
1314
+ .resultExplore.limit,
1278
1315
  { defaultLimit: getDefaultQueryRowLimit(), maxRows },
1279
1316
  );
1280
1317
  const endTime = performance.now();
@@ -1282,7 +1319,12 @@ export class Model {
1282
1319
 
1283
1320
  let queryResults;
1284
1321
  try {
1285
- queryResults = await runnable.run({ rowLimit, givens, abortSignal });
1322
+ queryResults = await runnable.run({
1323
+ rowLimit,
1324
+ givens,
1325
+ abortSignal,
1326
+ buildManifest,
1327
+ });
1286
1328
  } catch (error) {
1287
1329
  // Record error metrics
1288
1330
  const errorEndTime = performance.now();
@@ -1521,9 +1563,16 @@ export class Model {
1521
1563
 
1522
1564
  const cellMaxRows = getMaxQueryRows();
1523
1565
  const cellMaxBytes = getMaxResponseBytes();
1566
+ // Per-query freshness gate (see getQueryResults): the same
1567
+ // freshness-filtered manifest gates notebook-cell queries.
1568
+ const buildManifest = this.resolveFreshBuildManifest();
1524
1569
  const rowLimit = resolveModelQueryRowLimit(
1525
- (await runnableToExecute.getPreparedResult({ givens }))
1526
- .resultExplore.limit,
1570
+ (
1571
+ await runnableToExecute.getPreparedResult({
1572
+ givens,
1573
+ buildManifest,
1574
+ })
1575
+ ).resultExplore.limit,
1527
1576
  {
1528
1577
  defaultLimit: getDefaultQueryRowLimit(),
1529
1578
  maxRows: cellMaxRows,
@@ -1533,6 +1582,7 @@ export class Model {
1533
1582
  rowLimit,
1534
1583
  givens,
1535
1584
  abortSignal,
1585
+ buildManifest,
1536
1586
  });
1537
1587
  const query = (await runnableToExecute.getPreparedQuery())._query;
1538
1588
  queryName = (query as NamedQueryDef).as || query.name;