layercache 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,81 @@
1
1
  // src/CacheStack.ts
2
2
  import { randomUUID } from "crypto";
3
3
 
4
+ // src/internal/StoredValue.ts
5
+ function isStoredValueEnvelope(value) {
6
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
7
+ }
8
+ function createStoredValueEnvelope(options) {
9
+ const now = options.now ?? Date.now();
10
+ const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
11
+ const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
12
+ const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
13
+ const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
14
+ const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
15
+ const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
16
+ return {
17
+ __layercache: 1,
18
+ kind: options.kind,
19
+ value: options.value,
20
+ freshUntil,
21
+ staleUntil,
22
+ errorUntil
23
+ };
24
+ }
25
+ function resolveStoredValue(stored, now = Date.now()) {
26
+ if (!isStoredValueEnvelope(stored)) {
27
+ return { state: "fresh", value: stored, stored };
28
+ }
29
+ if (stored.freshUntil === null || stored.freshUntil > now) {
30
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
31
+ }
32
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
33
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
34
+ }
35
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
36
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
37
+ }
38
+ return { state: "expired", value: null, stored, envelope: stored };
39
+ }
40
+ function unwrapStoredValue(stored) {
41
+ if (!isStoredValueEnvelope(stored)) {
42
+ return stored;
43
+ }
44
+ if (stored.kind === "empty") {
45
+ return null;
46
+ }
47
+ return stored.value ?? null;
48
+ }
49
+ function remainingStoredTtlSeconds(stored, now = Date.now()) {
50
+ if (!isStoredValueEnvelope(stored)) {
51
+ return void 0;
52
+ }
53
+ const expiry = maxExpiry(stored);
54
+ if (expiry === null) {
55
+ return void 0;
56
+ }
57
+ const remainingMs = expiry - now;
58
+ if (remainingMs <= 0) {
59
+ return 1;
60
+ }
61
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
62
+ }
63
+ function maxExpiry(stored) {
64
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
65
+ (value) => value !== null
66
+ );
67
+ if (values.length === 0) {
68
+ return null;
69
+ }
70
+ return Math.max(...values);
71
+ }
72
+ function normalizePositiveSeconds(value) {
73
+ if (!value || value <= 0) {
74
+ return void 0;
75
+ }
76
+ return value;
77
+ }
78
+
4
79
  // src/invalidation/PatternMatcher.ts
