@ultimat3/cache 21.0.0 → 22.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.
Files changed (4) hide show
  1. package/CLAUDE.md +113 -299
  2. package/README.md +15 -0
  3. package/package.json +2 -2
  4. package/src/cdn.ts +6 -2
package/CLAUDE.md CHANGED
@@ -10,308 +10,120 @@ Tier 1. Tagged caching + THE invalidation graph.
10
10
 
11
11
  ## Rules
12
12
 
13
- - **A tier's own LIMIT is screened where `app.config.ts` names it, `As of 2026-08-26`** —
14
- `assertFiniteCapacity` for a byte budget or an entry ceiling, `assertFiniteDurationMs` for a
15
- request budget or a `defaultTtlMs`, `assertFiniteSimilarityFloor` for the semantic tier's
16
- threshold — all three beside `assertTtl` in `tiers.ts`, and all three raising one code,
17
- `X_CACHE_LIMIT_INVALID`. Not a nicety: `Number(process.env.CACHE_MAX_BYTES)` on an unset variable
18
- is `NaN`, `NaN` is not nullish so `??` keeps it, and every comparison against it is FALSE —
19
- `while (bytes > maxBytes)` never evicted, so the bounded cache this package is built on had no
20
- bound at all, and `similarity < floor` never skipped, so the semantic tier answered the nearest
21
- thing it held to a question nobody asked. The failure is never a wrong number; it is the guard
22
- switching itself off. Never `Math.max(1, x)` in front of one: that is a clamp, not a validator.
23
- **The `Finite` in all three names is load-bearing** — `bun run finite-bounds` recognises a repair
24
- by the shape of the CALL, so while they were spelled `assertCapacity` / `assertTimeoutMs` all
25
- nine of this package's options read as unchecked to the ratchet while every one was screened.
26
-
27
- - `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path. Never call
28
- `tier.invalidateTags()` from outside it. It is also the only place the log is written:
29
- `recentInvalidations()` is a read of what that one path already reported, never a second
30
- recorder a caller has to remember to call.
31
- - **The fan-out clears FARTHEST tier first, and reports in read order.** Near-to-far leaves the far
32
- tier holding the old value after the near ones are clear, and a read racing the bust promotes it
33
- straight back up — `report.errors` empty, LRU stale again before the call returns. `CacheStack.drop`
34
- reverses for the same reason. The report is re-sorted into `TIER_ORDER` because it is what the
35
- `/_x` panel renders. Pinned in `invalidation-race.test.ts`.
36
- - **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). A read-through
37
- fill publishes rows `load()` read in the past, so a bust landing in between finds a key that is
38
- not there yet, reports `errors: []`, and is overwritten milliseconds later — invisible for the
39
- whole TTL. `sampleFence({ key, tags })` → `fence.isValid()` is the whole API, `markInvalidated`
40
- is its write half (called by `fanOut`, `CacheStack.write` and `CacheStack.drop`; a caller only
41
- needs it for a clearing path of its own). It is **exported** because a store outside this package
42
- with the same hole must not grow a second mechanism. `cover()` widens a fence
43
- RETROACTIVELY — needed only where joiners contribute tags the leader never sampled, which is
44
- `createCacheStack.read` and nothing else. A fence never fails a read: it declines to publish.
45
- This is the one process-global here with **no `isolate*()` seam and no reset**, and that is
46
- structural: a fence samples the current generation, which is always at or above what the ring has
47
- forgotten, so another file's marks cannot invalidate a fence sampled after them.
48
- - One graph. `graph.ts` exports functions over module state and **no constructor** — do not
49
- add one, do not add a second registry anywhere else.
13
+ - **A tier's own LIMIT is screened where `app.config.ts` names it** — `assertFiniteCapacity`,
14
+ `assertFiniteDurationMs`, `assertFiniteSimilarityFloor`, beside `assertTtl` in `tiers.ts`, all
15
+ raising `X_CACHE_LIMIT_INVALID`. A `NaN` limit makes every comparison false, so the guard switches
16
+ itself off (no eviction, no similarity floor). Never `Math.max(1, x)` in front: a clamp is not a
17
+ validator. **The `Finite` in the names is load-bearing** — `bun run finite-bounds` recognises a
18
+ repair by the call's shape.
19
+ - `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path, and the only writer of the log
20
+ `recentInvalidations()` reads. Never call `tier.invalidateTags()` from outside it.
21
+ - **The fan-out clears FARTHEST tier first, and reports in read order** (`TIER_ORDER`, what `/_x`
22
+ renders). Near-to-far lets a racing read promote the stale far value straight back up.
23
+ `CacheStack.drop` reverses for the same reason. `invalidation-race.test.ts`.
24
+ - **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). `sampleFence({ key,
25
+ tags })` → `fence.isValid()`; `markInvalidated` is the write half (`fanOut`, `CacheStack.write`,
26
+ `CacheStack.drop`). Exported so a store outside this package reuses it rather than growing a
27
+ second mechanism. `cover()` widens a fence for joiners' tags (`createCacheStack.read` only). A
28
+ fence never fails a read — it declines to publish. The one process-global here with no `isolate*()`
29
+ seam, structurally: a fence samples the current generation.
30
+ - One graph. `graph.ts` exports functions over module state and **no constructor**; no second registry.
50
31
  - Tag order is `TIER_ORDER`, never registration order. `sortTiers()` enforces it.
