@ultimat3/cache 7.0.0 → 9.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 +22 -4
- package/package.json +2 -2
- package/src/purge-http.ts +6 -1
- package/src/set-options.ts +17 -0
- package/src/tiers.ts +31 -7
package/CLAUDE.md
CHANGED
|
@@ -34,15 +34,27 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
34
34
|
- One graph. `graph.ts` exports functions over module state and **no constructor** — do not
|
|
35
35
|
add one, do not add a second registry anywhere else.
|
|
36
36
|
- Tag order is `TIER_ORDER`, never registration order. `sortTiers()` enforces it.
|
|
37
|
+
- **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.**
|
|
38
|
+
`TierName` is an alias of core's `CacheTierName` and `TIER_ORDER` IS `CACHE_TIERS` — the same
|
|
39
|
+
array object, which `tier-vocabulary.test.ts` asserts by identity. Tier 0 owns the spelling
|
|
40
|
+
because `app.config.ts`'s `cache.tiers` names the same rungs and core is the only place a tier-0
|
|
41
|
+
declaration and this package can both see. It was spelled twice until 2026-08-22 (issue #293):
|
|
42
|
+
config accepted `memo | lru | shared | isr | cdn`, so `cache: { tiers: ['isr'] }` typechecked and
|
|
43
|
+
selected nothing, and `sortTiers` would have placed either unknown name at `-1` — AHEAD of the
|
|
44
|
+
request memo. Adding a rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory
|
|
45
|
+
here; `scripts/render-modes.ts` refuses a second declaration of the set anywhere in `packages/*/src`.
|
|
46
|
+
**`isr` is not and never was a tier** — it is a `RenderMode`; the `'isr'` in `invalidate.ts` is an
|
|
47
|
+
ISR ROUTE queued for regeneration (`DependentKind = 'isr-route'`), which is a different subject.
|
|
37
48
|
- **`bestEffort()` is public, and it is the only sanctioned way to swallow a cache refusal.** A
|
|
38
49
|
store outside this package that wraps its own `try/catch` degrades invisibly, and a second
|
|
39
50
|
failure log nobody reads is what this bounded one exists to prevent. Its label is `TierLabel` —
|
|
40
51
|
`TierName` plus `'query-read'` — closed, and deliberately NOT a widening of `TierName`: a name
|
|
41
52
|
missing from `TIER_ORDER` sorts to `-1`, ahead of the request memo. A label is a log facet; a
|
|
42
53
|
`TierName` is a position on the ladder.
|
|
43
|
-
- **A refusal is rendered with `renderThrowable()`, never `error.message`** — the
|
|
44
|
-
absorb one (`bestEffort`'s log entry,
|
|
45
|
-
tier, a revalidator
|
|
54
|
+
- **A refusal is rendered with `renderThrowable()`, never `error.message`** — the five sites that
|
|
55
|
+
absorb one (`bestEffort`'s log entry, `fanOut`'s tier, ISR and broadcast catch blocks, and
|
|
56
|
+
`purgePost`'s transport catch). A tier, a revalidator, a broadcast and the `fetch` a purge driver
|
|
57
|
+
is given are all app-supplied, so the value they reject with is too:
|
|
46
58
|
`instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so
|
|
47
59
|
building the log line used to raise INSTEAD of absorbing the refusal — on the business write that
|
|
48
60
|
triggered the bust, which is the one caller both contracts promise to protect. The code field
|
|
@@ -131,7 +143,13 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
131
143
|
carrying only the leader's tags: the joiner's tag reached nothing, so the invalidation it declared
|
|
132
144
|
never fired. Tags union, TTLs take the SHORTEST — an entry held longer than a caller asked for is
|
|
133
145
|
stale to that caller. `work` reads the merge through `shared()` **after** the load, or it sees
|
|
134
|
-
only what the leader brought
|
|
146
|
+
only what the leader brought — and **once more after the fill**, because the flight stays open
|
|
147
|
+
for the whole ladder and a joiner merging a tag mid-fill hit the identical hole one rung later.
|
|
148
|
+
The second read re-fills EVERY tier rather than the rungs still to come: re-reading per tier
|
|
149
|
+
would land the near tier — the one every later read hits first — with the FEWEST tags, so an
|
|
150
|
+
invalidation would clear the far rungs and leave the near one serving. `tagsAddedSince` in
|
|
151
|
+
`set-options.ts` is what makes the second pass conditional; `tiers.test.ts`'s
|
|
152
|
+
`a single-flight joiner that arrives during the FILL` is what notices.
|
|
135
153
|
- **`negativeTtlMs` is the stack's decision, not a tier's.** Only `createCacheStack` sees what
|
|
136
154
|
`load()` answered, so the `null`/`undefined` branch lives in `ttlOptionsFor` there and reaches a
|
|
137
155
|
tier as an ordinary `ttlMs`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.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": "
|
|
34
|
+
"@ultimat3/core": "9.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
|
-
|
|
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)`,
|
package/src/set-options.ts
CHANGED
|
@@ -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
|
@@ -3,20 +3,27 @@
|
|
|
3
3
|
// so a deployment can omit Redis (single node) or add the CDN tier without touching call
|
|
4
4
|
// sites. Order is data, not control flow.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import { systemClock } from '@ultimat3/core';
|
|
6
|
+
import type { CacheTierName, Clock } from '@ultimat3/core';
|
|
7
|
+
import { CACHE_TIERS, 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';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The rungs, spelled in `@ultimat3/core` and nowhere else. Tier 0 owns the NAMES because
|
|
18
|
+
* `app.config.ts` names them too and core is the one place a tier-0 declaration and this package
|
|
19
|
+
* can both see; the ladder — order, promotion, fan-out — is still this file's. Aliased rather than
|
|
20
|
+
* re-exported under core's name so the ~30 call sites that already import `TierName` keep working:
|
|
21
|
+
* one declaration, two names, against one declaration each in two packages that disagreed.
|
|
22
|
+
*/
|
|
23
|
+
export type TierName = CacheTierName;
|
|
17
24
|
|
|
18
25
|
/** Read order. Index in this array is the tier's distance from the request. */
|
|
19
|
-
export const TIER_ORDER: readonly TierName[] =
|
|
26
|
+
export const TIER_ORDER: readonly TierName[] = CACHE_TIERS;
|
|
20
27
|
|
|
21
28
|
/**
|
|
22
29
|
* Who a swallowed refusal is attributed to in `recentTierFailures()` and the `/_x` panel: every
|
|
@@ -255,6 +262,10 @@ export function createCacheStack(
|
|
|
255
262
|
tiers: ordered,
|
|
256
263
|
|
|
257
264
|
async read<T>(key: string, load: () => Promise<T>, setOptions?: CacheSetOptions): Promise<T> {
|
|
265
|
+
// Outside the flight on purpose, and the cost is known: N concurrent misses each walk the
|
|
266
|
+
// ladder before any of them joins, so a cold key pays N gets per rung. Moving it inside
|
|
267
|
+
// would serialise every HIT behind whichever caller happened to arrive first — the common
|
|
268
|
+
// case paying for the rare one. Carried as a Low; measure before changing it.
|
|
258
269
|
const hit = await lookup<T>(key, setOptions);
|
|
259
270
|
if (hit !== undefined) return hit.value;
|
|
260
271
|
|
|
@@ -273,9 +284,22 @@ export function createCacheStack(
|
|
|
273
284
|
const value = await load();
|
|
274
285
|
// Joiners merged their own tags into the load they shared; covering is retroactive, so
|
|
275
286
|
// a tag that arrived mid-load is fenced back to the sample rather than from now.
|
|
287
|
+
const publish = async (options: CacheSetOptions | undefined): Promise<void> => {
|
|
288
|
+
if (options?.tags !== undefined) fence.cover({ tags: options.tags });
|
|
289
|
+
await fill(key, value, options, fence);
|
|
290
|
+
};
|
|
276
291
|
const merged = shared() ?? setOptions;
|
|
277
|
-
|
|
278
|
-
|
|
292
|
+
await publish(merged);
|
|
293
|
+
// The flight stays open until this whole `work` settles, and `fill` is one await per
|
|
294
|
+
// rung — so a joiner can still merge a tag after the read above, and the entry that
|
|
295
|
+
// landed would carry the leader's tags alone, which `invalidateTags` can never reach.
|
|
296
|
+
// Re-read once and re-fill EVERY tier: re-reading per tier instead would land the near
|
|
297
|
+
// tier — the one every later read hits first — with the FEWEST tags, so an invalidation
|
|
298
|
+
// would clear the far rungs and leave the near one serving. A joiner arriving inside
|
|
299
|
+
// the second pass is left where a plain cache hit already leaves one: reading a value
|
|
300
|
+
// that was published without its tag.
|
|
301
|
+
const late = shared() ?? setOptions;
|
|
302
|
+
if (tagsAddedSince(merged, late)) await publish(late);
|
|
279
303
|
return value;
|
|
280
304
|
},
|
|
281
305
|
{ context: setOptions ?? {}, merge: mergeSetOptions },
|