5
80
  var PatternMatcher = class {
6
81
  static matches(pattern, value) {
@@ -93,6 +168,10 @@ var StampedeGuard = class {
93
168
  };
94
169
 
95
170
  // src/CacheStack.ts
171
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
172
+ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
173
+ var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
174
+ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
96
175
  var EMPTY_METRICS = () => ({
97
176
  hits: 0,
98
177
  misses: 0,
@@ -100,7 +179,12 @@ var EMPTY_METRICS = () => ({
100
179
  sets: 0,
101
180
  deletes: 0,
102
181
  backfills: 0,
103
- invalidations: 0
182
+ invalidations: 0,
183
+ staleHits: 0,
184
+ refreshes: 0,
185
+ refreshErrors: 0,
186
+ writeFailures: 0,
187
+ singleFlightWaits: 0
104
188
  });
105
189
  var DebugLogger = class {
106
190
  enabled;
@@ -112,7 +196,7 @@ var DebugLogger = class {
112
196
  return;
113
197
  }
114
198
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
115
- console.debug(`[cachestack] ${message}${suffix}`);
199
+ console.debug(`[layercache] ${message}${suffix}`);
116
200
  }
117
201
  };
118
202
  var CacheStack = class {
@@ -122,7 +206,7 @@ var CacheStack = class {
122
206
  if (layers.length === 0) {
123
207
  throw new Error("CacheStack requires at least one cache layer.");
124
208
  }
125
- const debugEnv = process.env.DEBUG?.split(",").includes("cachestack:debug") ?? false;
209
+ const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
126
210
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
127
211
  this.tagIndex = options.tagIndex ?? new TagIndex();
128
212
  this.startup = this.initialize();
@@ -136,49 +220,46 @@ var CacheStack = class {
136
220
  unsubscribeInvalidation;
137
221
  logger;
138
222
  tagIndex;
223
+ backgroundRefreshes = /* @__PURE__ */ new Map();
139
224
  async get(key, fetcher, options) {
140
225
  await this.startup;
141
- const hit = await this.getFromLayers(key, options);
226
+ const hit = await this.readFromLayers(key, options, "allow-stale");
142
227
  if (hit.found) {
143
- this.metrics.hits += 1;
144
- return hit.value;
228
+ if (hit.state === "fresh") {
229
+ this.metrics.hits += 1;
230
+ return hit.value;
231
+ }
232
+ if (hit.state === "stale-while-revalidate") {
233
+ this.metrics.hits += 1;
234
+ this.metrics.staleHits += 1;
235
+ if (fetcher) {
236
+ this.scheduleBackgroundRefresh(key, fetcher, options);
237
+ }
238
+ return hit.value;
239
+ }
240
+ if (!fetcher) {
241
+ this.metrics.hits += 1;
242
+ this.metrics.staleHits += 1;
243
+ return hit.value;
244
+ }
245
+ try {
246
+ return await this.fetchWithGuards(key, fetcher, options);
247
+ } catch (error) {
248
+ this.metrics.staleHits += 1;
249
+ this.metrics.refreshErrors += 1;
250
+ this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
251
+ return hit.value;
252
+ }
145
253
  }
146
254
  this.metrics.misses += 1;
147
255
  if (!fetcher) {
148
256
  return null;
149
257
  }
150
- const runFetch = async () => {
151
- const secondHit = await this.getFromLayers(key, options);
152
- if (secondHit.found) {
153
- this.metrics.hits += 1;
154
- return secondHit.value;
155
- }
156
- this.metrics.fetches += 1;
157
- const fetched = await fetcher();
158
- if (fetched === null || fetched === void 0) {
159
- return null;
160
- }
161
- await this.set(key, fetched, options);
162
- return fetched;
163
- };
164
- if (this.options.stampedePrevention === false) {
165
- return runFetch();
166
- }
167
- return this.stampedeGuard.execute(key, runFetch);
258
+ return this.fetchWithGuards(key, fetcher, options);
168
259
  }
169
260
  async set(key, value, options) {
170
261
  await this.startup;
171
- await this.setAcrossLayers(key, value, options);
172
- if (options?.tags) {
173
- await this.tagIndex.track(key, options.tags);
174
- } else {
175
- await this.tagIndex.touch(key);
176
- }
177
- this.metrics.sets += 1;
178
- this.logger.debug("set", { key, tags: options?.tags });
179
- if (this.options.publishSetInvalidation !== false) {
180
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
181
- }
262
+ await this.storeEntry(key, "value", value, options);
182
263
  }
183
264
  async delete(key) {
184
265
  await this.startup;
@@ -194,7 +275,48 @@ var CacheStack = class {
194
275
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
195
276
  }
196
277
  async mget(entries) {
197
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
278
+ if (entries.length === 0) {
279
+ return [];
280
+ }
281
+ const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
282
+ if (!canFastPath) {
283
+ return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
284
+ }
285
+ await this.startup;
286
+ const pending = new Set(entries.map((_, index) => index));
287
+ const results = Array(entries.length).fill(null);
288
+ for (const layer of this.layers) {
289
+ const indexes = [...pending];
290
+ if (indexes.length === 0) {
291
+ break;
292
+ }
293
+ const keys = indexes.map((index) => entries[index].key);
294
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
295
+ for (let offset = 0; offset < values.length; offset += 1) {
296
+ const index = indexes[offset];
297
+ const stored = values[offset];
298
+ if (stored === null) {
299
+ continue;
300
+ }
301
+ const resolved = resolveStoredValue(stored);
302
+ if (resolved.state === "expired") {
303
+ await layer.delete(entries[index].key);
304
+ continue;
305
+ }
306
+ await this.tagIndex.touch(entries[index].key);
307
+ await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
308
+ results[index] = resolved.value;
309
+ pending.delete(index);
310
+ this.metrics.hits += 1;
311
+ }
312
+ }
313
+ if (pending.size > 0) {
314
+ for (const index of pending) {
315
+ await this.tagIndex.remove(entries[index].key);
316
+ this.metrics.misses += 1;
317
+ }
318
+ }
319
+ return results;
198
320
  }
199
321
  async mset(entries) {
200
322
  await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
@@ -220,6 +342,7 @@ var CacheStack = class {
220
342
  async disconnect() {
221
343
  await this.startup;
222
344
  await this.unsubscribeInvalidation?.();
345
+ await Promise.allSettled(this.backgroundRefreshes.values());
223
346
  }
224
347
  async initialize() {
225
348
  if (!this.options.invalidationBus) {
@@ -229,46 +352,225 @@ var CacheStack = class {
229
352
  await this.handleInvalidationMessage(message);
230
353
  });
231
354
  }
232
- async getFromLayers(key, options) {
355
+ async fetchWithGuards(key, fetcher, options) {
356
+ const fetchTask = async () => {
357
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
358
+ if (secondHit.found) {
359
+ this.metrics.hits += 1;
360
+ return secondHit.value;
361
+ }
362
+ return this.fetchAndPopulate(key, fetcher, options);
363
+ };
364
+ const singleFlightTask = async () => {
365
+ if (!this.options.singleFlightCoordinator) {
366
+ return fetchTask();
367
+ }
368
+ return this.options.singleFlightCoordinator.execute(
369
+ key,
370
+ this.resolveSingleFlightOptions(),
371
+ fetchTask,
372
+ () => this.waitForFreshValue(key, fetcher, options)
373
+ );
374
+ };
375
+ if (this.options.stampedePrevention === false) {
376
+ return singleFlightTask();
377
+ }
378
+ return this.stampedeGuard.execute(key, singleFlightTask);
379
+ }
380
+ async waitForFreshValue(key, fetcher, options) {
381
+ const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
382
+ const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
383
+ const deadline = Date.now() + timeoutMs;
384
+ this.metrics.singleFlightWaits += 1;
385
+ while (Date.now() < deadline) {
386
+ const hit = await this.readFromLayers(key, options, "fresh-only");
387
+ if (hit.found) {
388
+ this.metrics.hits += 1;
389
+ return hit.value;
390
+ }
391
+ await this.sleep(pollIntervalMs);
392
+ }
393
+ return this.fetchAndPopulate(key, fetcher, options);
394
+ }
395
+ async fetchAndPopulate(key, fetcher, options) {
396
+ this.metrics.fetches += 1;
397
+ const fetched = await fetcher();
398
+ if (fetched === null || fetched === void 0) {
399
+ if (!this.shouldNegativeCache(options)) {
400
+ return null;
401
+ }
402
+ await this.storeEntry(key, "empty", null, options);
403
+ return null;
404
+ }
405
+ await this.storeEntry(key, "value", fetched, options);
406
+ return fetched;
407
+ }
408
+ async storeEntry(key, kind, value, options) {
409
+ await this.writeAcrossLayers(key, kind, value, options);
410
+ if (options?.tags) {
411
+ await this.tagIndex.track(key, options.tags);
412
+ } else {
413
+ await this.tagIndex.touch(key);
414
+ }
415
+ this.metrics.sets += 1;
416
+ this.logger.debug("set", { key, kind, tags: options?.tags });
417
+ if (this.options.publishSetInvalidation !== false) {
418
+ await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
419
+ }
420
+ }
421
+ async readFromLayers(key, options, mode) {
422
+ let sawRetainableValue = false;
233
423
  for (let index = 0; index < this.layers.length; index += 1) {
234
424
  const layer = this.layers[index];
235
- const value = await layer.get(key);
236
- if (value === null) {
425
+ const stored = await this.readLayerEntry(layer, key);
426
+ if (stored === null) {
427
+ continue;
428
+ }
429
+ const resolved = resolveStoredValue(stored);
430
+ if (resolved.state === "expired") {
431
+ await layer.delete(key);
432
+ continue;
433
+ }
434
+ sawRetainableValue = true;
435
+ if (mode === "fresh-only" && resolved.state !== "fresh") {
237
436
  continue;
238
437
  }
239
438
  await this.tagIndex.touch(key);
240
- await this.backfill(key, value, index - 1, options);
241
- this.logger.debug("hit", { key, layer: layer.name });
242
- return { found: true, value };
439
+ await this.backfill(key, stored, index - 1, options);
440
+ this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
441
+ return { found: true, value: resolved.value, stored, state: resolved.state };
243
442
  }
244
- await this.tagIndex.remove(key);
245
- this.logger.debug("miss", { key });
246
- return { found: false, value: null };
443
+ if (!sawRetainableValue) {
444
+ await this.tagIndex.remove(key);
445
+ }
446
+ this.logger.debug("miss", { key, mode });
447
+ return { found: false, value: null, stored: null, state: "miss" };
448
+ }
449
+ async readLayerEntry(layer, key) {
450
+ if (layer.getEntry) {
451
+ return layer.getEntry(key);
452
+ }
453
+ return layer.get(key);
247
454
  }
248
- async backfill(key, value, upToIndex, options) {
455
+ async backfill(key, stored, upToIndex, options) {
249
456
  if (upToIndex < 0) {
250
457
  return;
251
458
  }
252
459
  for (let index = 0; index <= upToIndex; index += 1) {
253
460
  const layer = this.layers[index];
254
- await layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl));
461
+ const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
462
+ await layer.set(key, stored, ttl);
255
463
  this.metrics.backfills += 1;
256
464
  this.logger.debug("backfill", { key, layer: layer.name });
257
465
  }
258
466
  }
259
- async setAcrossLayers(key, value, options) {
260
- await Promise.all(
261
- this.layers.map((layer) => layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl)))
262
- );
467
+ async writeAcrossLayers(key, kind, value, options) {
468
+ const now = Date.now();
469
+ const operations = this.layers.map((layer) => async () => {
470
+ const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
471
+ const staleWhileRevalidate = this.resolveLayerSeconds(
472
+ layer.name,
473
+ options?.staleWhileRevalidate,
474
+ this.options.staleWhileRevalidate
475
+ );
476
+ const staleIfError = this.resolveLayerSeconds(
477
+ layer.name,
478
+ options?.staleIfError,
479
+ this.options.staleIfError
480
+ );
481
+ const payload = createStoredValueEnvelope({
482
+ kind,
483
+ value,
484
+ freshTtlSeconds: freshTtl,
485
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
486
+ staleIfErrorSeconds: staleIfError,
487
+ now
488
+ });
489
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
490
+ await layer.set(key, payload, ttl);
491
+ });
492
+ await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
493
+ }
494
+ async executeLayerOperations(operations, context) {
495
+ if (this.options.writePolicy !== "best-effort") {
496
+ await Promise.all(operations.map((operation) => operation()));
497
+ return;
498
+ }
499
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
500
+ const failures = results.filter((result) => result.status === "rejected");
501
+ if (failures.length === 0) {
502
+ return;
503
+ }
504
+ this.metrics.writeFailures += failures.length;
505
+ this.logger.debug("write-failure", {
506
+ ...context,
507
+ failures: failures.map((failure) => this.formatError(failure.reason))
508
+ });
509
+ if (failures.length === operations.length) {
510
+ throw new AggregateError(
511
+ failures.map((failure) => failure.reason),
512
+ `${context.action} failed for every cache layer`
513
+ );
514
+ }
515
+ }
516
+ resolveFreshTtl(layerName, kind, options, fallbackTtl) {
517
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
518
+ layerName,
519
+ options?.negativeTtl,
520
+ this.options.negativeTtl,
521
+ this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
522
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
523
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
524
+ return this.applyJitter(baseTtl, jitter);
525
+ }
526
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
527
+ if (override !== void 0) {
528
+ return this.readLayerNumber(layerName, override) ?? fallback;
529
+ }
530
+ if (globalDefault !== void 0) {
531
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
532
+ }
533
+ return fallback;
263
534
  }
264
- resolveTtl(layerName, fallbackTtl, ttlOverride) {
265
- if (ttlOverride === void 0) {
266
- return fallbackTtl;
535
+ readLayerNumber(layerName, value) {
536
+ if (typeof value === "number") {
537
+ return value;
267
538
  }
268
- if (typeof ttlOverride === "number") {
269
- return ttlOverride;
539
+ return value[layerName];
540
+ }
541
+ applyJitter(ttl, jitter) {
542
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
543
+ return ttl;
270
544
  }
271
- return ttlOverride[layerName] ?? fallbackTtl;
545
+ const delta = (Math.random() * 2 - 1) * jitter;
546
+ return Math.max(1, Math.round(ttl + delta));
547
+ }
548
+ shouldNegativeCache(options) {
549
+ return options?.negativeCache ?? this.options.negativeCaching ?? false;
550
+ }
551
+ scheduleBackgroundRefresh(key, fetcher, options) {
552
+ if (this.backgroundRefreshes.has(key)) {
553
+ return;
554
+ }
555
+ const refresh = (async () => {
556
+ this.metrics.refreshes += 1;
557
+ try {
558
+ await this.fetchWithGuards(key, fetcher, options);
559
+ } catch (error) {
560
+ this.metrics.refreshErrors += 1;
561
+ this.logger.debug("refresh-error", { key, error: this.formatError(error) });
562
+ } finally {
563
+ this.backgroundRefreshes.delete(key);
564
+ }
565
+ })();
566
+ this.backgroundRefreshes.set(key, refresh);
567
+ }
568
+ resolveSingleFlightOptions() {
569
+ return {
570
+ leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
571
+ waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
572
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
573
+ };
272
574
  }
273
575
  async deleteKeys(keys) {
274
576
  if (keys.length === 0) {
@@ -325,6 +627,15 @@ var CacheStack = class {
325
627
  }
326
628
  }
327
629
  }
630
+ formatError(error) {
631
+ if (error instanceof Error) {
632
+ return error.message;
633
+ }
634
+ return String(error);
635
+ }
636
+ sleep(ms) {
637
+ return new Promise((resolve) => setTimeout(resolve, ms));
638
+ }
328
639
  };
329
640
 
330
641
  // src/invalidation/RedisInvalidationBus.ts
@@ -335,7 +646,7 @@ var RedisInvalidationBus = class {
335
646
  constructor(options) {
336
647
  this.publisher = options.publisher;
337
648
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
338
- this.channel = options.channel ?? "cachestack:invalidation";
649
+ this.channel = options.channel ?? "layercache:invalidation";
339
650
  }
340
651
  async subscribe(handler) {
341
652
  const listener = async (_channel, payload) => {
@@ -361,7 +672,7 @@ var RedisTagIndex = class {
361
672
  scanCount;
362
673
  constructor(options) {
363
674
  this.client = options.client;
364
- this.prefix = options.prefix ?? "cachestack:tag-index";
675
+ this.prefix = options.prefix ?? "layercache:tag-index";
365
676
  this.scanCount = options.scanCount ?? 100;
366
677
  }
367
678
  async touch(key) {
@@ -457,6 +768,10 @@ var MemoryLayer = class {
457
768
  this.maxSize = options.maxSize ?? 1e3;
458
769
  }
459
770
  async get(key) {
771
+ const value = await this.getEntry(key);
772
+ return unwrapStoredValue(value);
773
+ }
774
+ async getEntry(key) {
460
775
  const entry = this.entries.get(key);
461
776
  if (!entry) {
462
777
  return null;
@@ -469,6 +784,13 @@ var MemoryLayer = class {
469
784
  this.entries.set(key, entry);
470
785
  return entry.value;
471
786
  }
787
+ async getMany(keys) {
788
+ const values = [];
789
+ for (const key of keys) {
790
+ values.push(await this.getEntry(key));
791
+ }
792
+ return values;
793
+ }
472
794
  async set(key, value, ttl = this.defaultTtl) {
473
795
  this.entries.delete(key);
474
796
  this.entries.set(key, {
@@ -541,12 +863,36 @@ var RedisLayer = class {
541
863
  this.scanCount = options.scanCount ?? 100;
542
864
  }
543
865
  async get(key) {
866
+ const payload = await this.getEntry(key);
867
+ return unwrapStoredValue(payload);
868
+ }
869
+ async getEntry(key) {
544
870
  const payload = await this.client.getBuffer(this.withPrefix(key));
545
871
  if (payload === null) {
546
872
  return null;
547
873
  }
548
874
  return this.serializer.deserialize(payload);
549
875
  }
876
+ async getMany(keys) {
877
+ if (keys.length === 0) {
878
+ return [];
879
+ }
880
+ const pipeline = this.client.pipeline();
881
+ for (const key of keys) {
882
+ pipeline.getBuffer(this.withPrefix(key));
883
+ }
884
+ const results = await pipeline.exec();
885
+ if (results === null) {
886
+ return keys.map(() => null);
887
+ }
888
+ return results.map((result) => {
889
+ const [, payload] = result;
890
+ if (payload === null) {
891
+ return null;
892
+ }
893
+ return this.serializer.deserialize(payload);
894
+ });
895
+ }
550
896
  async set(key, value, ttl = this.defaultTtl) {
551
897
  const payload = this.serializer.serialize(value);
552
898
  const normalizedKey = this.withPrefix(key);
@@ -608,6 +954,36 @@ var MsgpackSerializer = class {
608
954
  return decode(normalized);
609
955
  }
610
956
  };
957
+
958
+ // src/singleflight/RedisSingleFlightCoordinator.ts
959
+ import { randomUUID as randomUUID2 } from "crypto";
960
+ var RELEASE_SCRIPT = `
961
+ if redis.call("get", KEYS[1]) == ARGV[1] then
962
+ return redis.call("del", KEYS[1])
963
+ end
964
+ return 0
965
+ `;
966
+ var RedisSingleFlightCoordinator = class {
967
+ client;
968
+ prefix;
969
+ constructor(options) {
970
+ this.client = options.client;
971
+ this.prefix = options.prefix ?? "layercache:singleflight";
972
+ }
973
+ async execute(key, options, worker, waiter) {
974
+ const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
975
+ const token = randomUUID2();
976
+ const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
977
+ if (acquired === "OK") {
978
+ try {
979
+ return await worker();
980
+ } finally {
981
+ await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
982
+ }
983
+ }
984
+ return waiter();
985
+ }
986
+ };
611
987
  export {
612
988
  CacheStack,
613
989
  JsonSerializer,
@@ -616,6 +992,7 @@ export {
616
992
  PatternMatcher,
617
993
  RedisInvalidationBus,
618
994
  RedisLayer,
995
+ RedisSingleFlightCoordinator,
619
996
  RedisTagIndex,
620
997
  StampedeGuard,
621
998
  TagIndex
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Unified multi-layer caching for Node.js with memory, Redis, stampede prevention, and invalidation helpers.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/flyingsquirrel0419/layercache"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.cjs",
8
12
  "module": "./dist/index.js",