51
- - **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.**
52
- `TierName` is an alias of core's `CacheTierName` and `TIER_ORDER` IS `CACHE_TIERS` — the same
53
- array object, which `tier-vocabulary.test.ts` asserts by identity. Tier 0 owns the spelling
54
- because `app.config.ts`'s `cache.tiers` names the same rungs and core is the only place a tier-0
55
- declaration and this package can both see. It was spelled twice until 2026-08-22 (issue #293):
56
- config accepted `memo | lru | shared | isr | cdn`, so `cache: { tiers: ['isr'] }` typechecked and
57
- selected nothing, and `sortTiers` would have placed either unknown name at `-1` — AHEAD of the
58
- request memo. Adding a rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory
59
- here; `scripts/render-modes.ts` refuses a second declaration of the set anywhere in `packages/*/src`.
60
- **`isr` is not and never was a tier** — it is a `RenderMode`; the `'isr'` in `invalidate.ts` is an
61
- ISR ROUTE queued for regeneration (`DependentKind = 'isr-route'`), which is a different subject.
62
- - **`bestEffort()` is public, and it is the only sanctioned way to swallow a cache refusal.** A
63
- store outside this package that wraps its own `try/catch` degrades invisibly, and a second
64
- failure log nobody reads is what this bounded one exists to prevent. Its label is `TierLabel` —
65
- `TierName` plus `'query-read'` — closed, and deliberately NOT a widening of `TierName`: a name
66
- missing from `TIER_ORDER` sorts to `-1`, ahead of the request memo. A label is a log facet; a
67
- `TierName` is a position on the ladder.
68
- - **A refusal is rendered with `renderThrowable()`, never `error.message`** — the five sites that
69
- absorb one (`bestEffort`'s log entry, `fanOut`'s tier, ISR and broadcast catch blocks, and
70
- `purgePost`'s transport catch). A tier, a revalidator, a broadcast and the `fetch` a purge driver
71
- is given are all app-supplied, so the value they reject with is too:
72
- `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so
73
- building the log line used to raise INSTEAD of absorbing the refusal — on the business write that
74
- triggered the bust, which is the one caller both contracts promise to protect. The code field
75
- keeps its own total probe (`ultimateCode` in `tier-failures.ts`) rather than core's `stringField`:
76
- a driver error's `code` is a SQLSTATE and must never be reported as an `X_*` one. Consequence to
77
- know: a recorded `message` carries the throwable's NAME (`Error: nats is down`, `"just a string"`),
78
- which is what `renderThrowable` renders and what the tests here now pin.
79
- - Tier failures go into `report.errors`. A cache tier may never fail a business read or write.
80
- `createCacheStack` routes every `get`/`set`/`del` through `bestEffort()` for that reason — a
81
- refusal becomes "that tier did not answer" and lands in `recentTierFailures()`, the read side's
82
- equivalent of `report.errors`. `load()` is the one unguarded call: it is the business read, so
83
- swallowing it would return `undefined` as if it were the value. `LruCache.set` still throws
84
- `X_CACHE_TOO_LARGE` to a direct caller — the stack is the layer that degrades, not the tier.
85
- - **`serializeTags` is the wire form; `tagKeys` is the IDENTITY form, and the difference is the
86
- point.** `tagKeys` sorts and de-duplicates, because its readers build a descriptor field and a
87
- cache KEY out of it — `@ultimat3/query`'s `cacheKeyFor` above all, where a key that varied with
88
- declaration order fills two entries for one read and an action's `invalidates` drops whichever
89
- one it happens to name. It lives here, in the package that owns `serializeTag`, because
90
- `@ultimat3/action` and `@ultimat3/query` held byte-identical copies of it and are the same
91
- tier — so neither can import the other and a copy in either is a second answer for the other.
92
- The same move `toBucket` made into `@ultimat3/http`. **Known collision, not fixed here:**
93
- `@ultimat3/render` exports its own `tagKeys` (`render/src/route.ts`) with different behaviour —
94
- `serializeTags` over an optional list, declaration order preserved, pinned by its own
95
- `dsl.test.ts`. Two behaviours under one name in two packages an app imports together; the
96
- consolidation is render's to make, and this doc block is where a reader finds out.
97
- - **Two exports here have no production caller and both are KEPT — re-verified 2026-08.**
98
- `invalidateWireTags` is the wire-form door (`x cache bust` is planned and exits
99
- `X_NOT_IMPLEMENTED`); its sibling `receiveInvalidationBroadcast` takes the same wire form and DOES
100
- have one (`packages/cli/src/dev-cache.ts`), so deleting the outbound door would leave one of a
101
- matched pair. `recentTierFailures()` is the read-ladder half of `report.errors` and the `/_x`
102
- cache panel is still **not** its reader — `packages/cli/src/dev-dashboard.ts` imports
103
- `recentInvalidations` and nothing else — so it is read only by tests, `@ultimat3/query`'s
104
- `cache-degraded.test.ts` included. Neither is dead in the sense that matters: a bounded log with
105
- no panel is a log an operator can still reach, and removing it is removing the only evidence a
106
- degraded tier leaves. Wire the panel or leave them; do not delete one and keep the other.
32
+ - **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.** `TierName`
33
+ aliases core's `CacheTierName` and `TIER_ORDER` IS `CACHE_TIERS` (asserted by identity in
34
+ `tier-vocabulary.test.ts`). Adding a rung is an edit to `packages/core/src/cache-vocabulary.ts`
35
+ plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
36
+ not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
37
+ - **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
38
+ (`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
39
+ - **A refusal is rendered with `renderThrowable()`, never `error.message`** — at all five absorbing
40
+ sites. Tiers, revalidators, broadcasts and purge `fetch`es are app-supplied, so their rejection
41
+ values are too. The code field keeps its own total probe (`ultimateCode` in `tier-failures.ts`):
42
+ a driver `code` is a SQLSTATE, never an `X_*`.
43
+ - Tier failures go into `report.errors`; a cache tier may never fail a business read or write.
44
+ `createCacheStack` routes every `get`/`set`/`del` through `bestEffort()` into
45
+ `recentTierFailures()`. `load()` is the one unguarded call (it IS the business read).
46
+ `LruCache.set` still throws `X_CACHE_TOO_LARGE` to a direct caller.
47
+ - **`serializeTags` is the wire form; `tagKeys` is the IDENTITY form** (sorted, de-duplicated —
48
+ `@ultimat3/query`'s `cacheKeyFor` builds keys from it). It lives here because `action` and `query`
49
+ are one tier and cannot share a copy. **Known collision:** `@ultimat3/render` exports its own
50
+ `tagKeys` (`render/src/route.ts`) with different behaviour; consolidating is render's call.
51
+ - **Two exports have no production caller and both are KEPT**: `invalidateWireTags` (the outbound
52
+ twin of `receiveInvalidationBroadcast`, which `packages/cli/src/dev-cache.ts` calls) and
53
+ `recentTierFailures()` (no `/_x` panel reads it yet). Wire the panel or leave them.
107
54
  - `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`.
108
- - **`declareTags()` is additive and process-wide, so a test undoes it with `isolateDeclaredTags()`,
109
- never with `resetDeclaredTags()`.** The empty set is what switches `assertKnownTags` off, so one
110
- suite declaring a fixture entity makes every later file in the same `bun test` process validate
111
- against a registry it never saw — the cross-file X_CACHE_TAG_UNKNOWN `packages/query`'s read-cache
112
- suite used to fail with. A reset would drop a neighbour's declarations instead of only your own;
113
- `@ultimat3/testing`'s leak guard fails the file that leaks either the tag set or the tier registry.
114
- - **Every process-global registry here has that same seam, and a test file uses it: `isolateGraph()`
115
- (`graph.ts`), `isolateTiers()` (`invalidate.ts`), `isolateTierFailures()` (`tier-failures.ts`).**
116
- A per-test `resetGraph()` / `resetTierFailures()` stays where an empty registry is the subject —
117
- pair it with the module-scope isolate and an `afterAll(restore)`. The leak guard reports
118
- *additions* only, so a file that DELETES a neighbour's registrations is invisible to it and lands
119
- as a failure in an innocent file: a reset in a test file is the one leak nothing catches for you.
120
- The last two exist in the owning module because a test file cannot reach the state — the
121
- revalidator has no reader and neither log has a writer, so `resetTiers()` is unrecoverable from
122
- outside. `isolateTierFailures` is deliberately off `index.ts`, same as `resetTierFailures`:
123
- nothing outside this package can clear that log except through `resetTiers()`, which
124
- `isolateTiers()` already covers.
55
+ - **`declareTags()` is additive and process-wide: a test undoes it with `isolateDeclaredTags()`,
56
+ never `resetDeclaredTags()`** (the empty set switches `assertKnownTags` off).
57
+ - **Every process-global registry has that seam: `isolateGraph()`, `isolateTiers()`,
58
+ `isolateTierFailures()`.** A reset belongs only where an empty registry is the subject, paired
59
+ with the module-scope isolate and `afterAll(restore)` — the leak guard sees additions, never
60
+ deletions. `isolateTierFailures`/`resetTierFailures` stay off `index.ts`.
125
61
  - Clocks are injected (`LruOptions.clock`, `CacheStackOptions.clock`); read them through `nowMs()`.
126
- - **`ttlMs` is positive and finite, and `assertTtl` (in `tiers.ts`) is the one place that says so.**
127
- Every tier calls it before it writes — **the request memo included, `As of 2026-08`**: it was the
128
- last rung skipping it, so `ttlMs: 0` was stored by the memo and refused by the other three, and
129
- `createCacheStack` swallows both refusals through `bestEffort`, so the read HIT out of the one
130
- tier that should never have taken it. It validates a lease it then discards (it holds nothing past
131
- the request), and only a lease the caller SUPPLIED — there is no memo default to fall back on.
132
- So does `createMemorySemanticCache.remember` — which was
133
- the one writer skipping it, so `ttlMs: 0` stored an entry already past its expiry and every lookup
134
- missed with a completion bill as the only evidence. Its scope is `'semantic'` (`TtlScope`), with
135
- `jitterFraction: 0`: spreading a lease is a herd defence for a SHARED store, and that one is per
136
- process. `0` used to be "never expires" here and `EX 1` in `redis.ts`,
137
- so one stack answered two ways; the rule lives beside `CacheSetOptions` precisely so a new tier
138
- cannot invent a third reading. `X_CACHE_TTL_INVALID`, never a resolution.
139
- - **`assertTtl` also SPREADS the lease it validated** — validate, then jitter, one choke point. A
140
- rolling restart warms 40,000 keys in 30s on one lease and they all expire in one 30s window; the
141
- spread is what makes that a ramp. `rng` is injected (`LruOptions.rng`, `RedisTierOptions.rng`) —
142
- **never `Math.random()` at a call site**, or nothing downstream is deterministic. `rng: () => 0`
143
- is the full lease and is what a test asserting an exact `expiresAt` passes; `jitterFraction: 0`
144
- turns it off. Outside `[0, 1)` is `X_CACHE_JITTER_INVALID`, refused rather than clamped.
145
- - **`createCacheStack` is the production read path, and `@ultimat3/query` is its caller** (`As of
146
- 2026-08`). It had none for two releases — read-down/promote-up, the fence, single flight and the
147
- negative TTL were the package's whole design, reachable only from its own tests, while the one
148
- cached read path in the framework kept a private store beside the registry. `readThrough` calls
149
- `createCacheStack(registeredTiers(), { clock })` now, so deleting or bypassing this function
150
- removes the only thing that makes an action's `cache.invalidates` reach a `cache:` query.
151
- - **`createCacheStack` shares one in-flight `load()` per key** (`single-flight.ts`, mirroring
152
- `realtime`'s `entry.reading`). The share ends as the load settles — a REJECTED load must clear
153
- its entry too, or one origin failure becomes a permanent cached rejection. One `SingleFlight` per
154
- stack, never a module-level map: two stacks are two ladders.
155
- **The mechanism is `@ultimat3/core`'s since 2026-08-23** — this file's shape verbatim, one tier
156
- down, because four packages each grew a deduper and only copies can drift. `single-flight.ts`
157
- stays as the door, so `createSingleFlight`, `SingleFlight` and `FlightJoin` are still exported
158
- from `@ultimat3/cache` unchanged; `single-flight.test.ts` pins the delegation by IDENTITY, since
159
- behavioural parity is exactly what let the four copies drift in the first place.
160
- - **A wedged `load()` no longer holds its key for ever** (`As of 2026-08-23`). It used to: every
161
- later reader of that key joined a promise nothing would resolve, so a cache stopped damping an
162
- outage and became one. `createCacheStack` passes `deadlineMs: DEFAULT_LOAD_DEADLINE_MS` (30s;
163
- `loadDeadlineMs` overrides it, `schedule` injects the timer). The number is `@ultimat3/http`'s
164
- `requestTimeoutMs` default written out — a `load()` still running at 30s has no reader left to
165
- serve, because the request waiting on it was abandoned at the same instant — and it is a literal
166
- because cache is tier 1 and http is tier 2. **Eviction frees the KEY and nothing else**: `load()`
167
- is the app's function and this stack holds no signal to abort it, so the wedged load runs on and
168
- its own readers still get its answer. The cost is one duplicate fill, which the ladder's
169
- last-write-wins `set` already tolerates. Not an `app.config.ts` key on purpose — the ceiling
170
- belongs to whoever wrote the `load()`, and `bun run scripts/config-readers.ts` refuses a leaf
171
- key nothing reads.
172
- - **A joiner shares the leader's WRITE, so it contributes to it** (`FlightJoin`, merged by
173
- `mergeSetOptions` in `set-options.ts`). Keyed on `key` alone and read late, the entry used to land
174
- carrying only the leader's tags: the joiner's tag reached nothing, so the invalidation it declared
175
- never fired. Tags union, TTLs take the SHORTEST — an entry held longer than a caller asked for is
176
- stale to that caller. `work` reads the merge through `shared()` **after** the load, or it sees
177
- only what the leader brought — and **once more after the fill**, because the flight stays open
178
- for the whole ladder and a joiner merging a tag mid-fill hit the identical hole one rung later.
179
- The second read re-fills EVERY tier rather than the rungs still to come: re-reading per tier
180
- would land the near tier — the one every later read hits first — with the FEWEST tags, so an
181
- invalidation would clear the far rungs and leave the near one serving. `tagsAddedSince` in
182
- `set-options.ts` is what makes the second pass conditional; `tiers.test.ts`'s
183
- `a single-flight joiner that arrives during the FILL` is what notices.
184
- - **`negativeTtlMs` is the stack's decision, not a tier's.** Only `createCacheStack` sees what
185
- `load()` answered, so the `null`/`undefined` branch lives in `ttlOptionsFor` there and reaches a
186
- tier as an ordinary `ttlMs`.
187
- - **A promotion carries the entry's remaining life, not `options.ttlMs`** — `createCacheStack.read`
188
- writes the closer tiers with `hit.expiresAt - now`, and drops a hit that fails `isExpired`. A
189
- fresh full lease per read is a hot key that never goes stale enough to refetch. `isExpired` was
190
- exported and unit-tested and called by nothing; the stack is its one caller.
191
- - **Every tier's `get` therefore reports `expiresAt`, or the promotion above has nothing to carry.**
192
- `redis.ts` reads it from `PTTL`, issued alongside the `GET` so Bun pipelines the pair — the server
193
- owns the clock, so it survives skew between the node that wrote and the node that reads, and no
194
- stored payload shape changes under a running deployment. `-1`/`-2` are sentinels, not durations:
195
- they mean no expiry, never one millisecond ago.
196
- - **`t:` is the TAG's bucket and `e:` is the ENTITY index, and one key may not be both** (`As of
197
- 2026-08`). Three tiers implement `tagMatches` and the shared one was the outlier: a row-tagged
198
- write joined the collection bucket, and a row bust read that bucket back, so
199
- `invalidateTags([tag('post', '1')])` returned every post-tagged key in the store and deleted them
200
- — one row write emptying the shared tier for that entity, while the LRU one rung closer kept
201
- exactly the row that changed. `writeBucketsFor` joins the declared tag plus `e:{entity}`;
202
- `bustBucketsFor` reads the index for a COLLECTION bust and `t:{entity}:<id>` + `t:{entity}` for a
203
- ROW one. A collection bust also reads `t:{entity}` — a strict subset of the index today, kept so a
204
- `buildId: null` deployment upgrading into this layout does not MISS its old two-role buckets;
205
- over-reading a subset costs a round trip, under-reading is a stale read. It IS a wire-layout
206
- change: a cold shared tier, which the default build-id namespace already pays per deploy.
207
- Pinned by `tier-parity.test.ts` (all three rungs, one test each) and `redis.live.test.ts` (the
208
- same two busts against a real server, asserting the LRU's and Redis's survivors are EQUAL).
209
- **A row bust does not read the index and still SREMs from it** (`sweepBucketsFor`) — reading it
210
- over-reaches, removing a member cannot: only what the bust deleted leaves. Without that, a
211
- deleted value key kept its membership in `e:{entity}` for ever while every write renewed that
212
- index's lease — the unbounded `SMEMBERS` the lease was added to prevent, rebuilt out of corpses.
213
- - **`CacheTier.set` REJECTS, never throws synchronously.** `createLruTier` and `createMemoTier` are
214
- `async` for that reason alone — `LruCache.set` stays a sync API, but a `CacheTier` is one
215
- interface with three implementations and `tier.set(...).catch(...)` has to mean the same thing on
216
- every rung. `bestEffort` absorbs both shapes; a direct caller does not.
217
- - **`redis.ts`'s script deletes NOTHING — it reads.** The members of a tag set are value keys in
218
- slots this node may not own, so `DEL`ing them from Lua is a cross-slot access that fails on Redis
219
- Cluster and Dragonfly strict mode — into `report.errors`, so the bust reads as partial and stale
220
- rows serve until TTL. The script returns the members; the tier deletes them client-side, one key
221
- per `DEL`, which is slot-local under every topology.
222
- - **The bucket is not dropped in the script either, and the tier `SREM`s only what it deleted.**
223
- Dropping it atomically with the `SMEMBERS` made one failure permanent: a refused `DEL` left its
224
- member with no bucket to be found in, so the retry the error asks for answered `keys: []` and
225
- those rows served until their own TTL. `Promise.allSettled` is what makes "what actually died"
226
- knowable. A member a concurrent write added between the two halves keeps its membership instead
227
- of being orphaned by a bust that never deleted it.
228
- - **A `set` joins its buckets BEFORE it writes the value, and re-checks membership after.** Value
229
- first left a window where a bust's `SMEMBERS` saw an empty bucket and the value survived its own
230
- invalidation for the full TTL. Joining first moves the window somewhere observable: membership
231
- gone by the time the `SET` lands means this write was busted in the air, and the value goes with
232
- it — a row nothing can reach by tag is one no later bust can clear. Only a literal `0` from
233
- `SISMEMBER` counts as gone (`saysAbsent`); a reply the tier cannot read is not evidence, and
234
- deleting on one is a cache that never caches.
235
- - **`GET` and `PTTL` are two commands and the key can die between them.** `PTTL: -2` for a value
236
- the `GET` returned is a MISS, not an entry with no expiry — reported as a hit it is promoted into
237
- the LRU on the CALLER's ttl, so a row one millisecond from death gets a fresh five minutes one
238
- tier closer.
239
- - **That fixed half of it; `KEYS` itself was the other half.** Tag keys carry a `{entity}` hash tag
240
- (`<ns>:t:{post}`, `<ns>:t:{post}:7`) and `invalidateTags` issues **one script call per tag**, so
241
- every key a call is handed hashes to one slot. A single `EVAL` carrying two tags' buckets is
242
- `CROSSSLOT`-rejected before the script runs. The invariant a test pins: no command's `KEYS` ever
243
- spans two hash tags.
244
- - **Every tag set carries a lease, and the lease only grows.** `TAG_MEMBER_SCRIPT` does the `SADD`
245
- and the `EXPIRE` in one call, one key in `KEYS`. A set with no TTL is unbounded memory and a
246
- multi-million-member `SMEMBERS` on the next publish. `EXPIRE … GT` is NOT enough on its own — it
247
- treats a key with no TTL as infinite, so a fresh bucket would stay immortal, which is the bug.
248
- `SREM`-on-`del` is deliberately not done: `del(key)` does not know the key's tags without a read,
249
- and a bounded bucket lease already bounds the growth.
250
- - **A fake cannot run Lua, so it must never pretend to.** Both fakes used to mirror
251
- `INVALIDATE_SCRIPT` and `TAG_MEMBER_SCRIPT` in TypeScript and match on the exported constant's
252
- identity — so gutting either script to `return 1` / `return {}` left all 517 tests in `cache` +
253
- `query` green, with the entire shared-tier invalidation path proving nothing. The fakes are
254
- recorders now: the wire traffic is what a unit test asserts, and a test whose path READS a
255
- script's reply states it with `answerEval(script, reply)` — an unprogrammed `EVAL` throws rather
256
- than answering `[]`, which is exactly what the gutted script returns. **Every claim about what a
257
- script DOES belongs in `redis.live.test.ts`** (`describe.skipIf(!TEST_REDIS_URL)`), which is the
258
- only place either one is executed.
259
- - **The Redis namespace carries the build id by default** (`namespaceFor`, `appVersion()`). Two
260
- builds sharing one Redis otherwise read each other's payloads through a `JSON.parse` that does
261
- not validate. `buildId: null` opts out. The layout is wire-visible: changing it is a cold cache.
262
- - **Cross-instance invalidation is a SEAM here, never a transport.** `registerInvalidationBroadcast`
263
- (outbound) and `receiveInvalidationBroadcast` (inbound) mirror `registerRevalidator`; `cli` owns
264
- the bus. The inbound half cannot re-emit and that is structural — `emit` is on `fanOut`'s private
265
- options and only `receiveInvalidationBroadcast` passes `false`. An inbound tag this process never
266
- declared is dropped into `report.errors`, never thrown: a throw kills the subscriber loop and
267
- silently ends cross-instance invalidation for the whole process.
268
- - **A `cdn` tier holding `noopPurgeDriver()` reports `skipped`, never keys.** That is the default
269
- state of every deployment with no CDN credentials (`selectPurgeDriver`), and the noop ECHOES the
270
- keys it is handed — so the tier reported every tag as CLEARED and `busted` listed keys nothing
271
- had purged, with `errors: []`. `isNoopPurgeDriver` lives in `cdn.ts`, beside the `name: 'noop'`
272
- it tests for, because `createCdnTier` cannot import `purge-env.ts` without a cycle.
273
- - **`report.cdn` is what depends on the tags; `report.tiers` is what cleared.** The `cdn` tier
274
- purges `cdn-path` dependents itself, alongside the tags, so `busted` is built from `tiers` +
275
- `isr` + `liveQueries` and never from `cdn` — folding in a list nothing purged is exactly the
276
- partial-bust-reading-as-clean this log exists to prevent.
277
- - A purge driver is selected by `selectPurgeDriver` from the environment, never from an
278
- `app.config.ts` field — nothing loads that file's contents at runtime. Two CDN credentials at
279
- once is refused, not resolved, and half a pair is refused too: "no CDN" is the one wrong answer,
280
- because a deployment then ships believing it purges. The token never reaches a printed string.
281
- - A purge key is a wire tag unchanged. Every CDN splits a key list on whitespace and a comma, so
282
- `assertPurgeableKeys` refuses either **before** the request — a split key is purged successfully
283
- and clears nothing, which is the one CDN failure no later read can catch.
284
- - `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus
285
- any request that never got a status. The table is **`@ultimat3/core`'s** `isRetryableStatus`
286
- (`retryable-status.ts`), `As of 2026-08-23`, and is edited there — `purge-http.ts` re-exports it
287
- so both drivers still read "what a failure means" off the shared HTTP half, the same door
288
- `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`. It was a private `RETRYABLE_STATUSES`
289
- here that was byte-identical to `packages/mail/src/driver-resend.ts`'s, in two packages that
290
- cannot import each other, so one copy was always going to be edited alone.
291
- **It reaches `error.retry` too, `As of 2026-08-23`** — `CachePurgeFailedError` passes
292
- `retry: input.retryable ? 'retryable' : 'terminal'`. Without it the constructor fell back to
293
- `retryFor('X_CACHE_PURGE_FAILED')`, this package registers no retry class, and the default is
294
- `terminal`: a 429 serialised as `{ "retry": "terminal", "meta": { "retryable": true } }` — one
295
- error answering the retry question two ways, with `errorRetry()` (the one question a retry loop
296
- asks) giving the wrong one. Per-INSTANCE, never `registerErrorRetry`: one code covers a 401 that
297
- will never land and a 429 that will.
298
- - `X_CACHE_PURGE_FAILED` means a provider refused. A batch size that is not a positive integer is
299
- this package miswired, so `chunked()` raises `X_CACHE_DRIVER_UNAVAILABLE` instead — and it raises
300
- it *before* the loop, because a `0` spins forever and a `NaN` yields one empty batch, a purge that
301
- reports success having cleared nothing.
302
- - Every `fixFor()` branch names a command, an env key or a call. The gate's `fix:` scanner reads
303
- `fix:` properties, not the `return` literals in those functions, so the colocated
304
- "every failure fix names a command" tests are the only thing enforcing it.
305
- - A refusal's diagnostic reads the environment, never a hardcoded pair: `meta.configured` and the
306
- cause name the keys that are actually set. Names only — all four keys can hold a credential.
307
- - A remote driver takes an injected `fetch` so a test never unseals the network; the loopback
308
- proof in `purge-fastly.test.ts` is the only place the default one runs.
309
- - **The purge URL in a `fix:` goes through `renderFixShellArg`** (`As of 2026-09-06`).
310
- `purgePost`'s transport failure names `curl -sS -m 5 -o /dev/null <url>`, a command a reader
311
- pastes, and the URL is operator data from `.env.production` (`purge-env.ts`) — so `$(…)` and a
312
- backtick in it substitute before `curl` is reached. An ordinary endpoint travels verbatim; the
313
- URL is in the `detail` either way, which is read rather than run. `renderFixLiteral` cannot
314
- stand in: its double quotes leave `$(…)` live.
62
+ - **`ttlMs` is positive and finite, and `assertTtl` (`tiers.ts`) is the one place that says so** —
63
+ every tier calls it before writing, the request memo and `createMemorySemanticCache.remember`
64
+ included (scope `'semantic'`, `jitterFraction: 0`). `X_CACHE_TTL_INVALID`, never a resolution.
65
+ - **`assertTtl` also SPREADS the lease it validated.** `rng` is injected (`LruOptions.rng`,
66
+ `RedisTierOptions.rng`) — never `Math.random()` at a call site; `rng: () => 0` is the full lease.
67
+ `jitterFraction` outside `[0, 1)` is `X_CACHE_JITTER_INVALID`, refused, not clamped.
68
+ - **`createCacheStack` is the production read path, and `@ultimat3/query`'s `readThrough` is its
69
+ caller** — it is what makes an action's `cache.invalidates` reach a `cache:` query.
70
+ - **`createCacheStack` shares one in-flight `load()` per key** through `single-flight.ts`, which is
71
+ the door onto `@ultimat3/core`'s `createSingleFlight` (pinned by identity in
72
+ `single-flight.test.ts`). A rejected load clears its entry. One `SingleFlight` per stack.
73
+ - **A wedged `load()` frees its key after `DEFAULT_LOAD_DEADLINE_MS` (30 s)** — http's
74
+ `requestTimeoutMs` default, written as a literal because http is tier 2. `loadDeadlineMs`
75
+ overrides it and `schedule` injects the timer. Eviction frees the KEY only; the load runs on. Not
76
+ an `app.config.ts` key on purpose.
77
+ - **A joiner shares the leader's WRITE, so it contributes to it** (`FlightJoin`, `mergeSetOptions`
78
+ in `set-options.ts`): tags union, TTLs take the shortest. `work` reads the merge after the load
79
+ AND once more after the fill (`tagsAddedSince`), re-filling every tier.
80
+ `tiers.test.ts`'s "a single-flight joiner that arrives during the FILL" is the proof.
81
+ - **`negativeTtlMs` is the stack's decision** (`ttlOptionsFor`), reaching a tier as an ordinary `ttlMs`.
82
+ - **A promotion carries the entry's remaining life**, `hit.expiresAt - now`, and drops a hit that
83
+ fails `isExpired`. So every tier's `get` reports `expiresAt`: `redis.ts` reads `PTTL` pipelined
84
+ with the `GET` (server clock). `-1`/`-2` are sentinels, and `PTTL: -2` beside a returned value is a MISS.
85
+ - **`t:` is the TAG's bucket and `e:` is the ENTITY index; one key is never both.**
86
+ `writeBucketsFor` joins the declared tag plus `e:{entity}`; `bustBucketsFor` reads the index for a
87
+ collection bust and `t:{entity}:<id>` + `t:{entity}` for a row one. A row bust still `SREM`s what
88
+ it deleted from the index (`sweepBucketsFor`). Pinned by `tier-parity.test.ts` and
89
+ `redis.live.test.ts`.
90
+ - **`CacheTier.set` REJECTS, never throws synchronously** — `createLruTier`/`createMemoTier` are
91
+ `async` for that alone.
92
+ - **`redis.ts`'s script deletes NOTHING — it reads.** It returns the members; the tier `DEL`s them
93
+ client-side one key at a time (slot-local on Cluster/Dragonfly) and `SREM`s only what
94
+ `Promise.allSettled` says died, so a refused `DEL` stays findable for the retry.
95
+ - **A `set` joins its buckets BEFORE it writes the value, and re-checks membership after**; only a
96
+ literal `0` from `SISMEMBER` counts as busted-in-the-air (`saysAbsent`).
97
+ - **Tag keys carry a `{entity}` hash tag and `invalidateTags` issues one script call per tag**, so
98
+ no command's `KEYS` ever spans two hash tags.
99
+ - **Every tag set carries a lease that only grows** — `TAG_MEMBER_SCRIPT` does `SADD` + `EXPIRE` in
100
+ one call (`GT` alone leaves a fresh bucket immortal).
101
+ - **A fake cannot run Lua, so it must never pretend to.** The fakes are recorders; a test reading a
102
+ script's reply states it with `answerEval(script, reply)`, and an unprogrammed `EVAL` throws.
103
+ Every claim about what a script DOES belongs in `redis.live.test.ts`.
104
+ - **The Redis namespace carries the build id by default** (`namespaceFor`, `appVersion()`);
105
+ `buildId: null` opts out. Changing the layout is a cold cache.
106
+ - **Cross-instance invalidation is a SEAM, never a transport.** `registerInvalidationBroadcast` /
107
+ `receiveInvalidationBroadcast`; `cli` owns the bus. The inbound half cannot re-emit (only it passes
108
+ `emit: false`). An undeclared inbound tag goes to `report.errors`, never a throw.
109
+ - **A `cdn` tier holding `noopPurgeDriver()` reports `skipped`, never keys** (`isNoopPurgeDriver`,
110
+ in `cdn.ts`). **`report.cdn` is what depends; `report.tiers` is what cleared** — `busted` is built
111
+ from `tiers` + `isr` + `liveQueries`, never `cdn`.
112
+ - A purge driver is selected by `selectPurgeDriver` from the environment, never `app.config.ts`. Two
113
+ CDN credentials, or half a pair, is refused. The token never reaches a printed string.
114
+ - `assertPurgeableKeys` refuses whitespace or a comma in a key **before** the request.
115
+ - `retryable` on `X_CACHE_PURGE_FAILED` is core's `isRetryableStatus` (re-exported by
116
+ `purge-http.ts`), plus any request with no status, and `CachePurgeFailedError` passes it through
117
+ as a per-instance `retry` — never `registerErrorRetry`, one code covers a 401 and a 429.
118
+ - A non-positive-integer batch size is `X_CACHE_DRIVER_UNAVAILABLE`, raised by `chunked()` before
119
+ the loop.
120
+ - Every `fixFor()` branch names a command, an env key or a call; the colocated tests enforce it,
121
+ because the gate's scanner reads `fix:` properties only. A refusal names the env keys actually set,
122
+ never their values.
123
+ - A remote driver takes an injected `fetch`; `purge-fastly.test.ts`'s loopback proof is the only
124
+ place the default runs.
125
+ - **The purge URL in a `fix:` goes through `renderFixShellArg`** — it is operator data, and
126
+ `renderFixLiteral`'s double quotes leave `$(…)` live.
315
127
 
