@ultimat3/cache 3.0.0 → 4.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 +9 -0
- package/README.md +12 -3
- package/package.json +2 -2
- package/src/cdn.ts +16 -0
- package/src/index.ts +2 -2
- package/src/purge-env.ts +0 -3
- package/src/redis.ts +20 -7
package/CLAUDE.md
CHANGED
|
@@ -157,6 +157,10 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
157
157
|
change: a cold shared tier, which the default build-id namespace already pays per deploy.
|
|
158
158
|
Pinned by `tier-parity.test.ts` (all three rungs, one test each) and `redis.live.test.ts` (the
|
|
159
159
|
same two busts against a real server, asserting the LRU's and Redis's survivors are EQUAL).
|
|
160
|
+
**A row bust does not read the index and still SREMs from it** (`sweepBucketsFor`) — reading it
|
|
161
|
+
over-reaches, removing a member cannot: only what the bust deleted leaves. Without that, a
|
|
162
|
+
deleted value key kept its membership in `e:{entity}` for ever while every write renewed that
|
|
163
|
+
index's lease — the unbounded `SMEMBERS` the lease was added to prevent, rebuilt out of corpses.
|
|
160
164
|
- **`CacheTier.set` REJECTS, never throws synchronously.** `createLruTier` and `createMemoTier` are
|
|
161
165
|
`async` for that reason alone — `LruCache.set` stays a sync API, but a `CacheTier` is one
|
|
162
166
|
interface with three implementations and `tier.set(...).catch(...)` has to mean the same thing on
|
|
@@ -212,6 +216,11 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
212
216
|
options and only `receiveInvalidationBroadcast` passes `false`. An inbound tag this process never
|
|
213
217
|
declared is dropped into `report.errors`, never thrown: a throw kills the subscriber loop and
|
|
214
218
|
silently ends cross-instance invalidation for the whole process.
|
|
219
|
+
- **A `cdn` tier holding `noopPurgeDriver()` reports `skipped`, never keys.** That is the default
|
|
220
|
+
state of every deployment with no CDN credentials (`selectPurgeDriver`), and the noop ECHOES the
|
|
221
|
+
keys it is handed — so the tier reported every tag as CLEARED and `busted` listed keys nothing
|
|
222
|
+
had purged, with `errors: []`. `isNoopPurgeDriver` lives in `cdn.ts`, beside the `name: 'noop'`
|
|
223
|
+
it tests for, because `createCdnTier` cannot import `purge-env.ts` without a cycle.
|
|
215
224
|
- **`report.cdn` is what depends on the tags; `report.tiers` is what cleared.** The `cdn` tier
|
|
216
225
|
purges `cdn-path` dependents itself, alongside the tags, so `busted` is built from `tiers` +
|
|
217
226
|
`isr` + `liveQueries` and never from `cdn` — folding in a list nothing purged is exactly the
|
package/README.md
CHANGED
|
@@ -170,9 +170,12 @@ partial bust while stale rows served until TTL. Value keys are still deleted cli
|
|
|
170
170
|
each, which is slot-local under every topology.
|
|
171
171
|
|
|
172
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,
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
`SREM`s exactly the members whose `DEL` succeeded, from every bucket that member joined —
|
|
174
|
+
including, for a row bust, the entity index it deliberately never *reads*. Reading `e:{entity}` for
|
|
175
|
+
a row bust would return every key of the entity; removing from it cannot over-reach, and a member
|
|
176
|
+
left there is a corpse in a set every later write renews the lease on. A refused delete keeps its
|
|
177
|
+
membership and the retry the error asks for still finds it; dropping the bucket inside the script
|
|
178
|
+
made that failure permanent. A `set` mirrors it: buckets are joined **before** the value is written and membership is
|
|
176
179
|
re-checked after, because a bust that landed in between would otherwise leave a row nothing can
|
|
177
180
|
reach by tag, serving until its own lease ran out.
|
|
178
181
|
|
|
@@ -319,6 +322,12 @@ nothing loads that file's contents at runtime:
|
|
|
319
322
|
| `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare |
|
|
320
323
|
| neither | nothing is purged, and `x dev` prints `cdn=none` |
|
|
321
324
|
|
|
325
|
+
A `cdn` tier holding the noop driver answers an invalidation with
|
|
326
|
+
`{ tier: 'cdn', keys: [], skipped: 'no purge driver configured' }` — never a list of keys. The noop
|
|
327
|
+
echoes what it is handed, so reporting its reply as accepted made every tag read as CLEARED in
|
|
328
|
+
`report.tiers` and in `recentInvalidations().busted`, with `errors: []`, in the default state of a
|
|
329
|
+
deployment that has no CDN at all. `isNoopPurgeDriver(driver)` is the same probe, exported.
|
|
330
|
+
|
|
322
331
|
Both pairs at once is `X_CONFIG_INVALID`: one process purges exactly one edge. Half a pair
|
|
323
332
|
is refused the same way — treating it as "no CDN" is how a deployment ships believing it
|
|
324
333
|
purges. Either refusal names the keys that are actually set, in `cause` and in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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": "4.1.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/cdn.ts
CHANGED
|
@@ -66,6 +66,15 @@ export function noopPurgeDriver(): PurgeDriver {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A driver that reaches no CDN, so a caller can report "purges nothing" without a name match.
|
|
71
|
+
*
|
|
72
|
+
* Lives here rather than beside `selectPurgeDriver`, which is where it was until 2026-08: the
|
|
73
|
+
* `name: 'noop'` it tests for is declared one function up, and `createCdnTier` — the caller that
|
|
74
|
+
* most needs it — cannot import from `purge-env.ts` without making a cycle of the two files.
|
|
75
|
+
*/
|
|
76
|
+
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
77
|
+
|
|
69
78
|
export interface CdnTierOptions {
|
|
70
79
|
readonly purge?: PurgeDriver;
|
|
71
80
|
/**
|
|
@@ -108,6 +117,13 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
|
|
|
108
117
|
* `cdn-path` dependent must tag that response with its own path.
|
|
109
118
|
*/
|
|
110
119
|
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
|
|
120
|
+
// The default state of any deployment with no CDN credentials, and it has to say so: the
|
|
121
|
+
// noop driver ECHOES its argument, so every tag came back as an accepted purge and
|
|
122
|
+
// `recentInvalidations().busted` listed keys nothing had cleared, `errors: []`. `skipped`
|
|
123
|
+
// is the field that already exists for this — a tier that did not run, named as one.
|
|
124
|
+
if (isNoopPurgeDriver(driver)) {
|
|
125
|
+
return { tier: 'cdn', keys: [], skipped: 'no purge driver configured' };
|
|
126
|
+
}
|
|
111
127
|
const keys = [...new Set([...serializeTags(tags), ...dependentsOfKind(tags, 'cdn-path')])];
|
|
112
128
|
if (keys.length === 0) return { tier: 'cdn', keys: [] };
|
|
113
129
|
const accepted = await driver.purge(keys);
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public API of @ultimat3/cache. Explicit, no `export *`.
|
|
2
2
|
|
|
3
3
|
export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn';
|
|
4
|
-
export { cacheHeaders, createCdnTier, noopPurgeDriver } from './cdn';
|
|
4
|
+
export { cacheHeaders, createCdnTier, isNoopPurgeDriver, noopPurgeDriver } from './cdn';
|
|
5
5
|
export type { CacheErrorCode } from './errors';
|
|
6
6
|
export {
|
|
7
7
|
CACHE_ERROR_CODES,
|
|
@@ -55,7 +55,7 @@ export {
|
|
|
55
55
|
cloudflarePurgeDriver,
|
|
56
56
|
} from './purge-cloudflare';
|
|
57
57
|
export type { PurgeEnvironment, PurgeSelection } from './purge-env';
|
|
58
|
-
export { CDN_PURGE_ENV_KEYS,
|
|
58
|
+
export { CDN_PURGE_ENV_KEYS, selectPurgeDriver } from './purge-env';
|
|
59
59
|
export type { FastlyPurgeOptions } from './purge-fastly';
|
|
60
60
|
export { FASTLY_API_URL, FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from './purge-fastly';
|
|
61
61
|
export type { PurgeFetch } from './purge-http';
|
package/src/purge-env.ts
CHANGED
|
@@ -41,9 +41,6 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
41
41
|
const configuredKeys = (env: PurgeEnvironment): readonly string[] =>
|
|
42
42
|
CDN_PURGE_ENV_KEYS.filter((key) => nonEmpty(env[key]) !== undefined);
|
|
43
43
|
|
|
44
|
-
/** A driver that reaches no CDN, so a caller can report "purges nothing" without a name match. */
|
|
45
|
-
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
46
|
-
|
|
47
44
|
/**
|
|
48
45
|
* Either key selects its provider, and the other is then required: a `FASTLY_SERVICE_ID` with no
|
|
49
46
|
* token is a half-finished deploy, and treating it as "no CDN" is how an environment ships
|
package/src/redis.ts
CHANGED
|
@@ -239,6 +239,19 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
239
239
|
? [entityKey(owned.entity), tagKey(owned)]
|
|
240
240
|
: [tagKey(owned), tagKey({ entity: owned.entity })];
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The buckets a bust CLEANS UP, which is not the set it reads — and the asymmetry is the point.
|
|
244
|
+
*
|
|
245
|
+
* A row bust must not READ the entity index: it holds every key of the entity, so the bust would
|
|
246
|
+
* delete them all. It must still SREM from it. `set` joins the index on every write, so a row
|
|
247
|
+
* bust that deletes a value key and leaves its membership there leaves a corpse no later bust
|
|
248
|
+
* can reach — while `TAG_MEMBER_SCRIPT` renews that index's lease on every write, which is the
|
|
249
|
+
* unbounded `SMEMBERS` the lease exists to prevent, rebuilt out of dead keys. Removing a member
|
|
250
|
+
* cannot over-reach the way reading one can: only what this bust actually deleted leaves.
|
|
251
|
+
*/
|
|
252
|
+
const sweepBucketsFor = (owned: CacheTag): string[] =>
|
|
253
|
+
owned.id === undefined ? [] : [entityKey(owned.entity)];
|
|
254
|
+
|
|
242
255
|
return {
|
|
243
256
|
name: 'redis',
|
|
244
257
|
|
|
@@ -330,18 +343,18 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
330
343
|
*/
|
|
331
344
|
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
|
|
332
345
|
const claimed = new Set<string>();
|
|
333
|
-
const perTag: string[][] = [];
|
|
346
|
+
const perTag: { read: string[]; sweep: string[] }[] = [];
|
|
334
347
|
for (const owned of tags) {
|
|
335
348
|
// A bucket already claimed by an earlier tag is dropped rather than re-sent: a collection
|
|
336
349
|
// tag and one of its rows overlap, and the second call would read the same members.
|
|
337
|
-
const
|
|
338
|
-
for (const bucket of
|
|
339
|
-
if (
|
|
350
|
+
const read = bustBucketsFor(owned).filter((bucket) => !claimed.has(bucket));
|
|
351
|
+
for (const bucket of read) claimed.add(bucket);
|
|
352
|
+
if (read.length > 0) perTag.push({ read, sweep: [...read, ...sweepBucketsFor(owned)] });
|
|
340
353
|
}
|
|
341
354
|
if (perTag.length === 0) return { tier: 'redis', keys: [] };
|
|
342
355
|
const replies = await Promise.all(
|
|
343
|
-
perTag.map((
|
|
344
|
-
conn().send('EVAL', [INVALIDATE_SCRIPT, String(
|
|
356
|
+
perTag.map(({ read }) =>
|
|
357
|
+
conn().send('EVAL', [INVALIDATE_SCRIPT, String(read.length), ...read]),
|
|
345
358
|
),
|
|
346
359
|
);
|
|
347
360
|
// A member may sit in two tag sets; deleting it twice is harmless but reporting it twice
|
|
@@ -369,7 +382,7 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
369
382
|
// drops the bucket, which is what made that failure permanent.
|
|
370
383
|
for (let i = 0; i < perTag.length; i += 1) {
|
|
371
384
|
const gone = [...new Set(toStrings(replies[i]))].filter((member) => deleted.has(member));
|
|
372
|
-
for (const bucket of perTag[i] ?? []) {
|
|
385
|
+
for (const bucket of perTag[i]?.sweep ?? []) {
|
|
373
386
|
for (let start = 0; start < gone.length; start += DELETE_BATCH) {
|
|
374
387
|
await conn().send('SREM', [bucket, ...gone.slice(start, start + DELETE_BATCH)]);
|
|
375
388
|
}
|