@ultimat3/cache 1.2.0 → 3.0.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/CLAUDE.md ADDED
@@ -0,0 +1,267 @@
1
+ # @ultimat3/cache — agent notes
2
+
3
+ Tier 1. Tagged caching + THE invalidation graph.
4
+
5
+ ## Boundary
6
+
7
+ - May import: `@ultimat3/core`, `@ultimat3/schema`. Nothing else, ever.
8
+ - Must NOT know about entities, HTTP, jobs, render. `tagsFor()` takes structural args.
9
+ - Consumers: `entity` (write hooks), `action` (`cache.invalidates`), `render` (ISR), `cli`.
10
+
11
+ ## Rules
12
+
13
+ - `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path. Never call
14
+ `tier.invalidateTags()` from outside it. It is also the only place the log is written:
15
+ `recentInvalidations()` is a read of what that one path already reported, never a second
16
+ recorder a caller has to remember to call.
17
+ - **The fan-out clears FARTHEST tier first, and reports in read order.** Near-to-far leaves the far
18
+ tier holding the old value after the near ones are clear, and a read racing the bust promotes it
19
+ straight back up — `report.errors` empty, LRU stale again before the call returns. `CacheStack.drop`
20
+ reverses for the same reason. The report is re-sorted into `TIER_ORDER` because it is what the
21
+ `/_x` panel renders. Pinned in `invalidation-race.test.ts`.
22
+ - **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). A read-through
23
+ fill publishes rows `load()` read in the past, so a bust landing in between finds a key that is
24
+ not there yet, reports `errors: []`, and is overwritten milliseconds later — invisible for the
25
+ whole TTL. `sampleFence({ key, tags })` → `fence.isValid()` is the whole API, `markInvalidated`
26
+ is its write half (called by `fanOut`, `CacheStack.write` and `CacheStack.drop`; a caller only
27
+ needs it for a clearing path of its own). It is **exported** because a store outside this package
28
+ with the same hole must not grow a second mechanism. `cover()` widens a fence
29
+ RETROACTIVELY — needed only where joiners contribute tags the leader never sampled, which is
30
+ `createCacheStack.read` and nothing else. A fence never fails a read: it declines to publish.
31
+ This is the one process-global here with **no `isolate*()` seam and no reset**, and that is
32
+ structural: a fence samples the current generation, which is always at or above what the ring has
33
+ forgotten, so another file's marks cannot invalidate a fence sampled after them.
34
+ - One graph. `graph.ts` exports functions over module state and **no constructor** — do not
35
+ add one, do not add a second registry anywhere else.
36
+ - Tag order is `TIER_ORDER`, never registration order. `sortTiers()` enforces it.
37
+ - **`bestEffort()` is public, and it is the only sanctioned way to swallow a cache refusal.** A
38
+ store outside this package that wraps its own `try/catch` degrades invisibly, and a second
39
+ failure log nobody reads is what this bounded one exists to prevent. Its label is `TierLabel` —
40
+ `TierName` plus `'query-read'` — closed, and deliberately NOT a widening of `TierName`: a name
41
+ missing from `TIER_ORDER` sorts to `-1`, ahead of the request memo. A label is a log facet; a
42
+ `TierName` is a position on the ladder.
43
+ - **A refusal is rendered with `renderThrowable()`, never `error.message`** — the four sites that
44
+ absorb one (`bestEffort`'s log entry, and `fanOut`'s tier, ISR and broadcast catch blocks). A
45
+ tier, a revalidator and a broadcast are all app-supplied, so the value they reject with is too:
46
+ `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so
47
+ building the log line used to raise INSTEAD of absorbing the refusal — on the business write that
48
+ triggered the bust, which is the one caller both contracts promise to protect. The code field
49
+ keeps its own total probe (`ultimateCode` in `tier-failures.ts`) rather than core's `stringField`:
50
+ a driver error's `code` is a SQLSTATE and must never be reported as an `X_*` one. Consequence to
51
+ know: a recorded `message` carries the throwable's NAME (`Error: nats is down`, `"just a string"`),
52
+ which is what `renderThrowable` renders and what the tests here now pin.
53
+ - Tier failures go into `report.errors`. A cache tier may never fail a business read or write.
54
+ `createCacheStack` routes every `get`/`set`/`del` through `bestEffort()` for that reason — a
55
+ refusal becomes "that tier did not answer" and lands in `recentTierFailures()`, the read side's
56
+ equivalent of `report.errors`. `load()` is the one unguarded call: it is the business read, so
57
+ swallowing it would return `undefined` as if it were the value. `LruCache.set` still throws
58
+ `X_CACHE_TOO_LARGE` to a direct caller — the stack is the layer that degrades, not the tier.
59
+ - **`serializeTags` is the wire form; `tagKeys` is the IDENTITY form, and the difference is the
60
+ point.** `tagKeys` sorts and de-duplicates, because its readers build a descriptor field and a
61
+ cache KEY out of it — `@ultimat3/query`'s `cacheKeyFor` above all, where a key that varied with
62
+ declaration order fills two entries for one read and an action's `invalidates` drops whichever
63
+ one it happens to name. It lives here, in the package that owns `serializeTag`, because
64
+ `@ultimat3/action` and `@ultimat3/query` held byte-identical copies of it and are the same
65
+ tier — so neither can import the other and a copy in either is a second answer for the other.
66
+ The same move `toBucket` made into `@ultimat3/http`. **Known collision, not fixed here:**
67
+ `@ultimat3/render` exports its own `tagKeys` (`render/src/route.ts`) with different behaviour —
68
+ `serializeTags` over an optional list, declaration order preserved, pinned by its own
69
+ `dsl.test.ts`. Two behaviours under one name in two packages an app imports together; the
70
+ consolidation is render's to make, and this doc block is where a reader finds out.
71
+ - **Two exports here have no production caller and both are KEPT — re-verified 2026-08.**
72
+ `invalidateWireTags` is the wire-form door (`x cache bust` is planned and exits
73
+ `X_NOT_IMPLEMENTED`); its sibling `receiveInvalidationBroadcast` takes the same wire form and DOES
74
+ have one (`packages/cli/src/dev-cache.ts`), so deleting the outbound door would leave one of a
75
+ matched pair. `recentTierFailures()` is the read-ladder half of `report.errors` and the `/_x`
76
+ cache panel is still **not** its reader — `packages/cli/src/dev-dashboard.ts` imports
77
+ `recentInvalidations` and nothing else — so it is read only by tests, `@ultimat3/query`'s
78
+ `cache-degraded.test.ts` included. Neither is dead in the sense that matters: a bounded log with
79
+ no panel is a log an operator can still reach, and removing it is removing the only evidence a
80
+ degraded tier leaves. Wire the panel or leave them; do not delete one and keep the other.
81
+ - `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`.
82
+ - **`declareTags()` is additive and process-wide, so a test undoes it with `isolateDeclaredTags()`,
83
+ never with `resetDeclaredTags()`.** The empty set is what switches `assertKnownTags` off, so one
84
+ suite declaring a fixture entity makes every later file in the same `bun test` process validate
85
+ against a registry it never saw — the cross-file X_CACHE_TAG_UNKNOWN `packages/query`'s read-cache
86
+ suite used to fail with. A reset would drop a neighbour's declarations instead of only your own;
87
+ `@ultimat3/testing`'s leak guard fails the file that leaks either the tag set or the tier registry.
88
+ - **Every process-global registry here has that same seam, and a test file uses it: `isolateGraph()`
89
+ (`graph.ts`), `isolateTiers()` (`invalidate.ts`), `isolateTierFailures()` (`tier-failures.ts`).**
90
+ A per-test `resetGraph()` / `resetTierFailures()` stays where an empty registry is the subject —
91
+ pair it with the module-scope isolate and an `afterAll(restore)`. The leak guard reports
92
+ *additions* only, so a file that DELETES a neighbour's registrations is invisible to it and lands
93
+ as a failure in an innocent file: a reset in a test file is the one leak nothing catches for you.
94
+ The last two exist in the owning module because a test file cannot reach the state — the
95
+ revalidator has no reader and neither log has a writer, so `resetTiers()` is unrecoverable from
96
+ outside. `isolateTierFailures` is deliberately off `index.ts`, same as `resetTierFailures`:
97
+ nothing outside this package can clear that log except through `resetTiers()`, which
98
+ `isolateTiers()` already covers.
99
+ - Clocks are injected (`LruOptions.clock`, `CacheStackOptions.clock`); read them through `nowMs()`.
100
+ - **`ttlMs` is positive and finite, and `assertTtl` (in `tiers.ts`) is the one place that says so.**
101
+ Every tier calls it before it writes — **the request memo included, `As of 2026-08`**: it was the
102
+ last rung skipping it, so `ttlMs: 0` was stored by the memo and refused by the other three, and
103
+ `createCacheStack` swallows both refusals through `bestEffort`, so the read HIT out of the one
104
+ tier that should never have taken it. It validates a lease it then discards (it holds nothing past
105
+ the request), and only a lease the caller SUPPLIED — there is no memo default to fall back on.
106
+ So does `createMemorySemanticCache.remember` — which was
107
+ the one writer skipping it, so `ttlMs: 0` stored an entry already past its expiry and every lookup
108
+ missed with a completion bill as the only evidence. Its scope is `'semantic'` (`TtlScope`), with
109
+ `jitterFraction: 0`: spreading a lease is a herd defence for a SHARED store, and that one is per
110
+ process. `0` used to be "never expires" here and `EX 1` in `redis.ts`,
111
+ so one stack answered two ways; the rule lives beside `CacheSetOptions` precisely so a new tier
112
+ cannot invent a third reading. `X_CACHE_TTL_INVALID`, never a resolution.
113
+ - **`assertTtl` also SPREADS the lease it validated** — validate, then jitter, one choke point. A
114
+ rolling restart warms 40,000 keys in 30s on one lease and they all expire in one 30s window; the
115
+ spread is what makes that a ramp. `rng` is injected (`LruOptions.rng`, `RedisTierOptions.rng`) —
116
+ **never `Math.random()` at a call site**, or nothing downstream is deterministic. `rng: () => 0`
117
+ is the full lease and is what a test asserting an exact `expiresAt` passes; `jitterFraction: 0`
118
+ turns it off. Outside `[0, 1)` is `X_CACHE_JITTER_INVALID`, refused rather than clamped.
119
+ - **`createCacheStack` is the production read path, and `@ultimat3/query` is its caller** (`As of
120
+ 2026-08`). It had none for two releases — read-down/promote-up, the fence, single flight and the
121
+ negative TTL were the package's whole design, reachable only from its own tests, while the one
122
+ cached read path in the framework kept a private store beside the registry. `readThrough` calls
123
+ `createCacheStack(registeredTiers(), { clock })` now, so deleting or bypassing this function
124
+ removes the only thing that makes an action's `cache.invalidates` reach a `cache:` query.
125
+ - **`createCacheStack` shares one in-flight `load()` per key** (`single-flight.ts`, mirroring
126
+ `realtime`'s `entry.reading`). The share ends as the load settles — a REJECTED load must clear
127
+ its entry too, or one origin failure becomes a permanent cached rejection. One `SingleFlight` per
128
+ stack, never a module-level map: two stacks are two ladders.
129
+ - **A joiner shares the leader's WRITE, so it contributes to it** (`FlightJoin`, merged by
130
+ `mergeSetOptions` in `set-options.ts`). Keyed on `key` alone and read late, the entry used to land
131
+ carrying only the leader's tags: the joiner's tag reached nothing, so the invalidation it declared
132
+ never fired. Tags union, TTLs take the SHORTEST — an entry held longer than a caller asked for is
133
+ stale to that caller. `work` reads the merge through `shared()` **after** the load, or it sees
134
+ only what the leader brought.
135
+ - **`negativeTtlMs` is the stack's decision, not a tier's.** Only `createCacheStack` sees what
136
+ `load()` answered, so the `null`/`undefined` branch lives in `ttlOptionsFor` there and reaches a
137
+ tier as an ordinary `ttlMs`.
138
+ - **A promotion carries the entry's remaining life, not `options.ttlMs`** — `createCacheStack.read`
139
+ writes the closer tiers with `hit.expiresAt - now`, and drops a hit that fails `isExpired`. A
140
+ fresh full lease per read is a hot key that never goes stale enough to refetch. `isExpired` was
141
+ exported and unit-tested and called by nothing; the stack is its one caller.
142
+ - **Every tier's `get` therefore reports `expiresAt`, or the promotion above has nothing to carry.**
143
+ `redis.ts` reads it from `PTTL`, issued alongside the `GET` so Bun pipelines the pair — the server
144
+ owns the clock, so it survives skew between the node that wrote and the node that reads, and no
145
+ stored payload shape changes under a running deployment. `-1`/`-2` are sentinels, not durations:
146
+ they mean no expiry, never one millisecond ago.
147
+ - **`t:` is the TAG's bucket and `e:` is the ENTITY index, and one key may not be both** (`As of
148
+ 2026-08`). Three tiers implement `tagMatches` and the shared one was the outlier: a row-tagged
149
+ write joined the collection bucket, and a row bust read that bucket back, so
150
+ `invalidateTags([tag('post', '1')])` returned every post-tagged key in the store and deleted them
151
+ — one row write emptying the shared tier for that entity, while the LRU one rung closer kept
152
+ exactly the row that changed. `writeBucketsFor` joins the declared tag plus `e:{entity}`;
153
+ `bustBucketsFor` reads the index for a COLLECTION bust and `t:{entity}:<id>` + `t:{entity}` for a
154
+ ROW one. A collection bust also reads `t:{entity}` — a strict subset of the index today, kept so a
155
+ `buildId: null` deployment upgrading into this layout does not MISS its old two-role buckets;
156
+ over-reading a subset costs a round trip, under-reading is a stale read. It IS a wire-layout
157
+ change: a cold shared tier, which the default build-id namespace already pays per deploy.
158
+ Pinned by `tier-parity.test.ts` (all three rungs, one test each) and `redis.live.test.ts` (the
159
+ same two busts against a real server, asserting the LRU's and Redis's survivors are EQUAL).
160
+ - **`CacheTier.set` REJECTS, never throws synchronously.** `createLruTier` and `createMemoTier` are
161
+ `async` for that reason alone — `LruCache.set` stays a sync API, but a `CacheTier` is one
162
+ interface with three implementations and `tier.set(...).catch(...)` has to mean the same thing on
163
+ every rung. `bestEffort` absorbs both shapes; a direct caller does not.
164
+ - **`redis.ts`'s script deletes NOTHING — it reads.** The members of a tag set are value keys in
165
+ slots this node may not own, so `DEL`ing them from Lua is a cross-slot access that fails on Redis
166
+ Cluster and Dragonfly strict mode — into `report.errors`, so the bust reads as partial and stale
167
+ rows serve until TTL. The script returns the members; the tier deletes them client-side, one key
168
+ per `DEL`, which is slot-local under every topology.
169
+ - **The bucket is not dropped in the script either, and the tier `SREM`s only what it deleted.**
170
+ Dropping it atomically with the `SMEMBERS` made one failure permanent: a refused `DEL` left its
171
+ member with no bucket to be found in, so the retry the error asks for answered `keys: []` and
172
+ those rows served until their own TTL. `Promise.allSettled` is what makes "what actually died"
173
+ knowable. A member a concurrent write added between the two halves keeps its membership instead
174
+ of being orphaned by a bust that never deleted it.
175
+ - **A `set` joins its buckets BEFORE it writes the value, and re-checks membership after.** Value
176
+ first left a window where a bust's `SMEMBERS` saw an empty bucket and the value survived its own
177
+ invalidation for the full TTL. Joining first moves the window somewhere observable: membership
178
+ gone by the time the `SET` lands means this write was busted in the air, and the value goes with
179
+ it — a row nothing can reach by tag is one no later bust can clear. Only a literal `0` from
180
+ `SISMEMBER` counts as gone (`saysAbsent`); a reply the tier cannot read is not evidence, and
181
+ deleting on one is a cache that never caches.
182
+ - **`GET` and `PTTL` are two commands and the key can die between them.** `PTTL: -2` for a value
183
+ the `GET` returned is a MISS, not an entry with no expiry — reported as a hit it is promoted into
184
+ the LRU on the CALLER's ttl, so a row one millisecond from death gets a fresh five minutes one
185
+ tier closer.
186
+ - **That fixed half of it; `KEYS` itself was the other half.** Tag keys carry a `{entity}` hash tag
187
+ (`<ns>:t:{post}`, `<ns>:t:{post}:7`) and `invalidateTags` issues **one script call per tag**, so
188
+ every key a call is handed hashes to one slot. A single `EVAL` carrying two tags' buckets is
189
+ `CROSSSLOT`-rejected before the script runs. The invariant a test pins: no command's `KEYS` ever
190
+ spans two hash tags.
191
+ - **Every tag set carries a lease, and the lease only grows.** `TAG_MEMBER_SCRIPT` does the `SADD`
192
+ and the `EXPIRE` in one call, one key in `KEYS`. A set with no TTL is unbounded memory and a
193
+ multi-million-member `SMEMBERS` on the next publish. `EXPIRE … GT` is NOT enough on its own — it
194
+ treats a key with no TTL as infinite, so a fresh bucket would stay immortal, which is the bug.
195
+ `SREM`-on-`del` is deliberately not done: `del(key)` does not know the key's tags without a read,
196
+ and a bounded bucket lease already bounds the growth.
197
+ - **A fake cannot run Lua, so it must never pretend to.** Both fakes used to mirror
198
+ `INVALIDATE_SCRIPT` and `TAG_MEMBER_SCRIPT` in TypeScript and match on the exported constant's
199
+ identity — so gutting either script to `return 1` / `return {}` left all 517 tests in `cache` +
200
+ `query` green, with the entire shared-tier invalidation path proving nothing. The fakes are
201
+ recorders now: the wire traffic is what a unit test asserts, and a test whose path READS a
202
+ script's reply states it with `answerEval(script, reply)` — an unprogrammed `EVAL` throws rather
203
+ than answering `[]`, which is exactly what the gutted script returns. **Every claim about what a
204
+ script DOES belongs in `redis.live.test.ts`** (`describe.skipIf(!TEST_REDIS_URL)`), which is the
205
+ only place either one is executed.
206
+ - **The Redis namespace carries the build id by default** (`namespaceFor`, `appVersion()`). Two
207
+ builds sharing one Redis otherwise read each other's payloads through a `JSON.parse` that does
208
+ not validate. `buildId: null` opts out. The layout is wire-visible: changing it is a cold cache.
209
+ - **Cross-instance invalidation is a SEAM here, never a transport.** `registerInvalidationBroadcast`
210
+ (outbound) and `receiveInvalidationBroadcast` (inbound) mirror `registerRevalidator`; `cli` owns
211
+ the bus. The inbound half cannot re-emit and that is structural — `emit` is on `fanOut`'s private
212
+ options and only `receiveInvalidationBroadcast` passes `false`. An inbound tag this process never
213
+ declared is dropped into `report.errors`, never thrown: a throw kills the subscriber loop and
214
+ silently ends cross-instance invalidation for the whole process.
215
+ - **`report.cdn` is what depends on the tags; `report.tiers` is what cleared.** The `cdn` tier
216
+ purges `cdn-path` dependents itself, alongside the tags, so `busted` is built from `tiers` +
217
+ `isr` + `liveQueries` and never from `cdn` — folding in a list nothing purged is exactly the
218
+ partial-bust-reading-as-clean this log exists to prevent.
219
+ - A purge driver is selected by `selectPurgeDriver` from the environment, never from an
220
+ `app.config.ts` field — nothing loads that file's contents at runtime. Two CDN credentials at
221
+ once is refused, not resolved, and half a pair is refused too: "no CDN" is the one wrong answer,
222
+ because a deployment then ships believing it purges. The token never reaches a printed string.
223
+ - A purge key is a wire tag unchanged. Every CDN splits a key list on whitespace and a comma, so
224
+ `assertPurgeableKeys` refuses either **before** the request — a split key is purged successfully
225
+ and clears nothing, which is the one CDN failure no later read can catch.
226
+ - `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus
227
+ any request that never got a status. That table lives in `purge-http.ts` and is edited there.
228
+ - `X_CACHE_PURGE_FAILED` means a provider refused. A batch size that is not a positive integer is
229
+ this package miswired, so `chunked()` raises `X_CACHE_DRIVER_UNAVAILABLE` instead — and it raises
230
+ it *before* the loop, because a `0` spins forever and a `NaN` yields one empty batch, a purge that
231
+ reports success having cleared nothing.
232
+ - Every `fixFor()` branch names a command, an env key or a call. The gate's `fix:` scanner reads
233
+ `fix:` properties, not the `return` literals in those functions, so the colocated
234
+ "every failure fix names a command" tests are the only thing enforcing it.
235
+ - A refusal's diagnostic reads the environment, never a hardcoded pair: `meta.configured` and the
236
+ cause name the keys that are actually set. Names only — all four keys can hold a credential.
237
+ - A remote driver takes an injected `fetch` so a test never unseals the network; the loopback
238
+ proof in `purge-fastly.test.ts` is the only place the default one runs.
239
+
240
+ ## Files
241
+
242
+ | File | Owns |
243
+ |---|---|
244
+ | `tags.ts` | `tag` factory, wire form, match semantics, `tagKeys`, declared-tag registry |
245
+ | `graph.ts` | tag → dependents (cache keys, ISR routes, CDN paths, live queries) |
246
+ | `tiers.ts` | `CacheTier`, `TIER_ORDER`, `TierLabel`, `assertTtl`, read-through stack |
247
+ | `fence.ts` | the invalidation fence a fill (here or in `query`) checks before it publishes |
248
+ | `set-options.ts` | how two callers' `CacheSetOptions` combine, and the `null`-load TTL |
249
+ | `tier-failures.ts` | `bestEffort()`, and the bounded log of refusals it absorbs |
250
+ | `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
251
+ | `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
252
+ | `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag |
253
+ | `single-flight.ts` | one in-flight `load()` per key, shared by every concurrent miss |
254
+ | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` |
255
+ | `purge-http.ts` | the HTTP half both remote drivers share: one POST, retryable table, batching, key guard |
256
+ | `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` |
257
+ | `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` |
258
+ | `purge-env.ts` | `selectPurgeDriver`: which edge an environment purges, and nothing else |
259
+ | `invalidate.ts` | the single entry point, `InvalidationReport`, and the bounded log `/_x` renders |
260
+ | `semantic.ts` | embedding cache for LLM calls |
261
+
262
+ ## Commands
263
+
264
+ ```
265
+ bun test packages/cache
266
+ bun run --filter @ultimat3/cache typecheck
267
+ ```
package/README.md CHANGED
@@ -27,7 +27,7 @@ Reads walk down until a hit, then populate every tier they walked past. Writes p
27
27
  |---|---|---|---|---|