316
128
  ## Files
317
129
 
@@ -341,3 +153,5 @@ Tier 1. Tagged caching + THE invalidation graph.
341
153
  bun test packages/cache
342
154
  bun run --filter @ultimat3/cache typecheck
343
155
  ```
156
+
157
+ Why each rule above is shaped the way it is: [`docs/history/cache.md`](../../docs/history/cache.md).
package/README.md CHANGED
@@ -363,6 +363,21 @@ cache).
363
363
  | `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
364
364
  | `X_CACHE_TTL_INVALID` | a `ttlMs` that is not a positive, finite number of milliseconds |
365
365
 
366
+ ### Error classes
367
+
368
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
369
+ a job boundary the class is gone and the `code` is what survives — match on that.
370
+
371
+ | Class | Code | Declared in |
372
+ |---|---|---|
373
+ | `CacheDriverUnavailableError` | `X_CACHE_DRIVER_UNAVAILABLE` | `src/errors.ts` |
374
+ | `CacheJitterInvalidError` | `X_CACHE_JITTER_INVALID` | `src/errors.ts` |
375
+ | `CacheLimitInvalidError` | `X_CACHE_LIMIT_INVALID` | `src/errors.ts` |
376
+ | `CachePurgeFailedError` | `X_CACHE_PURGE_FAILED` | `src/errors.ts` |
377
+ | `CacheTagUnknownError` | `X_CACHE_TAG_UNKNOWN` | `src/errors.ts` |
378
+ | `CacheTooLargeError` | `X_CACHE_TOO_LARGE` | `src/errors.ts` |
379
+ | `CacheTtlInvalidError` | `X_CACHE_TTL_INVALID` | `src/errors.ts` |
380
+
366
381
  ## Boundary
