@ultimat3/cache 8.0.0 → 10.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 +18 -0
- package/package.json +2 -2
- package/src/errors.ts +5 -8
- package/src/purge-fastly.ts +4 -1
- package/src/tiers.ts +11 -4
package/CLAUDE.md
CHANGED
|
@@ -34,6 +34,17 @@ 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` —
|
|
@@ -241,6 +252,13 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
241
252
|
and clears nothing, which is the one CDN failure no later read can catch.
|
|
242
253
|
- `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus
|
|
243
254
|
any request that never got a status. That table lives in `purge-http.ts` and is edited there.
|
|
255
|
+
**It reaches `error.retry` too, `As of 2026-08-23`** — `CachePurgeFailedError` passes
|
|
256
|
+
`retry: input.retryable ? 'retryable' : 'terminal'`. Without it the constructor fell back to
|
|
257
|
+
`retryFor('X_CACHE_PURGE_FAILED')`, this package registers no retry class, and the default is
|
|
258
|
+
`terminal`: a 429 serialised as `{ "retry": "terminal", "meta": { "retryable": true } }` — one
|
|
259
|
+
error answering the retry question two ways, with `errorRetry()` (the one question a retry loop
|
|
260
|
+
asks) giving the wrong one. Per-INSTANCE, never `registerErrorRetry`: one code covers a 401 that
|
|
261
|
+
will never land and a 429 that will.
|
|
244
262
|
- `X_CACHE_PURGE_FAILED` means a provider refused. A batch size that is not a positive integer is
|
|
245
263
|
this package miswired, so `chunked()` raises `X_CACHE_DRIVER_UNAVAILABLE` instead — and it raises
|
|
246
264
|
it *before* the loop, because a `0` spins forever and a `NaN` yields one empty batch, a purge that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.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": "10.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/errors.ts
CHANGED
|
@@ -33,8 +33,6 @@ registerErrorCodes(
|
|
|
33
33
|
Object.fromEntries(Object.entries(CACHE_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
-
const docsFor = (code: CacheErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
37
|
-
|
|
38
36
|
/** A tier's backing store is missing at runtime (no Redis binding, no CDN token). */
|
|
39
37
|
export class CacheDriverUnavailableError extends UltimateError {
|
|
40
38
|
constructor(input: { driver: string; cause: string; fix: string }) {
|
|
@@ -42,7 +40,6 @@ export class CacheDriverUnavailableError extends UltimateError {
|
|
|
42
40
|
code: 'X_CACHE_DRIVER_UNAVAILABLE',
|
|
43
41
|
cause: `cache tier "${input.driver}" is unavailable: ${input.cause}`,
|
|
44
42
|
fix: input.fix,
|
|
45
|
-
docs: docsFor('X_CACHE_DRIVER_UNAVAILABLE'),
|
|
46
43
|
});
|
|
47
44
|
}
|
|
48
45
|
}
|
|
@@ -59,7 +56,6 @@ export class CacheTagUnknownError extends UltimateError {
|
|
|
59
56
|
input.known.length > 0 ? input.known.join(', ') : 'none'
|
|
60
57
|
})`,
|
|
61
58
|
fix: 'x manifest',
|
|
62
|
-
docs: docsFor('X_CACHE_TAG_UNKNOWN'),
|
|
63
59
|
});
|
|
64
60
|
}
|
|
65
61
|
}
|
|
@@ -71,7 +67,6 @@ export class CacheTooLargeError extends UltimateError {
|
|
|
71
67
|
code: 'X_CACHE_TOO_LARGE',
|
|
72
68
|
cause: `entry "${input.key}" is ${input.bytes}B, over the ${input.tier} budget of ${input.maxBytes}B`,
|
|
73
69
|
fix: `raise cache.${input.tier}.maxBytes in app.config.ts, or cache a projection instead of the row`,
|
|
74
|
-
docs: docsFor('X_CACHE_TOO_LARGE'),
|
|
75
70
|
});
|
|
76
71
|
}
|
|
77
72
|
}
|
|
@@ -93,7 +88,6 @@ export class CacheTtlInvalidError extends UltimateError {
|
|
|
93
88
|
input.ttlMs,
|
|
94
89
|
)}; a TTL is a positive, finite number of milliseconds`,
|
|
95
90
|
fix: `cache.write('${input.key}', value, { ttlMs: 60_000 }) # or drop the option for the tier default; a value you do not want held is one you do not write`,
|
|
96
|
-
docs: docsFor('X_CACHE_TTL_INVALID'),
|
|
97
91
|
meta: { key: input.key, ttlMs: input.ttlMs, tier: input.tier },
|
|
98
92
|
});
|
|
99
93
|
}
|
|
@@ -116,7 +110,6 @@ export class CacheJitterInvalidError extends UltimateError {
|
|
|
116
110
|
input.jitterFraction,
|
|
117
111
|
)}; a jitter fraction is a finite number in [0, 1)`,
|
|
118
112
|
fix: `set cache.${input.tier}.jitterFraction in app.config.ts to a value in [0, 1) — 0.05 is the default, 0 disables jitter`,
|
|
119
|
-
docs: docsFor('X_CACHE_JITTER_INVALID'),
|
|
120
113
|
meta: { tier: input.tier, jitterFraction: input.jitterFraction },
|
|
121
114
|
});
|
|
122
115
|
}
|
|
@@ -141,7 +134,11 @@ export class CachePurgeFailedError extends UltimateError {
|
|
|
141
134
|
code: 'X_CACHE_PURGE_FAILED',
|
|
142
135
|
cause: `${input.driver} refused the purge${status}: ${input.detail}`,
|
|
143
136
|
fix: input.fix,
|
|
144
|
-
|
|
137
|
+
// Per-instance rather than `registerErrorRetry`, because one code covers both a 401 (never
|
|
138
|
+
// worth resending) and a 429 (worth resending unchanged). Without it every purge failure
|
|
139
|
+
// fell back to the registry's `terminal` while `meta.retryable` said otherwise — one error
|
|
140
|
+
// answering the retry question two ways.
|
|
141
|
+
retry: input.retryable ? 'retryable' : 'terminal',
|
|
145
142
|
meta: {
|
|
146
143
|
driver: input.driver,
|
|
147
144
|
retryable: input.retryable,
|
package/src/purge-fastly.ts
CHANGED
|
@@ -61,7 +61,10 @@ const fixFor = (status: number): string => {
|
|
|
61
61
|
function acceptedFrom(body: PurgeBody, batch: readonly string[]): string[] {
|
|
62
62
|
const payload = body.json;
|
|
63
63
|
if (!isRecord(payload)) return [...batch];
|
|
64
|
-
|
|
64
|
+
// `Object.hasOwn`, never `in`: a surrogate key named `constructor` or `toString` answered `true`
|
|
65
|
+
// out of a body that never mentioned it, so `InvalidationReport.tiers` listed keys nothing had
|
|
66
|
+
// purged — a partial bust reading as a clean one.
|
|
67
|
+
const named = batch.filter((key) => Object.hasOwn(payload, key));
|
|
65
68
|
return named.length > 0 ? named : [...batch];
|
|
66
69
|
}
|
|
67
70
|
|
package/src/tiers.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
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';
|
|
@@ -13,10 +13,17 @@ 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
|