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 +165 -7
- package/dist/chunk-46UH7LNM.js +312 -0
- package/dist/{chunk-QUB5VZFZ.js → chunk-GF47Y3XR.js} +16 -38
- package/dist/chunk-ZMDB5KOK.js +159 -0
- package/dist/cli.cjs +133 -23
- package/dist/cli.js +66 -4
- package/dist/edge-C1sBhTfv.d.cts +667 -0
- package/dist/edge-C1sBhTfv.d.ts +667 -0
- package/dist/edge.cjs +399 -0
- package/dist/edge.d.cts +2 -0
- package/dist/edge.d.ts +2 -0
- package/dist/edge.js +14 -0
- package/dist/index.cjs +1259 -192
- package/dist/index.d.cts +132 -480
- package/dist/index.d.ts +132 -480
- package/dist/index.js +1115 -474
- package/package.json +7 -2
- package/packages/nestjs/dist/index.cjs +1025 -327
- package/packages/nestjs/dist/index.d.cts +167 -1
- package/packages/nestjs/dist/index.d.ts +167 -1
- package/packages/nestjs/dist/index.js +1013 -325
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/layercache)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.typescriptlang.org/)
|
|
9
|
-
[](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
|
-
- **
|
|
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
|
-
|
|
2
|
-
|
|
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
|
};
|