layercache 1.2.1 → 1.2.2

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
@@ -1,12 +1,12 @@
1
1
  # layercache
2
2
 
3
- **Multi-layer caching for Node.js — memory Redis your DB, unified in one API.**
3
+ **Production-ready multi-layer caching for Node.js — memory, Redis, persistence, invalidation, and resilience in one API.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/layercache)](https://www.npmjs.com/package/layercache)
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-158%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
+ [![test coverage](https://img.shields.io/badge/tests-164%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
10
10
 
11
11
  ```
12
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -26,6 +26,8 @@ Most Node.js services end up with the same problem:
26
26
 
27
27
  layercache solves all three. You declare your layers once and call `get`. Everything else is handled.
28
28
 
29
+ It is designed for production services that need predictable cache behavior under load: stampede prevention, cross-instance invalidation, layered TTL control, operational metrics, and safer persistence defaults.
30
+
29
31
  ```ts
30
32
  const user = await cache.get('user:123', () => db.findUser(123))
31
33
  // ↑ only called on a full miss
@@ -452,13 +454,14 @@ const cache = new CacheStack(
452
454
  {
453
455
  singleFlightCoordinator: coordinator,
454
456
  singleFlightLeaseMs: 30_000,
457
+ singleFlightRenewIntervalMs: 10_000,
455
458
  singleFlightTimeoutMs: 5_000,
456
459
  singleFlightPollMs: 50
457
460
  }
458
461
  )
459
462
  ```
460
463
 
461
- 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.
464
+ 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.
462
465
 
463
466
  ### Cross-server L1 invalidation
464
467
 
@@ -494,7 +497,8 @@ import { RedisTagIndex } from 'layercache'
494
497
 
495
498
  const sharedTagIndex = new RedisTagIndex({
496
499
  client: redis,
497
- prefix: 'myapp:tag-index' // namespaced so it doesn't collide with other data
500
+ prefix: 'myapp:tag-index', // namespaced so it doesn't collide with other data
501
+ knownKeysShards: 8
498
502
  })
499
503
 
500
504
  // Every CacheStack instance should use the same Redis-backed tag index config
@@ -528,6 +532,38 @@ new RedisLayer({
528
532
  })
529
533
  ```
530
534
 
535
+ 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.
536
+
537
+ ### DiskLayer safety
538
+
539
+ `DiskLayer` is best used with an application-controlled directory and an explicit `maxFiles` bound.
540
+
541
+ ```ts
542
+ import { resolve } from 'node:path'
543
+
544
+ const disk = new DiskLayer({
545
+ directory: resolve('./var/cache/layercache'),
546
+ maxFiles: 10_000
547
+ })
548
+ ```
549
+
550
+ 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.
551
+
552
+ ### Scoped fetcher rate limiting
553
+
554
+ 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.
555
+
556
+ ```ts
557
+ await cache.get('user:123', fetchUser, {
558
+ fetcherRateLimit: {
559
+ maxConcurrent: 1,
560
+ scope: 'key'
561
+ }
562
+ })
563
+ ```
564
+
565
+ Use `scope: 'fetcher'` to share a bucket across calls using the same fetcher function reference, or `bucketKey: 'billing-api'` for a custom named bucket.
566
+
531
567
  ---
532
568
 
533
569
  ## Per-layer TTL overrides
@@ -7,19 +7,21 @@ var RedisTagIndex = class {
7
7
  client;
8
8
  prefix;
9
9
  scanCount;
10
+ knownKeysShards;
10
11
  constructor(options) {
11
12
  this.client = options.client;
12
13
  this.prefix = options.prefix ?? "layercache:tag-index";
13
14
  this.scanCount = options.scanCount ?? 100;
15
+ this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
14
16
  }
15
17
  async touch(key) {
16
- await this.client.sadd(this.knownKeysKey(), key);
18
+ await this.client.sadd(this.knownKeysKeyFor(key), key);
17
19
  }
18
20
  async track(key, tags) {
19
21
  const keyTagsKey = this.keyTagsKey(key);
20
22
  const existingTags = await this.client.smembers(keyTagsKey);
21
23
  const pipeline = this.client.pipeline();
22
- pipeline.sadd(this.knownKeysKey(), key);
24
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
23
25
  for (const tag of existingTags) {
24
26
  pipeline.srem(this.tagKeysKey(tag), key);
25
27
  }
@@ -36,7 +38,7 @@ var RedisTagIndex = class {
36
38
  const keyTagsKey = this.keyTagsKey(key);
37
39
  const existingTags = await this.client.smembers(keyTagsKey);
38
40
  const pipeline = this.client.pipeline();
39
- pipeline.srem(this.knownKeysKey(), key);
41
+ pipeline.srem(this.knownKeysKeyFor(key), key);
40
42
  pipeline.del(keyTagsKey);
41
43
  for (const tag of existingTags) {
42
44
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -48,12 +50,14 @@ var RedisTagIndex = class {
48
50
  }
49
51
  async keysForPrefix(prefix) {
50
52
  const matches = [];
51
- let cursor = "0";
52
- do {
53
- const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
54
- cursor = nextCursor;
55
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
56
- } while (cursor !== "0");
53
+ for (const knownKeysKey of this.knownKeysKeys()) {
54
+ let cursor = "0";
55
+ do {
56
+ const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
57
+ cursor = nextCursor;
58
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
59
+ } while (cursor !== "0");
60
+ }
57
61
  return matches;
58
62
  }
59
63
  async tagsForKey(key) {
@@ -61,19 +65,21 @@ var RedisTagIndex = class {
61
65
  }
62
66
  async matchPattern(pattern) {
63
67
  const matches = [];
64
- let cursor = "0";
65
- do {
66
- const [nextCursor, keys] = await this.client.sscan(
67
- this.knownKeysKey(),
68
- cursor,
69
- "MATCH",
70
- pattern,
71
- "COUNT",
72
- this.scanCount
73
- );
74
- cursor = nextCursor;
75
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
76
- } while (cursor !== "0");
68
+ for (const knownKeysKey of this.knownKeysKeys()) {
69
+ let cursor = "0";
70
+ do {
71
+ const [nextCursor, keys] = await this.client.sscan(
72
+ knownKeysKey,
73
+ cursor,
74
+ "MATCH",
75
+ pattern,
76
+ "COUNT",
77
+ this.scanCount
78
+ );
79
+ cursor = nextCursor;
80
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
81
+ } while (cursor !== "0");
82
+ }
77
83
  return matches;
78
84
  }
79
85
  async clear() {
@@ -94,8 +100,17 @@ var RedisTagIndex = class {
94
100
  } while (cursor !== "0");
95
101
  return matches;
96
102
  }
97
- knownKeysKey() {
98
- return `${this.prefix}:keys`;
103
+ knownKeysKeyFor(key) {
104
+ if (this.knownKeysShards === 1) {
105
+ return `${this.prefix}:keys`;
106
+ }
107
+ return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
108
+ }
109
+ knownKeysKeys() {
110
+ if (this.knownKeysShards === 1) {
111
+ return [`${this.prefix}:keys`];
112
+ }
113
+ return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
99
114
  }
100
115
  keyTagsKey(key) {
101
116
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
@@ -104,6 +119,22 @@ var RedisTagIndex = class {
104
119
  return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
105
120
  }
106
121
  };
122
+ function normalizeKnownKeysShards(value) {
123
+ if (value === void 0) {
124
+ return 1;
125
+ }
126
+ if (!Number.isInteger(value) || value <= 0) {
127
+ throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
128
+ }
129
+ return value;
130
+ }
131
+ function simpleHash(value) {
132
+ let hash = 0;
133
+ for (let index = 0; index < value.length; index += 1) {
134
+ hash = hash * 31 + value.charCodeAt(index) >>> 0;
135
+ }
136
+ return hash;
137
+ }
107
138
 
108
139
  export {
109
140
  RedisTagIndex
package/dist/cli.cjs CHANGED
@@ -118,19 +118,21 @@ var RedisTagIndex = class {
118
118
  client;
119
119
  prefix;
120
120
  scanCount;
121
+ knownKeysShards;
121
122
  constructor(options) {
122
123
  this.client = options.client;
123
124
  this.prefix = options.prefix ?? "layercache:tag-index";
124
125
  this.scanCount = options.scanCount ?? 100;
126
+ this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
125
127
  }
126
128
  async touch(key) {
127
- await this.client.sadd(this.knownKeysKey(), key);
129
+ await this.client.sadd(this.knownKeysKeyFor(key), key);
128
130
  }
129
131
  async track(key, tags) {
130
132
  const keyTagsKey = this.keyTagsKey(key);
131
133
  const existingTags = await this.client.smembers(keyTagsKey);
132
134
  const pipeline = this.client.pipeline();
133
- pipeline.sadd(this.knownKeysKey(), key);
135
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
134
136
  for (const tag of existingTags) {
135
137
  pipeline.srem(this.tagKeysKey(tag), key);
136
138
  }
@@ -147,7 +149,7 @@ var RedisTagIndex = class {
147
149
  const keyTagsKey = this.keyTagsKey(key);
148
150
  const existingTags = await this.client.smembers(keyTagsKey);
149
151
  const pipeline = this.client.pipeline();
150
- pipeline.srem(this.knownKeysKey(), key);
152
+ pipeline.srem(this.knownKeysKeyFor(key), key);
151
153
  pipeline.del(keyTagsKey);
152
154
  for (const tag of existingTags) {
153
155
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -159,12 +161,14 @@ var RedisTagIndex = class {
159
161
  }
160
162
  async keysForPrefix(prefix) {
161
163
  const matches = [];
162
- let cursor = "0";
163
- do {
164
- const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
165
- cursor = nextCursor;
166
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
167
- } while (cursor !== "0");
164
+ for (const knownKeysKey of this.knownKeysKeys()) {
165
+ let cursor = "0";
166
+ do {
167
+ const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
168
+ cursor = nextCursor;
169
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
170
+ } while (cursor !== "0");
171
+ }
168
172
  return matches;
169
173
  }
170
174
  async tagsForKey(key) {
@@ -172,19 +176,21 @@ var RedisTagIndex = class {
172
176
  }
173
177
  async matchPattern(pattern) {
174
178
  const matches = [];
175
- let cursor = "0";
176
- do {
177
- const [nextCursor, keys] = await this.client.sscan(
178
- this.knownKeysKey(),
179
- cursor,
180
- "MATCH",
181
- pattern,
182
- "COUNT",
183
- this.scanCount
184
- );
185
- cursor = nextCursor;
186
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
187
- } while (cursor !== "0");
179
+ for (const knownKeysKey of this.knownKeysKeys()) {
180
+ let cursor = "0";
181
+ do {
182
+ const [nextCursor, keys] = await this.client.sscan(
183
+ knownKeysKey,
184
+ cursor,
185
+ "MATCH",
186
+ pattern,
187
+ "COUNT",
188
+ this.scanCount
189
+ );
190
+ cursor = nextCursor;
191
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
192
+ } while (cursor !== "0");
193
+ }
188
194
  return matches;
189
195
  }
190
196
  async clear() {
@@ -205,8 +211,17 @@ var RedisTagIndex = class {
205
211
  } while (cursor !== "0");
206
212
  return matches;
207
213
  }
208
- knownKeysKey() {
209
- return `${this.prefix}:keys`;
214
+ knownKeysKeyFor(key) {
215
+ if (this.knownKeysShards === 1) {
216
+ return `${this.prefix}:keys`;
217
+ }
218
+ return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
219
+ }
220
+ knownKeysKeys() {
221
+ if (this.knownKeysShards === 1) {
222
+ return [`${this.prefix}:keys`];
223
+ }
224
+ return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
210
225
  }
211
226
  keyTagsKey(key) {
212
227
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
@@ -215,6 +230,22 @@ var RedisTagIndex = class {
215
230
  return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
216
231
  }
217
232
  };
233
+ function normalizeKnownKeysShards(value) {
234
+ if (value === void 0) {
235
+ return 1;
236
+ }
237
+ if (!Number.isInteger(value) || value <= 0) {
238
+ throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
239
+ }
240
+ return value;
241
+ }
242
+ function simpleHash(value) {
243
+ let hash = 0;
244
+ for (let index = 0; index < value.length; index += 1) {
245
+ hash = hash * 31 + value.charCodeAt(index) >>> 0;
246
+ }
247
+ return hash;
248
+ }
218
249
 
219
250
  // src/cli.ts
220
251
  var CONNECT_TIMEOUT_MS = 5e3;
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  RedisTagIndex
4
- } from "./chunk-GF47Y3XR.js";
4
+ } from "./chunk-IXCMHVHP.js";
5
5
  import {
6
6
  isStoredValueEnvelope,
7
7
  resolveStoredValue
@@ -165,6 +165,7 @@ interface CacheSingleFlightExecutionOptions {
165
165
  leaseMs: number;
166
166
  waitTimeoutMs: number;
167
167
  pollIntervalMs: number;
168
+ renewIntervalMs?: number;
168
169
  }
169
170
  interface CacheSingleFlightCoordinator {
170
171
  execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
@@ -198,6 +199,7 @@ interface CacheStackOptions {
198
199
  singleFlightLeaseMs?: number;
199
200
  singleFlightTimeoutMs?: number;
200
201
  singleFlightPollMs?: number;
202
+ singleFlightRenewIntervalMs?: number;
201
203
  /**
202
204
  * Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
203
205
  * before the oldest entries are pruned. Prevents unbounded memory growth.
@@ -228,6 +230,8 @@ interface CacheRateLimitOptions {
228
230
  maxConcurrent?: number;
229
231
  intervalMs?: number;
230
232
  maxPerInterval?: number;
233
+ scope?: 'global' | 'key' | 'fetcher';
234
+ bucketKey?: string;
231
235
  }
232
236
  interface CacheWriteBehindOptions {
233
237
  flushIntervalMs?: number;
@@ -627,6 +631,7 @@ declare class CacheStack extends EventEmitter {
627
631
  private validateWriteOptions;
628
632
  private validateLayerNumberOption;
629
633
  private validatePositiveNumber;
634
+ private validateRateLimitOptions;
630
635
  private validateNonNegativeNumber;
631
636
  private validateCacheKey;
632
637
  private validateTtlPolicy;
@@ -165,6 +165,7 @@ interface CacheSingleFlightExecutionOptions {
165
165
  leaseMs: number;
166
166
  waitTimeoutMs: number;
167
167
  pollIntervalMs: number;
168
+ renewIntervalMs?: number;
168
169
  }
169
170
  interface CacheSingleFlightCoordinator {
170
171
  execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
@@ -198,6 +199,7 @@ interface CacheStackOptions {
198
199
  singleFlightLeaseMs?: number;
199
200
  singleFlightTimeoutMs?: number;
200
201
  singleFlightPollMs?: number;
202
+ singleFlightRenewIntervalMs?: number;
201
203
  /**
202
204
  * Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
203
205
  * before the oldest entries are pruned. Prevents unbounded memory growth.
@@ -228,6 +230,8 @@ interface CacheRateLimitOptions {
228
230
  maxConcurrent?: number;
229
231
  intervalMs?: number;
230
232
  maxPerInterval?: number;
233
+ scope?: 'global' | 'key' | 'fetcher';
234
+ bucketKey?: string;
231
235
  }
232
236
  interface CacheWriteBehindOptions {
233
237
  flushIntervalMs?: number;
@@ -627,6 +631,7 @@ declare class CacheStack extends EventEmitter {
627
631
  private validateWriteOptions;
628
632
  private validateLayerNumberOption;
629
633
  private validatePositiveNumber;
634
+ private validateRateLimitOptions;
630
635
  private validateNonNegativeNumber;
631
636
  private validateCacheKey;
632
637
  private validateTtlPolicy;
package/dist/edge.d.cts CHANGED
@@ -1,2 +1,2 @@
1
- export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-C1sBhTfv.cjs';
1
+ export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-DLpdQN0W.cjs';
2
2
  import 'node:events';
package/dist/edge.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-C1sBhTfv.js';
1
+ export { e as CacheGetOptions, f as CacheLayer, h as CacheLayerSetManyEntry, t as CacheMetricsSnapshot, w as CacheRateLimitOptions, B as CacheTtlPolicy, D as CacheTtlPolicyContext, J as CacheWriteOptions, K as EvictionPolicy, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-DLpdQN0W.js';
2
2
  import 'node:events';