layercache 1.2.5 → 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,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
- Default writes are strict: if any layer write fails, the operation throws.
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
- const cache = new CacheStack([...], {
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 RedisLayer({
543
- client: redis,
544
- prefix: 'myapp:cache:' // recommended for safe clear() and key scans
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
- If you really want to clear an unprefixed namespace, you must opt in explicitly:
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
- ## Sliding & adaptive TTL
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
- **Refresh-ahead** triggers a background refresh when the remaining TTL drops below a threshold, so callers never see a miss.
117
+ ### Core Caching
640
118
 
641
- ```ts
642
- await cache.get('leaderboard', fetchLeaderboard, {
643
- ttl: 120,
644
- refreshAhead: 30 // start refreshing when ≤30s remain
645
- })
646
- ```
647
-
648
- Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
649
-
650
- ---
651
-
652
- ## Graceful degradation & circuit breaker
653
-
654
- **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.
655
-
656
- ```ts
657
- new CacheStack([...], {
658
- gracefulDegradation: { retryAfterMs: 10_000 }
659
- })
660
- ```
661
-
662
- **Circuit breaker** opens after repeated fetcher failures for a key, returning `null` instead of hammering a broken downstream.
663
-
664
- ```ts
665
- new CacheStack([...], {
666
- circuitBreaker: {
667
- failureThreshold: 5, // open after 5 consecutive failures
668
- cooldownMs: 30_000 // retry after 30s
669
- }
670
- })
671
-
672
- // Or per-operation
673
- await cache.get('fragile-key', fetch, {
674
- circuitBreaker: { failureThreshold: 3, cooldownMs: 10_000 }
675
- })
676
- ```
677
-
678
- ---
679
-
680
- ## Compression
681
-
682
- `RedisLayer` can transparently compress values before writing. Values smaller than `compressionThreshold` are stored as-is.
683
-
684
- ```ts
685
- new RedisLayer({
686
- client: redis,
687
- ttl: 300,
688
- compression: 'gzip', // or 'brotli'
689
- compressionThreshold: 1_024 // bytes skip compression for small values
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
- ## Event hooks
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
- ```ts
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
- ## Integrations & tooling
777
-
778
- ### 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 |
779
187
 
780
- #### Express
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
- // Automatically caches GET responses as JSON
788
- app.get('/api/users', createExpressCacheMiddleware(cache, { ttl: 30 }), (req, res) => {
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
- ttl: 60
797
- }), handler)
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
- ## NestJS
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
- ## Express / Next.js
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
- ## Environment-based configuration
257
+ ## Distributed Deployments
258
+
259
+ layercache is built for multi-instance production environments:
966
260
 
967
- ```ts
968
- export const cache = process.env.NODE_ENV === 'production'
969
- ? new CacheStack([
970
- new MemoryLayer({ ttl: 60 }),
971
- new RedisLayer({ client: redis, ttl: 3600 })
972
- ])
973
- : new CacheStack([
974
- new MemoryLayer({ ttl: 60 }) // no Redis needed in dev
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
- ## Benchmarks
279
+ <details>
280
+ <summary><b>Full distributed setup</b></summary>
981
281
 
982
- ```bash
983
- npm run bench:latency
984
- 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
+ )
985
305
  ```
986
306
 
987
- 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>
988
308
 
989
- Example output from a local run:
309
+ ---
990
310
 
991
- | | avg latency |
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 │ stampede prevention in action
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
- | | node-cache-manager | keyv | cacheable | **layercache** |
339
+ | | node-cache-manager | keyv | cacheable | **layercache** |
1009
340
  |---|:---:|:---:|:---:|:---:|
1010
- | Multi-layer | | Plugin | | |
1011
- | Auto backfill | | | | |
1012
- | Stampede prevention | | | | |
1013
- | Tag invalidation | | | | |
1014
- | Distributed tags | | | | |
1015
- | Cross-server L1 flush | | | | |
1016
- | TypeScript-first | | | | |
1017
- | Wrap / decorator API | | | | |
1018
- | Cache warming | | | | |
1019
- | Namespaces | | | | |
1020
- | Sliding / adaptive TTL | | | | |
1021
- | Event hooks | | | | |
1022
- | Circuit breaker | | | | |
1023
- | Graceful degradation | | | | |
1024
- | Compression | | | | |
1025
- | Persistence / snapshots | | | | |
1026
- | Admin CLI | | | | |
1027
- | Pluggable logger | | | | |
1028
- | NestJS module | | | | |
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
- ## Debug logging
366
+ ## Documentation
1034
367
 
1035
- ```bash
1036
- DEBUG=layercache:debug node server.js
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
- Or pass a logger instance:
377
+ ---
1040
378
 
1041
- ```ts
1042
- new CacheStack([...], {
1043
- logger: {
1044
- debug(message, context) { myLogger.debug(message, context) }
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 20
1054
- - TypeScript 5.0 (optional fully typed, ships `.d.ts`)
1055
- - 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>
1056
396
 
1057
397
  ---
1058
398
 
1059
399
  ## Contributing
1060
400
 
1061
- 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.
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
- - Read the [contribution guide](./CONTRIBUTING.md) before opening a PR.
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
- 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>