@ultimat3/cache 25.0.0 → 25.2.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
@@ -95,6 +95,10 @@ Tier 1. Tagged caching + THE invalidation graph.
95
95
  collection bust and `t:{entity}:<id>` + `t:{entity}` for a row one. A row bust still `SREM`s what
96
96
  it deleted from the index (`sweepBucketsFor`). Pinned by `tier-parity.test.ts` and
97
97
  `redis.live.test.ts`.
98
+ - **Every tier stores codec TEXT, never the caller's object** (`value-codec.ts`: JSON + tagged
99
+ `Date`/`bigint`/`Map`/`Set`, app `$x` keys escaped). One shape whichever tier hit, no shared
100
+ mutable reference; a cycle, depth > 512 or a bigint > 4096 chars is `X_CACHE_VALUE_UNENCODABLE`
101
+ at the write. Only `x1:`-marked text is revived; unmarked (pre-codec) is plain `JSON.parse`.
98
102
  - **`CacheTier.set` REJECTS, never throws synchronously** — `lruTier`/`memoTier` are
99
103
  `async` for that alone.
100
104
  - **`redis.ts`'s script deletes NOTHING — it reads.** It returns the members; the tier `DEL`s them
@@ -156,6 +160,7 @@ Tier 1. Tagged caching + THE invalidation graph.
156
160
  | `tier-failures.ts` | `bestEffort()`, and the bounded log of refusals it absorbs |
157
161
  | `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
158
162
  | `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
163
+ | `value-codec.ts` | the one value encoding every tier stores and decodes |
159
164
  | `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag |
160
165
  | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, `surrogateKeys`, the purge key list, the `PurgeDriver` seam |
161
166
  | `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
package/README.md CHANGED
@@ -46,6 +46,22 @@ const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
46
46
  });
47
47
  ```
48
48
 
49
+ **Cached values: JSON + `Date` + `bigint` + `Map` + `Set` — the same shape from every tier.**
50
+ Every tier (the memo and the LRU included) stores the value as text through one codec
51
+ (`value-codec.ts`) and decodes a fresh copy on each hit, so:
52
+
53
+ | You write | Every tier answers |
54
+ |---|---|
55
+ | a `Date`, a `bigint`, a `Map`, a `Set` (at any depth) | the same type back |
56
+ | a plain object, array, string, number, boolean, `null` | JSON semantics: `NaN`/`Infinity` → `null`, `undefined` members dropped |
57
+ | a class instance | a plain object — its prototype is not stored |
58
+ | a cycle, a `toJSON` that throws, nesting past `MAX_CACHE_VALUE_DEPTH` (512), a `bigint` past `MAX_CACHE_BIGINT_DIGITS` (4096 chars) | `X_CACHE_VALUE_UNENCODABLE`, at the write — a value is stored and readable, or refused |
59
+
60
+ A hit is never the object that was written, nor the one another reader got: mutating it changes
61
+ nothing in the cache. An app key spelled `$x` (or `$$x`…) is escaped on the way in. The stored text
62
+ is `x1:` + JSON; only marked text is revived, so a Redis entry written as plain JSON before the
63
+ codec reads back exactly as stored — never revived, never renamed.
64
+
49
65
  **`ttlMs` is positive and finite, in every tier.** Omit it for the tier's default; anything else
50
66
  is `X_CACHE_TTL_INVALID`. There is no "never expires" and no "do not cache" — `0` used to mean the
51
67
  first in the LRU tier and one second in the Redis tier, so a stack holding both answered
@@ -399,6 +415,7 @@ cache).
399
415
  | `X_CACHE_TAG_UNKNOWN` | a tag no entity declared — usually a typo |
400
416
  | `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget |
401
417
  | `X_CACHE_TTL_INVALID` | a `ttlMs` that is not a positive, finite number of milliseconds |
418
+ | `X_CACHE_VALUE_UNENCODABLE` | a value with a cycle, a throwing `toJSON`, nesting past 512 levels or a `bigint` past 4096 digits, written to any tier |
402
419
 
