@ultimat3/cache 9.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 CHANGED
@@ -252,6 +252,13 @@ Tier 1. Tagged caching + THE invalidation graph.
252
252
  and clears nothing, which is the one CDN failure no later read can catch.
253
253
  - `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus
254
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.
255
262
  - `X_CACHE_PURGE_FAILED` means a provider refused. A batch size that is not a positive integer is
256
263
  this package miswired, so `chunked()` raises `X_CACHE_DRIVER_UNAVAILABLE` instead — and it raises
257
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": "9.0.0",
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": "9.0.0"
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
- docs: docsFor('X_CACHE_PURGE_FAILED'),
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,
@@ -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
- const named = batch.filter((key) => key in payload);
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