@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 +267 -0
- package/README.md +205 -4
- package/package.json +3 -2
- package/src/cdn.ts +12 -1
- package/src/errors.ts +50 -0
- package/src/fence.ts +110 -0
- package/src/graph.ts +0 -0
- package/src/index.ts +40 -3
- package/src/invalidate.ts +145 -19
- package/src/lru.ts +34 -12
- package/src/memo.ts +20 -2
- package/src/redis-fake.ts +115 -0
- package/src/redis.ts +287 -31
- package/src/semantic.ts +9 -2
- package/src/set-options.ts +65 -0
- package/src/single-flight.ts +78 -0
- package/src/tags.ts +45 -0
- package/src/tier-failures.ts +113 -0
- package/src/tiers.ts +221 -19
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.
|
|
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": ["
|
|
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.
|
|
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": "
|
|
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": "
|
|
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 };
|