@ultimat3/cache 16.0.0 → 17.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
@@ -10,6 +10,20 @@ Tier 1. Tagged caching + THE invalidation graph.
10
10
 
11
11
  ## Rules
12
12
 
13
+ - **A tier's own LIMIT is screened where `app.config.ts` names it, `As of 2026-08-26`** —
14
+ `assertFiniteCapacity` for a byte budget or an entry ceiling, `assertFiniteDurationMs` for a
15
+ request budget or a `defaultTtlMs`, `assertFiniteSimilarityFloor` for the semantic tier's
16
+ threshold — all three beside `assertTtl` in `tiers.ts`, and all three raising one code,
17
+ `X_CACHE_LIMIT_INVALID`. Not a nicety: `Number(process.env.CACHE_MAX_BYTES)` on an unset variable
18
+ is `NaN`, `NaN` is not nullish so `??` keeps it, and every comparison against it is FALSE —
19
+ `while (bytes > maxBytes)` never evicted, so the bounded cache this package is built on had no
20
+ bound at all, and `similarity < floor` never skipped, so the semantic tier answered the nearest
21
+ thing it held to a question nobody asked. The failure is never a wrong number; it is the guard
22
+ switching itself off. Never `Math.max(1, x)` in front of one: that is a clamp, not a validator.
23
+ **The `Finite` in all three names is load-bearing** — `bun run finite-bounds` recognises a repair
24
+ by the shape of the CALL, so while they were spelled `assertCapacity` / `assertTimeoutMs` all
25
+ nine of this package's options read as unchecked to the ratchet while every one was screened.
26
+
13
27
  - `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path. Never call
14
28
  `tier.invalidateTags()` from outside it. It is also the only place the log is written:
15
29
  `recentInvalidations()` is a read of what that one path already reported, never a second
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "16.0.0",
3
+ "version": "17.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": "16.0.0"
34
+ "@ultimat3/core": "17.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -6,6 +6,7 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
6
  export const CACHE_OWNED_ERROR_CODES = [
7
7
  'X_CACHE_DRIVER_UNAVAILABLE',
8
8
  'X_CACHE_JITTER_INVALID',
9
+ 'X_CACHE_LIMIT_INVALID',
9
10
  'X_CACHE_PURGE_FAILED',
10
11
  'X_CACHE_TAG_UNKNOWN',
11
12
  'X_CACHE_TOO_LARGE',
@@ -21,6 +22,7 @@ export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number];
21
22
  export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> = {
22
23
  X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing",
23
24
  X_CACHE_JITTER_INVALID: 'a TTL jitter fraction outside [0, 1)',
25
+ X_CACHE_LIMIT_INVALID: 'a cache ceiling, similarity floor or timeout that is not a usable number',
24
26
  X_CACHE_PURGE_FAILED: 'the CDN refused a purge',
25
27
  X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
26
28
  X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
@@ -71,6 +73,56 @@ export class CacheTooLargeError extends UltimateError {
71
73
  }
72
74
  }
73
75
 
76
+ /**
77
+ * A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the
78
+ * similarity floor the semantic tier decides "same question" with, or a purge driver's request
79
+ * timeout. Sibling of the TTL refusal below, over the knobs an `app.config.ts` carries and
80
+ * `CacheTooLargeError`'s `fix:` line already tells an operator to raise.
81
+ *
82
+ * `Number(process.env.CACHE_MAX_BYTES)` on an unset variable is `NaN`, `NaN` is not nullish so
83
+ * `??` passes it through, and every comparison it reaches then answers false. The failure is never
84
+ * a wrong number, it is the guard turning itself off — `while (bytes > NaN)` never evicts, so the
85
+ * cache has no ceiling at all, and `similarity < NaN` never skips, so a semantic lookup answers
86
+ * the nearest thing it holds to a question nobody asked. Refused where the value is still
87
+ * nameable, exactly as `assertTtl` does.
88
+ */
89
+ /**
90
+ * The mechanism is stated ONLY for `NaN`, and that is deliberate. This cause used to say "a
91
+ * comparison against this one is false for every entry" for every rejected value, which is true of
92
+ * `NaN` and false of everything else it also receives: `Infinity`, `-Infinity`, `0`, a fraction, an
93
+ * unsafe integer. A ceiling of `Infinity` is never *exceeded* and a similarity floor of `Infinity`
94
+ * is never *met* — opposite outcomes, from one sentence claiming both. A cause that names the wrong
95
+ * failure sends the reader to the wrong place, which is the failure mode axiom 4 exists to prevent,
96
+ * so the shared half says only what is true of every value and the `NaN` half is added when it is.
97
+ */
98
+ const limitEffect = (value: number): string =>
99
+ Number.isNaN(value)
100
+ ? ', and every comparison against NaN is false, so the limit stops being enforced rather than being enforced wrongly — NaN is what Number(process.env.…) answers for an unset variable, and it is not nullish, so `??` does not catch it'
101
+ : ', and it is applied as given, so the tier is bounded at a value nobody chose';
102
+
103
+ export class CacheLimitInvalidError extends UltimateError {
104
+ constructor(input: {
105
+ option: string;
106
+ value: number;
107
+ tier: string;
108
+ expected: string;
109
+ /**
110
+ * Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the
111
+ * similarity override arrive as call arguments (`createCacheStack`, `lookup`), so telling their
112
+ * caller to edit `app.config.ts` names a key that does not exist.
113
+ */
114
+ source?: string | undefined;
115
+ }) {
116
+ const where = input.source ?? `${input.tier}.${input.option} in app.config.ts`;
117
+ super({
118
+ code: 'X_CACHE_LIMIT_INVALID',
119
+ cause: `the ${input.tier} tier was given ${input.option}=${String(input.value)}; it must be ${input.expected}${limitEffect(input.value)}`,
120
+ fix: `set ${where} to ${input.expected} — and if it comes from the environment, parse it first: Number(process.env.CACHE_LIMIT) is NaN when the variable is unset`,
121
+ meta: { option: input.option, value: String(input.value), tier: input.tier },
122
+ });
123
+ }
124
+ }
125
+
74
126
  /**
75
127
  * A `ttlMs` that is not a positive, finite number of milliseconds.
76
128
  *
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  CACHE_ERROR_TITLES,
9
9
  CacheDriverUnavailableError,
10
10
  CacheJitterInvalidError,
11
+ CacheLimitInvalidError,
11
12
  CachePurgeFailedError,
12
13
  CacheTagUnknownError,
13
14
  CacheTooLargeError,
@@ -109,6 +110,9 @@ export type {
109
110
  TtlScope,
110
111
  } from './tiers';
111
112
  export {
113
+ assertFiniteCapacity,
114
+ assertFiniteDurationMs,
115
+ assertFiniteSimilarityFloor,
112
116
  assertTtl,
113
117
  createCacheStack,
114
118
  DEFAULT_LOAD_DEADLINE_MS,
package/src/lru.ts CHANGED
@@ -16,7 +16,7 @@ import type {
16
16
  TierInvalidation,
17
17
  TtlJitter,
18
18
  } from './tiers';
19
- import { assertTtl, nowMs } from './tiers';
19
+ import { assertFiniteCapacity, assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
20
20
 
21
21
  export interface LruOptions {
22
22
  /** Byte budget for the whole tier. Default 64 MiB. */
@@ -81,6 +81,9 @@ export interface LruStats {
81
81
  readonly evictions: number;
82
82
  }
83
83
 
84
+ /** 64 MiB. Named because the constructor now screens it, and a screen needs something to pass. */
85
+ const DEFAULT_LRU_MAX_BYTES = 64 * 1024 * 1024;
86
+
84
87
  export class LruCache {
85
88
  private readonly map = new Map<string, LruNode>();
86
89
  private readonly tagIndex = new Map<string, Set<string>>();
@@ -97,8 +100,16 @@ export class LruCache {
97
100
  private evictions = 0;
98
101
 
99
102
  constructor(options: LruOptions = {}) {
100
- this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
101
- this.defaultTtlMs = options.defaultTtlMs ?? 60_000;
103
+ this.maxBytes = assertFiniteCapacity(
104
+ 'lru',
105
+ 'maxBytes',
106
+ options.maxBytes ?? DEFAULT_LRU_MAX_BYTES,
107
+ );
108
+ this.defaultTtlMs = assertFiniteDurationMs(
109
+ 'lru',
110
+ 'defaultTtlMs',
111
+ options.defaultTtlMs ?? 60_000,
112
+ );
102
113
  this.clock = options.clock ?? systemClock;
103
114
  this.jitter = {
104
115
  ...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
@@ -18,6 +18,7 @@ import {
18
18
  purgePost,
19
19
  requireCredential,
20
20
  } from './purge-http';
21
+ import { assertFiniteDurationMs } from './tiers';
21
22
 
22
23
  export const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4';
23
24
 
@@ -73,7 +74,11 @@ export function cloudflarePurgeDriver(options: CloudflarePurgeOptions): PurgeDri
73
74
  const apiToken = requireCredential(options.apiToken, 'CLOUDFLARE_API_TOKEN', 'cloudflare');
74
75
  const zoneId = requireCredential(options.zoneId, 'CLOUDFLARE_ZONE_ID', 'cloudflare');
75
76
  const baseUrl = options.baseUrl ?? CLOUDFLARE_API_URL;
76
- const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS;
77
+ const timeoutMs = assertFiniteDurationMs(
78
+ 'cloudflare',
79
+ 'timeoutMs',
80
+ options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS,
81
+ );
77
82
  const doFetch = options.fetch ?? defaultPurgeFetch;
78
83
  const headers = { Authorization: `Bearer ${apiToken}` };
79
84
 
@@ -19,6 +19,7 @@ import {
19
19
  purgePost,
20
20
  requireCredential,
21
21
  } from './purge-http';
22
+ import { assertFiniteDurationMs } from './tiers';
22
23
 
23
24
  export const FASTLY_API_URL = 'https://api.fastly.com';
24
25
 
@@ -72,7 +73,11 @@ export function fastlyPurgeDriver(options: FastlyPurgeOptions): PurgeDriver {
72
73
  const apiToken = requireCredential(options.apiToken, 'FASTLY_API_TOKEN', 'fastly');
73
74
  const serviceId = requireCredential(options.serviceId, 'FASTLY_SERVICE_ID', 'fastly');
74
75
  const baseUrl = options.baseUrl ?? FASTLY_API_URL;
75
- const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS;
76
+ const timeoutMs = assertFiniteDurationMs(
77
+ 'fastly',
78
+ 'timeoutMs',
79
+ options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS,
80
+ );
76
81
  const doFetch = options.fetch ?? defaultPurgeFetch;
77
82
  const headers = { 'Fastly-Key': apiToken };
78
83
 
package/src/purge-http.ts CHANGED
@@ -73,14 +73,43 @@ export function requireCredential(value: string, envKey: string, driver: string)
73
73
  // Every CDN splits a key list on whitespace or a comma, so a key carrying either purges two
74
74
  // things that do not exist instead of the one that does — silently, since the request succeeds.
75
75
  const UNSAFE_KEY = /[\s,]/;
76
- const MAX_KEY_LENGTH = 1024;
76
+ const MAX_KEY_BYTES = 1024;
77
77
 
78
- const keyProblem = (key: string): string | undefined => {
79
- if (key === '') return 'is empty';
78
+ // The CDN's limit is on the HEADER, so it is bytes — and `String.length` is UTF-16 code units,
79
+ // which for a non-ASCII tag is a third of the answer: 900 CJK characters measured 900 against a
80
+ // 1024 limit and put 2,700 bytes on the wire, where the provider refuses or truncates the key.
81
+ // That is the accepted purge that cleared nothing, arriving through the guard meant to stop it.
82
+ const keyBytes = (key: string): number => new TextEncoder().encode(key).byteLength;
83
+
84
+ /**
85
+ * The problem AND the repair, together — because there are three ways a key is unpurgeable and only
86
+ * one of them is fixed by renaming away a separator. A 900-character CJK tag carries no whitespace
87
+ * and no comma, so "rename it so it carries no space or comma" left the next purge failing exactly
88
+ * as the last one did: an instruction that does not repair the stated cause is the shape axiom 4
89
+ * exists to forbid.
90
+ */
91
+ interface KeyProblem {
92
+ readonly problem: string;
93
+ readonly fix: string;
94
+ }
95
+
96
+ const keyProblem = (key: string): KeyProblem | undefined => {
97
+ if (key === '')
98
+ return {
99
+ problem: 'is empty',
100
+ fix: 'give the tag a name in its declareTags(...) call — an empty surrogate key purges nothing',
101
+ };
80
102
  if (UNSAFE_KEY.test(key))
81
- return 'contains whitespace or a comma, which a CDN reads as a separator';
82
- if (key.length > MAX_KEY_LENGTH)
83
- return `is ${key.length} characters, over the 1024-byte key limit`;
103
+ return {
104
+ problem: 'contains whitespace or a comma, which a CDN reads as a separator',
105
+ fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma',
106
+ };
107
+ const bytes = keyBytes(key);
108
+ if (bytes > MAX_KEY_BYTES)
109
+ return {
110
+ problem: `is ${bytes} bytes, over the ${MAX_KEY_BYTES}-byte key limit`,
111
+ fix: `shorten the tag in its declareTags(...) call to at most ${MAX_KEY_BYTES} UTF-8 bytes — the limit is on the header, so it is BYTES, and a non-ASCII character costs two or three of them each`,
112
+ };
84
113
  return undefined;
85
114
  };
86
115
 
@@ -94,9 +123,9 @@ export function assertPurgeableKeys(driver: string, keys: readonly string[]): vo
94
123
  if (problem === undefined) continue;
95
124
  throw new CachePurgeFailedError({
96
125
  driver,
97
- detail: `surrogate key ${JSON.stringify(key)} ${problem}`,
126
+ detail: `surrogate key ${JSON.stringify(key)} ${problem.problem}`,
98
127
  retryable: false,
99
- fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma',
128
+ fix: problem.fix,
100
129
  });
101
130
  }
102
131
  }
package/src/redis.ts CHANGED
@@ -16,7 +16,7 @@ import type {
16
16
  TierInvalidation,
17
17
  TtlJitter,
18
18
  } from './tiers';
19
- import { assertTtl, nowMs } from './tiers';
19
+ import { assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
20
20
 
21
21
  /** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
22
22
  export interface RedisLike {
@@ -191,7 +191,11 @@ function raiseSweepFailure(failures: readonly unknown[], attempted: number): nev
191
191
 
192
192
  export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
193
193
  const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
194
- const defaultTtlMs = options.defaultTtlMs ?? 300_000;
194
+ const defaultTtlMs = assertFiniteDurationMs(
195
+ 'redis',
196
+ 'defaultTtlMs',
197
+ options.defaultTtlMs ?? 300_000,
198
+ );
195
199
  const clock = options.clock ?? systemClock;
196
200
  const jitter: TtlJitter = {
197
201
  ...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
package/src/semantic.ts CHANGED
@@ -8,7 +8,13 @@ import type { Clock } from '@ultimat3/core';
8
8
  import { systemClock } from '@ultimat3/core';
9
9
  import type { CacheTag } from './tags';
10
10
  import { tagsIntersect } from './tags';
11
- import { assertTtl, nowMs } from './tiers';
11
+ import {
12
+ assertFiniteCapacity,
13
+ assertFiniteDurationMs,
14
+ assertFiniteSimilarityFloor,
15
+ assertTtl,
16
+ nowMs,
17
+ } from './tiers';
12
18
 
13
19
  export type Embedding = readonly number[];
14
20
 
@@ -73,9 +79,15 @@ interface SemanticRecord {
73
79
  }
74
80
 
75
81
  export function createMemorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
76
- const threshold = options.threshold ?? 0.92;
77
- const maxEntries = options.maxEntries ?? 1000;
78
- const defaultTtlMs = options.defaultTtlMs ?? 3_600_000;
82
+ // Screened at construction, both of them: this tier's two knobs are the ones whose failure is
83
+ // silent — a floor of NaN matches everything, a ceiling of NaN evicts nothing.
84
+ const threshold = assertFiniteSimilarityFloor('semantic', 'threshold', options.threshold ?? 0.92);
85
+ const maxEntries = assertFiniteCapacity('semantic', 'maxEntries', options.maxEntries ?? 1000);
86
+ const defaultTtlMs = assertFiniteDurationMs(
87
+ 'semantic',
88
+ 'defaultTtlMs',
89
+ options.defaultTtlMs ?? 3_600_000,
90
+ );
79
91
  const clock = options.clock ?? systemClock;
80
92
  const records = new Map<string, SemanticRecord>();
81
93
 
@@ -93,7 +105,12 @@ export function createMemorySemanticCache(options: SemanticCacheOptions = {}): S
93
105
  name: 'memory',
94
106
 
95
107
  lookup<T>(embedding: Embedding, override?: number): Promise<SemanticHit<T> | undefined> {
96
- const floor = override ?? threshold;
108
+ // The override is the same boundary, arriving per call — screened for the same reason, and
109
+ // this is the one that can carry a value straight off a request.
110
+ const floor =
111
+ override === undefined
112
+ ? threshold
113
+ : assertFiniteSimilarityFloor('semantic', 'threshold', override);
97
114
  let best: SemanticHit<T> | undefined;
98
115
  for (const record of live()) {
99
116
  const similarity = cosineSimilarity(embedding, record.embedding);
package/src/tiers.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
7
7
  import { CACHE_TIERS, systemClock } from '@ultimat3/core';
8
- import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors';
8
+ import { CacheJitterInvalidError, CacheLimitInvalidError, CacheTtlInvalidError } from './errors';
9
9
  import type { CacheFence } from './fence';
10
10
  import { markInvalidated, sampleFence } from './fence';
11
11
  import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options';
@@ -99,6 +99,61 @@ export interface CacheSetOptions {
99
99
  */
100
100
  export type TtlScope = TierName | 'semantic';
101
101
 
102
+ /**
103
+ * A tier's ceiling — bytes or entries — screened where it is still nameable. The same rule as
104
+ * `assertTtl` beside it, over the other knob an `app.config.ts` carries. A SEPARATE refusal from
105
+ * `X_CACHE_TOO_LARGE`, which is one entry against a valid ceiling: this one is the ceiling itself,
106
+ * and it needs a screen because a bad ceiling refuses nothing and evicts nothing rather than
107
+ * refusing everything. `NaN > x` and `x > NaN` are both false.
108
+ */
109
+ export function assertFiniteCapacity(tier: TtlScope, option: string, value: number): number {
110
+ if (Number.isSafeInteger(value) && value > 0) return value;
111
+ throw new CacheLimitInvalidError({
112
+ tier,
113
+ option,
114
+ value,
115
+ expected: 'a whole number greater than zero',
116
+ });
117
+ }
118
+
119
+ /**
120
+ * A millisecond duration: a driver's request budget (handed to `AbortSignal.timeout`, which THROWS
121
+ * on a non-finite one — inside `purgePost`'s try, so the config typo came back as a retryable
122
+ * transport failure that never happened) and a tier's own `defaultTtlMs`, which is otherwise only
123
+ * screened by `assertTtl` at the first `set`, one write into the process's life.
124
+ *
125
+ * The `Finite` in all three names here is load-bearing: `bun run finite-bounds` recognises a repair
126
+ * by the shape of the CALL, so `assertCapacity` and `assertTimeoutMs` left all nine of this
127
+ * package's options reading as unchecked while every one of them was screened.
128
+ */
129
+ export function assertFiniteDurationMs(
130
+ tier: string,
131
+ option: string,
132
+ value: number,
133
+ /** Passed only where the value is a CALL argument rather than an `app.config.ts` key. */
134
+ source?: string | undefined,
135
+ ): number {
136
+ if (Number.isSafeInteger(value) && value > 0) return value;
137
+ throw new CacheLimitInvalidError({
138
+ tier,
139
+ option,
140
+ value,
141
+ expected: 'a whole number of milliseconds greater than zero',
142
+ source,
143
+ });
144
+ }
145
+
146
+ /**
147
+ * The similarity floor the semantic tier decides "same question" with — a correctness boundary,
148
+ * not a tuning knob, so `NaN` here does not loosen it, it removes it: `similarity < NaN` is false
149
+ * for every record. Cosine similarity is in [-1, 1] and a floor of 0 already admits an orthogonal
150
+ * vector, so anything outside [0, 1] is a number nobody can have meant.
151
+ */
152
+ export function assertFiniteSimilarityFloor(tier: TtlScope, option: string, value: number): number {
153
+ if (Number.isFinite(value) && value >= 0 && value <= 1) return value;
154
+ throw new CacheLimitInvalidError({ tier, option, value, expected: 'a number from 0 to 1' });
155
+ }
156
+
102
157
  export function assertTtl(
103
158
  key: string,
104
159
  ttlMs: number,
@@ -204,7 +259,14 @@ export function createCacheStack(
204
259
  // NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's
205
260
  // last-write-wins `set` already tolerates, against a key pinned for the life of the process.
206
261
  const flight = createSingleFlight({
207
- deadlineMs: options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
262
+ deadlineMs: assertFiniteDurationMs(
263
+ 'ladder',
264
+ 'loadDeadlineMs',
265
+ options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
266
+ // Caller-owned: it arrives as a `createCacheStack({ loadDeadlineMs })` argument, so there is
267
+ // no `app.config.ts` key to send the reader to.
268
+ 'the loadDeadlineMs argument to createCacheStack(...)',
269
+ ),
208
270
  schedule: options.schedule,
209
271
  });
210
272