@teamscala/service-boot 0.23.464 → 0.23.465

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.464",
9
+ "version": "0.23.465",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -41,6 +41,48 @@ export interface ResolveRequestGraphArgs {
41
41
  configFingerprint?: string;
42
42
  }
43
43
 
44
+ /**
45
+ * Clone a loader graph for the cache, degrading loudly rather than silently.
46
+ *
47
+ * ONE CLONE, TWO CALLERS. The cold path and the background-refresh path both
48
+ * store a graph and both must survive a non-cloneable value in it — a function,
49
+ * a Proxy, a live client handle. They previously had SEPARATE implementations:
50
+ * the cold path carried this fallback ladder, the refresh path called
51
+ * structuredClone bare. So a graph that the cold path stored happily threw on
52
+ * every subsequent refresh, the cache never updated, and the entry aged out into
53
+ * a full in-request resolve. Two copies of one decision, and the copy nobody
54
+ * hardened is the one that fired.
55
+ *
56
+ * Returns undefined when the graph cannot be cloned at all; the caller then
57
+ * declines to cache that key. Both fallbacks log, so the offending source stays
58
+ * observable and fixable — never a silent fail-open
59
+ * (`protective-layers-fail-explicitly`).
60
+ */
61
+ function cloneForCache(
62
+ graph: unknown,
63
+ graphKey: string,
64
+ serviceSlug: string,
65
+ ): unknown | undefined {
66
+ const log = getAppLogger();
67
+ try {
68
+ return structuredClone(graph);
69
+ } catch (err) {
70
+ log.warn(
71
+ `[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}`,
72
+ );
73
+ try {
74
+ // JSON-clone preserves the data graph, dropping the functions and
75
+ // undefined a loader graph should not be carrying anyway.
76
+ return JSON.parse(JSON.stringify(graph)) as unknown;
77
+ } catch (err2) {
78
+ log.warn(
79
+ `[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
80
+ );
81
+ return undefined;
82
+ }
83
+ }
84
+ }
85
+
44
86
  export async function resolveRequestGraph(
45
87
  args: ResolveRequestGraphArgs,
46
88
  ): Promise<Record<string, unknown>> {
@@ -102,7 +144,23 @@ export async function resolveRequestGraph(
102
144
  `primary resource "${primaryType}" missing — skip cache update for ${graphKey}`,
103
145
  );
104
146
  }
105
- return structuredClone(freshGraph);
147
+ // SAME DEFENSIVE CLONE AS THE COLD PATH. This used to call
148
+ // structuredClone bare, so a non-cloneable value in the loader
149
+ // graph threw on EVERY background refresh: refreshInBackground
150
+ // caught it, kept the stale entry, and logged one line — so the
151
+ // cache could never update, expired at staleTtlMs, and the next
152
+ // request re-resolved the whole graph in-request. Measured
153
+ // 2026-09-03 on the live proposal page: "The object can not be
154
+ // cloned" every ~13 minutes on the same graphKey, indefinitely.
155
+ // The cold path was hardened for exactly this and the refresh
156
+ // path was not — one defect, two clone sites, one protected.
157
+ const refreshed = cloneForCache(freshGraph, graphKey, serviceSlug);
158
+ if (refreshed === undefined) {
159
+ throw new Error(
160
+ `graph for ${graphKey} is not cloneable — skip cache update`,
161
+ );
162
+ }
163
+ return refreshed;
106
164
  },
107
165
  ),
108
166
  );
@@ -125,22 +183,7 @@ export async function resolveRequestGraph(
125
183
  // graph shouldn't carry) so the cache stays effective; both fallbacks
126
184
  // are LOGGED so the offending non-cloneable source is observable +
127
185
  // fixable, never silently disabling the cache again.
128
- let cloned: unknown;
129
- try {
130
- cloned = structuredClone(resolved);
131
- } catch (err) {
132
- log.warn(
133
- `[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}`,
134
- );
135
- try {
136
- cloned = JSON.parse(JSON.stringify(resolved));
137
- } catch (err2) {
138
- log.warn(
139
- `[scala-install:${serviceSlug}] graph-cache JSON-clone also failed for ${graphKey} — cache disabled for this key (observable): ${err2 instanceof Error ? err2.message : err2}`,
140
- );
141
- cloned = undefined;
142
- }
143
- }
186
+ const cloned = cloneForCache(resolved, graphKey, serviceSlug);
144
187
  if (cloned !== undefined) {
145
188
  graphCache.set(graphKey, cloned);
146
189
  }