layercache 1.2.4 → 1.2.6

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
@@ -5,121 +5,87 @@
5
5
  <h1 align="center">layercache</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Production-ready multi-layer caching for Node.js</strong><br>
9
- <em>Memory, Redis, persistence, invalidation, observability, and resilience in one API.</em>
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/npm/l/layercache" alt="license"></a>
16
- <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-blue" alt="TypeScript"></a>
17
- <a href="https://github.com/flyingsquirrel0419/layercache"><img src="https://img.shields.io/badge/tests-180%20passing-brightgreen" alt="tests"></a>
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-328_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="#installation">Installation</a> ·
22
- <a href="#quick-start">Quick Start</a> ·
23
- <a href="#core-api">Core API</a> ·
24
- <a href="#invalidation--freshness">Invalidation</a> ·
25
- <a href="#integrations--tooling">Integrations</a> ·
26
- <a href="#contributing">Contributing</a>
23
+ <a href="#-quick-start">Quick Start</a>&nbsp;&nbsp;|&nbsp;&nbsp;
24
+ <a href="#-features">Features</a>&nbsp;&nbsp;|&nbsp;&nbsp;
25
+ <a href="./docs/api.md">API Reference</a>&nbsp;&nbsp;|&nbsp;&nbsp;
26
+ <a href="#-integrations">Integrations</a>&nbsp;&nbsp;|&nbsp;&nbsp;
27
+ <a href="#-comparison">Comparison</a>&nbsp;&nbsp;|&nbsp;&nbsp;
28
+ <a href="./docs/tutorial.md">Tutorial</a>&nbsp;&nbsp;|&nbsp;&nbsp;
29
+ <a href="./docs/migration-guide.md">Migration Guide</a>
27
30
  </p>
28
31
 
29
32
  ---
30
33
 
31
- ## At a glance
34
+ ## The Problem
32
35
 
33
- - **Fast read path** combine memory L1, Redis L2, disk, or custom layers behind one API with automatic backfill.
34
- - **Stampede control** — prevent duplicate miss storms with in-process dedupe and optional distributed single-flight.
35
- - **Strong invalidation model** — support tags, batched tags, wildcards, prefixes, and generation-based cache rotation.
36
- - **Built for production failure modes** — serve stale safely, refresh ahead, degrade gracefully, trip circuit breakers, and throttle fetchers.
37
- - **Operational visibility included** — expose metrics, stats, health checks, OpenTelemetry spans, and an admin CLI.
38
- - **Fits real Node stacks** — integrate directly with Express, Fastify, Hono, GraphQL, tRPC, and NestJS.
36
+ Every growing Node.js service hits the same caching wall:
39
37
 
40
- Most Node.js services hit the same wall:
41
-
42
- | Approach | Tradeoff |
43
- |---|---|
44
- | Memory-only cache | Fast, but every instance has a different view of data |
45
- | Redis-only cache | Shared, but every request pays a network round-trip |
46
- | Hand-rolled hybrid cache | Possible, but you rebuild stampede prevention, invalidation, TTL policy, and observability yourself |
47
-
48
- `layercache` gives you a single API for layered caching and handles the hard parts for you: read-through fetches, backfill, stale serving, distributed invalidation, rate limiting, persistence, and operational introspection.
49
-
50
- On a hit, `layercache` serves the fastest available layer and backfills anything above it. On a miss, the fetcher runs once, even under heavy concurrency.
51
-
52
- ## Performance profile
53
-
54
- ```text
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
- ## Why teams use it
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
- ### Operations and resilience
47
+ **layercache** gives you a unified multi-layer cache with production-grade features built in:
85
48
 
86
- - Graceful degradation, circuit breakers, scoped fetcher rate limiting, and write-behind
87
- - Persistence to memory snapshots or disk snapshots
88
- - Compression, serializer fallback chains, and MessagePack support
89
- - Health checks, per-layer metrics, latency tracking, and event hooks
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
- ### Integrations and tooling
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
- - Express, Fastify, Hono, GraphQL, tRPC, and NestJS integrations
94
- - Redis-backed distributed tag index and invalidation bus support
95
- - Admin CLI for stats, key inspection, and invalidation
96
- - Edge-safe entry point for Worker-style runtimes
66
+ ---
97
67
 
98
- ## Installation
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
- const user = await cache.get<User>('user:123', () => db.findUser(123))
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
- Memory-only setup (no Redis required):
87
+ <details>
88
+ <summary><b>Memory-only (no Redis required)</b></summary>
123
89
 
124
90
  ```ts
