@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.
- package/dist/app/api-doc.yaml +75 -0
- package/dist/app/assets/{EnvironmentPage-3wCdljov.js → EnvironmentPage-DYTeXDll.js} +1 -1
- package/dist/app/assets/{HomePage-K0GHwqq2.js → HomePage-pDK2BPJY.js} +1 -1
- package/dist/app/assets/{LightMode-CwU3kN4I.js → LightMode-C2bwGPY1.js} +1 -1
- package/dist/app/assets/{MainPage-CTncHE5T.js → MainPage-WtBulMH_.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-DziAOD6-.js → MaterializationsPage-hMgOtflG.js} +1 -1
- package/dist/app/assets/{ModelPage-XrS2jXEc.js → ModelPage-B2N5kYII.js} +1 -1
- package/dist/app/assets/{PackagePage-BkwgFxG8.js → PackagePage-CEN90nQG.js} +1 -1
- package/dist/app/assets/{RouteError-9cIQga6p.js → RouteError-BG2c5Zf0.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DWiCRfEe.js → ThemeEditorPage-DNfeUwEZ.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Ce7FM_Po.js → WorkbookPage-NKI1BhFS.js} +1 -1
- package/dist/app/assets/{core-D9Hl0IDY.es-CrO01m4X.js → core-C6anj5c0.es-DDLHqpzt.js} +1 -1
- package/dist/app/assets/{index-D2sm5RBA.js → index-C6gZ6sSY.js} +5 -5
- package/dist/app/assets/{index-CtQm7kvp.js → index-CzNqKMVl.js} +1 -1
- package/dist/app/assets/{index-wJU_kzl2.js → index-DMQtnaf4.js} +2 -2
- package/dist/app/assets/{index-Dz8hgkmy.js → index-JXgvyZna.js} +1 -1
- package/dist/app/assets/{index-8E2uLeV9.js → index-OEjKNSYb.js} +2 -2
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +411 -26
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +12 -12
- package/src/controller/materialization.controller.spec.ts +72 -0
- package/src/controller/materialization.controller.ts +75 -11
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/service/build_plan.spec.ts +119 -0
- package/src/service/build_plan.ts +143 -0
- package/src/service/environment.ts +3 -3
- package/src/service/freshness.spec.ts +183 -0
- package/src/service/freshness.ts +112 -0
- package/src/service/manifest_loader.spec.ts +33 -0
- package/src/service/manifest_loader.ts +17 -8
- package/src/service/materialization_cron_gate.spec.ts +61 -17
- package/src/service/materialization_service.spec.ts +42 -0
- package/src/service/materialization_service.ts +46 -3
- package/src/service/materialization_test_fixtures.ts +47 -14
- package/src/service/model.ts +54 -4
- package/src/service/package.ts +173 -35
- package/src/storage/DatabaseInterface.ts +24 -0
- package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
- package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
- package/tests/fixtures/persist-multi-level/publisher.json +5 -0
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
- 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 {
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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<
|
|
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:
|
|
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] = {
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
}
|
package/src/service/model.ts
CHANGED
|
@@ -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 }))
|
|
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({
|
|
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
|
-
(
|
|
1526
|
-
.
|
|
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;
|