28
28
  | 0 | `request-memo` | ALS context (`WeakMap`) | dies with the request | never |
29
29
  | 1 | `lru` | in-process, byte-budgeted | tag index | never |
30
- | 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` | single node |
30
+ | 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` **per tag** + slot-local `DEL`s | single node |
31
31
  | 3 | `cdn` | headers + purge driver | surrogate keys | no CDN |
32
32
 
33
33
  A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`). Swap or omit any of them
@@ -45,6 +45,76 @@ const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
45
45
  });
46
46
  ```
47
47
 
48
+ **`ttlMs` is positive and finite, in every tier.** Omit it for the tier's default; anything else
49
+ is `X_CACHE_TTL_INVALID`. There is no "never expires" and no "do not cache" — `0` used to mean the
50
+ first in the LRU tier and one second in the Redis tier, so a stack holding both answered
51
+ differently depending on which one hit, and neither reading was what the caller meant. A value you
52
+ do not want held is a value you do not put in the cache.
53
+
54
+ **Every lease is spread.** A tier shortens each `ttlMs` by a random slice of up to 5%
55
+ (`DEFAULT_TTL_JITTER_FRACTION`) before it writes, in `assertTtl` — the one place every tier already
56
+ called. 40,000 keys warmed by one rolling restart otherwise share one expiry instant and all miss
57
+ inside the same 30-second window. The roll is injected, never `Math.random()` at a call site:
58
+
59
+ ```ts
60
+ createLruTier({ rng: () => 0 }); // the full lease — what a test asserting an exact expiry wants
61
+ createRedisTier({ jitterFraction: 0 }); // off entirely
62
+ createRedisTier({ jitterFraction: 0.2 }); // a wider spread; outside [0, 1) is X_CACHE_JITTER_INVALID
63
+ ```
64
+
65
+ **N concurrent misses are ONE origin load.** `stack.read` shares an in-flight `load()` per key, so
66
+ a reader arriving while another's load is running joins it instead of issuing its own — the share
67
+ ends as the load settles, rejection included, so one failure is never held as a permanent one. A
68
+ feed cached for 60s and read 8,000×/s otherwise sends ~1,600 identical queries to Postgres at every
69
+ TTL boundary, because the write only lands after `load()` resolves. The primitive is
70
+ `createSingleFlight()` if you need it elsewhere; the stack holds one per stack. A joiner shares the
71
+ leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest.
72
+ Without that the entry landed carrying only the leader's tags and the joiner's invalidation never
73
+ fired.
74
+
75
+ **A fill obeys an invalidation that raced it.** `load()` answers with rows it read in the past, so a
76
+ bust landing in between finds a key that is not there yet — it reports `errors: []` and the fill
77
+ republishes the pre-write rows for the full TTL, invisibly. `stack.read` samples a fence before the
78
+ load and re-checks it before each tier write; a fill that lost the race is dropped, and anything it
79
+ already wrote is taken back. The caller still gets what the origin answered: a fence declines to
80
+ publish, it never fails a read. It is exported for any cache doing its own read-through:
81
+
82
+ ```ts
83
+ const fence = sampleFence({ key, tags });
84
+ const value = await run();
85
+ if (fence.isValid()) await tier.set(key, value, { tags });
86
+ ```
87
+
88
+ **A `null` can carry its own TTL.** `negativeTtlMs` is used when the loaded value is `null` or
89
+ `undefined`, so a lookup for a row that has not replicated yet is not held for the positive lease:
90
+
91
+ ```ts
92
+ await stack.read(key, () => db.posts.byId(id), { ttlMs: 300_000, negativeTtlMs: 5_000 });
93
+ ```
94
+
95
+ **A promoted hit carries its own remaining life.** When a read hits a far tier and populates the
96
+ closer ones, it writes them with `expiresAt - now`, not with the `ttlMs` the caller passed — a
97
+ fresh full lease on every read is a hot key that never gets stale enough to be refetched. An entry
98
+ already past its expiry is dropped on the way through and the read falls to `load()`. Each tier
99
+ supplies that expiry from its own store, so the number is real: the Redis tier reads `PTTL`
100
+ alongside the value, in the same pipelined round trip.
101
+
102
+ Every tier call the stack makes is best-effort: a tier that throws on `get`, `set` or `del` is a
103
+ tier that did not answer, so `read`, `write` and `drop` carry on. A feed too big for the LRU
104
+ (`X_CACHE_TOO_LARGE`) or a Redis with no socket costs the entry, never the read. The one call left
105
+ to throw is `load()` — it *is* the business read, and absorbing it would hand back `undefined` as
106
+ though it were the value.
107
+
108
+ `recentTierFailures()` is where those refusals go: last 100, newest first, each naming the tier, the
109
+ operation, the key and the `X_*` code, and each one also logged as `cache.tier.failed`. Same
110
+ bargain as `report.errors` on the invalidation side — degraded is visible, not merely slow.
111
+
112
+ `bestEffort(label, op, key, run)` is that guard, exported: a cache that is not a rung of this ladder
113
+ degrades into the same log rather than into a private `try/catch` nobody can read. The label is
114
+ closed (`TierLabel`) so the panel can group by it. `'query-read'` is in that union and emits nothing
115
+ — it named `@ultimat3/query`'s own read cache, a store in no registry, and that store is gone: a
116
+ `cache:` read fills these tiers, so its refusals carry the refusing tier's own name.
117
+
48
118
  ## Tags