125
91
  const cache = new CacheStack([
@@ -127,756 +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>`
96
+ </details>
158
97
 
159
- Writes to all layers simultaneously.
98
+ <details>
99
+ <summary><b>Three-layer setup with disk persistence</b></summary>
160
100
 
161
101
  ```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
- For multi-instance deployments, prefer a shared `RedisTagIndex`. Without it, pattern invalidation still scans real layer keys when available, but that fallback only helps on layers that implement `keys()`, and tag tracking itself remains process-local.
207
-
208
- ### `cache.invalidateByPrefix(prefix): Promise<void>`
209
-
210
- Prefer this over glob invalidation when your keys are hierarchical.
211
-
212
- ```ts
213
- await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
214
- ```
215
-
216
- The prefix is matched as-is. You do not need to append `*`, and namespace helpers pass their namespace prefix directly.
217
-
218
- ### `cache.mget<T>(entries): Promise<Array<T | null>>`
219
-
220
- Concurrent multi-key fetch, each with its own optional fetcher.
221
-
222
- If every entry is a simple read (`{ key }` only), `CacheStack` will use layer-level `getMany()` fast paths when the layer implements one.
223
-
224
- ```ts
225
- const [user1, user2] = await cache.mget([
226
- { key: 'user:1', fetch: () => db.findUser(1) },
227
- { key: 'user:2', fetch: () => db.findUser(2) },
228
- ])
229
- ```
230
-
231
- ### `cache.getMetrics(): CacheMetricsSnapshot`
232
-
233
- ```ts
234
- const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
235
- ```
236
-
237
- ### `cache.healthCheck(): Promise<CacheHealthCheckResult[]>`
238
-
239
- ```ts
240
- const health = await cache.healthCheck()
241
- // [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
242
- ```
243
-
244
- ### `cache.resetMetrics(): void`
245
-
246
- Resets all counters to zero — useful for per-interval reporting.
247
-
248
- ```ts
249
- cache.resetMetrics()
250
- ```
251
-
252
- ### `cache.getStats(): CacheStatsSnapshot`
253
-
254
- Returns metrics, per-layer degradation state, and the number of in-flight background refreshes.
255
-
256
- ```ts
257
- const { metrics, layers, backgroundRefreshes } = cache.getStats()
258
- // layers: [{ name, isLocal, degradedUntil }]
259
- ```
260
-
261
- ### `cache.wrap(prefix, fetcher, options?)`
262
-
263
- Wraps an async function so every call is transparently cached. The key is derived from the function arguments unless you supply a `keyResolver`.
264
-
265
- ```ts
266
- const getUser = cache.wrap('user', (id: number) => db.findUser(id))
267
-
268
- const user = await getUser(123) // key → "user:123"
269
-
270
- // Custom key resolver
271
- const getUser = cache.wrap(
272
- 'user',
273
- (id: number) => db.findUser(id),
274
- { keyResolver: (id) => String(id), ttl: 300 }
275
- )
276
- ```
277
-
278
- ### Generation-based invalidation
279
-
280
- Add a generation prefix to every key and rotate it when you want to invalidate the whole cache namespace without scanning:
281
-
282
- ```ts
283
- const cache = new CacheStack([...], { generation: 1 })
284
-
285
- await cache.set('user:123', user)
286
- cache.bumpGeneration() // now reads use v2:user:123
287
- ```
288
-
289
- If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
290
-
291
- ```ts
292
- const cache = new CacheStack([...], {
293
- generation: 1,
294
- generationCleanup: { batchSize: 500 }
295
- })
296
- ```
297
-
298
- `bumpGeneration()` only rotates future reads and writes by default. Enable `generationCleanup` when you want previous generations to be pruned automatically instead of aging out by TTL.
299
-
300
- ### OpenTelemetry note
301
-
302
- `createOpenTelemetryPlugin()` currently wraps a `CacheStack` instance's methods directly. Use one OpenTelemetry plugin per cache instance; if you need to compose multiple wrappers, install them in a fixed order and uninstall them in reverse order.
303
-
304
- ### `cache.warm(entries, options?)`
305
-
306
- Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
307
-
308
- ```ts
309
- await cache.warm(
310
- [
311
- { key: 'config', fetcher: () => db.getConfig(), priority: 10 },
312
- { key: 'user:1', fetcher: () => db.findUser(1), priority: 5 },
313
- { key: 'user:2', fetcher: () => db.findUser(2), priority: 5 },
314
- ],
315
- { concurrency: 4, continueOnError: true }
316
- )
317
- ```
318
-
319
- ### `cache.namespace(prefix): CacheNamespace`
320
-
321
- 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.
322
-
323
- ```ts
324
- const users = cache.namespace('users')
325
- const posts = cache.namespace('posts')
326
-
327
- await users.set('123', userData) // stored as "users:123"
328
- await users.clear() // only deletes "users:*"
329
-
330
- // Nested namespaces
331
- const tenant = cache.namespace('tenant:abc')
332
- const posts = tenant.namespace('posts')
333
- await posts.set('1', postData) // stored as "tenant:abc:posts:1"
334
- ```
335
-
336
- ### `cache.getOrThrow<T>(key, fetcher?, options?): Promise<T>`
337
-
338
- Like `get()`, but throws `CacheMissError` instead of returning `null`. Useful when you know the value must exist (e.g. after a warm-up).
339
-
340
- ```ts
341
- import { CacheMissError } from 'layercache'
342
-
343
- try {
344
- const config = await cache.getOrThrow<Config>('app:config')
345
- } catch (err) {
346
- if (err instanceof CacheMissError) {
347
- console.error(`Missing key: ${err.key}`)
348
- }
349
- }
350
- ```
351
-
352
- ### `cache.inspect(key): Promise<CacheInspectResult | null>`
353
-
354
- Returns detailed metadata about a cache key for debugging. Returns `null` if the key is not in any layer.
355
-
356
- ```ts
357
- const info = await cache.inspect('user:123')
358
- // {
359
- // key: 'user:123',
360
- // foundInLayers: ['memory', 'redis'],
361
- // freshTtlSeconds: 45,
362
- // staleTtlSeconds: 75,
363
- // errorTtlSeconds: 345,
364
- // isStale: false,
365
- // tags: ['user', 'user:123']
366
- // }
367
- ```
368
-
369
- ### Conditional caching with `shouldCache`
370
-
371
- Skip caching specific results without affecting the return value:
372
-
373
- ```ts
374
- const data = await cache.get('api:response', fetchFromApi, {
375
- shouldCache: (value) => (value as any).status === 200
376
- })
377
- // If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
378
- ```
379
-
380
- ### TTL policies
381
-
382
- Align expirations to calendar or boundary-based schedules:
383
-
384
- ```ts
385
- await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
386
- await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
387
- await cache.set('aligned', value, { ttlPolicy: { alignTo: 300 } }) // next 5-minute boundary
388
- await cache.set('custom', value, {
389
- ttlPolicy: ({ key, value }) => key.startsWith('hot:') ? 30 : 300
390
- })
391
- ```
392
-
393
- ---
394
-
395
- ## Negative + stale caching
396
-
397
- `negativeCache` stores fetcher misses for a short TTL, which is useful for "user not found" or "feature flag absent" style lookups.
398
-
399
- ```ts
400
- const user = await cache.get(`user:${id}`, () => db.findUser(id), {
401
- negativeCache: true,
402
- negativeTtl: 15
403
- })
404
- ```
405
-
406
- `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.
407
-
408
- ```ts
409
- await cache.set('config', currentConfig, {
410
- ttl: 60,
411
- staleWhileRevalidate: 30,
412
- staleIfError: 300
413
- })
414
- ```
415
-
416
- ---
417
-
418
- ## Write failure policy
419
-
420
- Default writes are strict: if any layer write fails, the operation throws.
421
-
422
- If you prefer "at least one layer succeeds", enable best-effort mode:
423
-
424
- ```ts
425
- const cache = new CacheStack([...], {
426
- writePolicy: 'best-effort'
427
- })
428
- ```
429
-
430
- `best-effort` logs the failed layers, increments `writeFailures`, and only throws if *every* layer failed.
431
-
432
- ---
433
-
434
- ## Cache stampede prevention
435
-
436
- When 100 requests arrive simultaneously for an uncached key, only one fetcher runs. The rest wait and share the result.
437
-
438
- ```ts
439
- const cache = new CacheStack([...])
440
- // stampedePrevention is true by default
441
-
442
- // 100 concurrent requests → fetcher executes exactly once
443
- const results = await Promise.all(
444
- Array.from({ length: 100 }, () =>
445
- cache.get('hot-key', expensiveFetch)
446
- )
447
- )
448
- ```
449
-
450
- Disable it if you prefer independent fetches:
451
-
452
- ```ts
453
- new CacheStack([...], { stampedePrevention: false })
454
- ```
455
-
456
- ---
457
-
458
- ## Distributed deployments
459
-
460
- ### Distributed single-flight
461
-
462
- Local stampede prevention only deduplicates requests inside one Node.js process. To dedupe cross-instance misses, configure a shared coordinator.
463
-
464
- ```ts
465
- import { RedisSingleFlightCoordinator } from 'layercache'
466
-
467
- const coordinator = new RedisSingleFlightCoordinator({ client: redis })
468
-
469
- const cache = new CacheStack(
470
- [new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })],
471
- {
472
- singleFlightCoordinator: coordinator,
473
- singleFlightLeaseMs: 30_000,
474
- singleFlightRenewIntervalMs: 10_000,
475
- singleFlightTimeoutMs: 5_000,
476
- singleFlightPollMs: 50
477
- }
478
- )
479
- ```
480
-
481
- 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.
482
-
483
- ### Cross-server L1 invalidation
484
-
485
- 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.
486
-
487
- ```ts
488
- import { RedisInvalidationBus } from 'layercache'
489
-
490
- const publisher = new Redis()
491
- const subscriber = new Redis()
492
- const bus = new RedisInvalidationBus({ publisher, subscriber })
493
-
494
- const cache = new CacheStack(
495
- [new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: publisher, ttl: 300 })],
496
- { invalidationBus: bus }
497
- )
498
-
499
- await cache.disconnect() // unsubscribes cleanly on shutdown
500
- ```
501
-
502
- By default, write-triggered L1 invalidation is **off** even when an invalidation bus is configured. This avoids surprising Redis Pub/Sub traffic in write-heavy services. Enable it explicitly when you want every write to evict peer memory caches immediately:
503
-
504
- ```ts
505
- new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
506
- ```
507
-
508
- ### Distributed tag invalidation
509
-
510
- 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`:
511
-
512
- ```ts
513
- import { RedisTagIndex } from 'layercache'
514
-
515
- const sharedTagIndex = new RedisTagIndex({
516
- client: redis,
517
- prefix: 'myapp:tag-index', // namespaced so it doesn't collide with other data
518
- knownKeysShards: 8
519
- })
520
-
521
- // Every CacheStack instance should use the same Redis-backed tag index config
522
- const cache = new CacheStack(
523
- [new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })],
524
- { invalidationBus: bus, tagIndex: sharedTagIndex }
525
- )
526
- ```
527
-
528
- Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
529
-
530
- The same recommendation applies to `invalidateByPattern()` and `invalidateByPrefix()` in distributed deployments: a shared tag index gives the most complete view of known keys, while layer key scans act as a fallback only when the shared layer exposes `keys()`.
102
+ import { CacheStack, MemoryLayer, RedisLayer, DiskLayer } from 'layercache'
531
103
 