367
382
 
368
383
  Tier 1. Imports `@ultimat3/core` and `@ultimat3/schema` only. Knows nothing about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "21.0.0",
3
+ "version": "22.1.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "21.0.0"
34
+ "@ultimat3/core": "22.1.0"
35
35
  }
36
36
  }
package/src/cdn.ts CHANGED
@@ -22,7 +22,7 @@ export interface CacheHeaderOptions {
22
22
  readonly tags?: readonly CacheTag[];
23
23
  }
24
24
 
25
- /** `Cache-Control` + `Surrogate-Key`, ready to spread into a `Headers` init. */
25
+ /** `Cache-Control` + `Surrogate-Key` + `Cache-Tag`, ready to spread into a `Headers` init. */
26
26
  export function cacheHeaders(options: CacheHeaderOptions = {}): Record<string, string> {
27
27
  const visibility = options.visibility ?? 'public';
28
28
  const parts: string[] = [visibility];
@@ -42,7 +42,11 @@ export function cacheHeaders(options: CacheHeaderOptions = {}): Record<string, s
42
42
 
43
43
  const headers: Record<string, string> = { 'Cache-Control': parts.join(', ') };
44
44
  const keys = serializeTags(options.tags ?? []);
45
- if (keys.length > 0) headers['Surrogate-Key'] = keys.join(' ');
45
+ if (keys.length > 0) {
46
+ headers['Surrogate-Key'] = keys.join(' ');
47
+ // Cloudflare's spelling of the same list; `@ultimat3/http`'s `applyCacheHeaders` writes both.
48
+ headers['Cache-Tag'] = keys.join(',');
49
+ }
46
50
  return headers;
47
51
  }
48
52