layercache 1.2.3 → 1.2.4

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
@@ -1,92 +1,99 @@
1
- # layercache
1
+ <p align="center">
2
+ <img src="./logo.png" width="520" alt="layercache logo">
3
+ </p>
4
+
5
+ <h1 align="center">layercache</h1>
6
+
7
+ <p align="center">
8
+ <strong>Production-ready multi-layer caching for Node.js</strong><br>
9
+ <em>Memory, Redis, persistence, invalidation, observability, and resilience in one API.</em>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/layercache"><img src="https://img.shields.io/npm/v/layercache" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/layercache"><img src="https://img.shields.io/npm/dw/layercache" alt="npm downloads"></a>
15
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/layercache" alt="license"></a>
16
+ <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-blue" alt="TypeScript"></a>
17
+ <a href="https://github.com/flyingsquirrel0419/layercache"><img src="https://img.shields.io/badge/tests-180%20passing-brightgreen" alt="tests"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#installation">Installation</a> ·
22
+ <a href="#quick-start">Quick Start</a> ·
23
+ <a href="#core-api">Core API</a> ·
24
+ <a href="#invalidation--freshness">Invalidation</a> ·
25
+ <a href="#integrations--tooling">Integrations</a> ·
26
+ <a href="#contributing">Contributing</a>
27
+ </p>
2
28
 
3
- **Production-ready multi-layer caching for Node.js — memory, Redis, persistence, invalidation, and resilience in one API.**
29
+ ---
4
30
 