532
- ### Safe Redis clearing
533
-
534
- `RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
535
-
536
- ```ts
537
104
  const cache = new CacheStack([
538
- new RedisLayer({
539
- client: redis,
540
- prefix: 'myapp:cache:' // recommended for safe clear() and key scans
541
- })
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 }),
542
108
  ])
543
109
  ```
544
110
 
545
- If you really want to clear an unprefixed namespace, you must opt in explicitly:
546
-
547
- ```ts
548
- new RedisLayer({
549
- client: redis,
550
- allowUnprefixedClear: true
551
- })
552
- ```
553
-
554
- 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.
555
-
556
- ### DiskLayer safety
557
-
558
- `DiskLayer` is best used with an application-controlled directory and an explicit `maxFiles` bound.
559
-
560
- ```ts
561
- import { resolve } from 'node:path'
562
-
563
- const disk = new DiskLayer({
564
- directory: resolve('./var/cache/layercache'),
565
- maxFiles: 10_000
566
- })
567
- ```
568
-
569
- 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.
570
-
571
- ### Scoped fetcher rate limiting
572
-
573
- 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.
574
-
575
- ```ts
576
- await cache.get('user:123', fetchUser, {
577
- fetcherRateLimit: {
578
- maxConcurrent: 1,
579
- scope: 'key'
580
- }
581
- })
582
- ```
583
-
584
- Use `scope: 'fetcher'` to share a bucket across calls using the same fetcher function reference, or `bucketKey: 'billing-api'` for a custom named bucket.
585
-
586
- ---
587
-
588
- ## Per-layer TTL overrides
589
-
590
- Layer names match the `name` option on each layer (`'memory'` and `'redis'` by default).
591
-
592
- ```ts
593
- await cache.set('session:abc', sessionData, {
594
- ttl: { memory: 30, redis: 3600 } // 30s in RAM, 1h in Redis
595
- })
596
-
597
- // Same override works on get (applied to backfills)
598
- await cache.get('session:abc', fetchSession, {
599
- ttl: { memory: 30, redis: 3600 }
600
- })
601
- ```
602
-
603
- Custom layer names:
604
-
605
- ```ts
606
- new MemoryLayer({ name: 'local', ttl: 60 })
607
- new RedisLayer({ name: 'shared', client: redis, ttl: 300 })
608
-
609
- // then
610
- await cache.set('key', value, { ttl: { local: 15, shared: 600 } })
611
- ```
612
-
613
- ---
614
-
615
- ## Sliding & adaptive TTL
616
-
617
- **Sliding TTL** resets the TTL on every read so frequently-accessed keys never expire.
618
-
619
- ```ts
620
- const value = await cache.get('session:abc', fetchSession, { slidingTtl: true })
621
- ```
622
-
623
- **Adaptive TTL** automatically increases the TTL of hot keys up to a ceiling.
624
-
625
- ```ts
626
- await cache.get('popular-post', fetchPost, {
627
- adaptiveTtl: {
628
- hotAfter: 5, // ramp up after 5 hits
629
- step: 60, // add 60s per hit
630
- maxTtl: 3600 // cap at 1h
631
- }
632
- })
633
- ```
634
-
635
- **Refresh-ahead** triggers a background refresh when the remaining TTL drops below a threshold, so callers never see a miss.
636
-
637
- ```ts
638
- await cache.get('leaderboard', fetchLeaderboard, {
639
- ttl: 120,
640
- refreshAhead: 30 // start refreshing when ≤30s remain
641
- })
642
- ```
643
-
644
- Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
645
-
646
- ---
647
-
648
- ## Graceful degradation & circuit breaker
649
-
650
- **Graceful degradation** marks a layer as degraded on failure and skips it for a retry window, keeping the cache available even if Redis is briefly unreachable.
651
-
652
- ```ts
653
- new CacheStack([...], {
654
- gracefulDegradation: { retryAfterMs: 10_000 }
655
- })
656
- ```
657
-
658
- **Circuit breaker** opens after repeated fetcher failures for a key, returning `null` instead of hammering a broken downstream.
659
-
660
- ```ts
661
- new CacheStack([...], {
662
- circuitBreaker: {
663
- failureThreshold: 5, // open after 5 consecutive failures
664
- cooldownMs: 30_000 // retry after 30s
665
- }
666
- })
667
-
668
- // Or per-operation
669
- await cache.get('fragile-key', fetch, {
670
- circuitBreaker: { failureThreshold: 3, cooldownMs: 10_000 }
671
- })
672
- ```
673
-
674
- ---
675
-
676
- ## Compression
677
-
678
- `RedisLayer` can transparently compress values before writing. Values smaller than `compressionThreshold` are stored as-is.
679
-
680
- ```ts
681
- new RedisLayer({
682
- client: redis,
683
- ttl: 300,
684
- compression: 'gzip', // or 'brotli'
685
- compressionThreshold: 1_024 // bytes — skip compression for small values
686
- })
687
- ```
111
+ </details>
688
112
 
689
113
  ---
690
114
 
691
- ## Stats & HTTP endpoint
115
+ ## Features
692
116
 
693
- `cache.getStats()` returns a full snapshot suitable for dashboards or health checks.
117
+ ### Core Caching
694
118
 
695
- ```ts
696
- const stats = cache.getStats()
697
- // {
698
- // metrics: { hits, misses, fetches, circuitBreakerTrips, ... },
699
- // layers: [{ name, isLocal, degradedUntil }],
700
- // backgroundRefreshes: 2
701
- // }
702
- ```
703
-
704
- Mount a JSON endpoint with the built-in HTTP handler (works with Express, Fastify, Next.js):
705
-
706
- ```ts
707
- import { createCacheStatsHandler } from 'layercache'
708
- import http from 'node:http'
709
-
710
- const statsHandler = createCacheStatsHandler(cache)
711
- http.createServer(statsHandler).listen(9090)
712
- // GET / JSON stats
713
- ```
714
-
715
- Or use the Fastify plugin:
716
-
717
- ```ts
718
- import { createFastifyLayercachePlugin } from 'layercache/integrations/fastify'
719
-
720
- await fastify.register(createFastifyLayercachePlugin(cache, {
721
- statsPath: '/cache/stats' // default; set exposeStatsRoute: false to disable
722
- }))
723
- // fastify.cache is now available in all handlers
724
- ```
725
-
726
- ---
727
-
728
- ## Persistence & snapshots
729
-
730
- Transfer cache state between `CacheStack` instances or survive a restart.
731
-
732
- ```ts
733
- // In-memory snapshot
734
- const snapshot = await cache.exportState()
735
- await anotherCache.importState(snapshot)
736
-
737
- // Disk snapshot
738
- await cache.persistToFile('./cache-snapshot.json')
739
- await cache.restoreFromFile('./cache-snapshot.json')
740
- ```
741
-
742
- For safety, file snapshots are restricted to `process.cwd()` by default. Set `snapshotBaseDir` to an explicit directory for application-controlled snapshot storage, or `false` if you intentionally want to disable that restriction.
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 |
743
170
 
744
171
  ---
745
172
 
746
- ## Event hooks
747
-
748
- `CacheStack` extends `EventEmitter`. Subscribe to events for monitoring or custom side-effects.
749
-
750
- | Event | Payload |
751
- |-------|---------|
752
- | `hit` | `{ key, layer }` |
753
- | `miss` | `{ key }` |
754
- | `set` | `{ key }` |
755
- | `delete` | `{ key }` |
756
- | `stale-serve` | `{ key, state, layer }` |
757
- | `stampede-dedupe` | `{ key }` |
758
- | `backfill` | `{ key, fromLayer, toLayer }` |
759
- | `warm` | `{ key }` |
760
- | `error` | `{ event, context }` |
761
-
762
- ```ts
763
- cache.on('hit', ({ key, layer }) => metrics.inc('cache.hit', { layer }))
764
- cache.on('miss', ({ key }) => metrics.inc('cache.miss'))
765
- cache.on('error', ({ event, context }) => logger.error(event, context))
766
- ```
767
-
768
- ---
173
+ ## Integrations
769
174
 
770
- ## Integrations & tooling
175
+ layercache plugs into the frameworks you already use:
771
176
 
772
- ### Framework integrations
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 |
773
187
 
774
- #### Express
188
+ <details>
189
+ <summary><b>Express example</b></summary>
775
190
 
776
191
  ```ts
