layercache 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-49%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
+ [![test coverage](https://img.shields.io/badge/tests-158%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
10
10
 
11
11
  ```
12
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -40,15 +40,22 @@ On a hit, the value is returned from the fastest layer that has it, and automati
40
40
  - **Layered reads & automatic backfill** — hits in slower layers propagate up
41
41
  - **Cache stampede prevention** — mutex-based deduplication per key
42
42
  - **Tag-based invalidation** — `set('user:123:posts', posts, { tags: ['user:123'] })` then `invalidateByTag('user:123')`
43
+ - **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
43
44
  - **Pattern invalidation** — `invalidateByPattern('user:*')`
45
+ - **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
46
+ - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
44
47
  - **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
48
+ - **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
45
49
  - **Negative caching** — cache known misses for a short TTL to protect the database
46
50
  - **Stale strategies** — `staleWhileRevalidate` and `staleIfError` as opt-in read behavior
47
51
  - **TTL jitter** — spread expirations to avoid synchronized stampedes
48
52
  - **Sliding & adaptive TTL** — extend TTL on every read or ramp it up for hot keys
49
53
  - **Refresh-ahead** — trigger background refresh when TTL drops below a threshold
54
+ - **Fetcher rate limiting** — cap concurrent fetchers or requests per interval
50
55
  - **Best-effort writes** — tolerate partial layer write failures when desired
56
+ - **Write-behind mode** — write local layers immediately and flush slower remote layers asynchronously
51
57
  - **Bulk reads** — `mget` uses layer-level `getMany()` when available
58
+ - **Bulk writes** — `mset` uses layer-level `setMany()` when available
52
59
  - **Distributed tag index** — `RedisTagIndex` keeps tag state consistent across multiple servers
53
60
  - **Optional distributed single-flight** — plug in a coordinator to dedupe misses across instances
54
61
  - **Cross-server L1 invalidation** — Redis pub/sub bus flushes stale memory on other instances when you write or delete
@@ -58,14 +65,23 @@ On a hit, the value is returned from the fastest layer that has it, and automati
58
65
  - **Event hooks** — `EventEmitter`-based events for hits, misses, stale serves, errors, and more
59
66
  - **Graceful degradation** — skip a failing layer for a configurable retry window
60
67
  - **Circuit breaker** — per-key or global; opens after N failures, recovers after cooldown
61
- - **Compression** — transparent gzip/brotli in `RedisLayer` with a byte threshold
62
- - **Metrics & stats** — per-layer hit/miss counters, circuit-breaker trips, degraded operations; HTTP stats handler
68
+ - **Compression** — transparent async gzip/brotli in `RedisLayer` (non-blocking) with a byte threshold
69
+ - **Serializer fallback chains** — transparently read legacy payloads (for example JSON) and rewrite them with the primary serializer
70
+ - **Metrics & stats** — per-layer hit/miss counters, **per-layer latency tracking**, circuit-breaker trips, degraded operations; HTTP stats handler
71
+ - **Health checks** — `cache.healthCheck()` returns per-layer health and latency
63
72
  - **Persistence** — `exportState` / `importState` for in-process snapshots; `persistToFile` / `restoreFromFile` for disk
64
- - **Admin CLI** — `layercache stats | keys | invalidate` against any Redis URL
65
- - **Framework integrations** — Fastify plugin, tRPC middleware, GraphQL resolver wrapper
73
+ - **Admin CLI** — `layercache stats | keys | inspect | invalidate` against any Redis URL
74
+ - **Framework integrations** — Express middleware, Fastify plugin, Hono middleware, tRPC middleware, GraphQL resolver wrapper
75
+ - **OpenTelemetry plugin** — instrument `get` / `set` / invalidation flows with spans
66
76
  - **MessagePack serializer** — drop-in replacement for lower Redis memory usage
67
- - **NestJS module** — `CacheStackModule.forRoot(...)` with `@InjectCacheStack()`
77
+ - **NestJS module** — `CacheStackModule.forRoot(...)` and `forRootAsync(...)` with `@InjectCacheStack()`
78
+ - **`getOrThrow()`** — throws `CacheMissError` instead of returning `null`, for strict use cases
79
+ - **`inspect()`** — debug a key: see which layers hold it, remaining TTLs, tags, and staleness state
80
+ - **MemoryLayer cleanup hooks** — periodic TTL cleanup and `onEvict` callbacks
81
+ - **Conditional caching** — `shouldCache` predicate to skip caching specific fetcher results
82
+ - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
68
83
  - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
84
+ - **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
69
85
  - **ESM + CJS** — works with both module systems, Node.js ≥ 18
70
86
 
71
87
  ---
@@ -164,6 +180,15 @@ await cache.set('user:123:posts', posts, { tags: ['user:123'] })
164
180
  await cache.invalidateByTag('user:123') // both keys gone
165
181
  ```
166
182
 
183
+ ### `cache.invalidateByTags(tags, mode?): Promise<void>`
184
+
185
+ Delete keys that match any or all of a set of tags.
186
+
187
+ ```ts
188
+ await cache.invalidateByTags(['tenant:a', 'users'], 'all') // keys tagged with both
189
+ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with either
190
+ ```
191
+
167
192
  ### `cache.invalidateByPattern(pattern): Promise<void>`
168
193
 
169
194
  Glob-style deletion against the tracked key set.
@@ -172,6 +197,16 @@ Glob-style deletion against the tracked key set.
172
197
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
173
198
  ```
174
199
 
200
+ ### `cache.invalidateByPrefix(prefix): Promise<void>`
201
+
202
+ Prefer this over glob invalidation when your keys are hierarchical.
203
+
204
+ ```ts
205
+ await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
206
+ ```
207
+
208
+ The prefix is matched as-is. You do not need to append `*`, and namespace helpers pass their namespace prefix directly.
209
+
175
210
  ### `cache.mget<T>(entries): Promise<Array<T | null>>`
176
211
 
177
212
  Concurrent multi-key fetch, each with its own optional fetcher.
@@ -191,6 +226,13 @@ const [user1, user2] = await cache.mget([
191
226
  const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
192
227
  ```
193
228
 
229
+ ### `cache.healthCheck(): Promise<CacheHealthCheckResult[]>`
230
+
231
+ ```ts
232
+ const health = await cache.healthCheck()
233
+ // [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
234
+ ```
235
+
194
236
  ### `cache.resetMetrics(): void`
195
237
 
196
238
  Resets all counters to zero — useful for per-interval reporting.
@@ -225,6 +267,21 @@ const getUser = cache.wrap(
225
267
  )
226
268
  ```
227
269
 
270
+ ### Generation-based invalidation
271
+
272
+ Add a generation prefix to every key and rotate it when you want to invalidate the whole cache namespace without scanning:
273
+
274
+ ```ts
275
+ const cache = new CacheStack([...], { generation: 1 })
276
+
277
+ await cache.set('user:123', user)
278
+ cache.bumpGeneration() // now reads use v2:user:123
279
+ ```
280
+
281
+ ### OpenTelemetry note
282
+
283
+ `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.
284
+
228
285
  ### `cache.warm(entries, options?)`
229
286
 
230
287
  Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
@@ -242,7 +299,7 @@ await cache.warm(
242
299
 
243
300
  ### `cache.namespace(prefix): CacheNamespace`
244
301
 
245
- Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys.
302
+ 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.
246
303
 
247
304
  ```ts
248
305
  const users = cache.namespace('users')
@@ -250,6 +307,68 @@ const posts = cache.namespace('posts')
250
307
 
251
308
  await users.set('123', userData) // stored as "users:123"
252
309
  await users.clear() // only deletes "users:*"
310
+
311
+ // Nested namespaces
312
+ const tenant = cache.namespace('tenant:abc')
313
+ const posts = tenant.namespace('posts')
314
+ await posts.set('1', postData) // stored as "tenant:abc:posts:1"
315
+ ```
316
+
317
+ ### `cache.getOrThrow<T>(key, fetcher?, options?): Promise<T>`
318
+
319
+ Like `get()`, but throws `CacheMissError` instead of returning `null`. Useful when you know the value must exist (e.g. after a warm-up).
320
+
321
+ ```ts
322
+ import { CacheMissError } from 'layercache'
323
+
324
+ try {
325
+ const config = await cache.getOrThrow<Config>('app:config')
326
+ } catch (err) {
327
+ if (err instanceof CacheMissError) {
328
+ console.error(`Missing key: ${err.key}`)
329
+ }
330
+ }
331
+ ```
332
+
333
+ ### `cache.inspect(key): Promise<CacheInspectResult | null>`
334
+
335
+ Returns detailed metadata about a cache key for debugging. Returns `null` if the key is not in any layer.
336
+
337
+ ```ts
338
+ const info = await cache.inspect('user:123')
339
+ // {
340
+ // key: 'user:123',
341
+ // foundInLayers: ['memory', 'redis'],
342
+ // freshTtlSeconds: 45,
343
+ // staleTtlSeconds: 75,
344
+ // errorTtlSeconds: 345,
345
+ // isStale: false,
346
+ // tags: ['user', 'user:123']
347
+ // }
348
+ ```
349
+
350
+ ### Conditional caching with `shouldCache`
351
+
352
+ Skip caching specific results without affecting the return value:
353
+
354
+ ```ts
355
+ const data = await cache.get('api:response', fetchFromApi, {
356
+ shouldCache: (value) => (value as any).status === 200
357
+ })
358
+ // If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
359
+ ```
360
+
361
+ ### TTL policies
362
+
363
+ Align expirations to calendar or boundary-based schedules:
364
+
365
+ ```ts
366
+ await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
367
+ await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
368
+ await cache.set('aligned', value, { ttlPolicy: { alignTo: 300 } }) // next 5-minute boundary
369
+ await cache.set('custom', value, {
370
+ ttlPolicy: ({ key, value }) => key.startsWith('hot:') ? 30 : 300
371
+ })
253
372
  ```
254
373
 
255
374
  ---
@@ -591,6 +710,26 @@ cache.on('error', ({ event, context }) => logger.error(event, context))
591
710
 
592
711
  ## Framework integrations
593
712
 
713
+ ### Express
714
+
715
+ ```ts
716
+ import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
717
+
718
+ const cache = new CacheStack([new MemoryLayer({ ttl: 60 })])
719
+
720
+ // Automatically caches GET responses as JSON
721
+ app.get('/api/users', createExpressCacheMiddleware(cache, { ttl: 30 }), (req, res) => {
722
+ res.json(await db.getUsers())
723
+ })
724
+
725
+ // Custom key resolver + tag support
726
+ app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
727
+ keyResolver: (req) => `user:${req.url}`,
728
+ tags: ['users'],
729
+ ttl: 60
730
+ }), handler)
731
+ ```
732
+
594
733
  ### tRPC
595
734
 
596
735
  ```ts
@@ -699,6 +838,25 @@ import { CacheStackModule } from '@cachestack/nestjs'
699
838
  export class AppModule {}
700
839
  ```
701
840
 
841
+ Async configuration (resolve dependencies from DI):
842
+
843
+ ```ts
844
+ @Module({
845
+ imports: [
846
+ CacheStackModule.forRootAsync({
847
+ inject: [ConfigService],
848
+ useFactory: (config: ConfigService) => ({
849
+ layers: [
850
+ new MemoryLayer({ ttl: 20 }),
851
+ new RedisLayer({ client: new Redis(config.get('REDIS_URL')), ttl: 300 })
852
+ ]
853
+ })
854
+ })
855
+ ]
856
+ })
857
+ export class AppModule {}
858
+ ```
859
+
702
860
  ```ts
703
861
  // your.service.ts
704
862
  import { InjectCacheStack } from '@cachestack/nestjs'
@@ -0,0 +1,312 @@
1
+ import {
2
+ PatternMatcher,
3
+ unwrapStoredValue
4
+ } from "./chunk-ZMDB5KOK.js";
5
+
6
+ // src/layers/MemoryLayer.ts
7
+ var MemoryLayer = class {
8
+ name;
9
+ defaultTtl;
10
+ isLocal = true;
11
+ maxSize;
12
+ evictionPolicy;
13
+ onEvict;
14
+ entries = /* @__PURE__ */ new Map();
15
+ cleanupTimer;
16
+ constructor(options = {}) {
17
+ this.name = options.name ?? "memory";
18
+ this.defaultTtl = options.ttl;
19
+ this.maxSize = options.maxSize ?? 1e3;
20
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
21
+ this.onEvict = options.onEvict;
22
+ if (options.cleanupIntervalMs && options.cleanupIntervalMs > 0) {
23
+ this.cleanupTimer = setInterval(() => {
24
+ this.pruneExpired();
25
+ }, options.cleanupIntervalMs);
26
+ this.cleanupTimer.unref?.();
27
+ }
28
+ }
29
+ async get(key) {
30
+ const value = await this.getEntry(key);
31
+ return unwrapStoredValue(value);
32
+ }
33
+ async getEntry(key) {
34
+ const entry = this.entries.get(key);
35
+ if (!entry) {
36
+ return null;
37
+ }
38
+ if (this.isExpired(entry)) {
39
+ this.entries.delete(key);
40
+ return null;
41
+ }
42
+ if (this.evictionPolicy === "lru") {
43
+ this.entries.delete(key);
44
+ entry.accessCount += 1;
45
+ this.entries.set(key, entry);
46
+ } else if (this.evictionPolicy === "lfu") {
47
+ entry.accessCount += 1;
48
+ }
49
+ return entry.value;
50
+ }
51
+ async getMany(keys) {
52
+ const values = [];
53
+ for (const key of keys) {
54
+ values.push(await this.getEntry(key));
55
+ }
56
+ return values;
57
+ }
58
+ async setMany(entries) {
59
+ for (const entry of entries) {
60
+ await this.set(entry.key, entry.value, entry.ttl);
61
+ }
62
+ }
63
+ async set(key, value, ttl = this.defaultTtl) {
64
+ this.entries.delete(key);
65
+ this.entries.set(key, {
66
+ value,
67
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
68
+ accessCount: 0,
69
+ insertedAt: Date.now()
70
+ });
71
+ while (this.entries.size > this.maxSize) {
72
+ this.evict();
73
+ }
74
+ }
75
+ async has(key) {
76
+ const entry = this.entries.get(key);
77
+ if (!entry) {
78
+ return false;
79
+ }
80
+ if (this.isExpired(entry)) {
81
+ this.entries.delete(key);
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ async ttl(key) {
87
+ const entry = this.entries.get(key);
88
+ if (!entry) {
89
+ return null;
90
+ }
91
+ if (this.isExpired(entry)) {
92
+ this.entries.delete(key);
93
+ return null;
94
+ }
95
+ if (entry.expiresAt === null) {
96
+ return null;
97
+ }
98
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
99
+ }
100
+ async size() {
101
+ this.pruneExpired();
102
+ return this.entries.size;
103
+ }
104
+ async delete(key) {
105
+ this.entries.delete(key);
106
+ }
107
+ async deleteMany(keys) {
108
+ for (const key of keys) {
109
+ this.entries.delete(key);
110
+ }
111
+ }
112
+ async clear() {
113
+ this.entries.clear();
114
+ }
115
+ async ping() {
116
+ return true;
117
+ }
118
+ async dispose() {
119
+ if (this.cleanupTimer) {
120
+ clearInterval(this.cleanupTimer);
121
+ this.cleanupTimer = void 0;
122
+ }
123
+ }
124
+ async keys() {
125
+ this.pruneExpired();
126
+ return [...this.entries.keys()];
127
+ }
128
+ exportState() {
129
+ this.pruneExpired();
130
+ return [...this.entries.entries()].map(([key, entry]) => ({
131
+ key,
132
+ value: entry.value,
133
+ expiresAt: entry.expiresAt
134
+ }));
135
+ }
136
+ importState(entries) {
137
+ for (const entry of entries) {
138
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
139
+ continue;
140
+ }
141
+ this.entries.set(entry.key, {
142
+ value: entry.value,
143
+ expiresAt: entry.expiresAt,
144
+ accessCount: 0,
145
+ insertedAt: Date.now()
146
+ });
147
+ }
148
+ while (this.entries.size > this.maxSize) {
149
+ this.evict();
150
+ }
151
+ }
152
+ evict() {
153
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
154
+ const oldestKey = this.entries.keys().next().value;
155
+ if (oldestKey !== void 0) {
156
+ const entry = this.entries.get(oldestKey);
157
+ this.entries.delete(oldestKey);
158
+ if (entry) {
159
+ this.onEvict?.(oldestKey, unwrapStoredValue(entry.value));
160
+ }
161
+ }
162
+ return;
163
+ }
164
+ let victimKey;
165
+ let minCount = Number.POSITIVE_INFINITY;
166
+ let minInsertedAt = Number.POSITIVE_INFINITY;
167
+ for (const [key, entry] of this.entries.entries()) {
168
+ if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
169
+ minCount = entry.accessCount;
170
+ minInsertedAt = entry.insertedAt;
171
+ victimKey = key;
172
+ }
173
+ }
174
+ if (victimKey !== void 0) {
175
+ const victim = this.entries.get(victimKey);
176
+ this.entries.delete(victimKey);
177
+ if (victim) {
178
+ this.onEvict?.(victimKey, unwrapStoredValue(victim.value));
179
+ }
180
+ }
181
+ }
182
+ pruneExpired() {
183
+ for (const [key, entry] of this.entries.entries()) {
184
+ if (this.isExpired(entry)) {
185
+ this.entries.delete(key);
186
+ }
187
+ }
188
+ }
189
+ isExpired(entry) {
190
+ return entry.expiresAt !== null && entry.expiresAt <= Date.now();
191
+ }
192
+ };
193
+
194
+ // src/invalidation/TagIndex.ts
195
+ var TagIndex = class {
196
+ tagToKeys = /* @__PURE__ */ new Map();
197
+ keyToTags = /* @__PURE__ */ new Map();
198
+ knownKeys = /* @__PURE__ */ new Set();
199
+ maxKnownKeys;
200
+ constructor(options = {}) {
201
+ this.maxKnownKeys = options.maxKnownKeys;
202
+ }
203
+ async touch(key) {
204
+ this.knownKeys.add(key);
205
+ this.pruneKnownKeysIfNeeded();
206
+ }
207
+ async track(key, tags) {
208
+ this.knownKeys.add(key);
209
+ this.pruneKnownKeysIfNeeded();
210
+ if (tags.length === 0) {
211
+ return;
212
+ }
213
+ const existingTags = this.keyToTags.get(key);
214
+ if (existingTags) {
215
+ for (const tag of existingTags) {
216
+ this.tagToKeys.get(tag)?.delete(key);
217
+ }
218
+ }
219
+ const tagSet = new Set(tags);
220
+ this.keyToTags.set(key, tagSet);
221
+ for (const tag of tagSet) {
222
+ const keys = this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set();
223
+ keys.add(key);
224
+ this.tagToKeys.set(tag, keys);
225
+ }
226
+ }
227
+ async remove(key) {
228
+ this.removeKey(key);
229
+ }
230
+ async keysForTag(tag) {
231
+ return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
232
+ }
233
+ async keysForPrefix(prefix) {
234
+ return [...this.knownKeys].filter((key) => key.startsWith(prefix));
235
+ }
236
+ async tagsForKey(key) {
237
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
238
+ }
239
+ async matchPattern(pattern) {
240
+ return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
241
+ }
242
+ async clear() {
243
+ this.tagToKeys.clear();
244
+ this.keyToTags.clear();
245
+ this.knownKeys.clear();
246
+ }
247
+ pruneKnownKeysIfNeeded() {
248
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
249
+ return;
250
+ }
251
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
252
+ let removed = 0;
253
+ for (const key of this.knownKeys) {
254
+ if (removed >= toRemove) {
255
+ break;
256
+ }
257
+ this.removeKey(key);
258
+ removed += 1;
259
+ }
260
+ }
261
+ removeKey(key) {
262
+ this.knownKeys.delete(key);
263
+ const tags = this.keyToTags.get(key);
264
+ if (!tags) {
265
+ return;
266
+ }
267
+ for (const tag of tags) {
268
+ const keys = this.tagToKeys.get(tag);
269
+ if (!keys) {
270
+ continue;
271
+ }
272
+ keys.delete(key);
273
+ if (keys.size === 0) {
274
+ this.tagToKeys.delete(tag);
275
+ }
276
+ }
277
+ this.keyToTags.delete(key);
278
+ }
279
+ };
280
+
281
+ // src/integrations/hono.ts
282
+ function createHonoCacheMiddleware(cache, options = {}) {
283
+ const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
284
+ return async (context, next) => {
285
+ const method = (context.req.method ?? "GET").toUpperCase();
286
+ if (!allowedMethods.has(method)) {
287
+ await next();
288
+ return;
289
+ }
290
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
291
+ const cached = await cache.get(key, void 0, options);
292
+ if (cached !== null) {
293
+ context.header?.("x-cache", "HIT");
294
+ context.header?.("content-type", "application/json; charset=utf-8");
295
+ context.json(cached);
296
+ return;
297
+ }
298
+ const originalJson = context.json.bind(context);
299
+ context.json = (body, status) => {
300
+ context.header?.("x-cache", "MISS");
301
+ void cache.set(key, body, options);
302
+ return originalJson(body, status);
303
+ };
304
+ await next();
305
+ };
306
+ }
307
+
308
+ export {
309
+ MemoryLayer,
310
+ TagIndex,
311
+ createHonoCacheMiddleware
312
+ };
@@ -1,40 +1,6 @@
1
- // src/invalidation/PatternMatcher.ts
2
- var PatternMatcher = class _PatternMatcher {
3
- /**
4
- * Tests whether a glob-style pattern matches a value.
5
- * Supports `*` (any sequence of characters) and `?` (any single character).
6
- * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
7
- */
8
- static matches(pattern, value) {
9
- return _PatternMatcher.matchLinear(pattern, value);
10
- }
11
- /**
12
- * Linear-time glob matching using dynamic programming.
13
- * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
14
- */
15
- static matchLinear(pattern, value) {
16
- const m = pattern.length;
17
- const n = value.length;
18
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
19
- dp[0][0] = true;
20
- for (let i = 1; i <= m; i++) {
21
- if (pattern[i - 1] === "*") {
22
- dp[i][0] = dp[i - 1]?.[0];
23
- }
24
- }
25
- for (let i = 1; i <= m; i++) {
26
- for (let j = 1; j <= n; j++) {
27
- const pc = pattern[i - 1];
28
- if (pc === "*") {
29
- dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
30
- } else if (pc === "?" || pc === value[j - 1]) {
31
- dp[i][j] = dp[i - 1]?.[j - 1];
32
- }
33
- }
34
- }
35
- return dp[m]?.[n];
36
- }
37
- };
1
+ import {
2
+ PatternMatcher
3
+ } from "./chunk-ZMDB5KOK.js";
38
4
 
39
5
  // src/invalidation/RedisTagIndex.ts
40
6
  var RedisTagIndex = class {
@@ -80,6 +46,19 @@ var RedisTagIndex = class {
80
46
  async keysForTag(tag) {
81
47
  return this.client.smembers(this.tagKeysKey(tag));
82
48
  }
49
+ async keysForPrefix(prefix) {
50
+ const matches = [];
51
+ let cursor = "0";
52
+ do {
53
+ const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
54
+ cursor = nextCursor;
55
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
56
+ } while (cursor !== "0");
57
+ return matches;
58
+ }
59
+ async tagsForKey(key) {
60
+ return this.client.smembers(this.keyTagsKey(key));
61
+ }
83
62
  async matchPattern(pattern) {
84
63
  const matches = [];
85
64
  let cursor = "0";
@@ -127,6 +106,5 @@ var RedisTagIndex = class {
127
106
  };
128
107
 
129
108
  export {
130
- PatternMatcher,
131
109
  RedisTagIndex
132
110
  };