@astilba/cache 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/adapters/cloudflare/index.d.ts +299 -0
- package/dist/adapters/cloudflare/index.js +1699 -0
- package/dist/adapters/react-router/index.d.ts +92 -0
- package/dist/adapters/react-router/index.js +272 -0
- package/dist/cache-YWn0VZpn.d.ts +930 -0
- package/dist/cache-Z4cJzPmm.js +3528 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +2 -0
- package/package.json +96 -3
|
@@ -0,0 +1,3528 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
//#region src/errors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by every public entry point during the correctness-kernel phase. A RED
|
|
5
|
+
* invariant that fails with `NotImplementedError` is red for the right reason
|
|
6
|
+
* (the kernel is unimplemented), as opposed to a toolchain/config failure.
|
|
7
|
+
*/
|
|
8
|
+
var NotImplementedError = class extends Error {
|
|
9
|
+
constructor(symbol) {
|
|
10
|
+
super(`${symbol}: not implemented in the correctness-kernel phase`);
|
|
11
|
+
this.name = "NotImplementedError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The single kernel-phase stub body. Every public function/method delegates here
|
|
16
|
+
* so the switch to real logic is a body replacement with zero signature churn.
|
|
17
|
+
* Callers pass their arguments through so parameters are genuinely referenced;
|
|
18
|
+
* inside an `async` method the thrown error becomes a rejected promise.
|
|
19
|
+
*/
|
|
20
|
+
function notImplemented(symbol, ..._args) {
|
|
21
|
+
throw new NotImplementedError(symbol);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Distinguishes cache-originated timeouts (retriable — evidence about origin
|
|
25
|
+
* health) from caller aborts (not evidence of origin health).
|
|
26
|
+
*/
|
|
27
|
+
var CacheTimeoutError = class extends Error {
|
|
28
|
+
source;
|
|
29
|
+
constructor(source, message) {
|
|
30
|
+
super(message ?? `cache operation timed out (source: ${source})`);
|
|
31
|
+
this.name = "CacheTimeoutError";
|
|
32
|
+
this.source = source;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by a `strong` read when the registry is unreachable. Opt out with
|
|
37
|
+
* `onUnavailable: 'eventual'`.
|
|
38
|
+
*/
|
|
39
|
+
var RegistryUnavailableError = class extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* `options.cause` carries the error this one displaced, where there was one. The serve-time
|
|
42
|
+
* re-validation of a graced candidate runs INSIDE a failed fill: if the registry is unreachable
|
|
43
|
+
* there, this error is what the caller sees instead of the factory failure that started it, so
|
|
44
|
+
* the factory failure rides along rather than being dropped. Nothing is lost and callers get one
|
|
45
|
+
* uniform type. Widened additively — `new RegistryUnavailableError(msg)` is unchanged.
|
|
46
|
+
*/
|
|
47
|
+
constructor(message, options) {
|
|
48
|
+
super(message ?? "registry unreachable", options);
|
|
49
|
+
this.name = "RegistryUnavailableError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Thrown by a read whose tag knowledge is UNKNOWN under `unknownPolicy: 'error'`.
|
|
54
|
+
*
|
|
55
|
+
* WHAT MAKES THIS DISTINCT FROM A MISS, AND WHY THE DISTINCTION IS THE POINT. Every other
|
|
56
|
+
* fail-closed resolution refills from origin, which is hard-safe exactly when the origin is truth.
|
|
57
|
+
* `'error'` is the policy for the case where it is NOT: a namespace under a takedown or a legal
|
|
58
|
+
* embargo. There, an origin that has not caught up yet can lose a race with the purge and hand back
|
|
59
|
+
* exactly the content that was supposed to vanish. So this policy declines to guess: it neither
|
|
60
|
+
* serves the unknown value nor replaces it
|
|
61
|
+
* with a fresh one, and the caller — who knows what the content is — decides. A policy that resolved
|
|
62
|
+
* to the same refill as `'miss'` would be a switch that reads as protection and does nothing, which
|
|
63
|
+
* is worse than no switch at all.
|
|
64
|
+
*
|
|
65
|
+
* Carries the key and the tags whose knowledge was unknown, so a caller can tell an embargoed
|
|
66
|
+
* dependency from a cold one. EXACTLY those: the tags this read could not establish, and no others.
|
|
67
|
+
* A payload of everything the entry declared cannot answer the question the field exists for — the
|
|
68
|
+
* caller would have to guess which of them was the problem, which is the guess this policy refuses
|
|
69
|
+
* to make on their behalf. Reserved (`__`-prefixed) tags never appear here: they are the kernel's
|
|
70
|
+
* own bookkeeping, they are unknown for exactly the same reason the user's are, and naming them
|
|
71
|
+
* tells the caller nothing they can act on.
|
|
72
|
+
*/
|
|
73
|
+
var UnknownTagError = class extends Error {
|
|
74
|
+
key;
|
|
75
|
+
tags;
|
|
76
|
+
/**
|
|
77
|
+
* `armedBy` names what selected this resolution — an explicit `unknownPolicy: 'error'`, or the
|
|
78
|
+
* `takedownSensitive` override that outranks it. The message used to state the former
|
|
79
|
+
* unconditionally, which was wrong precisely on the takedown path: the one place where the
|
|
80
|
+
* operator most needs to know which switch fired is the one where the message named the other.
|
|
81
|
+
*/
|
|
82
|
+
constructor(key, tags, armedBy = "unknownPolicy") {
|
|
83
|
+
const cause = armedBy === "takedownSensitive" ? "takedownSensitive is set" : "unknownPolicy is 'error'";
|
|
84
|
+
super(`read for key ${JSON.stringify(key)} has unknown tag knowledge and ${cause} (§5a, E9)`);
|
|
85
|
+
this.name = "UnknownTagError";
|
|
86
|
+
this.key = key;
|
|
87
|
+
this.tags = tags;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** A typed HTTP error carrying the offending status and response. */
|
|
91
|
+
var HttpError = class extends Error {
|
|
92
|
+
status;
|
|
93
|
+
response;
|
|
94
|
+
constructor(response) {
|
|
95
|
+
super(`HTTP ${response.status}`);
|
|
96
|
+
this.name = "HttpError";
|
|
97
|
+
this.status = response.status;
|
|
98
|
+
this.response = response;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Thrown at the `getOrSet`/`getOrSetEntry` write boundary when a caller-supplied
|
|
103
|
+
* tag is malformed — in particular a user tag beginning with the reserved `__`
|
|
104
|
+
* prefix. A real runtime error, never `NotImplementedError`: tag
|
|
105
|
+
* validation is not deferred kernel logic.
|
|
106
|
+
*/
|
|
107
|
+
var InvalidTagError = class extends Error {
|
|
108
|
+
tag;
|
|
109
|
+
constructor(tag, reason) {
|
|
110
|
+
super(`invalid tag ${JSON.stringify(tag)}: ${reason}`);
|
|
111
|
+
this.name = "InvalidTagError";
|
|
112
|
+
this.tag = tag;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Thrown by `duration()` when a computed duration is malformed — a non-positive or
|
|
117
|
+
* non-finite `value`, or (critical case) a `value × unitFactor` product that
|
|
118
|
+
* is not a finite, positive, safe integer once multiplied out. A real
|
|
119
|
+
* runtime error, never `NotImplementedError`: duration validation is not deferred kernel
|
|
120
|
+
* logic. Mirrors `InvalidTagError` — the builder rejects a bad input, it never coerces one.
|
|
121
|
+
*/
|
|
122
|
+
var InvalidDurationError = class extends Error {
|
|
123
|
+
value;
|
|
124
|
+
unit;
|
|
125
|
+
constructor(value, unit, reason) {
|
|
126
|
+
super(`invalid duration ${JSON.stringify(String(value) + unit)}: ${reason}`);
|
|
127
|
+
this.name = "InvalidDurationError";
|
|
128
|
+
this.value = value;
|
|
129
|
+
this.unit = unit;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Thrown by the plain `getOrSet` form when a fill is fenced past its write-back
|
|
134
|
+
* retry attempt budget: a hard delete keeps landing mid-fill/mid-
|
|
135
|
+
* refresh so the accepted result never postdates every purge visible at
|
|
136
|
+
* write-back, and the fill fails within its attempt budget rather than ever
|
|
137
|
+
* storing or serving a fenced value. A real runtime outcome, never
|
|
138
|
+
* `NotImplementedError` — the entry form (`getOrSetEntry`) surfaces the same
|
|
139
|
+
* outcome as `{ value: undefined }` instead of throwing.
|
|
140
|
+
*/
|
|
141
|
+
var FencedError = class extends Error {
|
|
142
|
+
key;
|
|
143
|
+
constructor(key, message) {
|
|
144
|
+
super(message ?? `fill for key ${JSON.stringify(key)} fenced past its write-back attempt budget (§5.0/MI6)`);
|
|
145
|
+
this.name = "FencedError";
|
|
146
|
+
this.key = key;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Thrown when a factory declares a dependency after it has settled: a
|
|
151
|
+
* `ctx.dependsOn`/`ctx.setTags` call reaching the context AFTER the factory promise
|
|
152
|
+
* resolved or rejected. The fill's tag set closes at settle, so a later declaration is
|
|
153
|
+
* outside the value's lifetime and cannot join it. A real runtime error, never
|
|
154
|
+
* `NotImplementedError`. Carries the offending key.
|
|
155
|
+
*/
|
|
156
|
+
var FactorySettledError = class extends Error {
|
|
157
|
+
key;
|
|
158
|
+
constructor(key) {
|
|
159
|
+
super(`factory for key ${JSON.stringify(key)} declared a dependency after it settled (§2.1, E24)`);
|
|
160
|
+
this.name = "FactorySettledError";
|
|
161
|
+
this.key = key;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* The `EntryFactoryCtx.skip()` sentinel (M10, invariant 24) — a private
|
|
166
|
+
* throw/catch signal, analogous to how a classified factory error rides
|
|
167
|
+
* `fail()`. `skip()` is typed `never` so it never falsely resolves `undefined`
|
|
168
|
+
* as a caller's `T`; `fillAndStore` catches this exact class and turns it into
|
|
169
|
+
* a `{ skipped: true, value: undefined }` outcome — nothing is stored, nothing
|
|
170
|
+
* is served. Never part of the public surface (not exported from `index.ts`).
|
|
171
|
+
*/
|
|
172
|
+
var SkipSignalError = class extends Error {
|
|
173
|
+
constructor() {
|
|
174
|
+
super("ctx.skip() called — the fill is skipped (nothing stored, nothing served)");
|
|
175
|
+
this.name = "SkipSignalError";
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/registry/mirror-keys.ts
|
|
180
|
+
/** The default `regId` — single-registry deployments stay zero-config. */
|
|
181
|
+
const DEFAULT_REG_ID = "default";
|
|
182
|
+
/** The `regId` charset + length bound (`[a-z0-9._-]`, ≤ 64). */
|
|
183
|
+
const REG_ID_PATTERN = /^[a-z0-9._-]{1,64}$/u;
|
|
184
|
+
/**
|
|
185
|
+
* Does `regId` satisfy the charset/length (`[a-z0-9._-]`, 1–64 chars)? The pure predicate
|
|
186
|
+
* behind `assertRegId` — the SINGLE source of the grammar, so the machine's throwing gate and the
|
|
187
|
+
* adapter's name-derivation refusal (`resolveRegId`) test the same rule and can never drift.
|
|
188
|
+
*/
|
|
189
|
+
function isRegId(regId) {
|
|
190
|
+
return REG_ID_PATTERN.test(regId);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Validate a `regId` against the charset/length (`[a-z0-9._-]`, 1–64 chars) — the gate
|
|
194
|
+
* that makes the scoped keyspace injective without escaping (the delimiter `:` cannot occur
|
|
195
|
+
* inside a `regId`). Called at `configure` time (the machine's single seeding path), so a
|
|
196
|
+
* malformed id fails LOUD before it can ever mint an ambiguous key. Throws on violation.
|
|
197
|
+
*/
|
|
198
|
+
function assertRegId(regId) {
|
|
199
|
+
if (!isRegId(regId)) throw new Error(`invalid regId ${JSON.stringify(regId)} — must match [a-z0-9._-], length 1-64 (E19)`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the registry-scoped mirror keys for `regId`. `regId` is expected to be
|
|
203
|
+
* already validated (it enters the machine only via `configure`, which calls `assertRegId`);
|
|
204
|
+
* this is a pure formatter on the hot flush/resync path, so it does not re-validate. Both the
|
|
205
|
+
* writer (execute.ts) and the reader (invalidation.ts) address keys ONLY through here.
|
|
206
|
+
*/
|
|
207
|
+
function mirrorKeys(regId) {
|
|
208
|
+
const prefix = `__reg:${regId}:`;
|
|
209
|
+
return {
|
|
210
|
+
deltaKey: (seq) => `${prefix}delta:${seq}`,
|
|
211
|
+
snapKey: (seq) => `${prefix}snap:${seq}`,
|
|
212
|
+
stateKey: `${prefix}state`
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/tags.ts
|
|
217
|
+
/** The two reserved tag namespaces: the per-key digest and the
|
|
218
|
+
* per-namespace watermark. A user tag beginning with this prefix is rejected
|
|
219
|
+
* at write — only the kernel itself may stamp `__k:<h>` / `__ns:<name>`. */
|
|
220
|
+
const RESERVED_TAG_PREFIX = "__";
|
|
221
|
+
/** Whether `tag` falls in the reserved `__`-prefixed namespace. */
|
|
222
|
+
function isReservedTag(tag) {
|
|
223
|
+
return tag.startsWith(RESERVED_TAG_PREFIX);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Validate one caller-supplied tag at the write boundary. Throws a
|
|
227
|
+
* real `InvalidTagError` — never `NotImplementedError` — the instant a
|
|
228
|
+
* `__`-prefixed tag reaches `getOrSet`/`getOrSetEntry`/a purge selector, so a
|
|
229
|
+
* forged reserved tag can never be smuggled in as a "user" tag.
|
|
230
|
+
*
|
|
231
|
+
* Division of labor: the CHARSET/length/non-empty authority is the builders — `t`
|
|
232
|
+
* validates-and-rejects, `compound` escapes — so a `Tag` they produce is already
|
|
233
|
+
* well-formed. This write-time check is deliberately the NARROW `__`-prefix backstop,
|
|
234
|
+
* not a re-validation: it catches only the one class the type system cannot — a
|
|
235
|
+
* `__`-prefixed string force-branded to `Tag` (a raw `as Tag` cast, a harness `mkTag`,
|
|
236
|
+
* any hand-forged brand) that never passed through a builder. It intentionally does NOT
|
|
237
|
+
* re-check charset — narrow, not weak.
|
|
238
|
+
*/
|
|
239
|
+
function assertUserTag(tag) {
|
|
240
|
+
if (isReservedTag(tag)) throw new InvalidTagError(tag, `user tags may not begin with the reserved "${RESERVED_TAG_PREFIX}" prefix (§2.3, MI13)`);
|
|
241
|
+
}
|
|
242
|
+
/** The full tag charset: the design's `[a-z0-9:_\-.|]` extended with
|
|
243
|
+
* `%` (`compound()`'s escape byte). One or more of these, nothing else. `-` is placed
|
|
244
|
+
* last so it is a literal, not a range. */
|
|
245
|
+
const TAG_CHARSET = /^[a-z0-9:_.|%-]+$/u;
|
|
246
|
+
/** Maximum tag size — a UTF-8 BYTE budget, not code units. */
|
|
247
|
+
const MAX_TAG_BYTES = 256;
|
|
248
|
+
/** UTF-8 byte length of `s` — the tag budget is bytes, not string length. */
|
|
249
|
+
function byteLength(s) {
|
|
250
|
+
return new TextEncoder().encode(s).length;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Template helper for a single tag, e.g. ``t`book:${id}` ``. The tag TRUST
|
|
254
|
+
* BOUNDARY: the interpolations (`values`) are UNTRUSTED user data, so `t` assembles the
|
|
255
|
+
* template, then VALIDATES-and-REJECTS the final string against the full grammar
|
|
256
|
+
* (non-empty, charset, ≤256 UTF-8 bytes, not `__`-prefixed) — throwing `InvalidTagError`
|
|
257
|
+
* on any violation. It never escapes-by-guess: escaping is `compound()`'s separate
|
|
258
|
+
* contract, and silently rewriting an interpolation is exactly how tag injection slips
|
|
259
|
+
* through. On success returns the branded `Tag`.
|
|
260
|
+
*/
|
|
261
|
+
function t(strings, ...values) {
|
|
262
|
+
let assembled = "";
|
|
263
|
+
for (const [i, literal] of strings.entries()) {
|
|
264
|
+
assembled += literal;
|
|
265
|
+
if (i < values.length) assembled += String(values[i]);
|
|
266
|
+
}
|
|
267
|
+
if (assembled.length === 0) throw new InvalidTagError(assembled, "a tag must be non-empty (§2.3)");
|
|
268
|
+
assertUserTag(assembled);
|
|
269
|
+
if (!TAG_CHARSET.test(assembled)) throw new InvalidTagError(assembled, "a tag character is outside the [a-z0-9:_-.|%] grammar (§2.3, E10)");
|
|
270
|
+
if (byteLength(assembled) > MAX_TAG_BYTES) throw new InvalidTagError(assembled, `a tag may not exceed ${MAX_TAG_BYTES} UTF-8 bytes (§2.3)`);
|
|
271
|
+
return assembled;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Escapes one `compound()` argument: `%` first (`%25`), then `:`
|
|
275
|
+
* (`%3a`) and `|` (`%7c`), lowercase hex. Escaping `%` first guarantees the
|
|
276
|
+
* mapping is injective — an escape-lookalike in the input (a literal `%3a`)
|
|
277
|
+
* is itself escaped to `%253a`, so it can never be produced by escaping a raw
|
|
278
|
+
* `:` (which yields `%3a`, with no leading `%25`).
|
|
279
|
+
*/
|
|
280
|
+
function escapePart(part) {
|
|
281
|
+
return String(part).replaceAll("%", "%25").replaceAll(":", "%3a").replaceAll("|", "%7c");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Injective compound tag over an argument vector, e.g. `compound('book', id,
|
|
285
|
+
* 'locale', locale)`. Escaping; empty vector distinct from
|
|
286
|
+
* `['']`.
|
|
287
|
+
*
|
|
288
|
+
* Every part is escaped so an escaped part can never itself
|
|
289
|
+
* contain an unescaped `|`; joining escaped parts on `|` is therefore an
|
|
290
|
+
* injective join. An explicit leading arity count (itself `|`-terminated)
|
|
291
|
+
* makes the count unambiguous — it is always a decimal-digit run followed by
|
|
292
|
+
* the first `|` — which in turn makes vectors of differing length (including
|
|
293
|
+
* the empty vector vs. `['']`) distinct regardless of what the escaped parts
|
|
294
|
+
* contain.
|
|
295
|
+
*/
|
|
296
|
+
function compound(...parts) {
|
|
297
|
+
return `${parts.length}|${parts.map(escapePart).join("|")}`;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A globally-scoped tag — its own scope-prefix escape hatch. Deferred to v0.4:
|
|
301
|
+
* its escape-FORM is a wire-compatibility decision reserved for the scope-qualification
|
|
302
|
+
* design, and ships with that consumer rather than standalone here.
|
|
303
|
+
*/
|
|
304
|
+
function globalTag(name) {
|
|
305
|
+
return notImplemented("globalTag (deferred to v0.4)", name);
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/telemetry.ts
|
|
309
|
+
/**
|
|
310
|
+
* The telemetry event-name CATALOG — the single, kernel-hosted registry of every
|
|
311
|
+
* event `type` this library emits, the same shape as the exported error classes. Each member is an
|
|
312
|
+
* event NAME; the value equals the key so a producer can reference a name as `TELEMETRY_EVENTS.<name>`
|
|
313
|
+
* (a literal type, refactor-safe) while `TelemetryEventName` (below) is the union of them all.
|
|
314
|
+
*
|
|
315
|
+
* The names are grouped by their emitting LAYER (kernel / L1 driver / react-router adapter /
|
|
316
|
+
* cloudflare adapter) — the annotation the charter requires. The machine-checked layer map (which
|
|
317
|
+
* fails to compile if a member is unannotated) lives in the catalog's exhaustiveness tooth in the
|
|
318
|
+
* test lane.
|
|
319
|
+
*
|
|
320
|
+
* GROWTH: adding a name is ADDITIVE and consumer-SAFE (a sink RECEIVES events, so a producer that
|
|
321
|
+
* emits a wider vocabulary hands a consumer a value from a set it already types against — narrowing,
|
|
322
|
+
* not widening, at the receiver) — so a new name is a MINOR-version change. Removing or renaming one
|
|
323
|
+
* is breaking.
|
|
324
|
+
*/
|
|
325
|
+
const TELEMETRY_EVENTS = {
|
|
326
|
+
writeback_throttled: "writeback_throttled",
|
|
327
|
+
l2_write_error: "l2_write_error",
|
|
328
|
+
singleflight_option_mismatch: "singleflight_option_mismatch",
|
|
329
|
+
store_read_suppressed: "store_read_suppressed",
|
|
330
|
+
neg_suppressed: "neg_suppressed",
|
|
331
|
+
private_evicted: "private_evicted",
|
|
332
|
+
poll_tick_failed: "poll_tick_failed",
|
|
333
|
+
l3_ineligible: "l3_ineligible",
|
|
334
|
+
regid_divergence: "regid_divergence",
|
|
335
|
+
bus_dial_failed: "bus_dial_failed",
|
|
336
|
+
strong_degraded: "strong_degraded",
|
|
337
|
+
registry_degraded: "registry_degraded",
|
|
338
|
+
state_stale: "state_stale"
|
|
339
|
+
};
|
|
340
|
+
/** Report a swallowed sink failure to the injected hook, itself guarded. The hook is app code at the
|
|
341
|
+
* very end of the swallow chain, so its own failure has no meta-channel to report to — swallowed, so
|
|
342
|
+
* `safeEmit` cannot throw or float through it. */
|
|
343
|
+
function reportSinkError(hook, error) {
|
|
344
|
+
if (hook === void 0) return;
|
|
345
|
+
const called = hook;
|
|
346
|
+
try {
|
|
347
|
+
Promise.resolve(called(error)).catch(() => {});
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Emit ONE event through an app-supplied sink so that NOTHING it does can escape (
|
|
352
|
+
* the swallow-wrap extracted from the poll driver and made shared). A sink is arbitrary app code;
|
|
353
|
+
* both of its failure modes are neutralized here:
|
|
354
|
+
*
|
|
355
|
+
* • a SYNCHRONOUS throw → the `try`/`catch` (without it the caller's own path would throw);
|
|
356
|
+
* • a REJECTING thenable → the returned value is ADOPTED (`Promise.resolve(...).catch(...)`) so a
|
|
357
|
+
* rejecting `async` sink can never float out as an unhandled rejection.
|
|
358
|
+
*
|
|
359
|
+
* On either failure the injected `onSinkError` hook is called exactly once for that emit. `safeEmit`
|
|
360
|
+
* NEVER throws and returns nothing the caller must await (adopt-don't-await: it must not inherit
|
|
361
|
+
* app-code latency on a background or response path).
|
|
362
|
+
*
|
|
363
|
+
* The TYPE is the structural tooth: `event` is a `TelemetryEvent`, whose `type` is the CATALOG
|
|
364
|
+
* union — so an off-catalog event name is a COMPILE error at the call site, never a grep finding.
|
|
365
|
+
*/
|
|
366
|
+
function safeEmit(sink, event, onSinkError) {
|
|
367
|
+
if (sink === void 0) return;
|
|
368
|
+
const called = sink;
|
|
369
|
+
try {
|
|
370
|
+
Promise.resolve(called(event)).catch((error) => {
|
|
371
|
+
reportSinkError(onSinkError, error);
|
|
372
|
+
});
|
|
373
|
+
} catch (error) {
|
|
374
|
+
reportSinkError(onSinkError, error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/invalidation.ts
|
|
379
|
+
/** The one reason class a `strong_degraded` emit carries today. */
|
|
380
|
+
const STRONG_DEGRADED_REGISTRY_UNREACHABLE = "registry_unreachable";
|
|
381
|
+
/** The channel's starting state, and the one a reader returns to saying rather than assuming: it has
|
|
382
|
+
* never heard a `hello`. Named once so the three sites that compare against it cannot drift. */
|
|
383
|
+
const NEVER_ESTABLISHED = "never-established";
|
|
384
|
+
/** Build the `transient-miss` retry signal for `seq` (the single runtime literal source). */
|
|
385
|
+
function transientMiss(seq) {
|
|
386
|
+
return {
|
|
387
|
+
kind: "transient-miss",
|
|
388
|
+
seq
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** Narrow an `unknown` to an index-readable object, or `undefined`. The
|
|
392
|
+
* single narrowing assertion in this module: every field read off the result is
|
|
393
|
+
* individually type-checked below before use, so the record view is sound. */
|
|
394
|
+
function parseValueObject(value) {
|
|
395
|
+
if (typeof value !== "object" || value === null) return;
|
|
396
|
+
return value;
|
|
397
|
+
}
|
|
398
|
+
/** Parse a stored JSON string to an object, or `undefined` on a malformed body. */
|
|
399
|
+
function parseJsonObject(raw) {
|
|
400
|
+
let parsed;
|
|
401
|
+
try {
|
|
402
|
+
parsed = JSON.parse(raw);
|
|
403
|
+
} catch {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
return parseValueObject(parsed);
|
|
407
|
+
}
|
|
408
|
+
/** Read the `state` pointer shape from stored bytes. Malformed — including a
|
|
409
|
+
* non-string or absent `regId` echo — ⇒ undefined (fail closed). */
|
|
410
|
+
function readPointer(raw) {
|
|
411
|
+
const o = parseJsonObject(raw);
|
|
412
|
+
if (!o || typeof o.epoch !== "number" || typeof o.lastBatch !== "number" || typeof o.regId !== "string") return;
|
|
413
|
+
const snapBatch = typeof o.snapBatch === "number" ? o.snapBatch : 0;
|
|
414
|
+
const flushedAt = typeof o.flushedAt === "number" ? o.flushedAt : 0;
|
|
415
|
+
return {
|
|
416
|
+
epoch: o.epoch,
|
|
417
|
+
flushedAt,
|
|
418
|
+
lastBatch: o.lastBatch,
|
|
419
|
+
regId: o.regId,
|
|
420
|
+
snapBatch
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Read one watermark change from a parsed value. A CORRUPT change ⇒ `undefined`
|
|
425
|
+
* (fail closed), DISTINGUISHED from a legitimately-absent optional axis:
|
|
426
|
+
* • a PRESENT-but-non-number `softEpoch`/`hardEpoch` (the field is on the object but is not a
|
|
427
|
+
* `number`) is corruption — dropping it and applying the rest would silently delete a purge
|
|
428
|
+
* from history (a serve-dead vector under non-canonical KV writes);
|
|
429
|
+
* • an AXIS-EMPTY change (a `tag` but NEITHER a numeric `softEpoch` NOR a numeric `hardEpoch`)
|
|
430
|
+
* violates the ≥1-axis rule.
|
|
431
|
+
* An ABSENT optional axis stays legal — a change may legitimately touch a single axis. A corrupt
|
|
432
|
+
* change holes its whole batch via `readFrame` (never a partial-apply); the caller fails closed.
|
|
433
|
+
*/
|
|
434
|
+
function readChange(value) {
|
|
435
|
+
const rec = parseValueObject(value);
|
|
436
|
+
if (!rec) return;
|
|
437
|
+
const { tag } = rec;
|
|
438
|
+
if (typeof tag !== "string") return;
|
|
439
|
+
const soft = rec.softEpoch;
|
|
440
|
+
const hard = rec.hardEpoch;
|
|
441
|
+
if ("softEpoch" in rec && typeof soft !== "number" || "hardEpoch" in rec && typeof hard !== "number") return;
|
|
442
|
+
if (typeof soft !== "number" && typeof hard !== "number") return;
|
|
443
|
+
return {
|
|
444
|
+
tag,
|
|
445
|
+
...typeof soft === "number" ? { softEpoch: soft } : {},
|
|
446
|
+
...typeof hard === "number" ? { hardEpoch: hard } : {}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Read a `__delta` batch frame from stored bytes. Malformed ⇒ undefined. Exported so the
|
|
451
|
+
* doBus wire codec (`src/adapters/cloudflare/wire.ts`) validates a wire-carried frame through the
|
|
452
|
+
* SAME frame parser the KV-resync path uses — one parser for the durable mirror AND the live channel,
|
|
453
|
+
* so a corrupt wire change fails closed exactly like a corrupt `__delta` byte (never a partial
|
|
454
|
+
* apply). Additive export only: no reader branch changes, no behavior change (the corrupt-change
|
|
455
|
+
* fail-closed semantics are pinned by `reader-corrupt-change-fail-closed`).
|
|
456
|
+
*/
|
|
457
|
+
function readFrame(raw) {
|
|
458
|
+
const o = parseJsonObject(raw);
|
|
459
|
+
if (!o || typeof o.fromEpoch !== "number" || typeof o.toEpoch !== "number" || !Array.isArray(o.changes)) return;
|
|
460
|
+
const changes = [];
|
|
461
|
+
for (const entry of o.changes) {
|
|
462
|
+
const change = readChange(entry);
|
|
463
|
+
if (!change) return;
|
|
464
|
+
changes.push(change);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
changes,
|
|
468
|
+
fromEpoch: o.fromEpoch,
|
|
469
|
+
toEpoch: o.toEpoch
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** Merge a frame's watermark changes into a knowledge map (max-on-write). */
|
|
473
|
+
function applyChanges(known, frame) {
|
|
474
|
+
for (const change of frame.changes) {
|
|
475
|
+
const w = known.get(change.tag) ?? {
|
|
476
|
+
hardEpoch: 0,
|
|
477
|
+
softEpoch: 0
|
|
478
|
+
};
|
|
479
|
+
if (change.softEpoch !== void 0) w.softEpoch = Math.max(w.softEpoch, change.softEpoch);
|
|
480
|
+
if (change.hardEpoch !== void 0) w.hardEpoch = Math.max(w.hardEpoch, change.hardEpoch);
|
|
481
|
+
known.set(change.tag, w);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** The validity table over an entry's `bornEpoch` and per-tag knowledge. */
|
|
485
|
+
function classify(bornEpoch, knowledge) {
|
|
486
|
+
if (knowledge.some((k) => !k.known)) return "unknown";
|
|
487
|
+
if (knowledge.some((k) => k.known && k.hardEpoch > bornEpoch)) return "dead";
|
|
488
|
+
if (knowledge.some((k) => k.known && k.softEpoch > bornEpoch)) return "stale";
|
|
489
|
+
return "fresh";
|
|
490
|
+
}
|
|
491
|
+
/** The highest `throughEpoch` a live check verified (0 if none is `known`). */
|
|
492
|
+
function throughEpochOf(knowledge) {
|
|
493
|
+
let max = 0;
|
|
494
|
+
for (const k of knowledge) if (k.known && k.throughEpoch > max) max = k.throughEpoch;
|
|
495
|
+
return max;
|
|
496
|
+
}
|
|
497
|
+
/** Default liveness heartbeat interval in ms — the cadence at which a live writer refreshes the
|
|
498
|
+
* pointer's freshness field. The reader mirrors the coordinator's `REGISTRY_HEARTBEAT_MS`; the
|
|
499
|
+
* 2b-2-b shell threads the real value at construction. Suspicion raises after `3 ×` this much
|
|
500
|
+
* OBSERVED SILENCE — an elapsed-time derivative, sampling-rate-independent (not a tick count). */
|
|
501
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
502
|
+
const SUSPICION_SILENCE_MULTIPLE = 3;
|
|
503
|
+
/**
|
|
504
|
+
* The per-isolate invalidation state. Tracks tri-state tag knowledge
|
|
505
|
+
* fed by the bus, the highest contiguously-applied epoch, and a `suspect` flag
|
|
506
|
+
* raised on any gap/reconnect that a resync must clear before warm trust resumes.
|
|
507
|
+
*Tags absent from the map are `unknown` and fail closed (invariant 11).
|
|
508
|
+
*/
|
|
509
|
+
var Invalidation = class {
|
|
510
|
+
#registry;
|
|
511
|
+
#stateStore;
|
|
512
|
+
/** The bus, HELD as an address rather than a subscription. Construction wires; it does not
|
|
513
|
+
* dial. The first read that needs the channel subscribes (`#ensureSubscribed`), inside the request
|
|
514
|
+
* context that asked — which is the only context where a dial is permitted to happen at all. */
|
|
515
|
+
#bus;
|
|
516
|
+
/** Telemetry for the reader's own raw facts — currently `bus_dial_failed`. Optional: a cache
|
|
517
|
+
* built with no sink emits nothing, and the reader never depends on the emit for correctness. */
|
|
518
|
+
#emit;
|
|
519
|
+
/** This isolate's mirror-key scope, read from `registry.regId` at construction. It
|
|
520
|
+
* both scopes the keys this reader resyncs off (`mirrorKeys(#regId)`) AND is the value the
|
|
521
|
+
* pointer's echoed `regId` must equal — a mismatch reads a foreign keyspace and fails closed. */
|
|
522
|
+
#regId;
|
|
523
|
+
#subscription = null;
|
|
524
|
+
/** `close()` was called: no further subscribe may happen (a lazy dial must not resurrect a closed
|
|
525
|
+
* reader — the one way deferral could outlive its owner). */
|
|
526
|
+
#closed = false;
|
|
527
|
+
/** The live channel's state — see `ChannelState`. Starts at the honest answer: this reader
|
|
528
|
+
* has never heard a `hello`, and until it does it says so rather than resting on silence. */
|
|
529
|
+
#channel = NEVER_ESTABLISHED;
|
|
530
|
+
/** tag → applied watermark; absence ⇒ `unknown` (tri-state). */
|
|
531
|
+
#known = /* @__PURE__ */ new Map();
|
|
532
|
+
/** Highest contiguous epoch applied from bus frames / resync. Drives gap
|
|
533
|
+
* detection — must stay strictly contiguous. */
|
|
534
|
+
#appliedEpoch = 0;
|
|
535
|
+
/** Highest epoch this isolate has VERIFIED from any authority — the contiguous bus
|
|
536
|
+
* `#appliedEpoch` OR a live check's `throughEpoch`. This is the writer's
|
|
537
|
+
* applied registry epoch for bornEpoch minting: a fill after a verified
|
|
538
|
+
* live check must postdate what that check observed, so a legitimate refill on a
|
|
539
|
+
* behind isolate is not fenced by its own recorded watermark. */
|
|
540
|
+
#knownThroughEpoch = 0;
|
|
541
|
+
/** Raised on a bus gap or reset; forces resync before warm trust. While raised,
|
|
542
|
+
* every tag is treated as unknown at eventual reads — a possibly-gapped map is not the
|
|
543
|
+
* merely-degraded warm map Q4 lets an isolate keep serving on. */
|
|
544
|
+
#suspect = false;
|
|
545
|
+
/** The max head DECLARED over the currently-open live channel during THIS suspect episode
|
|
546
|
+
* — from a LOSS-revealing event only: `hello.head`, `gap.head`, or a NON-CONTIGUOUS
|
|
547
|
+
* frame's `toEpoch`. A contiguous frame never declares (it only reaches toward a named head).
|
|
548
|
+
* `null` until a head is declared this episode; a `reset` discards it (new episode), a `gap`
|
|
549
|
+
* RAISES it (≥ the frame it lost) — so a hidden purge behind a hole is accounted before
|
|
550
|
+
* convergence, never skipped (serve-dead — the shape). Convergence anchors
|
|
551
|
+
* to THIS, never to pointer freshness. */
|
|
552
|
+
#headThisEpisode = null;
|
|
553
|
+
/** The `flushedAt` of the pointer at the last observation that ADVANCED it. `null` until
|
|
554
|
+
* the first observation seeds the baseline. Compared DERIVATIVELY — "did it advance?" via
|
|
555
|
+
* EQUALITY/`>` — never as an absolute-freshness anchor ( : `flushedAt` may not clear
|
|
556
|
+
* suspicion nor date the map). */
|
|
557
|
+
#lastFlushedAt = null;
|
|
558
|
+
/** The `nowMs` of the previous observation — a MONOTONICITY guard so a stale/reordered tick
|
|
559
|
+
* (`nowMs ≤` this) is a true no-op, NOT an age anchor (it is never compared to `flushedAt`).
|
|
560
|
+
* `null` until the first observation. */
|
|
561
|
+
#lastObservationMs = null;
|
|
562
|
+
/** The `nowMs` at which the current unbroken SILENCE began — the last observation that seeded
|
|
563
|
+
* the baseline or saw `flushedAt` advance. `nowMs − this` is the observed silence; reaching
|
|
564
|
+
* `#suspicionAfterMs` RAISES suspicion (derivative, raise-only, never clears). `null` until the
|
|
565
|
+
* first observation. */
|
|
566
|
+
#silenceStartMs = null;
|
|
567
|
+
/** Raise suspicion once OBSERVED SILENCE reaches this — `3 × heartbeatIntervalMs`, fixed at
|
|
568
|
+
* construction. Elapsed silence is sampling-rate-INDEPENDENT: fast ticks cannot witness this much
|
|
569
|
+
* silence in less than this much time, so a false raise is structurally impossible. */
|
|
570
|
+
#suspicionAfterMs;
|
|
571
|
+
constructor(registry, stateStore, bus, heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, emit) {
|
|
572
|
+
this.#registry = registry;
|
|
573
|
+
this.#emit = emit;
|
|
574
|
+
this.#suspicionAfterMs = SUSPICION_SILENCE_MULTIPLE * heartbeatIntervalMs;
|
|
575
|
+
this.#stateStore = stateStore;
|
|
576
|
+
this.#regId = registry.regId;
|
|
577
|
+
this.#bus = bus;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Subscribe to the invalidation bus if this reader has not already — THE LAZY DIAL,
|
|
581
|
+
* and the deferral the reachability rule requires of construction. Idempotent and synchronous, so any entry point
|
|
582
|
+
* may call it unconditionally; the dial itself is the transport's business and may complete long
|
|
583
|
+
* after this returns.
|
|
584
|
+
*
|
|
585
|
+
* CALL IT FROM EVERY PATH THAT IS "USE" — the read path (`validate`), the tick path (`resync` /
|
|
586
|
+
* `resyncViaSnapshot`), and the purge verbs, which are registry RPCs and therefore already inside
|
|
587
|
+
* a request context. NEVER from `explain`: a witness that dialed would be testimony rather than
|
|
588
|
+
* introspection, which is why `explainVerdict` is the one reader entry point that does not
|
|
589
|
+
* appear on that list.
|
|
590
|
+
*
|
|
591
|
+
* NOTHING IS MISSED BY SUBSCRIBING LATE. A purge published before this reader was listening is not
|
|
592
|
+
* lost: every establishment handshakes `reset` → `hello{head}`, which suspends warm trust and
|
|
593
|
+
* anchors a resync against the durable `__state`/`__delta` mirror. The live channel is an
|
|
594
|
+
* optimization over that mirror, never the sole path — so the cost of subscribing at first use is
|
|
595
|
+
* one cold resync, and the benefit is an isolate that can be built at module scope at all.
|
|
596
|
+
*
|
|
597
|
+
* The subscriber consumes a BusEvent union: a `reset` (transport (re)establishment) or a `gap`
|
|
598
|
+
* (dropped frame) marks the map suspect and forces a resync before warm trust resumes; a `frame`
|
|
599
|
+
* takes the contiguity path; a `lost` records the channel's own state.
|
|
600
|
+
*/
|
|
601
|
+
ensureSubscribed() {
|
|
602
|
+
if (this.#subscription !== null || this.#closed) return;
|
|
603
|
+
this.#subscription = this.#bus.subscribe((event) => {
|
|
604
|
+
this.#applyEvent(event);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
get appliedEpoch() {
|
|
608
|
+
return this.#appliedEpoch;
|
|
609
|
+
}
|
|
610
|
+
/** Whether this isolate currently DISTRUSTS its warm map — read-only. A read
|
|
611
|
+
* arriving while suspect fails closed (the gate); the shell reads it to know whether
|
|
612
|
+
* the ladder still has work. Raised by a bus gap/reset OR the derivative heartbeat miss;
|
|
613
|
+
* cleared ONLY by `#tryConverge` reaching a live-channel head (never by pointer freshness). */
|
|
614
|
+
get suspect() {
|
|
615
|
+
return this.#suspect;
|
|
616
|
+
}
|
|
617
|
+
/** The live channel's state — read-only exposition of state this reader already tracks for
|
|
618
|
+
* its own use, never new bookkeeping added for the witness. */
|
|
619
|
+
get channel() {
|
|
620
|
+
return this.#channel;
|
|
621
|
+
}
|
|
622
|
+
close() {
|
|
623
|
+
this.#closed = true;
|
|
624
|
+
this.#subscription?.close();
|
|
625
|
+
this.#subscription = null;
|
|
626
|
+
}
|
|
627
|
+
/** Advance the contiguous applied epoch (and the verified high-water) to `toEpoch`. */
|
|
628
|
+
#advanceApplied(toEpoch) {
|
|
629
|
+
this.#appliedEpoch = toEpoch;
|
|
630
|
+
this.#knownThroughEpoch = Math.max(this.#knownThroughEpoch, toEpoch);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Dispatch one bus event. Convergence anchors to a head DECLARED this episode,
|
|
634
|
+
*from exactly the LOSS-REVEALING events: a `hello` (the channel head), a `gap`
|
|
635
|
+
* (its `head`, ≥ the frame it lost), or a NON-CONTIGUOUS frame (its `toEpoch`, the hole
|
|
636
|
+
* it proves). A CONTIGUOUS frame does NOT declare — it only reaches toward a head already named.
|
|
637
|
+
* A `reset` discards the anchor (a new episode; the following `hello` re-declares). The kernel,
|
|
638
|
+
* not the bus, owns contiguity validation.
|
|
639
|
+
*/
|
|
640
|
+
#applyEvent(event) {
|
|
641
|
+
switch (event.kind) {
|
|
642
|
+
case "frame":
|
|
643
|
+
this.#applyFrame(event.frame);
|
|
644
|
+
return;
|
|
645
|
+
case "hello":
|
|
646
|
+
this.#channel = "established";
|
|
647
|
+
this.#headThisEpisode = Math.max(this.#headThisEpisode ?? 0, event.head);
|
|
648
|
+
this.#tryConverge();
|
|
649
|
+
return;
|
|
650
|
+
case "lost": {
|
|
651
|
+
const healthyClose = this.#channel === "established";
|
|
652
|
+
this.#channel = this.#channel === NEVER_ESTABLISHED ? NEVER_ESTABLISHED : "lost";
|
|
653
|
+
if (!healthyClose) this.#emit?.({ type: TELEMETRY_EVENTS.bus_dial_failed });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
case "gap":
|
|
657
|
+
this.#suspect = true;
|
|
658
|
+
this.#headThisEpisode = Math.max(this.#headThisEpisode ?? 0, event.head);
|
|
659
|
+
return;
|
|
660
|
+
case "reset":
|
|
661
|
+
this.#suspect = true;
|
|
662
|
+
this.#headThisEpisode = null;
|
|
663
|
+
return;
|
|
664
|
+
default: return event;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Apply a bus frame if contiguous; otherwise raise suspicion. */
|
|
668
|
+
#applyFrame(frame) {
|
|
669
|
+
if (frame.fromEpoch !== this.#appliedEpoch) {
|
|
670
|
+
this.#suspect = true;
|
|
671
|
+
this.#headThisEpisode = Math.max(this.#headThisEpisode ?? 0, frame.toEpoch);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
applyChanges(this.#known, frame);
|
|
675
|
+
this.#advanceApplied(frame.toEpoch);
|
|
676
|
+
this.#tryConverge();
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Clear suspicion iff a head was DECLARED over the live channel this episode (hello/frame)
|
|
680
|
+
* AND the applied epoch has reached it. `#appliedEpoch` advances only by contiguous
|
|
681
|
+
* application, so `applied >= head` already encodes hole-free reach (β) — no separate holes
|
|
682
|
+
* check. Pointer freshness never arbitrates clearing: a heartbeat refreshes the pointer's
|
|
683
|
+
* freshness field without minting epochs, so freshness motion is not evidence the reader has
|
|
684
|
+
* caught up; only a live-channel head declaration anchors convergence. Bus-down /
|
|
685
|
+
* poll-only declares no head ⇒ sticky suspect (posture, priced).
|
|
686
|
+
*/
|
|
687
|
+
#tryConverge() {
|
|
688
|
+
if (this.#suspect && this.#headThisEpisode !== null && this.#appliedEpoch >= this.#headThisEpisode) this.#suspect = false;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Cold/resync path — the bounded single DELTA pass. Read the `__state`
|
|
692
|
+
* pointer from L2 and replay the `(applied, lastBatch]` delta batches — durably write-once
|
|
693
|
+
* in L2 even when the bus frame was dropped — applying the contiguous prefix (β), which
|
|
694
|
+
* advances `#appliedEpoch`. A missing pointer or a hole leaves affected knowledge `unknown`
|
|
695
|
+
* (fail closed). Resync does NOT clear suspicion off pointer freshness: a heartbeat refreshes the
|
|
696
|
+
* freshness field without minting epochs, so freshness motion is not evidence of catch-up.
|
|
697
|
+
*It only APPLIES the durable prefix; clearing is `#tryConverge`'s job, and fires iff
|
|
698
|
+
* a head was DECLARED over the live channel this episode (hello/frame) AND this replay carried
|
|
699
|
+
* the applied epoch up to it.
|
|
700
|
+
*
|
|
701
|
+
* SURFACES A STATUS (2b-2-a): the walk's hole is no longer discarded — it is returned so the
|
|
702
|
+
* SHELL (2b-2-b) can schedule the retry ladder off a pure decision. `resync` NEVER sleeps; the
|
|
703
|
+
* escalation across attempts is driven by the caller's `policy.deltasExhausted`, not by time.
|
|
704
|
+
*
|
|
705
|
+
* THE RETRY LADDER NEVER BLOCKS A READ: `validate()`'s awaited resync stays THIS bounded fast
|
|
706
|
+
* pass; a read arriving during `transient-miss` classifies `unknown` and takes the posture
|
|
707
|
+
* NOW — the ladder heals the future, not the present. Fail-closed means what it says. (So
|
|
708
|
+
* `validate` calls `resync()` with the default policy and ignores the status; it does not wait
|
|
709
|
+
* on retries, and a `transient-miss`/`needs-snapshot`/`terminal-gap` read fails closed at once
|
|
710
|
+
* via the gate.)
|
|
711
|
+
*/
|
|
712
|
+
async resync(policy) {
|
|
713
|
+
this.ensureSubscribed();
|
|
714
|
+
const deltasExhausted = policy?.deltasExhausted ?? false;
|
|
715
|
+
const mirror = await this.#readMirrorPointer();
|
|
716
|
+
if (!mirror) {
|
|
717
|
+
this.#tryConverge();
|
|
718
|
+
return transientMiss(0);
|
|
719
|
+
}
|
|
720
|
+
const hole = await this.#replayBatches(mirror.store, mirror.keys, 1, mirror.pointer.lastBatch);
|
|
721
|
+
this.#tryConverge();
|
|
722
|
+
if (hole === null) return "converged";
|
|
723
|
+
return deltasExhausted ? "needs-snapshot" : transientMiss(hole);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The SNAPSHOT bridge — the escalation the shell drives after `resync` returns
|
|
727
|
+
* `needs-snapshot`. Load ONLY the pointer-blessed snapshot `__snap:<pointer.snapBatch>` (a
|
|
728
|
+
* from-0 `BusFrame`, `fromEpoch:0`, parsed by the SAME `readFrame`), apply it as an ABSOLUTE
|
|
729
|
+
* base (`#appliedEpoch` ← `snap.toEpoch` when ahead — bridging a hole in the now-GC-eligible
|
|
730
|
+
* region below `snapBatch`), then replay the tail `(snapBatch, lastBatch]`. Never fetches an
|
|
731
|
+
* UNBLESSED snap nor a delta past `lastBatch` (the over-fetch discipline the contract pins).
|
|
732
|
+
*
|
|
733
|
+
* A lagging `snapBatch` is FIRST-CLASS NORMAL (1c): `snapBatch` may trail `lastBatch`
|
|
734
|
+
* arbitrarily — the arithmetic on `(snapBatch, lastBatch]` is correct, no warning, no anomaly.
|
|
735
|
+
*
|
|
736
|
+
* BLESSED-BUT-MISSING SNAP: if `__snap:<snapBatch>` is itself absent, give it ONE brief retry
|
|
737
|
+
* (`transient-miss` — it may be lagging); once the caller's snapshot-budget is spent
|
|
738
|
+
* (`snapshotExhausted`), rule permanently — a FULL delta replay from the floor if the chain
|
|
739
|
+
* `(0, lastBatch]` is intact (⇒ `converged`), else `terminal-gap`. No spin, no serve-through.
|
|
740
|
+
*/
|
|
741
|
+
async resyncViaSnapshot(policy) {
|
|
742
|
+
this.ensureSubscribed();
|
|
743
|
+
const snapshotExhausted = policy?.snapshotExhausted ?? false;
|
|
744
|
+
const mirror = await this.#readMirrorPointer();
|
|
745
|
+
if (!mirror) {
|
|
746
|
+
this.#tryConverge();
|
|
747
|
+
return transientMiss(0);
|
|
748
|
+
}
|
|
749
|
+
const { store, keys, pointer } = mirror;
|
|
750
|
+
const snapRaw = await store.get(keys.snapKey(pointer.snapBatch), "snap");
|
|
751
|
+
const snap = snapRaw ? readFrame(snapRaw.value) : void 0;
|
|
752
|
+
if (!snap) {
|
|
753
|
+
if (!snapshotExhausted) {
|
|
754
|
+
this.#tryConverge();
|
|
755
|
+
return transientMiss(pointer.snapBatch);
|
|
756
|
+
}
|
|
757
|
+
const holeNoSnap = await this.#replayBatches(store, keys, 1, pointer.lastBatch);
|
|
758
|
+
this.#tryConverge();
|
|
759
|
+
return holeNoSnap === null ? "converged" : "terminal-gap";
|
|
760
|
+
}
|
|
761
|
+
this.#applySnapshotBase(snap);
|
|
762
|
+
const tailHole = await this.#replayBatches(store, keys, pointer.snapBatch + 1, pointer.lastBatch);
|
|
763
|
+
this.#tryConverge();
|
|
764
|
+
if (tailHole === null) return "converged";
|
|
765
|
+
return transientMiss(tailHole);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Read the `__state` pointer through the registry-scoped keys, returning the scoped
|
|
769
|
+
* `store`/`keys` alongside it, or `undefined` when the pointer is absent/malformed OR echoes a
|
|
770
|
+
* foreign `regId` (a scheme/prefix divergence or another registry's pointer — fail closed).
|
|
771
|
+
*/
|
|
772
|
+
async #readMirrorPointer() {
|
|
773
|
+
const store = this.#stateStore;
|
|
774
|
+
const keys = mirrorKeys(this.#regId);
|
|
775
|
+
const raw = await store.get(keys.stateKey, "pointer");
|
|
776
|
+
const pointer = raw ? readPointer(raw.value) : void 0;
|
|
777
|
+
if (!pointer || pointer.regId !== this.#regId) return;
|
|
778
|
+
return {
|
|
779
|
+
keys,
|
|
780
|
+
pointer,
|
|
781
|
+
store
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Replay batches `fromSeq..lastBatch` (XRANGE discipline), applying each whose
|
|
786
|
+
* `fromEpoch` is contiguous with the applied epoch. Returns the seq of the FIRST hole
|
|
787
|
+
* (a missing/garbled/non-contiguous forward batch — fail closed), or `null` when the walk
|
|
788
|
+
* reached `lastBatch` hole-free. `fromSeq` is `1` for a from-floor `(0, lastBatch]` replay and
|
|
789
|
+
* `snapBatch + 1` for a snapshot-bridged tail `(snapBatch, lastBatch]` — so GC'd batches below
|
|
790
|
+
* a snapshot floor are never miscounted as holes. Reads ONLY `deltaKey(fromSeq..lastBatch)` —
|
|
791
|
+
* never a seq past the pointer — through the SAME `keys` the pointer was read with (
|
|
792
|
+
* over-fetch discipline).
|
|
793
|
+
*/
|
|
794
|
+
async #replayBatches(store, keys, fromSeq, lastBatch) {
|
|
795
|
+
let firstHole = null;
|
|
796
|
+
for (let seq = fromSeq; seq <= lastBatch; seq++) {
|
|
797
|
+
const batchRaw = await store.get(keys.deltaKey(seq), "delta");
|
|
798
|
+
const frame = batchRaw ? readFrame(batchRaw.value) : void 0;
|
|
799
|
+
const applied = frame ? this.#applyIfContiguous(frame) : false;
|
|
800
|
+
if (!(frame && applied) && firstHole === null) firstHole = seq;
|
|
801
|
+
}
|
|
802
|
+
return firstHole;
|
|
803
|
+
}
|
|
804
|
+
/** Apply a batch frame iff it advances the applied epoch contiguously. */
|
|
805
|
+
#applyIfContiguous(frame) {
|
|
806
|
+
if (frame.toEpoch <= this.#appliedEpoch) return true;
|
|
807
|
+
if (frame.fromEpoch !== this.#appliedEpoch) return false;
|
|
808
|
+
applyChanges(this.#known, frame);
|
|
809
|
+
this.#advanceApplied(frame.toEpoch);
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Apply a from-0 snapshot frame as an ABSOLUTE base: merge its watermarks (max-on-write,
|
|
814
|
+
* never regressing a higher already-known value) and JUMP the applied epoch to `snap.toEpoch`
|
|
815
|
+
* when the snapshot is AHEAD — a complete `(0, snap.toEpoch]` checkpoint carries its own
|
|
816
|
+
* contiguity from the floor, so no `fromEpoch === applied` gate applies. A snapshot BEHIND the
|
|
817
|
+
* applied epoch (a lagging `snapBatch` — first-class normal) adds nothing and never regresses.
|
|
818
|
+
*/
|
|
819
|
+
#applySnapshotBase(snap) {
|
|
820
|
+
if (snap.toEpoch <= this.#appliedEpoch) return;
|
|
821
|
+
applyChanges(this.#known, snap);
|
|
822
|
+
this.#advanceApplied(snap.toEpoch);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* derivative suspicion RAISING — a PURE state update fed one pointer
|
|
826
|
+
* observation. A heartbeat refreshes the pointer's freshness field without minting epochs, so
|
|
827
|
+
* freshness MOTION is not evidence the reader has caught up; only a live-channel head declaration
|
|
828
|
+
* anchors convergence. This monitor uses freshness the OTHER way — to RAISE: it tracks OBSERVED
|
|
829
|
+
* SILENCE (how long `flushedAt` has failed to advance) and raises `#suspect` once that silence
|
|
830
|
+
* reaches `#suspicionAfterMs`. DERIVATIVE-ONLY, RAISE-ONLY: `flushedAt` never clears suspicion nor
|
|
831
|
+
* anchors absolute freshness — only `>`/equality ACROSS observations, only to RAISE.
|
|
832
|
+
* `nowMs` is compared ONLY to our own prior watch-times (`#lastObservationMs`, `#silenceStartMs`),
|
|
833
|
+
* NEVER to `flushedAt`, so no absolute-age anchor sneaks in. Measuring ELAPSED silence (not
|
|
834
|
+
* counting non-advancing ticks) makes the raise SAMPLING-RATE-INDEPENDENT: fast ticks cannot
|
|
835
|
+
* witness `#suspicionAfterMs` of silence in less time, and a slow-but-live writer resets the
|
|
836
|
+
* window. The shell feeds it observations (2b-2-b); here explicit test ticks.
|
|
837
|
+
*/
|
|
838
|
+
notePointerObservation(pointer, nowMs) {
|
|
839
|
+
if (this.#lastObservationMs !== null && nowMs <= this.#lastObservationMs) return;
|
|
840
|
+
this.#lastObservationMs = nowMs;
|
|
841
|
+
if (this.#lastFlushedAt === null || this.#silenceStartMs === null) {
|
|
842
|
+
this.#lastFlushedAt = pointer.flushedAt;
|
|
843
|
+
this.#silenceStartMs = nowMs;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (pointer.flushedAt > this.#lastFlushedAt) {
|
|
847
|
+
this.#lastFlushedAt = pointer.flushedAt;
|
|
848
|
+
this.#silenceStartMs = nowMs;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (nowMs - this.#silenceStartMs >= this.#suspicionAfterMs) this.#suspect = true;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* The poll DECISION — a PURE, deterministic predicate: has at least `thresholdMs`
|
|
855
|
+
* elapsed since the last poll? The base cadence is maxSyncLag (60s) and the ±20% jitter is the
|
|
856
|
+
* SHELL's rng job (2b-2-b) — both enter as the caller-computed `thresholdMs`, so no ambient
|
|
857
|
+
* time/rng lives here. The poll DRIVER (the timer that calls this) is 2b-2-b.
|
|
858
|
+
*/
|
|
859
|
+
pollDue(nowMs, lastPollMs, thresholdMs) {
|
|
860
|
+
return nowMs - lastPollMs >= thresholdMs;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* The `bornEpoch` to stamp into a fresh envelope at fill start, read
|
|
864
|
+
* SYNCHRONOUSLY from LOCAL VERIFIED knowledge — the highest epoch this isolate has
|
|
865
|
+
* observed from any authority (contiguous bus applies OR a recorded live check),
|
|
866
|
+
* never a fresh live read. So the default read path keeps the zero-round-trip cost
|
|
867
|
+
* model, while a fill that follows a verified live check postdates what that check
|
|
868
|
+
* observed — a legitimate refill on a behind isolate is not fenced by its own
|
|
869
|
+
* recorded watermark. A lagging high-water can only UNDER-state bornEpoch ⇒
|
|
870
|
+
* over-invalidation ⇒ the safe direction. The caller snapshots
|
|
871
|
+
* this EARLY as the fence-critical floor (a concurrent bus purge landing after the
|
|
872
|
+
* snapshot cannot lift it, so INV-9/12 hold) and only raises the fill's bornEpoch by
|
|
873
|
+
* the epoch its OWN reads verified (via `validate`'s `checkedThrough`), never by a
|
|
874
|
+
* concurrent frame — see `runGetOrSet`.
|
|
875
|
+
*/
|
|
876
|
+
localBornEpoch() {
|
|
877
|
+
return this.#knownThroughEpoch;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The verdict `explain` reports — a PURE read of CURRENT local knowledge: classify
|
|
881
|
+
* the envelope against what this isolate knows NOW, with NO live check, NO resync, and NO
|
|
882
|
+
* recording. It is exposition, not testimony: it adds no tracked state and mutates nothing (the
|
|
883
|
+
* introspection-not-testimony line). A suspect map fails closed to `unknown` (the posture
|
|
884
|
+
* an eventual read would take), and `unknown` stays `unknown`. The soft/hard watermarks are the
|
|
885
|
+
* evidence the bornEpoch was weighed against — the purge epochs behind a `stale`/`dead` verdict
|
|
886
|
+
* (`0` when nothing is known / fail-closed).
|
|
887
|
+
*/
|
|
888
|
+
explainVerdict(envelope) {
|
|
889
|
+
const knowledge = this.#localKnowledge(envelope.tags);
|
|
890
|
+
let softEpoch = 0;
|
|
891
|
+
let hardEpoch = 0;
|
|
892
|
+
for (const k of knowledge) if (k.known) {
|
|
893
|
+
softEpoch = Math.max(softEpoch, k.softEpoch);
|
|
894
|
+
hardEpoch = Math.max(hardEpoch, k.hardEpoch);
|
|
895
|
+
}
|
|
896
|
+
const validity = this.#suspect ? "unknown" : classify(envelope.bornEpoch, knowledge);
|
|
897
|
+
return {
|
|
898
|
+
hardEpoch,
|
|
899
|
+
softEpoch,
|
|
900
|
+
validity
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Validate an envelope against this isolate's knowledge (table). `strong`
|
|
905
|
+
* (or an unknown tag under `registry-check`) consults a live registry read; a
|
|
906
|
+
* plain `eventual` read uses only local bus-fed knowledge and fails closed on any
|
|
907
|
+
* unknown/suspect tag (invariant 11). Never serves a hard-purged value.
|
|
908
|
+
*
|
|
909
|
+
* REACHABILITY IS SPLIT BY CHANNEL. This method is the REGISTRY-RPC half: a failure here is
|
|
910
|
+
* a strong read's `RegistryUnavailableError` or, on the eventual path, a `'miss'`.
|
|
911
|
+
* The BUS half is not represented here at all — a bus that is down or never came up is never
|
|
912
|
+
* grounds for a throw, because the eventual path's whole design is to be correct without it.
|
|
913
|
+
*/
|
|
914
|
+
async validate(envelope, consistency, resolution, onUnavailable) {
|
|
915
|
+
const { tags, bornEpoch } = envelope;
|
|
916
|
+
this.ensureSubscribed();
|
|
917
|
+
if (consistency === "strong") try {
|
|
918
|
+
return await this.#strongLiveCheck(tags, bornEpoch);
|
|
919
|
+
} catch (error) {
|
|
920
|
+
if (onUnavailable !== "eventual") throw new RegistryUnavailableError(`strong read could not reach the registry — ${String(error)}`);
|
|
921
|
+
this.#emit?.({
|
|
922
|
+
reason: STRONG_DEGRADED_REGISTRY_UNREACHABLE,
|
|
923
|
+
type: TELEMETRY_EVENTS.strong_degraded
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (this.#suspect) await this.resync();
|
|
927
|
+
const localVerdict = this.#suspect ? "unknown" : classify(bornEpoch, this.#localKnowledge(tags));
|
|
928
|
+
if (localVerdict !== "unknown") return {
|
|
929
|
+
checkedThrough: 0,
|
|
930
|
+
validity: localVerdict
|
|
931
|
+
};
|
|
932
|
+
if (typeof resolution !== "string") throw new UnknownTagError(envelope.key, this.#unknownUserTags(tags), resolution.armedBy);
|
|
933
|
+
if (resolution !== "registry-check") return {
|
|
934
|
+
checkedThrough: 0,
|
|
935
|
+
validity: "unknown"
|
|
936
|
+
};
|
|
937
|
+
let checked;
|
|
938
|
+
try {
|
|
939
|
+
checked = await this.#registry.check(tags);
|
|
940
|
+
} catch {
|
|
941
|
+
return {
|
|
942
|
+
checkedThrough: 0,
|
|
943
|
+
validity: "unknown"
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
this.#recordChecked(tags, checked);
|
|
947
|
+
return {
|
|
948
|
+
checkedThrough: throughEpochOf(checked),
|
|
949
|
+
validity: classify(bornEpoch, checked)
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* The strong-read live check: ONE live, un-memoized registry read over
|
|
954
|
+
* `tags`, memoized into local knowledge (`#recordChecked`), classified against
|
|
955
|
+
* `bornEpoch` via the table. The single engine of both the strong READ arm of
|
|
956
|
+
* `validate` and the pre-fetch strong-miss check (`liveCheckForFill`) —
|
|
957
|
+
* local knowledge is never consulted here; the primary read is the authority.
|
|
958
|
+
*/
|
|
959
|
+
async #strongLiveCheck(tags, bornEpoch) {
|
|
960
|
+
const knowledge = await this.#registry.check(tags);
|
|
961
|
+
this.#recordChecked(tags, knowledge);
|
|
962
|
+
return {
|
|
963
|
+
checkedThrough: throughEpochOf(knowledge),
|
|
964
|
+
validity: classify(bornEpoch, knowledge)
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Pre-fetch strong-miss live check: the SAME live un-memoized
|
|
969
|
+
* registry read `#strongLiveCheck` runs, executed BEFORE the factory on a strong fill so
|
|
970
|
+
* the fill's bornEpoch is minted from now-current knowledge — an understatement, never an
|
|
971
|
+
* overstatement, of recency (the safe direction). Records the reading into `#known` (
|
|
972
|
+
* memoization), so a later eventual read of the tag fails closed with no second round-trip.
|
|
973
|
+
* Returns the verified high-water; the caller raises the fill's bornEpoch to it.
|
|
974
|
+
*/
|
|
975
|
+
async liveCheckForFill(tags, onUnavailable) {
|
|
976
|
+
try {
|
|
977
|
+
const { checkedThrough } = await this.#strongLiveCheck(tags, 0);
|
|
978
|
+
return checkedThrough;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (onUnavailable !== "eventual") throw new RegistryUnavailableError(`strong fill could not reach the registry to mint its bornEpoch — ${String(error)}`);
|
|
981
|
+
this.#emit?.({
|
|
982
|
+
reason: STRONG_DEGRADED_REGISTRY_UNREACHABLE,
|
|
983
|
+
type: TELEMETRY_EVENTS.strong_degraded
|
|
984
|
+
});
|
|
985
|
+
return 0;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
/** Merge a verified live-check result into local knowledge. Max-on-write, so
|
|
989
|
+
* a concurrently-applied newer bus frame is never regressed. The check's
|
|
990
|
+
* `throughEpoch` advances the verified high-water (bornEpoch source) so a subsequent
|
|
991
|
+
* refill postdates what this check observed. */
|
|
992
|
+
#recordChecked(tags, knowledge) {
|
|
993
|
+
for (const [i, tag] of tags.entries()) {
|
|
994
|
+
const k = knowledge[i];
|
|
995
|
+
if (k === void 0 || !k.known) continue;
|
|
996
|
+
const w = this.#known.get(tag) ?? {
|
|
997
|
+
hardEpoch: 0,
|
|
998
|
+
softEpoch: 0
|
|
999
|
+
};
|
|
1000
|
+
w.softEpoch = Math.max(w.softEpoch, k.softEpoch);
|
|
1001
|
+
w.hardEpoch = Math.max(w.hardEpoch, k.hardEpoch);
|
|
1002
|
+
this.#known.set(tag, w);
|
|
1003
|
+
this.#knownThroughEpoch = Math.max(this.#knownThroughEpoch, k.throughEpoch);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* The write-back fence, evaluated against LOCAL bus-applied knowledge: any tag
|
|
1008
|
+
* `hardEpoch > bornEpoch` in the isolate's contiguously-applied state ⇒ the candidate
|
|
1009
|
+
* is fenced (a hard purge landed DURING the fill and was delivered here). Returns
|
|
1010
|
+
* `true` when the write-back may proceed. A soft purge lands already-stale but still
|
|
1011
|
+
* stores; only DEAD fences.
|
|
1012
|
+
*
|
|
1013
|
+
* DESIGN (per the INV-12 erratum): the write-back guard is realized as a LOCAL
|
|
1014
|
+
* fence here plus read-time fail-closed resolution — not a live registry read on the
|
|
1015
|
+
* write path. The local fence catches a purge that landed AND delivered locally DURING the
|
|
1016
|
+
* fill (tag KNOWN-dead locally ⇒ INV-9/12); it lets a cold/dropped-frame isolate STORE a value
|
|
1017
|
+
* whose tag is locally unknown (origin is truth after a purge — the legitimate
|
|
1018
|
+
* refill case). A born-dead value that slips past this fence is stored but INERT: its own
|
|
1019
|
+
* SUBSEQUENT eventual reads fail closed on the unknown tag and live-check — that read-time
|
|
1020
|
+
* check is where the GOAL (never serve a value born against a durably-accepted purge) is
|
|
1021
|
+
* enforced for THAT path. A strong fill's OWN serve is anchored earlier, at INITIATION: the
|
|
1022
|
+
* pre-factory check minted its `bornEpoch` at the global epoch, so it already dominates
|
|
1023
|
+
* every purge accepted before initiation — no read-time check re-verifies the fill
|
|
1024
|
+
* that produced it.
|
|
1025
|
+
*/
|
|
1026
|
+
fenceWriteBack(tags, bornEpoch) {
|
|
1027
|
+
return classify(bornEpoch, this.#localKnowledge(tags)) !== "dead";
|
|
1028
|
+
}
|
|
1029
|
+
/** Local tri-state knowledge for these tags (absent ⇒ unknown). */
|
|
1030
|
+
/**
|
|
1031
|
+
* The USER tags this read could not establish — what `UnknownTagError` carries.
|
|
1032
|
+
*
|
|
1033
|
+
* Under suspicion EVERY tag is unknown (the gate treats a map with a hole as knowing
|
|
1034
|
+
* nothing), so the whole user set is the honest answer there. Otherwise it is exactly the tags the
|
|
1035
|
+
* map has no watermark for. Reserved tags are dropped either way: `__k:`/`__ns:` are the kernel's
|
|
1036
|
+
* own bookkeeping, they are unknown for the same reason the user's tags are, and a caller cannot
|
|
1037
|
+
* do anything with them — the same reason they never reach an L3 header.
|
|
1038
|
+
*/
|
|
1039
|
+
#unknownUserTags(tags) {
|
|
1040
|
+
const knowledge = this.#localKnowledge(tags);
|
|
1041
|
+
return tags.filter((tag, index) => !isReservedTag(tag) && (this.#suspect || knowledge[index]?.known !== true));
|
|
1042
|
+
}
|
|
1043
|
+
#localKnowledge(tags) {
|
|
1044
|
+
return tags.map((tag) => {
|
|
1045
|
+
const w = this.#known.get(tag);
|
|
1046
|
+
if (!w) return { known: false };
|
|
1047
|
+
return {
|
|
1048
|
+
hardEpoch: w.hardEpoch,
|
|
1049
|
+
known: true,
|
|
1050
|
+
softEpoch: w.softEpoch,
|
|
1051
|
+
throughEpoch: this.#appliedEpoch
|
|
1052
|
+
};
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/drivers.ts
|
|
1058
|
+
/**
|
|
1059
|
+
* Structural type-guard for `StoreWriteError`. Purely shape-based — it NEVER
|
|
1060
|
+
* uses `instanceof`, so a harness fake and a real driver are recognized by the SAME
|
|
1061
|
+
* check without a shared class. Total and safe on arbitrary `unknown`: rejects
|
|
1062
|
+
* non-objects/null, requires `code` to be exactly one of the three literals and
|
|
1063
|
+
* `retryable` to be a boolean.
|
|
1064
|
+
*/
|
|
1065
|
+
function isStoreWriteError(e) {
|
|
1066
|
+
if (typeof e !== "object" || e === null) return false;
|
|
1067
|
+
const code = "code" in e ? e.code : void 0;
|
|
1068
|
+
const retryable = "retryable" in e ? e.retryable : void 0;
|
|
1069
|
+
return (code === "throttled" || code === "too_large" || code === "unavailable") && typeof retryable === "boolean";
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Structural type-guard for `StoreReadError` — shape-based, never `instanceof`,
|
|
1073
|
+
* exactly as `isStoreWriteError` is. Total and safe on arbitrary `unknown`. A driver error
|
|
1074
|
+
* that carries a read-suppressible `code` satisfies this whatever class raised it, which is
|
|
1075
|
+
* the point: the own `unavailable` shape is recognized without a shared identity.
|
|
1076
|
+
*/
|
|
1077
|
+
function isStoreReadError(e) {
|
|
1078
|
+
if (typeof e !== "object" || e === null) return false;
|
|
1079
|
+
const code = "code" in e ? e.code : void 0;
|
|
1080
|
+
const retryable = "retryable" in e ? e.retryable : void 0;
|
|
1081
|
+
return (code === "throttled" || code === "unavailable") && typeof retryable === "boolean";
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Rejection raised by `memory().set` when a single value's UTF-8 byte length exceeds the
|
|
1085
|
+
* store's total `maxBytes` budget — a value larger than the whole budget can never fit, so
|
|
1086
|
+
* it is REJECTED, never truncated. Satisfies the structural `StoreWriteError`
|
|
1087
|
+
* contract (`too_large`, non-retryable) WITHOUT sharing a class with the kernel: consumers
|
|
1088
|
+
* discriminate via `isStoreWriteError` (structural), never `instanceof`. The kernel-side
|
|
1089
|
+
* twin of the harness `OversizeValueError`; internal, so it needs no export.
|
|
1090
|
+
*/
|
|
1091
|
+
var MemoryOversizeError = class extends Error {
|
|
1092
|
+
code = "too_large";
|
|
1093
|
+
retryable = false;
|
|
1094
|
+
constructor(key, sizeBytes, maxBytes) {
|
|
1095
|
+
super(`value too large: ${key} (${sizeBytes} > ${maxBytes} bytes)`);
|
|
1096
|
+
this.name = "MemoryOversizeError";
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
/**
|
|
1100
|
+
* Rejection raised by `memory().set` when a write carries `expirationTtl` but the store was built
|
|
1101
|
+
* with NO injected clock — the residency cannot be honored without a `now` source, so the write
|
|
1102
|
+
* FAILS LOUD rather than silently drop the TTL. Unlike `MemoryOversizeError` this is a
|
|
1103
|
+
* MISCONFIGURATION, not a `StoreWriteError`: it must NOT satisfy the structural write-failure
|
|
1104
|
+
* contract the kernel absorbs — a residency the store can never honor is a programmer error,
|
|
1105
|
+
* surfaced typed so a caller can `instanceof`-catch it, never quietly ignored. Internal, mirroring
|
|
1106
|
+
* `MemoryOversizeError` (neither is re-exported from `index.ts`).
|
|
1107
|
+
*/
|
|
1108
|
+
var MemoryConfigError = class extends Error {
|
|
1109
|
+
constructor(message) {
|
|
1110
|
+
super(message);
|
|
1111
|
+
this.name = "MemoryConfigError";
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
/** is this a PRIVATE (`usr:`-scoped) L1 entry? Read off the `scope`
|
|
1115
|
+
* metadata LITERAL the kernel writes with every L1 set (`pub` / `usr:<h>` / `ten:<h>`) — the
|
|
1116
|
+
* `usr:` PREFIX only, never the hash. A `usr:` entry is per-principal, so its eviction is disclosed;
|
|
1117
|
+
* detection touches nothing identifying, and a non-canonical store (no `scope` metadata) never
|
|
1118
|
+
* matches. */
|
|
1119
|
+
function isPrivateEntry(entry) {
|
|
1120
|
+
const scope = entry.metadata?.scope;
|
|
1121
|
+
return typeof scope === "string" && scope.startsWith("usr:");
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* In-process L1 store — isolate-local, bounded by `maxEntries` / `maxBytes` with LRU
|
|
1125
|
+
* eviction on either bound (an insertion-ordered `Map`, re-inserted on touch, so recency —
|
|
1126
|
+
* not mere insertion order — picks the victim). Each `memory()` owns a PER-INSTANCE byte
|
|
1127
|
+
* accounter (Σ value UTF-8 byte lengths).
|
|
1128
|
+
*
|
|
1129
|
+
* an optional `telemetry` sink receives a `private_evicted` event when a
|
|
1130
|
+
* PRIVATE (`usr:`-scoped) entry is evicted from the LRU under memory pressure — reason class +
|
|
1131
|
+
* counts + bytes ONLY, so an operator sees L1 pressure on private data without the event ever
|
|
1132
|
+
* naming WHAT was evicted (payload discipline). The `usr:`-priority-protected LRU (evict
|
|
1133
|
+
* private entries LAST) is a spec MAY and stays UNBUILT: this discloses whatever the current LRU
|
|
1134
|
+
* policy evicts, it does not change the victim. The cross-instance shared/joint reserve (warn
|
|
1135
|
+
* on Σ`maxBytes` overflow across isolate L1s) is a separate advisory extra, still DEFERRED.
|
|
1136
|
+
*/
|
|
1137
|
+
function memory(options) {
|
|
1138
|
+
const clock = options?.clock;
|
|
1139
|
+
const maxEntries = options?.maxEntries;
|
|
1140
|
+
const maxBytes = options?.maxBytes;
|
|
1141
|
+
const telemetry = options?.telemetry;
|
|
1142
|
+
const onSinkError = options?.onSinkError;
|
|
1143
|
+
const encoder = new TextEncoder();
|
|
1144
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1145
|
+
let totalBytes = 0;
|
|
1146
|
+
const drop = (key, entry) => {
|
|
1147
|
+
entries.delete(key);
|
|
1148
|
+
totalBytes -= entry.bytes;
|
|
1149
|
+
};
|
|
1150
|
+
const overBounds = () => maxEntries !== void 0 && entries.size > maxEntries || maxBytes !== void 0 && totalBytes > maxBytes;
|
|
1151
|
+
return {
|
|
1152
|
+
delete: async (key) => {
|
|
1153
|
+
const entry = entries.get(key);
|
|
1154
|
+
if (entry !== void 0) drop(key, entry);
|
|
1155
|
+
},
|
|
1156
|
+
get: async (key) => {
|
|
1157
|
+
const entry = entries.get(key);
|
|
1158
|
+
const expired = entry !== void 0 && clock !== void 0 && entry.expireAtMs !== void 0 && clock.now() >= entry.expireAtMs;
|
|
1159
|
+
if (entry === void 0 || expired) {
|
|
1160
|
+
if (entry !== void 0) drop(key, entry);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
entries.delete(key);
|
|
1164
|
+
entries.set(key, entry);
|
|
1165
|
+
return entry.metadata === void 0 ? { value: entry.value } : {
|
|
1166
|
+
metadata: entry.metadata,
|
|
1167
|
+
value: entry.value
|
|
1168
|
+
};
|
|
1169
|
+
},
|
|
1170
|
+
set: async (key, value, writeOptions) => {
|
|
1171
|
+
const bytes = encoder.encode(value).length;
|
|
1172
|
+
if (maxBytes !== void 0 && bytes > maxBytes) throw new MemoryOversizeError(key, bytes, maxBytes);
|
|
1173
|
+
let expireAtMs;
|
|
1174
|
+
if (writeOptions?.expirationTtl !== void 0) {
|
|
1175
|
+
if (clock === void 0) throw new MemoryConfigError("memory(): expirationTtl requires an injected clock — construct memory({ clock, … }) to honor residency (§8)");
|
|
1176
|
+
expireAtMs = clock.now() + writeOptions.expirationTtl * 1e3;
|
|
1177
|
+
}
|
|
1178
|
+
const existing = entries.get(key);
|
|
1179
|
+
if (existing !== void 0) drop(key, existing);
|
|
1180
|
+
entries.set(key, {
|
|
1181
|
+
bytes,
|
|
1182
|
+
value,
|
|
1183
|
+
...writeOptions?.metadata === void 0 ? {} : { metadata: writeOptions.metadata },
|
|
1184
|
+
...expireAtMs === void 0 ? {} : { expireAtMs }
|
|
1185
|
+
});
|
|
1186
|
+
totalBytes += bytes;
|
|
1187
|
+
while (overBounds()) {
|
|
1188
|
+
const lruKey = entries.keys().next().value;
|
|
1189
|
+
if (lruKey === void 0) break;
|
|
1190
|
+
const lru = entries.get(lruKey);
|
|
1191
|
+
if (lru !== void 0) {
|
|
1192
|
+
drop(lruKey, lru);
|
|
1193
|
+
if (telemetry !== void 0 && isPrivateEntry(lru)) safeEmit(telemetry, {
|
|
1194
|
+
bytes: lru.bytes,
|
|
1195
|
+
count: 1,
|
|
1196
|
+
type: TELEMETRY_EVENTS.private_evicted
|
|
1197
|
+
}, onSinkError);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
//#endregion
|
|
1204
|
+
//#region src/duration.ts
|
|
1205
|
+
/**
|
|
1206
|
+
* Milliseconds per unit — the ONE `Duration`→ms grammar source. Shared with
|
|
1207
|
+
* `cache.ts`'s coarse `durationMs` parser (which imports this const) so the overflow
|
|
1208
|
+
* check here and the retention/heartbeat parse there agree on the factor table byte-for-byte.
|
|
1209
|
+
* `m` = minutes.
|
|
1210
|
+
*/
|
|
1211
|
+
const DURATION_UNIT_MS = {
|
|
1212
|
+
d: 864e5,
|
|
1213
|
+
h: 36e5,
|
|
1214
|
+
m: 6e4,
|
|
1215
|
+
ms: 1,
|
|
1216
|
+
s: 1e3
|
|
1217
|
+
};
|
|
1218
|
+
/**
|
|
1219
|
+
* Build a `Duration` from computed values — the trust point for
|
|
1220
|
+
* programmatically-derived durations (as opposed to a `'5m'` string literal the type
|
|
1221
|
+
* system already constrains). Rejects a non-positive or non-finite `value`, and — and,
|
|
1222
|
+
* the case a naive impl passes — a `value × unitFactor` product that is not a finite,
|
|
1223
|
+
* positive, SAFE integer once multiplied out. So `duration(Number.MAX_SAFE_INTEGER, 'd')`
|
|
1224
|
+
* is REJECTED even though the input is a safe integer: the overflow lives in the product.
|
|
1225
|
+
* Throws `InvalidDurationError` on any violation; never coerces.
|
|
1226
|
+
*/
|
|
1227
|
+
function duration(value, unit) {
|
|
1228
|
+
if (!Number.isFinite(value) || value <= 0) throw new InvalidDurationError(value, unit, "value must be a finite number greater than 0 (§2.1)");
|
|
1229
|
+
const ms = value * DURATION_UNIT_MS[unit];
|
|
1230
|
+
if (!Number.isSafeInteger(ms) || ms <= 0) throw new InvalidDurationError(value, unit, "computed milliseconds must be a finite, positive, safe integer (F31)");
|
|
1231
|
+
return `${value}${unit}`;
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/http.ts
|
|
1235
|
+
/** The default retriable HTTP status set (Cloudflare 52x joins it). */
|
|
1236
|
+
const RETRIABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
1237
|
+
408,
|
|
1238
|
+
425,
|
|
1239
|
+
429,
|
|
1240
|
+
500,
|
|
1241
|
+
502,
|
|
1242
|
+
503,
|
|
1243
|
+
504,
|
|
1244
|
+
520,
|
|
1245
|
+
521,
|
|
1246
|
+
522,
|
|
1247
|
+
523,
|
|
1248
|
+
524,
|
|
1249
|
+
525,
|
|
1250
|
+
526,
|
|
1251
|
+
527,
|
|
1252
|
+
530
|
|
1253
|
+
]);
|
|
1254
|
+
/** Wrap a non-ok `Response` into a typed `HttpError`. */
|
|
1255
|
+
function httpError(response) {
|
|
1256
|
+
return new HttpError(response);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* The default `staleIfError` predicate — retriable: the default
|
|
1260
|
+
* retriable set, network errors, and cache-originated timeouts. `404/410/403` are
|
|
1261
|
+
* facts, never masked (never retriable) — a caller-abort `CacheTimeoutError` with
|
|
1262
|
+
* `source:'caller'` is likewise not evidence of origin health.
|
|
1263
|
+
*/
|
|
1264
|
+
function isRetriableHttp(err) {
|
|
1265
|
+
if (err instanceof HttpError) return RETRIABLE_STATUSES.has(err.status);
|
|
1266
|
+
if (err instanceof CacheTimeoutError) return err.source === "cache";
|
|
1267
|
+
return err instanceof TypeError;
|
|
1268
|
+
}
|
|
1269
|
+
//#endregion
|
|
1270
|
+
//#region src/replication-poller.ts
|
|
1271
|
+
const DEFAULT_POLL_BASE_MS = 6e4;
|
|
1272
|
+
const DEFAULT_FIRST_LADDER_DELAY_MS = 1e3;
|
|
1273
|
+
const DEFAULT_LATER_LADDER_DELAY_MS = 5e3;
|
|
1274
|
+
const DEFAULT_JITTER_FRACTION = .2;
|
|
1275
|
+
/** Poll-failure backoff ceiling (knob): a warm baseline poll that does NOT converge doubles
|
|
1276
|
+
* the cadence (×2) up to this cap — 300s — and the doubled interval is jittered like any other, so a
|
|
1277
|
+
* fleet whose L2 is failing degrades to one attempt per ~5 minutes per isolate instead of hammering
|
|
1278
|
+
* a sick store every 60s. The cap is on the BASE interval; the ±jitter rides on top (a jittered
|
|
1279
|
+
* 300s is 240–360s), because de-synchronizing the fleet matters most exactly when the store is sick. */
|
|
1280
|
+
const MAX_POLL_BACKOFF_MS = 3e5;
|
|
1281
|
+
/** Backoff multiplier per consecutive failed baseline poll (×2). */
|
|
1282
|
+
const POLL_BACKOFF_FACTOR = 2;
|
|
1283
|
+
/** The rung at which the delta budget is spent and the pass may escalate to the snapshot bridge. */
|
|
1284
|
+
const ESCALATE_AT_ATTEMPT = 3;
|
|
1285
|
+
/** The rung at which the snapshot budget is ALSO spent — the reader makes the permanent ruling
|
|
1286
|
+
* (full replay if the chain is intact ⇒ converged, else terminal-gap). Below it, a lagging snap
|
|
1287
|
+
* gets one brief retry. */
|
|
1288
|
+
const SNAPSHOT_FINAL_ATTEMPT = 4;
|
|
1289
|
+
/** Parse the `__state` pointer bytes the reader resyncs off into the wire shape the
|
|
1290
|
+
* monitor consumes. Malformed / absent ⇒ `undefined` (the observation is simply skipped — a shell
|
|
1291
|
+
* read that cannot see the pointer never fabricates liveness). Numbers-only fields; missing
|
|
1292
|
+
* optionals default the same way the kernel reader does. */
|
|
1293
|
+
function readStatePointer(raw) {
|
|
1294
|
+
let parsed;
|
|
1295
|
+
try {
|
|
1296
|
+
parsed = JSON.parse(raw);
|
|
1297
|
+
} catch {
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
1301
|
+
const o = parsed;
|
|
1302
|
+
if (typeof o.epoch !== "number" || typeof o.lastBatch !== "number" || typeof o.regId !== "string") return;
|
|
1303
|
+
return {
|
|
1304
|
+
epoch: o.epoch,
|
|
1305
|
+
flushedAt: typeof o.flushedAt === "number" ? o.flushedAt : 0,
|
|
1306
|
+
lastBatch: o.lastBatch,
|
|
1307
|
+
regId: o.regId,
|
|
1308
|
+
snapBatch: typeof o.snapBatch === "number" ? o.snapBatch : 0
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* The tick-driven replication poll driver. Each `onPollTick(nowMs)`:
|
|
1313
|
+
* 1. reads the `__state` pointer and feeds the derivative liveness monitor
|
|
1314
|
+
* (`notePointerObservation`) — raise-only, every tick, sampling-rate-independent;
|
|
1315
|
+
* 2. while the reader is SUSPECT, runs the retry ladder when an attempt is DUE:
|
|
1316
|
+
* attempt 1 → `resync()` (delta); +~1s → attempt 2 → `resync()` (delta); +~5s →
|
|
1317
|
+
* attempt 3 → `resync({deltasExhausted})` and, on `needs-snapshot`, `resyncViaSnapshot()`
|
|
1318
|
+
* (snapshot-permitted). `converged` clears the reader (and backs the ladder off to the
|
|
1319
|
+
* baseline cadence when suspicion persists awaiting a live-channel head — never a spin);
|
|
1320
|
+
* `terminal-gap` STOPS the ladder permanently (fail-closed, no spin);
|
|
1321
|
+
* 3. while WARM, runs the baseline liveness poll on the maxSyncLag cadence (60s ±20%), gated by
|
|
1322
|
+
* the kernel's pure `pollDue`, and BACKS OFF on failure: a non-converged poll doubles the
|
|
1323
|
+
* interval (×2, cap 300s, still jittered); a converged one resets it to the base.
|
|
1324
|
+
*
|
|
1325
|
+
* Pure + tick-driven: no wall clock, no ambient timers, no sleeps — `nowMs` and the injected rng
|
|
1326
|
+
* are the only sources of time/randomness.
|
|
1327
|
+
*
|
|
1328
|
+
* RE-ENTRANCY-SAFE BY CONTRACT. `onPollTick` is NOT required to be awaited before the
|
|
1329
|
+
* next one is fired, and under the request-piggyback driver it routinely is not — a burst of
|
|
1330
|
+
* requests hands one isolate several ticks while a step's store I/O is still in flight. Two
|
|
1331
|
+
* properties make that safe HERE, at the root, rather than in each driver: (i) every due-decision
|
|
1332
|
+
* and the state writes it authorizes happen in ONE synchronous block — no `await` separates a
|
|
1333
|
+
* due-check from its commit, so no tick ever acts on a schedule another tick has already consumed;
|
|
1334
|
+
* (ii) at most ONE ladder step is in flight, and a tick arriving mid-step declines rather than
|
|
1335
|
+
* queues. The `__state` observation is exempt from both — the +25 vigil runs on EVERY tick.
|
|
1336
|
+
*/
|
|
1337
|
+
var ReplicationPoller = class {
|
|
1338
|
+
#reader;
|
|
1339
|
+
#store;
|
|
1340
|
+
#keys;
|
|
1341
|
+
#rng;
|
|
1342
|
+
#pollBaseMs;
|
|
1343
|
+
#firstLadderDelayMs;
|
|
1344
|
+
#laterLadderDelayMs;
|
|
1345
|
+
#jitterFraction;
|
|
1346
|
+
/** Active hole-chasing ladder, or `null` when warm (no hole to chase). */
|
|
1347
|
+
#ladder = null;
|
|
1348
|
+
/** A terminal-gap verdict indexed to the pointer that produced it: the LADDER stays stopped
|
|
1349
|
+
* (fail-closed, no spin) while this chain cannot complete. Permanence attaches to the CHAIN, not
|
|
1350
|
+
* the vigil — the baseline observation never stops (below). Cleared on a fresh suspect episode OR
|
|
1351
|
+
* when a durable `snapBatch` advance past `#terminalSnapBatch` bridges the hole. */
|
|
1352
|
+
#terminal = false;
|
|
1353
|
+
/** The pointer `snapBatch` the terminal verdict was made against — expired deltas never return, so
|
|
1354
|
+
* ONLY an observed `snapBatch` beyond this can re-arm one snapshot-permitted attempt. `null` when
|
|
1355
|
+
* not terminal. */
|
|
1356
|
+
#terminalSnapBatch = null;
|
|
1357
|
+
/** Suspect state at the previous tick — the false→true EDGE opens a fresh episode. */
|
|
1358
|
+
#prevSuspect = false;
|
|
1359
|
+
/** SINGLE-FLIGHT: a ladder step is between its due-decision and its awaits. Set in the
|
|
1360
|
+
* SAME synchronous block as the due-decision that authorized it and cleared in that block's
|
|
1361
|
+
* `finally`, so at most one step ever runs. Ticks are request-piggybacked (an isolate gets no
|
|
1362
|
+
* free-running timer), which makes RE-ENTRY ROUTINE rather than exotic — a burst of requests can
|
|
1363
|
+
* hand this poller several ticks while one step's store I/O is still in flight. A tick that finds
|
|
1364
|
+
* a step running DECLINES; it never queues, because queueing merely defers the burst it exists to
|
|
1365
|
+
* prevent. Nothing is lost: the rung it would have run is already scheduled (below). */
|
|
1366
|
+
#stepInFlight = false;
|
|
1367
|
+
/** Last baseline poll time (`null` until the first warm tick seeds the cursor). */
|
|
1368
|
+
#lastPollMs = null;
|
|
1369
|
+
/** Jittered baseline threshold, recomputed each poll (the current interval ±20%). */
|
|
1370
|
+
#pollThresholdMs;
|
|
1371
|
+
/** The CURRENT baseline interval the threshold is drawn from: `#pollBaseMs` while polls converge,
|
|
1372
|
+
* doubled per consecutive failure up to `MAX_POLL_BACKOFF_MS`. Reset to the base by the
|
|
1373
|
+
* first poll that converges again — the backoff is a property of the current failure episode, never
|
|
1374
|
+
* a permanent cadence change. */
|
|
1375
|
+
#pollIntervalMs;
|
|
1376
|
+
constructor(reader, store, keys, rng, options = {}) {
|
|
1377
|
+
this.#reader = reader;
|
|
1378
|
+
this.#store = store;
|
|
1379
|
+
this.#keys = keys;
|
|
1380
|
+
this.#rng = rng;
|
|
1381
|
+
this.#pollBaseMs = options.pollBaseMs ?? DEFAULT_POLL_BASE_MS;
|
|
1382
|
+
this.#firstLadderDelayMs = options.firstLadderDelayMs ?? DEFAULT_FIRST_LADDER_DELAY_MS;
|
|
1383
|
+
this.#laterLadderDelayMs = options.laterLadderDelayMs ?? DEFAULT_LATER_LADDER_DELAY_MS;
|
|
1384
|
+
this.#jitterFraction = options.jitterFraction ?? DEFAULT_JITTER_FRACTION;
|
|
1385
|
+
this.#pollIntervalMs = this.#pollBaseMs;
|
|
1386
|
+
this.#pollThresholdMs = this.#jitter(this.#pollIntervalMs);
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Whether the reader's replication has reached a permanent terminal gap — the
|
|
1390
|
+
* hole-chasing ladder is stopped, fail-closed, until a durable `snapBatch` advance re-arms it.
|
|
1391
|
+
* Read-only exposition of the already-tracked `#terminal` verdict for `explain`; it
|
|
1392
|
+
* adds no bookkeeping — the field is written only by the tick machinery, never by this getter.
|
|
1393
|
+
*/
|
|
1394
|
+
get terminal() {
|
|
1395
|
+
return this.#terminal;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Advance the state machine one tick. Always observes the pointer for liveness; then
|
|
1399
|
+
* drives the ladder (suspect) or the baseline poll (warm). Out-of-band — never on a read path.
|
|
1400
|
+
*/
|
|
1401
|
+
async onPollTick(nowMs) {
|
|
1402
|
+
const raw = await this.#store.get(this.#keys.stateKey);
|
|
1403
|
+
const pointer = raw === void 0 ? void 0 : readStatePointer(raw.value);
|
|
1404
|
+
if (pointer !== void 0) this.#reader.notePointerObservation(pointer, nowMs);
|
|
1405
|
+
if (this.#stepInFlight) return;
|
|
1406
|
+
const { suspect } = this.#reader;
|
|
1407
|
+
if (suspect && !this.#prevSuspect) {
|
|
1408
|
+
this.#terminal = false;
|
|
1409
|
+
this.#terminalSnapBatch = null;
|
|
1410
|
+
this.#ladder = null;
|
|
1411
|
+
}
|
|
1412
|
+
this.#prevSuspect = suspect;
|
|
1413
|
+
if (this.#terminal) {
|
|
1414
|
+
if (pointer !== void 0 && this.#terminalSnapBatch !== null && pointer.snapBatch > this.#terminalSnapBatch) {
|
|
1415
|
+
this.#terminal = false;
|
|
1416
|
+
this.#terminalSnapBatch = null;
|
|
1417
|
+
if (await this.#reader.resyncViaSnapshot({ snapshotExhausted: true }) === "terminal-gap") {
|
|
1418
|
+
this.#terminal = true;
|
|
1419
|
+
this.#terminalSnapBatch = pointer.snapBatch;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (suspect) {
|
|
1425
|
+
const ladder = this.#ladder ?? {
|
|
1426
|
+
attempt: 0,
|
|
1427
|
+
nextDueMs: nowMs
|
|
1428
|
+
};
|
|
1429
|
+
this.#ladder = ladder;
|
|
1430
|
+
if (nowMs < ladder.nextDueMs) return;
|
|
1431
|
+
ladder.attempt += 1;
|
|
1432
|
+
ladder.nextDueMs = nowMs + this.#jitter(this.#ladderDelay(ladder.attempt));
|
|
1433
|
+
this.#stepInFlight = true;
|
|
1434
|
+
try {
|
|
1435
|
+
await this.#runLadderStep(nowMs, ladder, pointer?.snapBatch ?? 0);
|
|
1436
|
+
} finally {
|
|
1437
|
+
this.#stepInFlight = false;
|
|
1438
|
+
}
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
this.#ladder = null;
|
|
1442
|
+
if (this.#lastPollMs === null) {
|
|
1443
|
+
this.#lastPollMs = nowMs;
|
|
1444
|
+
this.#pollThresholdMs = this.#jitter(this.#pollIntervalMs);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (this.#reader.pollDue(nowMs, this.#lastPollMs, this.#pollThresholdMs)) {
|
|
1448
|
+
this.#lastPollMs = nowMs;
|
|
1449
|
+
const status = await this.#reader.resync();
|
|
1450
|
+
this.#pollIntervalMs = status === "converged" ? this.#pollBaseMs : this.#backedOff(this.#pollIntervalMs);
|
|
1451
|
+
this.#pollThresholdMs = this.#jitter(this.#pollIntervalMs);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/** One backoff step: ×2, capped at 300s (never below the configured base — a base ABOVE the cap is
|
|
1455
|
+
* already slower than any backoff could make it, so the cap floors to it rather than SPEEDING the
|
|
1456
|
+
* poll up on failure). */
|
|
1457
|
+
#backedOff(intervalMs) {
|
|
1458
|
+
const cap = Math.max(MAX_POLL_BACKOFF_MS, this.#pollBaseMs);
|
|
1459
|
+
return Math.min(intervalMs * POLL_BACKOFF_FACTOR, cap);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Run one ladder rung. Rungs 1–2 retry the delta pass; rung 3 escalates
|
|
1463
|
+
* (`deltasExhausted`) and bridges via the snapshot on `needs-snapshot`; rung ≥4 spends the
|
|
1464
|
+
* snapshot budget so the reader makes the permanent ruling. Never sleeps.
|
|
1465
|
+
*
|
|
1466
|
+
* the caller has ALREADY claimed the rung (`ladder.attempt`) and committed its provisional
|
|
1467
|
+
* next-due, synchronously, before this is entered — so this method only ever OVERWRITES that
|
|
1468
|
+
* schedule when the outcome calls for a different one. It re-schedules NOTHING on the retry path
|
|
1469
|
+
* (the provisional IS the retry), and it writes nothing that a re-entrant tick could race, because
|
|
1470
|
+
* the in-flight flag the caller set means no other tick is deciding anything while this runs.
|
|
1471
|
+
*/
|
|
1472
|
+
async #runLadderStep(nowMs, ladder, observedSnapBatch) {
|
|
1473
|
+
const { attempt } = ladder;
|
|
1474
|
+
let status = await this.#reader.resync({ deltasExhausted: attempt >= ESCALATE_AT_ATTEMPT });
|
|
1475
|
+
if (status === "needs-snapshot") status = await this.#reader.resyncViaSnapshot({ snapshotExhausted: attempt >= SNAPSHOT_FINAL_ATTEMPT });
|
|
1476
|
+
if (status === "converged") {
|
|
1477
|
+
ladder.attempt = 0;
|
|
1478
|
+
ladder.nextDueMs = nowMs + this.#jitter(this.#pollBaseMs);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (status === "terminal-gap") {
|
|
1482
|
+
this.#terminal = true;
|
|
1483
|
+
this.#terminalSnapBatch = observedSnapBatch;
|
|
1484
|
+
this.#ladder = null;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/** The base ladder delay for the rung just run: attempt 1 → ~1s, attempts ≥2 → ~5s. */
|
|
1488
|
+
#ladderDelay(attempt) {
|
|
1489
|
+
return attempt <= 1 ? this.#firstLadderDelayMs : this.#laterLadderDelayMs;
|
|
1490
|
+
}
|
|
1491
|
+
/** Jitter a base delay symmetrically by ±`jitterFraction`, drawn from the injected rng (no
|
|
1492
|
+
* ambient randomness). rng.next() ∈ [0,1) ⇒ factor ∈ [1−f, 1+f). */
|
|
1493
|
+
#jitter(baseMs) {
|
|
1494
|
+
const f = this.#jitterFraction;
|
|
1495
|
+
return baseMs * (1 - f + 2 * f * this.#rng.next());
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
/** The library's default safe-mode codec. Deterministic JSON round-trip. */
|
|
1499
|
+
const safeCodec = {
|
|
1500
|
+
decode(value) {
|
|
1501
|
+
return JSON.parse(value);
|
|
1502
|
+
},
|
|
1503
|
+
encode(value) {
|
|
1504
|
+
return JSON.stringify(value);
|
|
1505
|
+
},
|
|
1506
|
+
id: "safe/1"
|
|
1507
|
+
};
|
|
1508
|
+
//#endregion
|
|
1509
|
+
//#region src/cache.ts
|
|
1510
|
+
/** The `<scope>` literal of the one shared, principal-free storage class. It is
|
|
1511
|
+
* the ONLY value that keeps a response shared-cacheable under. */
|
|
1512
|
+
const PUBLIC_SCOPE = "pub";
|
|
1513
|
+
/** The scope evidence a served HIT carries: its stored literal, or the fail-closed
|
|
1514
|
+
* `unreadable` class when the shared surface returned none. */
|
|
1515
|
+
function hitScopeEvidence(hit) {
|
|
1516
|
+
if (hit.scopeUnreadable || hit.rawScope === void 0) return { kind: "unreadable" };
|
|
1517
|
+
return {
|
|
1518
|
+
kind: "readable",
|
|
1519
|
+
literal: hit.rawScope
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
/** The scope evidence a FILL carries: the literal this kernel just resolved for it.
|
|
1523
|
+
*Never `unreadable` — a computed scope cannot fail to be read. */
|
|
1524
|
+
function fillScopeEvidence(literal) {
|
|
1525
|
+
return {
|
|
1526
|
+
kind: "readable",
|
|
1527
|
+
literal
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
/** Record one consumed dependency on the ambient collector, if there is one. */
|
|
1531
|
+
function recordDependency(ambient, dependency) {
|
|
1532
|
+
ambient?.()?.record(dependency);
|
|
1533
|
+
}
|
|
1534
|
+
/** Record a SERVED stored entry as a consumed dependency: the tags it was stored
|
|
1535
|
+
* with, plus the scope evidence read off the surface it came from — `unreadable` when that
|
|
1536
|
+
* surface returned no scope at all, which poisons the response rather than guessing. A
|
|
1537
|
+
* `undefined` hit is a serve that never happened, and records nothing. */
|
|
1538
|
+
function recordServedHit(ambient, hit) {
|
|
1539
|
+
if (hit === void 0) return;
|
|
1540
|
+
recordDependency(ambient, {
|
|
1541
|
+
scope: hitScopeEvidence(hit),
|
|
1542
|
+
tags: hit.envelope.tags
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
/** The verdict for a registry-absent instance: with no watermark authority every
|
|
1546
|
+
* stored entry reads FRESH, exactly as the codec-only read path classifies it — there is nothing
|
|
1547
|
+
* that could purge it, so nothing is `stale`/`dead`/`unknown`. */
|
|
1548
|
+
const FRESH_VERDICT = {
|
|
1549
|
+
hardEpoch: 0,
|
|
1550
|
+
softEpoch: 0,
|
|
1551
|
+
validity: "fresh"
|
|
1552
|
+
};
|
|
1553
|
+
/**
|
|
1554
|
+
* The request-scoped per-dependency attribution `explain` surfaces — the bound
|
|
1555
|
+
* render collector's recorded dependencies, projected to the public shape. `undefined` when no
|
|
1556
|
+
* collector is bound (outside an adapter request frame): the attribution lives and dies with the
|
|
1557
|
+
* request, so its ABSENCE is itself the honest report (a witness reports what it knows; unbound ⇒
|
|
1558
|
+
* there is nothing to attribute).
|
|
1559
|
+
*/
|
|
1560
|
+
function explainAttribution(ambient) {
|
|
1561
|
+
const state = ambient?.();
|
|
1562
|
+
if (state === void 0) return;
|
|
1563
|
+
return state.records.map((dependency) => ({
|
|
1564
|
+
scope: dependency.scope,
|
|
1565
|
+
tags: dependency.tags,
|
|
1566
|
+
...dependency.l3 === void 0 ? {} : { l3: dependency.l3 }
|
|
1567
|
+
}));
|
|
1568
|
+
}
|
|
1569
|
+
/** Per-instance namespace-version counter. `clear()` bumps it so
|
|
1570
|
+
* every canonical key minted AFTER the bump is unreachable from a pre-clear key —
|
|
1571
|
+
* a second, independent defense alongside the `__ns` hard watermark (INV-23). Held
|
|
1572
|
+
* as a small mutable cell (not a plain closure variable) so it can be threaded
|
|
1573
|
+
* through the free functions below exactly like `Invalidation`. */
|
|
1574
|
+
var NsVersion = class {
|
|
1575
|
+
#version = 0;
|
|
1576
|
+
get current() {
|
|
1577
|
+
return this.#version;
|
|
1578
|
+
}
|
|
1579
|
+
bump() {
|
|
1580
|
+
this.#version += 1;
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
/** Canonical key form `<ns-version>:<ns>:<scope>:<user-key>`. */
|
|
1584
|
+
function canonicalKey(nsVersion, namespace, scope, userKey) {
|
|
1585
|
+
return `${nsVersion}:${namespace}:${scope}:${userKey}`;
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* The adapter-visible identity fields a default-scope request can carry, in
|
|
1589
|
+
* precedence order ( principal-derived scoping; adapter-visible
|
|
1590
|
+
* identity / request-identity-header values). `userId` is the per-user
|
|
1591
|
+
* principal the RR/Workers adapter surfaces; a request that carries only a
|
|
1592
|
+
* `tenant` still bears adapter-visible identity, so it too derives a principal.
|
|
1593
|
+
* A request with none of these carries no derivable principal.
|
|
1594
|
+
*/
|
|
1595
|
+
const PRINCIPAL_FIELDS = ["userId", "tenant"];
|
|
1596
|
+
/**
|
|
1597
|
+
* Derive the adapter-visible principal from a request. Returns
|
|
1598
|
+
* the first present identity field's value keyed by its field name, or `undefined`
|
|
1599
|
+
* when the request carries no recognized identity (no context / null principal ⇒
|
|
1600
|
+
* shared `pub` class). Adapter-visible identity is a primitive
|
|
1601
|
+
* (a userId/tenant string, a header value) — a non-primitive field value is not a
|
|
1602
|
+
* clean principal and is skipped. A read here goes through the caller-supplied
|
|
1603
|
+
* request object directly (NOT the dev-mode guarded Proxy) — principal derivation
|
|
1604
|
+
* is the kernel's own inspection, not a public-factory closure, so it must never
|
|
1605
|
+
* itself trip the guarded-accessor demotion (INV-4 test 2).
|
|
1606
|
+
*/
|
|
1607
|
+
function derivePrincipal(request) {
|
|
1608
|
+
if (request === void 0) return;
|
|
1609
|
+
for (const field of PRINCIPAL_FIELDS) {
|
|
1610
|
+
const value = request[field];
|
|
1611
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return `${field}:${value.toString()}`;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Hash a derived principal into the `usr:<hash>` scope suffix. A
|
|
1616
|
+
* synchronous SHA-256 truncated to 128 bits (32 hex) — hashing is not randomness,
|
|
1617
|
+
*and this keeps the fill path synchronous. The result is
|
|
1618
|
+
* non-empty and checkable, so the abolished hashless `usr:` literal can
|
|
1619
|
+
* never recur: every `usr:` scope carries a principal hash.
|
|
1620
|
+
*/
|
|
1621
|
+
function hashPrincipal(principal) {
|
|
1622
|
+
return createHash("sha256").update(principal).digest("hex").slice(0, 32);
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* The `<scope>` literal prefix of the per-principal storage class — the ONE
|
|
1626
|
+
* class `resolveScope` confines to the isolate-local L1. Shared by both directions so they
|
|
1627
|
+
* cannot drift: the MINT below builds a literal with it, and `storedScopeIsL1Only` reads a
|
|
1628
|
+
* STORED literal back through it. A serve derives confinement from the identity the entry was
|
|
1629
|
+
* stored under, so the two sides must agree on one prefix by construction.
|
|
1630
|
+
*/
|
|
1631
|
+
const PRINCIPAL_SCOPE_PREFIX = "usr:";
|
|
1632
|
+
function resolveScope(input) {
|
|
1633
|
+
const { scope } = input;
|
|
1634
|
+
if (scope === "public") return {
|
|
1635
|
+
l1Only: false,
|
|
1636
|
+
literal: "pub"
|
|
1637
|
+
};
|
|
1638
|
+
if (scope !== void 0 && typeof scope === "object") return {
|
|
1639
|
+
l1Only: false,
|
|
1640
|
+
literal: `ten:${hashPrincipal(scope.tenant)}`
|
|
1641
|
+
};
|
|
1642
|
+
const principal = derivePrincipal(input.request);
|
|
1643
|
+
if (principal !== void 0) return {
|
|
1644
|
+
l1Only: true,
|
|
1645
|
+
literal: `${PRINCIPAL_SCOPE_PREFIX}${hashPrincipal(principal)}`
|
|
1646
|
+
};
|
|
1647
|
+
return {
|
|
1648
|
+
l1Only: false,
|
|
1649
|
+
literal: "pub"
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Whether a STORED `<scope>` literal names the L1-confined class — the read-back twin
|
|
1654
|
+
* of `resolveScope`'s `l1Only`, over a literal rather than the call inputs a serve no longer has.
|
|
1655
|
+
* `pub`/`ten:` are shared-tier classes; only `usr:<hash>` is confined.
|
|
1656
|
+
*
|
|
1657
|
+
* NOTE the asymmetry this CANNOT see, by construction: a `pub` fill DEMOTED at fill time by the
|
|
1658
|
+
* dev-mode guarded-accessor Proxy stores the `pub` literal, so its confinement leaves no trace in
|
|
1659
|
+
* the stored identity. Such an entry reads back as `pub` here, and a serve reports its durability
|
|
1660
|
+
* ABSENT — unknowable, which is the honest answer, rather than a `true` the literal cannot
|
|
1661
|
+
* support.
|
|
1662
|
+
*/
|
|
1663
|
+
function storedScopeIsL1Only(literal) {
|
|
1664
|
+
return literal.startsWith(PRINCIPAL_SCOPE_PREFIX);
|
|
1665
|
+
}
|
|
1666
|
+
/** The `<scope>` literal alone — for the contexts (canonical key, join
|
|
1667
|
+
* identity, key-selector purge) that need only the string, not the tier policy. */
|
|
1668
|
+
function scopeLiteral(input = {}) {
|
|
1669
|
+
return resolveScope(input).literal;
|
|
1670
|
+
}
|
|
1671
|
+
/** Normalize the config's `telemetry` (a bare sink function, a config object, or
|
|
1672
|
+
* absent) to `{ sink, hosted?, salt?, onSinkError? }`. A bare function carries no
|
|
1673
|
+
* hosted/salt/onSinkError; the config-object form passes them through unchanged. */
|
|
1674
|
+
function telemetryOf(config) {
|
|
1675
|
+
const { telemetry } = config;
|
|
1676
|
+
if (telemetry === void 0) return;
|
|
1677
|
+
if (typeof telemetry === "function") return { sink: telemetry };
|
|
1678
|
+
return telemetry;
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Pseudonymize one string field for a HOSTED sink: a per-project
|
|
1682
|
+
* HMAC-salted hash, so a short or low-entropy identifier (a key or a tag) is not
|
|
1683
|
+
* offline-invertible and never travels raw. The salt is the per-project secret
|
|
1684
|
+
* (`hostedTelemetry(salt)`), so holding key+tag fixed and changing ONLY the salt
|
|
1685
|
+
* changes the ref — and the ref tracks its own input, so a key ref moves iff the
|
|
1686
|
+
* key moves and a tag ref moves iff the tag moves (INV-15). HMAC (not a bare hash)
|
|
1687
|
+
* so the salt genuinely keys the digest per project.
|
|
1688
|
+
*/
|
|
1689
|
+
function saltedRef(salt, value) {
|
|
1690
|
+
return createHmac("sha256", salt).update(value).digest("hex").slice(0, 32);
|
|
1691
|
+
}
|
|
1692
|
+
/** Redact one event field for a hosted sink: HMAC-salt every string (per-field,
|
|
1693
|
+
* including inside a string array — key ref, tag refs), leave non-string scalars
|
|
1694
|
+
* intact. `type` (the event NAME, not sensitive) is redacted by the caller's field
|
|
1695
|
+
* filter, never here. A field-total salted pseudonymization — not
|
|
1696
|
+
* anonymization. */
|
|
1697
|
+
function redactField(salt, value) {
|
|
1698
|
+
if (typeof value === "string") return saltedRef(salt, value);
|
|
1699
|
+
if (Array.isArray(value)) return value.map((element) => redactField(salt, element));
|
|
1700
|
+
return value;
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Emit a telemetry event. A plain sink receives the event verbatim
|
|
1704
|
+
* (raw key/tag/detail — redaction is hosted-only by design, INV-15). A HOSTED sink
|
|
1705
|
+
* receives a field-total salted pseudonymization: every string field EXCEPT `type`
|
|
1706
|
+
* (the event name) is replaced by a per-project HMAC-salted hash of its own value,
|
|
1707
|
+
* so no raw key/tag/salt/detail ever leaves the process. A hosted config
|
|
1708
|
+
* missing a salt cannot pseudonymize, so it is suppressed rather than leaking raw
|
|
1709
|
+
* fields. The final delivery — plain or redacted — goes through `safeEmit`, so an
|
|
1710
|
+
* app sink that throws or rejects is swallowed (its `onSinkError` hook notified) and
|
|
1711
|
+
* can never surface on the kernel's own path.
|
|
1712
|
+
*/
|
|
1713
|
+
function emitTelemetry(config, event) {
|
|
1714
|
+
const telemetry = telemetryOf(config);
|
|
1715
|
+
if (telemetry === void 0) return;
|
|
1716
|
+
if (telemetry.hosted !== true) {
|
|
1717
|
+
safeEmit(telemetry.sink, event, telemetry.onSinkError);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const { salt } = telemetry;
|
|
1721
|
+
if (salt === void 0) return;
|
|
1722
|
+
const redacted = { type: event.type };
|
|
1723
|
+
for (const [field, value] of Object.entries(event)) {
|
|
1724
|
+
if (field === "type") continue;
|
|
1725
|
+
redacted[field] = redactField(salt, value);
|
|
1726
|
+
}
|
|
1727
|
+
safeEmit(telemetry.sink, redacted, telemetry.onSinkError);
|
|
1728
|
+
}
|
|
1729
|
+
/** The reserved per-key tag: `__k:<h>`, `<h>` a ≥128-bit (32 hex char) digest
|
|
1730
|
+
* of the canonical key (scoped, ns-versioned). SHA-256 truncated to 128 bits — a
|
|
1731
|
+
* synchronous digest (`node:crypto`, available on both Node and Bun) so the fill
|
|
1732
|
+
* path stays fully synchronous; hashing is not randomness. `expire
|
|
1733
|
+
* ({key})` recomputes this same digest to target the identical tag. */
|
|
1734
|
+
function keyTag(canonical) {
|
|
1735
|
+
return `__k:${createHash("sha256").update(canonical).digest("hex").slice(0, 32)}`;
|
|
1736
|
+
}
|
|
1737
|
+
/** The reserved per-namespace tag: `__ns:<name>`. Every envelope
|
|
1738
|
+
* carries it so `clear()`'s hard purge rides the same epoch/bus/conservative
|
|
1739
|
+
* machinery as any other purge — this is what lets a lagging isolate's read
|
|
1740
|
+
* fail closed instead of serving pre-clear data (INV-23). */
|
|
1741
|
+
function nsTag(namespace) {
|
|
1742
|
+
return `__ns:${namespace}`;
|
|
1743
|
+
}
|
|
1744
|
+
/** Validate the committed user-tag SET at the write boundary. Two ceilings, both
|
|
1745
|
+
* per typed `InvalidTagError`, never `NotImplementedError`:
|
|
1746
|
+
* - the `__`-prefix backstop, per tag — a forged reserved tag never reaches storage;
|
|
1747
|
+
* - the count ceiling, over the whole set — at most `MAX_USER_TAGS` user tags.
|
|
1748
|
+
* The caller passes whatever set is being committed — the declared tags at `getOrSet`'s
|
|
1749
|
+
* boundary, or the settle-union of declared + `setTags` + `dependsOn` — so the ceiling
|
|
1750
|
+
* is route-independent by construction: one function, one set, one boundary. The reserved
|
|
1751
|
+
* `__k`/`__ns` tags are stamped separately and never counted here. The set is expected already
|
|
1752
|
+
* deduped (the ceiling counts distinct user tags, not raw call arity). */
|
|
1753
|
+
function validateUserTags(tags) {
|
|
1754
|
+
if (tags === void 0) return;
|
|
1755
|
+
if (tags.length > 126) throw new InvalidTagError(tags[126] ?? "", `an entry may not carry more than 126 user tags (§2.3, MI13); got ${tags.length}`);
|
|
1756
|
+
for (const tag of tags) assertUserTag(tag);
|
|
1757
|
+
}
|
|
1758
|
+
/** `tags` are sorted, deduped, and include `__k`/`__ns`. Dedupes FIRST, then validates the
|
|
1759
|
+
* distinct set — so the count ceiling measures distinct user tags, not raw call
|
|
1760
|
+
* arity — and returns just the caller-supplied set, sorted and deduped. The reserved
|
|
1761
|
+
* `__k`/`__ns` tags are appended separately by `envelopeTags` below, since they depend on the
|
|
1762
|
+
* canonical key / namespace, not just the call options. */
|
|
1763
|
+
function sortedUserTags(tags) {
|
|
1764
|
+
if (tags === void 0) return [];
|
|
1765
|
+
const distinct = [...new Set(tags)].toSorted();
|
|
1766
|
+
validateUserTags(distinct);
|
|
1767
|
+
return distinct;
|
|
1768
|
+
}
|
|
1769
|
+
/** The full stored-envelope tag set: sorted user tags plus the reserved
|
|
1770
|
+
* `__k:<h>` (per-key digest of the canonical key) and `__ns:<name>` (per-namespace
|
|
1771
|
+
* watermark) tags. This is what rides the dual-epoch invalidation core, so
|
|
1772
|
+
* `expire({key})` / `clear()` invalidate through the same machinery as any tag
|
|
1773
|
+
* purge. */
|
|
1774
|
+
function envelopeTags(userTags, canonical, namespace) {
|
|
1775
|
+
return [
|
|
1776
|
+
...userTags,
|
|
1777
|
+
keyTag(canonical),
|
|
1778
|
+
nsTag(namespace)
|
|
1779
|
+
].toSorted();
|
|
1780
|
+
}
|
|
1781
|
+
function makeTagAccumulator(declared) {
|
|
1782
|
+
const membership = [];
|
|
1783
|
+
let authored;
|
|
1784
|
+
let settled = false;
|
|
1785
|
+
return {
|
|
1786
|
+
isSettled() {
|
|
1787
|
+
return settled;
|
|
1788
|
+
},
|
|
1789
|
+
markSettled() {
|
|
1790
|
+
settled = true;
|
|
1791
|
+
},
|
|
1792
|
+
addMembership(tag) {
|
|
1793
|
+
assertUserTag(tag);
|
|
1794
|
+
membership.push(tag);
|
|
1795
|
+
},
|
|
1796
|
+
author(tags) {
|
|
1797
|
+
for (const tag of tags) assertUserTag(tag);
|
|
1798
|
+
authored = [...tags];
|
|
1799
|
+
},
|
|
1800
|
+
settleUserTags() {
|
|
1801
|
+
return [.../* @__PURE__ */ new Set([...authored ?? declared, ...membership])].toSorted();
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
/** Resolve the codec, falling back to the built-in safe-mode codec when the
|
|
1806
|
+
* config wires none — `world()` wires no codec, so the core needs a default
|
|
1807
|
+
* to store/read values at all. */
|
|
1808
|
+
function codecOf(config) {
|
|
1809
|
+
return config.codec ?? safeCodec;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* The consistency this call reads at: per-call, then the instance's declared default, then
|
|
1813
|
+
* the own (`eventual`).
|
|
1814
|
+
*
|
|
1815
|
+
* The middle step is what makes `CacheDefaults.consistency` a setting rather than a decoration:
|
|
1816
|
+
* without it, a caller who declared `defaults: { consistency: 'strong' }` got eventual reads and
|
|
1817
|
+
* nothing said so. The final fallback is unchanged and matches what every shipped default already
|
|
1818
|
+
* declares, so an instance that sets nothing reads exactly as it did before.
|
|
1819
|
+
*/
|
|
1820
|
+
function consistencyOf(config, options) {
|
|
1821
|
+
return options.consistency ?? config.defaults?.consistency ?? "eventual";
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* How fail-closed `unknown` resolves for this instance.
|
|
1825
|
+
*
|
|
1826
|
+
* `takedownSensitive` is an OVERRIDE, and the precedence is the whole point. It outranks an
|
|
1827
|
+
* explicitly-set `unknownPolicy` rather than merely filling in for an absent one — because as an
|
|
1828
|
+
* absent-policy default it was unreachable in practice: any composition that sets `unknownPolicy`
|
|
1829
|
+
* shadows it, and `createWorkersCache`'s own safe posture sets it, so the switch was dead on the
|
|
1830
|
+
* entire hero path. A legal-embargo control that a routine, recommended default silently disables is
|
|
1831
|
+
* worse than no control: it reads as protection and does nothing.
|
|
1832
|
+
*
|
|
1833
|
+
* Reading it as an override is also what the flag MEANS. `unknownPolicy` says how this namespace
|
|
1834
|
+
* would like unknowns resolved; `takedownSensitive` says this namespace is one where guessing is not
|
|
1835
|
+
* allowed. The second is a statement about the CONTENT, so it cannot be outranked by a preference.
|
|
1836
|
+
*/
|
|
1837
|
+
function unknownResolution(config) {
|
|
1838
|
+
if (config.takedownSensitive === true || config.defaults?.takedownSensitive === true) return {
|
|
1839
|
+
armedBy: "takedownSensitive",
|
|
1840
|
+
kind: "error"
|
|
1841
|
+
};
|
|
1842
|
+
const policy = config.defaults?.unknownPolicy ?? "registry-check";
|
|
1843
|
+
return policy === "error" ? {
|
|
1844
|
+
armedBy: "unknownPolicy",
|
|
1845
|
+
kind: "error"
|
|
1846
|
+
} : policy;
|
|
1847
|
+
}
|
|
1848
|
+
/** The `staleIfError` predicate for this instance — the configured
|
|
1849
|
+
* override, or the default HTTP classifier. */
|
|
1850
|
+
function staleIfErrorOf(config) {
|
|
1851
|
+
return config.defaults?.staleIfError ?? isRetriableHttp;
|
|
1852
|
+
}
|
|
1853
|
+
/** Whether a thrown error is the `not_found` fact (a 404) — never masked,
|
|
1854
|
+
* never retriable, but eligible for negative caching when `notFoundTtl` is declared. */
|
|
1855
|
+
function isNotFound(err) {
|
|
1856
|
+
return err instanceof HttpError && err.status === 404;
|
|
1857
|
+
}
|
|
1858
|
+
/** A miss with no live check performed (absent / codecId / decode). */
|
|
1859
|
+
const MISS_READ = { checkedThrough: 0 };
|
|
1860
|
+
/** Whether a stored entry's codec identity matches (or is an accepted migration
|
|
1861
|
+
* source) — checked BEFORE decode so a foreign codec never runs. */
|
|
1862
|
+
function codecIdMatches(storedCodecId, codec, config) {
|
|
1863
|
+
if (storedCodecId === codec.id) return true;
|
|
1864
|
+
const acceptCodecs = config.defaults?.acceptCodecs ?? [];
|
|
1865
|
+
return typeof storedCodecId === "string" && acceptCodecs.includes(storedCodecId);
|
|
1866
|
+
}
|
|
1867
|
+
/** Reconstruct the envelope shell from stored bytes + metadata. `bornEpoch`/
|
|
1868
|
+
* `tags` ride in metadata so validation runs without a second decode. */
|
|
1869
|
+
function envelopeFromStore(key, raw, metadata, decoded, codecId) {
|
|
1870
|
+
const bornEpoch = typeof metadata.bornEpoch === "number" ? metadata.bornEpoch : 0;
|
|
1871
|
+
const bornMs = typeof metadata.bornMs === "number" ? metadata.bornMs : 0;
|
|
1872
|
+
const tags = Array.isArray(metadata.tags) ? metadata.tags.filter((t) => typeof t === "string") : [];
|
|
1873
|
+
return {
|
|
1874
|
+
bornEpoch,
|
|
1875
|
+
bornMs,
|
|
1876
|
+
codecId,
|
|
1877
|
+
enc: "json",
|
|
1878
|
+
grace: 0,
|
|
1879
|
+
key,
|
|
1880
|
+
kind: metadata.kind === "neg" ? "neg" : "val",
|
|
1881
|
+
scope: typeof metadata.scope === "string" ? metadata.scope : "pub",
|
|
1882
|
+
size: raw.length,
|
|
1883
|
+
src: "factory",
|
|
1884
|
+
storedAt: bornMs,
|
|
1885
|
+
tags,
|
|
1886
|
+
ttl: 0,
|
|
1887
|
+
v: 3,
|
|
1888
|
+
val: decoded,
|
|
1889
|
+
...typeof metadata.fence === "number" ? { fence: metadata.fence } : {}
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* The value a HIT on this envelope SURFACES.
|
|
1894
|
+
*
|
|
1895
|
+
* A `kind:'neg'` envelope is a remembered 404: it caches the not-found FACT, and the envelope gives it no
|
|
1896
|
+
* value to carry. Its stored bytes are a JSON-representable placeholder (`writeNegEnvelope`) chosen
|
|
1897
|
+
* only because `undefined` is not a storable body — they are never the answer. So a read of a neg
|
|
1898
|
+
* surfaces `undefined`, which is EXACTLY what the fill turn that wrote it surfaced (INV-7): the
|
|
1899
|
+
* point of caching a 404 is that the next reader gets the same answer without paying the origin,
|
|
1900
|
+
* and an answer that changes on the second call is not a cached fact at all. Serving the placeholder
|
|
1901
|
+
* instead handed the caller a `null` no origin ever produced, and reported a durable VALUE hit for
|
|
1902
|
+
* an entry holding only a negative.
|
|
1903
|
+
*
|
|
1904
|
+
* A `val` envelope surfaces its decoded value unchanged. This is the ONE point where the two kinds
|
|
1905
|
+
* diverge on the read path; every other neg rule is already a refusal to serve at all ( a
|
|
1906
|
+
* stale neg is never grace/SWR-served, so the only servable neg is a fresh one).
|
|
1907
|
+
*/
|
|
1908
|
+
function servedValue(envelope) {
|
|
1909
|
+
if (envelope.kind === "neg") return;
|
|
1910
|
+
return envelope.val;
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Classify a rejected `Store.get` — the read-side twin of `classifyWriteBackFailure`, and
|
|
1914
|
+
* consistent with its reasoning rather than merely its shape:
|
|
1915
|
+
*
|
|
1916
|
+
* - CLASSIFIED (`throttled` / `unavailable`) ⇒ returned to the caller, which absorbs it. The kernel signs
|
|
1917
|
+
* that a suppressed L2 GET error is a plain MISS — never routed to unknown/fail-closed — so on
|
|
1918
|
+
* the serving path the caller refills, and a configured L1 (checked FIRST) may already
|
|
1919
|
+
* have served without this tier being read at all.
|
|
1920
|
+
* - UNCLASSIFIED ⇒ RETHROWN unchanged, for the same reason the write half rethrows one: the kernel
|
|
1921
|
+
* absorbs only failures a driver has named, and a rejection nobody classified is not evidence of
|
|
1922
|
+
* absence. Swallowing it would turn an unknown fault into a confident "not found" — which, on a
|
|
1923
|
+
* read, is the one answer that silently refills over whatever is really there.
|
|
1924
|
+
*
|
|
1925
|
+
* Suppression is the only telemetry-bearing outcome (`store_read_suppressed`, a raw fact at the
|
|
1926
|
+
* instant it occurs). A rethrown error needs no event: it reaches the caller intact.
|
|
1927
|
+
*/
|
|
1928
|
+
function classifyStoreReadFailure(config, key, error) {
|
|
1929
|
+
if (!isStoreReadError(error)) throw error;
|
|
1930
|
+
emitTelemetry(config, {
|
|
1931
|
+
code: error.code,
|
|
1932
|
+
key,
|
|
1933
|
+
type: TELEMETRY_EVENTS.store_read_suppressed
|
|
1934
|
+
});
|
|
1935
|
+
return error;
|
|
1936
|
+
}
|
|
1937
|
+
/** The ONE spelling of the "the store did not answer" discriminant, shared by the probe's outcome
|
|
1938
|
+
* and the witness's own arm so the two can never drift apart. */
|
|
1939
|
+
const READ_FAILED_KIND = "read-failed";
|
|
1940
|
+
/** The two probe outcomes that carry no payload — one shared instance each, exactly as `MISS_READ`
|
|
1941
|
+
* is for the read path. */
|
|
1942
|
+
const PROBE_ABSENT = { kind: "absent" };
|
|
1943
|
+
const PROBE_READ_FAILED = { kind: READ_FAILED_KIND };
|
|
1944
|
+
/**
|
|
1945
|
+
* A NON-mutating presence probe for `explain`: read `canonical` from `store` with a
|
|
1946
|
+
* plain `store.get` — never a hydrating read — and reconstruct its envelope plus the RAW stored
|
|
1947
|
+
* scope; `absent` when the entry is missing, codec-mismatched, or undecodable; `read-failed`
|
|
1948
|
+
* when the store's own read was suppressed. The read fails closed exactly as the serve path
|
|
1949
|
+
* does (INV-8/27); it observes, it never writes.
|
|
1950
|
+
*/
|
|
1951
|
+
async function probeStoredEntry(store, codec, config, canonical) {
|
|
1952
|
+
let stored;
|
|
1953
|
+
try {
|
|
1954
|
+
stored = await store.get(canonical);
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
classifyStoreReadFailure(config, canonical, error);
|
|
1957
|
+
return PROBE_READ_FAILED;
|
|
1958
|
+
}
|
|
1959
|
+
if (!stored || !codecIdMatches(stored.metadata?.codecId, codec, config)) return PROBE_ABSENT;
|
|
1960
|
+
let decoded;
|
|
1961
|
+
try {
|
|
1962
|
+
decoded = codec.decode(stored.value);
|
|
1963
|
+
} catch {
|
|
1964
|
+
return PROBE_ABSENT;
|
|
1965
|
+
}
|
|
1966
|
+
const envelope = envelopeFromStore(canonical, stored.value, stored.metadata ?? {}, decoded, codec.id);
|
|
1967
|
+
const storedScope = stored.metadata?.scope;
|
|
1968
|
+
const scope = typeof storedScope === "string" ? {
|
|
1969
|
+
kind: "readable",
|
|
1970
|
+
literal: storedScope
|
|
1971
|
+
} : { kind: "unreadable" };
|
|
1972
|
+
const storedTtl = stored.metadata?.ttl;
|
|
1973
|
+
return {
|
|
1974
|
+
envelope,
|
|
1975
|
+
kind: "found",
|
|
1976
|
+
scope,
|
|
1977
|
+
ttl: typeof storedTtl === "number" ? {
|
|
1978
|
+
kind: "stored",
|
|
1979
|
+
seconds: storedTtl
|
|
1980
|
+
} : { kind: "not-stored" }
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* THE READ PATH'S CLASSIFIER for the SERVING path. Given raw stored bytes and metadata, it answers
|
|
1985
|
+
* what a reader on THIS instance would make of them: an envelope, or nothing at all. (The
|
|
1986
|
+
* introspection probe still runs its own copy of this sequence — see `probeStoredEntry`, which
|
|
1987
|
+
* answers a different question and must report the causes of absence rather than collapse them to
|
|
1988
|
+
* one. Folding the two is worth doing and is not this change.)
|
|
1989
|
+
*
|
|
1990
|
+
* Two gates precede the reconstruction and both fail CLOSED, because a reader that cannot identify
|
|
1991
|
+
* or decode an entry is served a miss rather than a guess — the codecId check (INV-27/ : a
|
|
1992
|
+
* mismatch `acceptCodecs` does not admit is a miss) and `decode` itself (INV-8: a body this codec
|
|
1993
|
+
* cannot read is a miss). Only past both is an envelope built.
|
|
1994
|
+
*
|
|
1995
|
+
* Both seats call THIS function, by identity, and that is the point. "Servable" is defined by the
|
|
1996
|
+
* read path, so the write layer's neg-write guard must ask the READER's question rather than a looser one
|
|
1997
|
+
* of its own: an envelope no reader can classify as a val is not a servable good value — it is a
|
|
1998
|
+
* miss wearing bytes, and protecting it protects a ghost. Answering that question with a partial
|
|
1999
|
+
* copy of these steps is exactly how two seats stop meaning one thing while each keeps passing its
|
|
2000
|
+
* own tests.
|
|
2001
|
+
*/
|
|
2002
|
+
function readerEnvelope(config, codec, key, stored) {
|
|
2003
|
+
if (!codecIdMatches(stored.metadata?.codecId, codec, config)) return;
|
|
2004
|
+
let decoded;
|
|
2005
|
+
try {
|
|
2006
|
+
decoded = codec.decode(stored.value);
|
|
2007
|
+
} catch {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
return envelopeFromStore(key, stored.value, stored.metadata ?? {}, decoded, codec.id);
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Read one tier: codecId-check + decode + validate against knowledge.
|
|
2014
|
+
* `hit` is a `ReadHit` (fresh or stale-servable) or `undefined` (absent / dead /
|
|
2015
|
+
* decode failure / codecId mismatch / suppressed read failure / unknown-fail-closed ⇒ the
|
|
2016
|
+
* caller refills). `checkedThrough` is the epoch a live check by THIS read verified (0 if
|
|
2017
|
+
* none), which the refill uses so a behind isolate does not fence its own legitimate refetch.
|
|
2018
|
+
*/
|
|
2019
|
+
async function readTier(store, codec, config, inval, consistency, key) {
|
|
2020
|
+
let stored;
|
|
2021
|
+
try {
|
|
2022
|
+
stored = await store.get(key);
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
classifyStoreReadFailure(config, key, error);
|
|
2025
|
+
return MISS_READ;
|
|
2026
|
+
}
|
|
2027
|
+
if (!stored) return MISS_READ;
|
|
2028
|
+
const envelope = readerEnvelope(config, codec, key, stored);
|
|
2029
|
+
if (!envelope) return MISS_READ;
|
|
2030
|
+
const storedScope = stored.metadata?.scope;
|
|
2031
|
+
const rawScope = typeof storedScope === "string" ? storedScope : void 0;
|
|
2032
|
+
const scopeEvidence = {
|
|
2033
|
+
scopeUnreadable: rawScope === void 0,
|
|
2034
|
+
...rawScope === void 0 ? {} : { rawScope }
|
|
2035
|
+
};
|
|
2036
|
+
if (!inval) return {
|
|
2037
|
+
checkedThrough: 0,
|
|
2038
|
+
hit: {
|
|
2039
|
+
...scopeEvidence,
|
|
2040
|
+
envelope,
|
|
2041
|
+
stale: false,
|
|
2042
|
+
value: servedValue(envelope)
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
const { validity, checkedThrough } = await inval.validate(envelope, consistency, unknownResolution(config), config.defaults?.onUnavailable);
|
|
2046
|
+
if (validity === "fresh") return {
|
|
2047
|
+
checkedThrough,
|
|
2048
|
+
hit: {
|
|
2049
|
+
...scopeEvidence,
|
|
2050
|
+
envelope,
|
|
2051
|
+
stale: false,
|
|
2052
|
+
value: servedValue(envelope)
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
if (validity === "stale" && envelope.kind !== "neg") return {
|
|
2056
|
+
checkedThrough,
|
|
2057
|
+
hit: {
|
|
2058
|
+
...scopeEvidence,
|
|
2059
|
+
envelope,
|
|
2060
|
+
stale: true,
|
|
2061
|
+
value: servedValue(envelope)
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
if (envelope.kind === "neg") return { checkedThrough };
|
|
2065
|
+
return {
|
|
2066
|
+
checkedThrough,
|
|
2067
|
+
staleCandidate: {
|
|
2068
|
+
...scopeEvidence,
|
|
2069
|
+
envelope,
|
|
2070
|
+
stale: true,
|
|
2071
|
+
value: servedValue(envelope)
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* DEVELOPMENT-TIME TEACHING TOOL — NOT A PRODUCTION SAFETY NET. It is dev-gated by design
|
|
2077
|
+
* (`config.dev === true`), and it stays that way. Two readings to head off, in both directions:
|
|
2078
|
+
*
|
|
2079
|
+
* - DO NOT PROMOTE IT TO PRODUCTION believing it closes a privacy hole. It does not close one:
|
|
2080
|
+
* an application that hand-mixes per-principal content into a public-scoped fill without
|
|
2081
|
+
* touching `ctx.request` at all is untouched by it, so it was never a boundary — it is a
|
|
2082
|
+
* tripwire that catches the honest mistake early, in the one environment where the developer
|
|
2083
|
+
* is watching. Making the demotion live in production would make an entry's STORAGE TIER a
|
|
2084
|
+
* function of what a factory happened to read on one execution: the same key lands durable on
|
|
2085
|
+
* the request whose factory took the cache-hit path and L1-only on the request whose factory
|
|
2086
|
+
* read the principal, so the tier stops being a property of the call and becomes a property of
|
|
2087
|
+
* the run. That is nondeterministic caching, and it is a worse bargain than the mistake it
|
|
2088
|
+
* would be trying to catch.
|
|
2089
|
+
*
|
|
2090
|
+
* - DO NOT DELETE IT believing it is dead code. INV-4 pins it, and the demotion is exactly
|
|
2091
|
+
* what that invariant's enforceable half asserts — a public fill that read request state is
|
|
2092
|
+
* confined to L1 and never reaches the shared tier. Blind it and the sacred suite reds.
|
|
2093
|
+
*
|
|
2094
|
+
* It deliberately does NOT feed the shared-cacheability gate. That gate answers a different
|
|
2095
|
+
* question — whether the scopes the library DECLARED and STORED permit a shared HTTP cache to keep
|
|
2096
|
+
* the response — and it answers it from evidence that survives the request. This guard sees
|
|
2097
|
+
* only what one factory did while it ran. Wiring a runtime observation into that gate would make
|
|
2098
|
+
* the emitted response posture vary run to run for the same route, which is the same nondeterminism
|
|
2099
|
+
* as above, on the wire instead of in the tiers.
|
|
2100
|
+
*/
|
|
2101
|
+
function buildRequestGuard(config, options) {
|
|
2102
|
+
const { request } = options;
|
|
2103
|
+
if (!(config.dev === true && options.scope === "public") || request === void 0) return {
|
|
2104
|
+
request,
|
|
2105
|
+
wasTouched: () => false
|
|
2106
|
+
};
|
|
2107
|
+
let touched = false;
|
|
2108
|
+
return {
|
|
2109
|
+
request: new Proxy(request, { get(target, prop, receiver) {
|
|
2110
|
+
touched = true;
|
|
2111
|
+
return Reflect.get(target, prop, receiver);
|
|
2112
|
+
} }),
|
|
2113
|
+
wasTouched: () => touched
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
/** Acquire the cross-isolate lock if requested, returning its fence handle. */
|
|
2117
|
+
async function maybeLock(config, options) {
|
|
2118
|
+
if (options.lock !== true) return;
|
|
2119
|
+
const { lock } = config;
|
|
2120
|
+
if (!lock) return;
|
|
2121
|
+
return await lock.acquire(options.key);
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Classify a rejected L2 write-back into the kernel's write-failure semantics.
|
|
2125
|
+
* WIRES CLASSIFICATION + TELEMETRY ONLY — no REFRESH_QUEUE / retry machinery is built
|
|
2126
|
+
* (that is deferred). A recognized transient failure is SUPPRESSED: the write did not
|
|
2127
|
+
* become durable, but the operation does not fail the caller and produces no
|
|
2128
|
+
* fail-closed/unknown state — the function returns `false` (non-durable) and emits the
|
|
2129
|
+
* reserved telemetry name. Everything else keeps today's behavior and PROPAGATES:
|
|
2130
|
+
* - `throttled` (retryable) → `writeback_throttled`, suppressed (`durable:false`).
|
|
2131
|
+
* - `unavailable` (retryable) → `l2_write_error`, suppressed (`durable:false`).
|
|
2132
|
+
* - `too_large` (permanent, non-retryable) → rethrown, never silently suppressed.
|
|
2133
|
+
* - unclassified → surfaced as `l2_write_error` telemetry, then rethrown unchanged.
|
|
2134
|
+
*/
|
|
2135
|
+
function classifyWriteBackFailure(config, key, error) {
|
|
2136
|
+
const classified = isStoreWriteError(error) ? error : void 0;
|
|
2137
|
+
if (classified?.code === "throttled") {
|
|
2138
|
+
emitTelemetry(config, {
|
|
2139
|
+
key,
|
|
2140
|
+
type: "writeback_throttled"
|
|
2141
|
+
});
|
|
2142
|
+
return false;
|
|
2143
|
+
}
|
|
2144
|
+
if (classified?.code === "unavailable") {
|
|
2145
|
+
emitTelemetry(config, {
|
|
2146
|
+
key,
|
|
2147
|
+
type: "l2_write_error"
|
|
2148
|
+
});
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
if (classified === void 0) emitTelemetry(config, {
|
|
2152
|
+
key,
|
|
2153
|
+
type: "l2_write_error"
|
|
2154
|
+
});
|
|
2155
|
+
throw error;
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Is this stored envelope a `val` that protects? THE suppression-worthiness
|
|
2159
|
+
* test, and it lives in ONE place because two seats have to agree about it:
|
|
2160
|
+
*
|
|
2161
|
+
* - the SERVE seat (`gracedStillServable`) asks it of the candidate THIS read holds, deciding
|
|
2162
|
+
* whether an error-path serve may draw on it;
|
|
2163
|
+
* - the WRITE seat (`negDisplacesProtectedVal`) asks it of the envelope the born-monotone compare
|
|
2164
|
+
* already fetched, deciding whether a `kind:'neg'` candidate may land on it.
|
|
2165
|
+
*
|
|
2166
|
+
* One function, because the two answers may never disagree in the direction that destroys data: a
|
|
2167
|
+
* val the serve path would hand to a caller is exactly a val the write path must refuse to bury.
|
|
2168
|
+
* Written twice they drift, and the drift is silent — each site keeps passing its own tests while
|
|
2169
|
+
* the pair stops meaning one thing.
|
|
2170
|
+
*
|
|
2171
|
+
* `validity` is the verdict, produced by the same machinery on both sides. `unknown` is
|
|
2172
|
+
* deliberately NOT settled here: it is a GAP rather than a window, and the two seats answer a gap
|
|
2173
|
+
* differently on purpose — the serve seat refuses to serve THROUGH one, the write seat refuses to
|
|
2174
|
+
* write through one. Each states that rule, with its own derivation, at its own site.
|
|
2175
|
+
*/
|
|
2176
|
+
function e7ProtectsStoredVal(envelope, validity) {
|
|
2177
|
+
return envelope.kind !== "neg" && (validity === "fresh" || validity === "stale");
|
|
2178
|
+
}
|
|
2179
|
+
/** The `UnknownResolution` a RE-VALIDATION of an already-stored envelope asks for: settle
|
|
2180
|
+
* an unknown tag with one live registry read. Named once because the two seats that re-validate the
|
|
2181
|
+
* same envelope — the serve seat's grace check and the write seat's neg-write guard — must ask the SAME
|
|
2182
|
+
* question of it; drifting onto different resolutions would let them reach different verdicts about
|
|
2183
|
+
* one entry and call it agreement. Distinct from `unknownResolution`, which reports the instance's
|
|
2184
|
+
* configured READ-time policy. */
|
|
2185
|
+
const REVALIDATION_RESOLUTION = "registry-check";
|
|
2186
|
+
/**
|
|
2187
|
+
* the kind-aware guard: would landing a `kind:'neg'` candidate here bury a
|
|
2188
|
+
* `val` the letter protects? Asked of the envelope the born-monotone compare ALREADY fetched, which
|
|
2189
|
+
* is why it lives at the write layer — one boundary, so every neg-write source passes it identically
|
|
2190
|
+
* and the outcome cannot vary by the route that reached it.
|
|
2191
|
+
*
|
|
2192
|
+
* The question is ONTOLOGICAL: what does the STORE hold? That is a different question from the one
|
|
2193
|
+
* the filling caller's own read can answer, and the gap between them is the whole defect this closes
|
|
2194
|
+
* — a caller blind to a servable val (a tier that was unreadable for a moment, or a join whose
|
|
2195
|
+
* leader read nothing) would otherwise write straight over it, and every later reader would be
|
|
2196
|
+
* served the negative. Note also which KIND is which: `FilledAttempt.storedKind` describes what THIS
|
|
2197
|
+
* fill produced, while the kind read here is the STORED envelope's — different objects, and only the
|
|
2198
|
+
* second one can answer this.
|
|
2199
|
+
*
|
|
2200
|
+
* The within-grace term is not re-invented: it is the verdict `validate` gives, asked on the
|
|
2201
|
+
* same terms the serve seat asks for it (`gracedStillServable`), and settled by the shared predicate
|
|
2202
|
+
* both seats share.
|
|
2203
|
+
*/
|
|
2204
|
+
async function negDisplacesProtectedVal(config, key, existing, inval, consistency) {
|
|
2205
|
+
const stored = readerEnvelope(config, codecOf(config), key, existing);
|
|
2206
|
+
if (!stored) return false;
|
|
2207
|
+
if (!inval) return e7ProtectsStoredVal(stored, "fresh");
|
|
2208
|
+
let validity;
|
|
2209
|
+
try {
|
|
2210
|
+
({validity} = await inval.validate(stored, consistency, REVALIDATION_RESOLUTION, config.defaults?.onUnavailable));
|
|
2211
|
+
} catch {
|
|
2212
|
+
validity = "unknown";
|
|
2213
|
+
}
|
|
2214
|
+
return e7ProtectsStoredVal(stored, validity === "unknown" ? "stale" : validity);
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Born-monotone write-back (invariant 17): never replace a stored
|
|
2218
|
+
* envelope with a strictly-higher `(bornEpoch, fence)`. Read-compare-write against
|
|
2219
|
+
* L2 — the KV CAS-less residual race is documented and bounded to one refresh cycle.
|
|
2220
|
+
*`bornMs` is never an arbiter. `durable` is `true` when the candidate landed (or a
|
|
2221
|
+
* strictly-newer envelope already sits durably), `false` when a classified `throttled`/`unavailable`
|
|
2222
|
+
* failure suppressed it.
|
|
2223
|
+
*
|
|
2224
|
+
* It is ALSO where the kind-aware guard sits, for the reason the guard needs and this function
|
|
2225
|
+
* already has: the stored envelope is in hand here. A `kind:'neg'` candidate may only land on a true
|
|
2226
|
+
* miss, so every neg-write source — a solo caller's own disposition, a blind leader's in-window
|
|
2227
|
+
* recording — meets the same test on the same evidence, and route-independence holds by construction
|
|
2228
|
+
* rather than by matching two call sites. `val`-over-`neg` is untouched: recovery is the direction
|
|
2229
|
+
* The neg-write guard leaves open, and the guard is asked only of a neg candidate.
|
|
2230
|
+
*/
|
|
2231
|
+
async function bornMonotoneWrite(config, l2, key, encoded, candidate, inval, consistency) {
|
|
2232
|
+
let existing;
|
|
2233
|
+
try {
|
|
2234
|
+
existing = await l2.get(key);
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
return {
|
|
2237
|
+
durable: classifyWriteBackFailure(config, key, error),
|
|
2238
|
+
writeSuppressed: false
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
if (existing) {
|
|
2242
|
+
if (candidate.kind === "neg" && await negDisplacesProtectedVal(config, key, existing, inval, consistency)) {
|
|
2243
|
+
emitTelemetry(config, {
|
|
2244
|
+
key,
|
|
2245
|
+
type: TELEMETRY_EVENTS.neg_suppressed
|
|
2246
|
+
});
|
|
2247
|
+
return {
|
|
2248
|
+
durable: false,
|
|
2249
|
+
writeSuppressed: true
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
const meta = existing.metadata ?? {};
|
|
2253
|
+
const storedBornEpoch = typeof meta.bornEpoch === "number" ? meta.bornEpoch : 0;
|
|
2254
|
+
const storedFence = typeof meta.fence === "number" ? meta.fence : -1;
|
|
2255
|
+
const candidateFence = candidate.fence ?? -1;
|
|
2256
|
+
if (storedBornEpoch > candidate.bornEpoch || storedBornEpoch === candidate.bornEpoch && storedFence > candidateFence) return {
|
|
2257
|
+
durable: true,
|
|
2258
|
+
writeSuppressed: false
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
try {
|
|
2262
|
+
await l2.set(key, encoded, { metadata: {
|
|
2263
|
+
bornEpoch: candidate.bornEpoch,
|
|
2264
|
+
bornMs: candidate.bornMs,
|
|
2265
|
+
codecId: candidate.codecId,
|
|
2266
|
+
enc: candidate.enc,
|
|
2267
|
+
kind: candidate.kind,
|
|
2268
|
+
scope: candidate.scope,
|
|
2269
|
+
tags: candidate.tags,
|
|
2270
|
+
v: candidate.v,
|
|
2271
|
+
...candidate.fence === void 0 ? {} : { fence: candidate.fence }
|
|
2272
|
+
} });
|
|
2273
|
+
return {
|
|
2274
|
+
durable: true,
|
|
2275
|
+
writeSuppressed: false
|
|
2276
|
+
};
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
return {
|
|
2279
|
+
durable: classifyWriteBackFailure(config, key, error),
|
|
2280
|
+
writeSuppressed: false
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Re-validate a graced candidate's tags AT SERVE TIME: a hard delete
|
|
2286
|
+
* that landed after the candidate was read (e.g. during the failed refresh) must
|
|
2287
|
+
* fence it here — a graced value is never served once its purge is visible. No
|
|
2288
|
+
* `Invalidation` (registry-absent) ⇒ nothing to invalidate it ⇒ always servable.
|
|
2289
|
+
*
|
|
2290
|
+
* AT `strong` THIS IS A LIVE REGISTRY CHECK LIKE ANY OTHER, so it honors `onUnavailable`
|
|
2291
|
+
* exactly as its sibling sites do. Leaving it unguarded did not avoid a throw — it MANUFACTURED
|
|
2292
|
+
* one: the guard inside `validate` fires for a caller who explicitly opted out, which is the one
|
|
2293
|
+
* caller who asked not to see it.
|
|
2294
|
+
*
|
|
2295
|
+
* `factoryError` is what this re-validation runs on behalf of, and it rides out as the thrown
|
|
2296
|
+
* error's `cause`. That answers the reason the guard was omitted here — the fear that a throw would
|
|
2297
|
+
* REPLACE the caller's factory error — without leaving the site unguarded: nothing is dropped, and
|
|
2298
|
+
* callers still get the one type the API names.
|
|
2299
|
+
*
|
|
2300
|
+
* WHEN THE OPT-OUT IS TAKEN this call drops to what an eventual read would do, and the candidate is
|
|
2301
|
+
* settled against local knowledge alone. A tag whose hardness cannot be established stays UNKNOWN,
|
|
2302
|
+
* and an unknown tag is never graced (ladder sends unknown to 5a and never to grace) —
|
|
2303
|
+
* when hardness is exactly what we failed to learn, a hard purge is one of the things the gap could
|
|
2304
|
+
* be concealing. So an unknown candidate is not servable, and the factory's own error is what the
|
|
2305
|
+
* caller ends up with.
|
|
2306
|
+
*/
|
|
2307
|
+
async function gracedStillServable(inval, consistency, envelope, onUnavailable, factoryError) {
|
|
2308
|
+
if (!inval) return e7ProtectsStoredVal(envelope, "fresh");
|
|
2309
|
+
let validity;
|
|
2310
|
+
try {
|
|
2311
|
+
({validity} = await inval.validate(envelope, consistency, REVALIDATION_RESOLUTION, onUnavailable));
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
if (error instanceof RegistryUnavailableError) throw new RegistryUnavailableError(error.message, { cause: factoryError });
|
|
2314
|
+
throw error;
|
|
2315
|
+
}
|
|
2316
|
+
return e7ProtectsStoredVal(envelope, validity);
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Write a `kind:'neg'` envelope for a `not_found` fact.
|
|
2320
|
+
* `notFoundTtl` is the per-call opt-in; the write shares the same born-monotone
|
|
2321
|
+
* fence as any write-back.
|
|
2322
|
+
*
|
|
2323
|
+
* Returns the write's OUTCOME (durability, and whether the guard refused it) AND
|
|
2324
|
+
* the `bornMs` it stamped — the neg's birth instant, read off the clock exactly once here and
|
|
2325
|
+
* carried out for every later consumer, the same way a value fill's is.
|
|
2326
|
+
*
|
|
2327
|
+
* `inval`/`consistency` ride down for the guard alone: it decides against the STORE, and deciding
|
|
2328
|
+
* against the store means asking the same validity question a reader asks of what it finds there.
|
|
2329
|
+
*/
|
|
2330
|
+
async function writeNegEnvelope(config, key, tags, scope, bornEpoch, inval, consistency) {
|
|
2331
|
+
const { l2 } = config;
|
|
2332
|
+
const bornMs = config.clock.now();
|
|
2333
|
+
if (!l2) return {
|
|
2334
|
+
bornMs,
|
|
2335
|
+
durable: true,
|
|
2336
|
+
writeSuppressed: false
|
|
2337
|
+
};
|
|
2338
|
+
const codec = codecOf(config);
|
|
2339
|
+
const encoded = codec.encode(null);
|
|
2340
|
+
return {
|
|
2341
|
+
bornMs,
|
|
2342
|
+
...await bornMonotoneWrite(config, l2, key, encoded, {
|
|
2343
|
+
bornEpoch,
|
|
2344
|
+
bornMs,
|
|
2345
|
+
enc: "json",
|
|
2346
|
+
grace: 0,
|
|
2347
|
+
key,
|
|
2348
|
+
kind: "neg",
|
|
2349
|
+
scope,
|
|
2350
|
+
size: encoded.length,
|
|
2351
|
+
src: "factory",
|
|
2352
|
+
storedAt: bornMs,
|
|
2353
|
+
tags,
|
|
2354
|
+
ttl: 0,
|
|
2355
|
+
v: 3,
|
|
2356
|
+
val: null,
|
|
2357
|
+
codecId: codec.id
|
|
2358
|
+
}, inval, consistency)
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
/** Build the INV-26 neg-suppression serve of a still-servable candidate (a 404 that a servable
|
|
2362
|
+
* `val` displaces). ONE constructor for both call sites — the leader-sighted classification and a
|
|
2363
|
+
* blind leader's per-waiter tail — so `value` is always the served candidate's own value.
|
|
2364
|
+
*
|
|
2365
|
+
* It also EMITS, and the emit belongs here rather than at either caller. The design lists `neg_suppressed`
|
|
2366
|
+
* among a suppression's consequences, and the design attaches those consequences to whichever seat renders
|
|
2367
|
+
* the suppression, one event per suppression. This is the serve seat's renderer; the write layer's
|
|
2368
|
+
* guard emits its own at its own site. Emitting from the shared constructor is what makes one
|
|
2369
|
+
* event per suppression structural instead of a convention two call sites have to remember —
|
|
2370
|
+
* before it, the epistemic suppression that the own rationale describes fired no telemetry at all,
|
|
2371
|
+
* so the commonest case was the silent one. */
|
|
2372
|
+
function negSuppressed(config, key, served) {
|
|
2373
|
+
emitTelemetry(config, {
|
|
2374
|
+
key,
|
|
2375
|
+
type: TELEMETRY_EVENTS.neg_suppressed
|
|
2376
|
+
});
|
|
2377
|
+
return {
|
|
2378
|
+
kind: "neg-suppressed",
|
|
2379
|
+
served,
|
|
2380
|
+
value: served.hit.value
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Write the `not_found` neg and package it as the `filled` attempt every
|
|
2385
|
+
* SERVED-fill path already understands: `value:undefined`, the neg's stored tag set, and the write's
|
|
2386
|
+
* own `durable`/`bornMs`. A neg carries no `bornEpoch` on the outcome — its presence is the marker
|
|
2387
|
+
* of a successful VALUE fill, which this is not — so L1 hydration falls back to the caller's
|
|
2388
|
+
* outer floor exactly as the value path intends.
|
|
2389
|
+
*
|
|
2390
|
+
* ONE writer of the neg for two call sites: the leader that read a candidate of its own that
|
|
2391
|
+
* re-validated DEAD (its own disposition, in `classifyFillError`), and any blind waiter's own tail
|
|
2392
|
+
* (`runOwnNotFoundTail`). Both stamp the fill's tags/scope/bornEpoch, so a neg written on either path
|
|
2393
|
+
* is byte-identical to the one a solo caller writes — and both meet the write-layer guard, which is
|
|
2394
|
+
* why a SUPPRESSED write is one of the outcomes this can carry (`writeSuppressed`): the fill still
|
|
2395
|
+
* produced the not-found FACT and still serves it, but nothing was stored and nothing is evicted.
|
|
2396
|
+
*/
|
|
2397
|
+
async function negWriteFilled(config, key, tags, scope, bornEpoch, inval, consistency) {
|
|
2398
|
+
const neg = await writeNegEnvelope(config, key, tags, scope, bornEpoch, inval, consistency);
|
|
2399
|
+
return {
|
|
2400
|
+
bornMs: neg.bornMs,
|
|
2401
|
+
durable: neg.durable,
|
|
2402
|
+
kind: "filled",
|
|
2403
|
+
storedKind: "neg",
|
|
2404
|
+
tags,
|
|
2405
|
+
value: void 0,
|
|
2406
|
+
writeSuppressed: neg.writeSuppressed
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* The factory error-path classification, extracted out of
|
|
2411
|
+
* `fillAndStore` to keep its cognitive complexity down:
|
|
2412
|
+
* - `ctx.skip()` (M10/invariant 24): `{kind:'skipped'}` — nothing stored,
|
|
2413
|
+
* nothing served; no write-back of any kind, not even a neg.
|
|
2414
|
+
* - `not_found` (404, a fact — never masked): with `notFoundTtl` declared and THIS fill's own read
|
|
2415
|
+
* holding a still-servable `val`, suppress the neg-write and serve that val — a neg must never
|
|
2416
|
+
* displace a servable value — with no `servedOnError` (a suppressed neg is not an
|
|
2417
|
+
* error-path serve). This fill's own copy re-validated DEAD ⇒ write the neg and serve it (its own
|
|
2418
|
+
* disposition). This fill read NO copy at all (a blind leader) ⇒ ride the 404 out as a shareable
|
|
2419
|
+
* `not-found` FACT so each waiter runs its own tail against its own read ( `NotFoundFact
|
|
2420
|
+
* Attempt`), the mirror of the retriable branch's fenced-vs-fact split just below. Without
|
|
2421
|
+
* `notFoundTtl`, rethrow.
|
|
2422
|
+
* - retriable (`staleIfError`, default `isRetriableHttp`) with a graced `val`
|
|
2423
|
+
* still servable at serve time: serve it, `servedOnError: true`. A
|
|
2424
|
+
* `kind:'neg'` graced candidate is NEVER offered here — it is never grace/SIE-
|
|
2425
|
+
* served — so a neg-only history rethrows.
|
|
2426
|
+
* - otherwise (non-retriable, or no servable graced copy): rethrow — a failed or
|
|
2427
|
+
* timed-out factory never modifies the stored good value.
|
|
2428
|
+
*/
|
|
2429
|
+
async function classifyFillError(error, config, key, tags, scope, bornEpoch, inval, consistency, options, graced) {
|
|
2430
|
+
if (error instanceof SkipSignalError) return { kind: "skipped" };
|
|
2431
|
+
if (isNotFound(error)) {
|
|
2432
|
+
if (options.notFoundTtl === void 0) throw error;
|
|
2433
|
+
if (graced) {
|
|
2434
|
+
if (await gracedStillServable(inval, consistency, graced.hit.envelope, config.defaults?.onUnavailable, error)) return negSuppressed(config, key, graced);
|
|
2435
|
+
return await negWriteFilled(config, key, tags, scope, bornEpoch, inval, consistency);
|
|
2436
|
+
}
|
|
2437
|
+
return {
|
|
2438
|
+
error,
|
|
2439
|
+
kind: "not-found",
|
|
2440
|
+
neg: await negWriteFilled(config, key, tags, scope, bornEpoch, inval, consistency),
|
|
2441
|
+
scope
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
if (options.grace !== void 0 && staleIfErrorOf(config)(error)) {
|
|
2445
|
+
if (graced) {
|
|
2446
|
+
if (await gracedStillServable(inval, consistency, graced.hit.envelope, config.defaults?.onUnavailable, error)) return {
|
|
2447
|
+
kind: "graced",
|
|
2448
|
+
served: graced,
|
|
2449
|
+
value: graced.hit.value
|
|
2450
|
+
};
|
|
2451
|
+
return {
|
|
2452
|
+
error,
|
|
2453
|
+
kind: "fenced"
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
return {
|
|
2457
|
+
error,
|
|
2458
|
+
kind: "retriable-failed"
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
throw error;
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Fill: run the factory, fence the write-back, then born-monotone
|
|
2465
|
+
* write-through to L2. Returns `{kind:'filled', value}` on success,
|
|
2466
|
+
* `{kind:'fenced'}` when the fill was fenced by a hard purge visible at write-back
|
|
2467
|
+
* (never store or serve a fenced value), `{kind:'skipped'}` on
|
|
2468
|
+
* `ctx.skip()` (M10/invariant 24), or `{kind:'graced', value}` when the
|
|
2469
|
+
* factory threw a retriable error and `graced` — the currently-known
|
|
2470
|
+
* stale-but-servable `val` — is STILL servable once re-validated at serve time.
|
|
2471
|
+
*`bornEpoch` is captured by the caller at FILL START so a
|
|
2472
|
+
* purge that bumps the epoch after the fill began cannot retroactively make the
|
|
2473
|
+
* born value look fresh. Error-path handling is `classifyFillError` above.
|
|
2474
|
+
*/
|
|
2475
|
+
async function fillAndStore(config, options, key, inval, consistency, bornEpoch, graced, ambient) {
|
|
2476
|
+
const codec = codecOf(config);
|
|
2477
|
+
const { l2 } = config;
|
|
2478
|
+
if (!l2) return notImplemented("cache.getOrSet (no l2 configured)", config, options);
|
|
2479
|
+
const declaredUserTags = sortedUserTags(options.tags);
|
|
2480
|
+
const declaredTags = envelopeTags(declaredUserTags, key, config.namespace);
|
|
2481
|
+
const accum = makeTagAccumulator(declaredUserTags);
|
|
2482
|
+
const resolvedScope = resolveScope(options);
|
|
2483
|
+
const scope = resolvedScope.literal;
|
|
2484
|
+
const guard = buildRequestGuard(config, options);
|
|
2485
|
+
const lockHandle = await maybeLock(config, options);
|
|
2486
|
+
const ctx = {
|
|
2487
|
+
dependsOn(tag, dependsOptions) {
|
|
2488
|
+
if (dependsOptions?.l3 === false) notImplemented("FactoryCtx.dependsOn({ l3: false }) — deferred to v0.5", config, options);
|
|
2489
|
+
if (accum.isSettled()) throw new FactorySettledError(key);
|
|
2490
|
+
accum.addMembership(tag);
|
|
2491
|
+
recordDependency(ambient, {
|
|
2492
|
+
scope: fillScopeEvidence(scope),
|
|
2493
|
+
tags: [tag],
|
|
2494
|
+
...dependsOptions?.l3 === void 0 ? {} : { l3: dependsOptions.l3 }
|
|
2495
|
+
});
|
|
2496
|
+
},
|
|
2497
|
+
fail(err) {
|
|
2498
|
+
if (err === void 0) throw new Error("factory failed (non-retriable)");
|
|
2499
|
+
if (err instanceof Error) throw err;
|
|
2500
|
+
throw new Error("factory failed (non-retriable)", { cause: err });
|
|
2501
|
+
},
|
|
2502
|
+
/** Deferred to v0.4 — explicit grace-reuse from within a fill is not part of this phase. */
|
|
2503
|
+
reuseGraced() {
|
|
2504
|
+
return notImplemented("FactoryCtx.reuseGraced (deferred to v0.4)", config, options);
|
|
2505
|
+
},
|
|
2506
|
+
...guard.request === void 0 ? {} : { request: guard.request },
|
|
2507
|
+
setTags(tags) {
|
|
2508
|
+
if (accum.isSettled()) throw new FactorySettledError(key);
|
|
2509
|
+
accum.author(tags);
|
|
2510
|
+
},
|
|
2511
|
+
/** Deferred to v0.4 — the TTL-override (what it overrides, how it meets grace and
|
|
2512
|
+
* the duration grammar) is a v0.4 design slot, not a silent no-op (spec-surface sweep). */
|
|
2513
|
+
setTtl() {
|
|
2514
|
+
notImplemented("FactoryCtx.setTtl (deferred to v0.4)", config, options);
|
|
2515
|
+
},
|
|
2516
|
+
signal: new AbortController().signal,
|
|
2517
|
+
skip() {
|
|
2518
|
+
throw new SkipSignalError();
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
try {
|
|
2522
|
+
let value;
|
|
2523
|
+
try {
|
|
2524
|
+
value = await options.factory(ctx);
|
|
2525
|
+
} catch (error) {
|
|
2526
|
+
accum.markSettled();
|
|
2527
|
+
return await classifyFillError(error, config, key, declaredTags, scope, bornEpoch, inval, consistency, options, graced);
|
|
2528
|
+
}
|
|
2529
|
+
accum.markSettled();
|
|
2530
|
+
const userTags = accum.settleUserTags();
|
|
2531
|
+
validateUserTags(userTags);
|
|
2532
|
+
const tags = envelopeTags(userTags, key, config.namespace);
|
|
2533
|
+
if (inval && tags.length > 0 && !inval.fenceWriteBack(tags, bornEpoch)) return { kind: "fenced" };
|
|
2534
|
+
const bornMs = config.clock.now();
|
|
2535
|
+
const encoded = codec.encode(value);
|
|
2536
|
+
const fence = lockHandle?.fence;
|
|
2537
|
+
const envelope = {
|
|
2538
|
+
bornEpoch,
|
|
2539
|
+
bornMs,
|
|
2540
|
+
enc: "json",
|
|
2541
|
+
grace: 0,
|
|
2542
|
+
key,
|
|
2543
|
+
kind: "val",
|
|
2544
|
+
scope,
|
|
2545
|
+
size: encoded.length,
|
|
2546
|
+
src: "factory",
|
|
2547
|
+
storedAt: bornMs,
|
|
2548
|
+
tags,
|
|
2549
|
+
ttl: 0,
|
|
2550
|
+
v: 3,
|
|
2551
|
+
val: value,
|
|
2552
|
+
codecId: codec.id,
|
|
2553
|
+
...fence === void 0 ? {} : { fence }
|
|
2554
|
+
};
|
|
2555
|
+
const written = resolvedScope.l1Only || guard.wasTouched() ? {
|
|
2556
|
+
durable: false,
|
|
2557
|
+
writeSuppressed: false
|
|
2558
|
+
} : await bornMonotoneWrite(config, l2, key, encoded, envelope, inval, consistency);
|
|
2559
|
+
return {
|
|
2560
|
+
bornEpoch,
|
|
2561
|
+
bornMs,
|
|
2562
|
+
durable: written.durable,
|
|
2563
|
+
kind: "filled",
|
|
2564
|
+
storedKind: "val",
|
|
2565
|
+
tags,
|
|
2566
|
+
value,
|
|
2567
|
+
writeSuppressed: written.writeSuppressed
|
|
2568
|
+
};
|
|
2569
|
+
} finally {
|
|
2570
|
+
await lockHandle?.release();
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
/** Write-back attempt budget: fence-taught retries. A fill fenced by a
|
|
2574
|
+
* hard purge that landed mid-fill re-mints its `bornEpoch` from the now-advanced verified
|
|
2575
|
+
* high-water and refetches, up to this many attempts before failing typed. */
|
|
2576
|
+
const WRITE_BACK_ATTEMPT_BUDGET = 3;
|
|
2577
|
+
/**
|
|
2578
|
+
* `fillAndStore` fronted by the CHECK-THEN-FETCH strong-miss reorder
|
|
2579
|
+
* and the fence-taught retry. On a
|
|
2580
|
+
* `strong`, registry-backed fill the live un-memoized registry check
|
|
2581
|
+
* (`inval.liveCheckForFill`) runs over the fill's tags BEFORE the factory; the fill's
|
|
2582
|
+
* `bornEpoch` is raised to the now-current verified high-water, so the born value
|
|
2583
|
+
* postdates every purge that check observed — hard OR soft. A strong read
|
|
2584
|
+
* therefore never serves a stale-born or hard-dead value, and two strong reads of an
|
|
2585
|
+
* unmutated key agree.
|
|
2586
|
+
*
|
|
2587
|
+
* If the LOCAL write-back fence (`fenceWriteBack`) fences the fill — a hard purge
|
|
2588
|
+
* landed DURING the factory and was delivered/verified into local knowledge — the fill
|
|
2589
|
+
* RE-MINTS `bornEpoch` from the now-advanced verified high-water (`localBornEpoch`,
|
|
2590
|
+
* which a resync or this call's own live check has caught up between attempts, per
|
|
2591
|
+
* `#recordChecked`) and REFETCHES: the honest post-acceptance fetch the purge demanded,
|
|
2592
|
+
* which borns past it and passes the fence ( observations commission later
|
|
2593
|
+
* fetches, they never bless the earlier one). Bounded by the attempt budget; an
|
|
2594
|
+
* attempt whose re-mint cannot advance the floor gives up (the fence is not from an
|
|
2595
|
+
* observable purge). Exhaustion returns `fenced` — `getOrSet` throws `FencedError`,
|
|
2596
|
+
* `getOrSetEntry` misses. `eventual`/registry-absent/tags-empty fills skip
|
|
2597
|
+
* the pre-fetch check but share the retry.
|
|
2598
|
+
*/
|
|
2599
|
+
async function fillAndStoreChecked(config, options, key, inval, consistency, bornEpoch, graced, ambient) {
|
|
2600
|
+
const tags = inval ? envelopeTags(sortedUserTags(options.tags), key, config.namespace) : [];
|
|
2601
|
+
let bornEpochForFill = bornEpoch;
|
|
2602
|
+
if (consistency === "strong" && inval && tags.length > 0) {
|
|
2603
|
+
const checkedThrough = await inval.liveCheckForFill(tags, config.defaults?.onUnavailable);
|
|
2604
|
+
bornEpochForFill = Math.max(bornEpochForFill, checkedThrough);
|
|
2605
|
+
}
|
|
2606
|
+
for (let attempt = 0; attempt < WRITE_BACK_ATTEMPT_BUDGET; attempt++) {
|
|
2607
|
+
const result = await fillAndStore(config, options, key, inval, consistency, bornEpochForFill, graced, ambient);
|
|
2608
|
+
if (result.kind !== "fenced" || !inval || tags.length === 0) return result;
|
|
2609
|
+
const reMinted = Math.max(bornEpochForFill, inval.localBornEpoch());
|
|
2610
|
+
if (reMinted === bornEpochForFill) return result;
|
|
2611
|
+
bornEpochForFill = reMinted;
|
|
2612
|
+
}
|
|
2613
|
+
return { kind: "fenced" };
|
|
2614
|
+
}
|
|
2615
|
+
/** A fresh, empty singleflight registry for one isolate. */
|
|
2616
|
+
function newSingleflight() {
|
|
2617
|
+
return {
|
|
2618
|
+
byIdentity: /* @__PURE__ */ new Map(),
|
|
2619
|
+
byKey: /* @__PURE__ */ new Map()
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* The singleflight JOIN IDENTITY ( strict option-compatibility, step
|
|
2624
|
+
* the canonical key plus every structurally-relevant option a
|
|
2625
|
+
* waiter would otherwise silently miss. Two concurrent calls coalesce onto ONE
|
|
2626
|
+
* factory invocation iff their join identities are byte-identical strings.
|
|
2627
|
+
*
|
|
2628
|
+
* - `key`: the already-canonicalized (ns-versioned, scoped) key — two calls
|
|
2629
|
+
* for different keys must never coalesce.
|
|
2630
|
+
* - `consistency`: a `strong` request never joins a non-strong (`eventual`)
|
|
2631
|
+
* fill — an eventual waiter MAY join a strong fill (strictly safer), so the
|
|
2632
|
+
* identity is keyed on the REQUESTING call's own consistency, not some
|
|
2633
|
+
* merged value; a `strong` caller's identity differs from an `eventual`
|
|
2634
|
+
* caller's, so they never hash equal and each strong call always runs its
|
|
2635
|
+
* own fill, while two `eventual` calls (or two `strong` calls) still share.
|
|
2636
|
+
* - `apiForm`: `getOrSet` vs `getOrSetEntry` — a plain-`T` waiter must
|
|
2637
|
+
* never join an entry-form fill's ctx (skip-capability differs, M10).
|
|
2638
|
+
* - structural options (GPT note): tags (sorted+deduped, so order never
|
|
2639
|
+
* matters), ttl, grace, scope, notFoundTtl, codec id — a mismatch here means
|
|
2640
|
+
* a waiter could receive an envelope missing its own declared dependency
|
|
2641
|
+
* tags, the exact stringly-typed no-op class this library exists to kill.
|
|
2642
|
+
*
|
|
2643
|
+
* `dev` mismatch handling (throw vs a separate fill + telemetry event) belongs
|
|
2644
|
+
* to a later cluster; this cluster only builds the identity and coalesces
|
|
2645
|
+
* exact matches — a mismatch here simply produces a different map key, so it
|
|
2646
|
+
* already runs its own separate fill (the safe default) without yet emitting
|
|
2647
|
+
* `singleflight_option_mismatch` or dev-throwing.
|
|
2648
|
+
*/
|
|
2649
|
+
function joinIdentity(key, consistency, apiForm, codec, options) {
|
|
2650
|
+
const userTags = sortedUserTags(options.tags);
|
|
2651
|
+
return JSON.stringify({
|
|
2652
|
+
apiForm,
|
|
2653
|
+
codecId: codec.id,
|
|
2654
|
+
consistency,
|
|
2655
|
+
grace: options.grace ?? null,
|
|
2656
|
+
key,
|
|
2657
|
+
notFoundTtl: options.notFoundTtl ?? null,
|
|
2658
|
+
scope: scopeLiteral(options),
|
|
2659
|
+
tags: userTags,
|
|
2660
|
+
ttl: options.ttl ?? null
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
/** A structural options mismatch between concurrent same-key fills.
|
|
2664
|
+
* The message points at the fix (declare compatible options); dev throws it. */
|
|
2665
|
+
var SingleflightOptionMismatchError = class extends Error {
|
|
2666
|
+
constructor(userKey) {
|
|
2667
|
+
super(`singleflight option mismatch for key ${JSON.stringify(userKey)}: concurrent calls declared incompatible options (tags/ttl/grace/scope/notFoundTtl/codec). Make the options identical, or serialize the calls.`);
|
|
2668
|
+
this.name = "SingleflightOptionMismatchError";
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
/**
|
|
2672
|
+
* Detect + handle a singleflight OPTION MISMATCH: another fill for
|
|
2673
|
+
* the SAME canonical key is already in flight under a DIFFERENT join identity.
|
|
2674
|
+
* `dev:true` throws (a caller bug surfaced loudly); prod runs a SEPARATE fill and
|
|
2675
|
+
* emits `singleflight_option_mismatch` — carrying the colliding key + the
|
|
2676
|
+
* declared tags (hosted sinks salt every field, INV-15). A matching-identity
|
|
2677
|
+
* joiner never reaches here (it joined in `singleflight` before this ran).
|
|
2678
|
+
*/
|
|
2679
|
+
function handleOptionMismatch(map, key, identity, config, options) {
|
|
2680
|
+
const identities = map.byKey.get(key);
|
|
2681
|
+
if (identities === void 0) return;
|
|
2682
|
+
let incumbent;
|
|
2683
|
+
for (const [inFlight, record] of identities) if (inFlight !== identity) {
|
|
2684
|
+
incumbent = record;
|
|
2685
|
+
break;
|
|
2686
|
+
}
|
|
2687
|
+
if (incumbent === void 0) return;
|
|
2688
|
+
if (config.dev === true) throw new SingleflightOptionMismatchError(options.key);
|
|
2689
|
+
emitTelemetry(config, {
|
|
2690
|
+
key,
|
|
2691
|
+
tags: incumbent.envelopeTags,
|
|
2692
|
+
type: "singleflight_option_mismatch"
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
/** Track this identity as in flight for its canonical key (mismatch detection),
|
|
2696
|
+
* remembering its full stored tag set for a later mismatch event's tag ref. */
|
|
2697
|
+
function trackKeyIdentity(map, key, identity, fillEnvelopeTags) {
|
|
2698
|
+
const identities = map.byKey.get(key) ?? /* @__PURE__ */ new Map();
|
|
2699
|
+
const existing = identities.get(identity);
|
|
2700
|
+
if (existing) existing.count += 1;
|
|
2701
|
+
else identities.set(identity, {
|
|
2702
|
+
count: 1,
|
|
2703
|
+
envelopeTags: fillEnvelopeTags
|
|
2704
|
+
});
|
|
2705
|
+
map.byKey.set(key, identities);
|
|
2706
|
+
}
|
|
2707
|
+
/** Untrack this identity once its fill settles (mismatch detection cleanup). */
|
|
2708
|
+
function untrackKeyIdentity(map, key, identity) {
|
|
2709
|
+
const identities = map.byKey.get(key);
|
|
2710
|
+
if (identities === void 0) return;
|
|
2711
|
+
const record = identities.get(identity);
|
|
2712
|
+
if (record !== void 0) {
|
|
2713
|
+
record.count -= 1;
|
|
2714
|
+
if (record.count <= 0) identities.delete(identity);
|
|
2715
|
+
}
|
|
2716
|
+
if (identities.size === 0) map.byKey.delete(key);
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Run `fill` under in-isolate singleflight (invariant 5): if a fill with
|
|
2720
|
+
* the SAME join identity is already in flight, join it instead of starting a
|
|
2721
|
+
* second factory invocation — both callers resolve to the one result. Otherwise,
|
|
2722
|
+
* a concurrent fill for the same KEY under a DIFFERENT identity is an OPTION
|
|
2723
|
+
* MISMATCH: dev throws, prod runs a separate fill + emits the event
|
|
2724
|
+
* (`handleOptionMismatch`). Then register this call's fill and run it.
|
|
2725
|
+
*
|
|
2726
|
+
* CRITICAL — coalescing under `Promise.all` / same-tick concurrent callers
|
|
2727
|
+
* (not just `fc.scheduler` interleavings): the in-flight promise is registered
|
|
2728
|
+
* into the map SYNCHRONOUSLY, in the same microtask turn as the identity
|
|
2729
|
+
* lookup, with NO `await` between the `byIdentity.get` check and the
|
|
2730
|
+
* registration. `fill()` itself is only invoked (and starts doing real async
|
|
2731
|
+
* work) after registration. Two callers racing through `Promise.all` each
|
|
2732
|
+
* invoke `singleflight` synchronously up to this point; the first to run
|
|
2733
|
+
* (JS's single-threaded execution order gives the two `Promise.all` callbacks
|
|
2734
|
+
* a well-defined sequential dispatch — there is no `await` before either call
|
|
2735
|
+
* reaches here) wins the identity check and registers, so a same-identity
|
|
2736
|
+
* second call synchronously observes the first's entry and joins it, while a
|
|
2737
|
+
* different-identity second call synchronously observes the mismatch. The entry
|
|
2738
|
+
* is removed in a `finally` once the fill settles (success or failure) — a
|
|
2739
|
+
* subsequent, non-concurrent call always runs its own fresh fill.
|
|
2740
|
+
*/
|
|
2741
|
+
async function singleflight(map, key, identity, config, options, fill) {
|
|
2742
|
+
const existing = map.byIdentity.get(identity);
|
|
2743
|
+
if (existing) return await existing;
|
|
2744
|
+
handleOptionMismatch(map, key, identity, config, options);
|
|
2745
|
+
trackKeyIdentity(map, key, identity, envelopeTags(sortedUserTags(options.tags), key, config.namespace));
|
|
2746
|
+
const registration = (async () => {
|
|
2747
|
+
try {
|
|
2748
|
+
return await fill();
|
|
2749
|
+
} finally {
|
|
2750
|
+
map.byIdentity.delete(identity);
|
|
2751
|
+
untrackKeyIdentity(map, key, identity);
|
|
2752
|
+
}
|
|
2753
|
+
})();
|
|
2754
|
+
map.byIdentity.set(identity, registration);
|
|
2755
|
+
return await registration;
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Open the mint for one read, snapshotting the `bornEpoch` floor SYNCHRONOUSLY — the writer's
|
|
2759
|
+
* applied registry epoch at fill start. Taking it at OPEN is the fence-critical part:
|
|
2760
|
+
* a purge that lands via a concurrent bus frame after this call cannot lift the floor, so a fill
|
|
2761
|
+
* that BEGAN before a concurrent purge still fences correctly at write-back (INV-9/INV-12).
|
|
2762
|
+
* Registry-absent ⇒ 0.
|
|
2763
|
+
*/
|
|
2764
|
+
function openBornMint(inval) {
|
|
2765
|
+
const floor = inval ? inval.localBornEpoch() : 0;
|
|
2766
|
+
return { stamp: (checkedThrough) => Math.max(floor, checkedThrough) };
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* What a SERVE can prove about the served value's durability. A serve
|
|
2770
|
+
* reports only what ITS OWN evidence supports:
|
|
2771
|
+
*
|
|
2772
|
+
* - found in L2 ⇒ `true`. The read itself observed the value in the shared tier, so the claim is
|
|
2773
|
+
* backed by something this turn actually did.
|
|
2774
|
+
* - the STORED scope resolves L1-only (a `usr:` principal) ⇒ `false`. Derived from the identity
|
|
2775
|
+
* the entry was stored under, not from this turn: such an entry is never published to L2, so
|
|
2776
|
+
* the kernel KNOWS it is not durable.
|
|
2777
|
+
* - otherwise ⇒ ABSENT. A `pub`/`ten:` entry found in L1, or a scope the surface did not return
|
|
2778
|
+
* readably: the L1 envelope carries no write-back record, and the serve algorithm does
|
|
2779
|
+
* not consult L2 on an L1 hit, and adding a read to populate this field would make an L1 hit
|
|
2780
|
+
* cost what the driver contract forbids. The write-through outcome simply cannot be settled here, and
|
|
2781
|
+
* an unsettled question is reported as such rather than guessed in either direction.
|
|
2782
|
+
*
|
|
2783
|
+
* Returned as a spread FRAGMENT so absence is STRUCTURAL — the property simply not present, never
|
|
2784
|
+
* a sentinel and never an explicit `undefined` (`exactOptionalPropertyTypes` keeps the difference
|
|
2785
|
+
* observable to a consumer that compares against `false`).
|
|
2786
|
+
*/
|
|
2787
|
+
function serveDurability(tier, hit) {
|
|
2788
|
+
if (tier === "l2") return { durable: true };
|
|
2789
|
+
const evidence = hitScopeEvidence(hit);
|
|
2790
|
+
if (evidence.kind === "readable" && storedScopeIsL1Only(evidence.literal)) return { durable: false };
|
|
2791
|
+
return {};
|
|
2792
|
+
}
|
|
2793
|
+
/**
|
|
2794
|
+
* The error-path grace serve: a stored candidate that re-validated as
|
|
2795
|
+
* still-servable is served `stale:true` after the fill failed. `graced` (a retriable failure)
|
|
2796
|
+
* carries `servedOnError`; `neg-suppressed` (a 404, which is not `staleIfError`) does not.
|
|
2797
|
+
*
|
|
2798
|
+
* Every field here reports the CANDIDATE — the entry actually being served — rather than the fill
|
|
2799
|
+
* that failed: its tier is the tier it was read from, its durability what that tier can prove,
|
|
2800
|
+
*its age its own stamp. The earlier shape named `tier:'l2'` and `durable:true` as
|
|
2801
|
+
* constants, for a value that may have been found in L1 and never written to a shared tier at all.
|
|
2802
|
+
*/
|
|
2803
|
+
function gracedServe(attempt, ambient) {
|
|
2804
|
+
const { served } = attempt;
|
|
2805
|
+
recordServedHit(ambient, served.hit);
|
|
2806
|
+
return {
|
|
2807
|
+
...serveDurability(served.tier, served.hit),
|
|
2808
|
+
bornMs: served.hit.envelope.bornMs,
|
|
2809
|
+
skipped: false,
|
|
2810
|
+
stale: true,
|
|
2811
|
+
tier: served.tier,
|
|
2812
|
+
value: attempt.value,
|
|
2813
|
+
...attempt.kind === "graced" ? { servedOnError: true } : {}
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* One caller's error-path tail over a SHARED retriable failure: the fill that
|
|
2818
|
+
* failed produced no serve of its own (a blind leader), so it handed out the classified error as a
|
|
2819
|
+
* FACT. This runs on behalf of a caller against ITS OWN read — a singleflight joiner that read a
|
|
2820
|
+
* copy of its own, or the blind leader itself. Each state resolves to exactly what a SOLO caller in
|
|
2821
|
+
* that same state resolves to, because a caller's outcome must not depend on which caller happened
|
|
2822
|
+
* to win singleflight:
|
|
2823
|
+
*
|
|
2824
|
+
* - this caller read a `val` candidate that STILL re-validates as servable now ( a
|
|
2825
|
+
* hard delete during the fill must not resurrect it): serve it `{stale:true, servedOnError}`
|
|
2826
|
+
* with THIS candidate's own evidence (tier/durable/age), exactly as a leader's own
|
|
2827
|
+
* graced serve would. `staleCandidate` is already a `ServedCandidate`, so the evidence-riding
|
|
2828
|
+
* machinery is reused unchanged.
|
|
2829
|
+
* - this caller read a candidate that re-validates DEAD: it REFUSES the serve and takes the
|
|
2830
|
+
* classified failure — returned as the FENCED OUTCOME, not thrown, so the wrapper applies the
|
|
2831
|
+
* same per-form mapping the solo path gets for this state (the plain form rethrows the typed
|
|
2832
|
+
* error, the entry form resolves a miss). Throwing here would make an entry-form joiner reject
|
|
2833
|
+
* where an identical solo call resolves a miss.
|
|
2834
|
+
* - this caller read no candidate at all: the classified error is rethrown, exactly as a solo
|
|
2835
|
+
* caller with nothing to fall back on rethrows it.
|
|
2836
|
+
*
|
|
2837
|
+
* A joiner's recourse is thus judged against its OWN read, never the leader's disposition — the
|
|
2838
|
+
* leader's blindness is a fact it shares, not a verdict it imposes.
|
|
2839
|
+
*/
|
|
2840
|
+
async function runOwnErrorTail(config, options, inval, consistency, staleCandidate, error, ambient) {
|
|
2841
|
+
if (options.grace !== void 0 && staleCandidate !== void 0) {
|
|
2842
|
+
if (await gracedStillServable(inval, consistency, staleCandidate.hit.envelope, config.defaults?.onUnavailable, error)) return gracedServe({
|
|
2843
|
+
kind: "graced",
|
|
2844
|
+
served: staleCandidate,
|
|
2845
|
+
value: staleCandidate.hit.value
|
|
2846
|
+
}, ambient);
|
|
2847
|
+
return {
|
|
2848
|
+
durable: false,
|
|
2849
|
+
error,
|
|
2850
|
+
skipped: false,
|
|
2851
|
+
stale: false,
|
|
2852
|
+
tier: "miss"
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
throw error;
|
|
2856
|
+
}
|
|
2857
|
+
/** The read tiers in order: L1 then L2. Absent tiers are skipped. */
|
|
2858
|
+
function tiersOf(config) {
|
|
2859
|
+
const tiers = [];
|
|
2860
|
+
if (config.l1) tiers.push([config.l1, "l1"]);
|
|
2861
|
+
if (config.l2) tiers.push([config.l2, "l2"]);
|
|
2862
|
+
return tiers;
|
|
2863
|
+
}
|
|
2864
|
+
/** Write the freshly-filled value into L1 (write-through) — best-effort. The whole fill
|
|
2865
|
+
* outcome rides in, because every field written here belongs to that ONE fill: its `bornEpoch` so
|
|
2866
|
+
* both tiers validate identically ( `fallbackBornEpoch` covers the neg-write outcome, which
|
|
2867
|
+
* stamps none), its `bornMs` so both tiers report the same age ( birth is an event, and a value
|
|
2868
|
+
* has ONE: the stamp is minted at fill and carried here, never re-read off the clock, which would
|
|
2869
|
+
* date the L1 copy to the moment the L2 write finished and make one value two ages), and its STORED
|
|
2870
|
+
* tag set (settle-union) so the two tiers are tag-coherent: a later L1 hit replays the
|
|
2871
|
+
* same purge handles L2 carries.
|
|
2872
|
+
*
|
|
2873
|
+
* A `kind:'neg'` fill whose write LANDED stores nothing here and EVICTS whatever L1 still holds for
|
|
2874
|
+
* the key — two halves of one rule, and neither is sufficient alone. A neg whose write the guard
|
|
2875
|
+
* SUPPRESSED does neither: eviction follows the write's fate (see below).
|
|
2876
|
+
*
|
|
2877
|
+
* It stores nothing because the neg's storage is `writeNegEnvelope`'s single L2 write
|
|
2878
|
+
* — that function is the neg's whole write path, and it deliberately writes one tier. What
|
|
2879
|
+
* this write-through would put in L1 is a `kind:'val'`-stamped copy of the neg's placeholder body:
|
|
2880
|
+
* an envelope claiming to be a VALUE, holding a value no origin produced. Read back, it would
|
|
2881
|
+
* surface that placeholder (the read path's neg rule keys on the stored `kind`, which this write
|
|
2882
|
+
* misstates) and, worse, stand as a grace/stale-if-error candidate — precisely what invariant 7 forbids a
|
|
2883
|
+
* neg from ever being. Today the default codec cannot encode `undefined` to a decodable body, so
|
|
2884
|
+
* such a write lands as an unreadable entry that always misses; that is an accident of one codec,
|
|
2885
|
+
* not a guarantee any other owes us.
|
|
2886
|
+
*
|
|
2887
|
+
* It evicts because L1 may ALREADY hold an OLDER value for this key. A fill runs when the read
|
|
2888
|
+
* found nothing servable — and a tier read that a classified transient failure suppressed is
|
|
2889
|
+
* a plain MISS over an entry that is still sitting there. Refusing the write alone leaves that
|
|
2890
|
+
* entry standing, and it outlives the 404: once the tier reads cleanly again L1 is consulted first
|
|
2891
|
+
* (steps 2/4) and answers with the superseded value, in preference to the neg L2 now holds —
|
|
2892
|
+
* the origin's not-found reverted by a tier that was merely unreadable for a moment. Deleting ends
|
|
2893
|
+
* the turn with L1 holding nothing for the key, so reads fall through to the tier that remembers
|
|
2894
|
+
* the 404 and a served neg still reports the tier and durability its one write proves.
|
|
2895
|
+
*
|
|
2896
|
+
* Whether a neg SHOULD also LIVE in L1 is a storage-policy question the design does not settle — it would
|
|
2897
|
+
* change that reported tier and durability — so this evicts rather than storing a neg envelope,
|
|
2898
|
+
* closing the mislabel and the resurrection without deciding it.
|
|
2899
|
+
*
|
|
2900
|
+
* EVICTION FOLLOWS THE WRITE'S FATE. Both halves above are premised on a neg that
|
|
2901
|
+
* LANDED: the eviction exists to stop a superseded L1 copy outliving a 404 the durable tier now
|
|
2902
|
+
* remembers. A neg the guard suppressed leaves the durable tier remembering no 404 at all — the
|
|
2903
|
+
* verdict the suppression rendered is that the val stands — so deleting that val's L1 copy would
|
|
2904
|
+
* contradict the verdict on the way out, and cost a refill of a value that is still good. A
|
|
2905
|
+
* suppressed write mutates nothing, in either tier. */
|
|
2906
|
+
async function hydrateL1(config, codec, key, options, attempt, fallbackBornEpoch) {
|
|
2907
|
+
const { l1 } = config;
|
|
2908
|
+
if (!l1) return;
|
|
2909
|
+
if (attempt.storedKind === "neg") {
|
|
2910
|
+
if (attempt.writeSuppressed) return;
|
|
2911
|
+
try {
|
|
2912
|
+
await l1.delete(key);
|
|
2913
|
+
} catch {}
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
const { bornMs, tags, value } = attempt;
|
|
2917
|
+
const bornEpoch = attempt.bornEpoch ?? fallbackBornEpoch;
|
|
2918
|
+
try {
|
|
2919
|
+
await l1.set(key, codec.encode(value), { metadata: {
|
|
2920
|
+
bornEpoch,
|
|
2921
|
+
bornMs,
|
|
2922
|
+
codecId: codec.id,
|
|
2923
|
+
enc: "json",
|
|
2924
|
+
kind: "val",
|
|
2925
|
+
scope: scopeLiteral(options),
|
|
2926
|
+
tags,
|
|
2927
|
+
v: 3
|
|
2928
|
+
} });
|
|
2929
|
+
} catch {}
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* The successful foreground fill's serve: record the dependency, hydrate L1, and
|
|
2933
|
+
* report the fill's own outcome.
|
|
2934
|
+
*
|
|
2935
|
+
* This is the one serve that can always answer `durable` (leaves it untouched): it performed
|
|
2936
|
+
* the write-back itself and awaited the result, so it reports a real boolean rather than the absent
|
|
2937
|
+
* arm the read-path serves need.
|
|
2938
|
+
*/
|
|
2939
|
+
async function filledServe(config, codec, key, options, scope, attempt, bornEpoch, ambient) {
|
|
2940
|
+
const storedTags = attempt.tags;
|
|
2941
|
+
recordDependency(ambient, {
|
|
2942
|
+
scope: fillScopeEvidence(scope),
|
|
2943
|
+
tags: storedTags
|
|
2944
|
+
});
|
|
2945
|
+
const filled = attempt.value;
|
|
2946
|
+
await hydrateL1(config, codec, key, options, attempt, bornEpoch);
|
|
2947
|
+
return {
|
|
2948
|
+
durable: attempt.durable,
|
|
2949
|
+
bornMs: attempt.bornMs,
|
|
2950
|
+
skipped: false,
|
|
2951
|
+
stale: false,
|
|
2952
|
+
tier: "origin",
|
|
2953
|
+
value: filled
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
/**
|
|
2957
|
+
* One caller's error-path neg tail over a SHARED `not_found` fact. The fill
|
|
2958
|
+
* read no candidate of its own (a blind leader), so it recorded the fact and handed it out rather
|
|
2959
|
+
* than deciding, from a read that saw nothing, what every waiter should be served. This decides
|
|
2960
|
+
* DISPOSITION ONLY — it records nothing, because the recording already happened once,
|
|
2961
|
+
* inside the fill's window. It runs on behalf of one caller against ITS OWN read (a joiner that read
|
|
2962
|
+
* a copy of its own, or the blind leader itself), so that each caller resolves EXACTLY as a SOLO
|
|
2963
|
+
* caller in that same state resolves — an outcome must not depend on which caller won singleflight:
|
|
2964
|
+
*
|
|
2965
|
+
* - this caller read a `val` candidate that STILL re-validates as servable now: the guard serves
|
|
2966
|
+
* that val `{stale:true}` with THIS candidate's own evidence (tier/durable/age), and — a
|
|
2967
|
+
* 404 not being `staleIfError` — WITHOUT `servedOnError`, exactly as a solo sighted caller does.
|
|
2968
|
+
* The gate matches the solo path's: this arm is offered only when `grace` was declared (the solo
|
|
2969
|
+
* fill receives its `staleCandidate` only then).
|
|
2970
|
+
* - otherwise (this caller read no candidate, or its own copy re-validated dead): it takes the
|
|
2971
|
+
* RECORDED neg as its outcome, served `{tier:'origin'}` with that write's own evidence — the same
|
|
2972
|
+
* `filled` serve a solo blind caller performs over its own neg-write.
|
|
2973
|
+
*
|
|
2974
|
+
* A joiner's disposition is thus judged against its OWN read — its read is the existence proof the
|
|
2975
|
+
* blind leader lacked — while the fact itself was recorded exactly once, by the fill that found it.
|
|
2976
|
+
*/
|
|
2977
|
+
async function runOwnNotFoundTail(config, options, inval, consistency, key, staleCandidate, fact, outerBornEpoch, ambient) {
|
|
2978
|
+
if (options.grace !== void 0 && staleCandidate !== void 0 && await gracedStillServable(inval, consistency, staleCandidate.hit.envelope, config.defaults?.onUnavailable, fact.error)) return gracedServe(negSuppressed(config, key, staleCandidate), ambient);
|
|
2979
|
+
return await filledServe(config, codecOf(config), key, options, fact.scope, fact.neg, outerBornEpoch, ambient);
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* A STALE hit → the SWR path (SWR). The OBSERVABLE contract, independent of `grace`:
|
|
2983
|
+
* **this response always serves the stale value with `stale:true`** — never the
|
|
2984
|
+
* refreshed/fresh result. A refresh runs synchronously (the full background /
|
|
2985
|
+
* `waitUntil` / REFRESH_QUEUE machinery of step 6 stays deferred; a synchronous
|
|
2986
|
+
* refresh is a placeholder that produces the same observable serve), and its result
|
|
2987
|
+
* is written through for the NEXT read only. It never changes the value THIS call
|
|
2988
|
+
* returns, and its failure never propagates to the caller — SWR's promise is a fast
|
|
2989
|
+
* stale response.
|
|
2990
|
+
*
|
|
2991
|
+
* `servedOnError:true` iff the refresh failed RETRIABLY (per `staleIfError`)
|
|
2992
|
+
* within a grace window (`grace` declared on the call) AND the stale copy is still
|
|
2993
|
+
* servable after serve-time re-validation — the `graced` outcome. A
|
|
2994
|
+
* successful refresh, a fenced/non-servable refresh, a suppressed 404 ( a neg
|
|
2995
|
+
* never displaces a servable val, and a 404 is not `staleIfError`), and a
|
|
2996
|
+
* non-retriable refresh failure all serve the stale value WITHOUT `servedOnError`.
|
|
2997
|
+
* `grace` only affects whether the error path yields `servedOnError`, never whether
|
|
2998
|
+
* the stale value is served (it always is).
|
|
2999
|
+
*/
|
|
3000
|
+
async function refreshOrGrace(config, options, key, inval, consistency, tier, hit, bornEpoch) {
|
|
3001
|
+
const codec = codecOf(config);
|
|
3002
|
+
let graced = false;
|
|
3003
|
+
try {
|
|
3004
|
+
const attempt = await fillAndStore(config, options, key, inval, consistency, bornEpoch, {
|
|
3005
|
+
hit,
|
|
3006
|
+
tier
|
|
3007
|
+
});
|
|
3008
|
+
if (attempt.kind === "filled") await hydrateL1(config, codec, key, options, attempt, bornEpoch);
|
|
3009
|
+
graced = attempt.kind === "graced" && options.grace !== void 0;
|
|
3010
|
+
} catch {}
|
|
3011
|
+
return {
|
|
3012
|
+
...serveDurability(tier, hit),
|
|
3013
|
+
bornMs: hit.envelope.bornMs,
|
|
3014
|
+
skipped: false,
|
|
3015
|
+
stale: true,
|
|
3016
|
+
tier,
|
|
3017
|
+
value: hit.value,
|
|
3018
|
+
...graced ? { servedOnError: true } : {}
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* The core getOrSet path: L1 → L2 → foreground fill with the fence and
|
|
3023
|
+
* Born-monotone write-back. Serves fresh directly; serves a soft-stale value
|
|
3024
|
+
* with `stale:true`; refills on miss/dead/unknown. Shared by `getOrSet` (plain) and
|
|
3025
|
+
* `getOrSetEntry`.
|
|
3026
|
+
*/
|
|
3027
|
+
async function runGetOrSet(config, options, inval, nsVersion, apiForm, inFlight, ambient) {
|
|
3028
|
+
const codec = codecOf(config);
|
|
3029
|
+
const scope = scopeLiteral(options);
|
|
3030
|
+
const key = canonicalKey(nsVersion.current, config.namespace, scope, options.key);
|
|
3031
|
+
const consistency = consistencyOf(config, options);
|
|
3032
|
+
const mint = openBornMint(inval);
|
|
3033
|
+
let checkedThrough = 0;
|
|
3034
|
+
let staleCandidate;
|
|
3035
|
+
for (const [store, tier] of tiersOf(config)) {
|
|
3036
|
+
const read = await readTier(store, codec, config, inval, consistency, key);
|
|
3037
|
+
checkedThrough = Math.max(checkedThrough, read.checkedThrough);
|
|
3038
|
+
if (read.hit) {
|
|
3039
|
+
if (read.hit.stale && consistency !== "strong") {
|
|
3040
|
+
recordServedHit(ambient, read.hit);
|
|
3041
|
+
return await refreshOrGrace(config, options, key, inval, consistency, tier, read.hit, mint.stamp(checkedThrough));
|
|
3042
|
+
}
|
|
3043
|
+
if (!read.hit.stale) {
|
|
3044
|
+
recordServedHit(ambient, read.hit);
|
|
3045
|
+
return {
|
|
3046
|
+
...serveDurability(tier, read.hit),
|
|
3047
|
+
bornMs: read.hit.envelope.bornMs,
|
|
3048
|
+
skipped: false,
|
|
3049
|
+
stale: false,
|
|
3050
|
+
tier,
|
|
3051
|
+
value: read.hit.value
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
const candidate = read.hit?.stale === true ? read.hit : read.staleCandidate;
|
|
3056
|
+
if (candidate !== void 0) staleCandidate = {
|
|
3057
|
+
hit: candidate,
|
|
3058
|
+
tier
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
const bornEpoch = mint.stamp(checkedThrough);
|
|
3062
|
+
const attempt = await singleflight(inFlight, key, joinIdentity(key, consistency, apiForm, codec, options), config, options, async () => await fillAndStoreChecked(config, options, key, inval, consistency, bornEpoch, options.grace === void 0 ? void 0 : staleCandidate, ambient));
|
|
3063
|
+
if (attempt.kind === "filled") return await filledServe(config, codec, key, options, scope, attempt, bornEpoch, ambient);
|
|
3064
|
+
if (attempt.kind === "graced" || attempt.kind === "neg-suppressed") return gracedServe(attempt, ambient);
|
|
3065
|
+
if (attempt.kind === "retriable-failed") return await runOwnErrorTail(config, options, inval, consistency, staleCandidate, attempt.error, ambient);
|
|
3066
|
+
if (attempt.kind === "not-found") return await runOwnNotFoundTail(config, options, inval, consistency, key, staleCandidate, attempt, bornEpoch, ambient);
|
|
3067
|
+
if (attempt.kind === "skipped") return {
|
|
3068
|
+
durable: false,
|
|
3069
|
+
skipped: true,
|
|
3070
|
+
stale: false,
|
|
3071
|
+
tier: "miss"
|
|
3072
|
+
};
|
|
3073
|
+
return {
|
|
3074
|
+
durable: false,
|
|
3075
|
+
skipped: false,
|
|
3076
|
+
stale: false,
|
|
3077
|
+
tier: "miss",
|
|
3078
|
+
...attempt.error === void 0 ? {} : { error: attempt.error }
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
/** Whether a raw regex-captured unit is a known `DurationUnit`. */
|
|
3082
|
+
function isDurationUnit(unit) {
|
|
3083
|
+
return unit !== void 0 && Object.hasOwn(DURATION_UNIT_MS, unit);
|
|
3084
|
+
}
|
|
3085
|
+
/** Convert a `Duration` to milliseconds. The full `Duration` grammar is
|
|
3086
|
+
* deferred; the retention lease and the reader's liveness heartbeat cadence need
|
|
3087
|
+
* only a coarse ms value, so this parses the `<number><unit>` shape. */
|
|
3088
|
+
function durationMs(duration) {
|
|
3089
|
+
const match = /^(?<value>[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?<unit>ms|s|m|h|d)$/u.exec(duration);
|
|
3090
|
+
const value = Number(match?.groups?.value ?? "0");
|
|
3091
|
+
const rawUnit = match?.groups?.unit;
|
|
3092
|
+
return value * (isDurationUnit(rawUnit) ? DURATION_UNIT_MS[rawUnit] : 1);
|
|
3093
|
+
}
|
|
3094
|
+
/** Build the per-instance invalidation core when a registry + bus are wired.
|
|
3095
|
+
*Registry-absent instances (codec.test.ts) get `undefined` and keep the
|
|
3096
|
+
* always-fresh codec path.
|
|
3097
|
+
*
|
|
3098
|
+
* A registry+bus instance REQUIRES an L2 replication mirror: the reader's only safe way to
|
|
3099
|
+
* clear a bus gap is to resync off the `state`/`delta` mirror there. Constructing
|
|
3100
|
+
* it without one is rejected LOUDLY here (dead-branch ruling) — the reader has no
|
|
3101
|
+
* honest job with no store. This throw is dead today (a registry+bus config is only ever wired
|
|
3102
|
+
* with L2), and it is guarded precisely because that reachability would change silently if L2
|
|
3103
|
+
* became optional: the old handling — clearing suspicion without resyncing — was a fail-OPEN
|
|
3104
|
+
* hole. Fail loud instead of silently reopening it. */
|
|
3105
|
+
function buildInvalidation(config) {
|
|
3106
|
+
const { registry, bus, l2 } = config;
|
|
3107
|
+
if (!registry || !bus) return;
|
|
3108
|
+
if (!l2) throw new Error("createCache: a registry+bus instance requires an l2 store (the §4.2 replication mirror the reader resyncs off — E20)");
|
|
3109
|
+
const emit = (event) => {
|
|
3110
|
+
emitTelemetry(config, event);
|
|
3111
|
+
};
|
|
3112
|
+
const heartbeat = config.defaults?.heartbeatInterval;
|
|
3113
|
+
return new Invalidation(registry, l2, bus, heartbeat === void 0 ? void 0 : durationMs(heartbeat), emit);
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* A facade → the instance it fronts. THE ONE PLACE an alternate identity for one cache is recorded.
|
|
3117
|
+
*
|
|
3118
|
+
* WHY THIS EXISTS, AND WHY IT IS ONE MECHANISM RATHER THAN A HABIT. Several internal seams hang
|
|
3119
|
+
* per-instance state off a `Cache` through a module-private WeakMap, keyed by the object itself. A
|
|
3120
|
+
* composition that returns a WRAPPER around a `createCache` instance — `createWorkersCache` does, to
|
|
3121
|
+
* hang its freshness carrier on the read path — hands callers an object those maps have never heard
|
|
3122
|
+
* of. The address a seam is keyed by is therefore the identity CALLERS HOLD, and a facade that
|
|
3123
|
+
* re-registers one seam and forgets another does not fail loudly: it produces a seam that silently
|
|
3124
|
+
* answers `undefined` forever. Registering per-seam is the fix that does not generalize — the next
|
|
3125
|
+
* seam, or the next facade, reopens it.
|
|
3126
|
+
*
|
|
3127
|
+
* So identity is aliased ONCE, here, and every seam lookup resolves through `resolveCacheIdentity`
|
|
3128
|
+
* BEFORE keying. A new seam is covered because it resolves like the others; a new facade is covered
|
|
3129
|
+
* because it aliases like the others. Neither has to remember the list.
|
|
3130
|
+
*/
|
|
3131
|
+
const identityAliases = /* @__PURE__ */ new WeakMap();
|
|
3132
|
+
/**
|
|
3133
|
+
* The instance `cache` stands for — itself, unless it is a facade over another.
|
|
3134
|
+
*
|
|
3135
|
+
* SAME-PACKAGE internal seam, NOT public API (not re-exported from `index.ts`). Every WeakMap keyed
|
|
3136
|
+
* by a `Cache` resolves through this before keying, so a facade and the instance it fronts address
|
|
3137
|
+
* ONE slot. A cache that is nobody's facade resolves to itself, which is every raw `createCache`
|
|
3138
|
+
* instance and therefore the overwhelmingly common path.
|
|
3139
|
+
*
|
|
3140
|
+
* ONE STEP IS ENOUGH because `aliasCacheIdentity` flattens the chain at registration.
|
|
3141
|
+
*/
|
|
3142
|
+
function resolveCacheIdentity(cache) {
|
|
3143
|
+
return identityAliases.get(cache) ?? cache;
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Record that `facade` is an alternate identity for `inner` — called ONCE, by whatever
|
|
3147
|
+
* composition returns a wrapper, at construction. SAME-PACKAGE internal seam, NOT public API.
|
|
3148
|
+
*
|
|
3149
|
+
* THE TARGET IS RESOLVED AS IT IS STORED, so a facade over a facade lands on the instance at the
|
|
3150
|
+
* bottom rather than on the middle link. That keeps `resolveCacheIdentity` a single lookup on the
|
|
3151
|
+
* hot path while making a stack of facades correct BY CONSTRUCTION rather than by anyone noticing
|
|
3152
|
+
* that it is a stack — the chain is flattened once, here, where it is built.
|
|
3153
|
+
*/
|
|
3154
|
+
function aliasCacheIdentity(facade, inner) {
|
|
3155
|
+
identityAliases.set(facade, resolveCacheIdentity(inner));
|
|
3156
|
+
}
|
|
3157
|
+
/** Cache instance → its replication poller. Module-private internal state: the
|
|
3158
|
+
* poller is attached WITHOUT touching the public `Cache` surface. the request-piggyback driver
|
|
3159
|
+
* (the RR adapter) is the READER of this map — the seam arrived with its consumer, so the map is no
|
|
3160
|
+
* longer write-only and the `sonarjs/no-unused-collection` suppression that carried the named "C4"
|
|
3161
|
+
* expiry is DELETED (its premise is gone; a suppression outliving its premise is a lie). */
|
|
3162
|
+
const replicationPollers = /* @__PURE__ */ new WeakMap();
|
|
3163
|
+
/**
|
|
3164
|
+
* The replication poller attached to `cache`, or `undefined` for a registry-absent instance (which
|
|
3165
|
+
* has no reader to drive). SAME-PACKAGE internal seam, NOT public API: it is deliberately not
|
|
3166
|
+
* re-exported from `index.ts`, and its ONE consumer is the request-piggyback tick driver in the
|
|
3167
|
+
* RR adapter (`src/adapters/react-router/poll-driver.ts`), which owns the staleness gate + the
|
|
3168
|
+
* failure swallow. Kept as narrow as that consumer needs: a `Cache` in, its poller out — no other
|
|
3169
|
+
* kernel internal crosses the boundary.
|
|
3170
|
+
*
|
|
3171
|
+
* Resolves identity first, so a facade over an instance finds that instance's poller. Without that
|
|
3172
|
+
* an RR app on a wrapping factory would lose its replication poll SILENTLY — `tickIfStale` reads a
|
|
3173
|
+
* missing poller as "registry-absent, nothing to drive" and returns.
|
|
3174
|
+
*/
|
|
3175
|
+
function replicationPollerOf(cache) {
|
|
3176
|
+
return replicationPollers.get(resolveCacheIdentity(cache));
|
|
3177
|
+
}
|
|
3178
|
+
/** A `RenderCollector` → its internal scope-bearing writer. Module-private: the
|
|
3179
|
+
* public collector interface cannot carry the scope half (a public `dependsOn` has no entry
|
|
3180
|
+
* behind it), so the kernel's automatic hit/fill recording reaches it through this map
|
|
3181
|
+
* instead of through a widened public type or a cast. */
|
|
3182
|
+
const collectorStates = /* @__PURE__ */ new WeakMap();
|
|
3183
|
+
/**
|
|
3184
|
+
* An `L3Emission` → how many dependencies the render had consumed when that emission was
|
|
3185
|
+
* committed — hits, fills, and explicit `dependsOn` declarations. Same
|
|
3186
|
+
* module-private-WeakMap seam as `collectorStates` above, and for a kindred reason: the count is
|
|
3187
|
+
* a fact the ADAPTER's header decision needs and no caller of the public type does, and
|
|
3188
|
+
* `L3Emission` is a spec-verbatim shape — so the count rides the private channel rather
|
|
3189
|
+
* than widening a published surface with a field only this package reads.
|
|
3190
|
+
*/
|
|
3191
|
+
const emissionCounts = /* @__PURE__ */ new WeakMap();
|
|
3192
|
+
/** Bank an emission's consumed-dependency count on the seam and return the emission (the ONE
|
|
3193
|
+
* writer of `emissionCounts` — the count is banked with the emission it belongs to, so the two
|
|
3194
|
+
* can never drift apart). */
|
|
3195
|
+
function withManagedDependencies(emission, count) {
|
|
3196
|
+
emissionCounts.set(emission, count);
|
|
3197
|
+
return emission;
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* How many dependencies the render had consumed when `emission` was committed.
|
|
3201
|
+
* SAME-PACKAGE internal seam, NOT public API: deliberately not re-exported from `index.ts`, and
|
|
3202
|
+
* its ONE consumer is the React-Router middleware's header decision, where ZERO is the adapter's
|
|
3203
|
+
* JURISDICTION FLOOR — a render that consumed no managed dependency gets a safe DEFAULT and
|
|
3204
|
+
* nothing more (`private` filled in only where the app wrote no `Cache-Control`, an app-set
|
|
3205
|
+
* `Cache-Tag` left where it is, nothing overridden). It is what separates that floor from an
|
|
3206
|
+
* eligible render whose dependencies simply emit no tag.
|
|
3207
|
+
*
|
|
3208
|
+
* An emission this kernel never minted reads as 0, i.e. as the floor — the direction in which the
|
|
3209
|
+
* adapter asserts least, which is the safe one for an unrecognized input.
|
|
3210
|
+
*/
|
|
3211
|
+
function managedDependenciesOf(emission) {
|
|
3212
|
+
return emissionCounts.get(emission) ?? 0;
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* A `Cache` → the accessor that yields the AMBIENT render collector for the request in
|
|
3216
|
+
* flight. Same module-private-WeakMap seam as `replicationPollers` above, and the
|
|
3217
|
+
* same reason: the collector must reach the kernel WITHOUT widening the public `Cache`
|
|
3218
|
+
* surface and WITHOUT the kernel importing `node:async_hooks` (the request frame is the
|
|
3219
|
+
* adapter's — a Workers deployment without the ALS compat flag must still be able to import
|
|
3220
|
+
* this module).
|
|
3221
|
+
*
|
|
3222
|
+
* Keyed by RESOLVED identity, like every other instance-keyed seam here: an adapter binds against
|
|
3223
|
+
* the cache the app holds, and the kernel looks up against the instance running the read. Those are
|
|
3224
|
+
* different objects whenever a facade is in play, and this map is where they have to meet — a
|
|
3225
|
+
* privacy gate that reads an empty collector concludes the render consumed nothing and asserts
|
|
3226
|
+
* nothing, which is the loudest possible silence.
|
|
3227
|
+
*/
|
|
3228
|
+
const renderCollectors = /* @__PURE__ */ new WeakMap();
|
|
3229
|
+
/**
|
|
3230
|
+
* Bind the adapter's ambient-collector accessor to `cache`. SAME-PACKAGE internal
|
|
3231
|
+
* seam, NOT public API: deliberately not re-exported from `index.ts`, and its ONE consumer is
|
|
3232
|
+
* the React-Router middleware (`src/adapters/react-router/middleware.ts`), which owns the
|
|
3233
|
+
* `AsyncLocalStorage` request frame the accessor reads.
|
|
3234
|
+
*
|
|
3235
|
+
* The consequence is the safety property, not a limitation: a cache used WITHOUT the adapter
|
|
3236
|
+
* has no bound accessor, so the kernel records nothing, no `commitHeaders` ever runs, and no
|
|
3237
|
+
* header is ever emitted. Nothing to leak, by construction — it fails closed.
|
|
3238
|
+
*/
|
|
3239
|
+
function bindRenderCollector(cache, accessor) {
|
|
3240
|
+
renderCollectors.set(resolveCacheIdentity(cache), accessor);
|
|
3241
|
+
}
|
|
3242
|
+
/** The ambient collector's internal writer for `cache`, or `undefined` when the adapter bound
|
|
3243
|
+
* none / no request frame is open. */
|
|
3244
|
+
function ambientCollectorOf(cache) {
|
|
3245
|
+
const collector = renderCollectors.get(resolveCacheIdentity(cache))?.();
|
|
3246
|
+
return collector === void 0 ? void 0 : collectorStates.get(collector);
|
|
3247
|
+
}
|
|
3248
|
+
/** Build the tick-driven replication poll driver alongside the reader, under the
|
|
3249
|
+
* SAME condition (registry+bus+L2, already validated by `buildInvalidation`). It is CONSTRUCTED
|
|
3250
|
+
* here — never a knip entry: the construction makes `replication-poller.ts` reachable, so deleting
|
|
3251
|
+
* this wiring reds knip, which is the point (the dead-code gate becomes the wiring's tooth). The
|
|
3252
|
+
* poller runs no timer of its own; C4 drives it (deterministic ticks drive it in tests). The
|
|
3253
|
+
* `maxSyncLag` base cadence defaults to the Workers profile (60s) unless config overrides. */
|
|
3254
|
+
function buildReplicationPoller(config, reader) {
|
|
3255
|
+
const { l2, registry } = config;
|
|
3256
|
+
if (!l2 || !registry) throw new Error("createCache: the replication poller requires the same registry+l2 as the reader (§4.2)");
|
|
3257
|
+
const maxSyncLag = config.defaults?.maxSyncLag;
|
|
3258
|
+
const options = maxSyncLag === void 0 ? {} : { pollBaseMs: durationMs(maxSyncLag) };
|
|
3259
|
+
return new ReplicationPoller(reader, l2, mirrorKeys(registry.regId), config.rng, options);
|
|
3260
|
+
}
|
|
3261
|
+
/**
|
|
3262
|
+
* Register this instance's retention lease — DEFERRED TO FIRST USE, best-effort
|
|
3263
|
+
* and fire-and-forget; a registration failure never blocks anything (the GC horizon extends under
|
|
3264
|
+
* uncertainty).
|
|
3265
|
+
*
|
|
3266
|
+
* IT USED TO FIRE FROM `createCache`, AND THAT IS AN RPC AT CONSTRUCTION. Construction performs no
|
|
3267
|
+
* I/O: at module scope the platform refuses the call outright, and the bare `try{}catch{}` around it
|
|
3268
|
+
* — swallowing by design, which is right for a best-effort lease — meant the refusal was invisible.
|
|
3269
|
+
* A lease that never registered and never said so is the worst of both: the Coordinator's GC horizon
|
|
3270
|
+
* is not extended by an instance it never heard from, and nothing anywhere reports it.
|
|
3271
|
+
*
|
|
3272
|
+
* Returns a function the first read calls. It runs AT MOST ONCE per instance and never blocks: the
|
|
3273
|
+
* lease is a hint to the GC horizon, not a precondition for reading, so making a read wait on it
|
|
3274
|
+
* would trade a correctness-neutral registration for real latency on the request that happened to be
|
|
3275
|
+
* first.
|
|
3276
|
+
*/
|
|
3277
|
+
function retentionRegistrar(config) {
|
|
3278
|
+
const { registry } = config;
|
|
3279
|
+
const retention = config.defaults?.maxEntryRetention;
|
|
3280
|
+
if (!registry || retention === void 0) return () => {};
|
|
3281
|
+
let registered = false;
|
|
3282
|
+
return () => {
|
|
3283
|
+
if (registered) return;
|
|
3284
|
+
registered = true;
|
|
3285
|
+
(async () => {
|
|
3286
|
+
try {
|
|
3287
|
+
await registry.registerRetention(durationMs(retention));
|
|
3288
|
+
} catch {}
|
|
3289
|
+
})();
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
/** Resolve a purge selector to its registry tag(s). A key selector maps to
|
|
3293
|
+
* the reserved `__k` tag digest of that key's canonical form — the
|
|
3294
|
+
* IDENTICAL digest `expire`/`delete` by tag would never see, since the literal
|
|
3295
|
+
* `__k` form is internal and only `expire({key})`/`delete({key})` may target
|
|
3296
|
+
* it. No `scope` and no request on the key selector ⇒ the same `pub` default
|
|
3297
|
+
* a scope-less, contextless `getOrSet` call stamps, so
|
|
3298
|
+
* `expire({key: options.key})` targets exactly the tag a matching contextless
|
|
3299
|
+
* `getOrSet({key: options.key})` carries. */
|
|
3300
|
+
function selectorTags(selector, config, nsVersion) {
|
|
3301
|
+
if ("tag" in selector) return [selector.tag];
|
|
3302
|
+
return [keyTag(canonicalKey(nsVersion.current, config.namespace, scopeLiteral(), selector.key))];
|
|
3303
|
+
}
|
|
3304
|
+
/** Wrap a registry ack into a `PurgeResult`. `flushed`/`edgePurged`
|
|
3305
|
+
* resolve minimally here — the registry mutation already durably accepted + emitted
|
|
3306
|
+
* the __delta/__state batch (steps 1–3); the KV-acceptance handoff and CDN
|
|
3307
|
+
* drain (step 4) are deferred to a later cluster. */
|
|
3308
|
+
function purgeResult(ack) {
|
|
3309
|
+
return {
|
|
3310
|
+
edgePurged: async () => {
|
|
3311
|
+
await Promise.resolve();
|
|
3312
|
+
},
|
|
3313
|
+
epoch: ack.epoch,
|
|
3314
|
+
flushed: async () => {
|
|
3315
|
+
await Promise.resolve();
|
|
3316
|
+
},
|
|
3317
|
+
matchedHint: "unknown"
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
/**
|
|
3321
|
+
* Build a cache instance. Wires the dual-epoch invalidation core when a
|
|
3322
|
+
* registry + bus are present; otherwise runs the registry-absent codec path.
|
|
3323
|
+
* Not-yet-implemented verbs still throw `NotImplementedError`.
|
|
3324
|
+
*
|
|
3325
|
+
* CONSTRUCTION PERFORMS NO I/O. What happens here is bookkeeping over the arguments —
|
|
3326
|
+
* addresses are recorded, components are pointed at each other, and no door is opened. Every
|
|
3327
|
+
* deferred piece (the bus subscription via `Invalidation`'s lazy dial; the retention lease via
|
|
3328
|
+
* `registerRetention` below) waits for the first read that needs it and runs inside that read's
|
|
3329
|
+
* request context. This is what makes one cache per isolate, built at module scope, a legal and
|
|
3330
|
+
* expected shape rather than an isolate that refuses to start.
|
|
3331
|
+
*/
|
|
3332
|
+
function createCache(config) {
|
|
3333
|
+
const inval = buildInvalidation(config);
|
|
3334
|
+
const poller = inval === void 0 ? void 0 : buildReplicationPoller(config, inval);
|
|
3335
|
+
/** The lease, armed but NOT fired — the first read pays it, and only the first. */
|
|
3336
|
+
const registerRetention = retentionRegistrar(config);
|
|
3337
|
+
const nsVersion = new NsVersion();
|
|
3338
|
+
const inFlight = newSingleflight();
|
|
3339
|
+
const ambientCell = { lookup() {} };
|
|
3340
|
+
const ambient = () => ambientCell.lookup();
|
|
3341
|
+
const instance = {
|
|
3342
|
+
async clear() {
|
|
3343
|
+
const { registry } = config;
|
|
3344
|
+
if (!registry) return notImplemented("cache.clear (no registry configured)", config);
|
|
3345
|
+
inval?.ensureSubscribed();
|
|
3346
|
+
nsVersion.bump();
|
|
3347
|
+
return purgeResult(await registry.delete([nsTag(config.namespace)]));
|
|
3348
|
+
},
|
|
3349
|
+
collect() {
|
|
3350
|
+
const required = [];
|
|
3351
|
+
let committed = false;
|
|
3352
|
+
let lateTag = false;
|
|
3353
|
+
let managedDependencies = 0;
|
|
3354
|
+
let poisoned = false;
|
|
3355
|
+
let scopeUnreadable = false;
|
|
3356
|
+
const records = [];
|
|
3357
|
+
const state = {
|
|
3358
|
+
get records() {
|
|
3359
|
+
return records;
|
|
3360
|
+
},
|
|
3361
|
+
record(dependency) {
|
|
3362
|
+
records.push(dependency);
|
|
3363
|
+
const { l3, scope, tags } = dependency;
|
|
3364
|
+
managedDependencies += 1;
|
|
3365
|
+
if (scope.kind === "unreadable") {
|
|
3366
|
+
scopeUnreadable = true;
|
|
3367
|
+
poisoned = true;
|
|
3368
|
+
} else if (scope.kind === "readable" && scope.literal !== PUBLIC_SCOPE) poisoned = true;
|
|
3369
|
+
if (l3 === false) return;
|
|
3370
|
+
if (committed) {
|
|
3371
|
+
lateTag = true;
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
required.push(...tags);
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
const collector = {
|
|
3378
|
+
commitHeaders(budget) {
|
|
3379
|
+
const cacheTags = required.filter((tag) => !isReservedTag(tag));
|
|
3380
|
+
committed = true;
|
|
3381
|
+
if (poisoned) return withManagedDependencies({
|
|
3382
|
+
cacheTags: [],
|
|
3383
|
+
eligible: false,
|
|
3384
|
+
ineligibleReason: scopeUnreadable ? "scope-unreadable" : "scope"
|
|
3385
|
+
}, managedDependencies);
|
|
3386
|
+
if (lateTag) return withManagedDependencies({
|
|
3387
|
+
cacheTags,
|
|
3388
|
+
eligible: false,
|
|
3389
|
+
ineligibleReason: "late-tag"
|
|
3390
|
+
}, managedDependencies);
|
|
3391
|
+
const maxTags = budget?.maxTags;
|
|
3392
|
+
if (maxTags !== void 0 && cacheTags.length > maxTags) return withManagedDependencies({
|
|
3393
|
+
cacheTags,
|
|
3394
|
+
eligible: false,
|
|
3395
|
+
ineligibleReason: "budget"
|
|
3396
|
+
}, managedDependencies);
|
|
3397
|
+
const maxBytes = budget?.maxBytes;
|
|
3398
|
+
if (maxBytes !== void 0) {
|
|
3399
|
+
if (new TextEncoder().encode(cacheTags.join(",")).length > maxBytes) return withManagedDependencies({
|
|
3400
|
+
cacheTags,
|
|
3401
|
+
eligible: false,
|
|
3402
|
+
ineligibleReason: "budget"
|
|
3403
|
+
}, managedDependencies);
|
|
3404
|
+
}
|
|
3405
|
+
return withManagedDependencies({
|
|
3406
|
+
cacheTags,
|
|
3407
|
+
eligible: true
|
|
3408
|
+
}, managedDependencies);
|
|
3409
|
+
},
|
|
3410
|
+
dependsOn(tag, dependsOptions) {
|
|
3411
|
+
state.record({
|
|
3412
|
+
scope: { kind: "none" },
|
|
3413
|
+
tags: [tag],
|
|
3414
|
+
...dependsOptions?.l3 === void 0 ? {} : { l3: dependsOptions.l3 }
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
};
|
|
3418
|
+
collectorStates.set(collector, state);
|
|
3419
|
+
return collector;
|
|
3420
|
+
},
|
|
3421
|
+
async delete(selector) {
|
|
3422
|
+
const { registry } = config;
|
|
3423
|
+
if (!registry) return notImplemented("cache.delete (no registry configured)", selector);
|
|
3424
|
+
inval?.ensureSubscribed();
|
|
3425
|
+
return purgeResult(await registry.delete(selectorTags(selector, config, nsVersion)));
|
|
3426
|
+
},
|
|
3427
|
+
/** Deferred to v0.4 — namespace-wide hard purge behind the origin-load guard. */
|
|
3428
|
+
async deleteAll(guard) {
|
|
3429
|
+
return notImplemented("cache.deleteAll (deferred to v0.4)", config, guard);
|
|
3430
|
+
},
|
|
3431
|
+
async expire(selector) {
|
|
3432
|
+
const { registry } = config;
|
|
3433
|
+
if (!registry) return notImplemented("cache.expire (no registry configured)", selector);
|
|
3434
|
+
inval?.ensureSubscribed();
|
|
3435
|
+
return purgeResult(await registry.expire(selectorTags(selector, config, nsVersion)));
|
|
3436
|
+
},
|
|
3437
|
+
/** Deferred to v0.4 — namespace-wide soft purge behind the origin-load guard. */
|
|
3438
|
+
async expireAll(guard) {
|
|
3439
|
+
return notImplemented("cache.expireAll (deferred to v0.4)", config, guard);
|
|
3440
|
+
},
|
|
3441
|
+
/**
|
|
3442
|
+
* Witness a key's state — introspection, not testimony: every field is drawn from
|
|
3443
|
+
* an ALREADY-KNOWN source, and nothing here mutates state. The tier is a NON-mutating presence
|
|
3444
|
+
* probe (`store.get`, never `hydrateL1` ); the verdict is a pure classify over the
|
|
3445
|
+
* reader's CURRENT knowledge (no live check, no resync ); the reader state is exposed
|
|
3446
|
+
* via read-only accessors; the request attribution is the bound render collector's
|
|
3447
|
+
* request-scoped records. The key is canonicalized at the DEFAULT (`pub`) scope — the
|
|
3448
|
+
* signature carries no scope, so `explain` introspects the default-scoped entry for that key.
|
|
3449
|
+
*/
|
|
3450
|
+
async explain(key) {
|
|
3451
|
+
const codec = codecOf(config);
|
|
3452
|
+
const canonical = canonicalKey(nsVersion.current, config.namespace, scopeLiteral(), key);
|
|
3453
|
+
const reader = {
|
|
3454
|
+
appliedEpoch: inval?.appliedEpoch ?? 0,
|
|
3455
|
+
channel: inval?.channel ?? "never-established",
|
|
3456
|
+
suspect: inval?.suspect ?? false,
|
|
3457
|
+
terminal: poller?.terminal ?? false
|
|
3458
|
+
};
|
|
3459
|
+
const requestDependencies = explainAttribution(ambient);
|
|
3460
|
+
const attribution = requestDependencies === void 0 ? {} : { requestDependencies };
|
|
3461
|
+
let readFailed = false;
|
|
3462
|
+
for (const [store, tier] of tiersOf(config)) {
|
|
3463
|
+
const probed = await probeStoredEntry(store, codec, config, canonical);
|
|
3464
|
+
if (probed.kind !== "found") {
|
|
3465
|
+
readFailed ||= probed.kind === READ_FAILED_KIND;
|
|
3466
|
+
continue;
|
|
3467
|
+
}
|
|
3468
|
+
return {
|
|
3469
|
+
identity: {
|
|
3470
|
+
bornEpoch: probed.envelope.bornEpoch,
|
|
3471
|
+
kind: probed.envelope.kind,
|
|
3472
|
+
scope: probed.scope,
|
|
3473
|
+
tags: probed.envelope.tags,
|
|
3474
|
+
ttl: probed.ttl
|
|
3475
|
+
},
|
|
3476
|
+
key,
|
|
3477
|
+
kind: "present",
|
|
3478
|
+
reader,
|
|
3479
|
+
tier,
|
|
3480
|
+
verdict: inval ? inval.explainVerdict(probed.envelope) : FRESH_VERDICT,
|
|
3481
|
+
...attribution
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
if (readFailed) return {
|
|
3485
|
+
key,
|
|
3486
|
+
kind: READ_FAILED_KIND,
|
|
3487
|
+
reader,
|
|
3488
|
+
...attribution
|
|
3489
|
+
};
|
|
3490
|
+
return {
|
|
3491
|
+
key,
|
|
3492
|
+
kind: "absent",
|
|
3493
|
+
reader,
|
|
3494
|
+
tier: "miss",
|
|
3495
|
+
...attribution
|
|
3496
|
+
};
|
|
3497
|
+
},
|
|
3498
|
+
async getOrSet(options) {
|
|
3499
|
+
registerRetention();
|
|
3500
|
+
inval?.ensureSubscribed();
|
|
3501
|
+
const outcome = await runGetOrSet(config, options, inval, nsVersion, "getOrSet", inFlight, ambient);
|
|
3502
|
+
if (outcome.value === void 0 && outcome.tier === "miss") {
|
|
3503
|
+
if (outcome.error !== void 0) throw outcome.error;
|
|
3504
|
+
throw new FencedError(options.key);
|
|
3505
|
+
}
|
|
3506
|
+
return outcome.value;
|
|
3507
|
+
},
|
|
3508
|
+
async getOrSetEntry(options) {
|
|
3509
|
+
registerRetention();
|
|
3510
|
+
inval?.ensureSubscribed();
|
|
3511
|
+
const outcome = await runGetOrSet(config, options, inval, nsVersion, "getOrSetEntry", inFlight, ambient);
|
|
3512
|
+
return {
|
|
3513
|
+
age: outcome.bornMs === void 0 ? 0 : Math.max(0, config.clock.now() - outcome.bornMs),
|
|
3514
|
+
...outcome.durable === void 0 ? {} : { durable: outcome.durable },
|
|
3515
|
+
skipped: outcome.skipped,
|
|
3516
|
+
stale: outcome.stale,
|
|
3517
|
+
tier: outcome.tier,
|
|
3518
|
+
value: outcome.value,
|
|
3519
|
+
...outcome.servedOnError === void 0 ? {} : { servedOnError: outcome.servedOnError }
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3522
|
+
};
|
|
3523
|
+
ambientCell.lookup = () => ambientCollectorOf(instance);
|
|
3524
|
+
if (poller !== void 0) replicationPollers.set(instance, poller);
|
|
3525
|
+
return instance;
|
|
3526
|
+
}
|
|
3527
|
+
//#endregion
|
|
3528
|
+
export { UnknownTagError as A, FactorySettledError as C, InvalidTagError as D, InvalidDurationError as E, NotImplementedError as O, CacheTimeoutError as S, HttpError as T, t as _, replicationPollerOf as a, isRegId as b, isRetriableHttp as c, memory as d, readFrame as f, globalTag as g, compound as h, managedDependenciesOf as i, RegistryUnavailableError as k, duration as l, safeEmit as m, bindRenderCollector as n, resolveCacheIdentity as o, TELEMETRY_EVENTS as p, createCache as r, httpError as s, aliasCacheIdentity as t, isStoreWriteError as u, DEFAULT_REG_ID as v, FencedError as w, mirrorKeys as x, assertRegId as y };
|