layercache 1.2.3 → 1.2.5

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`).
@@ -199,6 +203,8 @@ Glob-style deletion against the tracked key set, plus any layer that can enumera
199
203
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
200
204
  ```
201
205
 
206
+ Patterns must be non-empty, at most 1024 characters long, and free of control characters.
207
+
202
208
  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
209
 
204
210
  ### `cache.invalidateByPrefix(prefix): Promise<void>`
@@ -316,6 +322,8 @@ await cache.warm(
316
322
 
317
323
  Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys, and namespace metrics are serialized per `CacheStack` instance so unrelated caches do not block each other while metrics are collected.
318
324
 
325
+ Namespace prefixes must be non-empty, at most 256 characters long, and free of control characters.
326
+
319
327
  ```ts
320
328
  const users = cache.namespace('users')
321
329
  const posts = cache.namespace('posts')
@@ -708,6 +716,8 @@ http.createServer(statsHandler).listen(9090)
708
716
  // GET / → JSON stats
709
717
  ```
710
718
 
719
+ The built-in handler returns JSON with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` headers.
720
+
711
721
  Or use the Fastify plugin:
712
722
 
713
723
  ```ts
@@ -763,9 +773,11 @@ cache.on('error', ({ event, context }) => logger.error(event, context))
763
773
 
764
774
  ---
765
775
 
766
- ## Framework integrations
776
+ ## Integrations & tooling
767
777
 
768
- ### Express
778
+ ### Framework integrations
779
+
780
+ #### Express
769
781
 
770
782
  ```ts
771
783
  import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
@@ -785,7 +797,7 @@ app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
785
797
  }), handler)
786
798
  ```
787
799
 
788
- ### tRPC
800
+ #### tRPC
789
801
 
790
802
  ```ts
791
803
  import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
@@ -795,7 +807,7 @@ const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
795
807
  export const cachedProcedure = t.procedure.use(cacheMiddleware)
796
808
  ```
797
809
 
798
- ### GraphQL
810
+ #### GraphQL
799
811
 
800
812
  ```ts
801
813
  import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
@@ -812,7 +824,7 @@ const resolvers = {
812
824
 
813
825
  ---
814
826
 
815
- ## Admin CLI
827
+ ### Admin CLI
816
828
 
817
829
  Inspect and manage a Redis-backed cache without writing code.
818
830
 
@@ -1046,15 +1058,20 @@ new CacheStack([...], {
1046
1058
 
1047
1059
  ## Contributing
1048
1060
 
1061
+ Contributions are welcome, whether that means bug fixes, documentation improvements, performance work, new adapters, or issue reports.
1062
+
1049
1063
  ```bash
1050
1064
  git clone https://github.com/flyingsquirrel0419/layercache
1051
1065
  cd layercache
1052
1066
  npm install
1067
+ npm run lint
1053
1068
  npm test # vitest
1054
1069
  npm run build:all # esm + cjs + nestjs package
1055
1070
  ```
1056
1071
 
1057
- PRs and issues welcome.
1072
+ - Read the [contribution guide](./CONTRIBUTING.md) before opening a PR.
1073
+ - Participation in the project is covered by the [Code of Conduct](./CODE_OF_CONDUCT.md).
1074
+ - If you are filing an issue, include reproduction steps, expected behavior, and runtime details when relevant.
1058
1075
 
1059
1076
  ---
1060
1077
 
@@ -185,6 +185,7 @@ var MemoryLayer = class {
185
185
  };
186
186
 
187
187
  // src/invalidation/TagIndex.ts
188
+ var MAX_PATTERN_RECURSION_DEPTH = 500;
188
189
  var TagIndex = class {
189
190
  tagToKeys = /* @__PURE__ */ new Map();
190
191
  keyToTags = /* @__PURE__ */ new Map();
@@ -239,7 +240,7 @@ var TagIndex = class {
239
240
  }
240
241
  async matchPattern(pattern) {
241
242
  const matches = /* @__PURE__ */ new Set();
242
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
243
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
243
244
  return [...matches];
244
245
  }
245
246
  async clear() {
@@ -291,7 +292,10 @@ var TagIndex = class {
291
292
  this.collectFromNode(child, `${prefix}${character}`, matches);
292
293
  }
293
294
  }
294
- collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
295
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
296
+ if (depth > MAX_PATTERN_RECURSION_DEPTH) {
297
+ return;
298
+ }
295
299
  const stateKey = `${node.id}:${patternIndex}`;
296
300
  if (visited.has(stateKey)) {
297
301
  return;
@@ -308,21 +312,37 @@ var TagIndex = class {
308
312
  return;
309
313
  }
310
314
  if (patternChar === "*") {
311
- this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
315
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
312
316
  for (const [character, child2] of node.children) {
313
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
317
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
314
318
  }
315
319
  return;
316
320
  }
317
321
  if (patternChar === "?") {
318
322
  for (const [character, child2] of node.children) {
319
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
323
+ this.collectPatternMatches(
324
+ child2,
325
+ `${prefix}${character}`,
326
+ pattern,
327
+ patternIndex + 1,
328
+ matches,
329
+ visited,
330
+ depth + 1
331
+ );
320
332
  }
321
333
  return;
322
334
  }
323
335
  const child = node.children.get(patternChar);
324
336
  if (child) {
325
- this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
337
+ this.collectPatternMatches(
338
+ child,
339
+ `${prefix}${patternChar}`,
340
+ pattern,
341
+ patternIndex + 1,
342
+ matches,
343
+ visited,
344
+ depth + 1
345
+ );
326
346
  }
327
347
  }
328
348
  pruneKnownKeysIfNeeded() {
@@ -423,7 +443,7 @@ function normalizeUrl(url) {
423
443
  try {
424
444
  const parsed = new URL(url, "http://localhost");
425
445
  parsed.searchParams.sort();
426
- return decodeURIComponent(parsed.pathname) + parsed.search;
446
+ return parsed.pathname + parsed.search;
427
447
  } catch {
428
448
  return url;
429
449
  }
package/dist/cli.cjs CHANGED
@@ -281,10 +281,7 @@ async function main(argv = process.argv.slice(2)) {
281
281
  }
282
282
  const redisUrl = validateRedisUrl(args.redisUrl);
283
283
  if (!redisUrl) {
284
- process.stderr.write(
285
- `Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
286
- `
287
- );
284
+ process.stderr.write("Error: invalid Redis URL. Expected format: redis://[user:password@]host[:port][/db]\n");
288
285
  process.exitCode = 1;
289
286
  return;
290
287
  }
@@ -296,7 +293,7 @@ async function main(argv = process.argv.slice(2)) {
296
293
  try {
297
294
  await redis.connect().catch((error) => {
298
295
  const message = error instanceof Error ? error.message : String(error);
299
- throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
296
+ throw new Error(`Failed to connect to Redis: ${message}`);
300
297
  });
301
298
  if (args.command === "stats") {
302
299
  const keys = await scanKeys(redis, args.pattern ?? "*");
@@ -451,17 +448,6 @@ function summarizeInspectableValue(value) {
451
448
  }
452
449
  return value;
453
450
  }
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
- }
465
451
  if (process.argv[1]?.includes("cli.")) {
466
452
  void main();
467
453
  }
package/dist/cli.js CHANGED
@@ -19,10 +19,7 @@ async function main(argv = process.argv.slice(2)) {
19
19
  }
20
20
  const redisUrl = validateRedisUrl(args.redisUrl);
21
21
  if (!redisUrl) {
22
- process.stderr.write(
23
- `Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
24
- `
25
- );
22
+ process.stderr.write("Error: invalid Redis URL. Expected format: redis://[user:password@]host[:port][/db]\n");
26
23
  process.exitCode = 1;
27
24
  return;
28
25
  }
@@ -34,7 +31,7 @@ async function main(argv = process.argv.slice(2)) {
34
31
  try {
35
32
  await redis.connect().catch((error) => {
36
33
  const message = error instanceof Error ? error.message : String(error);
37
- throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
34
+ throw new Error(`Failed to connect to Redis: ${message}`);
38
35
  });
39
36
  if (args.command === "stats") {
40
37
  const keys = await scanKeys(redis, args.pattern ?? "*");
@@ -189,17 +186,6 @@ function summarizeInspectableValue(value) {
189
186
  }
190
187
  return value;
191
188
  }
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
- }
203
189
  if (process.argv[1]?.includes("cli.")) {
204
190
  void main();
205
191
  }
@@ -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;
@@ -664,6 +663,7 @@ declare class CacheStack extends EventEmitter {
664
663
  private validateRateLimitOptions;
665
664
  private validateNonNegativeNumber;
666
665
  private validateCacheKey;
666
+ private validatePattern;
667
667
  private validateTtlPolicy;
668
668
  private assertActive;
669
669
  private awaitStartup;
@@ -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;
@@ -664,6 +663,7 @@ declare class CacheStack extends EventEmitter {
664
663
  private validateRateLimitOptions;
665
664
  private validateNonNegativeNumber;
666
665
  private validateCacheKey;
666
+ private validatePattern;
667
667
  private validateTtlPolicy;
668
668
  private assertActive;
669
669
  private awaitStartup;
package/dist/edge.cjs CHANGED
@@ -295,6 +295,7 @@ var PatternMatcher = class _PatternMatcher {
295
295
  };
296
296
 
297
297
  // src/invalidation/TagIndex.ts
298
+ var MAX_PATTERN_RECURSION_DEPTH = 500;
298
299
  var TagIndex = class {
299
300
  tagToKeys = /* @__PURE__ */ new Map();
300
301
  keyToTags = /* @__PURE__ */ new Map();
@@ -349,7 +350,7 @@ var TagIndex = class {
349
350
  }
350
351
  async matchPattern(pattern) {
351
352
  const matches = /* @__PURE__ */ new Set();
352
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
353
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
353
354
  return [...matches];
354
355
  }
355
356
  async clear() {
@@ -401,7 +402,10 @@ var TagIndex = class {
401
402
  this.collectFromNode(child, `${prefix}${character}`, matches);
402
403
  }
403
404
  }
404
- collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
405
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
406
+ if (depth > MAX_PATTERN_RECURSION_DEPTH) {
407
+ return;
408
+ }
405
409
  const stateKey = `${node.id}:${patternIndex}`;
406
410
  if (visited.has(stateKey)) {
407
411
  return;
@@ -418,21 +422,37 @@ var TagIndex = class {
418
422
  return;
419
423
  }
420
424
  if (patternChar === "*") {
421
- this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
425
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
422
426
  for (const [character, child2] of node.children) {
423
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
427
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
424
428
  }
425
429
  return;
426
430
  }
427
431
  if (patternChar === "?") {
428
432
  for (const [character, child2] of node.children) {
429
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
433
+ this.collectPatternMatches(
434
+ child2,
435
+ `${prefix}${character}`,
436
+ pattern,
437
+ patternIndex + 1,
438
+ matches,
439
+ visited,
440
+ depth + 1
441
+ );
430
442
  }
431
443
  return;
432
444
  }
433
445
  const child = node.children.get(patternChar);
434
446
  if (child) {
435
- this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
447
+ this.collectPatternMatches(
448
+ child,
449
+ `${prefix}${patternChar}`,
450
+ pattern,
451
+ patternIndex + 1,
452
+ matches,
453
+ visited,
454
+ depth + 1
455
+ );
436
456
  }
437
457
  }
438
458
  pruneKnownKeysIfNeeded() {
@@ -533,7 +553,7 @@ function normalizeUrl(url) {
533
553
  try {
534
554
  const parsed = new URL(url, "http://localhost");
535
555
  parsed.searchParams.sort();
536
- return decodeURIComponent(parsed.pathname) + parsed.search;
556
+ return parsed.pathname + parsed.search;
537
557
  } catch {
538
558
  return url;
539
559
  }
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-P07GCO2Y.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-P07GCO2Y.js';
2
2
  import 'node:events';
package/dist/edge.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  MemoryLayer,
3
3
  TagIndex,
4
4
  createHonoCacheMiddleware
5
- } from "./chunk-KOYGHLVP.js";
5
+ } from "./chunk-JC26W3KK.js";
6
6
  import {
7
7
  PatternMatcher
8
8
  } from "./chunk-7V7XAB74.js";