@teamscala/service-boot 0.23.525 → 0.23.527
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/package.json +2 -6
- package/src/agreement-gate/signing-session.ts +21 -36
- package/src/agreement-gate.ts +2 -2
- package/src/expand-roster.ts +17 -27
- package/src/orpc-contract-rows.json +3871 -119
- package/src/pg-boss-mount.ts +6 -6
- package/src/register-registry-page-route/live-loader-config.ts +0 -16
- package/src/register-registry-page-route/resolve-request-graph.ts +2 -98
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +3 -20
- package/src/register-registry-page-route.ts +0 -10
- package/src/two-phase-full-app.ts +1 -0
- package/src/pg-boss-handler-registry.ts +0 -15
- package/src/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -54
package/src/pg-boss-mount.ts
CHANGED
|
@@ -10,16 +10,15 @@ import { getAllEntries } from "@teamscala/db/registry/accessors";
|
|
|
10
10
|
import type { RegistryEntry } from "@teamscala/db/registry/types";
|
|
11
11
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
12
12
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
+
import type { JobHandler } from "@teamscala/os/runtime-contracts/mount-context";
|
|
13
14
|
import type { ScheduleOptions } from "pg-boss";
|
|
14
15
|
import { PgBoss } from "pg-boss";
|
|
15
|
-
import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
|
|
16
|
-
|
|
17
|
-
|
|
18
16
|
|
|
19
17
|
export interface PgBossMountContext {
|
|
20
18
|
databaseUrl: string;
|
|
21
19
|
serviceSlug: string;
|
|
22
20
|
packages: string[];
|
|
21
|
+
jobHandlers: ReadonlyMap<string, JobHandler>;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
export interface MountedJob {
|
|
@@ -62,15 +61,16 @@ export function intervalToCron(intervalSeconds: number): string {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
async function resolveHandler(
|
|
64
|
+
jobHandlers: ReadonlyMap<string, JobHandler>,
|
|
65
65
|
handlerSlug: string,
|
|
66
66
|
config: ScheduledJobConfig,
|
|
67
67
|
jobData?: unknown,
|
|
68
68
|
): Promise<unknown> {
|
|
69
|
-
const handler =
|
|
69
|
+
const handler = jobHandlers.get(handlerSlug);
|
|
70
70
|
if (!handler) {
|
|
71
71
|
throw new Error(
|
|
72
72
|
`pg-boss handler "${handlerSlug}" has no registered function — ` +
|
|
73
|
-
`register it
|
|
73
|
+
`register it with ctx.registerJobHandler(slug, fn) in its package mount`,
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
return await handler({ config, jobData });
|
|
@@ -146,7 +146,7 @@ export async function mountPgBoss(
|
|
|
146
146
|
await boss.work(queueName, async (jobs) => {
|
|
147
147
|
let result: unknown;
|
|
148
148
|
for (const job of jobs) {
|
|
149
|
-
result = await resolveHandler(config.handler_slug, config, job.data);
|
|
149
|
+
result = await resolveHandler(ctx.jobHandlers, config.handler_slug, config, job.data);
|
|
150
150
|
}
|
|
151
151
|
return jobs.length === 1 ? result : undefined;
|
|
152
152
|
});
|
|
@@ -19,19 +19,3 @@ export async function readLiveLoaderConfig(
|
|
|
19
19
|
ResourceLoaderConfigSchema,
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
/** djb2 over the config's stable JSON — short, deterministic, SSR-safe. */
|
|
24
|
-
export function fingerprintLoaderConfig(config: unknown): string {
|
|
25
|
-
const json = JSON.stringify(config, sortedTopLevelKeys(config));
|
|
26
|
-
let hash = 5381;
|
|
27
|
-
for (let i = 0; i < json.length; i++) {
|
|
28
|
-
hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0;
|
|
29
|
-
}
|
|
30
|
-
return (hash >>> 0).toString(36);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function sortedTopLevelKeys(config: unknown): string[] {
|
|
34
|
-
return config != null && typeof config === "object"
|
|
35
|
-
? Object.keys(config).sort()
|
|
36
|
-
: [];
|
|
37
|
-
}
|
|
@@ -5,90 +5,32 @@
|
|
|
5
5
|
|
|
6
6
|
import type { PageRouteConfig } from "../register-registry-page-route.ts";
|
|
7
7
|
import * as v from "valibot";
|
|
8
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
9
8
|
import type { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
|
|
10
9
|
import { resolveFromLoaderConfig } from "../page-server-loader-graph.ts";
|
|
11
10
|
import { decideContentPageFloor } from "../content-page-floor.ts";
|
|
12
11
|
import { PrimaryResourceMissingError } from "../resource-not-found.ts";
|
|
13
|
-
import {
|
|
14
|
-
staticShellGraphCache,
|
|
15
|
-
paramRouteGraphCache,
|
|
16
|
-
readCachedGraph,
|
|
17
|
-
} from "./graph-cache.ts";
|
|
18
12
|
|
|
19
13
|
export interface ResolveRequestGraphArgs {
|
|
20
14
|
pattern: string;
|
|
21
|
-
routeSlug: string;
|
|
22
15
|
routeConfig: PageRouteConfig;
|
|
23
16
|
loaderConfig: v.InferOutput<typeof ResourceLoaderConfigSchema>;
|
|
24
17
|
params: Record<string, string>;
|
|
25
|
-
isStaticShell: boolean;
|
|
26
|
-
serviceSlug: string;
|
|
27
|
-
/** Stable fingerprint of the LIVE loader config (fingerprintLoaderConfig).
|
|
28
|
-
* Appended to the graphKey so a registry row edit invalidates cached
|
|
29
|
-
* graphs for the route on the very next request — the graph is a
|
|
30
|
-
* function of the config, so the config belongs in the cache key. */
|
|
31
|
-
configFingerprint?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Clone a loader graph for the cache, degrading loudly rather than silently.
|
|
36
|
-
*
|
|
37
|
-
* ONE CLONE, TWO CALLERS. The cold path and the background-refresh path both
|
|
38
|
-
* store a graph and both must survive a non-cloneable value in it — a function,
|
|
39
|
-
* a Proxy, a live client handle. They previously had SEPARATE implementations:
|
|
40
|
-
* the cold path carried this fallback ladder, the refresh path called
|
|
41
|
-
* structuredClone bare. So a graph that the cold path stored happily threw on
|
|
42
|
-
* every subsequent refresh, the cache never updated, and the entry aged out into
|
|
43
|
-
* a full in-request resolve. Two copies of one decision, and the copy nobody
|
|
44
|
-
* hardened is the one that fired.
|
|
45
|
-
*
|
|
46
|
-
* Returns undefined when the graph cannot be cloned at all; the caller then
|
|
47
|
-
* declines to cache that key. Both fallbacks log, so the offending source stays
|
|
48
|
-
* observable and fixable — never a silent fail-open
|
|
49
|
-
* (`protective-layers-fail-explicitly`).
|
|
50
|
-
*/
|
|
51
|
-
function cloneForCache(
|
|
52
|
-
graph: unknown,
|
|
53
|
-
graphKey: string,
|
|
54
|
-
serviceSlug: string,
|
|
55
|
-
): unknown | undefined {
|
|
56
|
-
const log = getAppLogger();
|
|
57
|
-
try {
|
|
58
|
-
return structuredClone(graph);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
log.warn(
|
|
61
|
-
`[scala-install:${serviceSlug}] graph-cache structuredClone failed for ${graphKey} — falling back to JSON-clone (non-cloneable value in loader graph): ${err instanceof Error ? err.message : err}`,
|
|
62
|
-
);
|
|
63
|
-
try {
|
|
64
|
-
return JSON.parse(JSON.stringify(graph)) as unknown;
|
|
65
|
-
} catch (err2) {
|
|
66
|
-
log.warn(
|
|
67
|
-
`[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
|
|
68
|
-
);
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
18
|
}
|
|
73
19
|
|
|
74
20
|
export async function resolveRequestGraph(
|
|
75
21
|
args: ResolveRequestGraphArgs,
|
|
76
22
|
): Promise<Record<string, unknown>> {
|
|
77
|
-
const { pattern,
|
|
23
|
+
const { pattern, routeConfig, loaderConfig, params } = args;
|
|
78
24
|
|
|
79
25
|
if (pattern === "/" && routeConfig.resource_type) {
|
|
80
26
|
throw new PrimaryResourceMissingError(routeConfig.resource_type, 200);
|
|
81
27
|
}
|
|
82
|
-
const graphKey = `graph:${routeSlug}:${params.host}:${params.path}${configFingerprint ? `:${configFingerprint}` : ""}`;
|
|
83
28
|
const primaryType = routeConfig.resource_type;
|
|
84
29
|
const hasPrimarySource =
|
|
85
30
|
!!primaryType &&
|
|
86
31
|
!!(loaderConfig as { sources?: { key: string }[] }).sources?.some(
|
|
87
32
|
(s) => s.key === primaryType,
|
|
88
33
|
);
|
|
89
|
-
const graphCache = isStaticShell
|
|
90
|
-
? staticShellGraphCache
|
|
91
|
-
: paramRouteGraphCache;
|
|
92
34
|
const typedLoaderConfig = loaderConfig as v.InferOutput<
|
|
93
35
|
typeof ResourceLoaderConfigSchema
|
|
94
36
|
> & {
|
|
@@ -96,45 +38,7 @@ export async function resolveRequestGraph(
|
|
|
96
38
|
v.InferOutput<typeof ResourceLoaderConfigSchema>["sources"]
|
|
97
39
|
>;
|
|
98
40
|
};
|
|
99
|
-
|
|
100
|
-
const cachedGraph = readCachedGraph(
|
|
101
|
-
graphCache,
|
|
102
|
-
graphKey,
|
|
103
|
-
() =>
|
|
104
|
-
resolveFromLoaderConfig(typedLoaderConfig, params).then(
|
|
105
|
-
(freshGraph) => {
|
|
106
|
-
const freshPrimaryMissing =
|
|
107
|
-
hasPrimarySource &&
|
|
108
|
-
primaryType &&
|
|
109
|
-
freshGraph[primaryType] == null;
|
|
110
|
-
if (freshPrimaryMissing) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
const refreshed = cloneForCache(freshGraph, graphKey, serviceSlug);
|
|
116
|
-
if (refreshed === undefined) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`graph for ${graphKey} is not cloneable — skip cache update`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
return refreshed;
|
|
122
|
-
},
|
|
123
|
-
),
|
|
124
|
-
);
|
|
125
|
-
if (cachedGraph !== undefined) {
|
|
126
|
-
resolved = structuredClone(cachedGraph) as Record<string, unknown>;
|
|
127
|
-
} else {
|
|
128
|
-
resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
129
|
-
const primaryMissing =
|
|
130
|
-
hasPrimarySource && primaryType && resolved[primaryType] == null;
|
|
131
|
-
if (!primaryMissing) {
|
|
132
|
-
const cloned = cloneForCache(resolved, graphKey, serviceSlug);
|
|
133
|
-
if (cloned !== undefined) {
|
|
134
|
-
graphCache.set(graphKey, cloned);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
41
|
+
const resolved = await resolveFromLoaderConfig(typedLoaderConfig, params);
|
|
138
42
|
if (hasPrimarySource && primaryType && resolved[primaryType] == null) {
|
|
139
43
|
throw new PrimaryResourceMissingError(primaryType);
|
|
140
44
|
}
|
|
@@ -4,28 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
7
|
+
import { collectBlockTypes, type PuckData } from "@teamscala/puck-tree/collect-blocks";
|
|
7
8
|
import type { RendererComponentEntry } from "../types.ts";
|
|
8
9
|
|
|
9
10
|
const uncoveredBlocksLogged = new Set<string>();
|
|
10
11
|
|
|
11
|
-
function collectBlockTypes(node: unknown, out: Set<string>): void {
|
|
12
|
-
if (Array.isArray(node)) {
|
|
13
|
-
for (const child of node) collectBlockTypes(child, out);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (node && typeof node === "object") {
|
|
17
|
-
const obj = node as Record<string, unknown>;
|
|
18
|
-
if (
|
|
19
|
-
typeof obj.type === "string" &&
|
|
20
|
-
obj.props &&
|
|
21
|
-
typeof obj.props === "object"
|
|
22
|
-
) {
|
|
23
|
-
out.add(obj.type);
|
|
24
|
-
}
|
|
25
|
-
for (const value of Object.values(obj)) collectBlockTypes(value, out);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
12
|
export function warnUncoveredBlocks(
|
|
30
13
|
pageData: unknown,
|
|
31
14
|
components: Record<string, RendererComponentEntry>,
|
|
@@ -33,8 +16,8 @@ export function warnUncoveredBlocks(
|
|
|
33
16
|
path: unknown,
|
|
34
17
|
log: ReturnType<typeof getAppLogger>,
|
|
35
18
|
): void {
|
|
36
|
-
const
|
|
37
|
-
collectBlockTypes(
|
|
19
|
+
const pd = pageData as { puckData?: PuckData; regions?: Array<{ puckData?: PuckData }> };
|
|
20
|
+
const used = collectBlockTypes([pd.puckData, ...(pd.regions ?? []).map((r) => r.puckData)]);
|
|
38
21
|
for (const type of used) {
|
|
39
22
|
if (type in components) continue;
|
|
40
23
|
const key = `${serviceSlug}:${type}`;
|
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
} from "./register-registry-page-route/proposal-brand-stamping.ts";
|
|
44
44
|
import type { CreatePageHandler, RendererComponentEntry } from "./types.ts";
|
|
45
45
|
import {
|
|
46
|
-
fingerprintLoaderConfig,
|
|
47
46
|
readLiveLoaderConfig,
|
|
48
47
|
type LoaderConfig,
|
|
49
48
|
} from "./register-registry-page-route/live-loader-config.ts";
|
|
@@ -145,11 +144,6 @@ export async function registerRegistryPageRoute(
|
|
|
145
144
|
throw err;
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
const isStaticShell =
|
|
149
|
-
!routeConfig.resource_type &&
|
|
150
|
-
!pattern.includes(":") &&
|
|
151
|
-
!pattern.includes("*");
|
|
152
|
-
|
|
153
147
|
const loadData = async (request: Request) => {
|
|
154
148
|
const liveLoaderConfig = await readLiveLoaderConfig(loaderSlug);
|
|
155
149
|
const url = new URL(request.url);
|
|
@@ -158,13 +152,9 @@ export async function registerRegistryPageRoute(
|
|
|
158
152
|
params.path = url.pathname || "/";
|
|
159
153
|
const resolved = await resolveRequestGraph({
|
|
160
154
|
pattern,
|
|
161
|
-
routeSlug: routeRow.slug,
|
|
162
155
|
routeConfig,
|
|
163
156
|
loaderConfig: liveLoaderConfig as never,
|
|
164
157
|
params,
|
|
165
|
-
isStaticShell,
|
|
166
|
-
serviceSlug,
|
|
167
|
-
configFingerprint: fingerprintLoaderConfig(liveLoaderConfig),
|
|
168
158
|
});
|
|
169
159
|
const { sessionUserId, sessionOrgId } = await stampSessionContext(
|
|
170
160
|
resolved,
|
|
@@ -46,6 +46,7 @@ export async function buildFullApp(
|
|
|
46
46
|
databaseUrl: minimalResult.databaseUrl,
|
|
47
47
|
serviceSlug,
|
|
48
48
|
packages: config.server_packages ?? [],
|
|
49
|
+
jobHandlers: mountRegistry.jobHandlers,
|
|
49
50
|
}));
|
|
50
51
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
|
|
51
52
|
} catch (error) {
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
6
|
-
import { createRegistry } from "@teamscala/generic-registry/create-registry";
|
|
7
|
-
|
|
8
|
-
export type PgBossJobHandler = (ctx: {
|
|
9
|
-
config: ScheduledJobConfig;
|
|
10
|
-
jobData?: unknown;
|
|
11
|
-
}) => Promise<unknown> | unknown;
|
|
12
|
-
|
|
13
|
-
export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
|
|
14
|
-
"pg-boss-handlers",
|
|
15
|
-
);
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
11
|
-
import { createCache } from "@teamscala/cache/create-cache";
|
|
12
|
-
import { readCachedGraph } from "./graph-cache.ts";
|
|
13
|
-
|
|
14
|
-
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
15
|
-
|
|
16
|
-
describe("readCachedGraph", () => {
|
|
17
|
-
test("fresh hit returns the cached value and fires NO refresh", async () => {
|
|
18
|
-
const cache = createCache<unknown>("graph-cache-test:fresh", {
|
|
19
|
-
maxSize: 4,
|
|
20
|
-
ttlMs: 1000,
|
|
21
|
-
staleTtlMs: 1000,
|
|
22
|
-
});
|
|
23
|
-
let refreshCalls = 0;
|
|
24
|
-
cache.set("k", "v");
|
|
25
|
-
expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
|
|
26
|
-
await delay(5);
|
|
27
|
-
expect(refreshCalls).toBe(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("stale hit returns the cached value AND fires the background refresh", async () => {
|
|
31
|
-
const cache = createCache<unknown>("graph-cache-test:stale", {
|
|
32
|
-
maxSize: 4,
|
|
33
|
-
ttlMs: 20,
|
|
34
|
-
staleTtlMs: 5000,
|
|
35
|
-
});
|
|
36
|
-
let refreshCalls = 0;
|
|
37
|
-
cache.set("k", "v");
|
|
38
|
-
await delay(40); // past ttlMs (fresh) → within staleTtlMs → stale
|
|
39
|
-
// Serves the stale value instantly...
|
|
40
|
-
expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
|
|
41
|
-
// ...and fires exactly one background refresh.
|
|
42
|
-
await delay(5);
|
|
43
|
-
expect(refreshCalls).toBe(1); // FAILS if the background refresh stops firing
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("miss returns undefined so the caller resolves synchronously", () => {
|
|
47
|
-
const cache = createCache<unknown>("graph-cache-test:miss", {
|
|
48
|
-
maxSize: 4,
|
|
49
|
-
ttlMs: 1000,
|
|
50
|
-
staleTtlMs: 1000,
|
|
51
|
-
});
|
|
52
|
-
let refreshCalls = 0;
|
|
53
|
-
expect(
|
|
54
|
-
readCachedGraph(cache, "absent", async () => ++refreshCalls),
|
|
55
|
-
).toBeUndefined();
|
|
56
|
-
expect(refreshCalls).toBe(0);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("stale refresh is single-flight (a second stale read while in-flight does not re-fire)", async () => {
|
|
60
|
-
const cache = createCache<unknown>("graph-cache-test:dedup", {
|
|
61
|
-
maxSize: 4,
|
|
62
|
-
ttlMs: 20,
|
|
63
|
-
staleTtlMs: 5000,
|
|
64
|
-
});
|
|
65
|
-
let refreshCalls = 0;
|
|
66
|
-
let resolveRefresh: (value: number) => void = () => {};
|
|
67
|
-
const slowRefresh = (): Promise<number> =>
|
|
68
|
-
new Promise<number>((resolve) => {
|
|
69
|
-
refreshCalls++;
|
|
70
|
-
resolveRefresh = resolve;
|
|
71
|
-
});
|
|
72
|
-
cache.set("k", "v");
|
|
73
|
-
await delay(40); // stale
|
|
74
|
-
readCachedGraph(cache, "k", slowRefresh); // fires refresh → in-flight
|
|
75
|
-
readCachedGraph(cache, "k", slowRefresh); // deduped against the same in-flight map
|
|
76
|
-
await delay(5);
|
|
77
|
-
expect(refreshCalls).toBe(1); // only ONE refresh in flight, not two
|
|
78
|
-
resolveRefresh(refreshCalls);
|
|
79
|
-
await delay(5);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createCache } from "@teamscala/cache/create-cache";
|
|
7
|
-
|
|
8
|
-
// The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
|
|
9
|
-
// ManagedCache<unknown> satisfies it; declared locally so the SWR decision does
|
|
10
|
-
// not depend on the cache package's internal type export path.
|
|
11
|
-
type GraphCache = {
|
|
12
|
-
getWithMeta(key: string): { value: unknown; stale: boolean } | undefined;
|
|
13
|
-
refreshInBackground(
|
|
14
|
-
key: string,
|
|
15
|
-
compute: () => Promise<unknown>,
|
|
16
|
-
opts?: { ttlMs?: number },
|
|
17
|
-
): void;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// Static-shell routes (no resource_type, no :params, no wildcard — the fleet
|
|
21
|
-
// dashboard's "/", any fixed page) resolve the SAME loader graph every request:
|
|
22
|
-
// pure page/website structure that changes only on editor writes, yet every SSR
|
|
23
|
-
// re-paid the full graph over the tunnel DB (load=1.8-2.2s measured 2026-06-06 —
|
|
24
|
-
// the whole remaining load tier). 60s fresh (ttlMs) + 540s stale (staleTtlMs) =
|
|
25
|
-
// 600s total; past the fresh window a hit still serves instantly and fires ONE
|
|
26
|
-
// background re-resolve. Without SWR every cache-expiry request paid the full
|
|
27
|
-
// resolve in-request — measured 3.8s on a cache-expired hit.
|
|
28
|
-
export const staticShellGraphCache = createCache<unknown>(
|
|
29
|
-
"registry-route:static-shell-graph",
|
|
30
|
-
{ maxSize: 64, ttlMs: 60_000, staleTtlMs: 540_000 },
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
// Parameterised routes (:param / wildcard — record pages, the portal catchall)
|
|
34
|
-
// were permanently uncacheable: every hit re-paid the full graph resolve (~2.3s
|
|
35
|
-
// from the dev host). The graphKey encodes routeRow.slug + host + path and the
|
|
36
|
-
// graph is purely path-determined (searchParams, theme, agreement gating all
|
|
37
|
-
// apply AFTER the clone), so a short fresh window is safe: a record edit is
|
|
38
|
-
// visible within 15s on a trafficked page (background refresh). 15s fresh
|
|
39
|
-
// (ttlMs) + 285s stale (staleTtlMs) = 300s total.
|
|
40
|
-
export const paramRouteGraphCache = createCache<unknown>(
|
|
41
|
-
"registry-route:param-route-graph",
|
|
42
|
-
{ maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
export function readCachedGraph(
|
|
46
|
-
cache: GraphCache,
|
|
47
|
-
key: string,
|
|
48
|
-
refresh: () => Promise<unknown>,
|
|
49
|
-
): unknown | undefined {
|
|
50
|
-
const meta = cache.getWithMeta(key);
|
|
51
|
-
if (meta === undefined) return undefined;
|
|
52
|
-
if (meta.stale) cache.refreshInBackground(key, refresh);
|
|
53
|
-
return meta.value;
|
|
54
|
-
}
|