@ultimat3/cache 7.0.0 → 8.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 CHANGED
@@ -40,9 +40,10 @@ Tier 1. Tagged caching + THE invalidation graph.
40
40
  `TierName` plus `'query-read'` — closed, and deliberately NOT a widening of `TierName`: a name
41
41
  missing from `TIER_ORDER` sorts to `-1`, ahead of the request memo. A label is a log facet; a
42
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:
43
+ - **A refusal is rendered with `renderThrowable()`, never `error.message`** — the five sites that
44
+ absorb one (`bestEffort`'s log entry, `fanOut`'s tier, ISR and broadcast catch blocks, and
45
+ `purgePost`'s transport catch). A tier, a revalidator, a broadcast and the `fetch` a purge driver
46
+ is given are all app-supplied, so the value they reject with is too:
46
47
  `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so
47
48
  building the log line used to raise INSTEAD of absorbing the refusal — on the business write that
48
49
  triggered the bust, which is the one caller both contracts promise to protect. The code field
@@ -131,7 +132,13 @@ Tier 1. Tagged caching + THE invalidation graph.
131
132
  carrying only the leader's tags: the joiner's tag reached nothing, so the invalidation it declared
132
133
  never fired. Tags union, TTLs take the SHORTEST — an entry held longer than a caller asked for is
133
134
  stale to that caller. `work` reads the merge through `shared()` **after** the load, or it sees
134
- only what the leader brought.
135
+ only what the leader brought — and **once more after the fill**, because the flight stays open
136
+ for the whole ladder and a joiner merging a tag mid-fill hit the identical hole one rung later.
137
+ The second read re-fills EVERY tier rather than the rungs still to come: re-reading per tier
138
+ would land the near tier — the one every later read hits first — with the FEWEST tags, so an
139
+ invalidation would clear the far rungs and leave the near one serving. `tagsAddedSince` in
140
+ `set-options.ts` is what makes the second pass conditional; `tiers.test.ts`'s
141
+ `a single-flight joiner that arrives during the FILL` is what notices.
135
142
  - **`negativeTtlMs` is the stack's decision, not a tier's.** Only `createCacheStack` sees what
136
143
  `load()` answered, so the `null`/`undefined` branch lives in `ttlOptionsFor` there and reaches a
137
144
  tier as an ordinary `ttlMs`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "7.0.0",
3
+ "version": "8.0.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": "7.0.0"
34
+ "@ultimat3/core": "8.0.0"
35
35
  }
36
36
  }
package/src/purge-http.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // forces. Kept apart from the drivers because "which failure can succeed unchanged" is one
4
4
  // judgement, and two copies of it would drift into two answers for the same 429.
5
5
 
6
+ import { renderThrowable } from '@ultimat3/core';
6
7
  import { CacheDriverUnavailableError, CachePurgeFailedError } from './errors';
7
8
 
8
9
  /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
@@ -152,7 +153,11 @@ export async function purgePost(input: PurgePostInput): Promise<Response> {
152
153
  signal: AbortSignal.timeout(input.timeoutMs),
153
154
  });
154
155
  } catch (error) {
155
- const reason = error instanceof Error ? error.message : 'the request failed before a response';
156
+ // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here,
157
+ // so the rejection is whatever a driver or a test double threw — and `instanceof` itself
158
+ // throws on a `Proxy` whose `getPrototypeOf` does, which would replace the coded refusal this
159
+ // catch exists to raise with a bare `TypeError` from inside it. Same rule as `invalidate.ts`.
160
+ const reason = renderThrowable(error);
156
161
  throw new CachePurgeFailedError({
157
162
  driver: input.driver,
158
163
  detail: `${reason} — nothing left this host for ${input.url} (egress, DNS or TLS)`,
@@ -63,3 +63,20 @@ export function mergeSetOptions(
63
63
  ...(negativeTtlMs === undefined ? {} : { negativeTtlMs }),
64
64
  };
65
65
  }
66
+
67
+ /**
68
+ * Did `latest` gain a tag `written` does not carry?
69
+ *
70
+ * Compared on the wire form — the identity every tier indexes by and the one `mergeTags` above
71
+ * dedupes on — so "already written" means the same thing to both, and a re-fill is asked for
72
+ * exactly when a joiner brought something new.
73
+ */
74
+ export function tagsAddedSince(
75
+ written: CacheSetOptions | undefined,
76
+ latest: CacheSetOptions | undefined,
77
+ ): boolean {
78
+ const added = latest?.tags;
79
+ if (added === undefined || added.length === 0) return false;
80
+ const seen = new Set((written?.tags ?? []).map(serializeTag));
81
+ return added.some((owned) => !seen.has(serializeTag(owned)));
82
+ }
package/src/tiers.ts CHANGED
@@ -8,7 +8,7 @@ import { systemClock } from '@ultimat3/core';
8
8
  import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors';
9
9
  import type { CacheFence } from './fence';
10
10
  import { markInvalidated, sampleFence } from './fence';
11
- import { mergeSetOptions, ttlOptionsFor } from './set-options';
11
+ import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options';
12
12
  import { createSingleFlight } from './single-flight';
13
13
  import type { CacheTag } from './tags';
14
14
  import { bestEffort } from './tier-failures';
@@ -255,6 +255,10 @@ export function createCacheStack(
255
255
  tiers: ordered,
256
256
 
257
257
  async read<T>(key: string, load: () => Promise<T>, setOptions?: CacheSetOptions): Promise<T> {
258
+ // Outside the flight on purpose, and the cost is known: N concurrent misses each walk the
259
+ // ladder before any of them joins, so a cold key pays N gets per rung. Moving it inside
260
+ // would serialise every HIT behind whichever caller happened to arrive first — the common
261
+ // case paying for the rare one. Carried as a Low; measure before changing it.
258
262
  const hit = await lookup<T>(key, setOptions);
259
263
  if (hit !== undefined) return hit.value;
260
264
 
@@ -273,9 +277,22 @@ export function createCacheStack(
273
277
  const value = await load();
274
278
  // Joiners merged their own tags into the load they shared; covering is retroactive, so
275
279
  // a tag that arrived mid-load is fenced back to the sample rather than from now.
280
+ const publish = async (options: CacheSetOptions | undefined): Promise<void> => {
281
+ if (options?.tags !== undefined) fence.cover({ tags: options.tags });
282
+ await fill(key, value, options, fence);
283
+ };
276
284
  const merged = shared() ?? setOptions;
277
- if (merged?.tags !== undefined) fence.cover({ tags: merged.tags });
278
- await fill(key, value, merged, fence);
285
+ await publish(merged);
286
+ // The flight stays open until this whole `work` settles, and `fill` is one await per
287
+ // rung — so a joiner can still merge a tag after the read above, and the entry that
288
+ // landed would carry the leader's tags alone, which `invalidateTags` can never reach.
289
+ // Re-read once and re-fill EVERY tier: re-reading per tier instead would land the near
290
+ // tier — the one every later read hits first — with the FEWEST tags, so an invalidation
291
+ // would clear the far rungs and leave the near one serving. A joiner arriving inside
292
+ // the second pass is left where a plain cache hit already leaves one: reading a value
293
+ // that was published without its tag.
294
+ const late = shared() ?? setOptions;
295
+ if (tagsAddedSince(merged, late)) await publish(late);
279
296
  return value;
280
297
  },
281
298
  { context: setOptions ?? {}, merge: mergeSetOptions },