layercache 1.2.2 → 1.2.3

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-164%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
+ [![test coverage](https://img.shields.io/badge/tests-180%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
10
10
 
11
11
  ```
12
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -45,7 +45,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
45
45
  - **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
46
46
  - **Pattern invalidation** — `invalidateByPattern('user:*')`
47
47
  - **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
48
- - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
48
+ - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly, with optional stale-generation cleanup
49
49
  - **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
50
50
  - **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
51
51
  - **Negative caching** — cache known misses for a short TTL to protect the database
@@ -84,7 +84,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
84
84
  - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
85
85
  - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
86
86
  - **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
87
- - **ESM + CJS** — works with both module systems, Node.js ≥ 18
87
+ - **ESM + CJS** — works with both module systems, Node.js ≥ 20
88
88
 
89
89
  ---
90
90
 
@@ -173,7 +173,7 @@ await cache.set('user:123', user, {
173
173
 
174
174
  ### `cache.invalidateByTag(tag): Promise<void>`
175
175
 
176
- Deletes every key that was stored with this tag across all layers.
176
+ Deletes every key that was stored with this tag across all layers. In multi-instance deployments, this is only complete when every instance shares the same tag index implementation (for example `RedisTagIndex`).
177
177
 
178
178
  ```ts
179
179
  await cache.set('user:123', user, { tags: ['user:123'] })
@@ -193,12 +193,14 @@ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with e
193
193
 
194
194
  ### `cache.invalidateByPattern(pattern): Promise<void>`
195
195
 
196
- Glob-style deletion against the tracked key set.
196
+ Glob-style deletion against the tracked key set, plus any layer that can enumerate real keys (for example `MemoryLayer`, `RedisLayer`, or `DiskLayer`).
197
197
 
198
198
  ```ts
199
199
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
200
200
  ```
201
201
 
202
+ For multi-instance deployments, prefer a shared `RedisTagIndex`. Without it, pattern invalidation still scans real layer keys when available, but that fallback only helps on layers that implement `keys()`, and tag tracking itself remains process-local.
203
+
202
204
  ### `cache.invalidateByPrefix(prefix): Promise<void>`
203
205
 
204
206
  Prefer this over glob invalidation when your keys are hierarchical.
@@ -280,6 +282,17 @@ await cache.set('user:123', user)
280
282
  cache.bumpGeneration() // now reads use v2:user:123
281
283
  ```
282
284
 
285
+ If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
286
+
287
+ ```ts
288
+ const cache = new CacheStack([...], {
289
+ generation: 1,
290
+ generationCleanup: { batchSize: 500 }
291
+ })
292
+ ```
293
+
294
+ `bumpGeneration()` only rotates future reads and writes by default. Enable `generationCleanup` when you want previous generations to be pruned automatically instead of aging out by TTL.
295
+
283
296
  ### OpenTelemetry note
284
297
 
285
298
  `createOpenTelemetryPlugin()` currently wraps a `CacheStack` instance's methods directly. Use one OpenTelemetry plugin per cache instance; if you need to compose multiple wrappers, install them in a fixed order and uninstall them in reverse order.
@@ -482,10 +495,10 @@ const cache = new CacheStack(
482
495
  await cache.disconnect() // unsubscribes cleanly on shutdown
483
496
  ```
484
497
 
485
- By default, every `set` also broadcasts an invalidation so other servers evict stale memory immediately. To suppress broadcasts on writes (high write-volume services):
498
+ By default, write-triggered L1 invalidation is **off** even when an invalidation bus is configured. This avoids surprising Redis Pub/Sub traffic in write-heavy services. Enable it explicitly when you want every write to evict peer memory caches immediately:
486
499
 
487
500
  ```ts
488
- new CacheStack([...], { invalidationBus: bus, publishSetInvalidation: false })
501
+ new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
489
502
  ```
490
503
 
491
504
  ### Distributed tag invalidation
@@ -510,6 +523,8 @@ const cache = new CacheStack(
510
523
 
511
524
  Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
512
525
 
526
+ The same recommendation applies to `invalidateByPattern()` and `invalidateByPrefix()` in distributed deployments: a shared tag index gives the most complete view of known keys, while layer key scans act as a fallback only when the shared layer exposes `keys()`.
527
+
513
528
  ### Safe Redis clearing
514
529
 
515
530
  `RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
@@ -622,6 +637,8 @@ await cache.get('leaderboard', fetchLeaderboard, {
622
637
  })
623
638
  ```
624
639
 
640
+ Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
641
+
625
642
  ---
626
643
 
627
644
  ## Graceful degradation & circuit breaker
@@ -718,6 +735,8 @@ await cache.persistToFile('./cache-snapshot.json')
718
735
  await cache.restoreFromFile('./cache-snapshot.json')
719
736
  ```
720
737
 
738
+ For safety, file snapshots are restricted to `process.cwd()` by default. Set `snapshotBaseDir` to an explicit directory for application-controlled snapshot storage, or `false` if you intentionally want to disable that restriction.
739
+
721
740
  ---
722
741
 
723
742
  ## Event hooks
@@ -1019,7 +1038,7 @@ new CacheStack([...], {
1019
1038
 
1020
1039
  ## Requirements
1021
1040
 
1022
- - Node.js ≥ 18
1041
+ - Node.js ≥ 20
1023
1042
  - TypeScript ≥ 5.0 (optional — fully typed, ships `.d.ts`)
1024
1043
  - ioredis ≥ 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
1025
1044
 
@@ -1,6 +1,29 @@
1
1
  // src/internal/StoredValue.ts
2
2
  function isStoredValueEnvelope(value) {
3
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
3
+ if (typeof value !== "object" || value === null) {
4
+ return false;
5
+ }
6
+ const v = value;
7
+ if (v.__layercache !== 1) {
8
+ return false;
9
+ }
10
+ if (v.kind !== "value" && v.kind !== "empty") {
11
+ return false;
12
+ }
13
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
14
+ return false;
15
+ }
16
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
17
+ return false;
18
+ }
19
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
20
+ return false;
21
+ }
22
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
23
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
24
+ return false;
25
+ }
26
+ return true;
4
27
  }
5
28
  function createStoredValueEnvelope(options) {
6
29
  const now = options.now ?? Date.now();
@@ -1,7 +1,6 @@
1
1
  import {
2
- PatternMatcher,
3
2
  unwrapStoredValue
4
- } from "./chunk-ZMDB5KOK.js";
3
+ } from "./chunk-7V7XAB74.js";
5
4
 
6
5
  // src/layers/MemoryLayer.ts
7
6
  var MemoryLayer = class {
@@ -49,16 +48,10 @@ var MemoryLayer = class {
49
48
  return entry.value;
50
49
  }
51
50
  async getMany(keys) {
52
- const values = [];
53
- for (const key of keys) {
54
- values.push(await this.getEntry(key));
55
- }
56
- return values;
51
+ return Promise.all(keys.map((key) => this.getEntry(key)));
57
52
  }
58
53
  async setMany(entries) {
59
- for (const entry of entries) {
60
- await this.set(entry.key, entry.value, entry.ttl);
61
- }
54
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
62
55
  }
63
56
  async set(key, value, ttl = this.defaultTtl) {
64
57
  this.entries.delete(key);
@@ -197,15 +190,17 @@ var TagIndex = class {
197
190
  keyToTags = /* @__PURE__ */ new Map();
198
191
  knownKeys = /* @__PURE__ */ new Set();
199
192
  maxKnownKeys;
193
+ nextNodeId = 1;
194
+ root = this.createTrieNode();
200
195
  constructor(options = {}) {
201
- this.maxKnownKeys = options.maxKnownKeys;
196
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
202
197
  }
203
198
  async touch(key) {
204
- this.knownKeys.add(key);
199
+ this.insertKnownKey(key);
205
200
  this.pruneKnownKeysIfNeeded();
206
201
  }
207
202
  async track(key, tags) {
208
- this.knownKeys.add(key);
203
+ this.insertKnownKey(key);
209
204
  this.pruneKnownKeysIfNeeded();
210
205
  if (tags.length === 0) {
211
206
  return;
@@ -231,18 +226,104 @@ var TagIndex = class {
231
226
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
232
227
  }
233
228
  async keysForPrefix(prefix) {
234
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
229
+ const node = this.findNode(prefix);
230
+ if (!node) {
231
+ return [];
232
+ }
233
+ const matches = [];
234
+ this.collectFromNode(node, prefix, matches);
235
+ return matches;
235
236
  }
236
237
  async tagsForKey(key) {
237
238
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
238
239
  }
239
240
  async matchPattern(pattern) {
240
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
241
+ const matches = /* @__PURE__ */ new Set();
242
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
243
+ return [...matches];
241
244
  }
242
245
  async clear() {
243
246
  this.tagToKeys.clear();
244
247
  this.keyToTags.clear();
245
248
  this.knownKeys.clear();
249
+ this.root.children.clear();
250
+ this.root.terminal = false;
251
+ this.nextNodeId = this.root.id + 1;
252
+ }
253
+ createTrieNode() {
254
+ return {
255
+ id: this.nextNodeId++,
256
+ terminal: false,
257
+ children: /* @__PURE__ */ new Map()
258
+ };
259
+ }
260
+ insertKnownKey(key) {
261
+ if (this.knownKeys.has(key)) {
262
+ return;
263
+ }
264
+ this.knownKeys.add(key);
265
+ let node = this.root;
266
+ for (const character of key) {
267
+ let child = node.children.get(character);
268
+ if (!child) {
269
+ child = this.createTrieNode();
270
+ node.children.set(character, child);
271
+ }
272
+ node = child;
273
+ }
274
+ node.terminal = true;
275
+ }
276
+ findNode(prefix) {
277
+ let node = this.root;
278
+ for (const character of prefix) {
279
+ node = node.children.get(character);
280
+ if (!node) {
281
+ return void 0;
282
+ }
283
+ }
284
+ return node;
285
+ }
286
+ collectFromNode(node, prefix, matches) {
287
+ if (node.terminal) {
288
+ matches.push(prefix);
289
+ }
290
+ for (const [character, child] of node.children) {
291
+ this.collectFromNode(child, `${prefix}${character}`, matches);
292
+ }
293
+ }
294
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
295
+ const stateKey = `${node.id}:${patternIndex}`;
296
+ if (visited.has(stateKey)) {
297
+ return;
298
+ }
299
+ visited.add(stateKey);
300
+ if (patternIndex === pattern.length) {
301
+ if (node.terminal) {
302
+ matches.add(prefix);
303
+ }
304
+ return;
305
+ }
306
+ const patternChar = pattern[patternIndex];
307
+ if (patternChar === void 0) {
308
+ return;
309
+ }
310
+ if (patternChar === "*") {
311
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
312
+ for (const [character, child2] of node.children) {
313
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
314
+ }
315
+ return;
316
+ }
317
+ if (patternChar === "?") {
318
+ for (const [character, child2] of node.children) {
319
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
320
+ }
321
+ return;
322
+ }
323
+ const child = node.children.get(patternChar);
324
+ if (child) {
325
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
326
+ }
246
327
  }
247
328
  pruneKnownKeysIfNeeded() {
248
329
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -259,7 +340,7 @@ var TagIndex = class {
259
340
  }
260
341
  }
261
342
  removeKey(key) {
262
- this.knownKeys.delete(key);
343
+ this.removeKnownKey(key);
263
344
  const tags = this.keyToTags.get(key);
264
345
  if (!tags) {
265
346
  return;
@@ -276,6 +357,34 @@ var TagIndex = class {
276
357
  }
277
358
  this.keyToTags.delete(key);
278
359
  }
360
+ removeKnownKey(key) {
361
+ if (!this.knownKeys.delete(key)) {
362
+ return;
363
+ }
364
+ const path = [];
365
+ let node = this.root;
366
+ for (const character of key) {
367
+ const child = node.children.get(character);
368
+ if (!child) {
369
+ return;
370
+ }
371
+ path.push([node, character]);
372
+ node = child;
373
+ }
374
+ node.terminal = false;
375
+ for (let index = path.length - 1; index >= 0; index -= 1) {
376
+ const entry = path[index];
377
+ if (!entry) {
378
+ continue;
379
+ }
380
+ const [parent, character] = entry;
381
+ const child = parent.children.get(character);
382
+ if (!child || child.terminal || child.children.size > 0) {
383
+ break;
384
+ }
385
+ parent.children.delete(character);
386
+ }
387
+ }
279
388
  };
280
389
 
281
390
  // src/integrations/hono.ts
@@ -287,7 +396,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
287
396
  await next();
288
397
  return;
289
398
  }
290
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
399
+ const rawPath = context.req.path ?? context.req.url ?? "/";
400
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
291
401
  const cached = await cache.get(key, void 0, options);
292
402
  if (cached !== null) {
293
403
  context.header?.("x-cache", "HIT");
@@ -298,12 +408,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
298
408
  const originalJson = context.json.bind(context);
299
409
  context.json = (body, status) => {
300
410
  context.header?.("x-cache", "MISS");
301
- void cache.set(key, body, options);
411
+ cache.set(key, body, options).catch((err) => {
412
+ cache.emit("error", {
413
+ operation: "set",
414
+ error: err instanceof Error ? err.message : String(err)
415
+ });
416
+ });
302
417
  return originalJson(body, status);
303
418
  };
304
419
  await next();
305
420
  };
306
421
  }
422
+ function normalizeUrl(url) {
423
+ try {
424
+ const parsed = new URL(url, "http://localhost");
425
+ parsed.searchParams.sort();
426
+ return decodeURIComponent(parsed.pathname) + parsed.search;
427
+ } catch {
428
+ return url;
429
+ }
430
+ }
307
431
 
308
432
  export {
309
433
  MemoryLayer,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  PatternMatcher
3
- } from "./chunk-ZMDB5KOK.js";
3
+ } from "./chunk-7V7XAB74.js";
4
4
 
5
5
  // src/invalidation/RedisTagIndex.ts
6
6
  var RedisTagIndex = class {
package/dist/cli.cjs CHANGED
@@ -38,7 +38,30 @@ var import_ioredis = __toESM(require("ioredis"), 1);
38
38
 
39
39
  // src/internal/StoredValue.ts
40
40
  function isStoredValueEnvelope(value) {
41
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
41
+ if (typeof value !== "object" || value === null) {
42
+ return false;
43
+ }
44
+ const v = value;
45
+ if (v.__layercache !== 1) {
46
+ return false;
47
+ }
48
+ if (v.kind !== "value" && v.kind !== "empty") {
49
+ return false;
50
+ }
51
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
52
+ return false;
53
+ }
54
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
55
+ return false;
56
+ }
57
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
58
+ return false;
59
+ }
60
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
61
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
62
+ return false;
63
+ }
64
+ return true;
42
65
  }
43
66
  function resolveStoredValue(stored, now = Date.now()) {
44
67
  if (!isStoredValueEnvelope(stored)) {
@@ -259,7 +282,7 @@ async function main(argv = process.argv.slice(2)) {
259
282
  const redisUrl = validateRedisUrl(args.redisUrl);
260
283
  if (!redisUrl) {
261
284
  process.stderr.write(
262
- `Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
285
+ `Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
263
286
  `
264
287
  );
265
288
  process.exitCode = 1;
@@ -273,7 +296,7 @@ async function main(argv = process.argv.slice(2)) {
273
296
  try {
274
297
  await redis.connect().catch((error) => {
275
298
  const message = error instanceof Error ? error.message : String(error);
276
- throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
299
+ throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
277
300
  });
278
301
  if (args.command === "stats") {
279
302
  const keys = await scanKeys(redis, args.pattern ?? "*");
@@ -428,6 +451,17 @@ function summarizeInspectableValue(value) {
428
451
  }
429
452
  return value;
430
453
  }
454
+ function maskRedisUrl(url) {
455
+ try {
456
+ const parsed = new URL(url);
457
+ if (parsed.password) {
458
+ parsed.password = "***";
459
+ }
460
+ return parsed.toString();
461
+ } catch {
462
+ return url.replace(/:([^@/]+)@/, ":***@");
463
+ }
464
+ }
431
465
  if (process.argv[1]?.includes("cli.")) {
432
466
  void main();
433
467
  }
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  RedisTagIndex
4
- } from "./chunk-IXCMHVHP.js";
4
+ } from "./chunk-QHWG7QS5.js";
5
5
  import {
6
6
  isStoredValueEnvelope,
7
7
  resolveStoredValue
8
- } from "./chunk-ZMDB5KOK.js";
8
+ } from "./chunk-7V7XAB74.js";
9
9
 
10
10
  // src/cli.ts
11
11
  import Redis from "ioredis";
@@ -20,7 +20,7 @@ async function main(argv = process.argv.slice(2)) {
20
20
  const redisUrl = validateRedisUrl(args.redisUrl);
21
21
  if (!redisUrl) {
22
22
  process.stderr.write(
23
- `Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
23
+ `Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
24
24
  `
25
25
  );
26
26
  process.exitCode = 1;
@@ -34,7 +34,7 @@ async function main(argv = process.argv.slice(2)) {
34
34
  try {
35
35
  await redis.connect().catch((error) => {
36
36
  const message = error instanceof Error ? error.message : String(error);
37
- throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
37
+ throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
38
38
  });
39
39
  if (args.command === "stats") {
40
40
  const keys = await scanKeys(redis, args.pattern ?? "*");
@@ -189,6 +189,17 @@ function summarizeInspectableValue(value) {
189
189
  }
190
190
  return value;
191
191
  }
192
+ function maskRedisUrl(url) {
193
+ try {
194
+ const parsed = new URL(url);
195
+ if (parsed.password) {
196
+ parsed.password = "***";
197
+ }
198
+ return parsed.toString();
199
+ } catch {
200
+ return url.replace(/:([^@/]+)@/, ":***@");
201
+ }
202
+ }
192
203
  if (process.argv[1]?.includes("cli.")) {
193
204
  void main();
194
205
  }
@@ -177,6 +177,7 @@ interface CacheStackOptions {
177
177
  invalidationBus?: InvalidationBus;
178
178
  tagIndex?: CacheTagIndex;
179
179
  generation?: number;
180
+ generationCleanup?: boolean | CacheGenerationCleanupOptions;
180
181
  broadcastL1Invalidation?: boolean;
181
182
  /**
182
183
  * @deprecated Use `broadcastL1Invalidation` instead.
@@ -195,11 +196,13 @@ interface CacheStackOptions {
195
196
  writeStrategy?: 'write-through' | 'write-behind';
196
197
  writeBehind?: CacheWriteBehindOptions;
197
198
  fetcherRateLimit?: CacheRateLimitOptions;
199
+ backgroundRefreshTimeoutMs?: number;
198
200
  singleFlightCoordinator?: CacheSingleFlightCoordinator;
199
201
  singleFlightLeaseMs?: number;
200
202
  singleFlightTimeoutMs?: number;
201
203
  singleFlightPollMs?: number;
202
204
  singleFlightRenewIntervalMs?: number;
205
+ snapshotBaseDir?: string | false;
203
206
  /**
204
207
  * Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
205
208
  * before the oldest entries are pruned. Prevents unbounded memory growth.
@@ -212,6 +215,9 @@ interface CacheAdaptiveTtlOptions {
212
215
  step?: number | LayerTtlMap;
213
216
  maxTtl?: number | LayerTtlMap;
214
217
  }
218
+ interface CacheGenerationCleanupOptions {
219
+ batchSize?: number;
220
+ }
215
221
  type CacheTtlPolicy = 'until-midnight' | 'next-hour' | {
216
222
  alignTo: number;
217
223
  } | ((context: CacheTtlPolicyContext) => number | undefined);
@@ -415,7 +421,7 @@ interface TagIndexOptions {
415
421
  /**
416
422
  * Maximum number of keys tracked in `knownKeys`. When exceeded, the oldest
417
423
  * 10 % of keys are pruned to keep memory bounded.
418
- * Defaults to unlimited.
424
+ * Defaults to 100,000.
419
425
  */
420
426
  maxKnownKeys?: number;
421
427
  }
@@ -424,6 +430,8 @@ declare class TagIndex implements CacheTagIndex {
424
430
  private readonly keyToTags;
425
431
  private readonly knownKeys;
426
432
  private readonly maxKnownKeys;
433
+ private nextNodeId;
434
+ private readonly root;
427
435
  constructor(options?: TagIndexOptions);
428
436
  touch(key: string): Promise<void>;
429
437
  track(key: string, tags: string[]): Promise<void>;
@@ -433,8 +441,14 @@ declare class TagIndex implements CacheTagIndex {
433
441
  tagsForKey(key: string): Promise<string[]>;
434
442
  matchPattern(pattern: string): Promise<string[]>;
435
443
  clear(): Promise<void>;
444
+ private createTrieNode;
445
+ private insertKnownKey;
446
+ private findNode;
447
+ private collectFromNode;
448
+ private collectPatternMatches;
436
449
  private pruneKnownKeysIfNeeded;
437
450
  private removeKey;
451
+ private removeKnownKey;
438
452
  }
439
453
 
440
454
  declare class CacheNamespace {
@@ -505,6 +519,7 @@ declare class CacheStack extends EventEmitter {
505
519
  private readonly logger;
506
520
  private readonly tagIndex;
507
521
  private readonly fetchRateLimiter;
522
+ private readonly snapshotSerializer;
508
523
  private readonly backgroundRefreshes;
509
524
  private readonly layerDegradedUntil;
510
525
  private readonly ttlResolver;
@@ -513,6 +528,7 @@ declare class CacheStack extends EventEmitter {
513
528
  private readonly writeBehindQueue;
514
529
  private writeBehindTimer?;
515
530
  private writeBehindFlushPromise?;
531
+ private generationCleanupPromise?;
516
532
  private isDisconnecting;
517
533
  private disconnectPromise?;
518
534
  constructor(layers: CacheLayer[], options?: CacheStackOptions);
@@ -523,6 +539,7 @@ declare class CacheStack extends EventEmitter {
523
539
  * and no `fetcher` is provided.
524
540
  */
525
541
  get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null>;
542
+ private getPrepared;
526
543
  /**
527
544
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
528
545
  * Fetches and caches the value if not already present.
@@ -581,6 +598,11 @@ declare class CacheStack extends EventEmitter {
581
598
  */
582
599
  getHitRate(): CacheHitRateSnapshot;
583
600
  healthCheck(): Promise<CacheHealthCheckResult[]>;
601
+ /**
602
+ * Rotates the active generation prefix used for all future cache keys.
603
+ * Previous-generation keys remain in the underlying layers until they expire,
604
+ * unless `generationCleanup` is enabled to prune them in the background.
605
+ */
584
606
  bumpGeneration(nextGeneration?: number): number;
585
607
  /**
586
608
  * Returns detailed metadata about a single cache key: which layers contain it,
@@ -608,6 +630,7 @@ declare class CacheStack extends EventEmitter {
608
630
  private resolveLayerSeconds;
609
631
  private shouldNegativeCache;
610
632
  private scheduleBackgroundRefresh;
633
+ private runBackgroundRefresh;
611
634
  private resolveSingleFlightOptions;
612
635
  private deleteKeys;
613
636
  private publishInvalidation;
@@ -615,7 +638,14 @@ declare class CacheStack extends EventEmitter {
615
638
  private getTagsForKey;
616
639
  private formatError;
617
640
  private sleep;
641
+ private withTimeout;
618
642
  private shouldBroadcastL1Invalidation;
643
+ private collectKeysWithPrefix;
644
+ private collectKeysMatchingPattern;
645
+ private shouldCleanupGenerations;
646
+ private generationCleanupBatchSize;
647
+ private scheduleGenerationCleanup;
648
+ private cleanupGeneration;
619
649
  private initializeWriteBehind;
620
650
  private shouldWriteBehind;
621
651
  private enqueueWriteBehind;
@@ -643,12 +673,15 @@ declare class CacheStack extends EventEmitter {
643
673
  private applyFreshReadPolicies;
644
674
  private shouldSkipLayer;
645
675
  private handleLayerFailure;
676
+ private reportRecoverableLayerFailure;
646
677
  private isGracefulDegradationEnabled;
647
678
  private recordCircuitFailure;
648
679
  private isNegativeStoredValue;
649
680
  private emitError;
650
681
  private serializeKeyPart;
651
682
  private isCacheSnapshotEntries;
683
+ private sanitizeSnapshotValue;
684
+ private validateSnapshotFilePath;
652
685
  private normalizeForSerialization;
653
686
  }
654
687