777
192
  import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
778
193
 
779
194
  const cache = new CacheStack([new MemoryLayer({ ttl: 60 })])
780
195
 
781
- // Automatically caches GET responses as JSON
782
- app.get('/api/users', createExpressCacheMiddleware(cache, { ttl: 30 }), (req, res) => {
783
- res.json(await db.getUsers())
784
- })
785
-
786
- // Custom key resolver + tag support
787
- app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
788
- keyResolver: (req) => `user:${req.url}`,
196
+ app.get('/api/users', createExpressCacheMiddleware(cache, {
197
+ ttl: 30,
789
198
  tags: ['users'],
790
- ttl: 60
791
- }), handler)
792
- ```
793
-
794
- #### tRPC
795
-
796
- ```ts
797
- import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
798
-
799
- const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
800
-
801
- export const cachedProcedure = t.procedure.use(cacheMiddleware)
802
- ```
803
-
804
- #### GraphQL
805
-
806
- ```ts
807
- import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
808
-
809
- const resolvers = {
810
- Query: {
811
- user: cacheGraphqlResolver(cache, 'user', (_root, { id }) => db.findUser(id), {
812
- keyResolver: (_root, { id }) => id,
813
- ttl: 300
814
- })
815
- }
816
- }
817
- ```
818
-
819
- ---
820
-
821
- ### Admin CLI
822
-
823
- Inspect and manage a Redis-backed cache without writing code.
824
-
825
- ```bash
826
- # Requires ioredis
827
- npx layercache stats --redis redis://localhost:6379
828
- npx layercache keys --redis redis://localhost:6379 --pattern "user:*"
829
- npx layercache invalidate --redis redis://localhost:6379 --tag user:123
830
- npx layercache invalidate --redis redis://localhost:6379 --pattern "session:*"
831
- ```
832
-
833
- ---
834
-
835
- ## MessagePack serialization
836
-
837
- Reduces Redis memory usage and speeds up serialization for large values:
838
-
839
- ```ts
840
- import { MsgpackSerializer } from 'layercache'
841
-
842
- new RedisLayer({
843
- client: redis,
844
- ttl: 300,
845
- serializer: new MsgpackSerializer()
199
+ keyResolver: (req) => `users:${req.url}`
200
+ }), async (req, res) => {
201
+ res.json(await db.getUsers())
846
202
  })