403
420
  ### Error classes
404
421
 
@@ -414,6 +431,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
414
431
  | `CacheTagUnknownError` | `X_CACHE_TAG_UNKNOWN` | `src/errors.ts` |
415
432
  | `CacheTooLargeError` | `X_CACHE_TOO_LARGE` | `src/errors.ts` |
416
433
  | `CacheTtlInvalidError` | `X_CACHE_TTL_INVALID` | `src/errors.ts` |
434
+ | `CacheValueUnencodableError` | `X_CACHE_VALUE_UNENCODABLE` | `src/errors.ts` |
417
435
 
418
436
  ## Boundary
419
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "25.0.0",
3
+ "version": "25.2.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "25.0.0"
35
+ "@ultimat3/core": "25.2.0"
36
36
  }
37
37
  }
package/src/errors.ts CHANGED
@@ -11,6 +11,7 @@ export const CACHE_OWNED_ERROR_CODES = [
11
11
  'X_CACHE_TAG_UNKNOWN',
12
12
  'X_CACHE_TOO_LARGE',
13
13
  'X_CACHE_TTL_INVALID',
14
+ 'X_CACHE_VALUE_UNENCODABLE',
14
15
  ] as const;
15
16
 
16
17
  /** Every code cache can throw. It borrows none: every remote driver here is implemented. */
@@ -27,6 +28,7 @@ export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> =
27
28
  X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
28
29
  X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
29
30
  X_CACHE_TTL_INVALID: 'a cache TTL that is not a positive number of milliseconds',
31
+ X_CACHE_VALUE_UNENCODABLE: 'a cached value the cache codec cannot encode',
30
32
  };
31
33
 
32
34
  // One unconditional call, so a second package claiming one of cache's codes throws
@@ -73,6 +75,23 @@ export class CacheTooLargeError extends UltimateError {
73
75
  }
74
76
  }
75
77
 
