layercache 1.1.0 → 1.2.0

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,7 +1,7 @@
1
1
  import {
2
2
  PatternMatcher,
3
3
  RedisTagIndex
4
- } from "./chunk-QUB5VZFZ.js";
4
+ } from "./chunk-BWM4MU2X.js";
5
5
 
6
6
  // src/CacheStack.ts
7
7
  import { randomUUID } from "crypto";
@@ -9,7 +9,7 @@ import { EventEmitter } from "events";
9
9
  import { promises as fs } from "fs";
10
10
 
11
11
  // src/CacheNamespace.ts
12
- var CacheNamespace = class {
12
+ var CacheNamespace = class _CacheNamespace {
13
13
  constructor(cache, prefix) {
14
14
  this.cache = cache;
15
15
  this.prefix = prefix;
@@ -22,6 +22,12 @@ var CacheNamespace = class {
22
22
  async getOrSet(key, fetcher, options) {
23
23
  return this.cache.getOrSet(this.qualify(key), fetcher, options);
24
24
  }
25
+ /**
26
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
27
+ */
28
+ async getOrThrow(key, fetcher, options) {
29
+ return this.cache.getOrThrow(this.qualify(key), fetcher, options);
30
+ }
25
31
  async has(key) {
26
32
  return this.cache.has(this.qualify(key));
27
33
  }
@@ -62,6 +68,12 @@ var CacheNamespace = class {
62
68
  async invalidateByPattern(pattern) {
63
69
  await this.cache.invalidateByPattern(this.qualify(pattern));
64
70
  }
71
+ /**
72
+ * Returns detailed metadata about a single cache key within this namespace.
73
+ */
74
+ async inspect(key) {
75
+ return this.cache.inspect(this.qualify(key));
76
+ }
65
77
  wrap(keyPrefix, fetcher, options) {
66
78
  return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
67
79
  }
@@ -80,6 +92,18 @@ var CacheNamespace = class {
80
92
  getHitRate() {
81
93
  return this.cache.getHitRate();
82
94
  }
95
+ /**
96
+ * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
97
+ *
98
+ * ```ts
99
+ * const tenant = cache.namespace('tenant:abc')
100
+ * const posts = tenant.namespace('posts')
101
+ * // keys become: "tenant:abc:posts:mykey"
102
+ * ```
103
+ */
104
+ namespace(childPrefix) {
105
+ return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
106
+ }
83
107
  qualify(key) {
84
108
  return `${this.prefix}:${key}`;
85
109
  }
@@ -181,7 +205,12 @@ var CircuitBreakerManager = class {
181
205
  var MetricsCollector = class {
182
206
  data = this.empty();
183
207
  get snapshot() {
184
- return { ...this.data };
208
+ return {
209
+ ...this.data,
210
+ hitsByLayer: { ...this.data.hitsByLayer },
211
+ missesByLayer: { ...this.data.missesByLayer },
212
+ latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
213
+ };
185
214
  }
186
215
  increment(field, amount = 1) {
187
216
  ;
@@ -190,6 +219,22 @@ var MetricsCollector = class {
190
219
  incrementLayer(map, layerName) {
191
220
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
192
221
  }
222
+ /**
223
+ * Records a read latency sample for the given layer.
224
+ * Maintains a rolling average and max using Welford's online algorithm.
225
+ */
226
+ recordLatency(layerName, durationMs) {
227
+ const existing = this.data.latencyByLayer[layerName];
228
+ if (!existing) {
229
+ this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
230
+ return;
231
+ }
232
+ existing.count += 1;
233
+ existing.avgMs += (durationMs - existing.avgMs) / existing.count;
234
+ if (durationMs > existing.maxMs) {
235
+ existing.maxMs = durationMs;
236
+ }
237
+ }
193
238
  reset() {
194
239
  this.data = this.empty();
195
240
  }
@@ -224,6 +269,7 @@ var MetricsCollector = class {
224
269
  degradedOperations: 0,
225
270
  hitsByLayer: {},
226
271
  missesByLayer: {},
272
+ latencyByLayer: {},
227
273
  resetAt: Date.now()
228
274
  };
229
275
  }
@@ -423,11 +469,17 @@ var TagIndex = class {
423
469
  tagToKeys = /* @__PURE__ */ new Map();
424
470
  keyToTags = /* @__PURE__ */ new Map();
425
471
  knownKeys = /* @__PURE__ */ new Set();
472
+ maxKnownKeys;
473
+ constructor(options = {}) {
474
+ this.maxKnownKeys = options.maxKnownKeys;
475
+ }
426
476
  async touch(key) {
427
477
  this.knownKeys.add(key);
478
+ this.pruneKnownKeysIfNeeded();
428
479
  }
429
480
  async track(key, tags) {
430
481
  this.knownKeys.add(key);
482
+ this.pruneKnownKeysIfNeeded();
431
483
  if (tags.length === 0) {
432
484
  return;
433
485
  }
@@ -466,6 +518,9 @@ var TagIndex = class {
466
518
  async keysForTag(tag) {
467
519
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
468
520
  }
521
+ async tagsForKey(key) {
522
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
523
+ }
469
524
  async matchPattern(pattern) {
470
525
  return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
471
526
  }
@@ -474,6 +529,21 @@ var TagIndex = class {
474
529
  this.keyToTags.clear();
475
530
  this.knownKeys.clear();
476
531
  }
532
+ pruneKnownKeysIfNeeded() {
533
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
534
+ return;
535
+ }
536
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
537
+ let removed = 0;
538
+ for (const key of this.knownKeys) {
539
+ if (removed >= toRemove) {
540
+ break;
541
+ }
542
+ this.knownKeys.delete(key);
543
+ this.keyToTags.delete(key);
544
+ removed += 1;
545
+ }
546
+ }
477
547
  };
478
548
 
479
549
  // src/stampede/StampedeGuard.ts
@@ -502,6 +572,16 @@ var StampedeGuard = class {
502
572
  }
503
573
  };
504
574
 
575
+ // src/types.ts
576
+ var CacheMissError = class extends Error {
577
+ key;
578
+ constructor(key) {
579
+ super(`Cache miss for key "${key}".`);
580
+ this.name = "CacheMissError";
581
+ this.key = key;
582
+ }
583
+ };
584
+
505
585
  // src/CacheStack.ts
506
586
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
507
587
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
@@ -628,6 +708,18 @@ var CacheStack = class extends EventEmitter {
628
708
  async getOrSet(key, fetcher, options) {
629
709
  return this.get(key, fetcher, options);
630
710
  }
711
+ /**
712
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
713
+ * Useful when the value is expected to exist or the fetcher is expected to
714
+ * return non-null.
715
+ */
716
+ async getOrThrow(key, fetcher, options) {
717
+ const value = await this.get(key, fetcher, options);
718
+ if (value === null) {
719
+ throw new CacheMissError(key);
720
+ }
721
+ return value;
722
+ }
631
723
  /**
632
724
  * Returns true if the given key exists and is not expired in any layer.
633
725
  */
@@ -901,6 +993,46 @@ var CacheStack = class extends EventEmitter {
901
993
  getHitRate() {
902
994
  return this.metricsCollector.hitRate();
903
995
  }
996
+ /**
997
+ * Returns detailed metadata about a single cache key: which layers contain it,
998
+ * remaining fresh/stale/error TTLs, and associated tags.
999
+ * Returns `null` if the key does not exist in any layer.
1000
+ */
1001
+ async inspect(key) {
1002
+ const normalizedKey = this.validateCacheKey(key);
1003
+ await this.startup;
1004
+ const foundInLayers = [];
1005
+ let freshTtlSeconds = null;
1006
+ let staleTtlSeconds = null;
1007
+ let errorTtlSeconds = null;
1008
+ let isStale = false;
1009
+ for (const layer of this.layers) {
1010
+ if (this.shouldSkipLayer(layer)) {
1011
+ continue;
1012
+ }
1013
+ const stored = await this.readLayerEntry(layer, normalizedKey);
1014
+ if (stored === null) {
1015
+ continue;
1016
+ }
1017
+ const resolved = resolveStoredValue(stored);
1018
+ if (resolved.state === "expired") {
1019
+ continue;
1020
+ }
1021
+ foundInLayers.push(layer.name);
1022
+ if (foundInLayers.length === 1 && resolved.envelope) {
1023
+ const now = Date.now();
1024
+ freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
1025
+ staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1026
+ errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1027
+ isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1028
+ }
1029
+ }
1030
+ if (foundInLayers.length === 0) {
1031
+ return null;
1032
+ }
1033
+ const tags = await this.getTagsForKey(normalizedKey);
1034
+ return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
1035
+ }
904
1036
  async exportState() {
905
1037
  await this.startup;
906
1038
  const exported = /* @__PURE__ */ new Map();
@@ -1037,6 +1169,9 @@ var CacheStack = class extends EventEmitter {
1037
1169
  await this.storeEntry(key, "empty", null, options);
1038
1170
  return null;
1039
1171
  }
1172
+ if (options?.shouldCache && !options.shouldCache(fetched)) {
1173
+ return fetched;
1174
+ }
1040
1175
  await this.storeEntry(key, "value", fetched, options);
1041
1176
  return fetched;
1042
1177
  }
@@ -1059,7 +1194,10 @@ var CacheStack = class extends EventEmitter {
1059
1194
  for (let index = 0; index < this.layers.length; index += 1) {
1060
1195
  const layer = this.layers[index];
1061
1196
  if (!layer) continue;
1197
+ const readStart = performance.now();
1062
1198
  const stored = await this.readLayerEntry(layer, key);
1199
+ const readDuration = performance.now() - readStart;
1200
+ this.metricsCollector.recordLatency(layer.name, readDuration);
1063
1201
  if (stored === null) {
1064
1202
  this.metricsCollector.incrementLayer("missesByLayer", layer.name);
1065
1203
  continue;
@@ -1261,6 +1399,12 @@ var CacheStack = class extends EventEmitter {
1261
1399
  }
1262
1400
  }
1263
1401
  }
1402
+ async getTagsForKey(key) {
1403
+ if (this.tagIndex.tagsForKey) {
1404
+ return this.tagIndex.tagsForKey(key);
1405
+ }
1406
+ return [];
1407
+ }
1264
1408
  formatError(error) {
1265
1409
  if (error instanceof Error) {
1266
1410
  return error.message;
@@ -1481,35 +1625,36 @@ var RedisInvalidationBus = class {
1481
1625
  channel;
1482
1626
  publisher;
1483
1627
  subscriber;
1484
- activeListener;
1628
+ handlers = /* @__PURE__ */ new Set();
1629
+ sharedListener;
1485
1630
  constructor(options) {
1486
1631
  this.publisher = options.publisher;
1487
1632
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
1488
1633
  this.channel = options.channel ?? "layercache:invalidation";
1489
1634
  }
1490
1635
  async subscribe(handler) {
1491
- if (this.activeListener) {
1492
- throw new Error("RedisInvalidationBus already has an active subscription.");
1636
+ if (this.handlers.size === 0) {
1637
+ const listener = (_channel, payload) => {
1638
+ void this.dispatchToHandlers(payload);
1639
+ };
1640
+ this.sharedListener = listener;
1641
+ this.subscriber.on("message", listener);
1642
+ await this.subscriber.subscribe(this.channel);
1493
1643
  }
1494
- const listener = (_channel, payload) => {
1495
- void this.handleMessage(payload, handler);
1496
- };
1497
- this.activeListener = listener;
1498
- this.subscriber.on("message", listener);
1499
- await this.subscriber.subscribe(this.channel);
1644
+ this.handlers.add(handler);
1500
1645
  return async () => {
1501
- if (this.activeListener !== listener) {
1502
- return;
1646
+ this.handlers.delete(handler);
1647
+ if (this.handlers.size === 0 && this.sharedListener) {
1648
+ this.subscriber.off("message", this.sharedListener);
1649
+ this.sharedListener = void 0;
1650
+ await this.subscriber.unsubscribe(this.channel);
1503
1651
  }
1504
- this.activeListener = void 0;
1505
- this.subscriber.off("message", listener);
1506
- await this.subscriber.unsubscribe(this.channel);
1507
1652
  };
1508
1653
  }
1509
1654
  async publish(message) {
1510
1655
  await this.publisher.publish(this.channel, JSON.stringify(message));
1511
1656
  }
1512
- async handleMessage(payload, handler) {
1657
+ async dispatchToHandlers(payload) {
1513
1658
  let message;
1514
1659
  try {
1515
1660
  const parsed = JSON.parse(payload);
@@ -1521,11 +1666,15 @@ var RedisInvalidationBus = class {
1521
1666
  this.reportError("invalid invalidation payload", error);
1522
1667
  return;
1523
1668
  }
1524
- try {
1525
- await handler(message);
1526
- } catch (error) {
1527
- this.reportError("invalidation handler failed", error);
1528
- }
1669
+ await Promise.all(
1670
+ [...this.handlers].map(async (handler) => {
1671
+ try {
1672
+ await handler(message);
1673
+ } catch (error) {
1674
+ this.reportError("invalidation handler failed", error);
1675
+ }
1676
+ })
1677
+ );
1529
1678
  }
1530
1679
  isInvalidationMessage(value) {
1531
1680
  if (!value || typeof value !== "object") {
@@ -1586,6 +1735,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
1586
1735
  };
1587
1736
  }
1588
1737
 
1738
+ // src/integrations/express.ts
1739
+ function createExpressCacheMiddleware(cache, options = {}) {
1740
+ const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
1741
+ return async (req, res, next) => {
1742
+ const method = (req.method ?? "GET").toUpperCase();
1743
+ if (!allowedMethods.has(method)) {
1744
+ next();
1745
+ return;
1746
+ }
1747
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
1748
+ const cached = await cache.get(key, void 0, options);
1749
+ if (cached !== null) {
1750
+ res.setHeader?.("content-type", "application/json; charset=utf-8");
1751
+ res.setHeader?.("x-cache", "HIT");
1752
+ if (res.json) {
1753
+ res.json(cached);
1754
+ } else {
1755
+ res.end?.(JSON.stringify(cached));
1756
+ }
1757
+ return;
1758
+ }
1759
+ const originalJson = res.json?.bind(res);
1760
+ if (originalJson) {
1761
+ res.json = (body) => {
1762
+ res.setHeader?.("x-cache", "MISS");
1763
+ void cache.set(key, body, options);
1764
+ return originalJson(body);
1765
+ };
1766
+ }
1767
+ next();
1768
+ };
1769
+ }
1770
+
1589
1771
  // src/integrations/graphql.ts
1590
1772
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1591
1773
  const wrapped = cache.wrap(prefix, resolver, {
@@ -1649,10 +1831,10 @@ var MemoryLayer = class {
1649
1831
  }
1650
1832
  if (this.evictionPolicy === "lru") {
1651
1833
  this.entries.delete(key);
1652
- entry.frequency += 1;
1834
+ entry.accessCount += 1;
1653
1835
  this.entries.set(key, entry);
1654
- } else {
1655
- entry.frequency += 1;
1836
+ } else if (this.evictionPolicy === "lfu") {
1837
+ entry.accessCount += 1;
1656
1838
  }
1657
1839
  return entry.value;
1658
1840
  }
@@ -1668,7 +1850,7 @@ var MemoryLayer = class {
1668
1850
  this.entries.set(key, {
1669
1851
  value,
1670
1852
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1671
- frequency: 0,
1853
+ accessCount: 0,
1672
1854
  insertedAt: Date.now()
1673
1855
  });
1674
1856
  while (this.entries.size > this.maxSize) {
@@ -1735,7 +1917,7 @@ var MemoryLayer = class {
1735
1917
  this.entries.set(entry.key, {
1736
1918
  value: entry.value,
1737
1919
  expiresAt: entry.expiresAt,
1738
- frequency: 0,
1920
+ accessCount: 0,
1739
1921
  insertedAt: Date.now()
1740
1922
  });
1741
1923
  }
@@ -1752,11 +1934,11 @@ var MemoryLayer = class {
1752
1934
  return;
1753
1935
  }
1754
1936
  let victimKey;
1755
- let minFreq = Number.POSITIVE_INFINITY;
1937
+ let minCount = Number.POSITIVE_INFINITY;
1756
1938
  let minInsertedAt = Number.POSITIVE_INFINITY;
1757
1939
  for (const [key, entry] of this.entries.entries()) {
1758
- if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1759
- minFreq = entry.frequency;
1940
+ if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
1941
+ minCount = entry.accessCount;
1760
1942
  minInsertedAt = entry.insertedAt;
1761
1943
  victimKey = key;
1762
1944
  }
@@ -1778,7 +1960,8 @@ var MemoryLayer = class {
1778
1960
  };
1779
1961
 
1780
1962
  // src/layers/RedisLayer.ts
1781
- import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "zlib";
1963
+ import { promisify } from "util";
1964
+ import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
1782
1965
 
1783
1966
  // src/serialization/JsonSerializer.ts
1784
1967
  var JsonSerializer = class {
@@ -1793,6 +1976,10 @@ var JsonSerializer = class {
1793
1976
 
1794
1977
  // src/layers/RedisLayer.ts
1795
1978
  var BATCH_DELETE_SIZE = 500;
1979
+ var gzipAsync = promisify(gzip);
1980
+ var gunzipAsync = promisify(gunzip);
1981
+ var brotliCompressAsync = promisify(brotliCompress);
1982
+ var brotliDecompressAsync = promisify(brotliDecompress);
1796
1983
  var RedisLayer = class {
1797
1984
  name;
1798
1985
  defaultTtl;
@@ -1849,7 +2036,8 @@ var RedisLayer = class {
1849
2036
  );
1850
2037
  }
1851
2038
  async set(key, value, ttl = this.defaultTtl) {
1852
- const payload = this.encodePayload(this.serializer.serialize(value));
2039
+ const serialized = this.serializer.serialize(value);
2040
+ const payload = await this.encodePayload(serialized);
1853
2041
  const normalizedKey = this.withPrefix(key);
1854
2042
  if (ttl && ttl > 0) {
1855
2043
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -1928,7 +2116,7 @@ var RedisLayer = class {
1928
2116
  }
1929
2117
  async deserializeOrDelete(key, payload) {
1930
2118
  try {
1931
- return this.serializer.deserialize(this.decodePayload(payload));
2119
+ return this.serializer.deserialize(await this.decodePayload(payload));
1932
2120
  } catch {
1933
2121
  await this.client.del(this.withPrefix(key)).catch(() => void 0);
1934
2122
  return null;
@@ -1937,7 +2125,11 @@ var RedisLayer = class {
1937
2125
  isSerializablePayload(payload) {
1938
2126
  return typeof payload === "string" || Buffer.isBuffer(payload);
1939
2127
  }
1940
- encodePayload(payload) {
2128
+ /**
2129
+ * Compresses the payload asynchronously if compression is enabled and the
2130
+ * payload exceeds the threshold. This avoids blocking the event loop.
2131
+ */
2132
+ async encodePayload(payload) {
1941
2133
  if (!this.compression) {
1942
2134
  return payload;
1943
2135
  }
@@ -1946,18 +2138,21 @@ var RedisLayer = class {
1946
2138
  return payload;
1947
2139
  }
1948
2140
  const header = Buffer.from(`LCZ1:${this.compression}:`);
1949
- const compressed = this.compression === "gzip" ? gzipSync(source) : brotliCompressSync(source);
2141
+ const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
1950
2142
  return Buffer.concat([header, compressed]);
1951
2143
  }
1952
- decodePayload(payload) {
2144
+ /**
2145
+ * Decompresses the payload asynchronously if a compression header is present.
2146
+ */
2147
+ async decodePayload(payload) {
1953
2148
  if (!Buffer.isBuffer(payload)) {
1954
2149
  return payload;
1955
2150
  }
1956
2151
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
1957
- return gunzipSync(payload.subarray(10));
2152
+ return gunzipAsync(payload.subarray(10));
1958
2153
  }
1959
2154
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
1960
- return brotliDecompressSync(payload.subarray(12));
2155
+ return brotliDecompressAsync(payload.subarray(12));
1961
2156
  }
1962
2157
  return payload;
1963
2158
  }
@@ -1973,11 +2168,13 @@ var DiskLayer = class {
1973
2168
  isLocal = true;
1974
2169
  directory;
1975
2170
  serializer;
2171
+ maxFiles;
1976
2172
  constructor(options) {
1977
2173
  this.directory = options.directory;
1978
2174
  this.defaultTtl = options.ttl;
1979
2175
  this.name = options.name ?? "disk";
1980
2176
  this.serializer = options.serializer ?? new JsonSerializer();
2177
+ this.maxFiles = options.maxFiles;
1981
2178
  }
1982
2179
  async get(key) {
1983
2180
  return unwrapStoredValue(await this.getEntry(key));
@@ -2006,11 +2203,15 @@ var DiskLayer = class {
2006
2203
  async set(key, value, ttl = this.defaultTtl) {
2007
2204
  await fs2.mkdir(this.directory, { recursive: true });
2008
2205
  const entry = {
2206
+ key,
2009
2207
  value,
2010
2208
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2011
2209
  };
2012
2210
  const payload = this.serializer.serialize(entry);
2013
2211
  await fs2.writeFile(this.keyToPath(key), payload);
2212
+ if (this.maxFiles !== void 0) {
2213
+ await this.enforceMaxFiles();
2214
+ }
2014
2215
  }
2015
2216
  async has(key) {
2016
2217
  const value = await this.getEntry(key);
@@ -2056,6 +2257,10 @@ var DiskLayer = class {
2056
2257
  entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
2057
2258
  );
2058
2259
  }
2260
+ /**
2261
+ * Returns the original cache key strings stored on disk.
2262
+ * Expired entries are skipped and cleaned up during the scan.
2263
+ */
2059
2264
  async keys() {
2060
2265
  let entries;
2061
2266
  try {
@@ -2063,7 +2268,32 @@ var DiskLayer = class {
2063
2268
  } catch {
2064
2269
  return [];
2065
2270
  }
2066
- return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2271
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2272
+ const keys = [];
2273
+ await Promise.all(
2274
+ lcFiles.map(async (name) => {
2275
+ const filePath = join(this.directory, name);
2276
+ let raw;
2277
+ try {
2278
+ raw = await fs2.readFile(filePath);
2279
+ } catch {
2280
+ return;
2281
+ }
2282
+ let entry;
2283
+ try {
2284
+ entry = this.serializer.deserialize(raw);
2285
+ } catch {
2286
+ await this.safeDelete(filePath);
2287
+ return;
2288
+ }
2289
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2290
+ await this.safeDelete(filePath);
2291
+ return;
2292
+ }
2293
+ keys.push(entry.key);
2294
+ })
2295
+ );
2296
+ return keys;
2067
2297
  }
2068
2298
  async size() {
2069
2299
  const keys = await this.keys();
@@ -2079,6 +2309,38 @@ var DiskLayer = class {
2079
2309
  } catch {
2080
2310
  }
2081
2311
  }
2312
+ /**
2313
+ * Removes the oldest files (by mtime) when the directory exceeds maxFiles.
2314
+ */
2315
+ async enforceMaxFiles() {
2316
+ if (this.maxFiles === void 0) {
2317
+ return;
2318
+ }
2319
+ let entries;
2320
+ try {
2321
+ entries = await fs2.readdir(this.directory);
2322
+ } catch {
2323
+ return;
2324
+ }
2325
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2326
+ if (lcFiles.length <= this.maxFiles) {
2327
+ return;
2328
+ }
2329
+ const withStats = await Promise.all(
2330
+ lcFiles.map(async (name) => {
2331
+ const filePath = join(this.directory, name);
2332
+ try {
2333
+ const stat = await fs2.stat(filePath);
2334
+ return { filePath, mtimeMs: stat.mtimeMs };
2335
+ } catch {
2336
+ return { filePath, mtimeMs: 0 };
2337
+ }
2338
+ })
2339
+ );
2340
+ withStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
2341
+ const toEvict = withStats.slice(0, lcFiles.length - this.maxFiles);
2342
+ await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
2343
+ }
2082
2344
  };
2083
2345
 
2084
2346
  // src/layers/MemcachedLayer.ts
@@ -2088,29 +2350,41 @@ var MemcachedLayer = class {
2088
2350
  isLocal = false;
2089
2351
  client;
2090
2352
  keyPrefix;
2353
+ serializer;
2091
2354
  constructor(options) {
2092
2355
  this.client = options.client;
2093
2356
  this.defaultTtl = options.ttl;
2094
2357
  this.name = options.name ?? "memcached";
2095
2358
  this.keyPrefix = options.keyPrefix ?? "";
2359
+ this.serializer = options.serializer ?? new JsonSerializer();
2096
2360
  }
2097
2361
  async get(key) {
2362
+ return unwrapStoredValue(await this.getEntry(key));
2363
+ }
2364
+ async getEntry(key) {
2098
2365
  const result = await this.client.get(this.withPrefix(key));
2099
2366
  if (!result || result.value === null) {
2100
2367
  return null;
2101
2368
  }
2102
2369
  try {
2103
- return JSON.parse(result.value.toString("utf8"));
2370
+ return this.serializer.deserialize(result.value);
2104
2371
  } catch {
2105
2372
  return null;
2106
2373
  }
2107
2374
  }
2375
+ async getMany(keys) {
2376
+ return Promise.all(keys.map((key) => this.getEntry(key)));
2377
+ }
2108
2378
  async set(key, value, ttl = this.defaultTtl) {
2109
- const payload = JSON.stringify(value);
2379
+ const payload = this.serializer.serialize(value);
2110
2380
  await this.client.set(this.withPrefix(key), payload, {
2111
2381
  expires: ttl && ttl > 0 ? ttl : void 0
2112
2382
  });
2113
2383
  }
2384
+ async has(key) {
2385
+ const result = await this.client.get(this.withPrefix(key));
2386
+ return result !== null && result.value !== null;
2387
+ }
2114
2388
  async delete(key) {
2115
2389
  await this.client.delete(this.withPrefix(key));
2116
2390
  }
@@ -2204,6 +2478,12 @@ function createPrometheusMetricsExporter(stacks) {
2204
2478
  lines.push("# TYPE layercache_hits_by_layer_total counter");
2205
2479
  lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2206
2480
  lines.push("# TYPE layercache_misses_by_layer_total counter");
2481
+ lines.push("# HELP layercache_layer_latency_avg_ms Average read latency per layer in milliseconds");
2482
+ lines.push("# TYPE layercache_layer_latency_avg_ms gauge");
2483
+ lines.push("# HELP layercache_layer_latency_max_ms Maximum read latency per layer in milliseconds");
2484
+ lines.push("# TYPE layercache_layer_latency_max_ms gauge");
2485
+ lines.push("# HELP layercache_layer_latency_count Number of read latency samples per layer");
2486
+ lines.push("# TYPE layercache_layer_latency_count counter");
2207
2487
  for (const { stack, name } of entries) {
2208
2488
  const m = stack.getMetrics();
2209
2489
  const hr = stack.getHitRate();
@@ -2227,6 +2507,12 @@ function createPrometheusMetricsExporter(stacks) {
2227
2507
  for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2228
2508
  lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2229
2509
  }
2510
+ for (const [layerName, latency] of Object.entries(m.latencyByLayer)) {
2511
+ const layerLabel = `${label},layer="${sanitizeLabel(layerName)}"`;
2512
+ lines.push(`layercache_layer_latency_avg_ms{${layerLabel}} ${latency.avgMs.toFixed(4)}`);
2513
+ lines.push(`layercache_layer_latency_max_ms{${layerLabel}} ${latency.maxMs.toFixed(4)}`);
2514
+ lines.push(`layercache_layer_latency_count{${layerLabel}} ${latency.count}`);
2515
+ }
2230
2516
  }
2231
2517
  lines.push("");
2232
2518
  return lines.join("\n");
@@ -2236,6 +2522,7 @@ function sanitizeLabel(value) {
2236
2522
  return value.replace(/["\\\n]/g, "_");
2237
2523
  }
2238
2524
  export {
2525
+ CacheMissError,
2239
2526
  CacheNamespace,
2240
2527
  CacheStack,
2241
2528
  DiskLayer,
@@ -2253,6 +2540,7 @@ export {
2253
2540
  cacheGraphqlResolver,
2254
2541
  createCacheStatsHandler,
2255
2542
  createCachedMethodDecorator,
2543
+ createExpressCacheMiddleware,
2256
2544
  createFastifyLayercachePlugin,
2257
2545
  createPrometheusMetricsExporter,
2258
2546
  createTrpcCacheMiddleware