847
203
  ```
848
204
 
849
- ---
205
+ </details>
850
206
 
851
- ## Custom layers
852
-
853
- Implement `CacheLayer` to plug in any backend:
854
-
855
- ```ts
856
- import type { CacheLayer } from 'layercache'
857
-
858
- class MemcachedLayer implements CacheLayer {
859
- readonly name = 'memcached'
860
- readonly defaultTtl = 300
861
- readonly isLocal = false
862
-
863
- async get<T>(key: string): Promise<T | null> { /* … */ }
864
- async getEntry?(key: string): Promise<unknown | null> { /* optional raw access */ }
865
- async getMany?(keys: string[]): Promise<Array<unknown | null>> { /* optional bulk read */ }
866
- async set(key: string, value: unknown, ttl?: number): Promise<void> { /* … */ }
867
- async delete(key: string): Promise<void> { /* … */ }
868
- async clear(): Promise<void> { /* … */ }
869
- }
870
-
871
- const cache = new CacheStack([
872
- new MemoryLayer({ ttl: 60 }),
873
- new MemcachedLayer()
874
- ])
875
- ```
876
-
877
- ---
878
-
879
- ## NestJS
207
+ <details>
208
+ <summary><b>NestJS example</b></summary>
880
209
 
881
210
  ```bash
