@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.
@@ -0,0 +1,1699 @@
1
+ import { a as replicationPollerOf, b as isRegId, c as isRetriableHttp, d as memory, f as readFrame, m as safeEmit, p as TELEMETRY_EVENTS, r as createCache, t as aliasCacheIdentity, u as isStoreWriteError, v as DEFAULT_REG_ID, x as mirrorKeys, y as assertRegId } from "../../cache-Z4cJzPmm.js";
2
+ import { DurableObject } from "cloudflare:workers";
3
+ /**
4
+ * App-specific WebSocket close codes (the 4000–4999 private range). Each is EXPLICIT so a
5
+ * subscriber can tell WHY it was cut and knows the remedy is the same everywhere: redial → the
6
+ * `reset` → `hello` handshake → KV resync (cold protocol).
7
+ * - `DELIVERY_DOUBT` — the SERVER closed a subscriber on any delivery doubt (a throwing `send`, a
8
+ * non-OPEN socket): doBus never risks a silent gap, it closes.
9
+ * - `SCOPE_MISMATCH` — the CLIENT closed because the DO's `hello` echoed a `regId` ≠ its own
10
+ * configured scope: it read a foreign channel, so it fails closed loud.
11
+ * - `PROTOCOL` — the CLIENT closed on an undecodable message (malformed / unknown kind / wrong
12
+ * version): never dispatch a partial event, never guess a future version.
13
+ */
14
+ const CLOSE_DELIVERY_DOUBT = 4001;
15
+ const CLOSE_SCOPE_MISMATCH = 4002;
16
+ const CLOSE_PROTOCOL = 4003;
17
+ /** Narrow an `unknown` to an index-readable record, or `undefined`. Every field is `typeof`-checked
18
+ * at the call site below before use, so the record view is sound. */
19
+ function asRecord(value) {
20
+ if (typeof value !== "object" || value === null) return;
21
+ return value;
22
+ }
23
+ /**
24
+ * Encode one wire event to a single WS-message string. The `frame` payload is
25
+ * `JSON.stringify(frame)` — the canonical bytes, carried UNCHANGED as a nested JSON string, so
26
+ * a frame survives the wire byte-identical (the canonical-bytes teeth pin it). Keys are emitted in
27
+ * a fixed order so the envelope bytes are themselves deterministic.
28
+ */
29
+ function encode(event) {
30
+ switch (event.kind) {
31
+ case "reset": return JSON.stringify({
32
+ kind: "reset",
33
+ v: 1
34
+ });
35
+ case "hello": return JSON.stringify({
36
+ head: event.head,
37
+ kind: "hello",
38
+ regId: event.regId,
39
+ v: 1
40
+ });
41
+ case "gap": return JSON.stringify({
42
+ head: event.head,
43
+ kind: "gap",
44
+ v: 1
45
+ });
46
+ case "frame": return JSON.stringify({
47
+ frame: JSON.stringify(event.frame),
48
+ kind: "frame",
49
+ v: 1
50
+ });
51
+ default: throw new Error(`unknown wire event: ${String(event)}`);
52
+ }
53
+ }
54
+ /**
55
+ * Decode one WS-message string, STRICTLY. Any of — non-JSON bytes, a non-object, a `v`
56
+ * that is not exactly `WIRE_VERSION` (a higher version is NEVER guessed; a lower one is refused
57
+ * too), an unknown `kind`, or a malformed payload (a non-number `head`/absent `regId`, or a frame
58
+ * the `readFrame` rejects) — yields `{ok:false}`, a CLOSE-and-reconnect signal. A well-formed
59
+ * message yields `{ok:true, event}`; the frame is validated through the reader's own `readFrame`,
60
+ * so a corrupt wire change fails closed exactly like a corrupt `__delta` byte.
61
+ */
62
+ function decode(bytes) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(bytes);
66
+ } catch {
67
+ return {
68
+ ok: false,
69
+ reason: "malformed json"
70
+ };
71
+ }
72
+ const env = asRecord(parsed);
73
+ if (!env) return {
74
+ ok: false,
75
+ reason: "envelope is not an object"
76
+ };
77
+ if (env.v !== 1) return {
78
+ ok: false,
79
+ reason: `unsupported wire version ${String(env.v)}`
80
+ };
81
+ switch (env.kind) {
82
+ case "reset": return {
83
+ event: { kind: "reset" },
84
+ ok: true
85
+ };
86
+ case "hello":
87
+ if (typeof env.head !== "number" || typeof env.regId !== "string") return {
88
+ ok: false,
89
+ reason: "malformed hello"
90
+ };
91
+ return {
92
+ event: {
93
+ head: env.head,
94
+ kind: "hello",
95
+ regId: env.regId
96
+ },
97
+ ok: true
98
+ };
99
+ case "gap":
100
+ if (typeof env.head !== "number") return {
101
+ ok: false,
102
+ reason: "malformed gap"
103
+ };
104
+ return {
105
+ event: {
106
+ head: env.head,
107
+ kind: "gap"
108
+ },
109
+ ok: true
110
+ };
111
+ case "frame": {
112
+ if (typeof env.frame !== "string") return {
113
+ ok: false,
114
+ reason: "malformed frame payload"
115
+ };
116
+ const frame = readFrame(env.frame);
117
+ if (!frame) return {
118
+ ok: false,
119
+ reason: "frame failed F12 parse"
120
+ };
121
+ return {
122
+ event: {
123
+ frame,
124
+ kind: "frame"
125
+ },
126
+ ok: true
127
+ };
128
+ }
129
+ default: return {
130
+ ok: false,
131
+ reason: `unknown wire kind ${String(env.kind)}`
132
+ };
133
+ }
134
+ }
135
+ //#endregion
136
+ //#region src/adapters/cloudflare/bus-client.ts
137
+ /** Is `value` a thenable (a promise-returning dial vs a synchronous one)? */
138
+ function isThenable(value) {
139
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
140
+ }
141
+ var DoBus = class {
142
+ #dial;
143
+ #regId;
144
+ #onClose;
145
+ constructor(dial, options) {
146
+ this.#dial = dial;
147
+ this.#regId = options.regId;
148
+ this.#onClose = options.onClose;
149
+ }
150
+ subscribe(onEvent) {
151
+ const state = {
152
+ closed: false,
153
+ onEvent,
154
+ reported: false,
155
+ socket: null
156
+ };
157
+ const attach = (socket) => {
158
+ if (state.closed) {
159
+ this.#safeSocketClose(socket, 1e3, "astilba/cache: unsubscribed");
160
+ return;
161
+ }
162
+ state.socket = socket;
163
+ socket.addEventListener("message", (event) => {
164
+ this.#onMessage(event.data, onEvent, state);
165
+ });
166
+ socket.addEventListener("close", (event) => {
167
+ this.#report(state, {
168
+ code: event.code,
169
+ local: false,
170
+ reason: event.reason
171
+ });
172
+ });
173
+ };
174
+ const dialed = this.#dial();
175
+ if (isThenable(dialed)) (async () => {
176
+ try {
177
+ attach(await dialed);
178
+ } catch (error) {
179
+ this.#report(state, {
180
+ code: CLOSE_PROTOCOL,
181
+ local: true,
182
+ reason: `astilba/cache: dial failed — ${String(error)}`
183
+ });
184
+ }
185
+ })();
186
+ else attach(dialed);
187
+ return { close: () => {
188
+ if (state.closed) return;
189
+ state.closed = true;
190
+ state.onEvent = null;
191
+ if (state.socket) {
192
+ if (!this.#safeSocketClose(state.socket, 1e3, "astilba/cache: unsubscribed")) return;
193
+ }
194
+ state.socket = null;
195
+ } };
196
+ }
197
+ /** Decode one message and dispatch it, or CLOSE loud on a protocol/scope violation (rulings). */
198
+ #onMessage(data, onEvent, state) {
199
+ if (state.closed) return;
200
+ if (typeof data !== "string") {
201
+ this.#localClose(state, CLOSE_PROTOCOL, "astilba/cache: non-string frame");
202
+ return;
203
+ }
204
+ const result = decode(data);
205
+ if (!result.ok) {
206
+ this.#localClose(state, CLOSE_PROTOCOL, `astilba/cache: undecodable frame — ${result.reason}`);
207
+ return;
208
+ }
209
+ const { event } = result;
210
+ switch (event.kind) {
211
+ case "hello":
212
+ if (event.regId !== this.#regId) {
213
+ this.#localClose(state, CLOSE_SCOPE_MISMATCH, `astilba/cache: hello regId "${event.regId}" != configured "${this.#regId}"`);
214
+ return;
215
+ }
216
+ onEvent({
217
+ head: event.head,
218
+ kind: "hello"
219
+ });
220
+ return;
221
+ case "reset":
222
+ onEvent({ kind: "reset" });
223
+ return;
224
+ case "gap":
225
+ onEvent({
226
+ head: event.head,
227
+ kind: "gap"
228
+ });
229
+ return;
230
+ case "frame":
231
+ onEvent({
232
+ frame: event.frame,
233
+ kind: "frame"
234
+ });
235
+ return;
236
+ default: {
237
+ const unreachable = event;
238
+ this.#localClose(state, CLOSE_PROTOCOL, `astilba/cache: unreachable wire kind ${String(unreachable)}`);
239
+ }
240
+ }
241
+ }
242
+ /** The client initiates a close (protocol/scope) and reports it as `local`. */
243
+ #localClose(state, code, reason) {
244
+ if (state.closed) return;
245
+ state.closed = true;
246
+ if (state.socket) this.#safeSocketClose(state.socket, code, reason);
247
+ this.#report(state, {
248
+ code,
249
+ local: true,
250
+ reason
251
+ });
252
+ }
253
+ /**
254
+ * Fire the status report at most once per subscription, and DECLARE the loss to the subscriber.
255
+ *The declaration is what keeps a dead channel from reading as a quiet one: `reset`/`hello`
256
+ * are the only other things this client dispatches, so without a `lost` the reader's evidence for
257
+ * "the bus never came up" and "the bus has nothing to say" is identical — namely nothing.
258
+ *
259
+ * The subscriber is notified BEFORE the app's `onClose`, and its throw cannot eat the report (nor
260
+ * the reverse): each is guarded separately, because the two consumers are independent and neither
261
+ * one's failure is the other's business.
262
+ */
263
+ #report(state, info) {
264
+ if (state.reported) return;
265
+ state.reported = true;
266
+ state.closed = true;
267
+ const { onEvent } = state;
268
+ if (onEvent) try {
269
+ onEvent({ kind: "lost" });
270
+ } catch {}
271
+ this.#onClose?.(info);
272
+ }
273
+ /**
274
+ * Close the socket, tolerating a platform refusal — WITHOUT claiming success.
275
+ *
276
+ * The refusal is real and routine: this socket may have been minted in an earlier request, and the
277
+ * platform refuses `close()` from a different one exactly as it refuses every other operation on
278
+ * a foreign handle. Swallowing that and marking the subscription closed anyway was the shape of
279
+ * the lie: local state said "closed", the socket stayed open, and the server kept a subscriber it
280
+ * would fan out to forever.
281
+ *
282
+ * SO THE RETURN VALUE IS THE HONESTY. `false` means the close did NOT happen and the socket is
283
+ * still out there; the caller records that rather than overwriting it with an assumption. This may
284
+ * DEFER a close (the leak below), and that is a cost; it may not claim one that never occurred.
285
+ *
286
+ * THE LEAK'S BOUND, STATED PLAINLY. A socket this cannot close is not unbounded: the server closes
287
+ * on doubt at its next fan-out (a send to a socket whose client is gone fails, and the DO drops
288
+ * it), and isolate eviction takes the rest. So the residue is one socket per refused close, for at
289
+ * most as long as the Coordinator takes to speak again — priced and finite, which a silent
290
+ * divergence between local state and the wire is not.
291
+ */
292
+ #safeSocketClose(socket, code, reason) {
293
+ try {
294
+ socket.close(code, reason);
295
+ return true;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+ };
301
+ /**
302
+ * Build a `doBus` client `Bus`. `dial` obtains an OPEN socket (synchronously in the
303
+ * conformance pair; a `stub.fetch` → `accept()` promise in workerd). `options.regId` is the scope
304
+ * the DO's `hello` must echo; `options.onClose` receives the socket's close (
305
+ * NO self-reconnect). The reader subscribes to the returned `Bus`; the client decodes each message
306
+ * and dispatches the `BusEvent`, closing loud on any protocol/scope violation.
307
+ */
308
+ function doBus(dial, options) {
309
+ return new DoBus(dial, options);
310
+ }
311
+ //#endregion
312
+ //#region src/registry/alarm.ts
313
+ /**
314
+ * The NEAREST of a currently-armed alarm and a newly-desired deadline. A `null`
315
+ * existing means nothing is armed yet, so the desired deadline wins outright. Never pushes an armed
316
+ * alarm LATER — the min guarantees a sooner deadline (a due flush) always survives a later one (the
317
+ * idle heartbeat cadence), and re-arming with the sooner value is idempotent.
318
+ */
319
+ function earliestAlarm(existing, desired) {
320
+ return existing === null ? desired : Math.min(existing, desired);
321
+ }
322
+ //#endregion
323
+ //#region src/registry/execute.ts
324
+ /**
325
+ * Write an immutable object THROUGH the non-retryable-write belt. The
326
+ * executor-side backstop for the delta/snapshot SIZE bound, distinct from the machine's snapshot
327
+ * skip: a NON-retryable classified rejection — an oversize immutable (KV `too_large`, 25 MiB, the
328
+ * code `cloudflareKV` raises), the pathological bound no retry can clear — emits a
329
+ * `flush_failed_nonretryable{code}` telemetry event so the failure is OBSERVABLE, then PROPAGATES
330
+ * (the flush fails whole; the terminal `__state` bless never runs, so no pointer references an
331
+ * object that was never written). Retryable rejections (`throttled`/`unavailable`) propagate
332
+ * UNTOUCHED — the alarm retries them, exactly as before.
333
+ */
334
+ async function putImmutableWithBelt(sink, key, bytes, kind) {
335
+ try {
336
+ await sink.putImmutable(key, bytes, kind);
337
+ } catch (error) {
338
+ if (isStoreWriteError(error) && !error.retryable) await sink.telemetry({
339
+ code: error.code,
340
+ event: "flush_failed_nonretryable"
341
+ });
342
+ throw error;
343
+ }
344
+ }
345
+ /**
346
+ * Execute a flush's effect plan against a host sink. The plan already carries
347
+ * the once-only terminal `advance-pointer` last, so this never issues two pointer puts in one
348
+ * flush. It does NOT catch a sink rejection: a failed `putPointer` fails the flush whole.
349
+ *
350
+ * `regId` scopes every key through `mirrorKeys(regId)` — the SAME formatter the reader
351
+ * addresses with — so the writer and reader can never re-drift to different bytes. The
352
+ * host passes its machine's `regId`; that same id is echoed in the pointer payload the machine
353
+ * minted, so the physical key and the payload agree by construction.
354
+ */
355
+ async function executeFlushEffects(effects, sink, regId) {
356
+ const keys = mirrorKeys(regId);
357
+ for (const effect of effects) switch (effect.kind) {
358
+ case "persist-batch":
359
+ await putImmutableWithBelt(sink, keys.deltaKey(effect.seq), JSON.stringify(effect.frame), "delta");
360
+ break;
361
+ case "persist-snapshot":
362
+ await putImmutableWithBelt(sink, keys.snapKey(effect.seq), JSON.stringify(effect.frame), "snap");
363
+ break;
364
+ case "broadcast-frame":
365
+ await sink.broadcast(effect.frame);
366
+ break;
367
+ case "telemetry":
368
+ await sink.telemetry({
369
+ bytes: effect.bytes,
370
+ ceiling: effect.ceiling,
371
+ event: effect.event
372
+ });
373
+ break;
374
+ case "advance-pointer":
375
+ await sink.putPointer(keys.stateKey, JSON.stringify(effect.pointer));
376
+ break;
377
+ default: throw new Error(`unknown flush effect: ${String(effect)}`);
378
+ }
379
+ }
380
+ //#endregion
381
+ //#region src/registry/machine.ts
382
+ /** Default snapshot cadence: checkpoint every 500 batches OR every 6h. */
383
+ const DEFAULT_SNAPSHOT_EVERY_BATCHES = 500;
384
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 216e5;
385
+ /** Default snapshot SIZE ceiling: the KV 25 MiB per-value profile. A due
386
+ * snapshot whose canonical shipped bytes exceed this is SKIPPED (never truncated, never a phantom
387
+ * bless) — the machine-owned gate above the executor-side delta bound. */
388
+ const DEFAULT_MAX_SNAPSHOT_BYTES = 26214400;
389
+ /**
390
+ * Fold a coalescing window's buffered changes into the CANONICAL batch form:
391
+ * - every tag appears EXACTLY ONCE, carrying its max soft/hard epoch over the window
392
+ * (any duplicate tags folded via max-on-write), and
393
+ * - the changes are ordered LEXICOGRAPHICALLY by tag (UTF-16 code-unit order).
394
+ * Only an axis actually touched in the window is emitted — an untouched axis has no delta
395
+ * over the (fromEpoch, toEpoch] range. Content-addressing makes a batch a pure
396
+ * function of durable state over its range, so it has one serialization; the kernel applies
397
+ * changes via max-on-write, which is commutative and idempotent, so dedup + lex-sort
398
+ * carry the SAME knowledge as the raw concatenation (INV-14/19/20 preserved). The emitted
399
+ * key order is alphabetical (hardEpoch, softEpoch, tag) — the repo's stable wire convention —
400
+ * so a canonical change has exactly one serialization on every host.
401
+ */
402
+ function canonicalBatch(pending) {
403
+ const folded = /* @__PURE__ */ new Map();
404
+ for (const change of pending) {
405
+ const acc = folded.get(change.tag) ?? {};
406
+ if (change.softEpoch !== void 0) acc.softEpoch = Math.max(acc.softEpoch ?? change.softEpoch, change.softEpoch);
407
+ if (change.hardEpoch !== void 0) acc.hardEpoch = Math.max(acc.hardEpoch ?? change.hardEpoch, change.hardEpoch);
408
+ folded.set(change.tag, acc);
409
+ }
410
+ return [...folded.keys()].toSorted().map((tag) => {
411
+ const acc = folded.get(tag) ?? {};
412
+ return {
413
+ ...acc.hardEpoch === void 0 ? {} : { hardEpoch: acc.hardEpoch },
414
+ ...acc.softEpoch === void 0 ? {} : { softEpoch: acc.softEpoch },
415
+ tag
416
+ };
417
+ });
418
+ }
419
+ /**
420
+ * The registry state machine. Holds dual watermark epochs per tag, a monotone epoch
421
+ * counter, a durable batch sequence, the monotone GC-horizon high-water, the snapshot
422
+ * cadence watermark (`snapBatch`/`lastSnapMs`), and the un-flushed (coalescing) change
423
+ * buffer. Every method is synchronous and side-effect free with respect to the outside
424
+ * world: mutations update machine state and return effect descriptions; queries return
425
+ * values.
426
+ */
427
+ var RegistryMachine = class {
428
+ #watermarks = /* @__PURE__ */ new Map();
429
+ #epoch = 0;
430
+ #batchSeq = 0;
431
+ #gcHorizonMs = 0;
432
+ #pendingFrom = null;
433
+ #pendingChanges = [];
434
+ #snapBatch = 0;
435
+ #lastSnapMs = null;
436
+ #snapshotEveryBatches = DEFAULT_SNAPSHOT_EVERY_BATCHES;
437
+ #snapshotIntervalMs = DEFAULT_SNAPSHOT_INTERVAL_MS;
438
+ #maxSnapshotBytes = DEFAULT_MAX_SNAPSHOT_BYTES;
439
+ #regId = DEFAULT_REG_ID;
440
+ constructor(options = {}) {
441
+ this.configure(options);
442
+ }
443
+ /** The current Coordinator epoch (the correctness clock, machine state). */
444
+ get currentEpoch() {
445
+ return this.#epoch;
446
+ }
447
+ /** The batch seq of the last emitted snapshot (`__state.snapBatch`), machine state. */
448
+ get snapBatch() {
449
+ return this.#snapBatch;
450
+ }
451
+ /** The last durably-emitted batch seq (`lastBatch`), machine state — both the flush and the heartbeat pointer effects carry it; a host may also read it directly. */
452
+ get lastBatch() {
453
+ return this.#batchSeq;
454
+ }
455
+ /** The effective machine config, machine state — the shell diffs `env` against this to decide whether to journal a fresh `configure` (Ruling B). */
456
+ get effectiveConfig() {
457
+ return {
458
+ maxSnapshotBytes: this.#maxSnapshotBytes,
459
+ regId: this.#regId,
460
+ snapshotEveryBatches: this.#snapshotEveryBatches,
461
+ snapshotIntervalMs: this.#snapshotIntervalMs
462
+ };
463
+ }
464
+ /** The mirror-key scoping id, machine state — the host reads it to build the
465
+ * scoped keyspace it writes the flush effects to (`mirrorKeys(regId)`). */
466
+ get regId() {
467
+ return this.#regId;
468
+ }
469
+ /**
470
+ * Apply an effective snapshot cadence as machine STATE. A JOURNALED command (Ruling B):
471
+ * `env` may AUTHOR this — it never parameterizes the machine directly — so a replay reproduces
472
+ * the cadence AT ITS JOURNAL POSITION, prospective by construction. Unset knobs fall to the
473
+ * defaults, exactly as the constructor seed does. Never retroactive: it changes only how future
474
+ * flushes trip the cadence, so `snapBatch` stays monotone across a config change.
475
+ */
476
+ configure(options) {
477
+ this.#snapshotEveryBatches = options.snapshotEveryBatches ?? DEFAULT_SNAPSHOT_EVERY_BATCHES;
478
+ this.#snapshotIntervalMs = options.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
479
+ this.#maxSnapshotBytes = options.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES;
480
+ const regId = options.regId ?? "default";
481
+ assertRegId(regId);
482
+ this.#regId = regId;
483
+ }
484
+ /** The monotone high-water GC horizon in ms — never retracts. */
485
+ get gcHorizonMs() {
486
+ return this.#gcHorizonMs;
487
+ }
488
+ /** Authoritative dual watermark for a tag; absent tags read as a zero watermark. */
489
+ watermarkOf(tag) {
490
+ const w = this.#watermarks.get(tag);
491
+ return {
492
+ hardEpoch: w?.hardEpoch ?? 0,
493
+ softEpoch: w?.softEpoch ?? 0
494
+ };
495
+ }
496
+ /**
497
+ * Live watermark knowledge for these tags. A primary read is always authoritative
498
+ * and known; an untouched tag reads as a known zero watermark, never as unknown.
499
+ */
500
+ knowledgeOf(tags) {
501
+ const through = this.#epoch;
502
+ return tags.map((tag) => {
503
+ const w = this.#watermarks.get(tag);
504
+ return {
505
+ hardEpoch: w?.hardEpoch ?? 0,
506
+ known: true,
507
+ softEpoch: w?.softEpoch ?? 0,
508
+ throughEpoch: through
509
+ };
510
+ });
511
+ }
512
+ /** Soft purge: max-on-write bump of `softEpoch` for each tag. */
513
+ expire(tags, nowMs) {
514
+ return this.#mutate(tags, "soft", nowMs);
515
+ }
516
+ /** Hard purge: max-on-write bump of `hardEpoch` for each tag. */
517
+ delete(tags, nowMs) {
518
+ return this.#mutate(tags, "hard", nowMs);
519
+ }
520
+ /** Register a retention lease; the GC horizon rises to the high-water. */
521
+ registerRetention(retentionMs) {
522
+ this.#gcHorizonMs = Math.max(this.#gcHorizonMs, retentionMs);
523
+ }
524
+ /**
525
+ * Describe the GC horizon at `nowMs`. Pure: the machine collects nothing;
526
+ * a host uses `collectBeforeMs` to decide what to reclaim. Uncertainty extends, never
527
+ * shortens, the horizon — it is the monotone high-water retention.
528
+ */
529
+ gc(nowMs) {
530
+ return {
531
+ collectBeforeMs: nowMs - this.#gcHorizonMs,
532
+ horizonMs: this.#gcHorizonMs
533
+ };
534
+ }
535
+ /**
536
+ * Flush the coalescing window into a persist-batch + broadcast-frame, plus a
537
+ * persist-snapshot when the cadence is due. Emits nothing when no epoch has advanced
538
+ * since the last flush. The batch takes the next durable seq (`lastBatch + 1`) and spans
539
+ * `(fromEpoch, toEpoch)` — content-addressed so replays are byte-identical. The
540
+ * buffered changes are canonicalized (each tag once at its max epoch, lex-sorted) so the
541
+ * batch has exactly ONE serialization across hosts (`canonicalBatch` — behavior-preserving
542
+ * under max-on-write).
543
+ *
544
+ * SNAPSHOT CADENCE: emit a snapshot when `seq − snapBatch ≥ snapshotEveryBatches`
545
+ * OR `nowMs − lastSnapMs ≥ snapshotIntervalMs`. The seq clause is replay-safe (a pure
546
+ * function of the command journal); the time clause reads the `nowMs` PARAMETER, so a host
547
+ * that replays flushes with the recorded `nowMs` reproduces `snapBatch` exactly.
548
+ *
549
+ * SIZE GATE: before emitting a due snapshot, measure its CANONICAL SHIPPED
550
+ * bytes; if they exceed `maxSnapshotBytes`, SKIP it — emit a `snapshot-skipped-oversize`
551
+ * telemetry effect INSTEAD of `persist-snapshot`, leave `#snapBatch` UNADVANCED (no phantom
552
+ * checkpoint blessed), and let batch/broadcast/pointer proceed normally. `#lastSnapMs` advances
553
+ * so the time clause does not re-fire every flush, but the batch clause stays tripped — a loud
554
+ * re-attempt on each subsequent flush. The gate is journaled (replay-deterministic).
555
+ *
556
+ * Emission order is batch → snapshot → broadcast → advance-pointer, so a host writes every
557
+ * immutable object (`__delta:<seq>`, and `__snap:<seq>` when the cadence fires) BEFORE the
558
+ * ONE terminal `advance-pointer` blesses `__state` (write-then-bless). The pointer is atomic
559
+ * across `lastBatch` AND `snapBatch` — there is no stale intermediate pointer, so no host can
560
+ * issue two sub-second `__state` puts in one flush.
561
+ */
562
+ flush(nowMs) {
563
+ if (this.#pendingFrom === null) return [];
564
+ const fromEpoch = this.#pendingFrom;
565
+ const toEpoch = this.#epoch;
566
+ const changes = canonicalBatch(this.#pendingChanges);
567
+ this.#pendingFrom = null;
568
+ this.#pendingChanges = [];
569
+ this.#batchSeq += 1;
570
+ const seq = this.#batchSeq;
571
+ const frame = {
572
+ changes,
573
+ fromEpoch,
574
+ toEpoch
575
+ };
576
+ const effects = [{
577
+ frame,
578
+ kind: "persist-batch",
579
+ seq
580
+ }];
581
+ this.#lastSnapMs ??= nowMs;
582
+ const dueByBatches = seq - this.#snapBatch >= this.#snapshotEveryBatches;
583
+ const dueByTime = nowMs - this.#lastSnapMs >= this.#snapshotIntervalMs;
584
+ if (dueByBatches || dueByTime) {
585
+ const snapFrame = {
586
+ changes: this.#snapshotChanges(),
587
+ fromEpoch: 0,
588
+ toEpoch
589
+ };
590
+ const snapBytes = new TextEncoder().encode(JSON.stringify(snapFrame)).length;
591
+ if (snapBytes <= this.#maxSnapshotBytes) {
592
+ this.#snapBatch = seq;
593
+ this.#lastSnapMs = nowMs;
594
+ effects.push({
595
+ frame: snapFrame,
596
+ kind: "persist-snapshot",
597
+ seq
598
+ });
599
+ } else {
600
+ this.#lastSnapMs = nowMs;
601
+ effects.push({
602
+ bytes: snapBytes,
603
+ ceiling: this.#maxSnapshotBytes,
604
+ event: "snapshot-skipped-oversize",
605
+ kind: "telemetry"
606
+ });
607
+ }
608
+ }
609
+ effects.push({
610
+ frame,
611
+ kind: "broadcast-frame"
612
+ });
613
+ const pointer = {
614
+ epoch: toEpoch,
615
+ flushedAt: nowMs,
616
+ lastBatch: seq,
617
+ regId: this.#regId,
618
+ snapBatch: this.#snapBatch
619
+ };
620
+ effects.push({
621
+ kind: "advance-pointer",
622
+ pointer
623
+ });
624
+ return effects;
625
+ }
626
+ /**
627
+ * A liveness heartbeat. Refreshes the `__state` pointer's freshness field
628
+ * (`flushedAt`) at `nowMs` while advancing NO cursor: it reads the CURRENT epoch/lastBatch/
629
+ * snapBatch and reuses the terminal `advance-pointer` effect, so a host blesses `__state`
630
+ * through the SAME executor a flush uses (one hot-key put, never a raw write). Emitted
631
+ * as a SINGLE pointer effect: the freshness fields move, the monotone cursors do not.
632
+ *
633
+ * STATE-NEUTRAL by construction — it mutates nothing, so the monotone cursors cannot move and
634
+ * replaying it is a pure no-op. That is the property that lets a host decline to journal a
635
+ * heartbeat for replay-exactness (replay is exact over the NON-heartbeat commands alone). The
636
+ * shell decides WHEN to issue it (an env-gated cadence, Ruling B); the machine only supplies the
637
+ * pointer bytes — `env` authors the command, it never parameterizes the machine.
638
+ */
639
+ heartbeat(nowMs) {
640
+ return {
641
+ kind: "advance-pointer",
642
+ pointer: {
643
+ epoch: this.#epoch,
644
+ flushedAt: nowMs,
645
+ lastBatch: this.#batchSeq,
646
+ regId: this.#regId,
647
+ snapBatch: this.#snapBatch
648
+ }
649
+ };
650
+ }
651
+ /**
652
+ * The canonical snapshot change set: every tag that has ever advanced, folded
653
+ * over (0, epoch] by the SAME serializer the delta path uses (`canonicalBatch`) — one entry
654
+ * per tag, touched axes only (a 0 axis is absent, never a fabricated 0), per-axis max,
655
+ * lex-sorted. So a snapshot is byte-identical to the single delta batch that would carry the
656
+ * whole watermark map — one serializer, one applier (the unification).
657
+ */
658
+ #snapshotChanges() {
659
+ const pending = [];
660
+ for (const [tag, w] of this.#watermarks) pending.push({
661
+ tag,
662
+ ...w.hardEpoch > 0 ? { hardEpoch: w.hardEpoch } : {},
663
+ ...w.softEpoch > 0 ? { softEpoch: w.softEpoch } : {}
664
+ });
665
+ return canonicalBatch(pending);
666
+ }
667
+ #mutate(tags, kind, nowMs) {
668
+ const fromEpoch = this.#epoch;
669
+ const toEpoch = fromEpoch + 1;
670
+ this.#epoch = toEpoch;
671
+ for (const tag of tags) {
672
+ const w = this.#watermarks.get(tag) ?? {
673
+ hardEpoch: 0,
674
+ softEpoch: 0
675
+ };
676
+ if (kind === "soft") {
677
+ w.softEpoch = Math.max(w.softEpoch, toEpoch);
678
+ this.#pendingChanges.push({
679
+ softEpoch: w.softEpoch,
680
+ tag
681
+ });
682
+ } else {
683
+ w.hardEpoch = Math.max(w.hardEpoch, toEpoch);
684
+ this.#pendingChanges.push({
685
+ hardEpoch: w.hardEpoch,
686
+ tag
687
+ });
688
+ }
689
+ this.#watermarks.set(tag, w);
690
+ }
691
+ this.#pendingFrom ??= fromEpoch;
692
+ return {
693
+ ack: { epoch: toEpoch },
694
+ effects: [{
695
+ deadlineMs: nowMs,
696
+ kind: "schedule-flush"
697
+ }]
698
+ };
699
+ }
700
+ };
701
+ //#endregion
702
+ //#region src/adapters/cloudflare/bus-server.ts
703
+ /** WS `OPEN` readyState (the numeric constant workerd and the browser share). */
704
+ const WS_OPEN = 1;
705
+ /** Close a socket, swallowing any throw (it may already be gone — the subscriber redials + resyncs). */
706
+ function safeClose(socket) {
707
+ try {
708
+ socket.close(CLOSE_DELIVERY_DOUBT, "astilba/cache: delivery doubt — reconnect + resync");
709
+ } catch {}
710
+ }
711
+ /**
712
+ * Send one datum to one subscriber, or CLOSE it on ANY delivery doubt: a non-OPEN
713
+ * socket, or a `send` that throws. Returns `true` iff the datum was handed to `send` cleanly. Never
714
+ * rethrows — a doubtful subscriber is closed and skipped so the fan-out continues to the rest.
715
+ */
716
+ function sendOrClose(socket, data) {
717
+ if (socket.readyState !== void 0 && socket.readyState !== WS_OPEN) {
718
+ safeClose(socket);
719
+ return false;
720
+ }
721
+ try {
722
+ socket.send(data);
723
+ return true;
724
+ } catch {
725
+ safeClose(socket);
726
+ return false;
727
+ }
728
+ }
729
+ /**
730
+ * The establishment handshake: emit `reset` THEN `hello{head, regId}` BEFORE any frame, so
731
+ * a subscriber starts with warm trust SUSPENDED and a head to reconverge on. `head` is the DO's OWN
732
+ * last-flushed epoch — NEVER `machine.currentEpoch` (it over-declares into the
733
+ * un-flushed window), NEVER a KV read-back (the mirror is stale-licensed; the DO is the truth
734
+ * source). The `hello` echoes `regId` so a mismatched subscriber closes loud.
735
+ */
736
+ function establish(socket, head, regId) {
737
+ sendOrClose(socket, encode({ kind: "reset" }));
738
+ sendOrClose(socket, encode({
739
+ head,
740
+ kind: "hello",
741
+ regId
742
+ }));
743
+ }
744
+ /**
745
+ * Fan ONE frame out to every current subscriber (stateless; no replay buffer). The frame
746
+ * is encoded ONCE, then delivered per-socket via `sendOrClose`, so a doubtful subscriber is closed
747
+ * and the rest still receive it. doBus emits NO `gap` here: the frame is a normal
748
+ * delivery, and the only failure mode is a per-socket CLOSE, whose remedy is the handshake.
749
+ */
750
+ function fanOut(sockets, frame) {
751
+ const data = encode({
752
+ frame,
753
+ kind: "frame"
754
+ });
755
+ for (const socket of sockets) sendOrClose(socket, data);
756
+ }
757
+ //#endregion
758
+ //#region src/adapters/cloudflare/kv.ts
759
+ /**
760
+ * KV's per-value ceiling (driver table): 25 MiB. An over-limit `set` is REJECTED
761
+ * `too_large` before any I/O — never truncated, never written.
762
+ */
763
+ const MAX_VALUE_BYTES = 26214400;
764
+ /**
765
+ * KV's write-side minimum residency (driver table): a `put` whose `expirationTtl`
766
+ * is below 60s is silently raised to 60s by KV itself. The driver raises it explicitly
767
+ * so the honored TTL is observable and matches what KV would do. Distinct from the
768
+ * read-side `cacheTtl` minimum (30s) — a different knob, not conflated here.
769
+ */
770
+ const KV_MIN_EXPIRATION_TTL = 60;
771
+ /**
772
+ * Read-cache `cacheTtl` per mirror-object kind, in SECONDS. The mutable `pointer`
773
+ * (`__state`) takes KV's pinned read-cache MINIMUM (30s); the immutable `delta`/`snap` cache hard
774
+ * (24h — cache the immutable, never the mutable). An UNHINTED read passes no `cacheTtl` and inherits
775
+ * KV's 60s edge-cache default (NOT minimal caching — the Appendix C trap this wiring closes).
776
+ */
777
+ const READ_CACHE_TTL_S = {
778
+ delta: 86400,
779
+ pointer: 30,
780
+ snap: 86400
781
+ };
782
+ /**
783
+ * A `StoreWriteError`-shaped rejection raised by the driver. A CLASS (not a bare
784
+ * object) so oxlint's throw rules are satisfied; consumers discriminate it
785
+ * STRUCTURALLY via `isStoreWriteError`, never by this class's identity, so it
786
+ * is never exported. `cause` carries the original KV rejection where there is one.
787
+ */
788
+ var KvStoreWriteError = class extends Error {
789
+ code;
790
+ retryable;
791
+ constructor(code, retryable, message, cause) {
792
+ super(message, cause === void 0 ? void 0 : { cause });
793
+ this.name = "KvStoreWriteError";
794
+ this.code = code;
795
+ this.retryable = retryable;
796
+ }
797
+ };
798
+ /**
799
+ * A `StoreReadError`-shaped rejection raised by the driver — the GET-side twin of
800
+ * `KvStoreWriteError`, and a CLASS for the same reason (oxlint's throw rules), recognized
801
+ * STRUCTURALLY by the kernel (`isStoreReadError`) and never by identity, so it is never
802
+ * exported. `cause` carries the original KV rejection.
803
+ */
804
+ var KvStoreReadError = class extends Error {
805
+ code;
806
+ retryable;
807
+ constructor(code, retryable, message, cause) {
808
+ super(message, { cause });
809
+ this.name = "KvStoreReadError";
810
+ this.code = code;
811
+ this.retryable = retryable;
812
+ }
813
+ };
814
+ /**
815
+ * Detect a KV rate-limit (429) rejection DEFENSIVELY. KV enforces 1 write/s/key and
816
+ * surfaces the ceiling as a 429; workerd throws that as an ordinary `Error` with no
817
+ * documented, stable structured field. So probe a few shapes WITHOUT assuming any
818
+ * exists — a numeric `status`/`statusCode`/`code` equal to 429, or a message that names
819
+ * the 429 / rate-limit condition — and treat everything unrecognized as a transient
820
+ * `unavailable` rather than mis-tagging it `throttled`. (Miniflare emits no real 429, so
821
+ * this branch is proven by the deterministic conformance fixture, not the integration
822
+ * lane — see DIVERGENCES true production 429 is a probe concern.)
823
+ */
824
+ function isRateLimited(error) {
825
+ if (typeof error === "object" && error !== null) {
826
+ if ("status" in error && error.status === 429) return true;
827
+ if ("statusCode" in error && error.statusCode === 429) return true;
828
+ if ("code" in error && error.code === 429) return true;
829
+ }
830
+ const message = error instanceof Error ? error.message : "";
831
+ return /\b429\b|too many requests|rate ?limit/iu.test(message);
832
+ }
833
+ /** Map a rejected KV write to the structural failure contract. */
834
+ function classifyWriteError(error) {
835
+ if (isRateLimited(error)) return new KvStoreWriteError("throttled", true, "cloudflareKV: write throttled (KV 1 write/s/key ceiling, 429)", error);
836
+ return new KvStoreWriteError("unavailable", true, "cloudflareKV: write failed (durable store unavailable)", error);
837
+ }
838
+ /**
839
+ * Map a rejected KV read to the structural failure contract. TOTAL, which is the whole
840
+ * design: the posture is that an L2 read failure of ANY shape is a miss-class event, so there is
841
+ * no rejection this function declines to name. `isRateLimited` is reused verbatim — the same 429 the
842
+ * write half recognizes, since it is the same platform saying the same thing — and everything else
843
+ * lands on transient `unavailable`.
844
+ *
845
+ * The totality is not tidiness. A partial classifier would name the shapes someone already thought
846
+ * of and drop the rest through the kernel's rethrow to the caller — and the rest is precisely the
847
+ * interesting set: the production faults nobody wrote a recognizer for. Read-side, over-naming costs
848
+ * an origin refill; under-naming costs the request. There is no read-side `too_large`: a size
849
+ * ceiling is a property of what a WRITE offers.
850
+ */
851
+ function classifyReadError(error) {
852
+ if (isRateLimited(error)) return new KvStoreReadError("throttled", true, "cloudflareKV: read throttled (KV rate ceiling, 429)", error);
853
+ return new KvStoreReadError("unavailable", true, "cloudflareKV: read failed (durable store unavailable)", error);
854
+ }
855
+ /** A `Store` backed by a single Cloudflare `KVNamespace` binding. */
856
+ var CloudflareKvStore = class {
857
+ #ns;
858
+ constructor(ns) {
859
+ this.#ns = ns;
860
+ }
861
+ async get(key, readKind) {
862
+ const cacheTtl = readKind === void 0 ? void 0 : READ_CACHE_TTL_S[readKind];
863
+ let result;
864
+ try {
865
+ result = await this.#ns.getWithMetadata(key, cacheTtl === void 0 ? void 0 : { cacheTtl });
866
+ } catch (error) {
867
+ throw classifyReadError(error);
868
+ }
869
+ if (result.value === null) return;
870
+ return result.metadata === null ? { value: result.value } : {
871
+ metadata: result.metadata,
872
+ value: result.value
873
+ };
874
+ }
875
+ async set(key, value, options) {
876
+ const sizeBytes = new TextEncoder().encode(value).length;
877
+ if (sizeBytes > MAX_VALUE_BYTES) throw new KvStoreWriteError("too_large", false, `cloudflareKV: value too large (${sizeBytes} > ${MAX_VALUE_BYTES} bytes)`);
878
+ const putOptions = {
879
+ ...options?.expirationTtl === void 0 ? {} : { expirationTtl: Math.max(KV_MIN_EXPIRATION_TTL, Math.ceil(options.expirationTtl)) },
880
+ ...options?.metadata === void 0 ? {} : { metadata: options.metadata }
881
+ };
882
+ try {
883
+ await this.#ns.put(key, value, putOptions);
884
+ } catch (error) {
885
+ throw classifyWriteError(error);
886
+ }
887
+ }
888
+ async delete(key) {
889
+ await this.#ns.delete(key);
890
+ }
891
+ };
892
+ /** Build a `Store` over a Cloudflare `KVNamespace` binding. */
893
+ function cloudflareKV(ns) {
894
+ return new CloudflareKvStore(ns);
895
+ }
896
+ //#endregion
897
+ //#region src/adapters/cloudflare/reg-id.ts
898
+ /**
899
+ * Refused: a Coordinator addressed by a Durable Object name that violates the regId
900
+ * grammar (`[a-z0-9._-]`, 1–64). Typed so the boot failure is unambiguous and machine-checkable —
901
+ * the fix is a conforming name or an explicit `REGISTRY_ID`. Thrown at first boot only (before any
902
+ * data), so it strands nothing: validate-and-refuse, not journal-wins. `Error.name` stays the class
903
+ * name; `registryName` carries the offending Durable Object name.
904
+ */
905
+ var InvalidRegistryNameError = class extends Error {
906
+ registryName;
907
+ constructor(registryName) {
908
+ super(`astilba/cache coordinator: Durable Object name ${JSON.stringify(registryName)} is not a valid registry id — it must match [a-z0-9._-], length 1-64; use a conforming name or set REGISTRY_ID (E19, E19-b)`);
909
+ this.name = "InvalidRegistryNameError";
910
+ this.registryName = registryName;
911
+ }
912
+ };
913
+ /**
914
+ * Resolve a Coordinator's regId from its platform identity. `name` is `ctx.id.name`
915
+ * (undefined for an un-named / `idFromString`-reached object); `envRegId` is `env.REGISTRY_ID`.
916
+ * Precedence: name ?? env ?? `"default"`. A NAME that violates the grammar REFUSES with a typed
917
+ * `InvalidRegistryNameError` (R2). An env/default winner is still asserted through the existing
918
+ * `assertRegId` (R3 — every winner passes the same charset gate), so a malformed `REGISTRY_ID` also
919
+ * fails loud. An empty-string env value is treated as unset (falls through to `"default"`).
920
+ */
921
+ function resolveRegId(name, envRegId) {
922
+ if (name !== void 0) {
923
+ if (!isRegId(name)) throw new InvalidRegistryNameError(name);
924
+ return name;
925
+ }
926
+ const fallback = envRegId !== void 0 && envRegId !== "" ? envRegId : DEFAULT_REG_ID;
927
+ assertRegId(fallback);
928
+ return fallback;
929
+ }
930
+ //#endregion
931
+ //#region src/adapters/cloudflare/coordinator.ts
932
+ /** The append-only command journal — the single source of truth for machine state. */
933
+ const JOURNAL_KEY = "__journal";
934
+ /** The cursor: the epoch through which a batch has been durably accepted into KV. */
935
+ const CURSOR_KEY = "kvFlushedEpoch";
936
+ /**
937
+ * The seeded registry identity — the `regId` this Coordinator journaled at FIRST boot,
938
+ * derived from its Durable Object name. Written EXACTLY ONCE (with the first `configure`) and never
939
+ * rewritten: it is the durable anchor the divergence detector compares a later name-derivation
940
+ * against (R1 — journal-wins). Its presence marks "identity established"; its absence marks a first
941
+ * boot. Distinct from `machine.regId` (which a bare cadence-`configure` would default) so the
942
+ * seeded id is unambiguous.
943
+ */
944
+ const IDENTITY_KEY = "regId";
945
+ /**
946
+ * KV physical residency, in SECONDS — KV `expirationTtl`. Delta batches age out at
947
+ * 48h; content-addressed snapshots at 7d; the `__state` pointer never expires (no TTL). TTL
948
+ * is host plumbing, not machine state — invisible to the content-addressed batch/snapshot
949
+ * bytes the transcript compares.
950
+ *
951
+ * @testExport exported solely so tests assert the single source of truth; not public API, no
952
+ * production importer — the integration residency scene pins the delta TTL (48h) the sink SELECTS
953
+ * (the decision, not KV's Miniflare-blind enforcement).
954
+ */
955
+ const DELTA_TTL_S = 172800;
956
+ /**
957
+ * @testExport exported solely so tests assert the single source of truth; not public API, no
958
+ * production importer — the integration residency scene pins the snapshot TTL (7d) the sink SELECTS.
959
+ */
960
+ const SNAP_TTL_S = 604800;
961
+ /**
962
+ * @testExport exported solely so tests assert the single source of truth; not public API, no
963
+ * production importer — a test pins the ≥1s floored flush window (the coalescing invariant, so a
964
+ * lowered tuning term can never breach the platform floor).
965
+ */
966
+ const FLUSH_WINDOW_MS = Math.max(1100, 1e3);
967
+ /**
968
+ * The Coordinator's KV flush sink. `executeFlushEffects` owns the write ORDER and
969
+ * the once-only `__state` bless; this sink owns only the KV I/O — routed through the
970
+ * `cloudflareKV` Store driver, so a rejected write is CLASSIFIED into the structural
971
+ * `StoreWriteError` vocabulary the kernel already speaks — no second classification.
972
+ * A `__state` 429 ⇒ `throttled` PROPAGATES out of the flush: nothing was blessed, the immutable
973
+ * objects are content-addressed/idempotent, and the alarm retries (no recovery logic needed).
974
+ *
975
+ * @testExport exported solely so tests assert the single source of truth; not public API, no
976
+ * production importer — the integration residency scene constructs it directly to drive the KV
977
+ * write-order + `__state`-bless behavior in isolation.
978
+ */
979
+ var CoordinatorFlushSink = class {
980
+ store;
981
+ broadcaster;
982
+ constructor(store, broadcaster) {
983
+ this.store = store;
984
+ this.broadcaster = broadcaster;
985
+ }
986
+ async putImmutable(key, bytes, kind) {
987
+ const expirationTtl = kind === "snap" ? SNAP_TTL_S : DELTA_TTL_S;
988
+ await this.store.set(key, bytes, { expirationTtl });
989
+ }
990
+ async putPointer(key, bytes) {
991
+ await this.store.set(key, bytes);
992
+ }
993
+ broadcast(frame) {
994
+ this.broadcaster(frame);
995
+ }
996
+ telemetry(event) {
997
+ console.warn("astilba/cache coordinator flush telemetry", event);
998
+ }
999
+ };
1000
+ var Coordinator = class extends DurableObject {
1001
+ #machine;
1002
+ #journal = [];
1003
+ #regIdDivergence = null;
1004
+ /**
1005
+ * The name/journal regId divergence detected at this instance's boot, or null when the
1006
+ * name still derives the journaled identity. A read-only boot diagnostic (surfaced to the
1007
+ * integration divergence scene via `runInDurableObject`); never consulted on a hot path.
1008
+ */
1009
+ get regIdDivergence() {
1010
+ return this.#regIdDivergence;
1011
+ }
1012
+ /** Strong read: live, un-memoized, primary-only — the DO IS the primary. */
1013
+ async check(tags) {
1014
+ return await this.ctx.blockConcurrencyWhile(async () => {
1015
+ return (await this.#load()).knowledgeOf(tags);
1016
+ });
1017
+ }
1018
+ /** Soft purge: max-on-write bump of `softEpoch`. */
1019
+ async expire(tags) {
1020
+ return await this.ctx.blockConcurrencyWhile(async () => await this.#mutate("expire", tags));
1021
+ }
1022
+ /** Hard purge: max-on-write bump of `hardEpoch`. */
1023
+ async delete(tags) {
1024
+ return await this.ctx.blockConcurrencyWhile(async () => await this.#mutate("delete", tags));
1025
+ }
1026
+ /** Register a retention lease; the GC horizon rises to the high-water. */
1027
+ async registerRetention(retentionMs) {
1028
+ await this.ctx.blockConcurrencyWhile(async () => {
1029
+ (await this.#load()).registerRetention(retentionMs);
1030
+ this.#journal.push({
1031
+ op: "registerRetention",
1032
+ retentionMs
1033
+ });
1034
+ await this.#persist({ [JOURNAL_KEY]: this.#journal });
1035
+ });
1036
+ }
1037
+ /**
1038
+ * The doBus live-channel endpoint. A subscriber opens a WebSocket here; the DO
1039
+ * accepts it HIBERNATABLY (no per-subscriber attachment, ZERO replay state) and runs
1040
+ * the establishment handshake: `reset` → `hello{head, regId}`, BEFORE any frame. `head`
1041
+ * is the DO's OWN last-flushed epoch (`#flushedEpoch`) and the `hello` echoes `regId`,
1042
+ *so a mismatched subscriber closes loud. Serialized under `blockConcurrencyWhile` so the
1043
+ * handshake snapshots a consistent (cursor, regId) against any in-flight mutation.
1044
+ */
1045
+ async fetch(request) {
1046
+ if (request.headers.get("Upgrade") !== "websocket") return new Response("astilba/cache coordinator: expected a WebSocket upgrade", { status: 426 });
1047
+ const { 0: client, 1: server } = new WebSocketPair();
1048
+ await this.ctx.blockConcurrencyWhile(async () => {
1049
+ this.ctx.acceptWebSocket(server);
1050
+ const machine = await this.#load();
1051
+ const head = await this.#flushedEpoch();
1052
+ establish(server, head, machine.regId);
1053
+ });
1054
+ return new Response(null, {
1055
+ status: 101,
1056
+ webSocket: client
1057
+ });
1058
+ }
1059
+ /**
1060
+ * Hibernatable WebSocket message handler. The doBus client is RECEIVE-ONLY —
1061
+ * it dials, decodes, and dispatches; it never sends. Any inbound message is unexpected and there
1062
+ * is no per-subscriber state to update, so it is ignored. Defined so the hibernation runtime has a
1063
+ * message handler for woken sockets.
1064
+ */
1065
+ webSocketMessage(_ws, _message) {}
1066
+ /**
1067
+ * Hibernatable WebSocket close handler. Stateless fan-out holds ZERO
1068
+ * per-subscriber state, so a closed socket needs no reclamation — a later `getWebSockets()` simply
1069
+ * will not include it. Defined so the hibernation runtime has a close handler for woken sockets.
1070
+ */
1071
+ webSocketClose(_ws, _code, _reason, _wasClean) {}
1072
+ /** Hibernatable WebSocket error handler — best-effort close; no state to reclaim. */
1073
+ webSocketError(ws, _error) {
1074
+ try {
1075
+ ws.close(1011, "astilba/cache coordinator: socket error");
1076
+ } catch {}
1077
+ }
1078
+ /**
1079
+ * The DO alarm handler, serving BOTH the flush window and the heartbeat cadence off the one
1080
+ * alarm slot. A PURE function of durable state, idempotent under at-least-once
1081
+ * delivery + redeploy: it rehydrates the machine from the journal and either FLUSHES the
1082
+ * coalescing window `(kvFlushedEpoch, epoch]` into one content-addressed batch (advancing the
1083
+ * durable cursor ONLY after KV accepts), or — on an EMPTY window — refreshes the `__state`
1084
+ * freshness via a state-neutral heartbeat. A redelivery before the cursor advances re-writes the
1085
+ * identical (content-addressed, write-once) bytes; a redelivery after finds an empty window and
1086
+ * heartbeats (or no-ops when the cadence is unset).
1087
+ */
1088
+ async alarm() {
1089
+ await this.ctx.blockConcurrencyWhile(async () => {
1090
+ const machine = await this.#load();
1091
+ const nowMs = Date.now();
1092
+ const effects = machine.flush(nowMs);
1093
+ if (effects.length > 0) {
1094
+ await executeFlushEffects(effects, new CoordinatorFlushSink(this.#kvStore(), (frame) => {
1095
+ this.#fanOut(frame);
1096
+ }), machine.regId);
1097
+ this.#journal.push({
1098
+ nowMs,
1099
+ op: "flush"
1100
+ });
1101
+ await this.#persist({
1102
+ [JOURNAL_KEY]: this.#journal,
1103
+ [CURSOR_KEY]: machine.currentEpoch
1104
+ });
1105
+ } else await this.#heartbeat(machine, nowMs);
1106
+ await this.#armHeartbeatAlarm(nowMs);
1107
+ });
1108
+ }
1109
+ async #mutate(op, tags) {
1110
+ const machine = await this.#load();
1111
+ const nowMs = Date.now();
1112
+ const { ack, effects } = op === "expire" ? machine.expire(tags, nowMs) : machine.delete(tags, nowMs);
1113
+ this.#journal.push({
1114
+ op,
1115
+ tags
1116
+ });
1117
+ await this.#persist({ [JOURNAL_KEY]: this.#journal });
1118
+ for (const schedule of effects) await this.#armFlushAlarm(schedule.deadlineMs);
1119
+ return ack;
1120
+ }
1121
+ /**
1122
+ * The KV mirror as a `Store` — the flush write path goes THROUGH the `cloudflareKV`
1123
+ * driver (not raw `put`), so a rejected write is classified into the structural
1124
+ * `StoreWriteError` vocabulary the kernel already speaks, and the sink stays a
1125
+ * dumb key/bytes writer. Built per flush off the LIVE `env.REGISTRY_KV` binding.
1126
+ */
1127
+ #kvStore() {
1128
+ return cloudflareKV(this.env.REGISTRY_KV);
1129
+ }
1130
+ /**
1131
+ * The DO's OWN last-flushed epoch — the doBus `hello` head. It is the durable
1132
+ * `kvFlushedEpoch` cursor the last pointer bless advanced (the value that pointer carried), read
1133
+ * from journaled DO state. NEVER `machine.currentEpoch` (it over-declares into the un-flushed
1134
+ * `(kvFlushedEpoch, epoch]` window, stranding a caught-up subscriber suspect on a purge not yet in
1135
+ * KV), and NEVER a KV read-back (the mirror is stale-licensed; the DO is the truth source). Absent
1136
+ * (a cold DO before any flush) ⇒ 0.
1137
+ */
1138
+ async #flushedEpoch() {
1139
+ return await this.ctx.storage.get(CURSOR_KEY) ?? 0;
1140
+ }
1141
+ /**
1142
+ * Stateless fan-out of one replication frame to every hibernatable subscriber.
1143
+ * Reads the CURRENT sockets via `getWebSockets()` (post-wake-safe) and delivers the frame once
1144
+ * each; a doubtful subscriber is CLOSED, never blocking the rest. No replay buffer, no
1145
+ * per-subscriber state — `reset` → `hello` + the reader's KV resync is the catch-up. doBus emits
1146
+ * NO server `gap`.
1147
+ */
1148
+ #fanOut(frame) {
1149
+ fanOut(this.ctx.getWebSockets(), frame);
1150
+ }
1151
+ /**
1152
+ * B5 fixed-deadline alarm: arm at the next window boundary, EARLIEST-WINS. Sets the
1153
+ * alarm to the NEAREST of any currently-armed deadline (possibly the heartbeat cadence) and
1154
+ * this flush's window boundary. Two properties, one `min`: a pending heartbeat NEVER starves the
1155
+ * flush (the honest ≤1.1s bound holds — the defect the old `getAlarm() !== null ⇒ return` guard
1156
+ * caused once a far heartbeat deadline was armed), and an already-armed EARLIER flush deadline is
1157
+ * never pushed forward (`setAlarm` overrides, so a naive re-arm would slide the deadline out and
1158
+ * starve the flush — platform note).
1159
+ */
1160
+ async #armFlushAlarm(nowMs) {
1161
+ const deadline = (Math.floor(nowMs / FLUSH_WINDOW_MS) + 1) * FLUSH_WINDOW_MS;
1162
+ const existing = await this.ctx.storage.getAlarm();
1163
+ await this.ctx.storage.setAlarm(earliestAlarm(existing, deadline));
1164
+ }
1165
+ /**
1166
+ * Issue the machine's state-neutral liveness heartbeat: refresh `__state.flushedAt` through the SAME
1167
+ * shared executor + sink a flush uses — the executor is the ONLY `__state` writer, so there is no
1168
+ * raw put. A no-op when the cadence is UNSET (the default in every test lane bar the
1169
+ * heartbeat suite), so an idle alarm there blesses nothing. The pointer carries the current
1170
+ * epoch/lastBatch/snapBatch UNCHANGED — nothing durable moves, so there is no journal append.
1171
+ */
1172
+ async #heartbeat(machine, nowMs) {
1173
+ if (this.#heartbeatIntervalMs() === null) return;
1174
+ await executeFlushEffects([machine.heartbeat(nowMs)], new CoordinatorFlushSink(this.#kvStore(), (frame) => {
1175
+ this.#fanOut(frame);
1176
+ }), machine.regId);
1177
+ }
1178
+ /**
1179
+ * Re-arm the heartbeat cadence, EARLIEST-WINS. Sets the alarm to the NEAREST of the
1180
+ * currently-armed deadline and `nowMs + interval`, so a pending B5 flush deadline is never pushed
1181
+ * out (unlike the old unconditional `setAlarm(now + interval)` last-writer-wins override). No-op
1182
+ * when the cadence is unset — the heartbeat path stays fully dormant there.
1183
+ */
1184
+ async #armHeartbeatAlarm(nowMs) {
1185
+ const intervalMs = this.#heartbeatIntervalMs();
1186
+ if (intervalMs === null) return;
1187
+ const existing = await this.ctx.storage.getAlarm();
1188
+ await this.ctx.storage.setAlarm(earliestAlarm(existing, nowMs + intervalMs));
1189
+ }
1190
+ /**
1191
+ * The heartbeat interval in ms from the config var (Ruling B): `env` decides WHEN the
1192
+ * shell issues a heartbeat command, never a machine parameter. `null` when unset or malformed —
1193
+ * the whole heartbeat path stays dormant (the default in every test lane bar the heartbeat suite).
1194
+ */
1195
+ #heartbeatIntervalMs() {
1196
+ const configured = this.env.REGISTRY_HEARTBEAT_MS;
1197
+ if (configured === void 0) return null;
1198
+ const intervalMs = Number(configured);
1199
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return null;
1200
+ return intervalMs;
1201
+ }
1202
+ /**
1203
+ * Derive this Coordinator's regId from its platform identity: identity = address. The
1204
+ * DO's OWN name (`ctx.id.name`, the string clients address it by) wins; `env.REGISTRY_ID` is the
1205
+ * DEMOTED fallback for an un-named object, then `"default"`. A NAME violating the grammar
1206
+ * REFUSES loud (R2 — a typed `InvalidRegistryNameError`). Called only from `#establishConfig`.
1207
+ */
1208
+ #deriveRegId() {
1209
+ return resolveRegId(this.ctx.id.name, this.env.REGISTRY_ID);
1210
+ }
1211
+ /**
1212
+ * Establish identity + cadence at boot (R1 + Ruling B). Two concerns, one seam:
1213
+ *
1214
+ * IDENTITY (R1 — journal-wins, never re-journal). regId derives from the DO name (`#deriveRegId`,
1215
+ * which REFUSES an invalid name — R2). At FIRST boot (no `IDENTITY_KEY`) it is journaled ONCE,
1216
+ * folded into the first `configure` with the deployed cadence, and recorded durably. On a LATER
1217
+ * boot regId is NEVER re-derived-and-rejournaled: this is a pure DIVERGENCE DETECTOR — if the name
1218
+ * now derives a DIFFERENT id than the journaled one, emit `regid_divergence` telemetry and KEEP
1219
+ * the journaled identity. Divergence cannot serve dead (both replication channels fail closed on
1220
+ * identity mismatch — the pointer's regId echo hello's regId echo ); it costs
1221
+ * misses/checks/telemetry, never a stale serve. Migration to a new identity = addressing a NEW
1222
+ * name (fresh journal, fresh keyspace), never a rekey of the live one.
1223
+ *
1224
+ * A name-seeded DO can NEVER diverge from itself: name-first precedence makes the journaled id
1225
+ * EQUAL the name at first boot, and the name is the address, so a steady-state named object always
1226
+ * re-derives its own journaled id. A `regid_divergence` in the field therefore means one of exactly
1227
+ * two things — legacy env-seeded data (journaled before), or a fallback-seeded (`newUniqueId`)
1228
+ * object whose env/default seed later disagrees — never a healthy name-addressed registry.
1229
+ *
1230
+ * CADENCE (Ruling B, unchanged in spirit). `env` may only AUTHOR a journaled `configure`; it never
1231
+ * parameterizes the machine directly. A deployed cadence differing from the journal's effective
1232
+ * config journals a fresh `configure` — PROSPECTIVE, at a definite journal position, replay-exact.
1233
+ * Crucially it carries the JOURNALED regId forward, so a cadence change NEVER rekeys the mirror
1234
+ * (the orphaning defect the old regId-in-the-diff path would cause — R1). Idempotent: a boot whose
1235
+ * cadence already matches writes nothing.
1236
+ */
1237
+ async #establishConfig(machine) {
1238
+ const derived = this.#deriveRegId();
1239
+ const seeded = await this.ctx.storage.get(IDENTITY_KEY);
1240
+ const cadence = this.#cadenceOptionsFromEnv();
1241
+ if (seeded === void 0) {
1242
+ const options = {
1243
+ ...cadence,
1244
+ regId: derived
1245
+ };
1246
+ machine.configure(options);
1247
+ this.#journal.push({
1248
+ op: "configure",
1249
+ options
1250
+ });
1251
+ await this.#persist({
1252
+ [IDENTITY_KEY]: derived,
1253
+ [JOURNAL_KEY]: this.#journal
1254
+ });
1255
+ this.#regIdDivergence = null;
1256
+ return;
1257
+ }
1258
+ if (derived === seeded) this.#regIdDivergence = null;
1259
+ else {
1260
+ this.#regIdDivergence = {
1261
+ journaled: seeded,
1262
+ name: derived
1263
+ };
1264
+ console.warn("astilba/cache coordinator regid divergence", {
1265
+ event: TELEMETRY_EVENTS.regid_divergence,
1266
+ journaled: seeded,
1267
+ name: derived
1268
+ });
1269
+ }
1270
+ const desired = new RegistryMachine();
1271
+ desired.configure(cadence);
1272
+ const current = machine.effectiveConfig;
1273
+ const want = desired.effectiveConfig;
1274
+ if (current.snapshotEveryBatches === want.snapshotEveryBatches && current.snapshotIntervalMs === want.snapshotIntervalMs && current.maxSnapshotBytes === want.maxSnapshotBytes) return;
1275
+ const options = {
1276
+ ...cadence,
1277
+ regId: seeded
1278
+ };
1279
+ machine.configure(options);
1280
+ this.#journal.push({
1281
+ op: "configure",
1282
+ options
1283
+ });
1284
+ await this.#persist({ [JOURNAL_KEY]: this.#journal });
1285
+ }
1286
+ /**
1287
+ * Parse the snapshot CADENCE knobs from config vars — an empty object ⇒ the machine's
1288
+ * 500-batch / 6h / 25 MiB defaults. Only well-formed values are forwarded
1289
+ * (exactOptionalPropertyTypes: no `undefined` pass-through). The scoping `regId` is NOT parsed
1290
+ * here (it derives from the DO name via `#deriveRegId`, journal-wins) — this returns
1291
+ * cadence-only options that `#establishConfig` combines with the journaled regId to author a
1292
+ * `configure` (Ruling B). Never fed to the machine constructor on rehydration.
1293
+ */
1294
+ #cadenceOptionsFromEnv() {
1295
+ const options = {};
1296
+ const everyBatches = Number(this.env.REGISTRY_SNAPSHOT_EVERY_BATCHES);
1297
+ if (Number.isFinite(everyBatches) && everyBatches > 0) options.snapshotEveryBatches = everyBatches;
1298
+ const intervalMs = Number(this.env.REGISTRY_SNAPSHOT_INTERVAL_MS);
1299
+ if (Number.isFinite(intervalMs) && intervalMs > 0) options.snapshotIntervalMs = intervalMs;
1300
+ const maxSnapshotBytes = Number(this.env.REGISTRY_MAX_SNAPSHOT_BYTES);
1301
+ if (Number.isFinite(maxSnapshotBytes) && maxSnapshotBytes > 0) options.maxSnapshotBytes = maxSnapshotBytes;
1302
+ return options;
1303
+ }
1304
+ /**
1305
+ * Rebuild the hosted machine from durable state on first touch / after a redeploy. Replays the
1306
+ * journal through a FRESH machine (the journal is the SINGLE durable truth — cadence + identity
1307
+ * included, via `configure` commands), discarding the replayed effects (state reconstruction only
1308
+ * — no re-write to KV, no re-broadcast). Then establishes config (`#establishConfig`): seeds the
1309
+ * Identity once / detects its divergence, and reconciles the cadence into a fresh
1310
+ * `configure` if the deployed cadence changed (Ruling B). Serialized by the caller's
1311
+ * `blockConcurrencyWhile`, so two events never rehydrate concurrently.
1312
+ */
1313
+ async #load() {
1314
+ if (this.#machine !== void 0) return this.#machine;
1315
+ const journal = await this.ctx.storage.get(JOURNAL_KEY) ?? [];
1316
+ const machine = new RegistryMachine();
1317
+ for (const entry of journal) switch (entry.op) {
1318
+ case "expire":
1319
+ machine.expire(entry.tags, 0);
1320
+ break;
1321
+ case "delete":
1322
+ machine.delete(entry.tags, 0);
1323
+ break;
1324
+ case "registerRetention":
1325
+ machine.registerRetention(entry.retentionMs);
1326
+ break;
1327
+ case "configure":
1328
+ machine.configure(entry.options);
1329
+ break;
1330
+ case "flush":
1331
+ machine.flush(entry.nowMs ?? 0);
1332
+ break;
1333
+ default: throw new Error(`unknown journal entry: ${String(entry)}`);
1334
+ }
1335
+ this.#journal = journal;
1336
+ this.#machine = machine;
1337
+ await this.#establishConfig(machine);
1338
+ return machine;
1339
+ }
1340
+ /**
1341
+ * Persist durable state, aborting the instance on any storage failure so a partial
1342
+ * write can never leave the in-memory machine ahead of the journal: the next event
1343
+ * rehydrates from the last consistent durable state (crash-consistency).
1344
+ */
1345
+ async #persist(entries) {
1346
+ try {
1347
+ await this.ctx.storage.put(entries);
1348
+ } catch (error) {
1349
+ this.ctx.abort("coordinator: durable persist failed");
1350
+ throw error;
1351
+ }
1352
+ }
1353
+ };
1354
+ //#endregion
1355
+ //#region src/adapters/cloudflare/do-registry.ts
1356
+ /** A `Registry` backed by a Coordinator Durable Object, reached over its RPC stub. */
1357
+ var DoRegistry = class {
1358
+ #source;
1359
+ /** The mirror-key scope the reader resyncs under. Defaults to `"default"`; it MUST equal the
1360
+ * Coordinator's regId so the client-side reader addresses the same keyspace the DO writes. Under
1361
+ * that regId IS the Coordinator's Durable Object NAME (identity = address) — so a caller who
1362
+ * addresses the DO via `idFromName(name)` passes that SAME `name` here. (C4 ledger: the
1363
+ * request-piggyback golden path should take ONE registry `name` and derive BOTH the DO stub —
1364
+ * `env.COORDINATOR.get(idFromName(name))` — AND this `regId` from it, so the address and the scope
1365
+ * cannot drift.) */
1366
+ regId;
1367
+ constructor(source, regId) {
1368
+ this.#source = source;
1369
+ this.regId = regId;
1370
+ }
1371
+ /** The stub for THIS call. A thunk source is invoked per use — never memoized, which is the
1372
+ * entire point: a cached stub is a stub minted in some earlier request, and the platform refuses
1373
+ * it. Minting is cheap (it is address arithmetic, not a connection), so there is nothing to save
1374
+ * by holding one and a working cache to lose. A bare stub source is returned as-is: the caller
1375
+ * chose to own the lifetime, and the signature says they may. */
1376
+ get #stub() {
1377
+ return typeof this.#source === "function" ? this.#source() : this.#source;
1378
+ }
1379
+ async check(tags) {
1380
+ return await this.#stub.check(tags);
1381
+ }
1382
+ async expire(tags) {
1383
+ return await this.#stub.expire(tags);
1384
+ }
1385
+ async delete(tags) {
1386
+ return await this.#stub.delete(tags);
1387
+ }
1388
+ async registerRetention(retentionMs) {
1389
+ await this.#stub.registerRetention(retentionMs);
1390
+ }
1391
+ };
1392
+ /** Build a `Registry` from a Coordinator DO stub OR a thunk that mints one (`StubSource` —
1393
+ * the widening is additive, so `doRegistry(stub, regId)` remains exactly as signed). `regId`
1394
+ * must match the Coordinator's regId — which is its Durable Object NAME
1395
+ * (identity = address) — so the client-side reader resyncs the same scoped keyspace the DO writes;
1396
+ * defaults to `"default"` for single-registry (zero-config) deployments.
1397
+ *
1398
+ * Pass a THUNK from anything that outlives a request (`createWorkersCache` does); pass a bare stub
1399
+ * when the caller mints and uses within one. */
1400
+ function doRegistry(source, regId = DEFAULT_REG_ID) {
1401
+ return new DoRegistry(source, regId);
1402
+ }
1403
+ //#endregion
1404
+ //#region src/adapters/cloudflare/redial.ts
1405
+ /** First redial delay (≈ the ladder's fastest rung). */
1406
+ const DEFAULT_BASE_DELAY_MS = 1e3;
1407
+ /** Redial backoff ceiling — the same 300s cap the poll-failure backoff uses. */
1408
+ const MAX_REDIAL_DELAY_MS = 3e5;
1409
+ /** ×2 per consecutive failed establishment. */
1410
+ const REDIAL_BACKOFF_FACTOR = 2;
1411
+ /** Symmetric jitter fraction (±20%), drawn from the injected rng. */
1412
+ const DEFAULT_JITTER_FRACTION = .2;
1413
+ /**
1414
+ * A `Bus` that keeps ONE live doBus subscription up (driver duty), redialing
1415
+ * LAZILY. Wraps the mechanism-only client: every close schedules a backed-off redial that the
1416
+ * next `tick` performs, and the app's `onEvent` sees an uninterrupted event stream across sockets
1417
+ * (each new socket re-handshakes `reset` → `hello`, which is exactly the signal the kernel reader
1418
+ * needs to re-resync — the same events it would see on a fresh subscribe).
1419
+ */
1420
+ var RedialingBus = class {
1421
+ #dial;
1422
+ #options;
1423
+ #baseDelayMs;
1424
+ #jitterFraction;
1425
+ /** The live inner subscription, or `null` while the channel is down. */
1426
+ #inner = null;
1427
+ /** The consecutive-failure count the backoff ladder rides on. */
1428
+ #attempt = 0;
1429
+ /** `true` once `close()` is called: no tick may redial afterwards. */
1430
+ #closed = false;
1431
+ /** The instant a redial becomes due, or `null` when the channel is up (nothing to redial) or has
1432
+ * never been dialed. Written when a close is REPORTED; consumed by `tick`. */
1433
+ #dueAtMs = null;
1434
+ /** The subscriber, held so a redial can re-attach the SAME consumer to a new socket. */
1435
+ #onEvent = null;
1436
+ /** The most recent `nowMs` a tick supplied — this driver's ONLY clock (`null` before the first
1437
+ * tick). See `#backoffFrom` for why a close is dated from it. */
1438
+ #lastTickMs = null;
1439
+ constructor(dial, options) {
1440
+ this.#dial = dial;
1441
+ this.#options = options;
1442
+ this.#baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
1443
+ this.#jitterFraction = options.jitterFraction ?? DEFAULT_JITTER_FRACTION;
1444
+ }
1445
+ subscribe(onEvent) {
1446
+ this.#onEvent = onEvent;
1447
+ this.#connect();
1448
+ return { close: () => {
1449
+ this.#closed = true;
1450
+ this.#dueAtMs = null;
1451
+ this.#onEvent = null;
1452
+ this.#inner?.close();
1453
+ this.#inner = null;
1454
+ } };
1455
+ }
1456
+ /**
1457
+ * Perform a DUE redial — called from a request context, which is what makes the dial legal.
1458
+ * A no-op when the channel is up, when nothing is due yet, or after `close()`. Cheap and
1459
+ * idempotent, so a carrier may call it on every tick without gating.
1460
+ */
1461
+ tick(nowMs) {
1462
+ this.#lastTickMs = nowMs;
1463
+ if (this.#closed || this.#dueAtMs === null || nowMs < this.#dueAtMs) return;
1464
+ this.#dueAtMs = null;
1465
+ this.#connect();
1466
+ }
1467
+ /** Open one socket and wire it. The close handler SCHEDULES the next attempt; it never dials. */
1468
+ #connect() {
1469
+ if (this.#closed || this.#onEvent === null) return;
1470
+ const onEvent = this.#onEvent;
1471
+ this.#inner = doBus(this.#dial, {
1472
+ onClose: (info) => {
1473
+ this.#onSocketClosed(info);
1474
+ },
1475
+ regId: this.#options.regId
1476
+ }).subscribe((event) => {
1477
+ if (event.kind === "hello") this.#attempt = 0;
1478
+ onEvent(event);
1479
+ });
1480
+ }
1481
+ /** A socket closed: count it, schedule the next attempt, and pass the report through. */
1482
+ #onSocketClosed(info) {
1483
+ this.#inner = null;
1484
+ if (!this.#closed) {
1485
+ this.#attempt += 1;
1486
+ this.#dueAtMs = this.#backoffFrom() + this.#jitter(this.#delayFor(this.#attempt));
1487
+ }
1488
+ this.#options.onClose?.(info);
1489
+ }
1490
+ /**
1491
+ * The instant a backoff is measured from. This driver is handed time ONLY by `tick`, and a socket
1492
+ * dies whenever it dies — between ticks, with no `nowMs` in scope. So a close is dated from the
1493
+ * last tick observed.
1494
+ *
1495
+ * THE RESIDUE, PRICED: the last tick is at most one tick-cadence old, so a rung can be served up
1496
+ * to that much early. It is bounded (never "arbitrarily stale" — every tick refreshes it) and it
1497
+ * errs toward redialing SOONER, which is the direction that costs a dial rather than a stale
1498
+ * serve. Against the rungs that matter — the cap is 300s, the cadence is ~1s — it is noise.
1499
+ *
1500
+ * Before the first tick there is no observation at all, so `0` stands in: the due-time becomes
1501
+ * "the delay, counted from the epoch", which every real `nowMs` already exceeds, so the first tick
1502
+ * after an early close redials at once. That is the right answer for the case it covers — a dial
1503
+ * that failed during the very first request, where a prompt retry is exactly what is wanted.
1504
+ */
1505
+ #backoffFrom() {
1506
+ return this.#lastTickMs ?? 0;
1507
+ }
1508
+ /** The un-jittered delay for the nth consecutive failed establishment: base × 2^(n−1), cap 300s. */
1509
+ #delayFor(attempt) {
1510
+ const grown = this.#baseDelayMs * REDIAL_BACKOFF_FACTOR ** Math.max(0, attempt - 1);
1511
+ return Math.min(grown, MAX_REDIAL_DELAY_MS);
1512
+ }
1513
+ /** Jitter a delay symmetrically by ±`jitterFraction` off the injected rng — a fleet whose
1514
+ * Coordinator just restarted must not redial in lockstep. rng.next() ∈ [0,1) ⇒ factor ∈ [1−f, 1+f). */
1515
+ #jitter(baseMs) {
1516
+ const f = this.#jitterFraction;
1517
+ return baseMs * (1 - f + 2 * f * this.#options.rng.next());
1518
+ }
1519
+ };
1520
+ /**
1521
+ * Build a self-redialing doBus `Bus`. This is the bus `createWorkersCache` wires: the
1522
+ * transport reports its close, and this driver owns the reconnect + the ×2/300s jittered
1523
+ * backoff — LAZILY, performed by `tick` inside a request context rather than by a timer that would
1524
+ * be refused at global scope and would race a dead isolate everywhere else. Use the bare `doBus`
1525
+ * instead when the caller drives redials itself (the integration lane does — tests own time).
1526
+ */
1527
+ function redialingDoBus(dial, options) {
1528
+ return new RedialingBus(dial, options);
1529
+ }
1530
+ //#endregion
1531
+ //#region src/adapters/cloudflare/workers-cache.ts
1532
+ /**
1533
+ * The safe posture `createWorkersCache` stamps unless a caller overrides it: reads are
1534
+ * `eventual`, stale-if-error resilience is on (the default HTTP retriable classifier), an
1535
+ * `unknown` validity fails CLOSED to a live registry check (`registry-check`, never `miss`), and the
1536
+ * liveness heartbeat cadence the reader's silence monitor mirrors is the one number above (30s) — the
1537
+ * READER end of it; the Coordinator end is a wrangler var no factory can reach (R6).
1538
+ * Set EXPLICITLY (not left to `createCache`'s implicit defaults) so the posture is a single, pinned
1539
+ * object the factory spreads — a caller's `config.defaults` still wins per-field. Private-by-default
1540
+ * (a principal-derived request is `usr:`-scoped, L1-only) is `createCache`'s own scope resolution,
1541
+ *inherited unchanged. Internal (a caller never needs it — the factory already merges it
1542
+ * with `config.defaults`); the safe-posture tooth proves it BEHAVIORALLY, not by importing it.
1543
+ */
1544
+ const SAFE_DEFAULTS = {
1545
+ consistency: "eventual",
1546
+ heartbeatInterval: "30s",
1547
+ staleIfError: isRetriableHttp,
1548
+ unknownPolicy: "registry-check"
1549
+ };
1550
+ /** The Coordinator `/bus` WebSocket endpoint the dial upgrades against (mirrors the DO's router). */
1551
+ const BUS_URL = "https://coordinator/bus";
1552
+ /**
1553
+ * Minimum ms between two carrier ticks on ONE instance. The ladder's fastest rung (~1s): ticking
1554
+ * faster cannot make the poller act sooner (every path it drives is gated by its own due-time), so
1555
+ * anything below this is pure waste under load. A burst of 1000 reads/second costs one tick.
1556
+ */
1557
+ const CARRIER_TICK_MIN_INTERVAL_MS = 1e3;
1558
+ /**
1559
+ * THE CARRIER (the Ticks clause). Anything this library ships as a ready-made composition has to
1560
+ * bring a freshness driver with it; this is the one `createWorkersCache` brings.
1561
+ *
1562
+ * WHY IT HAS TO EXIST. The poller and the redial ladder are both tick-driven and neither runs a
1563
+ * timer — a Workers isolate has no free-running timers, and the reachability rule forbids a timer performing I/O
1564
+ * anyway. Until now the ONLY thing that ever called a tick was the React Router middleware, so a
1565
+ * plain Worker built on this factory had no driver at all: its `__state` poll never ran, its
1566
+ * silence monitor was never fed, and its redial ladder never advanced. Nothing then held its
1567
+ * eventual reads inside the staleness bound INV-25 fixes. Whoever ships the drivers owes them
1568
+ * something to drive them.
1569
+ *
1570
+ * WHY THE READ PATH. It is the only thing a plain Worker is guaranteed to do. Riding the read means
1571
+ * freshness observation is request-coupled — detection latency is max(threshold, inter-request gap)
1572
+ * — which is the same honest residue the middleware's request-start tick has, and for the same
1573
+ * reason it is acceptable: the only reader a late observation could hurt is one that is not reading.
1574
+ *
1575
+ * ADOPT-DON'T-AWAIT. The tick is fired and NOT awaited: a freshness probe must never add latency to
1576
+ * the read that happened to trigger it, and must never fail it. `#run` catches everything, so the
1577
+ * returned promise cannot reject and nothing floats as an unhandled rejection.
1578
+ *
1579
+ * CANCELLATION-TOLERANT, AND HERE IS THE ARGUMENT. An unawaited promise in a Workers request is
1580
+ * cancelled when the response is returned — the tick's in-flight I/O simply rejects. That is safe,
1581
+ * by three independent properties, none of which is an accident of timing:
1582
+ * (i) the tick's writes are per-frame atomic and MONOTONE (max-on-write watermarks, a contiguous
1583
+ * applied epoch), so an interrupted pass leaves the reader BEHIND where it would have been —
1584
+ * never ahead. Behind is the fail-closed direction: it costs a live check, never a stale
1585
+ * serve.
1586
+ * (ii) suspicion is cleared only AFTER a replay completes, so a pass cut short mid-replay cannot
1587
+ * leave the reader falsely converged. The half-applied state is exactly the state it would
1588
+ * have had if the pass had never started, plus some correctly-applied frames.
1589
+ * (iii) the poller's in-flight guard is released in a `finally`, and the rejection a teardown
1590
+ * produces travels through the same `await` any other failure does — so it cannot wedge.
1591
+ * The cursor below is written BEFORE the tick is fired for the same family of reasons: a cancelled
1592
+ * tick must not leave the gate open for the next read to retry into a hot loop.
1593
+ */
1594
+ function withCarrier(cache, bus, clock, sink, onSinkError) {
1595
+ const poller = replicationPollerOf(cache);
1596
+ let lastTickMs = null;
1597
+ const run = async (nowMs) => {
1598
+ try {
1599
+ bus.tick(nowMs);
1600
+ await poller?.onPollTick(nowMs);
1601
+ } catch (error) {
1602
+ safeEmit(sink, {
1603
+ error: error instanceof Error ? error.name : "unknown",
1604
+ type: TELEMETRY_EVENTS.poll_tick_failed
1605
+ }, onSinkError);
1606
+ }
1607
+ };
1608
+ const carry = () => {
1609
+ const nowMs = clock.now();
1610
+ if (lastTickMs !== null && nowMs - lastTickMs < CARRIER_TICK_MIN_INTERVAL_MS) return;
1611
+ lastTickMs = nowMs;
1612
+ run(nowMs);
1613
+ };
1614
+ const carried = {
1615
+ ...cache,
1616
+ async getOrSet(options) {
1617
+ carry();
1618
+ return await cache.getOrSet(options);
1619
+ },
1620
+ async getOrSetEntry(options) {
1621
+ carry();
1622
+ return await cache.getOrSetEntry(options);
1623
+ }
1624
+ };
1625
+ aliasCacheIdentity(carried, cache);
1626
+ return carried;
1627
+ }
1628
+ /** L1 bounds for the Workers profile (example): 512 entries, 5 MB. */
1629
+ const L1_MAX_ENTRIES = 512;
1630
+ const L1_MAX_BYTES = 5e6;
1631
+ /**
1632
+ * Build a Workers-native `Cache` from one registry `name` + a KV namespace + a Coordinator DO
1633
+ * namespace. Sugar over `createCache`: the wall clock and RNG enter here at the platform
1634
+ * boundary and reach the kernel only as the injected `clock`/`rng`; the drivers are composed against
1635
+ * the single `name` (DO stub, registry id, key namespace). A caller's `defaults` override the safe
1636
+ * posture per-field; everything else is fixed.
1637
+ *
1638
+ * WHY THE L1 IS NOT OPTIONAL HERE, AND WHAT IT COSTS TO SKIP IT. This factory always composes an L1,
1639
+ * and under an L2 outage that L1 is the absorber. A failing L2 read is suppressed to a plain miss,
1640
+ *so the reader refills from origin — and with an L1 the refill lands there, is checked first
1641
+ * on every subsequent read, and the failing tier is never consulted again: one origin call,
1642
+ * however long the outage runs. Take the L1 away and nothing catches the refill. The read suppresses
1643
+ * to a miss, the write-back's read-compare cannot read, so nothing lands durably (`durable: false`),
1644
+ * and the next call repeats the whole trip — every call, forever, for as long as L2 is down. So a
1645
+ * raw `createCache` composed WITHOUT an `l1` converts an L2 outage into an unbounded origin
1646
+ * stampede, exactly when the origin is least able to absorb one. That is a priced consequence of
1647
+ * the miss-class posture, not a defect — a suppressed read must refill, and a refill it cannot
1648
+ * cache must happen again. Compose an L1, or accept the bill.
1649
+ *
1650
+ * ONE DEPLOYMENT STEP THIS FACTORY CANNOT DO FOR YOU (R6). A Worker cannot write a Durable Object's
1651
+ * `env`, so the Coordinator's idle liveness heartbeat is seeded ONLY by its own wrangler config, and it
1652
+ * ships DORMANT. Recommended production posture — set it to the same 30s this reader defaults to
1653
+ * (see `DEFAULT_HEARTBEAT_INTERVAL` for the full price of leaving it unset, and for why a mismatch
1654
+ * is safe-direction rather than a bug):
1655
+ *
1656
+ * // wrangler.jsonc (the Worker that hosts the Coordinator Durable Object)
1657
+ * "vars": { "REGISTRY_HEARTBEAT_MS": "30000" }
1658
+ */
1659
+ function createWorkersCache(config) {
1660
+ const { name, kv, coordinator } = config;
1661
+ const clock = { now: () => Date.now() };
1662
+ const rng = { next: () => Math.random() };
1663
+ const stubFor = () => coordinator.get(coordinator.idFromName(name));
1664
+ const dial = async () => {
1665
+ const response = await stubFor().fetch(BUS_URL, { headers: { Upgrade: "websocket" } });
1666
+ const socket = response.webSocket;
1667
+ if (!socket) throw new Error(`astilba/cache: expected a 101 webSocket from the Coordinator /bus endpoint, got status ${response.status}`);
1668
+ socket.accept();
1669
+ return socket;
1670
+ };
1671
+ const { telemetry } = config;
1672
+ const sink = typeof telemetry === "function" ? telemetry : telemetry?.sink;
1673
+ const onSinkError = typeof telemetry === "function" ? void 0 : telemetry?.onSinkError;
1674
+ const bus = redialingDoBus(dial, {
1675
+ regId: name,
1676
+ rng
1677
+ });
1678
+ return withCarrier(createCache({
1679
+ namespace: name,
1680
+ clock,
1681
+ rng,
1682
+ l1: memory({
1683
+ clock,
1684
+ maxEntries: L1_MAX_ENTRIES,
1685
+ maxBytes: L1_MAX_BYTES
1686
+ }),
1687
+ l2: cloudflareKV(kv),
1688
+ registry: doRegistry(stubFor, name),
1689
+ bus,
1690
+ defaults: {
1691
+ ...SAFE_DEFAULTS,
1692
+ ...config.defaults
1693
+ },
1694
+ ...telemetry === void 0 ? {} : { telemetry },
1695
+ ...config.takedownSensitive === void 0 ? {} : { takedownSensitive: config.takedownSensitive }
1696
+ }), bus, clock, sink, onSinkError);
1697
+ }
1698
+ //#endregion
1699
+ export { Coordinator, InvalidRegistryNameError, cloudflareKV, createWorkersCache, doBus, doRegistry, redialingDoBus };