layercache 1.2.2 → 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-164%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
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 ≥ 18
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,9 +173,11 @@ 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
- Deletes every key that was stored with this tag across all layers.
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`).
177
181
 
178
182
  ```ts
179
183
  await cache.set('user:123', user, { tags: ['user:123'] })
@@ -193,12 +197,14 @@ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with e
193
197
 
194
198
  ### `cache.invalidateByPattern(pattern): Promise<void>`
195
199
 
196
- Glob-style deletion against the tracked key set.
200
+ Glob-style deletion against the tracked key set, plus any layer that can enumerate real keys (for example `MemoryLayer`, `RedisLayer`, or `DiskLayer`).
197
201
 
198
202
  ```ts
199
203
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
200
204
  ```
201
205
 
206
+ 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.
207
+
202
208
  ### `cache.invalidateByPrefix(prefix): Promise<void>`
203
209
 
204
210
  Prefer this over glob invalidation when your keys are hierarchical.
@@ -280,6 +286,17 @@ await cache.set('user:123', user)
280
286
  cache.bumpGeneration() // now reads use v2:user:123
281
287
  ```
282
288
 
289
+ If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
290
+
291
+ ```ts
292
+ const cache = new CacheStack([...], {
293
+ generation: 1,
294
+ generationCleanup: { batchSize: 500 }
295
+ })
296
+ ```
297
+
298
+ `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.
299
+
283
300
  ### OpenTelemetry note
284
301
 
285
302
  `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 +499,10 @@ const cache = new CacheStack(
482
499
  await cache.disconnect() // unsubscribes cleanly on shutdown
483
500
  ```
484
501
 
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):
502
+ 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
503
 
487
504
  ```ts
488
- new CacheStack([...], { invalidationBus: bus, publishSetInvalidation: false })
505
+ new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
489
506
  ```
490
507
 
491
508
  ### Distributed tag invalidation
@@ -510,6 +527,8 @@ const cache = new CacheStack(
510
527
 
511
528
  Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
512
529
 
530
+ 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()`.
531
+
513
532
  ### Safe Redis clearing
514
533
 
515
534
  `RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
@@ -622,6 +641,8 @@ await cache.get('leaderboard', fetchLeaderboard, {
622
641
  })
623
642
  ```
624
643
 
644
+ Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
645
+
625
646
  ---
626
647
 
627
648
  ## Graceful degradation & circuit breaker
@@ -718,6 +739,8 @@ await cache.persistToFile('./cache-snapshot.json')
718
739
  await cache.restoreFromFile('./cache-snapshot.json')
719
740
  ```
720
741
 
742
+ 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.
743
+
721
744
  ---
722
745
 
723
746
  ## Event hooks
@@ -744,9 +767,11 @@ cache.on('error', ({ event, context }) => logger.error(event, context))
744
767
 
745
768
  ---
746
769
 
747
- ## Framework integrations
770
+ ## Integrations & tooling
771
+
772
+ ### Framework integrations
748
773
 
749
- ### Express
774
+ #### Express
750
775
 
751
776
  ```ts
752
777
  import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
@@ -766,7 +791,7 @@ app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
766
791
  }), handler)
767
792
  ```
768
793
 
769
- ### tRPC
794
+ #### tRPC
770
795
 
771
796
  ```ts
772
797
  import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
@@ -776,7 +801,7 @@ const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
776
801
  export const cachedProcedure = t.procedure.use(cacheMiddleware)
777
802
  ```
778
803
 
779
- ### GraphQL
804
+ #### GraphQL
780
805
 
781
806
  ```ts
782
807
  import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
@@ -793,7 +818,7 @@ const resolvers = {
793
818
 
794
819
  ---
795
820
 
796
- ## Admin CLI
821
+ ### Admin CLI
797
822
 
798
823
  Inspect and manage a Redis-backed cache without writing code.
799
824
 
@@ -1019,7 +1044,7 @@ new CacheStack([...], {
1019
1044
 
1020
1045
  ## Requirements
1021
1046
 
1022
- - Node.js ≥ 18
1047
+ - Node.js ≥ 20
1023
1048
  - TypeScript ≥ 5.0 (optional — fully typed, ships `.d.ts`)
1024
1049
  - ioredis ≥ 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
1025
1050
 
@@ -1027,15 +1052,20 @@ new CacheStack([...], {
1027
1052
 
1028
1053
  ## Contributing
1029
1054
 
1055
+ Contributions are welcome, whether that means bug fixes, documentation improvements, performance work, new adapters, or issue reports.
1056
+
1030
1057
  ```bash
1031
1058
  git clone https://github.com/flyingsquirrel0419/layercache
1032
1059
  cd layercache
1033
1060
  npm install
1061
+ npm run lint
1034
1062
  npm test # vitest
1035
1063
  npm run build:all # esm + cjs + nestjs package
1036
1064
  ```
1037
1065
 
1038
- 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.
1039
1069
 
1040
1070
  ---
1041
1071
 
@@ -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 {