882
211
  npm install @cachestack/nestjs
@@ -897,32 +226,8 @@ import { CacheStackModule } from '@cachestack/nestjs'
897
226
  ]
898
227
  })
899
228
  export class AppModule {}
900
- ```
901
-
902
- Async configuration (resolve dependencies from DI):
903
-
904
- ```ts
905
- @Module({
906
- imports: [
907
- CacheStackModule.forRootAsync({
908
- inject: [ConfigService],
909
- useFactory: (config: ConfigService) => ({
910
- layers: [
911
- new MemoryLayer({ ttl: 20 }),
912
- new RedisLayer({ client: new Redis(config.get('REDIS_URL')), ttl: 300 })
913
- ]
914
- })
915
- })
916
- ]
917
- })
918
- export class AppModule {}
919
- ```
920
-
921
- ```ts
922
- // your.service.ts
923
- import { InjectCacheStack } from '@cachestack/nestjs'
924
- import { CacheStack } from 'layercache'
925
229
 
230
+ // user.service.ts
926
231
  @Injectable()
927
232
  export class UserService {
928
233
  constructor(@InjectCacheStack() private readonly cache: CacheStack) {}
@@ -933,142 +238,185 @@ export class UserService {
933
238
  }
934
239
  ```
935
240
 
936
- ---
241
+ </details>
937
242
 
938
- ## Express / Next.js
243
+ <details>
244
+ <summary><b>Next.js App Router example</b></summary>
939
245
 
940
246
  ```ts
941
- // Express
942
- app.get('/users/:id', async (req, res) => {
943
- const user = await cache.get(`user:${req.params.id}`,
944
- () => db.findUser(Number(req.params.id)),
945
- { tags: [`user:${req.params.id}`] }
946
- )
947
- res.json(user)
948
- })
949
-
950
- // Next.js App Router
951
247
  export async function GET(_req: Request, { params }: { params: { id: string } }) {
952
248
  const data = await cache.get(`user:${params.id}`, () => db.findUser(Number(params.id)))
953
249
  return Response.json(data)
954
250
  }
955
251
  ```
956
252
 
253
+ </details>
254
+
957
255
  ---
958
256
 
959
- ## Environment-based configuration
257
+ ## Distributed Deployments
258
+
259
+ layercache is built for multi-instance production environments:
960
260
 
961
- ```ts
962
- export const cache = process.env.NODE_ENV === 'production'
963
- ? new CacheStack([
964
- new MemoryLayer({ ttl: 60 }),
965
- new RedisLayer({ client: redis, ttl: 3600 })
966
- ])
967
- : new CacheStack([
968
- new MemoryLayer({ ttl: 60 }) // no Redis needed in dev
969
- ])
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
+ └────────────┘
970
272
  ```
971
273
 
972
- ---
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
973
278
 
974
- ## Benchmarks
279
+ <details>
280
+ <summary><b>Full distributed setup</b></summary>
975
281
 
976
- ```bash
977
- npm run bench:latency
978
- npm run bench:stampede
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
+ )
979
305
  ```
980
306
 
981
- These scripts use `ioredis-mock` and a synthetic no-cache delay, so treat the numbers as a quick sanity check rather than a production benchmark.
307
+ </details>
982
308
 
983
- Example output from a local run:
309
+ ---
984
310
 
985
- | | avg latency |
986
- |---|---|
987
- | L1 memory hit | ~0.006 ms |
988
- | L2 Redis hit | ~0.020 ms |
989
- | No cache (simulated DB) | ~1.08 ms |
311
+ ## Performance
990
312
 
991
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
+
992
322
  ┌─────────────────────┬────────┐
993
323
  │ concurrentRequests │ 100 │
994
- │ fetcherExecutions │ 1 │ stampede prevention in action
324
+ │ fetcherExecutions │ 1 │ <-- stampede prevention
995
325
  └─────────────────────┴────────┘
996
326
  ```
