@teamscala/service-boot 0.23.525 → 0.23.526

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.
@@ -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, routeSlug, routeConfig, loaderConfig, params, isStaticShell, serviceSlug, configFingerprint } = args;
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
- let resolved: Record<string, unknown>;
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
  }
@@ -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,
@@ -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
- }