5
- [![npm version](https://img.shields.io/npm/v/layercache)](https://www.npmjs.com/package/layercache)
6
- [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
- [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
- [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-180%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
31
+ ## At a glance
10
32
 
11
- ```
33
+ - **Fast read path** — combine memory L1, Redis L2, disk, or custom layers behind one API with automatic backfill.
34
+ - **Stampede control** — prevent duplicate miss storms with in-process dedupe and optional distributed single-flight.
35
+ - **Strong invalidation model** — support tags, batched tags, wildcards, prefixes, and generation-based cache rotation.
36
+ - **Built for production failure modes** — serve stale safely, refresh ahead, degrade gracefully, trip circuit breakers, and throttle fetchers.
37
+ - **Operational visibility included** — expose metrics, stats, health checks, OpenTelemetry spans, and an admin CLI.
38
+ - **Fits real Node stacks** — integrate directly with Express, Fastify, Hono, GraphQL, tRPC, and NestJS.
39
+
40
+ Most Node.js services hit the same wall:
41
+
42
+ | Approach | Tradeoff |
43
+ |---|---|
44
+ | Memory-only cache | Fast, but every instance has a different view of data |
45
+ | Redis-only cache | Shared, but every request pays a network round-trip |
46
+ | Hand-rolled hybrid cache | Possible, but you rebuild stampede prevention, invalidation, TTL policy, and observability yourself |
47
+
48
+ `layercache` gives you a single API for layered caching and handles the hard parts for you: read-through fetches, backfill, stale serving, distributed invalidation, rate limiting, persistence, and operational introspection.
49
+
50
+ On a hit, `layercache` serves the fastest available layer and backfills anything above it. On a miss, the fetcher runs once, even under heavy concurrency.
51
+
52
+ ## Performance profile
53
+
54
+ ```text
12
55
  L1 hit ~0.01 ms ← served from memory, zero network
13
56
  L2 hit ~0.5 ms ← served from Redis, backfilled to memory
14
57
  miss ~20 ms ← fetcher runs once, all layers filled
15
58
  ```
16
59
 
17
- ---
60
+ ## Why teams use it
18
61
 
19
- ## Why layercache?
62
+ - **Predictable cache behavior** — layered reads, automatic backfill, negative caching, refresh-ahead, and stale serving.
63
+ - **Reliable invalidation** — tags, batched tags, wildcards, prefixes, and generation-based rotation.
64
+ - **Production safeguards** — rate limiting, circuit breakers, graceful degradation, compression limits, serializer hardening, and snapshot path validation.
65
+ - **Operational visibility** — metrics, health checks, OpenTelemetry spans, stats endpoints, and an admin CLI.
66
+ - **Works with your stack** — Express, Fastify, Hono, GraphQL, tRPC, NestJS, CLI, and custom cache layers.
20
67
 
21
- Most Node.js services end up with the same problem:
68
+ ## Feature map
22
69
 
23
- - **Memory-only** → fast, but not shared across servers
24
- - **Redis-only** → shared, but every read pays a network round-trip
25
- - **Hand-rolled layers** → works, but you rewrite stampede prevention, backfill logic, and tag invalidation in every project
70
+ ### Core caching
26
71
 
27
- layercache solves all three. You declare your layers once and call `get`. Everything else is handled.
72
+ - Layered reads with automatic backfill
73
+ - Stampede prevention and optional distributed single-flight
74
+ - Bulk reads and writes with layer-level `getMany()` / `setMany()` fast paths
75
+ - `wrap()`, namespaces, cache warming, `getOrThrow()`, and `inspect()`
28
76
 
29
- It is designed for production services that need predictable cache behavior under load: stampede prevention, cross-instance invalidation, layered TTL control, operational metrics, and safer persistence defaults.
77
+ ### Invalidation and freshness
30
78
 
31
- ```ts
32
- const user = await cache.get('user:123', () => db.findUser(123))
33
- // ↑ only called on a full miss
34
- ```
79
+ - Tag invalidation, batched tag invalidation, wildcard invalidation, and prefix invalidation
80
+ - Generation-based invalidation with optional stale-generation cleanup
81
+ - Sliding TTL, adaptive TTL, refresh-ahead, stale-while-revalidate, and stale-if-error
82
+ - Per-layer TTL overrides, TTL policies, negative caching, and TTL jitter
35
83
 
36
- On a hit, the value is returned from the fastest layer that has it, and automatically backfilled into any faster layers that didn't. On a miss, the fetcher runs exactly once — even under 100 concurrent requests for the same key.
84
+ ### Operations and resilience
37
85
 
38
- ---
86
+ - Graceful degradation, circuit breakers, scoped fetcher rate limiting, and write-behind
87
+ - Persistence to memory snapshots or disk snapshots
88
+ - Compression, serializer fallback chains, and MessagePack support
89
+ - Health checks, per-layer metrics, latency tracking, and event hooks
39
90
 
40
- ## Features
41
-
42
- - **Layered reads & automatic backfill** — hits in slower layers propagate up
43
- - **Cache stampede prevention** — mutex-based deduplication per key
44
- - **Tag-based invalidation** — `set('user:123:posts', posts, { tags: ['user:123'] })` then `invalidateByTag('user:123')`
45
- - **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
46
- - **Pattern invalidation** — `invalidateByPattern('user:*')`
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, with optional stale-generation cleanup
49
- - **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
50
- - **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
51
- - **Negative caching** — cache known misses for a short TTL to protect the database
52
- - **Stale strategies** — `staleWhileRevalidate` and `staleIfError` as opt-in read behavior
53
- - **TTL jitter** — spread expirations to avoid synchronized stampedes
54
- - **Sliding & adaptive TTL** — extend TTL on every read or ramp it up for hot keys
55
- - **Refresh-ahead** — trigger background refresh when TTL drops below a threshold
56
- - **Fetcher rate limiting** — cap concurrent fetchers or requests per interval
57
- - **Best-effort writes** — tolerate partial layer write failures when desired
58
- - **Write-behind mode** — write local layers immediately and flush slower remote layers asynchronously
59
- - **Bulk reads** — `mget` uses layer-level `getMany()` when available
60
- - **Bulk writes** — `mset` uses layer-level `setMany()` when available
61
- - **Distributed tag index** — `RedisTagIndex` keeps tag state consistent across multiple servers
62
- - **Optional distributed single-flight** — plug in a coordinator to dedupe misses across instances
63
- - **Cross-server L1 invalidation** — Redis pub/sub bus flushes stale memory on other instances when you write or delete
64
- - **`wrap()` decorator API** — turn any async function into a cached version with auto-generated keys
65
- - **Cache warming** — pre-populate layers with a prioritised list of entries at startup
66
- - **Namespaces** — scope a `CacheStack` to a key prefix for multi-tenant or module isolation
67
- - **Event hooks** — `EventEmitter`-based events for hits, misses, stale serves, errors, and more
68
- - **Graceful degradation** — skip a failing layer for a configurable retry window
69
- - **Circuit breaker** — per-key or global; opens after N failures, recovers after cooldown
70
- - **Compression** — transparent async gzip/brotli in `RedisLayer` (non-blocking) with a byte threshold
71
- - **Serializer fallback chains** — transparently read legacy payloads (for example JSON) and rewrite them with the primary serializer
72
- - **Metrics & stats** — per-layer hit/miss counters, **per-layer latency tracking**, circuit-breaker trips, degraded operations; HTTP stats handler
73
- - **Health checks** — `cache.healthCheck()` returns per-layer health and latency
74
- - **Persistence** — `exportState` / `importState` for in-process snapshots; `persistToFile` / `restoreFromFile` for disk
75
- - **Admin CLI** — `layercache stats | keys | inspect | invalidate` against any Redis URL
76
- - **Framework integrations** — Express middleware, Fastify plugin, Hono middleware, tRPC middleware, GraphQL resolver wrapper
77
- - **OpenTelemetry plugin** — instrument `get` / `set` / invalidation flows with spans
78
- - **MessagePack serializer** — drop-in replacement for lower Redis memory usage
79
- - **NestJS module** — `CacheStackModule.forRoot(...)` and `forRootAsync(...)` with `@InjectCacheStack()`
80
- - **`getOrThrow()`** — throws `CacheMissError` instead of returning `null`, for strict use cases
81
- - **`inspect()`** — debug a key: see which layers hold it, remaining TTLs, tags, and staleness state
82
- - **MemoryLayer cleanup hooks** — periodic TTL cleanup and `onEvict` callbacks
83
- - **Conditional caching** — `shouldCache` predicate to skip caching specific fetcher results
84
- - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
85
- - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
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 ≥ 20
91
+ ### Integrations and tooling
88
92
 
89
- ---
93
+ - Express, Fastify, Hono, GraphQL, tRPC, and NestJS integrations
94
+ - Redis-backed distributed tag index and invalidation bus support
95
+ - Admin CLI for stats, key inspection, and invalidation
96
+ - Edge-safe entry point for Worker-style runtimes
90
97
 
91
98
  ## Installation
92
99
 
@@ -105,16 +112,11 @@ import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'
105
112
  import Redis from 'ioredis'
106
113
 
107
114
  const cache = new CacheStack([
108
- new MemoryLayer({ ttl: 60, maxSize: 1_000 }), // L1 — local memory
109
- new RedisLayer({ client: new Redis(), ttl: 3600 }) // L2 — Redis
115
+ new MemoryLayer({ ttl: 60, maxSize: 1_000 }),
116
+ new RedisLayer({ client: new Redis(), ttl: 3600 })
110
117
  ])
111
118
 
112
- // Fetch pattern — cache miss runs the fetcher, hit skips it entirely
113
119
  const user = await cache.get<User>('user:123', () => db.findUser(123))
114
-
115
- // Manual set / delete
116
- await cache.set('user:123', user)
117
- await cache.delete('user:123')
118
120
  ```
119
121
 
120
122
  Memory-only setup (no Redis required):
@@ -171,6 +173,8 @@ await cache.set('user:123', user, {
171
173
  })
172
174
  ```
173
175
 
176
+ ## Invalidation & freshness
177
+
174
178
  ### `cache.invalidateByTag(tag): Promise<void>`
175
179
 
176
180
  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`).
@@ -763,9 +767,11 @@ cache.on('error', ({ event, context }) => logger.error(event, context))
763
767
 
764
768
  ---
765
769
 
766
- ## Framework integrations
770
+ ## Integrations & tooling
767
771
 
768
- ### Express
772
+ ### Framework integrations
773
+
774
+ #### Express
769
775
 
770
776
  ```ts
771
777
  import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
@@ -785,7 +791,7 @@ app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
785
791
  }), handler)
786
792
  ```
787
793
 
788
- ### tRPC
794
+ #### tRPC
789
795
 
790
796
  ```ts
791
797
  import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
@@ -795,7 +801,7 @@ const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
795
801
  export const cachedProcedure = t.procedure.use(cacheMiddleware)
796
802
  ```
797
803
 
798
- ### GraphQL
804
+ #### GraphQL
799
805
 
800
806
  ```ts
801
807
  import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
@@ -812,7 +818,7 @@ const resolvers = {
812
818
 
813
819
  ---
814
820
 
815
- ## Admin CLI
821
+ ### Admin CLI
816
822
 
817
823
  Inspect and manage a Redis-backed cache without writing code.
818
824
 
@@ -1046,15 +1052,20 @@ new CacheStack([...], {
1046
1052
 
1047
1053
  ## Contributing
1048
1054
 
1055
+ Contributions are welcome, whether that means bug fixes, documentation improvements, performance work, new adapters, or issue reports.
1056
+
1049
1057
  ```bash
1050
1058
  git clone https://github.com/flyingsquirrel0419/layercache
1051
1059
  cd layercache
1052
1060
  npm install
1061
+ npm run lint
1053
1062
  npm test # vitest
1054
1063
  npm run build:all # esm + cjs + nestjs package
1055
1064
  ```
1056
1065
 
1057
- PRs and issues welcome.
1066
+ - Read the [contribution guide](./CONTRIBUTING.md) before opening a PR.
1067
+ - Participation in the project is covered by the [Code of Conduct](./CODE_OF_CONDUCT.md).
1068
+ - If you are filing an issue, include reproduction steps, expected behavior, and runtime details when relevant.
1058
1069
 
1059
1070
  ---
1060
1071
 
@@ -518,6 +518,7 @@ declare class CacheStack extends EventEmitter {
518
518
  private unsubscribeInvalidation?;
519
519
  private readonly logger;
520
520
  private readonly tagIndex;
521
+ private readonly keyDiscovery;
521
522
  private readonly fetchRateLimiter;
522
523
  private readonly snapshotSerializer;
523
524
  private readonly backgroundRefreshes;
@@ -640,8 +641,6 @@ declare class CacheStack extends EventEmitter {
640
641
  private sleep;
641
642
  private withTimeout;
642
643
  private shouldBroadcastL1Invalidation;
643
- private collectKeysWithPrefix;
644
- private collectKeysMatchingPattern;
645
644
  private shouldCleanupGenerations;
646
645
  private generationCleanupBatchSize;
647
646
  private scheduleGenerationCleanup;
@@ -518,6 +518,7 @@ declare class CacheStack extends EventEmitter {
518
518
  private unsubscribeInvalidation?;
519
519
  private readonly logger;
520
520
  private readonly tagIndex;
521
+ private readonly keyDiscovery;
521
522
  private readonly fetchRateLimiter;
522
523
  private readonly snapshotSerializer;
523
524
  private readonly backgroundRefreshes;
@@ -640,8 +641,6 @@ declare class CacheStack extends EventEmitter {
640
641
  private sleep;
641
642
  private withTimeout;
642
643
  private shouldBroadcastL1Invalidation;
643
- private collectKeysWithPrefix;
644
- private collectKeysMatchingPattern;
645
644
  private shouldCleanupGenerations;
646
645
  private generationCleanupBatchSize;
647
646
  private scheduleGenerationCleanup;
package/dist/edge.d.cts CHANGED
@@ -1,2 +1,2 @@
1
- export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-B_rUqDy6.cjs';
1
+ export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-Dw97n89L.cjs';
2
2
  import 'node:events';
package/dist/edge.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-B_rUqDy6.js';
1
+ export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-Dw97n89L.js';
2
2
  import 'node:events';
package/dist/index.cjs CHANGED
@@ -306,6 +306,107 @@ function addMap(base, delta) {
306
306
  return result;
307
307
  }
308
308
 
309
+ // src/invalidation/PatternMatcher.ts
310
+ var PatternMatcher = class _PatternMatcher {
311
+ /**
312
+ * Tests whether a glob-style pattern matches a value.
313
+ * Supports `*` (any sequence of characters) and `?` (any single character).
314
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
315
+ * quadratic memory usage on long patterns/keys.
316
+ */
317
+ static matches(pattern, value) {
318
+ return _PatternMatcher.matchLinear(pattern, value);
319
+ }
320
+ /**
321
+ * Linear-time glob matching with O(1) extra memory.
322
+ */
323
+ static matchLinear(pattern, value) {
324
+ let patternIndex = 0;
325
+ let valueIndex = 0;
326
+ let starIndex = -1;
327
+ let backtrackValueIndex = 0;
328
+ while (valueIndex < value.length) {
329
+ const patternChar = pattern[patternIndex];
330
+ const valueChar = value[valueIndex];
331
+ if (patternChar === "*" && patternIndex < pattern.length) {
332
+ starIndex = patternIndex;
333
+ patternIndex += 1;
334
+ backtrackValueIndex = valueIndex;
335
+ continue;
336
+ }
337
+ if (patternChar === "?" || patternChar === valueChar) {
338
+ patternIndex += 1;
339
+ valueIndex += 1;
340
+ continue;
341
+ }
342
+ if (starIndex !== -1) {
343
+ patternIndex = starIndex + 1;
344
+ backtrackValueIndex += 1;
345
+ valueIndex = backtrackValueIndex;
346
+ continue;
347
+ }
348
+ return false;
349
+ }
350
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
351
+ patternIndex += 1;
352
+ }
353
+ return patternIndex === pattern.length;
354
+ }
355
+ };
356
+
357
+ // src/internal/CacheKeyDiscovery.ts
358
+ var CacheKeyDiscovery = class {
359
+ constructor(options) {
360
+ this.options = options;
361
+ }
362
+ options;
363
+ async collectKeysWithPrefix(prefix) {
364
+ const { tagIndex } = this.options;
365
+ const matches = new Set(
366
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
367
+ );
368
+ await Promise.all(
369
+ this.options.layers.map(async (layer) => {
370
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
371
+ return;
372
+ }
373
+ try {
374
+ const keys = await layer.keys();
375
+ for (const key of keys) {
376
+ if (key.startsWith(prefix)) {
377
+ matches.add(key);
378
+ }
379
+ }
380
+ } catch (error) {
381
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
382
+ }
383
+ })
384
+ );
385
+ return [...matches];
386
+ }
387
+ async collectKeysMatchingPattern(pattern) {
388
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
389
+ await Promise.all(
390
+ this.options.layers.map(async (layer) => {
391
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
392
+ return;
393
+ }
394
+ try {
395
+ const keys = await layer.keys();
396
+ for (const key of keys) {
397
+ if (PatternMatcher.matches(pattern, key)) {
398
+ matches.add(key);
399
+ }
400
+ }
401
+ } catch (error) {
402
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
403
+ }
404
+ })
405
+ );
406
+ return [...matches];
407
+ }
408
+ };
409
+
309
410
  // src/internal/CircuitBreakerManager.ts
310
411
  var CircuitBreakerManager = class {
311
412
  breakers = /* @__PURE__ */ new Map();
@@ -905,54 +1006,6 @@ var TtlResolver = class {
905
1006
  }
906
1007
  };
907
1008
 
908
- // src/invalidation/PatternMatcher.ts
909
- var PatternMatcher = class _PatternMatcher {
910
- /**
911
- * Tests whether a glob-style pattern matches a value.
912
- * Supports `*` (any sequence of characters) and `?` (any single character).
913
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
914
- * quadratic memory usage on long patterns/keys.
915
- */
916
- static matches(pattern, value) {
917
- return _PatternMatcher.matchLinear(pattern, value);
918
- }
919
- /**
920
- * Linear-time glob matching with O(1) extra memory.
921
- */
922
- static matchLinear(pattern, value) {
923
- let patternIndex = 0;
924
- let valueIndex = 0;
925
- let starIndex = -1;
926
- let backtrackValueIndex = 0;
927
- while (valueIndex < value.length) {
928
- const patternChar = pattern[patternIndex];
929
- const valueChar = value[valueIndex];
930
- if (patternChar === "*" && patternIndex < pattern.length) {
931
- starIndex = patternIndex;
932
- patternIndex += 1;
933
- backtrackValueIndex = valueIndex;
934
- continue;
935
- }
936
- if (patternChar === "?" || patternChar === valueChar) {
937
- patternIndex += 1;
938
- valueIndex += 1;
939
- continue;
940
- }
941
- if (starIndex !== -1) {
942
- patternIndex = starIndex + 1;
943
- backtrackValueIndex += 1;
944
- valueIndex = backtrackValueIndex;
945
- continue;
946
- }
947
- return false;
948
- }
949
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
950
- patternIndex += 1;
951
- }
952
- return patternIndex === pattern.length;
953
- }
954
- };
955
-
956
1009
  // src/invalidation/TagIndex.ts
957
1010
  var TagIndex = class {
958
1011
  tagToKeys = /* @__PURE__ */ new Map();
@@ -1282,6 +1335,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1282
1335
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1283
1336
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1284
1337
  this.tagIndex = options.tagIndex ?? new TagIndex();
1338
+ this.keyDiscovery = new CacheKeyDiscovery({
1339
+ layers: this.layers,
1340
+ tagIndex: this.tagIndex,
1341
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1342
+ handleLayerFailure: async (layer, operation, error) => {
1343
+ await this.handleLayerFailure(layer, operation, error);
1344
+ }
1345
+ });
1285
1346
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1286
1347
  this.logger.warn?.(
1287
1348
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -1309,6 +1370,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1309
1370
  unsubscribeInvalidation;
1310
1371
  logger;
1311
1372
  tagIndex;
1373
+ keyDiscovery;
1312
1374
  fetchRateLimiter = new FetchRateLimiter();
1313
1375
  snapshotSerializer = new JsonSerializer();
1314
1376
  backgroundRefreshes = /* @__PURE__ */ new Map();
@@ -1659,14 +1721,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1659
1721
  }
1660
1722
  async invalidateByPattern(pattern) {
1661
1723
  await this.awaitStartup("invalidateByPattern");
1662
- const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1724
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1663
1725
  await this.deleteKeys(keys);
1664
1726
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1665
1727
  }
1666
1728
  async invalidateByPrefix(prefix) {
1667
1729
  await this.awaitStartup("invalidateByPrefix");
1668
1730
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1669
- const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1731
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1670
1732
  await this.deleteKeys(keys);
1671
1733
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1672
1734
  }
@@ -2279,50 +2341,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
2279
2341
  shouldBroadcastL1Invalidation() {
2280
2342
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2281
2343
  }
2282
- async collectKeysWithPrefix(prefix) {
2283
- const matches = new Set(
2284
- this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2285
- );
2286
- await Promise.all(
2287
- this.layers.map(async (layer) => {
2288
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2289
- return;
2290
- }
2291
- try {
2292
- const keys = await layer.keys();
2293
- for (const key of keys) {
2294
- if (key.startsWith(prefix)) {
2295
- matches.add(key);
2296
- }
2297
- }
2298
- } catch (error) {
2299
- await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2300
- }
2301
- })
2302
- );
2303
- return [...matches];
2304
- }
2305
- async collectKeysMatchingPattern(pattern) {
2306
- const matches = new Set(await this.tagIndex.matchPattern(pattern));
2307
- await Promise.all(
2308
- this.layers.map(async (layer) => {
2309
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2310
- return;
2311
- }
2312
- try {
2313
- const keys = await layer.keys();
2314
- for (const key of keys) {
2315
- if (PatternMatcher.matches(pattern, key)) {
2316
- matches.add(key);
2317
- }
2318
- }
2319
- } catch (error) {
2320
- await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2321
- }
2322
- })
2323
- );
2324
- return [...matches];
2325
- }
2326
2344
  shouldCleanupGenerations() {
2327
2345
  return Boolean(this.options.generationCleanup);
2328
2346
  }
@@ -2345,7 +2363,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2345
2363
  }
2346
2364
  async cleanupGeneration(generation) {
2347
2365
  const prefix = `v${generation}:`;
2348
- const keys = await this.collectKeysWithPrefix(prefix);
2366
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2349
2367
  if (keys.length === 0) {
2350
2368
  return;
2351
2369
  }
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-B_rUqDy6.cjs';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-B_rUqDy6.cjs';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-Dw97n89L.cjs';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-Dw97n89L.cjs';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-B_rUqDy6.js';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-B_rUqDy6.js';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-Dw97n89L.js';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-Dw97n89L.js';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
package/dist/index.js CHANGED
@@ -266,6 +266,59 @@ function addMap(base, delta) {
266
266
  return result;
267
267
  }
268
268
 
269
+ // src/internal/CacheKeyDiscovery.ts
270
+ var CacheKeyDiscovery = class {
271
+ constructor(options) {
272
+ this.options = options;
273
+ }
274
+ options;
275
+ async collectKeysWithPrefix(prefix) {
276
+ const { tagIndex } = this.options;
277
+ const matches = new Set(
278
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
279
+ );
280
+ await Promise.all(
281
+ this.options.layers.map(async (layer) => {
282
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
283
+ return;
284
+ }
285
+ try {
286
+ const keys = await layer.keys();
287
+ for (const key of keys) {
288
+ if (key.startsWith(prefix)) {
289
+ matches.add(key);
290
+ }
291
+ }
292
+ } catch (error) {
293
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
294
+ }
295
+ })
296
+ );
297
+ return [...matches];
298
+ }
299
+ async collectKeysMatchingPattern(pattern) {
300
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
301
+ await Promise.all(
302
+ this.options.layers.map(async (layer) => {
303
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
304
+ return;
305
+ }
306
+ try {
307
+ const keys = await layer.keys();
308
+ for (const key of keys) {
309
+ if (PatternMatcher.matches(pattern, key)) {
310
+ matches.add(key);
311
+ }
312
+ }
313
+ } catch (error) {
314
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
315
+ }
316
+ })
317
+ );
318
+ return [...matches];
319
+ }
320
+ };
321
+
269
322
  // src/internal/CircuitBreakerManager.ts
270
323
  var CircuitBreakerManager = class {
271
324
  breakers = /* @__PURE__ */ new Map();
@@ -867,6 +920,14 @@ var CacheStack = class extends EventEmitter {
867
920
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
868
921
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
869
922
  this.tagIndex = options.tagIndex ?? new TagIndex();
923
+ this.keyDiscovery = new CacheKeyDiscovery({
924
+ layers: this.layers,
925
+ tagIndex: this.tagIndex,
926
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
927
+ handleLayerFailure: async (layer, operation, error) => {
928
+ await this.handleLayerFailure(layer, operation, error);
929
+ }
930
+ });
870
931
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
871
932
  this.logger.warn?.(
872
933
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -894,6 +955,7 @@ var CacheStack = class extends EventEmitter {
894
955
  unsubscribeInvalidation;
895
956
  logger;
896
957
  tagIndex;
958
+ keyDiscovery;
897
959
  fetchRateLimiter = new FetchRateLimiter();
898
960
  snapshotSerializer = new JsonSerializer();
899
961
  backgroundRefreshes = /* @__PURE__ */ new Map();
@@ -1244,14 +1306,14 @@ var CacheStack = class extends EventEmitter {
1244
1306
  }
1245
1307
  async invalidateByPattern(pattern) {
1246
1308
  await this.awaitStartup("invalidateByPattern");
1247
- const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1309
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1248
1310
  await this.deleteKeys(keys);
1249
1311
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1250
1312
  }
1251
1313
  async invalidateByPrefix(prefix) {
1252
1314
  await this.awaitStartup("invalidateByPrefix");
1253
1315
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1254
- const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1316
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1255
1317
  await this.deleteKeys(keys);
1256
1318
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1257
1319
  }
@@ -1864,50 +1926,6 @@ var CacheStack = class extends EventEmitter {
1864
1926
  shouldBroadcastL1Invalidation() {
1865
1927
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1866
1928
  }
1867
- async collectKeysWithPrefix(prefix) {
1868
- const matches = new Set(
1869
- this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
1870
- );
1871
- await Promise.all(
1872
- this.layers.map(async (layer) => {
1873
- if (!layer.keys || this.shouldSkipLayer(layer)) {
1874
- return;
1875
- }
1876
- try {
1877
- const keys = await layer.keys();
1878
- for (const key of keys) {
1879
- if (key.startsWith(prefix)) {
1880
- matches.add(key);
1881
- }
1882
- }
1883
- } catch (error) {
1884
- await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
1885
- }
1886
- })
1887
- );
1888
- return [...matches];
1889
- }
1890
- async collectKeysMatchingPattern(pattern) {
1891
- const matches = new Set(await this.tagIndex.matchPattern(pattern));
1892
- await Promise.all(
1893
- this.layers.map(async (layer) => {
1894
- if (!layer.keys || this.shouldSkipLayer(layer)) {
1895
- return;
1896
- }
1897
- try {
1898
- const keys = await layer.keys();
1899
- for (const key of keys) {
1900
- if (PatternMatcher.matches(pattern, key)) {
1901
- matches.add(key);
1902
- }
1903
- }
1904
- } catch (error) {
1905
- await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
1906
- }
1907
- })
1908
- );
1909
- return [...matches];
1910
- }
1911
1929
  shouldCleanupGenerations() {
1912
1930
  return Boolean(this.options.generationCleanup);
1913
1931
  }
@@ -1930,7 +1948,7 @@ var CacheStack = class extends EventEmitter {
1930
1948
  }
1931
1949
  async cleanupGeneration(generation) {
1932
1950
  const prefix = `v${generation}:`;
1933
- const keys = await this.collectKeysWithPrefix(prefix);
1951
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
1934
1952
  if (keys.length === 0) {
1935
1953
  return;
1936
1954
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Hardened multi-layer caching for Node.js with memory, Redis, stampede prevention, and operational invalidation helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -504,6 +504,107 @@ function addMap(base, delta) {
504
504
  return result;
505
505
  }
506
506
 
507
+ // ../../src/invalidation/PatternMatcher.ts
508
+ var PatternMatcher = class _PatternMatcher {
509
+ /**
510
+ * Tests whether a glob-style pattern matches a value.
511
+ * Supports `*` (any sequence of characters) and `?` (any single character).
512
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
513
+ * quadratic memory usage on long patterns/keys.
514
+ */
515
+ static matches(pattern, value) {
516
+ return _PatternMatcher.matchLinear(pattern, value);
517
+ }
518
+ /**
519
+ * Linear-time glob matching with O(1) extra memory.
520
+ */
521
+ static matchLinear(pattern, value) {
522
+ let patternIndex = 0;
523
+ let valueIndex = 0;
524
+ let starIndex = -1;
525
+ let backtrackValueIndex = 0;
526
+ while (valueIndex < value.length) {
527
+ const patternChar = pattern[patternIndex];
528
+ const valueChar = value[valueIndex];
529
+ if (patternChar === "*" && patternIndex < pattern.length) {
530
+ starIndex = patternIndex;
531
+ patternIndex += 1;
532
+ backtrackValueIndex = valueIndex;
533
+ continue;
534
+ }
535
+ if (patternChar === "?" || patternChar === valueChar) {
536
+ patternIndex += 1;
537
+ valueIndex += 1;
538
+ continue;
539
+ }
540
+ if (starIndex !== -1) {
541
+ patternIndex = starIndex + 1;
542
+ backtrackValueIndex += 1;
543
+ valueIndex = backtrackValueIndex;
544
+ continue;
545
+ }
546
+ return false;
547
+ }
548
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
549
+ patternIndex += 1;
550
+ }
551
+ return patternIndex === pattern.length;
552
+ }
553
+ };
554
+
555
+ // ../../src/internal/CacheKeyDiscovery.ts
556
+ var CacheKeyDiscovery = class {
557
+ constructor(options) {
558
+ this.options = options;
559
+ }
560
+ options;
561
+ async collectKeysWithPrefix(prefix) {
562
+ const { tagIndex } = this.options;
563
+ const matches = new Set(
564
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
565
+ );
566
+ await Promise.all(
567
+ this.options.layers.map(async (layer) => {
568
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
569
+ return;
570
+ }
571
+ try {
572
+ const keys = await layer.keys();
573
+ for (const key of keys) {
574
+ if (key.startsWith(prefix)) {
575
+ matches.add(key);
576
+ }
577
+ }
578
+ } catch (error) {
579
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
580
+ }
581
+ })
582
+ );
583
+ return [...matches];
584
+ }
585
+ async collectKeysMatchingPattern(pattern) {
586
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
587
+ await Promise.all(
588
+ this.options.layers.map(async (layer) => {
589
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
590
+ return;
591
+ }
592
+ try {
593
+ const keys = await layer.keys();
594
+ for (const key of keys) {
595
+ if (PatternMatcher.matches(pattern, key)) {
596
+ matches.add(key);
597
+ }
598
+ }
599
+ } catch (error) {
600
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
601
+ }
602
+ })
603
+ );
604
+ return [...matches];
605
+ }
606
+ };
607
+
507
608
  // ../../src/internal/CircuitBreakerManager.ts
508
609
  var CircuitBreakerManager = class {
509
610
  breakers = /* @__PURE__ */ new Map();
@@ -1103,54 +1204,6 @@ var TtlResolver = class {
1103
1204
  }
1104
1205
  };
1105
1206
 
1106
- // ../../src/invalidation/PatternMatcher.ts
1107
- var PatternMatcher = class _PatternMatcher {
1108
- /**
1109
- * Tests whether a glob-style pattern matches a value.
1110
- * Supports `*` (any sequence of characters) and `?` (any single character).
1111
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
1112
- * quadratic memory usage on long patterns/keys.
1113
- */
1114
- static matches(pattern, value) {
1115
- return _PatternMatcher.matchLinear(pattern, value);
1116
- }
1117
- /**
1118
- * Linear-time glob matching with O(1) extra memory.
1119
- */
1120
- static matchLinear(pattern, value) {
1121
- let patternIndex = 0;
1122
- let valueIndex = 0;
1123
- let starIndex = -1;
1124
- let backtrackValueIndex = 0;
1125
- while (valueIndex < value.length) {
1126
- const patternChar = pattern[patternIndex];
1127
- const valueChar = value[valueIndex];
1128
- if (patternChar === "*" && patternIndex < pattern.length) {
1129
- starIndex = patternIndex;
1130
- patternIndex += 1;
1131
- backtrackValueIndex = valueIndex;
1132
- continue;
1133
- }
1134
- if (patternChar === "?" || patternChar === valueChar) {
1135
- patternIndex += 1;
1136
- valueIndex += 1;
1137
- continue;
1138
- }
1139
- if (starIndex !== -1) {
1140
- patternIndex = starIndex + 1;
1141
- backtrackValueIndex += 1;
1142
- valueIndex = backtrackValueIndex;
1143
- continue;
1144
- }
1145
- return false;
1146
- }
1147
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
1148
- patternIndex += 1;
1149
- }
1150
- return patternIndex === pattern.length;
1151
- }
1152
- };
1153
-
1154
1207
  // ../../src/invalidation/TagIndex.ts
1155
1208
  var TagIndex = class {
1156
1209
  tagToKeys = /* @__PURE__ */ new Map();
@@ -1479,6 +1532,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1479
1532
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1480
1533
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1481
1534
  this.tagIndex = options.tagIndex ?? new TagIndex();
1535
+ this.keyDiscovery = new CacheKeyDiscovery({
1536
+ layers: this.layers,
1537
+ tagIndex: this.tagIndex,
1538
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1539
+ handleLayerFailure: async (layer, operation, error) => {
1540
+ await this.handleLayerFailure(layer, operation, error);
1541
+ }
1542
+ });
1482
1543
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1483
1544
  this.logger.warn?.(
1484
1545
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -1506,6 +1567,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1506
1567
  unsubscribeInvalidation;
1507
1568
  logger;
1508
1569
  tagIndex;
1570
+ keyDiscovery;
1509
1571
  fetchRateLimiter = new FetchRateLimiter();
1510
1572
  snapshotSerializer = new JsonSerializer();
1511
1573
  backgroundRefreshes = /* @__PURE__ */ new Map();
@@ -1856,14 +1918,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1856
1918
  }
1857
1919
  async invalidateByPattern(pattern) {
1858
1920
  await this.awaitStartup("invalidateByPattern");
1859
- const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1921
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1860
1922
  await this.deleteKeys(keys);
1861
1923
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1862
1924
  }
1863
1925
  async invalidateByPrefix(prefix) {
1864
1926
  await this.awaitStartup("invalidateByPrefix");
1865
1927
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1866
- const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1928
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1867
1929
  await this.deleteKeys(keys);
1868
1930
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1869
1931
  }
@@ -2476,50 +2538,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
2476
2538
  shouldBroadcastL1Invalidation() {
2477
2539
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2478
2540
  }
2479
- async collectKeysWithPrefix(prefix) {
2480
- const matches = new Set(
2481
- this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2482
- );
2483
- await Promise.all(
2484
- this.layers.map(async (layer) => {
2485
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2486
- return;
2487
- }
2488
- try {
2489
- const keys = await layer.keys();
2490
- for (const key of keys) {
2491
- if (key.startsWith(prefix)) {
2492
- matches.add(key);
2493
- }
2494
- }
2495
- } catch (error) {
2496
- await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2497
- }
2498
- })
2499
- );
2500
- return [...matches];
2501
- }
2502
- async collectKeysMatchingPattern(pattern) {
2503
- const matches = new Set(await this.tagIndex.matchPattern(pattern));
2504
- await Promise.all(
2505
- this.layers.map(async (layer) => {
2506
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2507
- return;
2508
- }
2509
- try {
2510
- const keys = await layer.keys();
2511
- for (const key of keys) {
2512
- if (PatternMatcher.matches(pattern, key)) {
2513
- matches.add(key);
2514
- }
2515
- }
2516
- } catch (error) {
2517
- await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2518
- }
2519
- })
2520
- );
2521
- return [...matches];
2522
- }
2523
2541
  shouldCleanupGenerations() {
2524
2542
  return Boolean(this.options.generationCleanup);
2525
2543
  }
@@ -2542,7 +2560,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2542
2560
  }
2543
2561
  async cleanupGeneration(generation) {
2544
2562
  const prefix = `v${generation}:`;
2545
- const keys = await this.collectKeysWithPrefix(prefix);
2563
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2546
2564
  if (keys.length === 0) {
2547
2565
  return;
2548
2566
  }
@@ -410,6 +410,7 @@ declare class CacheStack extends EventEmitter {
410
410
  private unsubscribeInvalidation?;
411
411
  private readonly logger;
412
412
  private readonly tagIndex;
413
+ private readonly keyDiscovery;
413
414
  private readonly fetchRateLimiter;
414
415
  private readonly snapshotSerializer;
415
416
  private readonly backgroundRefreshes;
@@ -532,8 +533,6 @@ declare class CacheStack extends EventEmitter {
532
533
  private sleep;
533
534
  private withTimeout;
534
535
  private shouldBroadcastL1Invalidation;
535
- private collectKeysWithPrefix;
536
- private collectKeysMatchingPattern;
537
536
  private shouldCleanupGenerations;
538
537
  private generationCleanupBatchSize;
539
538
  private scheduleGenerationCleanup;
@@ -410,6 +410,7 @@ declare class CacheStack extends EventEmitter {
410
410
  private unsubscribeInvalidation?;
411
411
  private readonly logger;
412
412
  private readonly tagIndex;
413
+ private readonly keyDiscovery;
413
414
  private readonly fetchRateLimiter;
414
415
  private readonly snapshotSerializer;
415
416
  private readonly backgroundRefreshes;
@@ -532,8 +533,6 @@ declare class CacheStack extends EventEmitter {
532
533
  private sleep;
533
534
  private withTimeout;
534
535
  private shouldBroadcastL1Invalidation;
535
- private collectKeysWithPrefix;
536
- private collectKeysMatchingPattern;
537
536
  private shouldCleanupGenerations;
538
537
  private generationCleanupBatchSize;
539
538
  private scheduleGenerationCleanup;
@@ -468,6 +468,107 @@ function addMap(base, delta) {
468
468
  return result;
469
469
  }
470
470
 
471
+ // ../../src/invalidation/PatternMatcher.ts
472
+ var PatternMatcher = class _PatternMatcher {
473
+ /**
474
+ * Tests whether a glob-style pattern matches a value.
475
+ * Supports `*` (any sequence of characters) and `?` (any single character).
476
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
477
+ * quadratic memory usage on long patterns/keys.
478
+ */
479
+ static matches(pattern, value) {
480
+ return _PatternMatcher.matchLinear(pattern, value);
481
+ }
482
+ /**
483
+ * Linear-time glob matching with O(1) extra memory.
484
+ */
485
+ static matchLinear(pattern, value) {
486
+ let patternIndex = 0;
487
+ let valueIndex = 0;
488
+ let starIndex = -1;
489
+ let backtrackValueIndex = 0;
490
+ while (valueIndex < value.length) {
491
+ const patternChar = pattern[patternIndex];
492
+ const valueChar = value[valueIndex];
493
+ if (patternChar === "*" && patternIndex < pattern.length) {
494
+ starIndex = patternIndex;
495
+ patternIndex += 1;
496
+ backtrackValueIndex = valueIndex;
497
+ continue;
498
+ }
499
+ if (patternChar === "?" || patternChar === valueChar) {
500
+ patternIndex += 1;
501
+ valueIndex += 1;
502
+ continue;
503
+ }
504
+ if (starIndex !== -1) {
505
+ patternIndex = starIndex + 1;
506
+ backtrackValueIndex += 1;
507
+ valueIndex = backtrackValueIndex;
508
+ continue;
509
+ }
510
+ return false;
511
+ }
512
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
513
+ patternIndex += 1;
514
+ }
515
+ return patternIndex === pattern.length;
516
+ }
517
+ };
518
+
519
+ // ../../src/internal/CacheKeyDiscovery.ts
520
+ var CacheKeyDiscovery = class {
521
+ constructor(options) {
522
+ this.options = options;
523
+ }
524
+ options;
525
+ async collectKeysWithPrefix(prefix) {
526
+ const { tagIndex } = this.options;
527
+ const matches = new Set(
528
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
529
+ );
530
+ await Promise.all(
531
+ this.options.layers.map(async (layer) => {
532
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
533
+ return;
534
+ }
535
+ try {
536
+ const keys = await layer.keys();
537
+ for (const key of keys) {
538
+ if (key.startsWith(prefix)) {
539
+ matches.add(key);
540
+ }
541
+ }
542
+ } catch (error) {
543
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
544
+ }
545
+ })
546
+ );
547
+ return [...matches];
548
+ }
549
+ async collectKeysMatchingPattern(pattern) {
550
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
551
+ await Promise.all(
552
+ this.options.layers.map(async (layer) => {
553
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
554
+ return;
555
+ }
556
+ try {
557
+ const keys = await layer.keys();
558
+ for (const key of keys) {
559
+ if (PatternMatcher.matches(pattern, key)) {
560
+ matches.add(key);
561
+ }
562
+ }
563
+ } catch (error) {
564
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
565
+ }
566
+ })
567
+ );
568
+ return [...matches];
569
+ }
570
+ };
571
+
471
572
  // ../../src/internal/CircuitBreakerManager.ts
472
573
  var CircuitBreakerManager = class {
473
574
  breakers = /* @__PURE__ */ new Map();
@@ -1067,54 +1168,6 @@ var TtlResolver = class {
1067
1168
  }
1068
1169
  };
1069
1170
 
1070
- // ../../src/invalidation/PatternMatcher.ts
1071
- var PatternMatcher = class _PatternMatcher {
1072
- /**
1073
- * Tests whether a glob-style pattern matches a value.
1074
- * Supports `*` (any sequence of characters) and `?` (any single character).
1075
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
1076
- * quadratic memory usage on long patterns/keys.
1077
- */
1078
- static matches(pattern, value) {
1079
- return _PatternMatcher.matchLinear(pattern, value);
1080
- }
1081
- /**
1082
- * Linear-time glob matching with O(1) extra memory.
1083
- */
1084
- static matchLinear(pattern, value) {
1085
- let patternIndex = 0;
1086
- let valueIndex = 0;
1087
- let starIndex = -1;
1088
- let backtrackValueIndex = 0;
1089
- while (valueIndex < value.length) {
1090
- const patternChar = pattern[patternIndex];
1091
- const valueChar = value[valueIndex];
1092
- if (patternChar === "*" && patternIndex < pattern.length) {
1093
- starIndex = patternIndex;
1094
- patternIndex += 1;
1095
- backtrackValueIndex = valueIndex;
1096
- continue;
1097
- }
1098
- if (patternChar === "?" || patternChar === valueChar) {
1099
- patternIndex += 1;
1100
- valueIndex += 1;
1101
- continue;
1102
- }
1103
- if (starIndex !== -1) {
1104
- patternIndex = starIndex + 1;
1105
- backtrackValueIndex += 1;
1106
- valueIndex = backtrackValueIndex;
1107
- continue;
1108
- }
1109
- return false;
1110
- }
1111
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
1112
- patternIndex += 1;
1113
- }
1114
- return patternIndex === pattern.length;
1115
- }
1116
- };
1117
-
1118
1171
  // ../../src/invalidation/TagIndex.ts
1119
1172
  var TagIndex = class {
1120
1173
  tagToKeys = /* @__PURE__ */ new Map();
@@ -1443,6 +1496,14 @@ var CacheStack = class extends EventEmitter {
1443
1496
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1444
1497
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1445
1498
  this.tagIndex = options.tagIndex ?? new TagIndex();
1499
+ this.keyDiscovery = new CacheKeyDiscovery({
1500
+ layers: this.layers,
1501
+ tagIndex: this.tagIndex,
1502
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1503
+ handleLayerFailure: async (layer, operation, error) => {
1504
+ await this.handleLayerFailure(layer, operation, error);
1505
+ }
1506
+ });
1446
1507
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1447
1508
  this.logger.warn?.(
1448
1509
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -1470,6 +1531,7 @@ var CacheStack = class extends EventEmitter {
1470
1531
  unsubscribeInvalidation;
1471
1532
  logger;
1472
1533
  tagIndex;
1534
+ keyDiscovery;
1473
1535
  fetchRateLimiter = new FetchRateLimiter();
1474
1536
  snapshotSerializer = new JsonSerializer();
1475
1537
  backgroundRefreshes = /* @__PURE__ */ new Map();
@@ -1820,14 +1882,14 @@ var CacheStack = class extends EventEmitter {
1820
1882
  }
1821
1883
  async invalidateByPattern(pattern) {
1822
1884
  await this.awaitStartup("invalidateByPattern");
1823
- const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1885
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1824
1886
  await this.deleteKeys(keys);
1825
1887
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1826
1888
  }
1827
1889
  async invalidateByPrefix(prefix) {
1828
1890
  await this.awaitStartup("invalidateByPrefix");
1829
1891
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1830
- const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1892
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1831
1893
  await this.deleteKeys(keys);
1832
1894
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1833
1895
  }
@@ -2440,50 +2502,6 @@ var CacheStack = class extends EventEmitter {
2440
2502
  shouldBroadcastL1Invalidation() {
2441
2503
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2442
2504
  }
2443
- async collectKeysWithPrefix(prefix) {
2444
- const matches = new Set(
2445
- this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2446
- );
2447
- await Promise.all(
2448
- this.layers.map(async (layer) => {
2449
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2450
- return;
2451
- }
2452
- try {
2453
- const keys = await layer.keys();
2454
- for (const key of keys) {
2455
- if (key.startsWith(prefix)) {
2456
- matches.add(key);
2457
- }
2458
- }
2459
- } catch (error) {
2460
- await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2461
- }
2462
- })
2463
- );
2464
- return [...matches];
2465
- }
2466
- async collectKeysMatchingPattern(pattern) {
2467
- const matches = new Set(await this.tagIndex.matchPattern(pattern));
2468
- await Promise.all(
2469
- this.layers.map(async (layer) => {
2470
- if (!layer.keys || this.shouldSkipLayer(layer)) {
2471
- return;
2472
- }
2473
- try {
2474
- const keys = await layer.keys();
2475
- for (const key of keys) {
2476
- if (PatternMatcher.matches(pattern, key)) {
2477
- matches.add(key);
2478
- }
2479
- }
2480
- } catch (error) {
2481
- await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2482
- }
2483
- })
2484
- );
2485
- return [...matches];
2486
- }
2487
2505
  shouldCleanupGenerations() {
2488
2506
  return Boolean(this.options.generationCleanup);
2489
2507
  }
@@ -2506,7 +2524,7 @@ var CacheStack = class extends EventEmitter {
2506
2524
  }
2507
2525
  async cleanupGeneration(generation) {
2508
2526
  const prefix = `v${generation}:`;
2509
- const keys = await this.collectKeysWithPrefix(prefix);
2527
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2510
2528
  if (keys.length === 0) {
2511
2529
  return;
2512
2530
  }