997
327
 
328
+ Run benchmarks locally:
329
+
330
+ ```bash
331
+ npm run bench:latency
332
+ npm run bench:stampede
333
+ ```
334
+
998
335
  ---
999
336
 
1000
337
  ## Comparison
1001
338
 
1002
- | | node-cache-manager | keyv | cacheable | **layercache** |
339
+ | | node-cache-manager | keyv | cacheable | **layercache** |
1003
340
  |---|:---:|:---:|:---:|:---:|
1004
- | Multi-layer | | Plugin | | |
1005
- | Auto backfill | | | | |
1006
- | Stampede prevention | | | | |
1007
- | Tag invalidation | | | | |
1008
- | Distributed tags | | | | |
1009
- | Cross-server L1 flush | | | | |
1010
- | TypeScript-first | | | | |
1011
- | Wrap / decorator API | | | | |
1012
- | Cache warming | | | | |
1013
- | Namespaces | | | | |
1014
- | Sliding / adaptive TTL | | | | |
1015
- | Event hooks | | | | |
1016
- | Circuit breaker | | | | |
1017
- | Graceful degradation | | | | |
1018
- | Compression | | | | |
1019
- | Persistence / snapshots | | | | |
1020
- | Admin CLI | | | | |
1021
- | Pluggable logger | | | | |
1022
- | NestJS module | | | | |
1023
- | 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.
1024
363
 