49
119
 
50
120
  ```ts
@@ -66,20 +136,79 @@ declare module '@ultimat3/cache' {
66
136
  }
67
137
  ```
68
138
 
139
+ `declareTags()` takes the manifest's entity names at boot and is **additive and process-wide** —
140
+ once anything is declared, an undeclared tag is `X_CACHE_TAG_UNKNOWN`. A test that declares its own
141
+ fixture entity therefore turns validation on for every later file in the same `bun test` process.
142
+ `isolateDeclaredTags()` is the seam for that — see [Test seams](#test-seams).
143
+
144
+ ## The Redis tier's key layout
145
+
146
+ | Key | Holds |
147
+ |---|---|
148
+ | `<prefix>:<buildId>:c:<key>` | the value, `SET … PX` |
149
+ | `<prefix>:<buildId>:t:{<entity>}` | members carrying the **collection tag** — `tag('post')` |
150
+ | `<prefix>:<buildId>:t:{<entity>}:<id>` | members carrying **that row's tag** — `tag('post', '1')` |
151
+ | `<prefix>:<buildId>:e:{<entity>}` | members carrying **any** tag of the entity — the index |
152
+
153
+ Four keys, three jobs, and the fourth is why: `t:` buckets are the tags a caller declared and `e:`
154
+ is the entity index. A **collection bust** reads the index, so it clears the rows too; a **row
155
+ bust** reads that row's bucket and the collection tag's, so `post:2` survives a bust of `post:1`.
156
+ That is `tagMatches` — the same predicate the LRU answers through its two indexes and the request
157
+ memo answers through `tagsIntersect` — and the shared tier is the rung that did not, because `t:`
158
+ served as the index as well: `invalidateTags([tag('post', '1')])` came back with every post-tagged
159
+ key in the store and deleted them, so one row write emptied the shared tier for that entity while
160
+ the in-process tier one rung closer kept exactly the row that had changed.
161
+ `tier-parity.test.ts` compares all three rungs and `redis.live.test.ts` runs the same two busts
162
+ against a real server.
163
+
164
+ `{<entity>}` is a **Redis Cluster hash tag**, not decoration: it is what makes a row's bucket, its
165
+ collection's bucket and the index hash to one slot, so a script may take them in one `KEYS`.
166
+ Invalidation issues
167
+ **one script call per tag** for that reason — the batched form carried every tag's buckets in one
168
+ `EVAL` and was rejected with `CROSSSLOT` before the script ran, landing in `report.errors` as a
169
+ partial bust while stale rows served until TTL. Value keys are still deleted client-side, one `DEL`
170
+ each, which is slot-local under every topology.
171
+
172
+ The script **deletes nothing at all** — not the value keys, and not the buckets either. The tier
173
+ `SREM`s exactly the members whose `DEL` succeeded, so a refused delete keeps its membership and the
174
+ retry the error asks for still finds it; dropping the bucket inside the script made that failure
175
+ permanent. A `set` mirrors it: buckets are joined **before** the value is written and membership is
176
+ re-checked after, because a bust that landed in between would otherwise leave a row nothing can
177
+ reach by tag, serving until its own lease ran out.
178
+
179
+ **Every tag set carries a lease**, renewed on each write to the member's own TTL plus 60s, raised
180
+ only when the new lease is longer — a 60s member must not shorten a bucket a 1h member is in.
181
+ Without it a tag set grew forever: value keys died after five minutes, their membership never did,
182
+ and after a month `SMEMBERS` on a multi-million-member set blocked the server for hundreds of
183
+ milliseconds and answered with a list the client then `DEL`'d in batches. One publish became a
184
+ Redis outage. The renewal is a script (`REDIS_TAG_MEMBER_SCRIPT`, one key in `KEYS`) rather than
185
+ `EXPIRE … GT`, because `GT` treats a key with no TTL as infinite and would leave a **fresh** bucket
186
+ immortal — which is the bug being fixed.
187
+
188
+ **`buildId` defaults to `appVersion()`** (`APP_VERSION`, else `dev`), so two builds sharing one
189
+ Redis cannot read each other's payloads. Rename `PostView.author` to `PostView.authorId` and
190
+ deploy: `JSON.parse` does not validate, so the old pod reads the new shape back and hands it to a
191
+ renderer expecting the old one — an undefined author on every cached post, on half the fleet, for
192
+ the length of the rolling deploy. The cost of the default is a **cold shared tier per deploy**,
193
+ which is the cheaper of the two. Opt out with `createRedisTier({ buildId: null })` if you version
194
+ your own payloads.
195
+
69
196
  ## Invalidating
70
197
 
71
198
  ```ts
72
199
  const report = await invalidateTags([tag('post', postId)]);
73
200
  ```
74
201
 
75
- One function. Returns the report the `/_x` cache panel and `x cache bust --json` render:
202
+ One function. It returns the report below, which is also what the `/_x` cache panel renders and
203
+ what `x cache bust --json` will print once it ships; that command is planned and exits
204
+ `X_NOT_IMPLEMENTED` today.
76
205
 
77
206
  ```json
78
207
  {
79
208
  "tags": ["post:1"],
80
209
  "tiers": [{ "tier": "lru", "keys": ["feed"] }, { "tier": "redis", "keys": ["feed"] }],
81
210
  "isr": ["/blog", "/blog/hello"],
82
- "cdn": ["post:1"],
211
+ "cdn": ["/feed.xml"],
83
212
  "liveQueries": [],
84
213
  "durationMs": 1.4,
85
214
  "errors": []
@@ -89,11 +218,79 @@ One function. Returns the report the `/_x` cache panel and `x cache bust --json`
89
218
  A dead tier lands in `errors` and never throws — a Redis outage must not fail the write
90
219
  that triggered the bust. Entries there expire by TTL instead.
91
220
 
221
+ The fan-out walks the ladder **farthest tier first** — Redis before the LRU before the request memo
222
+ — and reports in read order. Clearing near-to-far leaves the far tier holding the old value after
223
+ the near ones are clear, and a read racing the bust promotes it straight back up into them: every
224
+ tier reports cleared and the LRU is stale again before the call returns. `stack.drop(key)` reverses
225
+ for the same reason.
226
+
227
+ `cdn` is what the dependency graph hangs off these tags, not what cleared: the `cdn` tier purges
228
+ those paths (as surrogate keys, alongside the tags), so what actually cleared is that tier's row
229
+ in `tiers`. With no `cdn` tier registered the list purges nowhere, which is why
230
+ `recentInvalidations()` reports `busted` from `tiers` and never from `cdn` — a partial bust that
231
+ reads as a clean one is the failure that log exists to catch.
232
+
92
233
  Every report is also kept: `recentInvalidations()` hands back the last 100, newest first, each
93
234
  one naming the span that triggered it. That is the log the `/_x` cache panel renders — "did it
94
235
  actually clear?" is answerable without a log dive because the one fan-out path retained the
95
236
  answer, not because a second recorder was wired next to it.
96
237
 
238
+ ### Across instances
239
+
240
+ `invalidateTags` clears the tiers of the process that called it. On a fleet that is one pod: a user
241
+ edits their profile on pod 3, their next request lands on pod 7, and pod 7's LRU serves the pre-edit
242
+ value for up to `defaultTtlMs`. Cache ships the **seam**, not the transport — it is tier 1 and may
243
+ not reach `realtime` or a message bus — and whoever owns the transport (`@ultimat3/cli`) wires it at
244
+ boot, exactly as `@ultimat3/render` wires the `Revalidator`:
245
+
246
+ ```ts
247
+ registerInvalidationBroadcast(async (wireTags) => bus.publish('cache.invalidate', wireTags));
248
+ bus.subscribe('cache.invalidate', (wireTags) => receiveInvalidationBroadcast(wireTags));
249
+ ```
250
+
251
+ | Half | Function | Emits? |
252
+ |---|---|---|
253
+ | outbound | `registerInvalidationBroadcast(fn)` — `fn(wireTags: readonly string[])`, called last, best-effort | — |
254
+ | inbound | `receiveInvalidationBroadcast(wireTags)` → `InvalidationReport` | **never** |
255
+
256
+ The inbound half **cannot** re-emit, and that is structural rather than a flag: `emit` lives on the
257
+ private fan-out options and `receiveInvalidationBroadcast` is the only caller that passes `false`.
258
+ A receiver that re-broadcast would be a storm bounded by nothing. A failed send lands in
259
+ `report.errors` under `tier: "broadcast"` — the other pods then clear on TTL, and the write that
260
+ triggered the bust still succeeds. An inbound tag this process has not declared is dropped and
261
+ reported rather than thrown, because mid-deploy the new pods know an entity the old ones do not and
262
+ a throw would kill the subscriber loop that delivered it.
263
+
264
+ ## Test seams
265
+
266
+ Every registry here is process-global and `bun test` is one process, so a suite undoes its own
267
+ registrations with an `isolate*()` — **capture and restore, never a reset**:
268
+
269
+ ```ts
270
+ const restoreTags = isolateDeclaredTags();
271
+ declareTags(['fixture']);
272
+ afterAll(restoreTags);
273
+ ```
274
+
275
+ | Helper | Puts back |
276
+ |---|---|
277
+ | `isolateDeclaredTags()` | the declared-tag set |
278
+ | `isolateGraph()` | every tag → dependent edge, across all three indexes |
279
+ | `isolateTiers()` | everything `resetTiers()` drops: the tier registry in registration order, the revalidator, the invalidation broadcast, `recentInvalidations()` and `recentTierFailures()` |
280
+
281
+ Each returns the function that puts back **exactly what it found**, so a per-test `resetGraph()` is
282
+ still fine — pair it with the module-scope isolate and the process gets its baseline back. A reset
283
+ alone is not a substitute: it drops what a neighbouring file registered, and
284
+ `@ultimat3/testing`'s leak guard compares its samples for *additions*, so the loss is invisible to
285
+ it and surfaces as a failure in an innocent file. That is the one leak no mechanism catches for you.
286
+ The last two live in the modules that own the state because a test file cannot reach it: the
287
+ revalidator has no reader, and neither log has a writer.
288
+
289
+ **`resetTierFailures()` and `isolateTierFailures()` are deliberately off `index.ts`.** Nothing
290
+ outside this package clears that log except through `resetTiers()`, which `isolateTiers()` already
291
+ covers — so a suite outside `packages/cache` isolates the tiers and gets the failure log with them.
292
+ `recentTierFailures()` is the only member of that module the public surface carries.
293
+
97
294
  ## CDN
98
295
 
99
296
  ```ts
@@ -103,7 +300,9 @@ cacheHeaders({ sMaxAge: 300, staleWhileRevalidate: 86_400, tags: [tag('post', id
103
300
  ```
104
301
 
105
302
  The surrogate keys **are** the tags, byte for byte, so an edge purge and an app-level
106
- invalidation can never mean different things. Three `PurgeDriver`s ship:
303
+ invalidation can never mean different things. A `cdn-path` dependent registered against a tag goes
304
+ out in the same purge — as a surrogate key, the one currency `PurgeDriver` has — so a host
305
+ registering one must tag that response with its own path. Three `PurgeDriver`s ship:
107
306
 
108
307
  | Driver | Purge | Purge all | Batch |
109
308
  |---|---|---|---|
@@ -142,9 +341,11 @@ cache).
142
341
  | Code | Cause |
143
342
  |---|---|
144
343
  | `X_CACHE_DRIVER_UNAVAILABLE` | `Bun.redis` missing, a purge driver built without its token, or a batch size that is not a positive integer |
344
+ | `X_CACHE_JITTER_INVALID` | a tier's `jitterFraction` outside `[0, 1)` |
145
345
  | `X_CACHE_PURGE_FAILED` | the CDN refused a purge, or a key it would split on whitespace |
146
346
  | `X_CACHE_TAG_UNKNOWN` | a tag no entity declared — usually a typo |
147
347
  | `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
348
+ | `X_CACHE_TTL_INVALID` | a `ttlMs` that is not a positive, finite number of milliseconds |
148
349
 
149
350
  ## Boundary
150
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.2.0"
34
+ "@ultimat3/core": "3.0.0"
34
35
  }
35
36
  }
package/src/cdn.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // the response, and purging by surrogate key when a tag changes. Surrogate keys ARE the
4
4
  // tags — same strings, so a CDN purge cannot drift from an app-level invalidation.
5
5
 
6
+ import { dependentsOfKind } from './graph';
6
7
  import type { CacheTag } from './tags';
7
8
  import { serializeTags } from './tags';
8
9
  import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
@@ -96,8 +97,18 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
96
97
  if (paths.length > 0) await driver.purge(paths);
97
98
  },
98
99
 
100
+ /**
101
+ * The tags themselves plus every `cdn-path` the graph hangs off them — one purge, one list.
102
+ *
103
+ * Those paths were computed by `invalidate.ts` and reported as busted while nothing ever
104
+ * purged them, so `x cache bust --json` named a path the edge still held for its whole
105
+ * `s-maxage`: a partial bust reading as a clean one, which is the one failure the report
106
+ * exists to prevent. They go out **as surrogate keys**, the single currency `PurgeDriver`
107
+ * has — the same convention `pathsForKey` already documents, so a host registering a
108
+ * `cdn-path` dependent must tag that response with its own path.
109
+ */
99
110
  async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
100
- const keys = serializeTags(tags);
111
+ const keys = [...new Set([...serializeTags(tags), ...dependentsOfKind(tags, 'cdn-path')])];
101
112
  if (keys.length === 0) return { tier: 'cdn', keys: [] };
102
113
  const accepted = await driver.purge(keys);
103
114
  return { tier: 'cdn', keys: accepted };