// The X_* codes owned by @ultimat3/cache. Each one names the exact config change or // command that resolves it, so an agent reading the failure can act without a doc lookup. import { registerErrorCodes, UltimateError } from '@ultimat3/core'; /** Codes this package declares and owns. */ export const CACHE_OWNED_ERROR_CODES = [ 'X_CACHE_DRIVER_UNAVAILABLE', 'X_CACHE_JITTER_INVALID', 'X_CACHE_LIMIT_INVALID', 'X_CACHE_PURGE_FAILED', 'X_CACHE_TAG_UNKNOWN', 'X_CACHE_TOO_LARGE', 'X_CACHE_TTL_INVALID', 'X_CACHE_VALUE_UNENCODABLE', ] as const; /** Every code cache can throw. It borrows none: every remote driver here is implemented. */ export const CACHE_ERROR_CODES = [...CACHE_OWNED_ERROR_CODES] as const; export type CacheOwnedErrorCode = (typeof CACHE_OWNED_ERROR_CODES)[number]; export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number]; export const CACHE_ERROR_TITLES: Readonly> = { X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing", X_CACHE_JITTER_INVALID: 'a TTL jitter fraction outside [0, 1)', X_CACHE_LIMIT_INVALID: 'a cache ceiling, similarity floor or timeout that is not a usable number', X_CACHE_PURGE_FAILED: 'the CDN refused a purge', X_CACHE_TAG_UNKNOWN: 'a tag no entity declared', X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget", X_CACHE_TTL_INVALID: 'a cache TTL that is not a positive number of milliseconds', X_CACHE_VALUE_UNENCODABLE: 'a cached value the cache codec cannot encode', }; // One unconditional call, so a second package claiming one of cache's codes throws // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first. registerErrorCodes( Object.fromEntries(Object.entries(CACHE_ERROR_TITLES).map(([code, title]) => [code, { title }])), ); /** A tier's backing store is missing at runtime (no Redis binding, no CDN token). */ export class CacheDriverUnavailableError extends UltimateError { constructor(input: { driver: string; cause: string; fix: string }) { super({ code: 'X_CACHE_DRIVER_UNAVAILABLE', cause: `cache tier "${input.driver}" is unavailable: ${input.cause}`, fix: input.fix, }); } } /** * A tag was used that no entity declared. Caught here rather than at read time because a * typo in `invalidates: [tag.pots]` is otherwise a silent stale-forever bug. */ export class CacheTagUnknownError extends UltimateError { constructor(input: { tag: string; known: readonly string[] }) { super({ code: 'X_CACHE_TAG_UNKNOWN', cause: `tag "${input.tag}" is not declared by any entity (declared: ${ input.known.length > 0 ? input.known.join(', ') : 'none' })`, fix: 'x manifest', }); } } /** A single entry cannot fit the tier's byte budget, so caching it would evict everything. */ export class CacheTooLargeError extends UltimateError { constructor(input: { key: string; bytes: number; maxBytes: number; tier: string }) { super({ code: 'X_CACHE_TOO_LARGE', cause: `entry "${input.key}" is ${input.bytes}B, over the ${input.tier} budget of ${input.maxBytes}B`, fix: `raise cache.${input.tier}.maxBytes in app.config.ts, or cache a projection instead of the row`, }); } } /** * A value the cache codec (`value-codec.ts`) cannot write: a cycle, a `toJSON` that throws, nesting * past `MAX_CACHE_VALUE_DEPTH`, or a `bigint` past `MAX_CACHE_BIGINT_DIGITS`. Refused at the WRITE, in every tier alike — the LRU used to hold it by reference while Redis * threw a bare `TypeError`, so the same `set` worked on one rung and failed on the next. `reason` * is `renderThrowable` output, never the value: a cached value is application data. */ export class CacheValueUnencodableError extends UltimateError { constructor(input: { key: string; tier: string; reason: string }) { super({ code: 'X_CACHE_VALUE_UNENCODABLE', cause: `entry "${input.key}" cannot be written to the ${input.tier} tier: ${input.reason}. A cache tier holds JSON plus Date, bigint, Map and Set, the same shape on every tier`, fix: 'load: () => rows.map(({ parent, ...row }) => row) — cache a projection the codec reads back: plain objects, no back-references, nesting under 512 levels, a bigint past 4096 digits stored as a string', meta: { key: input.key, tier: input.tier }, }); } } /** * A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the * similarity floor the semantic tier decides "same question" with, or a purge driver's request * timeout. Sibling of the TTL refusal below, over the knobs an `app.config.ts` carries and * `CacheTooLargeError`'s `fix:` line already tells an operator to raise. * * `Number(process.env.CACHE_MAX_BYTES)` on an unset variable is `NaN`, `NaN` is not nullish so * `??` passes it through, and every comparison it reaches then answers false. The failure is never * a wrong number, it is the guard turning itself off — `while (bytes > NaN)` never evicts, so the * cache has no ceiling at all, and `similarity < NaN` never skips, so a semantic lookup answers * the nearest thing it holds to a question nobody asked. Refused where the value is still * nameable, exactly as `assertTtl` does. */ /** * The mechanism is stated ONLY for `NaN`, and that is deliberate. This cause used to say "a * comparison against this one is false for every entry" for every rejected value, which is true of * `NaN` and false of everything else it also receives: `Infinity`, `-Infinity`, `0`, a fraction, an * unsafe integer. A ceiling of `Infinity` is never *exceeded* and a similarity floor of `Infinity` * is never *met* — opposite outcomes, from one sentence claiming both. A cause that names the wrong * failure sends the reader to the wrong place, which is the failure mode axiom 4 exists to prevent, * so the shared half says only what is true of every value and the `NaN` half is added when it is. */ const limitEffect = (value: number): string => Number.isNaN(value) ? ', 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' : ', and it is applied as given, so the tier is bounded at a value nobody chose'; export class CacheLimitInvalidError extends UltimateError { constructor(input: { option: string; value: number; tier: string; expected: string; /** * Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the * similarity override arrive as call arguments (`cacheStack`, `lookup`), so telling their * caller to edit `app.config.ts` names a key that does not exist. */ source?: string | undefined; }) { const where = input.source ?? `${input.tier}.${input.option} in app.config.ts`; super({ code: 'X_CACHE_LIMIT_INVALID', cause: `the ${input.tier} tier was given ${input.option}=${String(input.value)}; it must be ${input.expected}${limitEffect(input.value)}`, 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`, meta: { option: input.option, value: String(input.value), tier: input.tier }, }); } } /** * A `ttlMs` that is not a positive, finite number of milliseconds. * * `0` used to mean two things: "never expires" in the LRU tier and `EX 1` — one second — in the * Redis tier, so a stack holding both answered differently depending on which one hit. Neither is * what a caller writing `0` intends, and the third reading ("do not cache") has its own spelling: * do not call the cache. Refused rather than resolved, so the miswiring is a failure and not a * behaviour that varies by deployment. */ export class CacheTtlInvalidError extends UltimateError { constructor(input: { key: string; ttlMs: number; tier: string }) { super({ code: 'X_CACHE_TTL_INVALID', cause: `entry "${input.key}" was written to the ${input.tier} tier with ttlMs=${String( input.ttlMs, )}; a TTL is a positive, finite number of milliseconds`, 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`, meta: { key: input.key, ttlMs: input.ttlMs, tier: input.tier }, }); } } /** * A jitter fraction a tier cannot spread a TTL with. * * Jitter exists because 40,000 keys warmed by one rolling restart share one TTL and therefore one * expiry instant; spreading them is the only thing that stops the herd. A fraction of `1` or more * would shave a whole lease away and a negative one would EXTEND it past what the caller asked * for, so both are miswiring rather than a preference — refused where the TTL rule already lives, * for the same reason `0` is not silently reinterpreted as "never expires". */ export class CacheJitterInvalidError extends UltimateError { constructor(input: { tier: string; jitterFraction: number }) { super({ code: 'X_CACHE_JITTER_INVALID', cause: `the ${input.tier} tier was configured with jitterFraction=${String( input.jitterFraction, )}; a jitter fraction is a finite number in [0, 1)`, fix: `set cache.${input.tier}.jitterFraction in app.config.ts to a value in [0, 1) — 0.05 is the default, 0 disables jitter`, meta: { tier: input.tier, jitterFraction: input.jitterFraction }, }); } } /** * A remote purge did not happen. Never fatal on its own — `invalidateTags` collects it into * `report.errors` so a dead CDN cannot fail the write that triggered the bust — which is exactly * why `retryable` is carried rather than guessed: the caller decides whether the same purge, * unchanged, is worth sending again, and a stale edge until TTL is the cost of getting it wrong. */ export class CachePurgeFailedError extends UltimateError { constructor(input: { driver: string; detail: string; status?: number | undefined; retryable: boolean; fix: string; }) { const status = input.status === undefined ? '' : ` (HTTP ${input.status})`; super({ code: 'X_CACHE_PURGE_FAILED', cause: `${input.driver} refused the purge${status}: ${input.detail}`, fix: input.fix, // Per-instance rather than `registerErrorRetry`, because one code covers both a 401 (never // worth resending) and a 429 (worth resending unchanged). Without it every purge failure // fell back to the registry's `terminal` while `meta.retryable` said otherwise — one error // answering the retry question two ways. retry: input.retryable ? 'retryable' : 'terminal', meta: { driver: input.driver, retryable: input.retryable, ...(input.status === undefined ? {} : { status: input.status }), }, }); } }