1025
364
  ---
1026
365
 
1027
- ## Debug logging
366
+ ## Documentation
1028
367
 
1029
- ```bash
1030
- DEBUG=layercache:debug node server.js
1031
- ```
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 |
1032
376
 
1033
- Or pass a logger instance:
377
+ ---
1034
378
 
1035
- ```ts
1036
- new CacheStack([...], {
1037
- logger: {
1038
- debug(message, context) { myLogger.debug(message, context) }
1039
- }
1040
- })
1041
- ```
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
1042
386
 
1043
387
  ---
1044
388
 
1045
389
  ## Requirements
1046
390
 
1047
- - Node.js 20
1048
- - TypeScript 5.0 (optional fully typed, ships `.d.ts`)
1049
- - ioredis 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
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>
1050
396
 
1051
397
  ---
1052
398
 
1053
399
  ## Contributing
1054
400
 
1055
- Contributions are welcome, whether that means bug fixes, documentation improvements, performance work, new adapters, or issue reports.
401
+ Contributions welcome - bug fixes, docs, performance, new adapters, or issues.
1056
402
 
1057
403
  ```bash
1058
404
  git clone https://github.com/flyingsquirrel0419/layercache
1059
405
  cd layercache
1060
406
  npm install
1061
- npm run lint
1062
- npm test # vitest
1063
- npm run build:all # esm + cjs + nestjs package
407
+ npm run lint && npm test && npm run build:all
1064
408
  ```
1065
409
 
1066
- - Read the [contribution guide](./CONTRIBUTING.md) before opening a PR.
1067
- - Participation in the project is covered by the [Code of Conduct](./CODE_OF_CONDUCT.md).
1068
- - If you are filing an issue, include reproduction steps, expected behavior, and runtime details when relevant.
410
+ See the [Contributing Guide](./CONTRIBUTING.md) and [Code of Conduct](./CODE_OF_CONDUCT.md).
1069
411
 
1070
412
  ---
1071
413
 
1072
414
  ## License
1073
415
 
1074
- MIT
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>