@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.
- package/CLAUDE.md +113 -299
- package/README.md +15 -0
- package/package.json +2 -2
- 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
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`
|
|
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
|
|
109
|
-
never
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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` (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
- **`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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": "
|
|
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": "
|
|
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)
|
|
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
|
|