@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rees Morris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @astilba/cache
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A portable TypeScript caching library — tag invalidation (soft/hard), classified stale-if-error resilience, and private-by-default scopes. Targets Node, Bun, and Cloudflare Workers.
4
+
5
+ > **Public alpha (0.1).** The correctness kernel and the Cloudflare Workers and React Router paths ship and run under the test suite, but elapsed TTL and grace periods are not enforced yet, a few methods still throw, and there is no production support commitment. Expect breaking changes before 1.0. Check [implementation status](https://astilba.com/docs/cache/api-status/) before relying on a feature.
6
+
7
+ ```sh
8
+ pnpm add @astilba/cache
9
+ ```
10
+
11
+ Documentation lives at [astilba.com/docs/cache](https://astilba.com/docs/cache/).
12
+
13
+ ## License
14
+
15
+ MIT © Rees Morris
@@ -0,0 +1,299 @@
1
+ import { Ct as TelemetrySink, G as Bus, Tt as TagKnowledge, at as Registry, bt as TelemetryConfig, ct as Store, ot as RegistryAck, r as CacheDefaults, st as Rng, t as Cache } from "../../cache-YWn0VZpn.js";
2
+ import { DurableObject } from "cloudflare:workers";
3
+ //#region src/adapters/cloudflare/bus-client.d.ts
4
+ /** A message event as delivered to a WS `message` listener — `data` is the payload string. */
5
+ interface WireMessageEvent {
6
+ readonly data: unknown;
7
+ }
8
+ /** A close event as delivered to a WS `close` listener. */
9
+ interface WireCloseEvent {
10
+ readonly code: number;
11
+ readonly reason: string;
12
+ }
13
+ /**
14
+ * The minimal client-side socket surface the doBus client needs. workerd's client WebSocket
15
+ * (`response.webSocket`, after `accept()`) satisfies it structurally; the conformance in-memory
16
+ * pair implements the same shape.
17
+ */
18
+ interface ClientSocket {
19
+ close(code?: number, reason?: string): void;
20
+ addEventListener(type: "message", listener: (event: WireMessageEvent) => void): void;
21
+ addEventListener(type: "close", listener: (event: WireCloseEvent) => void): void;
22
+ }
23
+ /** How the client obtains an OPEN socket. Returns synchronously (the conformance pair) or a promise
24
+ * (a real `stub.fetch` → `accept()`); the client handles both. */
25
+ type Dial = () => ClientSocket | Promise<ClientSocket>;
26
+ /** Why the doBus socket closed (status report). `local` distinguishes a close the client
27
+ * ITSELF initiated (a protocol/scope violation) from one the transport/server delivered. EXPORTED at
28
+ * its first consumer, the redial driver (`redial.ts`), has arrived, and an app that supplies
29
+ * its own `onClose` reads the same shape. */
30
+ interface DoBusCloseInfo {
31
+ readonly code: number;
32
+ readonly reason: string;
33
+ readonly local: boolean;
34
+ }
35
+ interface DoBusOptions {
36
+ /** The mirror-key scope this subscriber expects the DO to echo in its `hello`. */
37
+ readonly regId: string;
38
+ /** Status report: the socket closed. Fired at most once per subscription. The
39
+ * driver redials off this; integration tests redial explicitly. */
40
+ readonly onClose?: (info: DoBusCloseInfo) => void;
41
+ }
42
+ /**
43
+ * Build a `doBus` client `Bus`. `dial` obtains an OPEN socket (synchronously in the
44
+ * conformance pair; a `stub.fetch` → `accept()` promise in workerd). `options.regId` is the scope
45
+ * the DO's `hello` must echo; `options.onClose` receives the socket's close (
46
+ * NO self-reconnect). The reader subscribes to the returned `Bus`; the client decodes each message
47
+ * and dispatches the `BusEvent`, closing loud on any protocol/scope violation.
48
+ */
49
+ declare function doBus(dial: Dial, options: DoBusOptions): Bus;
50
+ //#endregion
51
+ //#region src/adapters/cloudflare/coordinator.d.ts
52
+ /** The bindings the Coordinator reads. `REGISTRY_KV` is the replication mirror. */
53
+ interface CoordinatorEnv {
54
+ /** The KV namespace the `__delta`/`__snap`/`__state` replication mirror is written to. */
55
+ readonly REGISTRY_KV: KVNamespace;
56
+ /**
57
+ * liveness heartbeat interval in ms, as a config var (`env` decides WHEN the shell
58
+ * issues a heartbeat command — it never parameterizes the machine). When set (Paid deployments),
59
+ * an idle alarm refreshes `__state`'s freshness even with no purges, via the machine's
60
+ * state-neutral heartbeat routed through the shared executor; earliest-wins arbitration keeps it
61
+ * from starving the flush deadline, and a flush never also heartbeats in the same alarm.
62
+ * UNSET by default (off — the default in every test lane; the heartbeat suite sets it per-test),
63
+ * so the heartbeat path stays fully dormant.
64
+ *
65
+ * THE COORDINATOR END OF THE ONE HEARTBEAT NUMBER, and the end NO WORKER CAN REACH.
66
+ * This var and the reader's `defaults.heartbeatInterval` are the two ends of the liveness
67
+ * contract, but a Worker cannot write a Durable Object's `env`: deploy-time wrangler config is
68
+ * this end's ONLY seed. So the two ends are NOT matched by construction — they are ONE DOCUMENTED
69
+ * NUMBER over TWO DEPLOYMENT SURFACES, and matching them is a deployment step. Recommended
70
+ * production posture: `"vars": { "REGISTRY_HEARTBEAT_MS": "30000" }` — the same 30s the
71
+ * `createWorkersCache` reader defaults to (see `DEFAULT_HEARTBEAT_INTERVAL` in `workers-cache.ts`
72
+ * for the full price of leaving it unset). A change here lands at the DO's next boot (one boot's
73
+ * lag). A drift between the ends is safe-direction (a shorter reader interval raises suspicion
74
+ * SPURIOUSLY: live registry checks, telemetry-visible, never a stale serve), which is why nothing
75
+ * enforces equality; the asymmetry is documented at that constant rather than engineered away.
76
+ */
77
+ readonly REGISTRY_HEARTBEAT_MS?: string;
78
+ /**
79
+ * Snapshot cadence knobs, as config vars. `env` may only AUTHOR a journaled `configure`
80
+ * command (Ruling B) — it never parameterizes the machine directly. On boot the shell diffs
81
+ * `env` against the journal's effective config and, if changed, journals a fresh `configure`
82
+ * (see `#reconcileConfig`), applied PROSPECTIVELY at a definite journal position — replay-exact
83
+ * forever. So a later var change can never retroactively fork `snapBatch` or bless a `__snap`
84
+ * that was never written; the journal is the single durable truth. UNSET ⇒ the machine's
85
+ * 500-batch / 6h defaults.
86
+ */
87
+ readonly REGISTRY_SNAPSHOT_EVERY_BATCHES?: string;
88
+ readonly REGISTRY_SNAPSHOT_INTERVAL_MS?: string;
89
+ /**
90
+ * Snapshot SIZE ceiling in bytes, as a config var. Seeds the machine's
91
+ * `maxSnapshotBytes` through the SAME journaled `configure` channel as the cadence (Ruling B):
92
+ * `env` authors it, the journal is the single durable truth, replay reproduces the skip decision.
93
+ * A due snapshot whose canonical shipped bytes exceed this is SKIPPED (never truncated, never a
94
+ * phantom bless). UNSET ⇒ the machine's 25 MiB default (the KV per-value profile).
95
+ */
96
+ readonly REGISTRY_MAX_SNAPSHOT_BYTES?: string;
97
+ /**
98
+ * The registry id scoping the mirror keyspace — the DEMOTED FALLBACK, as a config var.
99
+ * Identity = address: a Coordinator's `regId` derives from its OWN Durable Object name
100
+ * (`ctx.id.name`), the string clients address it by. `REGISTRY_ID` is consulted ONLY for an
101
+ * un-named object (`newUniqueId` ⇒ `ctx.id.name === undefined`), never as an override (an override
102
+ * would resurrect the one-registry-per-deployment defect kills). UNSET ⇒ `"default"`
103
+ * (single-registry deployments stay zero-config). Validated `[a-z0-9._-]`, ≤ 64 — a malformed
104
+ * value fails the boot loud (see `resolveRegId`) rather than minting a bad key.
105
+ */
106
+ readonly REGISTRY_ID?: string;
107
+ }
108
+ /**
109
+ * A name/journal regId DIVERGENCE detected at boot. regId is journaled at first
110
+ * `configure` and NEVER re-journaled; on a later boot where the DO's name derives a DIFFERENT id
111
+ * than the journaled one, the Coordinator KEEPS the journaled identity and records this — it can
112
+ * never serve dead, because both replication channels already fail closed on identity mismatch (the
113
+ * pointer's regId echo, `hello`'s regId echo). Migration to a new identity =
114
+ * addressing a NEW name (fresh journal, fresh keyspace), never a rekey of the live one.
115
+ */
116
+ interface RegIdDivergence {
117
+ /** The regId journaled at first boot — the identity that WINS (mirror keys stay `__reg:<this>:*`). */
118
+ readonly journaled: string;
119
+ /** The regId the current Durable Object name derives — observed, priced (misses/checks), never served. */
120
+ readonly name: string;
121
+ }
122
+ declare class Coordinator extends DurableObject<CoordinatorEnv> {
123
+ #private;
124
+ /**
125
+ * The name/journal regId divergence detected at this instance's boot, or null when the
126
+ * name still derives the journaled identity. A read-only boot diagnostic (surfaced to the
127
+ * integration divergence scene via `runInDurableObject`); never consulted on a hot path.
128
+ */
129
+ get regIdDivergence(): RegIdDivergence | null;
130
+ /** Strong read: live, un-memoized, primary-only — the DO IS the primary. */
131
+ check(tags: readonly string[]): Promise<readonly TagKnowledge[]>;
132
+ /** Soft purge: max-on-write bump of `softEpoch`. */
133
+ expire(tags: readonly string[]): Promise<RegistryAck>;
134
+ /** Hard purge: max-on-write bump of `hardEpoch`. */
135
+ delete(tags: readonly string[]): Promise<RegistryAck>;
136
+ /** Register a retention lease; the GC horizon rises to the high-water. */
137
+ registerRetention(retentionMs: number): Promise<void>;
138
+ /**
139
+ * The doBus live-channel endpoint. A subscriber opens a WebSocket here; the DO
140
+ * accepts it HIBERNATABLY (no per-subscriber attachment, ZERO replay state) and runs
141
+ * the establishment handshake: `reset` → `hello{head, regId}`, BEFORE any frame. `head`
142
+ * is the DO's OWN last-flushed epoch (`#flushedEpoch`) and the `hello` echoes `regId`,
143
+ *so a mismatched subscriber closes loud. Serialized under `blockConcurrencyWhile` so the
144
+ * handshake snapshots a consistent (cursor, regId) against any in-flight mutation.
145
+ */
146
+ fetch(request: Request): Promise<Response>;
147
+ /**
148
+ * Hibernatable WebSocket message handler. The doBus client is RECEIVE-ONLY —
149
+ * it dials, decodes, and dispatches; it never sends. Any inbound message is unexpected and there
150
+ * is no per-subscriber state to update, so it is ignored. Defined so the hibernation runtime has a
151
+ * message handler for woken sockets.
152
+ */
153
+ webSocketMessage(_ws: WebSocket, _message: ArrayBuffer | string): void;
154
+ /**
155
+ * Hibernatable WebSocket close handler. Stateless fan-out holds ZERO
156
+ * per-subscriber state, so a closed socket needs no reclamation — a later `getWebSockets()` simply
157
+ * will not include it. Defined so the hibernation runtime has a close handler for woken sockets.
158
+ */
159
+ webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): void;
160
+ /** Hibernatable WebSocket error handler — best-effort close; no state to reclaim. */
161
+ webSocketError(ws: WebSocket, _error: unknown): void;
162
+ /**
163
+ * The DO alarm handler, serving BOTH the flush window and the heartbeat cadence off the one
164
+ * alarm slot. A PURE function of durable state, idempotent under at-least-once
165
+ * delivery + redeploy: it rehydrates the machine from the journal and either FLUSHES the
166
+ * coalescing window `(kvFlushedEpoch, epoch]` into one content-addressed batch (advancing the
167
+ * durable cursor ONLY after KV accepts), or — on an EMPTY window — refreshes the `__state`
168
+ * freshness via a state-neutral heartbeat. A redelivery before the cursor advances re-writes the
169
+ * identical (content-addressed, write-once) bytes; a redelivery after finds an empty window and
170
+ * heartbeats (or no-ops when the cadence is unset).
171
+ */
172
+ alarm(): Promise<void>;
173
+ }
174
+ //#endregion
175
+ //#region src/adapters/cloudflare/do-registry.d.ts
176
+ /**
177
+ * Where a Coordinator stub comes from (widened by ADDITIVELY, in the warrant).
178
+ *
179
+ * A STUB is a client-held I/O handle: it belongs to the request context that minted it, and the
180
+ * platform refuses it anywhere else. A long-lived cache therefore cannot hold one — but the API defines
181
+ * `doRegistry(stub, regId)`, and a caller who mints and uses inside ONE request (the explicit
182
+ * composition, as written) is doing nothing wrong. So the parameter widens instead of changing: a
183
+ * bare stub stays exactly as valid as it was, and a THUNK lets a caller that outlives a request hand
184
+ * over the ADDRESS — the recipe for a stub — rather than a stub. The thunk is invoked per use, so
185
+ * whichever request is running gets a handle of its own.
186
+ */
187
+ type StubSource = DurableObjectStub<Coordinator> | (() => DurableObjectStub<Coordinator>);
188
+ /** Build a `Registry` from a Coordinator DO stub OR a thunk that mints one (`StubSource` —
189
+ * the widening is additive, so `doRegistry(stub, regId)` remains exactly as signed). `regId`
190
+ * must match the Coordinator's regId — which is its Durable Object NAME
191
+ * (identity = address) — so the client-side reader resyncs the same scoped keyspace the DO writes;
192
+ * defaults to `"default"` for single-registry (zero-config) deployments.
193
+ *
194
+ * Pass a THUNK from anything that outlives a request (`createWorkersCache` does); pass a bare stub
195
+ * when the caller mints and uses within one. */
196
+ declare function doRegistry(source: StubSource, regId?: string): Registry;
197
+ //#endregion
198
+ //#region src/adapters/cloudflare/kv.d.ts
199
+ /** Build a `Store` over a Cloudflare `KVNamespace` binding. */
200
+ declare function cloudflareKV(ns: KVNamespace): Store;
201
+ //#endregion
202
+ //#region src/adapters/cloudflare/redial.d.ts
203
+ /** `redialingDoBus` configuration. `regId` + `onClose` mirror `DoBusOptions` (the close report is
204
+ * PASSED THROUGH to the app after the redial is scheduled — the driver consumes it, it does not
205
+ * swallow it); `rng` is the jitter source (injected — the platform boundary owns ambient randomness,
206
+ * and `createWorkersCache` hands its one `Rng` to both the kernel and this driver). */
207
+ interface RedialOptions {
208
+ readonly regId: string;
209
+ readonly rng: Rng;
210
+ /** Status report, after this driver has scheduled the redial. Observability only. */
211
+ readonly onClose?: (info: DoBusCloseInfo) => void;
212
+ /** First-attempt delay before the ×2 ladder (default 1s). */
213
+ readonly baseDelayMs?: number;
214
+ /** Symmetric jitter fraction (default 0.2 → ±20%). */
215
+ readonly jitterFraction?: number;
216
+ }
217
+ /** A `Bus` whose redial ladder is driven by ticks rather than timers. `tick(nowMs)` performs a
218
+ * DUE redial and is a no-op otherwise; the composition calls it from a request context. */
219
+ interface RedialingBusHandle extends Bus {
220
+ tick(nowMs: number): void;
221
+ }
222
+ /**
223
+ * Build a self-redialing doBus `Bus`. This is the bus `createWorkersCache` wires: the
224
+ * transport reports its close, and this driver owns the reconnect + the ×2/300s jittered
225
+ * backoff — LAZILY, performed by `tick` inside a request context rather than by a timer that would
226
+ * be refused at global scope and would race a dead isolate everywhere else. Use the bare `doBus`
227
+ * instead when the caller drives redials itself (the integration lane does — tests own time).
228
+ */
229
+ declare function redialingDoBus(dial: Dial, options: RedialOptions): RedialingBusHandle;
230
+ //#endregion
231
+ //#region src/adapters/cloudflare/reg-id.d.ts
232
+ /**
233
+ * Refused: a Coordinator addressed by a Durable Object name that violates the regId
234
+ * grammar (`[a-z0-9._-]`, 1–64). Typed so the boot failure is unambiguous and machine-checkable —
235
+ * the fix is a conforming name or an explicit `REGISTRY_ID`. Thrown at first boot only (before any
236
+ * data), so it strands nothing: validate-and-refuse, not journal-wins. `Error.name` stays the class
237
+ * name; `registryName` carries the offending Durable Object name.
238
+ */
239
+ declare class InvalidRegistryNameError extends Error {
240
+ readonly registryName: string;
241
+ constructor(registryName: string);
242
+ }
243
+ //#endregion
244
+ //#region src/adapters/cloudflare/workers-cache.d.ts
245
+ /**
246
+ * `createWorkersCache` configuration (SIGNED shape). The required key set is EXACTLY
247
+ * `{ name, kv, coordinator }`; `defaults` is optional. `name` is the ONE identity string: it becomes
248
+ * the Coordinator DO address (`idFromName(name)`), the registry id (`regId`), and the cache key
249
+ * namespace — identity = address. `coordinator` is parameterized by `Coordinator` so
250
+ * `.get(idFromName(name))` yields the typed stub `doRegistry` needs.
251
+ */
252
+ interface WorkersCacheConfig {
253
+ readonly name: string;
254
+ readonly kv: KVNamespace;
255
+ readonly coordinator: DurableObjectNamespace<Coordinator>;
256
+ readonly defaults?: CacheDefaults;
257
+ /** A plain sink, or a hosted config that pseudonymizes every string field. Passed
258
+ * straight through to `createCache`. OPTIONAL, so the signed required key set is untouched — but
259
+ * PRESENT, because without it the events this composition emits (`bus_dial_failed`,
260
+ * `strong_degraded`, …) are unreachable for every hero-path user, which is a health surface that
261
+ * exists only on paper. */
262
+ readonly telemetry?: TelemetrySink | TelemetryConfig;
263
+ /** `unknown` resolves to `'error'` — the read THROWS rather than refilling. An OVERRIDE:
264
+ * it outranks `defaults.unknownPolicy`, including this factory's own safe-posture default. For
265
+ * namespaces under a takedown or legal embargo, where refilling from origin could resurrect the
266
+ * content that had to disappear. Optional; the key set is untouched. */
267
+ readonly takedownSensitive?: boolean;
268
+ }
269
+ /**
270
+ * Build a Workers-native `Cache` from one registry `name` + a KV namespace + a Coordinator DO
271
+ * namespace. Sugar over `createCache`: the wall clock and RNG enter here at the platform
272
+ * boundary and reach the kernel only as the injected `clock`/`rng`; the drivers are composed against
273
+ * the single `name` (DO stub, registry id, key namespace). A caller's `defaults` override the safe
274
+ * posture per-field; everything else is fixed.
275
+ *
276
+ * WHY THE L1 IS NOT OPTIONAL HERE, AND WHAT IT COSTS TO SKIP IT. This factory always composes an L1,
277
+ * and under an L2 outage that L1 is the absorber. A failing L2 read is suppressed to a plain miss,
278
+ *so the reader refills from origin — and with an L1 the refill lands there, is checked first
279
+ * on every subsequent read, and the failing tier is never consulted again: one origin call,
280
+ * however long the outage runs. Take the L1 away and nothing catches the refill. The read suppresses
281
+ * to a miss, the write-back's read-compare cannot read, so nothing lands durably (`durable: false`),
282
+ * and the next call repeats the whole trip — every call, forever, for as long as L2 is down. So a
283
+ * raw `createCache` composed WITHOUT an `l1` converts an L2 outage into an unbounded origin
284
+ * stampede, exactly when the origin is least able to absorb one. That is a priced consequence of
285
+ * the miss-class posture, not a defect — a suppressed read must refill, and a refill it cannot
286
+ * cache must happen again. Compose an L1, or accept the bill.
287
+ *
288
+ * ONE DEPLOYMENT STEP THIS FACTORY CANNOT DO FOR YOU (R6). A Worker cannot write a Durable Object's
289
+ * `env`, so the Coordinator's idle liveness heartbeat is seeded ONLY by its own wrangler config, and it
290
+ * ships DORMANT. Recommended production posture — set it to the same 30s this reader defaults to
291
+ * (see `DEFAULT_HEARTBEAT_INTERVAL` for the full price of leaving it unset, and for why a mismatch
292
+ * is safe-direction rather than a bug):
293
+ *
294
+ * // wrangler.jsonc (the Worker that hosts the Coordinator Durable Object)
295
+ * "vars": { "REGISTRY_HEARTBEAT_MS": "30000" }
296
+ */
297
+ declare function createWorkersCache(config: WorkersCacheConfig): Cache;
298
+ //#endregion
299
+ export { Coordinator, type Dial, type DoBusCloseInfo, type DoBusOptions, InvalidRegistryNameError, type RedialOptions, type RedialingBusHandle, type StubSource, type WorkersCacheConfig, cloudflareKV, createWorkersCache, doBus, doRegistry, redialingDoBus };