@malloy-publisher/server 0.0.223 → 0.0.224
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 +17 -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 +266 -13
- package/package.json +12 -12
- package/src/mcp/skills/skills_bundle.json +1 -1
- 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 +23 -18
- package/src/service/materialization_service.ts +6 -1
- package/src/service/model.ts +54 -4
- package/src/service/package.ts +126 -33
- package/src/storage/DatabaseInterface.ts +23 -0
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
package/src/service/package.ts
CHANGED
|
@@ -30,9 +30,14 @@ import {
|
|
|
30
30
|
import { formatDuration, logger } from "../logger";
|
|
31
31
|
import { recordBuildPlanComputeDuration } from "../materialization_metrics";
|
|
32
32
|
import { assertSafeEnvironmentPath, safeJoinUnderRoot } from "../path_safety";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
BuildManifest,
|
|
35
|
+
BuildPlan,
|
|
36
|
+
FreshnessManifest,
|
|
37
|
+
} from "../storage/DatabaseInterface";
|
|
34
38
|
import { errMessage, ignoreDotfiles } from "../utils";
|
|
35
39
|
import { computePackageBuildPlan } from "./build_plan";
|
|
40
|
+
import { filterFreshManifest } from "./freshness";
|
|
36
41
|
import { Model } from "./model";
|
|
37
42
|
import { assertPersistNamesQuoted } from "./persist_annotation_validation";
|
|
38
43
|
|
|
@@ -52,6 +57,22 @@ type PackageConnectionInput =
|
|
|
52
57
|
| Map<string, Connection>
|
|
53
58
|
| (() => MalloyConfig);
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Project the full wire entries down to the Malloy-runtime binding map
|
|
62
|
+
* (`sourceEntityId -> { tableName }`) used to hydrate models. The freshness
|
|
63
|
+
* fields are dropped here — they gate the serve path per query, not model
|
|
64
|
+
* hydration.
|
|
65
|
+
*/
|
|
66
|
+
function toTableNameManifest(
|
|
67
|
+
entries: FreshnessManifest,
|
|
68
|
+
): BuildManifest["entries"] {
|
|
69
|
+
const out: BuildManifest["entries"] = {};
|
|
70
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
71
|
+
out[sourceEntityId] = { tableName: entry.tableName };
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
export class Package {
|
|
56
77
|
private environmentName: string;
|
|
57
78
|
private packageName: string;
|
|
@@ -67,6 +88,22 @@ export class Package {
|
|
|
67
88
|
// /status (via getPackageMetadata) so the control plane can confirm a worker
|
|
68
89
|
// actually bound the distributed manifest instead of inferring it from logs.
|
|
69
90
|
private buildManifestEntries: BuildManifest["entries"] | undefined;
|
|
91
|
+
// The full wire entries retained at bind (sourceEntityId -> { tableName,
|
|
92
|
+
// dataAsOf, freshnessWindowSeconds, freshnessFallback }). Unlike
|
|
93
|
+
// {@link buildManifestEntries} (the tableName-only projection baked into the
|
|
94
|
+
// hydration runtime and reused by /compile), this keeps the control-plane
|
|
95
|
+
// freshness fields so the serve path can re-evaluate `age vs window` per
|
|
96
|
+
// query. Undefined when the package is serving live (unbound).
|
|
97
|
+
private freshnessEntries: FreshnessManifest | undefined;
|
|
98
|
+
// Memoized freshness-filtered manifest for the serve path. Almost all queries
|
|
99
|
+
// in a window share the same included set, so this is recomputed only when a
|
|
100
|
+
// retained entry actually crosses its window (`validUntil`, the next
|
|
101
|
+
// staleSince) or when a rebind replaces the entries (cleared in
|
|
102
|
+
// recordManifestBinding). validUntil === Infinity means no included entry has
|
|
103
|
+
// an evaluable window, so the result is stable until the next rebind.
|
|
104
|
+
private freshManifestCache:
|
|
105
|
+
| { manifest: BuildManifest["entries"]; validUntil: number }
|
|
106
|
+
| undefined;
|
|
70
107
|
private manifestBindingStatus: "unbound" | "bound" | "live_fallback" =
|
|
71
108
|
"unbound";
|
|
72
109
|
private manifestEntryCount = 0;
|
|
@@ -419,6 +456,10 @@ export class Package {
|
|
|
419
456
|
malloyConfig,
|
|
420
457
|
);
|
|
421
458
|
pkg.renderTagWarnings = renderTagWarnings;
|
|
459
|
+
// Install the per-query freshness resolver on the freshly-built models.
|
|
460
|
+
// At create time no manifest is bound yet, so the resolver returns
|
|
461
|
+
// undefined (serve live) until a subsequent bindManifest → reloadAllModels.
|
|
462
|
+
pkg.wireFreshnessResolvers();
|
|
422
463
|
|
|
423
464
|
// Compute the persist build plan off the live (unbound) models, before the
|
|
424
465
|
// caller binds any configured manifest, so the surfaced plan reflects the
|
|
@@ -523,6 +564,51 @@ export class Package {
|
|
|
523
564
|
return this.buildManifestEntries;
|
|
524
565
|
}
|
|
525
566
|
|
|
567
|
+
/**
|
|
568
|
+
* The freshness-filtered build manifest for the serve path, evaluated at
|
|
569
|
+
* `now`. Persistence.md §9.3 Phase B: a query on a `#@ persist` source may
|
|
570
|
+
* use its materialized table only while the table is within its declared
|
|
571
|
+
* freshness window; otherwise it falls back per the entry's `fallback`
|
|
572
|
+
* (`live`/`fail` drop the entry → serve live; `stale_ok` keeps it → serve the
|
|
573
|
+
* stale table). Un-gated entries (no window) always route to the table.
|
|
574
|
+
*
|
|
575
|
+
* Undefined when the package is serving live (unbound) — callers then apply
|
|
576
|
+
* no per-query override and the runtime serves live.
|
|
577
|
+
*
|
|
578
|
+
* Memoized: recomputed only when a retained entry crosses its window
|
|
579
|
+
* (monotonic fresh→stale, so a single `validUntil` deadline suffices) or when
|
|
580
|
+
* a rebind replaces the entries. Cheap O(1) hit on the common path.
|
|
581
|
+
*/
|
|
582
|
+
public getFreshBuildManifest(
|
|
583
|
+
now: number = Date.now(),
|
|
584
|
+
): BuildManifest["entries"] | undefined {
|
|
585
|
+
if (!this.freshnessEntries) return undefined;
|
|
586
|
+
if (this.freshManifestCache && now < this.freshManifestCache.validUntil) {
|
|
587
|
+
return this.freshManifestCache.manifest;
|
|
588
|
+
}
|
|
589
|
+
const { manifest, nextStaleSince } = filterFreshManifest(
|
|
590
|
+
this.freshnessEntries,
|
|
591
|
+
new Date(now),
|
|
592
|
+
);
|
|
593
|
+
this.freshManifestCache = {
|
|
594
|
+
manifest,
|
|
595
|
+
validUntil: nextStaleSince ?? Infinity,
|
|
596
|
+
};
|
|
597
|
+
return manifest;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Install the per-query freshness resolver on every owned model so the serve
|
|
602
|
+
* path (Model.getQueryResults / executeNotebookCell) applies the
|
|
603
|
+
* freshness-filtered manifest as a per-query Malloy `buildManifest` override.
|
|
604
|
+
* Idempotent; called after (re)building the model set.
|
|
605
|
+
*/
|
|
606
|
+
private wireFreshnessResolvers(): void {
|
|
607
|
+
for (const model of this.models.values()) {
|
|
608
|
+
model.setFreshnessResolver(() => this.getFreshBuildManifest());
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
526
612
|
/**
|
|
527
613
|
* Record the URI whose manifest is currently bound to the served models. May
|
|
528
614
|
* differ from `manifestLocation` after an in-memory auto-load following a
|
|
@@ -545,13 +631,18 @@ export class Package {
|
|
|
545
631
|
* observability (/status). An empty map means the manifest was cleared, so
|
|
546
632
|
* the package reverts to live (unbound).
|
|
547
633
|
*/
|
|
548
|
-
private recordManifestBinding(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
this.
|
|
634
|
+
private recordManifestBinding(entries: FreshnessManifest): void {
|
|
635
|
+
const count = Object.keys(entries).length;
|
|
636
|
+
// Full wire entries (with freshness) drive the per-query serve-path gate;
|
|
637
|
+
// the tableName-only projection is what /compile and /status consume.
|
|
638
|
+
this.freshnessEntries = count > 0 ? entries : undefined;
|
|
639
|
+
this.buildManifestEntries =
|
|
640
|
+
count > 0 ? toTableNameManifest(entries) : undefined;
|
|
553
641
|
this.manifestEntryCount = count;
|
|
554
642
|
this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
|
|
643
|
+
// A rebind replaces the entries, so any memoized freshness-filtered
|
|
644
|
+
// manifest is stale — drop it so the next query recomputes.
|
|
645
|
+
this.freshManifestCache = undefined;
|
|
555
646
|
if (count === 0) {
|
|
556
647
|
this.boundManifestUri = null;
|
|
557
648
|
}
|
|
@@ -615,33 +706,27 @@ export class Package {
|
|
|
615
706
|
}
|
|
616
707
|
|
|
617
708
|
/**
|
|
618
|
-
* Publish-gate for the package-level materialization cron
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
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.
|
|
629
720
|
*/
|
|
630
721
|
public scheduleWarnings(): string[] {
|
|
631
722
|
const schedule = this.packageMetadata.materialization?.schedule;
|
|
632
723
|
if (!schedule) return [];
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
);
|
|
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
|
+
];
|
|
645
730
|
}
|
|
646
731
|
|
|
647
732
|
/** The {@link scheduleWarnings} joined into one string, or "" if none. */
|
|
@@ -728,9 +813,11 @@ export class Package {
|
|
|
728
813
|
* timeout, pool shutting down) propagate as `ServiceUnavailableError`
|
|
729
814
|
* — the caller (manifest service) decides how to retry.
|
|
730
815
|
*/
|
|
731
|
-
public async reloadAllModels(
|
|
732
|
-
|
|
733
|
-
|
|
816
|
+
public async reloadAllModels(entries: FreshnessManifest): Promise<void> {
|
|
817
|
+
// Models are hydrated against the tableName-only projection; the freshness
|
|
818
|
+
// fields gate the serve path per query (via getFreshBuildManifest), not
|
|
819
|
+
// model hydration.
|
|
820
|
+
const buildManifest = toTableNameManifest(entries);
|
|
734
821
|
const modelPaths = Array.from(this.models.keys());
|
|
735
822
|
logger.info("Reloading all models with build manifest", {
|
|
736
823
|
packageName: this.packageName,
|
|
@@ -837,7 +924,13 @@ export class Package {
|
|
|
837
924
|
this.applyQueryBoundaryToModels();
|
|
838
925
|
// Remember what we just bound so /compile can route identically and
|
|
839
926
|
// /status can report the binding. An empty map reverts to live (unbound).
|
|
840
|
-
|
|
927
|
+
// Retains the full freshness entries so the serve-path gate can evaluate
|
|
928
|
+
// them per query.
|
|
929
|
+
this.recordManifestBinding(entries);
|
|
930
|
+
// Install the per-query freshness resolver on the rebuilt model set so the
|
|
931
|
+
// serve path applies the freshness-filtered manifest as a per-query
|
|
932
|
+
// override.
|
|
933
|
+
this.wireFreshnessResolvers();
|
|
841
934
|
// Re-run the fail-safe warning against the refreshed model set: an edit
|
|
842
935
|
// to publisher.json that introduces a bad entry should surface in the
|
|
843
936
|
// logs on reload too, not only at initial load (loadViaWorker).
|
|
@@ -157,3 +157,26 @@ export interface BuildManifest {
|
|
|
157
157
|
entries: Record<string, BuildManifestEntry>;
|
|
158
158
|
strict?: boolean;
|
|
159
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A wire manifest entry carrying the control-plane freshness fields alongside
|
|
163
|
+
* the physical `tableName`. This is what the publisher retains at bind so the
|
|
164
|
+
* serve path can re-evaluate `age vs window` per query (see
|
|
165
|
+
* {@link ../service/freshness}). A `{ tableName }`-only value is structurally
|
|
166
|
+
* assignable here (the freshness fields are optional), so callers that only
|
|
167
|
+
* know a table name — e.g. the post-build auto-load — bind un-gated entries.
|
|
168
|
+
*
|
|
169
|
+
* - `dataAsOf` the artifact's data-as-of instant (snapshot start); the age
|
|
170
|
+
* anchor. `age = now - dataAsOf`. Absent ⇒ never stale.
|
|
171
|
+
* - `freshnessWindowSeconds` the version's effective window. Absent ⇒ un-gated.
|
|
172
|
+
* - `freshnessFallback` per-version read-time policy when stale.
|
|
173
|
+
*/
|
|
174
|
+
export interface FreshnessAwareManifestEntry {
|
|
175
|
+
tableName: string;
|
|
176
|
+
dataAsOf?: string;
|
|
177
|
+
freshnessWindowSeconds?: number;
|
|
178
|
+
freshnessFallback?: "live" | "stale_ok" | "fail";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Full (unfiltered) manifest map keyed by sourceEntityId. */
|
|
182
|
+
export type FreshnessManifest = Record<string, FreshnessAwareManifestEntry>;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
4
|
+
import * as fsp from "fs/promises";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const PROJECT_NAME = "freshness-gate-project";
|
|
14
|
+
const PACKAGE_NAME = "persist-test";
|
|
15
|
+
const MODEL_PATH = "persist_test.malloy";
|
|
16
|
+
const API = `/api/v0/environments/${PROJECT_NAME}/packages/${PACKAGE_NAME}`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Covers the per-query freshness gate (persistence.md §9.3, §14 Phase B): a
|
|
20
|
+
* query on a `#@ persist` source uses its materialized table only while the
|
|
21
|
+
* table is within its declared freshness window; otherwise it falls back per the
|
|
22
|
+
* entry's declared `fallback`.
|
|
23
|
+
*
|
|
24
|
+
* - fresh (or un-gated) → routes to the materialized table
|
|
25
|
+
* - stale + fallback live → serves live SQL (entry dropped)
|
|
26
|
+
* - stale + fallback stale_ok→ serves the stale table
|
|
27
|
+
* - a fresh entry that crosses its window → serves live on the NEXT query,
|
|
28
|
+
* with no rebind (proves per-query re-evaluation, not bind-time filtering)
|
|
29
|
+
*
|
|
30
|
+
* The manifest is bound once physically (auto-run builds the table), then a
|
|
31
|
+
* manifest URI keyed by the source's real sourceEntityId routes served queries.
|
|
32
|
+
* `executedSql()` is the routing evidence: it scans `order_summary` when routed
|
|
33
|
+
* to the table, and `data/orders.csv` when serving live.
|
|
34
|
+
*/
|
|
35
|
+
describe("Per-query freshness gate (E2E)", () => {
|
|
36
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
37
|
+
let baseUrl: string;
|
|
38
|
+
let tmpDir: string;
|
|
39
|
+
let sourceEntityId: string;
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
env = await startRestE2E();
|
|
43
|
+
baseUrl = env.baseUrl;
|
|
44
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "freshness-gate-"));
|
|
45
|
+
|
|
46
|
+
const fixtureDir = path.resolve(__dirname, "../../fixtures/persist-test");
|
|
47
|
+
const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
name: PROJECT_NAME,
|
|
52
|
+
packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
|
|
53
|
+
connections: [],
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
if (!createRes.ok) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Failed to create test project (${createRes.status}): ${await createRes.text()}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const deadline = Date.now() + 30_000;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
const res = await fetch(`${baseUrl}${API}`);
|
|
65
|
+
if (res.ok) break;
|
|
66
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Physically build the persist source (auto-run self-assigns
|
|
70
|
+
// physicalTableName = "order_summary"), then revert to live so the
|
|
71
|
+
// manifest-URI bind is the only thing that can route the served query.
|
|
72
|
+
await buildTableThenRevertToLive();
|
|
73
|
+
sourceEntityId = await orderSummarySourceEntityId();
|
|
74
|
+
}, 120_000);
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
if (baseUrl) {
|
|
78
|
+
await fetch(`${baseUrl}/api/v0/environments/${PROJECT_NAME}`, {
|
|
79
|
+
method: "DELETE",
|
|
80
|
+
}).catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
await env?.stop();
|
|
83
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
84
|
+
env = null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function url(p: string): string {
|
|
88
|
+
return `${baseUrl}${API}${p}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ROUTING_QUERY = "run: order_summary -> { aggregate: c is count() }";
|
|
92
|
+
|
|
93
|
+
/** The SQL the served query actually compiled to (routing evidence). */
|
|
94
|
+
async function executedSql(): Promise<string> {
|
|
95
|
+
const res = await fetch(`${baseUrl}${API}/models/${MODEL_PATH}/query`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ query: ROUTING_QUERY }),
|
|
99
|
+
});
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
const body = (await res.json()) as { result: string };
|
|
102
|
+
return (JSON.parse(body.result) as { sql: string }).sql;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function orderSummarySourceEntityId(): Promise<string> {
|
|
106
|
+
const res = await fetch(url(""));
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
const pkg = (await res.json()) as {
|
|
109
|
+
buildPlan?: { sources?: Record<string, { sourceEntityId?: string }> };
|
|
110
|
+
};
|
|
111
|
+
const sources = pkg.buildPlan?.sources ?? {};
|
|
112
|
+
const id = Object.values(sources)[0]?.sourceEntityId;
|
|
113
|
+
expect(typeof id).toBe("string");
|
|
114
|
+
return id as string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function pollUntilTerminal(
|
|
118
|
+
id: string,
|
|
119
|
+
timeoutMs = 90_000,
|
|
120
|
+
): Promise<Record<string, unknown>> {
|
|
121
|
+
const terminal = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"];
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
while (Date.now() < deadline) {
|
|
124
|
+
const res = await fetch(url(`/materializations/${id}`));
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
127
|
+
if (terminal.includes(data.status as string)) return data;
|
|
128
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`Materialization ${id} did not reach a terminal state`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildTableThenRevertToLive(): Promise<void> {
|
|
134
|
+
const createRes = await fetch(url("/materializations"), {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
body: JSON.stringify({}),
|
|
138
|
+
});
|
|
139
|
+
expect(createRes.status).toBe(201);
|
|
140
|
+
const { id } = (await createRes.json()) as { id: string };
|
|
141
|
+
const built = await pollUntilTerminal(id);
|
|
142
|
+
expect(built.status).toBe("MANIFEST_FILE_READY");
|
|
143
|
+
await fetch(url("?reload=true"));
|
|
144
|
+
await fetch(url(`/materializations/${id}`), { method: "DELETE" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Write a CP-shaped manifest carrying the freshness fields. */
|
|
148
|
+
async function writeFreshnessManifest(opts: {
|
|
149
|
+
dataAsOf: string;
|
|
150
|
+
freshnessWindowSeconds: number;
|
|
151
|
+
freshnessFallback: "live" | "stale_ok" | "fail";
|
|
152
|
+
}): Promise<string> {
|
|
153
|
+
const file = path.join(
|
|
154
|
+
tmpDir,
|
|
155
|
+
`freshness-${Date.now()}-${Math.random()}.json`,
|
|
156
|
+
);
|
|
157
|
+
await fsp.writeFile(
|
|
158
|
+
file,
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
builtAt: new Date().toISOString(),
|
|
161
|
+
strict: false,
|
|
162
|
+
entries: {
|
|
163
|
+
[sourceEntityId]: {
|
|
164
|
+
sourceEntityId,
|
|
165
|
+
sourceName: "order_summary",
|
|
166
|
+
physicalTableName: "order_summary",
|
|
167
|
+
connectionName: "duckdb",
|
|
168
|
+
dataAsOf: opts.dataAsOf,
|
|
169
|
+
freshnessWindowSeconds: opts.freshnessWindowSeconds,
|
|
170
|
+
freshnessFallback: opts.freshnessFallback,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
"utf8",
|
|
175
|
+
);
|
|
176
|
+
return file;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function bind(manifestFile: string): Promise<void> {
|
|
180
|
+
const patchRes = await fetch(url(""), {
|
|
181
|
+
method: "PATCH",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
name: PACKAGE_NAME,
|
|
185
|
+
manifestLocation: manifestFile,
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
expect(patchRes.status).toBe(200);
|
|
189
|
+
const patched = (await patchRes.json()) as Record<string, unknown>;
|
|
190
|
+
// The bind is recorded regardless of per-query freshness — freshness is
|
|
191
|
+
// evaluated per query, not at bind.
|
|
192
|
+
expect(patched.manifestBindingStatus).toBe("bound");
|
|
193
|
+
expect(patched.manifestEntryCount).toBe(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function revertToLive(): Promise<void> {
|
|
197
|
+
await fetch(url(""), {
|
|
198
|
+
method: "PATCH",
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ name: PACKAGE_NAME, manifestLocation: null }),
|
|
201
|
+
});
|
|
202
|
+
await fetch(url("?reload=true"));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it(
|
|
206
|
+
"routes a fresh entry to the materialized table",
|
|
207
|
+
async () => {
|
|
208
|
+
const manifestFile = await writeFreshnessManifest({
|
|
209
|
+
dataAsOf: new Date().toISOString(),
|
|
210
|
+
freshnessWindowSeconds: 86_400, // 1 day — comfortably fresh
|
|
211
|
+
freshnessFallback: "live",
|
|
212
|
+
});
|
|
213
|
+
await bind(manifestFile);
|
|
214
|
+
|
|
215
|
+
const routed = await executedSql();
|
|
216
|
+
expect(routed).not.toContain("data/orders.csv");
|
|
217
|
+
expect(routed).toContain("order_summary");
|
|
218
|
+
|
|
219
|
+
await revertToLive();
|
|
220
|
+
},
|
|
221
|
+
{ timeout: 120_000 },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
it(
|
|
225
|
+
"serves live for a stale entry with fallback live",
|
|
226
|
+
async () => {
|
|
227
|
+
const manifestFile = await writeFreshnessManifest({
|
|
228
|
+
// 2h old against a 1h window ⇒ stale.
|
|
229
|
+
dataAsOf: new Date(Date.now() - 7200_000).toISOString(),
|
|
230
|
+
freshnessWindowSeconds: 3600,
|
|
231
|
+
freshnessFallback: "live",
|
|
232
|
+
});
|
|
233
|
+
await bind(manifestFile);
|
|
234
|
+
|
|
235
|
+
const served = await executedSql();
|
|
236
|
+
expect(served).toContain("data/orders.csv");
|
|
237
|
+
|
|
238
|
+
await revertToLive();
|
|
239
|
+
},
|
|
240
|
+
{ timeout: 120_000 },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
it(
|
|
244
|
+
"serves the stale table for a stale entry with fallback stale_ok",
|
|
245
|
+
async () => {
|
|
246
|
+
const manifestFile = await writeFreshnessManifest({
|
|
247
|
+
dataAsOf: new Date(Date.now() - 7200_000).toISOString(),
|
|
248
|
+
freshnessWindowSeconds: 3600,
|
|
249
|
+
freshnessFallback: "stale_ok",
|
|
250
|
+
});
|
|
251
|
+
await bind(manifestFile);
|
|
252
|
+
|
|
253
|
+
const served = await executedSql();
|
|
254
|
+
expect(served).not.toContain("data/orders.csv");
|
|
255
|
+
expect(served).toContain("order_summary");
|
|
256
|
+
|
|
257
|
+
await revertToLive();
|
|
258
|
+
},
|
|
259
|
+
{ timeout: 120_000 },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
it(
|
|
263
|
+
"serves live once a fresh entry crosses its window, with no rebind",
|
|
264
|
+
async () => {
|
|
265
|
+
const windowSeconds = 10;
|
|
266
|
+
const anchor = Date.now();
|
|
267
|
+
const manifestFile = await writeFreshnessManifest({
|
|
268
|
+
dataAsOf: new Date(anchor).toISOString(),
|
|
269
|
+
freshnessWindowSeconds: windowSeconds,
|
|
270
|
+
freshnessFallback: "live",
|
|
271
|
+
});
|
|
272
|
+
await bind(manifestFile);
|
|
273
|
+
|
|
274
|
+
// While inside the window, the served query routes to the table.
|
|
275
|
+
expect(Date.now() - anchor).toBeLessThan(windowSeconds * 1000);
|
|
276
|
+
const fresh = await executedSql();
|
|
277
|
+
expect(fresh).not.toContain("data/orders.csv");
|
|
278
|
+
expect(fresh).toContain("order_summary");
|
|
279
|
+
|
|
280
|
+
// Wait for the window to elapse — no rebind, no reload.
|
|
281
|
+
const waitMs = windowSeconds * 1000 - (Date.now() - anchor) + 1500;
|
|
282
|
+
await new Promise((r) => setTimeout(r, Math.max(waitMs, 0)));
|
|
283
|
+
|
|
284
|
+
// The very next query re-evaluates freshness and now serves live.
|
|
285
|
+
const afterCrossing = await executedSql();
|
|
286
|
+
expect(afterCrossing).toContain("data/orders.csv");
|
|
287
|
+
|
|
288
|
+
await revertToLive();
|
|
289
|
+
},
|
|
290
|
+
{ timeout: 120_000 },
|
|
291
|
+
);
|
|
292
|
+
});
|