78
+ /**
79
+ * A value the cache codec (`value-codec.ts`) cannot write: a cycle, a `toJSON` that throws, nesting
80
+ * 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
81
+ * threw a bare `TypeError`, so the same `set` worked on one rung and failed on the next. `reason`
82
+ * is `renderThrowable` output, never the value: a cached value is application data.
83
+ */
84
+ export class CacheValueUnencodableError extends UltimateError {
85
+ constructor(input: { key: string; tier: string; reason: string }) {
86
+ super({
87
+ code: 'X_CACHE_VALUE_UNENCODABLE',
88
+ 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`,
89
+ 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',
90
+ meta: { key: input.key, tier: input.tier },
91
+ });
92
+ }
93
+ }
94
+
76
95
  /**
77
96
  * A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the
78
97
  * similarity floor the semantic tier decides "same question" with, or a purge driver's request
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  CacheTagUnknownError,
20
20
  CacheTooLargeError,
21
21
  CacheTtlInvalidError,
22
+ CacheValueUnencodableError,
22
23
  } from './errors';
23
24
  export type { CacheFence, FenceScope } from './fence';
24
25
  export { FENCE_MEMORY, markInvalidated, sampleFence } from './fence';
package/src/lru.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  TtlJitter,
18
18
  } from './tiers';
19
19
  import { assertFiniteCapacity, assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
20
+ import { decodeCacheValue, encodeCacheValue } from './value-codec';
20
21
 
21
22
  export interface LruOptions {
22
23
  /** Byte budget for the whole tier. Default 64 MiB. */
@@ -32,7 +33,12 @@ export interface LruOptions {
32
33
 
33
34
  interface LruNode {
34
35
  key: string;
35
- value: unknown;
36
+ /**
37
+ * `{ v }` as `value-codec.ts` text, never the caller's object: held by reference, a hit was the
38
+ * writer's live object — mutated by one reader for every later one — and a `Date` here was an
39
+ * ISO string on the pod that read the same entry back out of Redis.
40
+ */
41
+ text: string;
36
42
  bytes: number;
37
43
  /** Epoch ms. Always finite: `assertTtl` refuses the `0` that used to mean "never expires". */
38
44
  expiresAt: number;
@@ -141,11 +147,15 @@ export class LruCache {
141
147
  }
142
148
  this.touch(node);
143
149
  this.hits += 1;
144
- return { value: node.value as T, tags: node.tags, expiresAt: node.expiresAt };
150
+ const { v } = decodeCacheValue(node.text) as { readonly v: T };
151
+ return { value: v, tags: node.tags, expiresAt: node.expiresAt };
145
152
  }
146
153
 
147
154
  set<T>(key: string, value: T, options: CacheSetOptions = {}): void {
148
- const bytes = estimateBytes(value) + encoder.encode(key).byteLength;
155
+ // Encoded first: the budget is then the exact bytes held, and an unencodable value is refused
156
+ // before anything is evicted for it.
157
+ const text = encodeCacheValue({ v: value }, { key, tier: 'lru' });
158
+ const bytes = estimateBytes(text) + encoder.encode(key).byteLength;
149
159
  if (bytes > this.maxBytes) {
150
160
  throw new CacheTooLargeError({ key, bytes, maxBytes: this.maxBytes, tier: 'lru' });
151
161
  }
@@ -158,7 +168,7 @@ export class LruCache {
158
168
 
159
169
  const node: LruNode = {
160
170
  key,
161
- value,
171
+ text,
162
172
  bytes,
163
173
  expiresAt: nowMs(this.clock) + ttl,
164
174
  tags: options.tags ?? [],
package/src/memo.ts CHANGED
@@ -10,8 +10,18 @@ import type { CacheTag } from './tags';
10
10
  import { tagsIntersect } from './tags';
11
11
  import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
12
12
  import { assertTtl } from './tiers';
13
+ import { decodeCacheValue, encodeCacheValue } from './value-codec';
13
14
 
14
- type MemoStore = Map<string, CacheEntry<unknown>>;
15
+ /**
16
+ * Codec text, as in every other tier: two callers in one request reading one key must not share
17
+ * one mutable object, and a hit here must be the shape the LRU or Redis would have answered.
18
+ */
19
+ interface MemoEntry {
20
+ readonly text: string;
21
+ readonly tags: readonly CacheTag[];
22
+ }
23
+
24
+ type MemoStore = Map<string, MemoEntry>;
15
25
 
16
26
  const stores = new WeakMap<object, MemoStore>();
17
27
 
@@ -53,7 +63,9 @@ export function memoTier(): CacheTier {
53
63
  get<T>(key: string): Promise<CacheEntry<T> | undefined> {
54
64
  const entry = storeFor(false)?.get(key);
55
65
  // No TTL check: a request is shorter than any meaningful TTL.
56
- return Promise.resolve(entry as CacheEntry<T> | undefined);
66
+ if (entry === undefined) return Promise.resolve(undefined);
67
+ const { v } = decodeCacheValue(entry.text) as { readonly v: T };
68
+ return Promise.resolve({ value: v, tags: entry.tags });
57
69
  },
58
70
 
59
71
  /**
@@ -75,7 +87,8 @@ export function memoTier(): CacheTier {
75
87
  if (options?.ttlMs !== undefined) {
76
88
  assertTtl(key, options.ttlMs, 'request-memo', { jitterFraction: 0 });
77
89
  }
78
- storeFor(true)?.set(key, { value, tags: options?.tags ?? [] });
90
+ const text = encodeCacheValue({ v: value }, { key, tier: 'request-memo' });
91
+ storeFor(true)?.set(key, { text, tags: options?.tags ?? [] });
79
92
  },
80
93
 
81
94
  del(key: string): Promise<void> {
package/src/redis.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  TtlJitter,
20
20
  } from './tiers';
21
21
  import { assertFiniteDurationMs, assertTtl, nowMs } from './tiers';
22
+ import { decodeCacheValue, encodeCacheValue } from './value-codec';
22
23
 
23
24
  /** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
24
25
  export interface RedisLike {
@@ -285,7 +286,9 @@ export function redisTier(options: RedisTierOptions = {}): CacheTier {
285
286
  // death handed a fresh five minutes, one tier closer to the request.
286
287
  if ('kind' in lease && lease.kind === 'reaped') return undefined;
287
288
  try {
288
- const parsed = JSON.parse(raw) as StoredEntry;
289
+ // `decodeCacheValue`, not `JSON.parse`: the LRU decodes with the same codec, so a `Date`
290
+ // promoted out of here is a `Date` there too. Plain JSON written before the codec reads as-is.
291
+ const parsed = decodeCacheValue(raw) as StoredEntry;
289
292
  return {
290
293
  value: parsed.v as T,
291
294
  tags: parsed.t.map(parseTag),
@@ -314,6 +317,8 @@ export function redisTier(options: RedisTierOptions = {}): CacheTier {
314
317
  const tags = setOptions?.tags ?? [];
315
318
  const ttlMs = assertTtl(key, setOptions?.ttlMs ?? defaultTtlMs, 'redis', jitter);
316
319
  const payload: StoredEntry = { v: value, t: tags.map(serializeTag) };
320
+ // Before any bucket is joined: an unencodable value is refused with nothing left behind.
321
+ const text = encodeCacheValue(payload, { key, tier: 'redis' });
317
322
  const stored = valueKey(key);
318
323
  // `PX`, not `EX`: the value's lease is spent in the milliseconds it was validated and
319
324
  // jittered in. `Math.ceil(ttlMs / 1000)` honoured a 1,001ms lease as 2s — rounding toward
@@ -329,7 +334,7 @@ export function redisTier(options: RedisTierOptions = {}): CacheTier {
329
334
  conn().send('EVAL', [TAG_MEMBER_SCRIPT, '1', bucket, stored, bucketTtlSeconds]),
330
335
  ),
331
336
  );
332
- await conn().send('SET', [stored, JSON.stringify(payload), 'PX', String(Math.ceil(ttlMs))]);
337
+ await conn().send('SET', [stored, text, 'PX', String(Math.ceil(ttlMs))]);
333
338
  if (buckets.length === 0) return;
334
339
  const membership = await Promise.all(
335
340
  buckets.map((bucket) => conn().send('SISMEMBER', [bucket, stored])),
@@ -0,0 +1,167 @@
1
+ // The ONE encoding every cache tier stores a value in: JSON, plus tagged `Date`, `bigint`, `Map`
2
+ // and `Set`. One codec is what makes a hit the same shape whichever rung answered it — and since
3
+ // every tier stores TEXT, no two readers ever share one mutable object. Lives here, not in
4
+ // `@ultimat3/query`'s cursor codec: query is tier 3, and this package is tier 1.
5
+
6
+ import { renderThrowable } from '@ultimat3/core';
7
+ import { CacheValueUnencodableError } from './errors';
8
+
9
+ /** The member that marks a tagged value. Only this codec writes it; an app's own is escaped. */
10
+ const TAG = '$x';
11
+ /** `$x`, `$$x`, `$$$x`…: an app key that could be read as a tag, written with one more `$`. */
12
+ const ESCAPABLE = /^\$+x$/;
13
+ /** What an escaped app key looks like on the way back: two or more `$`. */
14
+ const ESCAPED = /^\$\$+x$/;
15
+ const DIGITS = /^-?\d+$/;
16
+
17
+ /**
18
+ * The deepest a cached value may nest. `JSON.parse` with a reviver recurses, and overflows the
19
+ * stack somewhere between 5k and 20k levels that `JSON.stringify` wrote without complaint — so a
20
+ * deeper value was stored and then unreadable on every hit until its TTL. Refused at the write.
21
+ */
22
+ export const MAX_CACHE_VALUE_DEPTH = 512;
23
+
24
+ /**
25
+ * The longest `bigint` a cached value may hold, in characters. `BigInt(text)` is super-linear: a
26
+ * million digits throws a `RangeError`, 300k take over a second. 4096 is far past any column type
27
+ * (`numeric` aside) and costs microseconds.
28
+ */
29
+ export const MAX_CACHE_BIGINT_DIGITS = 4096;
30
+
31
+ /**
32
+ * Prefixed to every text this codec writes. No JSON text starts with `x`, so an unmarked entry —
33
+ * a Redis value written by a pod from before the codec — is read with plain `JSON.parse`, and app
34
+ * data that happens to look like a tag (`{"$x":"bigint","v":"12"}`) or an escape (`$$x`) is
35
+ * handed back exactly as it was stored, never revived or renamed.
36
+ */
37
+ export const CACHE_CODEC_MARK = 'x1:';
38
+
39
+ type Tagged = { readonly [TAG]: 'date' | 'bigint' | 'map' | 'set'; readonly v: unknown };
40
+
41
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
42
+ typeof value === 'object' && value !== null && !Array.isArray(value);
43
+
44
+ /** A copy with every tag-shaped key given one more `$`, or the object itself when it has none. */
45
+ const escapeKeys = (value: Record<string, unknown>): Record<string, unknown> => {
46
+ const keys = Object.keys(value);
47
+ if (!keys.some((key) => ESCAPABLE.test(key))) return value;
48
+ return Object.fromEntries(keys.map((key) => [ESCAPABLE.test(key) ? `$${key}` : key, value[key]]));
49
+ };
50
+
51
+ const unescapeKeys = (value: Record<string, unknown>): Record<string, unknown> => {
52
+ const keys = Object.keys(value);
53
+ if (!keys.some((key) => ESCAPED.test(key))) return value;
54
+ return Object.fromEntries(
55
+ keys.map((key) => [ESCAPED.test(key) ? key.slice(1) : key, value[key]]),
56
+ );
57
+ };
58
+
59
+ /**
60
+ * `this[key]`, not `value`: `JSON.stringify` calls `Date#toJSON` BEFORE the replacer sees it, so
61
+ * the argument is already a string — the holder still has the `Date`.
62
+ */
63
+ function transform(holder: unknown, key: string, value: unknown): unknown {
64
+ const raw =
65
+ isRecord(holder) || Array.isArray(holder) ? (holder as Record<string, unknown>)[key] : value;
66
+ if (raw instanceof Date) {
67
+ return { [TAG]: 'date', v: Number.isNaN(raw.getTime()) ? null : raw.toISOString() };
68
+ }
69
+ if (typeof raw === 'bigint') {
70
+ const digits = raw.toString();
71
+ if (digits.length > MAX_CACHE_BIGINT_DIGITS) {
72
+ throw new CacheCodecLimit(
73
+ `a bigint of ${digits.length} characters, over the ${MAX_CACHE_BIGINT_DIGITS} a cache holds`,
74
+ );
75
+ }
76
+ return { [TAG]: 'bigint', v: digits };
77
+ }
78
+ if (raw instanceof Map) return { [TAG]: 'map', v: [...raw.entries()] };
79
+ if (raw instanceof Set) return { [TAG]: 'set', v: [...raw] };
80
+ return isRecord(value) ? escapeKeys(value) : value;
81
+ }
82
+
83
+ /** A limit this codec refuses at, carried out of `JSON.stringify` to `encodeCacheValue`. */
84
+ class CacheCodecLimit {
85
+ constructor(readonly reason: string) {}
86
+ }
87
+
88
+ /**
89
+ * A replacer that knows how deep it is: every container it hands back is filed under its depth,
90
+ * and `JSON.stringify` calls it with that container as `this` for each member — one map lookup
91
+ * per node, no second walk. A cycle never reaches the cap: `JSON.stringify` refuses it first.
92
+ */
93
+ const replacerWithDepth = (): ((this: unknown, key: string, value: unknown) => unknown) => {
94
+ const depths = new WeakMap<object, number>();
95
+ return function replace(this: unknown, key: string, value: unknown): unknown {
96
+ const out = transform(this, key, value);
97
+ if (typeof out !== 'object' || out === null) return out;
98
+ const depth = (typeof this === 'object' && this !== null ? (depths.get(this) ?? 0) : 0) + 1;
99
+ if (depth > MAX_CACHE_VALUE_DEPTH) {
100
+ throw new CacheCodecLimit(
101
+ `nested deeper than the ${MAX_CACHE_VALUE_DEPTH} levels a cache reads back`,
102
+ );
103
+ }
104
+ depths.set(out, depth);
105
+ return out;
106
+ };
107
+ };
108
+
109
+ const isEntryList = (value: unknown): value is [unknown, unknown][] =>
110
+ Array.isArray(value) && value.every((entry) => Array.isArray(entry) && entry.length === 2);
111
+
112
+ /** The tagged value back, or `undefined` when `value` is not one this codec wrote. */
113
+ const untag = (value: Record<string, unknown>): { readonly revived: unknown } | undefined => {
114
+ const keys = Object.keys(value);
115
+ if (keys.length !== 2 || !Object.hasOwn(value, TAG) || !Object.hasOwn(value, 'v')) return;
116
+ const { v } = value as Tagged;
117
+ switch (value[TAG]) {
118
+ case 'date':
119
+ return v === null || typeof v === 'string'
120
+ ? { revived: new Date(v ?? Number.NaN) }
121
+ : undefined;
122
+ case 'bigint':
123
+ // Length first: text past the cap was not written by `encodeCacheValue`, and `BigInt` on it
124
+ // is the expensive call.
125
+ return typeof v === 'string' && v.length <= MAX_CACHE_BIGINT_DIGITS && DIGITS.test(v)
126
+ ? { revived: BigInt(v) }
127
+ : undefined;
128
+ case 'map':
129
+ return isEntryList(v) ? { revived: new Map(v) } : undefined;
130
+ case 'set':
131
+ return Array.isArray(v) ? { revived: new Set(v) } : undefined;
132
+ default:
133
+ return undefined;
134
+ }
135
+ };
136
+
137
+ /** Bottom-up, so a tagged value's members are already revived when the tag itself is read. */
138
+ const revive = (_key: string, value: unknown): unknown => {
139
+ if (!isRecord(value)) return value;
140
+ const tagged = untag(value);
141
+ return tagged === undefined ? unescapeKeys(value) : tagged.revived;
142
+ };
143
+
144
+ /**
145
+ * `payload` as the text a tier stores, marked with `CACHE_CODEC_MARK`. A cycle, a throwing
146
+ * `toJSON`, nesting past `MAX_CACHE_VALUE_DEPTH` or a bigint past `MAX_CACHE_BIGINT_DIGITS` is
147
+ * `X_CACHE_VALUE_UNENCODABLE`, naming the key and the tier: what is written is always readable.
148
+ */
149
+ export function encodeCacheValue(payload: unknown, at: { key: string; tier: string }): string {
150
+ try {
151
+ // `?? 'null'`: a top-level `undefined` or function is no JSON text at all.
152
+ return `${CACHE_CODEC_MARK}${JSON.stringify(payload, replacerWithDepth()) ?? 'null'}`;
153
+ } catch (error) {
154
+ const reason = error instanceof CacheCodecLimit ? error.reason : renderThrowable(error);
155
+ throw new CacheValueUnencodableError({ ...at, reason });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The inverse. Marked text is revived — only an object of exactly `{ $x, v }` with a tag this
161
+ * codec knows. Unmarked text predates the codec and is plain `JSON.parse`. Throws what
162
+ * `JSON.parse` throws; a tier reading text it did not write decides what a corrupt entry means.
163
+ */
164
+ export function decodeCacheValue(text: string): unknown {
165
+ if (!text.startsWith(CACHE_CODEC_MARK)) return JSON.parse(text);
166
+ return JSON.parse(text.slice(CACHE_CODEC_MARK.length), revive);
167
+ }