@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 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, 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
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.0.0",
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": "3.0.0"
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, isNoopPurgeDriver, selectPurgeDriver } from './purge-env';
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 buckets = bustBucketsFor(owned).filter((bucket) => !claimed.has(bucket));
338
- for (const bucket of buckets) claimed.add(bucket);
339
- if (buckets.length > 0) perTag.push(buckets);
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((buckets) =>
344
- conn().send('EVAL', [INVALIDATE_SCRIPT, String(buckets.length), ...buckets]),
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
  }