layercache 1.2.5 → 1.2.7
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/LICENSE +190 -21
- package/README.md +254 -912
- package/dist/{chunk-7V7XAB74.js → chunk-4PPBOOXT.js} +37 -3
- package/dist/{chunk-QHWG7QS5.js → chunk-BQLL6IM5.js} +47 -1
- package/dist/{chunk-JC26W3KK.js → chunk-GJBKCFE6.js} +38 -3
- package/dist/cli.cjs +83 -3
- package/dist/cli.js +2 -2
- package/dist/{edge-P07GCO2Y.d.ts → edge-BMmPVqaD.d.cts} +28 -21
- package/dist/{edge-P07GCO2Y.d.cts → edge-BMmPVqaD.d.ts} +28 -21
- package/dist/edge.cjs +74 -5
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +1671 -837
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +1327 -608
- package/package.json +29 -3
- package/packages/nestjs/dist/index.cjs +1296 -732
- package/packages/nestjs/dist/index.d.cts +19 -20
- package/packages/nestjs/dist/index.d.ts +19 -20
- package/packages/nestjs/dist/index.js +1296 -732
package/README.md
CHANGED
|
@@ -5,121 +5,87 @@
|
|
|
5
5
|
<h1 align="center">layercache</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
9
|
-
<em>
|
|
8
|
+
<strong>The multi-layer caching toolkit that Node.js deserves.</strong><br>
|
|
9
|
+
<em>Stack memory + Redis + disk. One API. Zero stampedes.</em>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
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/
|
|
16
|
-
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-
|
|
17
|
-
<
|
|
13
|
+
<a href="https://www.npmjs.com/package/layercache"><img src="https://img.shields.io/npm/v/layercache?color=cb3837&label=npm" alt="npm version"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/layercache"><img src="https://img.shields.io/npm/dw/layercache?color=blue" alt="npm downloads"></a>
|
|
15
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache_2.0-green" alt="license"></a>
|
|
16
|
+
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-3178C6?logo=typescript&logoColor=white" alt="TypeScript"></a>
|
|
17
|
+
<img src="https://img.shields.io/badge/Node.js-%E2%89%A5_20-339933?logo=nodedotjs&logoColor=white" alt="Node.js >= 20">
|
|
18
|
+
<img src="https://img.shields.io/badge/tests-393_passing-brightgreen" alt="tests">
|
|
19
|
+
<a href="https://coveralls.io/github/flyingsquirrel0419/layercache?branch=main"><img src="https://coveralls.io/repos/github/flyingsquirrel0419/layercache/badge.svg?branch=main" alt="Coveralls"></a>
|
|
18
20
|
</p>
|
|
19
21
|
|
|
20
22
|
<p align="center">
|
|
21
|
-
<a href="
|
|
22
|
-
<a href="
|
|
23
|
-
<a href="
|
|
24
|
-
<a href="
|
|
25
|
-
<a href="
|
|
26
|
-
<a href="
|
|
23
|
+
<a href="#-quick-start">Quick Start</a> |
|
|
24
|
+
<a href="#-features">Features</a> |
|
|
25
|
+
<a href="./docs/api.md">API Reference</a> |
|
|
26
|
+
<a href="#-integrations">Integrations</a> |
|
|
27
|
+
<a href="#-comparison">Comparison</a> |
|
|
28
|
+
<a href="./docs/tutorial.md">Tutorial</a> |
|
|
29
|
+
<a href="./docs/migration-guide.md">Migration Guide</a>
|
|
27
30
|
</p>
|
|
28
31
|
|
|
29
32
|
---
|
|
30
33
|
|
|
31
|
-
##
|
|
34
|
+
## The Problem
|
|
32
35
|
|
|
33
|
-
|
|
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.
|
|
36
|
+
Every growing Node.js service hits the same caching wall:
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
55
|
-
L1 hit ~0.01 ms ← served from memory, zero network
|
|
56
|
-
L2 hit ~0.5 ms ← served from Redis, backfilled to memory
|
|
57
|
-
miss ~20 ms ← fetcher runs once, all layers filled
|
|
38
|
+
```
|
|
39
|
+
Memory-only cache --> Fast, but each instance has a different view of data
|
|
40
|
+
Redis-only cache --> Shared, but every request pays a network round-trip
|
|
41
|
+
Hand-rolled hybrid --> Works... until you need stampede prevention, invalidation,
|
|
42
|
+
stale serving, observability, and distributed consistency
|
|
58
43
|
```
|
|
59
44
|
|
|
60
|
-
##
|
|
61
|
-
|
|
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.
|
|
67
|
-
|
|
68
|
-
## Feature map
|
|
69
|
-
|
|
70
|
-
### Core caching
|
|
71
|
-
|
|
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()`
|
|
76
|
-
|
|
77
|
-
### Invalidation and freshness
|
|
78
|
-
|
|
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
|
|
45
|
+
## The Solution
|
|
83
46
|
|
|
84
|
-
|
|
47
|
+
**layercache** gives you a unified multi-layer cache with production-grade features built in:
|
|
85
48
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
49
|
+
```
|
|
50
|
+
┌───────────────────────────────────────┐
|
|
51
|
+
your app ---->│ layercache │
|
|
52
|
+
│ │
|
|
53
|
+
│ L1 Memory ~0.01ms (per-process) │
|
|
54
|
+
│ | │
|
|
55
|
+
│ L2 Redis ~0.5ms (shared) │
|
|
56
|
+
│ | │
|
|
57
|
+
│ L3 Disk ~2ms (persistent) │
|
|
58
|
+
│ | │
|
|
59
|
+
│ Fetcher ~20ms (runs once) │
|
|
60
|
+
└───────────────────────────────────────┘
|
|
90
61
|
|
|
91
|
-
|
|
62
|
+
On a hit --> serves the fastest layer, backfills the rest
|
|
63
|
+
On a miss --> fetcher runs ONCE (even under 100x concurrency)
|
|
64
|
+
```
|
|
92
65
|
|
|
93
|
-
|
|
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
|
|
66
|
+
---
|
|
97
67
|
|
|
98
|
-
##
|
|
68
|
+
## Quick Start
|
|
99
69
|
|
|
100
70
|
```bash
|
|
101
71
|
npm install layercache
|
|
102
|
-
# Redis support (optional)
|
|
103
|
-
npm install ioredis
|
|
104
72
|
```
|
|
105
73
|
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## Quick start
|
|
109
|
-
|
|
110
74
|
```ts
|
|
111
75
|
import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'
|
|
112
76
|
import Redis from 'ioredis'
|
|
113
77
|
|
|
114
78
|
const cache = new CacheStack([
|
|
115
|
-
new MemoryLayer({ ttl: 60, maxSize: 1_000 }),
|
|
116
|
-
new RedisLayer({ client: new Redis(), ttl: 3600 })
|
|
79
|
+
new MemoryLayer({ ttl: 60, maxSize: 1_000 }), // L1: in-process
|
|
80
|
+
new RedisLayer({ client: new Redis(), ttl: 3600 }), // L2: shared
|
|
117
81
|
])
|
|
118
82
|
|
|
119
|
-
|
|
83
|
+
// Read-through: fetcher runs once, all layers filled
|
|
84
|
+
const user = await cache.get('user:123', () => db.findUser(123))
|
|
120
85
|
```
|
|
121
86
|
|
|
122
|
-
|
|
87
|
+
<details>
|
|
88
|
+
<summary><b>Memory-only (no Redis required)</b></summary>
|
|
123
89
|
|
|
124
90
|
```ts
|
|
125
91
|
const cache = new CacheStack([
|
|
@@ -127,762 +93,119 @@ const cache = new CacheStack([
|
|
|
127
93
|
])
|
|
128
94
|
```
|
|
129
95
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
## Core API
|
|
133
|
-
|
|
134
|
-
### `cache.get<T>(key, fetcher?, options?): Promise<T | null>`
|
|
135
|
-
|
|
136
|
-
Reads through all layers in order. On a partial hit (found in L2 but not L1), backfills the upper layers automatically. On a full miss, runs the fetcher — if one was provided.
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
// Without fetcher — returns null on miss
|
|
140
|
-
const user = await cache.get<User>('user:123')
|
|
141
|
-
|
|
142
|
-
// With fetcher — runs once on miss, fills all layers
|
|
143
|
-
const user = await cache.get<User>('user:123', () => db.findUser(123))
|
|
144
|
-
|
|
145
|
-
// With options
|
|
146
|
-
const user = await cache.get<User>('user:123', () => db.findUser(123), {
|
|
147
|
-
ttl: { memory: 30, redis: 600 }, // per-layer TTL
|
|
148
|
-
tags: ['user', 'user:123'], // tag this key for bulk invalidation
|
|
149
|
-
negativeCache: true, // cache null fetches
|
|
150
|
-
negativeTtl: 15, // short TTL for misses
|
|
151
|
-
staleWhileRevalidate: 30, // serve stale and refresh in background
|
|
152
|
-
staleIfError: 300, // serve stale if refresh fails
|
|
153
|
-
ttlJitter: 5 // +/- 5s expiry spread
|
|
154
|
-
})
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### `cache.set<T>(key, value, options?): Promise<void>`
|
|
158
|
-
|
|
159
|
-
Writes to all layers simultaneously.
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
await cache.set('user:123', user, {
|
|
163
|
-
ttl: { memory: 60, redis: 600 }, // per-layer TTL (seconds)
|
|
164
|
-
tags: ['user', 'user:123'],
|
|
165
|
-
staleWhileRevalidate: { redis: 30 },
|
|
166
|
-
staleIfError: { redis: 120 },
|
|
167
|
-
ttlJitter: { redis: 5 }
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
await cache.set('user:123', user, {
|
|
171
|
-
ttl: 120, // uniform TTL across all layers
|
|
172
|
-
tags: ['user', 'user:123']
|
|
173
|
-
})
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## Invalidation & freshness
|
|
177
|
-
|
|
178
|
-
### `cache.invalidateByTag(tag): Promise<void>`
|
|
179
|
-
|
|
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`).
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
await cache.set('user:123', user, { tags: ['user:123'] })
|
|
184
|
-
await cache.set('user:123:posts', posts, { tags: ['user:123'] })
|
|
185
|
-
|
|
186
|
-
await cache.invalidateByTag('user:123') // both keys gone
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### `cache.invalidateByTags(tags, mode?): Promise<void>`
|
|
190
|
-
|
|
191
|
-
Delete keys that match any or all of a set of tags.
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
await cache.invalidateByTags(['tenant:a', 'users'], 'all') // keys tagged with both
|
|
195
|
-
await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with either
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### `cache.invalidateByPattern(pattern): Promise<void>`
|
|
199
|
-
|
|
200
|
-
Glob-style deletion against the tracked key set, plus any layer that can enumerate real keys (for example `MemoryLayer`, `RedisLayer`, or `DiskLayer`).
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
Patterns must be non-empty, at most 1024 characters long, and free of control characters.
|
|
207
|
-
|
|
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.
|
|
209
|
-
|
|
210
|
-
### `cache.invalidateByPrefix(prefix): Promise<void>`
|
|
211
|
-
|
|
212
|
-
Prefer this over glob invalidation when your keys are hierarchical.
|
|
213
|
-
|
|
214
|
-
```ts
|
|
215
|
-
await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
The prefix is matched as-is. You do not need to append `*`, and namespace helpers pass their namespace prefix directly.
|
|
219
|
-
|
|
220
|
-
### `cache.mget<T>(entries): Promise<Array<T | null>>`
|
|
221
|
-
|
|
222
|
-
Concurrent multi-key fetch, each with its own optional fetcher.
|
|
223
|
-
|
|
224
|
-
If every entry is a simple read (`{ key }` only), `CacheStack` will use layer-level `getMany()` fast paths when the layer implements one.
|
|
225
|
-
|
|
226
|
-
```ts
|
|
227
|
-
const [user1, user2] = await cache.mget([
|
|
228
|
-
{ key: 'user:1', fetch: () => db.findUser(1) },
|
|
229
|
-
{ key: 'user:2', fetch: () => db.findUser(2) },
|
|
230
|
-
])
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
### `cache.getMetrics(): CacheMetricsSnapshot`
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
### `cache.healthCheck(): Promise<CacheHealthCheckResult[]>`
|
|
240
|
-
|
|
241
|
-
```ts
|
|
242
|
-
const health = await cache.healthCheck()
|
|
243
|
-
// [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
### `cache.resetMetrics(): void`
|
|
247
|
-
|
|
248
|
-
Resets all counters to zero — useful for per-interval reporting.
|
|
249
|
-
|
|
250
|
-
```ts
|
|
251
|
-
cache.resetMetrics()
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
### `cache.getStats(): CacheStatsSnapshot`
|
|
255
|
-
|
|
256
|
-
Returns metrics, per-layer degradation state, and the number of in-flight background refreshes.
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
const { metrics, layers, backgroundRefreshes } = cache.getStats()
|
|
260
|
-
// layers: [{ name, isLocal, degradedUntil }]
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
### `cache.wrap(prefix, fetcher, options?)`
|
|
264
|
-
|
|
265
|
-
Wraps an async function so every call is transparently cached. The key is derived from the function arguments unless you supply a `keyResolver`.
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
const getUser = cache.wrap('user', (id: number) => db.findUser(id))
|
|
269
|
-
|
|
270
|
-
const user = await getUser(123) // key → "user:123"
|
|
271
|
-
|
|
272
|
-
// Custom key resolver
|
|
273
|
-
const getUser = cache.wrap(
|
|
274
|
-
'user',
|
|
275
|
-
(id: number) => db.findUser(id),
|
|
276
|
-
{ keyResolver: (id) => String(id), ttl: 300 }
|
|
277
|
-
)
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
### Generation-based invalidation
|
|
281
|
-
|
|
282
|
-
Add a generation prefix to every key and rotate it when you want to invalidate the whole cache namespace without scanning:
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
const cache = new CacheStack([...], { generation: 1 })
|
|
286
|
-
|
|
287
|
-
await cache.set('user:123', user)
|
|
288
|
-
cache.bumpGeneration() // now reads use v2:user:123
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
const cache = new CacheStack([...], {
|
|
295
|
-
generation: 1,
|
|
296
|
-
generationCleanup: { batchSize: 500 }
|
|
297
|
-
})
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
`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.
|
|
301
|
-
|
|
302
|
-
### OpenTelemetry note
|
|
303
|
-
|
|
304
|
-
`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.
|
|
305
|
-
|
|
306
|
-
### `cache.warm(entries, options?)`
|
|
307
|
-
|
|
308
|
-
Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
await cache.warm(
|
|
312
|
-
[
|
|
313
|
-
{ key: 'config', fetcher: () => db.getConfig(), priority: 10 },
|
|
314
|
-
{ key: 'user:1', fetcher: () => db.findUser(1), priority: 5 },
|
|
315
|
-
{ key: 'user:2', fetcher: () => db.findUser(2), priority: 5 },
|
|
316
|
-
],
|
|
317
|
-
{ concurrency: 4, continueOnError: true }
|
|
318
|
-
)
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
### `cache.namespace(prefix): CacheNamespace`
|
|
322
|
-
|
|
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.
|
|
324
|
-
|
|
325
|
-
Namespace prefixes must be non-empty, at most 256 characters long, and free of control characters.
|
|
326
|
-
|
|
327
|
-
```ts
|
|
328
|
-
const users = cache.namespace('users')
|
|
329
|
-
const posts = cache.namespace('posts')
|
|
330
|
-
|
|
331
|
-
await users.set('123', userData) // stored as "users:123"
|
|
332
|
-
await users.clear() // only deletes "users:*"
|
|
333
|
-
|
|
334
|
-
// Nested namespaces
|
|
335
|
-
const tenant = cache.namespace('tenant:abc')
|
|
336
|
-
const posts = tenant.namespace('posts')
|
|
337
|
-
await posts.set('1', postData) // stored as "tenant:abc:posts:1"
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
### `cache.getOrThrow<T>(key, fetcher?, options?): Promise<T>`
|
|
341
|
-
|
|
342
|
-
Like `get()`, but throws `CacheMissError` instead of returning `null`. Useful when you know the value must exist (e.g. after a warm-up).
|
|
343
|
-
|
|
344
|
-
```ts
|
|
345
|
-
import { CacheMissError } from 'layercache'
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
const config = await cache.getOrThrow<Config>('app:config')
|
|
349
|
-
} catch (err) {
|
|
350
|
-
if (err instanceof CacheMissError) {
|
|
351
|
-
console.error(`Missing key: ${err.key}`)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
### `cache.inspect(key): Promise<CacheInspectResult | null>`
|
|
357
|
-
|
|
358
|
-
Returns detailed metadata about a cache key for debugging. Returns `null` if the key is not in any layer.
|
|
359
|
-
|
|
360
|
-
```ts
|
|
361
|
-
const info = await cache.inspect('user:123')
|
|
362
|
-
// {
|
|
363
|
-
// key: 'user:123',
|
|
364
|
-
// foundInLayers: ['memory', 'redis'],
|
|
365
|
-
// freshTtlSeconds: 45,
|
|
366
|
-
// staleTtlSeconds: 75,
|
|
367
|
-
// errorTtlSeconds: 345,
|
|
368
|
-
// isStale: false,
|
|
369
|
-
// tags: ['user', 'user:123']
|
|
370
|
-
// }
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
### Conditional caching with `shouldCache`
|
|
374
|
-
|
|
375
|
-
Skip caching specific results without affecting the return value:
|
|
376
|
-
|
|
377
|
-
```ts
|
|
378
|
-
const data = await cache.get('api:response', fetchFromApi, {
|
|
379
|
-
shouldCache: (value) => (value as any).status === 200
|
|
380
|
-
})
|
|
381
|
-
// If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
### TTL policies
|
|
385
|
-
|
|
386
|
-
Align expirations to calendar or boundary-based schedules:
|
|
387
|
-
|
|
388
|
-
```ts
|
|
389
|
-
await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
|
|
390
|
-
await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
|
|
391
|
-
await cache.set('aligned', value, { ttlPolicy: { alignTo: 300 } }) // next 5-minute boundary
|
|
392
|
-
await cache.set('custom', value, {
|
|
393
|
-
ttlPolicy: ({ key, value }) => key.startsWith('hot:') ? 30 : 300
|
|
394
|
-
})
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
---
|
|
398
|
-
|
|
399
|
-
## Negative + stale caching
|
|
400
|
-
|
|
401
|
-
`negativeCache` stores fetcher misses for a short TTL, which is useful for "user not found" or "feature flag absent" style lookups.
|
|
402
|
-
|
|
403
|
-
```ts
|
|
404
|
-
const user = await cache.get(`user:${id}`, () => db.findUser(id), {
|
|
405
|
-
negativeCache: true,
|
|
406
|
-
negativeTtl: 15
|
|
407
|
-
})
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
`staleWhileRevalidate` returns the last cached value immediately after expiry and refreshes it in the background. `staleIfError` keeps serving the stale value if the refresh fails.
|
|
411
|
-
|
|
412
|
-
```ts
|
|
413
|
-
await cache.set('config', currentConfig, {
|
|
414
|
-
ttl: 60,
|
|
415
|
-
staleWhileRevalidate: 30,
|
|
416
|
-
staleIfError: 300
|
|
417
|
-
})
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
---
|
|
421
|
-
|
|
422
|
-
## Write failure policy
|
|
96
|
+
</details>
|
|
423
97
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
If you prefer "at least one layer succeeds", enable best-effort mode:
|
|
98
|
+
<details>
|
|
99
|
+
<summary><b>Three-layer setup with disk persistence</b></summary>
|
|
427
100
|
|
|
428
101
|
```ts
|
|
429
|
-
|
|
430
|
-
writePolicy: 'best-effort'
|
|
431
|
-
})
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
`best-effort` logs the failed layers, increments `writeFailures`, and only throws if *every* layer failed.
|
|
435
|
-
|
|
436
|
-
---
|
|
437
|
-
|
|
438
|
-
## Cache stampede prevention
|
|
439
|
-
|
|
440
|
-
When 100 requests arrive simultaneously for an uncached key, only one fetcher runs. The rest wait and share the result.
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
const cache = new CacheStack([...])
|
|
444
|
-
// stampedePrevention is true by default
|
|
445
|
-
|
|
446
|
-
// 100 concurrent requests → fetcher executes exactly once
|
|
447
|
-
const results = await Promise.all(
|
|
448
|
-
Array.from({ length: 100 }, () =>
|
|
449
|
-
cache.get('hot-key', expensiveFetch)
|
|
450
|
-
)
|
|
451
|
-
)
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
Disable it if you prefer independent fetches:
|
|
455
|
-
|
|
456
|
-
```ts
|
|
457
|
-
new CacheStack([...], { stampedePrevention: false })
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
---
|
|
461
|
-
|
|
462
|
-
## Distributed deployments
|
|
463
|
-
|
|
464
|
-
### Distributed single-flight
|
|
465
|
-
|
|
466
|
-
Local stampede prevention only deduplicates requests inside one Node.js process. To dedupe cross-instance misses, configure a shared coordinator.
|
|
467
|
-
|
|
468
|
-
```ts
|
|
469
|
-
import { RedisSingleFlightCoordinator } from 'layercache'
|
|
470
|
-
|
|
471
|
-
const coordinator = new RedisSingleFlightCoordinator({ client: redis })
|
|
472
|
-
|
|
473
|
-
const cache = new CacheStack(
|
|
474
|
-
[new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })],
|
|
475
|
-
{
|
|
476
|
-
singleFlightCoordinator: coordinator,
|
|
477
|
-
singleFlightLeaseMs: 30_000,
|
|
478
|
-
singleFlightRenewIntervalMs: 10_000,
|
|
479
|
-
singleFlightTimeoutMs: 5_000,
|
|
480
|
-
singleFlightPollMs: 50
|
|
481
|
-
}
|
|
482
|
-
)
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
When another instance already owns the miss, the current process waits for the value to appear in the shared layer instead of running the fetcher again. `RedisSingleFlightCoordinator` also renews its Redis lease while the worker is still running, so long fetches are less likely to expire their lock mid-flight. Keep `singleFlightLeaseMs` comfortably above your expected fetch latency, and use `singleFlightRenewIntervalMs` if you need tighter control over renewal cadence.
|
|
486
|
-
|
|
487
|
-
### Cross-server L1 invalidation
|
|
488
|
-
|
|
489
|
-
When one server writes or deletes a key, other servers' memory layers go stale. The `RedisInvalidationBus` propagates invalidation events over Redis pub/sub so every instance stays consistent.
|
|
490
|
-
|
|
491
|
-
```ts
|
|
492
|
-
import { RedisInvalidationBus } from 'layercache'
|
|
493
|
-
|
|
494
|
-
const publisher = new Redis()
|
|
495
|
-
const subscriber = new Redis()
|
|
496
|
-
const bus = new RedisInvalidationBus({ publisher, subscriber })
|
|
497
|
-
|
|
498
|
-
const cache = new CacheStack(
|
|
499
|
-
[new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: publisher, ttl: 300 })],
|
|
500
|
-
{ invalidationBus: bus }
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
await cache.disconnect() // unsubscribes cleanly on shutdown
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
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:
|
|
507
|
-
|
|
508
|
-
```ts
|
|
509
|
-
new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
### Distributed tag invalidation
|
|
513
|
-
|
|
514
|
-
The default `TagIndex` lives in process memory — `invalidateByTag` on server A only knows about keys *that server A wrote*. For full cross-server tag invalidation, use `RedisTagIndex`:
|
|
515
|
-
|
|
516
|
-
```ts
|
|
517
|
-
import { RedisTagIndex } from 'layercache'
|
|
518
|
-
|
|
519
|
-
const sharedTagIndex = new RedisTagIndex({
|
|
520
|
-
client: redis,
|
|
521
|
-
prefix: 'myapp:tag-index', // namespaced so it doesn't collide with other data
|
|
522
|
-
knownKeysShards: 8
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
// Every CacheStack instance should use the same Redis-backed tag index config
|
|
526
|
-
const cache = new CacheStack(
|
|
527
|
-
[new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })],
|
|
528
|
-
{ invalidationBus: bus, tagIndex: sharedTagIndex }
|
|
529
|
-
)
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
|
|
102
|
+
import { CacheStack, MemoryLayer, RedisLayer, DiskLayer } from 'layercache'
|
|
533
103
|
|
|
534
|
-
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()`.
|
|
535
|
-
|
|
536
|
-
### Safe Redis clearing
|
|
537
|
-
|
|
538
|
-
`RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
|
|
539
|
-
|
|
540
|
-
```ts
|
|
541
104
|
const cache = new CacheStack([
|
|
542
|
-
new
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
})
|
|
105
|
+
new MemoryLayer({ ttl: 60, maxSize: 5_000 }),
|
|
106
|
+
new RedisLayer({ client: new Redis(), ttl: 3600, compression: 'gzip' }),
|
|
107
|
+
new DiskLayer({ directory: './var/cache', maxFiles: 10_000 }),
|
|
546
108
|
])
|
|
547
109
|
```
|
|
548
110
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
```ts
|
|
552
|
-
new RedisLayer({
|
|
553
|
-
client: redis,
|
|
554
|
-
allowUnprefixedClear: true
|
|
555
|
-
})
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
For production Redis, also set an explicit `prefix`, enforce Redis authentication/network isolation, and configure Redis `maxmemory` / eviction policy so cache growth cannot starve unrelated workloads.
|
|
559
|
-
|
|
560
|
-
### DiskLayer safety
|
|
561
|
-
|
|
562
|
-
`DiskLayer` is best used with an application-controlled directory and an explicit `maxFiles` bound.
|
|
563
|
-
|
|
564
|
-
```ts
|
|
565
|
-
import { resolve } from 'node:path'
|
|
566
|
-
|
|
567
|
-
const disk = new DiskLayer({
|
|
568
|
-
directory: resolve('./var/cache/layercache'),
|
|
569
|
-
maxFiles: 10_000
|
|
570
|
-
})
|
|
571
|
-
```
|
|
572
|
-
|
|
573
|
-
The library hashes cache keys before turning them into filenames, validates the configured directory, uses atomic temp-file writes, and removes malformed on-disk entries. You should still keep the directory outside any user-controlled path and set filesystem permissions so only your app can read or write it.
|
|
574
|
-
|
|
575
|
-
### Scoped fetcher rate limiting
|
|
576
|
-
|
|
577
|
-
Rate limits are global by default, but you can scope them per cache key or per fetcher function when different backends should not throttle each other.
|
|
578
|
-
|
|
579
|
-
```ts
|
|
580
|
-
await cache.get('user:123', fetchUser, {
|
|
581
|
-
fetcherRateLimit: {
|
|
582
|
-
maxConcurrent: 1,
|
|
583
|
-
scope: 'key'
|
|
584
|
-
}
|
|
585
|
-
})
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
Use `scope: 'fetcher'` to share a bucket across calls using the same fetcher function reference, or `bucketKey: 'billing-api'` for a custom named bucket.
|
|
589
|
-
|
|
590
|
-
---
|
|
591
|
-
|
|
592
|
-
## Per-layer TTL overrides
|
|
593
|
-
|
|
594
|
-
Layer names match the `name` option on each layer (`'memory'` and `'redis'` by default).
|
|
595
|
-
|
|
596
|
-
```ts
|
|
597
|
-
await cache.set('session:abc', sessionData, {
|
|
598
|
-
ttl: { memory: 30, redis: 3600 } // 30s in RAM, 1h in Redis
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
// Same override works on get (applied to backfills)
|
|
602
|
-
await cache.get('session:abc', fetchSession, {
|
|
603
|
-
ttl: { memory: 30, redis: 3600 }
|
|
604
|
-
})
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
Custom layer names:
|
|
608
|
-
|
|
609
|
-
```ts
|
|
610
|
-
new MemoryLayer({ name: 'local', ttl: 60 })
|
|
611
|
-
new RedisLayer({ name: 'shared', client: redis, ttl: 300 })
|
|
612
|
-
|
|
613
|
-
// then
|
|
614
|
-
await cache.set('key', value, { ttl: { local: 15, shared: 600 } })
|
|
615
|
-
```
|
|
111
|
+
</details>
|
|
616
112
|
|
|
617
113
|
---
|
|
618
114
|
|
|
619
|
-
##
|
|
620
|
-
|
|
621
|
-
**Sliding TTL** resets the TTL on every read so frequently-accessed keys never expire.
|
|
622
|
-
|
|
623
|
-
```ts
|
|
624
|
-
const value = await cache.get('session:abc', fetchSession, { slidingTtl: true })
|
|
625
|
-
```
|
|
626
|
-
|
|
627
|
-
**Adaptive TTL** automatically increases the TTL of hot keys up to a ceiling.
|
|
628
|
-
|
|
629
|
-
```ts
|
|
630
|
-
await cache.get('popular-post', fetchPost, {
|
|
631
|
-
adaptiveTtl: {
|
|
632
|
-
hotAfter: 5, // ramp up after 5 hits
|
|
633
|
-
step: 60, // add 60s per hit
|
|
634
|
-
maxTtl: 3600 // cap at 1h
|
|
635
|
-
}
|
|
636
|
-
})
|
|
637
|
-
```
|
|
115
|
+
## Features
|
|
638
116
|
|
|
639
|
-
|
|
117
|
+
### Core Caching
|
|
640
118
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
**
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
---
|
|
694
|
-
|
|
695
|
-
## Stats & HTTP endpoint
|
|
696
|
-
|
|
697
|
-
`cache.getStats()` returns a full snapshot suitable for dashboards or health checks.
|
|
698
|
-
|
|
699
|
-
```ts
|
|
700
|
-
const stats = cache.getStats()
|
|
701
|
-
// {
|
|
702
|
-
// metrics: { hits, misses, fetches, circuitBreakerTrips, ... },
|
|
703
|
-
// layers: [{ name, isLocal, degradedUntil }],
|
|
704
|
-
// backgroundRefreshes: 2
|
|
705
|
-
// }
|
|
706
|
-
```
|
|
707
|
-
|
|
708
|
-
Mount a JSON endpoint with the built-in HTTP handler (works with Express, Fastify, Next.js):
|
|
709
|
-
|
|
710
|
-
```ts
|
|
711
|
-
import { createCacheStatsHandler } from 'layercache'
|
|
712
|
-
import http from 'node:http'
|
|
713
|
-
|
|
714
|
-
const statsHandler = createCacheStatsHandler(cache)
|
|
715
|
-
http.createServer(statsHandler).listen(9090)
|
|
716
|
-
// GET / → JSON stats
|
|
717
|
-
```
|
|
718
|
-
|
|
719
|
-
The built-in handler returns JSON with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` headers.
|
|
720
|
-
|
|
721
|
-
Or use the Fastify plugin:
|
|
722
|
-
|
|
723
|
-
```ts
|
|
724
|
-
import { createFastifyLayercachePlugin } from 'layercache/integrations/fastify'
|
|
725
|
-
|
|
726
|
-
await fastify.register(createFastifyLayercachePlugin(cache, {
|
|
727
|
-
statsPath: '/cache/stats' // default; set exposeStatsRoute: false to disable
|
|
728
|
-
}))
|
|
729
|
-
// fastify.cache is now available in all handlers
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
---
|
|
733
|
-
|
|
734
|
-
## Persistence & snapshots
|
|
735
|
-
|
|
736
|
-
Transfer cache state between `CacheStack` instances or survive a restart.
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
// In-memory snapshot
|
|
740
|
-
const snapshot = await cache.exportState()
|
|
741
|
-
await anotherCache.importState(snapshot)
|
|
742
|
-
|
|
743
|
-
// Disk snapshot
|
|
744
|
-
await cache.persistToFile('./cache-snapshot.json')
|
|
745
|
-
await cache.restoreFromFile('./cache-snapshot.json')
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
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.
|
|
119
|
+
| Feature | What it does |
|
|
120
|
+
|---|---|
|
|
121
|
+
| **Layered reads + auto backfill** | Reads hit L1 first; on a partial hit, upper layers are filled automatically |
|
|
122
|
+
| **Stampede prevention** | 100 concurrent requests for the same key = 1 fetcher execution |
|
|
123
|
+
| **Distributed single-flight** | Cross-instance dedup via Redis locks with lease renewal |
|
|
124
|
+
| **Bulk operations** | `getMany()` / `setMany()` / `mdelete()` with layer-level fast paths |
|
|
125
|
+
| **`wrap()` API** | Transparent function caching with automatic key derivation |
|
|
126
|
+
| **Namespaces** | Scoped cache views with hierarchical prefix support |
|
|
127
|
+
| **Cache warming** | Pre-populate layers at startup with priority-based loading |
|
|
128
|
+
| **Negative caching** | Cache misses (e.g., "user not found") for short TTLs |
|
|
129
|
+
|
|
130
|
+
### Invalidation & Freshness
|
|
131
|
+
|
|
132
|
+
| Feature | What it does |
|
|
133
|
+
|---|---|
|
|
134
|
+
| **Tag invalidation** | Delete all keys with a given tag across all layers |
|
|
135
|
+
| **Batch tag invalidation** | Multi-tag operations with `any` / `all` semantics |
|
|
136
|
+
| **Wildcard & prefix invalidation** | Glob-style and hierarchical key patterns |
|
|
137
|
+
| **Generation-based rotation** | Bulk namespace invalidation without scanning |
|
|
138
|
+
| **Stale-while-revalidate** | Return cached value, refresh in background |
|
|
139
|
+
| **Stale-if-error** | Keep serving stale when upstream fails |
|
|
140
|
+
| **Sliding TTL** | Reset expiry on every read for frequently-accessed keys |
|
|
141
|
+
| **Adaptive TTL** | Auto-ramp TTL for hot keys up to a ceiling |
|
|
142
|
+
| **Refresh-ahead** | Proactively refresh before expiry |
|
|
143
|
+
| **TTL policies** | Align expirations to calendar boundaries (`until-midnight`, `next-hour`, custom) |
|
|
144
|
+
|
|
145
|
+
### Resilience & Operations
|
|
146
|
+
|
|
147
|
+
| Feature | What it does |
|
|
148
|
+
|---|---|
|
|
149
|
+
| **Graceful degradation** | Skip failed layers temporarily, keep cache available |
|
|
150
|
+
| **Circuit breaker** | Stop hammering broken upstreams after repeated failures |
|
|
151
|
+
| **Fetcher rate limiting** | Scoped to global, per-key, or per-fetcher with custom buckets |
|
|
152
|
+
| **Write policies** | `strict` (fail if any layer fails) or `best-effort` |
|
|
153
|
+
| **Write-behind** | Batch writes with configurable flush interval |
|
|
154
|
+
| **Compression** | gzip / brotli in RedisLayer with configurable threshold |
|
|
155
|
+
| **MessagePack** | Pluggable serializers (JSON default, MessagePack alternative) |
|
|
156
|
+
| **Persistence** | Export/import snapshots to memory or disk |
|
|
157
|
+
|
|
158
|
+
### Observability
|
|
159
|
+
|
|
160
|
+
| Feature | What it does |
|
|
161
|
+
|---|---|
|
|
162
|
+
| **Metrics** | Hits, misses, fetches, stale hits, circuit breaker trips, and more |
|
|
163
|
+
| **Per-layer latency** | Avg, max, and sample count using Welford's algorithm |
|
|
164
|
+
| **Health checks** | Async health endpoint per layer with latency measurement |
|
|
165
|
+
| **Event hooks** | `hit`, `miss`, `set`, `delete`, `stale-serve`, `stampede-dedupe`, `backfill`, `warm`, `error` |
|
|
166
|
+
| **OpenTelemetry** | Distributed tracing support |
|
|
167
|
+
| **Prometheus exporter** | Metrics export including latency gauges |
|
|
168
|
+
| **HTTP stats handler** | JSON endpoint for dashboards |
|
|
169
|
+
| **Admin CLI** | `npx layercache stats\|keys\|invalidate` for Redis-backed caches |
|
|
749
170
|
|
|
750
171
|
---
|
|
751
172
|
|
|
752
|
-
##
|
|
753
|
-
|
|
754
|
-
`CacheStack` extends `EventEmitter`. Subscribe to events for monitoring or custom side-effects.
|
|
755
|
-
|
|
756
|
-
| Event | Payload |
|
|
757
|
-
|-------|---------|
|
|
758
|
-
| `hit` | `{ key, layer }` |
|
|
759
|
-
| `miss` | `{ key }` |
|
|
760
|
-
| `set` | `{ key }` |
|
|
761
|
-
| `delete` | `{ key }` |
|
|
762
|
-
| `stale-serve` | `{ key, state, layer }` |
|
|
763
|
-
| `stampede-dedupe` | `{ key }` |
|
|
764
|
-
| `backfill` | `{ key, fromLayer, toLayer }` |
|
|
765
|
-
| `warm` | `{ key }` |
|
|
766
|
-
| `error` | `{ event, context }` |
|
|
173
|
+
## Integrations
|
|
767
174
|
|
|
768
|
-
|
|
769
|
-
cache.on('hit', ({ key, layer }) => metrics.inc('cache.hit', { layer }))
|
|
770
|
-
cache.on('miss', ({ key }) => metrics.inc('cache.miss'))
|
|
771
|
-
cache.on('error', ({ event, context }) => logger.error(event, context))
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
---
|
|
175
|
+
layercache plugs into the frameworks you already use:
|
|
775
176
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
177
|
+
| Framework | Integration |
|
|
178
|
+
|---|---|
|
|
179
|
+
| **Express** | `createExpressCacheMiddleware(cache, opts)` - auto-caches responses with `x-cache: HIT/MISS` header |
|
|
180
|
+
| **Fastify** | `createFastifyLayercachePlugin(cache, opts)` - registers `fastify.cache` with optional stats route |
|
|
181
|
+
| **Hono** | `createHonoCacheMiddleware(cache, opts)` - edge-compatible middleware |
|
|
182
|
+
| **tRPC** | `createTrpcCacheMiddleware(cache, prefix, opts)` - procedure middleware |
|
|
183
|
+
| **GraphQL** | `cacheGraphqlResolver(cache, prefix, resolver, opts)` - field resolver wrapper |
|
|
184
|
+
| **NestJS** | `@cachestack/nestjs` - `CacheStackModule.forRoot()`, `@Cacheable()` decorator |
|
|
185
|
+
| **Next.js** | Works natively with App Router and API routes |
|
|
186
|
+
| **OpenTelemetry** | `createOpenTelemetryPlugin(cache, tracer)` - distributed tracing spans |
|
|
779
187
|
|
|
780
|
-
|
|
188
|
+
<details>
|
|
189
|
+
<summary><b>Express example</b></summary>
|
|
781
190
|
|
|
782
191
|
```ts
|
|
783
192
|
import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
|
|
784
193
|
|
|
785
194
|
const cache = new CacheStack([new MemoryLayer({ ttl: 60 })])
|
|
786
195
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
res.json(await db.getUsers())
|
|
790
|
-
})
|
|
791
|
-
|
|
792
|
-
// Custom key resolver + tag support
|
|
793
|
-
app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
|
|
794
|
-
keyResolver: (req) => `user:${req.url}`,
|
|
196
|
+
app.get('/api/users', createExpressCacheMiddleware(cache, {
|
|
197
|
+
ttl: 30,
|
|
795
198
|
tags: ['users'],
|
|
796
|
-
|
|
797
|
-
}),
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
#### tRPC
|
|
801
|
-
|
|
802
|
-
```ts
|
|
803
|
-
import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
|
|
804
|
-
|
|
805
|
-
const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
|
|
806
|
-
|
|
807
|
-
export const cachedProcedure = t.procedure.use(cacheMiddleware)
|
|
808
|
-
```
|
|
809
|
-
|
|
810
|
-
#### GraphQL
|
|
811
|
-
|
|
812
|
-
```ts
|
|
813
|
-
import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
|
|
814
|
-
|
|
815
|
-
const resolvers = {
|
|
816
|
-
Query: {
|
|
817
|
-
user: cacheGraphqlResolver(cache, 'user', (_root, { id }) => db.findUser(id), {
|
|
818
|
-
keyResolver: (_root, { id }) => id,
|
|
819
|
-
ttl: 300
|
|
820
|
-
})
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
---
|
|
826
|
-
|
|
827
|
-
### Admin CLI
|
|
828
|
-
|
|
829
|
-
Inspect and manage a Redis-backed cache without writing code.
|
|
830
|
-
|
|
831
|
-
```bash
|
|
832
|
-
# Requires ioredis
|
|
833
|
-
npx layercache stats --redis redis://localhost:6379
|
|
834
|
-
npx layercache keys --redis redis://localhost:6379 --pattern "user:*"
|
|
835
|
-
npx layercache invalidate --redis redis://localhost:6379 --tag user:123
|
|
836
|
-
npx layercache invalidate --redis redis://localhost:6379 --pattern "session:*"
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
---
|
|
840
|
-
|
|
841
|
-
## MessagePack serialization
|
|
842
|
-
|
|
843
|
-
Reduces Redis memory usage and speeds up serialization for large values:
|
|
844
|
-
|
|
845
|
-
```ts
|
|
846
|
-
import { MsgpackSerializer } from 'layercache'
|
|
847
|
-
|
|
848
|
-
new RedisLayer({
|
|
849
|
-
client: redis,
|
|
850
|
-
ttl: 300,
|
|
851
|
-
serializer: new MsgpackSerializer()
|
|
199
|
+
keyResolver: (req) => `users:${req.url}`
|
|
200
|
+
}), async (req, res) => {
|
|
201
|
+
res.json(await db.getUsers())
|
|
852
202
|
})
|
|
853
203
|
```
|
|
854
204
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
## Custom layers
|
|
858
|
-
|
|
859
|
-
Implement `CacheLayer` to plug in any backend:
|
|
860
|
-
|
|
861
|
-
```ts
|
|
862
|
-
import type { CacheLayer } from 'layercache'
|
|
863
|
-
|
|
864
|
-
class MemcachedLayer implements CacheLayer {
|
|
865
|
-
readonly name = 'memcached'
|
|
866
|
-
readonly defaultTtl = 300
|
|
867
|
-
readonly isLocal = false
|
|
868
|
-
|
|
869
|
-
async get<T>(key: string): Promise<T | null> { /* … */ }
|
|
870
|
-
async getEntry?(key: string): Promise<unknown | null> { /* optional raw access */ }
|
|
871
|
-
async getMany?(keys: string[]): Promise<Array<unknown | null>> { /* optional bulk read */ }
|
|
872
|
-
async set(key: string, value: unknown, ttl?: number): Promise<void> { /* … */ }
|
|
873
|
-
async delete(key: string): Promise<void> { /* … */ }
|
|
874
|
-
async clear(): Promise<void> { /* … */ }
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const cache = new CacheStack([
|
|
878
|
-
new MemoryLayer({ ttl: 60 }),
|
|
879
|
-
new MemcachedLayer()
|
|
880
|
-
])
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
---
|
|
205
|
+
</details>
|
|
884
206
|
|
|
885
|
-
|
|
207
|
+
<details>
|
|
208
|
+
<summary><b>NestJS example</b></summary>
|
|
886
209
|
|
|
887
210
|
```bash
|
|
888
211
|
npm install @cachestack/nestjs
|
|
@@ -903,32 +226,8 @@ import { CacheStackModule } from '@cachestack/nestjs'
|
|
|
903
226
|
]
|
|
904
227
|
})
|
|
905
228
|
export class AppModule {}
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
Async configuration (resolve dependencies from DI):
|
|
909
|
-
|
|
910
|
-
```ts
|
|
911
|
-
@Module({
|
|
912
|
-
imports: [
|
|
913
|
-
CacheStackModule.forRootAsync({
|
|
914
|
-
inject: [ConfigService],
|
|
915
|
-
useFactory: (config: ConfigService) => ({
|
|
916
|
-
layers: [
|
|
917
|
-
new MemoryLayer({ ttl: 20 }),
|
|
918
|
-
new RedisLayer({ client: new Redis(config.get('REDIS_URL')), ttl: 300 })
|
|
919
|
-
]
|
|
920
|
-
})
|
|
921
|
-
})
|
|
922
|
-
]
|
|
923
|
-
})
|
|
924
|
-
export class AppModule {}
|
|
925
|
-
```
|
|
926
|
-
|
|
927
|
-
```ts
|
|
928
|
-
// your.service.ts
|
|
929
|
-
import { InjectCacheStack } from '@cachestack/nestjs'
|
|
930
|
-
import { CacheStack } from 'layercache'
|
|
931
229
|
|
|
230
|
+
// user.service.ts
|
|
932
231
|
@Injectable()
|
|
933
232
|
export class UserService {
|
|
934
233
|
constructor(@InjectCacheStack() private readonly cache: CacheStack) {}
|
|
@@ -939,142 +238,185 @@ export class UserService {
|
|
|
939
238
|
}
|
|
940
239
|
```
|
|
941
240
|
|
|
942
|
-
|
|
241
|
+
</details>
|
|
943
242
|
|
|
944
|
-
|
|
243
|
+
<details>
|
|
244
|
+
<summary><b>Next.js App Router example</b></summary>
|
|
945
245
|
|
|
946
246
|
```ts
|
|
947
|
-
// Express
|
|
948
|
-
app.get('/users/:id', async (req, res) => {
|
|
949
|
-
const user = await cache.get(`user:${req.params.id}`,
|
|
950
|
-
() => db.findUser(Number(req.params.id)),
|
|
951
|
-
{ tags: [`user:${req.params.id}`] }
|
|
952
|
-
)
|
|
953
|
-
res.json(user)
|
|
954
|
-
})
|
|
955
|
-
|
|
956
|
-
// Next.js App Router
|
|
957
247
|
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
|
958
248
|
const data = await cache.get(`user:${params.id}`, () => db.findUser(Number(params.id)))
|
|
959
249
|
return Response.json(data)
|
|
960
250
|
}
|
|
961
251
|
```
|
|
962
252
|
|
|
253
|
+
</details>
|
|
254
|
+
|
|
963
255
|
---
|
|
964
256
|
|
|
965
|
-
##
|
|
257
|
+
## Distributed Deployments
|
|
258
|
+
|
|
259
|
+
layercache is built for multi-instance production environments:
|
|
966
260
|
|
|
967
|
-
```
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
261
|
+
```
|
|
262
|
+
┌───────────┐ ┌───────────┐ ┌───────────┐
|
|
263
|
+
│ Server A │ │ Server B │ │ Server C │
|
|
264
|
+
│ [Memory] │ │ [Memory] │ │ [Memory] │
|
|
265
|
+
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
|
|
266
|
+
│ │ │
|
|
267
|
+
└──── Redis Pub/Sub ──────────────┘ <-- L1 invalidation bus
|
|
268
|
+
│
|
|
269
|
+
┌─────┴──────┐
|
|
270
|
+
│ Redis │ <-- shared L2 + tag index + single-flight
|
|
271
|
+
└────────────┘
|
|
976
272
|
```
|
|
977
273
|
|
|
978
|
-
|
|
274
|
+
- **Redis single-flight** - dedup misses across instances with distributed locks
|
|
275
|
+
- **Redis invalidation bus** - pub/sub-based L1 invalidation for memory consistency
|
|
276
|
+
- **Redis tag index** - shared tag tracking with optional sharding
|
|
277
|
+
- **Snapshot persistence** - export/import state between instances
|
|
979
278
|
|
|
980
|
-
|
|
279
|
+
<details>
|
|
280
|
+
<summary><b>Full distributed setup</b></summary>
|
|
981
281
|
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
|
|
282
|
+
```ts
|
|
283
|
+
import {
|
|
284
|
+
CacheStack, MemoryLayer, RedisLayer,
|
|
285
|
+
RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
|
|
286
|
+
} from 'layercache'
|
|
287
|
+
|
|
288
|
+
const redis = new Redis()
|
|
289
|
+
const bus = new RedisInvalidationBus({ publisher: redis, subscriber: new Redis() })
|
|
290
|
+
const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags' })
|
|
291
|
+
const coordinator = new RedisSingleFlightCoordinator({ client: redis })
|
|
292
|
+
|
|
293
|
+
const cache = new CacheStack(
|
|
294
|
+
[
|
|
295
|
+
new MemoryLayer({ ttl: 60, maxSize: 10_000 }),
|
|
296
|
+
new RedisLayer({ client: redis, ttl: 3600, prefix: 'myapp:cache:' })
|
|
297
|
+
],
|
|
298
|
+
{
|
|
299
|
+
invalidationBus: bus,
|
|
300
|
+
tagIndex: tagIndex,
|
|
301
|
+
singleFlightCoordinator: coordinator,
|
|
302
|
+
gracefulDegradation: { retryAfterMs: 10_000 }
|
|
303
|
+
}
|
|
304
|
+
)
|
|
985
305
|
```
|
|
986
306
|
|
|
987
|
-
|
|
307
|
+
</details>
|
|
988
308
|
|
|
989
|
-
|
|
309
|
+
---
|
|
990
310
|
|
|
991
|
-
|
|
992
|
-
|---|---|
|
|
993
|
-
| L1 memory hit | ~0.006 ms |
|
|
994
|
-
| L2 Redis hit | ~0.020 ms |
|
|
995
|
-
| No cache (simulated DB) | ~1.08 ms |
|
|
311
|
+
## Performance
|
|
996
312
|
|
|
997
313
|
```
|
|
314
|
+
┌─────────────────────┬──────────────┐
|
|
315
|
+
│ Scenario │ Avg Latency │
|
|
316
|
+
├─────────────────────┼──────────────┤
|
|
317
|
+
│ L1 memory hit │ ~0.006 ms │
|
|
318
|
+
│ L2 Redis hit │ ~0.020 ms │
|
|
319
|
+
│ No cache (sim. DB) │ ~1.08 ms │
|
|
320
|
+
└─────────────────────┴──────────────┘
|
|
321
|
+
|
|
998
322
|
┌─────────────────────┬────────┐
|
|
999
323
|
│ concurrentRequests │ 100 │
|
|
1000
|
-
│ fetcherExecutions │ 1 │
|
|
324
|
+
│ fetcherExecutions │ 1 │ <-- stampede prevention
|
|
1001
325
|
└─────────────────────┴────────┘
|
|
1002
326
|
```
|
|
1003
327
|
|
|
328
|
+
Run benchmarks locally:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
npm run bench:latency
|
|
332
|
+
npm run bench:stampede
|
|
333
|
+
```
|
|
334
|
+
|
|
1004
335
|
---
|
|
1005
336
|
|
|
1006
337
|
## Comparison
|
|
1007
338
|
|
|
1008
|
-
|
|
|
339
|
+
| | node-cache-manager | keyv | cacheable | **layercache** |
|
|
1009
340
|
|---|:---:|:---:|:---:|:---:|
|
|
1010
|
-
| Multi-layer |
|
|
1011
|
-
|
|
|
1012
|
-
|
|
|
1013
|
-
| Tag invalidation |
|
|
1014
|
-
| Distributed tags |
|
|
1015
|
-
| Cross-server L1 flush |
|
|
1016
|
-
|
|
|
1017
|
-
|
|
|
1018
|
-
|
|
|
1019
|
-
|
|
|
1020
|
-
|
|
|
1021
|
-
|
|
|
1022
|
-
|
|
|
1023
|
-
|
|
|
1024
|
-
|
|
|
1025
|
-
|
|
|
1026
|
-
|
|
|
1027
|
-
|
|
|
1028
|
-
|
|
|
1029
|
-
| Custom layers |
|
|
341
|
+
| Multi-layer with auto backfill | Partial | Plugin | -- | **Yes** |
|
|
342
|
+
| Stampede prevention | -- | -- | -- | **Yes** |
|
|
343
|
+
| Distributed single-flight | -- | -- | -- | **Yes** |
|
|
344
|
+
| Tag invalidation | -- | -- | Yes | **Yes** |
|
|
345
|
+
| Distributed tags | -- | -- | -- | **Yes** |
|
|
346
|
+
| Cross-server L1 flush | -- | -- | -- | **Yes** |
|
|
347
|
+
| Stale-while-revalidate | -- | -- | -- | **Yes** |
|
|
348
|
+
| Circuit breaker | -- | -- | -- | **Yes** |
|
|
349
|
+
| Graceful degradation | -- | -- | -- | **Yes** |
|
|
350
|
+
| Sliding / adaptive TTL | -- | -- | -- | **Yes** |
|
|
351
|
+
| Cache warming | -- | -- | -- | **Yes** |
|
|
352
|
+
| Persistence / snapshots | -- | -- | -- | **Yes** |
|
|
353
|
+
| Compression | -- | -- | Yes | **Yes** |
|
|
354
|
+
| Admin CLI | -- | -- | -- | **Yes** |
|
|
355
|
+
| NestJS module | -- | -- | -- | **Yes** |
|
|
356
|
+
| TypeScript-first | Partial | Yes | Yes | **Yes** |
|
|
357
|
+
| Wrap / decorator API | Yes | -- | -- | **Yes** |
|
|
358
|
+
| Namespaces | -- | Yes | Yes | **Yes** |
|
|
359
|
+
| Event hooks | Yes | Yes | Yes | **Yes** |
|
|
360
|
+
| Custom layers | Partial | -- | -- | **Yes** |
|
|
361
|
+
|
|
362
|
+
> See the full [comparison guide](./docs/comparison.md) for detailed breakdowns.
|
|
1030
363
|
|
|
1031
364
|
---
|
|
1032
365
|
|
|
1033
|
-
##
|
|
366
|
+
## Documentation
|
|
1034
367
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
368
|
+
| Document | Description |
|
|
369
|
+
|---|---|
|
|
370
|
+
| [API Reference](./docs/api.md) | Complete API documentation with all options |
|
|
371
|
+
| [Tutorial](./docs/tutorial.md) | Step-by-step operational walkthrough |
|
|
372
|
+
| [Comparison Guide](./docs/comparison.md) | Detailed feature comparison with alternatives |
|
|
373
|
+
| [Migration Guide](./docs/migration-guide.md) | Migrate from node-cache-manager, keyv, or cacheable |
|
|
374
|
+
| [Benchmarking](./docs/benchmarking.md) | Benchmark scenarios and methodology |
|
|
375
|
+
| [Changelog](./CHANGELOG.md) | Version history and breaking changes |
|
|
1038
376
|
|
|
1039
|
-
|
|
377
|
+
---
|
|
1040
378
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
379
|
+
## Examples
|
|
380
|
+
|
|
381
|
+
The [`examples/`](./examples) directory contains ready-to-run projects:
|
|
382
|
+
|
|
383
|
+
- [`express-api/`](./examples/express-api/) - Express REST API with layered caching
|
|
384
|
+
- [`nestjs-module/`](./examples/nestjs-module/) - NestJS module integration
|
|
385
|
+
- [`nextjs-api-routes/`](./examples/nextjs-api-routes/) - Next.js App Router with layercache
|
|
1048
386
|
|
|
1049
387
|
---
|
|
1050
388
|
|
|
1051
389
|
## Requirements
|
|
1052
390
|
|
|
1053
|
-
- Node.js
|
|
1054
|
-
- TypeScript
|
|
1055
|
-
- ioredis
|
|
391
|
+
- **Node.js** >= 20
|
|
392
|
+
- **TypeScript** >= 5.0 (optional - fully typed, ships `.d.ts`)
|
|
393
|
+
- **ioredis** >= 5 (optional - only needed for Redis features)
|
|
394
|
+
|
|
395
|
+
<sub>Runtime dependencies: `async-mutex` and `@msgpack/msgpack`</sub>
|
|
1056
396
|
|
|
1057
397
|
---
|
|
1058
398
|
|
|
1059
399
|
## Contributing
|
|
1060
400
|
|
|
1061
|
-
Contributions
|
|
401
|
+
Contributions welcome - bug fixes, docs, performance, new adapters, or issues.
|
|
1062
402
|
|
|
1063
403
|
```bash
|
|
1064
404
|
git clone https://github.com/flyingsquirrel0419/layercache
|
|
1065
405
|
cd layercache
|
|
1066
406
|
npm install
|
|
1067
|
-
npm run lint
|
|
1068
|
-
npm test # vitest
|
|
1069
|
-
npm run build:all # esm + cjs + nestjs package
|
|
407
|
+
npm run lint && npm test && npm run build:all
|
|
1070
408
|
```
|
|
1071
409
|
|
|
1072
|
-
|
|
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.
|
|
410
|
+
See the [Contributing Guide](./CONTRIBUTING.md) and [Code of Conduct](./CODE_OF_CONDUCT.md).
|
|
1075
411
|
|
|
1076
412
|
---
|
|
1077
413
|
|
|
1078
414
|
## License
|
|
1079
415
|
|
|
1080
|
-
|
|
416
|
+
[Apache 2.0](./LICENSE) - use it freely in personal and commercial projects.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
<p align="center">
|
|
421
|
+
If layercache saves you time, consider giving it a <a href="https://github.com/flyingsquirrel0419/layercache">star on GitHub</a>. It helps others discover the project.
|
|
422
|
+
</p>
|