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.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CacheMissError: () => CacheMissError,
23
24
  CacheNamespace: () => CacheNamespace,
24
25
  CacheStack: () => CacheStack,
25
26
  DiskLayer: () => DiskLayer,
@@ -37,6 +38,7 @@ __export(index_exports, {
37
38
  cacheGraphqlResolver: () => cacheGraphqlResolver,
38
39
  createCacheStatsHandler: () => createCacheStatsHandler,
39
40
  createCachedMethodDecorator: () => createCachedMethodDecorator,
41
+ createExpressCacheMiddleware: () => createExpressCacheMiddleware,
40
42
  createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
41
43
  createPrometheusMetricsExporter: () => createPrometheusMetricsExporter,
42
44
  createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
@@ -49,7 +51,7 @@ var import_node_events = require("events");
49
51
  var import_node_fs = require("fs");
50
52
 
51
53
  // src/CacheNamespace.ts
52
- var CacheNamespace = class {
54
+ var CacheNamespace = class _CacheNamespace {
53
55
  constructor(cache, prefix) {
54
56
  this.cache = cache;
55
57
  this.prefix = prefix;
@@ -62,6 +64,12 @@ var CacheNamespace = class {
62
64
  async getOrSet(key, fetcher, options) {
63
65
  return this.cache.getOrSet(this.qualify(key), fetcher, options);
64
66
  }
67
+ /**
68
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
69
+ */
70
+ async getOrThrow(key, fetcher, options) {
71
+ return this.cache.getOrThrow(this.qualify(key), fetcher, options);
72
+ }
65
73
  async has(key) {
66
74
  return this.cache.has(this.qualify(key));
67
75
  }
@@ -102,6 +110,12 @@ var CacheNamespace = class {
102
110
  async invalidateByPattern(pattern) {
103
111
  await this.cache.invalidateByPattern(this.qualify(pattern));
104
112
  }
113
+ /**
114
+ * Returns detailed metadata about a single cache key within this namespace.
115
+ */
116
+ async inspect(key) {
117
+ return this.cache.inspect(this.qualify(key));
118
+ }
105
119
  wrap(keyPrefix, fetcher, options) {
106
120
  return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
107
121
  }
@@ -120,6 +134,18 @@ var CacheNamespace = class {
120
134
  getHitRate() {
121
135
  return this.cache.getHitRate();
122
136
  }
137
+ /**
138
+ * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
139
+ *
140
+ * ```ts
141
+ * const tenant = cache.namespace('tenant:abc')
142
+ * const posts = tenant.namespace('posts')
143
+ * // keys become: "tenant:abc:posts:mykey"
144
+ * ```
145
+ */
146
+ namespace(childPrefix) {
147
+ return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
148
+ }
123
149
  qualify(key) {
124
150
  return `${this.prefix}:${key}`;
125
151
  }
@@ -221,7 +247,12 @@ var CircuitBreakerManager = class {
221
247
  var MetricsCollector = class {
222
248
  data = this.empty();
223
249
  get snapshot() {
224
- return { ...this.data };
250
+ return {
251
+ ...this.data,
252
+ hitsByLayer: { ...this.data.hitsByLayer },
253
+ missesByLayer: { ...this.data.missesByLayer },
254
+ latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
255
+ };
225
256
  }
226
257
  increment(field, amount = 1) {
227
258
  ;
@@ -230,6 +261,22 @@ var MetricsCollector = class {
230
261
  incrementLayer(map, layerName) {
231
262
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
232
263
  }
264
+ /**
265
+ * Records a read latency sample for the given layer.
266
+ * Maintains a rolling average and max using Welford's online algorithm.
267
+ */
268
+ recordLatency(layerName, durationMs) {
269
+ const existing = this.data.latencyByLayer[layerName];
270
+ if (!existing) {
271
+ this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
272
+ return;
273
+ }
274
+ existing.count += 1;
275
+ existing.avgMs += (durationMs - existing.avgMs) / existing.count;
276
+ if (durationMs > existing.maxMs) {
277
+ existing.maxMs = durationMs;
278
+ }
279
+ }
233
280
  reset() {
234
281
  this.data = this.empty();
235
282
  }
@@ -264,6 +311,7 @@ var MetricsCollector = class {
264
311
  degradedOperations: 0,
265
312
  hitsByLayer: {},
266
313
  missesByLayer: {},
314
+ latencyByLayer: {},
267
315
  resetAt: Date.now()
268
316
  };
269
317
  }
@@ -501,11 +549,17 @@ var TagIndex = class {
501
549
  tagToKeys = /* @__PURE__ */ new Map();
502
550
  keyToTags = /* @__PURE__ */ new Map();
503
551
  knownKeys = /* @__PURE__ */ new Set();
552
+ maxKnownKeys;
553
+ constructor(options = {}) {
554
+ this.maxKnownKeys = options.maxKnownKeys;
555
+ }
504
556
  async touch(key) {
505
557
  this.knownKeys.add(key);
558
+ this.pruneKnownKeysIfNeeded();
506
559
  }
507
560
  async track(key, tags) {
508
561
  this.knownKeys.add(key);
562
+ this.pruneKnownKeysIfNeeded();
509
563
  if (tags.length === 0) {
510
564
  return;
511
565
  }
@@ -544,6 +598,9 @@ var TagIndex = class {
544
598
  async keysForTag(tag) {
545
599
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
546
600
  }
601
+ async tagsForKey(key) {
602
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
603
+ }
547
604
  async matchPattern(pattern) {
548
605
  return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
549
606
  }
@@ -552,6 +609,21 @@ var TagIndex = class {
552
609
  this.keyToTags.clear();
553
610
  this.knownKeys.clear();
554
611
  }
612
+ pruneKnownKeysIfNeeded() {
613
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
614
+ return;
615
+ }
616
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
617
+ let removed = 0;
618
+ for (const key of this.knownKeys) {
619
+ if (removed >= toRemove) {
620
+ break;
621
+ }
622
+ this.knownKeys.delete(key);
623
+ this.keyToTags.delete(key);
624
+ removed += 1;
625
+ }
626
+ }
555
627
  };
556
628
 
557
629
  // src/stampede/StampedeGuard.ts
@@ -580,6 +652,16 @@ var StampedeGuard = class {
580
652
  }
581
653
  };
582
654
 
655
+ // src/types.ts
656
+ var CacheMissError = class extends Error {
657
+ key;
658
+ constructor(key) {
659
+ super(`Cache miss for key "${key}".`);
660
+ this.name = "CacheMissError";
661
+ this.key = key;
662
+ }
663
+ };
664
+
583
665
  // src/CacheStack.ts
584
666
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
585
667
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
@@ -706,6 +788,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
706
788
  async getOrSet(key, fetcher, options) {
707
789
  return this.get(key, fetcher, options);
708
790
  }
791
+ /**
792
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
793
+ * Useful when the value is expected to exist or the fetcher is expected to
794
+ * return non-null.
795
+ */
796
+ async getOrThrow(key, fetcher, options) {
797
+ const value = await this.get(key, fetcher, options);
798
+ if (value === null) {
799
+ throw new CacheMissError(key);
800
+ }
801
+ return value;
802
+ }
709
803
  /**
710
804
  * Returns true if the given key exists and is not expired in any layer.
711
805
  */
@@ -979,6 +1073,46 @@ var CacheStack = class extends import_node_events.EventEmitter {
979
1073
  getHitRate() {
980
1074
  return this.metricsCollector.hitRate();
981
1075
  }
1076
+ /**
1077
+ * Returns detailed metadata about a single cache key: which layers contain it,
1078
+ * remaining fresh/stale/error TTLs, and associated tags.
1079
+ * Returns `null` if the key does not exist in any layer.
1080
+ */
1081
+ async inspect(key) {
1082
+ const normalizedKey = this.validateCacheKey(key);
1083
+ await this.startup;
1084
+ const foundInLayers = [];
1085
+ let freshTtlSeconds = null;
1086
+ let staleTtlSeconds = null;
1087
+ let errorTtlSeconds = null;
1088
+ let isStale = false;
1089
+ for (const layer of this.layers) {
1090
+ if (this.shouldSkipLayer(layer)) {
1091
+ continue;
1092
+ }
1093
+ const stored = await this.readLayerEntry(layer, normalizedKey);
1094
+ if (stored === null) {
1095
+ continue;
1096
+ }
1097
+ const resolved = resolveStoredValue(stored);
1098
+ if (resolved.state === "expired") {
1099
+ continue;
1100
+ }
1101
+ foundInLayers.push(layer.name);
1102
+ if (foundInLayers.length === 1 && resolved.envelope) {
1103
+ const now = Date.now();
1104
+ freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
1105
+ staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1106
+ errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1107
+ isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1108
+ }
1109
+ }
1110
+ if (foundInLayers.length === 0) {
1111
+ return null;
1112
+ }
1113
+ const tags = await this.getTagsForKey(normalizedKey);
1114
+ return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
1115
+ }
982
1116
  async exportState() {
983
1117
  await this.startup;
984
1118
  const exported = /* @__PURE__ */ new Map();
@@ -1115,6 +1249,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1115
1249
  await this.storeEntry(key, "empty", null, options);
1116
1250
  return null;
1117
1251
  }
1252
+ if (options?.shouldCache && !options.shouldCache(fetched)) {
1253
+ return fetched;
1254
+ }
1118
1255
  await this.storeEntry(key, "value", fetched, options);
1119
1256
  return fetched;
1120
1257
  }
@@ -1137,7 +1274,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
1137
1274
  for (let index = 0; index < this.layers.length; index += 1) {
1138
1275
  const layer = this.layers[index];
1139
1276
  if (!layer) continue;
1277
+ const readStart = performance.now();
1140
1278
  const stored = await this.readLayerEntry(layer, key);
1279
+ const readDuration = performance.now() - readStart;
1280
+ this.metricsCollector.recordLatency(layer.name, readDuration);
1141
1281
  if (stored === null) {
1142
1282
  this.metricsCollector.incrementLayer("missesByLayer", layer.name);
1143
1283
  continue;
@@ -1339,6 +1479,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
1339
1479
  }
1340
1480
  }
1341
1481
  }
1482
+ async getTagsForKey(key) {
1483
+ if (this.tagIndex.tagsForKey) {
1484
+ return this.tagIndex.tagsForKey(key);
1485
+ }
1486
+ return [];
1487
+ }
1342
1488
  formatError(error) {
1343
1489
  if (error instanceof Error) {
1344
1490
  return error.message;
@@ -1559,35 +1705,36 @@ var RedisInvalidationBus = class {
1559
1705
  channel;
1560
1706
  publisher;
1561
1707
  subscriber;
1562
- activeListener;
1708
+ handlers = /* @__PURE__ */ new Set();
1709
+ sharedListener;
1563
1710
  constructor(options) {
1564
1711
  this.publisher = options.publisher;
1565
1712
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
1566
1713
  this.channel = options.channel ?? "layercache:invalidation";
1567
1714
  }
1568
1715
  async subscribe(handler) {
1569
- if (this.activeListener) {
1570
- throw new Error("RedisInvalidationBus already has an active subscription.");
1716
+ if (this.handlers.size === 0) {
1717
+ const listener = (_channel, payload) => {
1718
+ void this.dispatchToHandlers(payload);
1719
+ };
1720
+ this.sharedListener = listener;
1721
+ this.subscriber.on("message", listener);
1722
+ await this.subscriber.subscribe(this.channel);
1571
1723
  }
1572
- const listener = (_channel, payload) => {
1573
- void this.handleMessage(payload, handler);
1574
- };
1575
- this.activeListener = listener;
1576
- this.subscriber.on("message", listener);
1577
- await this.subscriber.subscribe(this.channel);
1724
+ this.handlers.add(handler);
1578
1725
  return async () => {
1579
- if (this.activeListener !== listener) {
1580
- return;
1726
+ this.handlers.delete(handler);
1727
+ if (this.handlers.size === 0 && this.sharedListener) {
1728
+ this.subscriber.off("message", this.sharedListener);
1729
+ this.sharedListener = void 0;
1730
+ await this.subscriber.unsubscribe(this.channel);
1581
1731
  }
1582
- this.activeListener = void 0;
1583
- this.subscriber.off("message", listener);
1584
- await this.subscriber.unsubscribe(this.channel);
1585
1732
  };
1586
1733
  }
1587
1734
  async publish(message) {
1588
1735
  await this.publisher.publish(this.channel, JSON.stringify(message));
1589
1736
  }
1590
- async handleMessage(payload, handler) {
1737
+ async dispatchToHandlers(payload) {
1591
1738
  let message;
1592
1739
  try {
1593
1740
  const parsed = JSON.parse(payload);
@@ -1599,11 +1746,15 @@ var RedisInvalidationBus = class {
1599
1746
  this.reportError("invalid invalidation payload", error);
1600
1747
  return;
1601
1748
  }
1602
- try {
1603
- await handler(message);
1604
- } catch (error) {
1605
- this.reportError("invalidation handler failed", error);
1606
- }
1749
+ await Promise.all(
1750
+ [...this.handlers].map(async (handler) => {
1751
+ try {
1752
+ await handler(message);
1753
+ } catch (error) {
1754
+ this.reportError("invalidation handler failed", error);
1755
+ }
1756
+ })
1757
+ );
1607
1758
  }
1608
1759
  isInvalidationMessage(value) {
1609
1760
  if (!value || typeof value !== "object") {
@@ -1664,6 +1815,9 @@ var RedisTagIndex = class {
1664
1815
  async keysForTag(tag) {
1665
1816
  return this.client.smembers(this.tagKeysKey(tag));
1666
1817
  }
1818
+ async tagsForKey(key) {
1819
+ return this.client.smembers(this.keyTagsKey(key));
1820
+ }
1667
1821
  async matchPattern(pattern) {
1668
1822
  const matches = [];
1669
1823
  let cursor = "0";
@@ -1754,6 +1908,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
1754
1908
  };
1755
1909
  }
1756
1910
 
1911
+ // src/integrations/express.ts
1912
+ function createExpressCacheMiddleware(cache, options = {}) {
1913
+ const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
1914
+ return async (req, res, next) => {
1915
+ const method = (req.method ?? "GET").toUpperCase();
1916
+ if (!allowedMethods.has(method)) {
1917
+ next();
1918
+ return;
1919
+ }
1920
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
1921
+ const cached = await cache.get(key, void 0, options);
1922
+ if (cached !== null) {
1923
+ res.setHeader?.("content-type", "application/json; charset=utf-8");
1924
+ res.setHeader?.("x-cache", "HIT");
1925
+ if (res.json) {
1926
+ res.json(cached);
1927
+ } else {
1928
+ res.end?.(JSON.stringify(cached));
1929
+ }
1930
+ return;
1931
+ }
1932
+ const originalJson = res.json?.bind(res);
1933
+ if (originalJson) {
1934
+ res.json = (body) => {
1935
+ res.setHeader?.("x-cache", "MISS");
1936
+ void cache.set(key, body, options);
1937
+ return originalJson(body);
1938
+ };
1939
+ }
1940
+ next();
1941
+ };
1942
+ }
1943
+
1757
1944
  // src/integrations/graphql.ts
1758
1945
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1759
1946
  const wrapped = cache.wrap(prefix, resolver, {
@@ -1817,10 +2004,10 @@ var MemoryLayer = class {
1817
2004
  }
1818
2005
  if (this.evictionPolicy === "lru") {
1819
2006
  this.entries.delete(key);
1820
- entry.frequency += 1;
2007
+ entry.accessCount += 1;
1821
2008
  this.entries.set(key, entry);
1822
- } else {
1823
- entry.frequency += 1;
2009
+ } else if (this.evictionPolicy === "lfu") {
2010
+ entry.accessCount += 1;
1824
2011
  }
1825
2012
  return entry.value;
1826
2013
  }
@@ -1836,7 +2023,7 @@ var MemoryLayer = class {
1836
2023
  this.entries.set(key, {
1837
2024
  value,
1838
2025
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1839
- frequency: 0,
2026
+ accessCount: 0,
1840
2027
  insertedAt: Date.now()
1841
2028
  });
1842
2029
  while (this.entries.size > this.maxSize) {
@@ -1903,7 +2090,7 @@ var MemoryLayer = class {
1903
2090
  this.entries.set(entry.key, {
1904
2091
  value: entry.value,
1905
2092
  expiresAt: entry.expiresAt,
1906
- frequency: 0,
2093
+ accessCount: 0,
1907
2094
  insertedAt: Date.now()
1908
2095
  });
1909
2096
  }
@@ -1920,11 +2107,11 @@ var MemoryLayer = class {
1920
2107
  return;
1921
2108
  }
1922
2109
  let victimKey;
1923
- let minFreq = Number.POSITIVE_INFINITY;
2110
+ let minCount = Number.POSITIVE_INFINITY;
1924
2111
  let minInsertedAt = Number.POSITIVE_INFINITY;
1925
2112
  for (const [key, entry] of this.entries.entries()) {
1926
- if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1927
- minFreq = entry.frequency;
2113
+ if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
2114
+ minCount = entry.accessCount;
1928
2115
  minInsertedAt = entry.insertedAt;
1929
2116
  victimKey = key;
1930
2117
  }
@@ -1946,6 +2133,7 @@ var MemoryLayer = class {
1946
2133
  };
1947
2134
 
1948
2135
  // src/layers/RedisLayer.ts
2136
+ var import_node_util = require("util");
1949
2137
  var import_node_zlib = require("zlib");
1950
2138
 
1951
2139
  // src/serialization/JsonSerializer.ts
@@ -1961,6 +2149,10 @@ var JsonSerializer = class {
1961
2149
 
1962
2150
  // src/layers/RedisLayer.ts
1963
2151
  var BATCH_DELETE_SIZE = 500;
2152
+ var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
2153
+ var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
2154
+ var brotliCompressAsync = (0, import_node_util.promisify)(import_node_zlib.brotliCompress);
2155
+ var brotliDecompressAsync = (0, import_node_util.promisify)(import_node_zlib.brotliDecompress);
1964
2156
  var RedisLayer = class {
1965
2157
  name;
1966
2158
  defaultTtl;
@@ -2017,7 +2209,8 @@ var RedisLayer = class {
2017
2209
  );
2018
2210
  }
2019
2211
  async set(key, value, ttl = this.defaultTtl) {
2020
- const payload = this.encodePayload(this.serializer.serialize(value));
2212
+ const serialized = this.serializer.serialize(value);
2213
+ const payload = await this.encodePayload(serialized);
2021
2214
  const normalizedKey = this.withPrefix(key);
2022
2215
  if (ttl && ttl > 0) {
2023
2216
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -2096,7 +2289,7 @@ var RedisLayer = class {
2096
2289
  }
2097
2290
  async deserializeOrDelete(key, payload) {
2098
2291
  try {
2099
- return this.serializer.deserialize(this.decodePayload(payload));
2292
+ return this.serializer.deserialize(await this.decodePayload(payload));
2100
2293
  } catch {
2101
2294
  await this.client.del(this.withPrefix(key)).catch(() => void 0);
2102
2295
  return null;
@@ -2105,7 +2298,11 @@ var RedisLayer = class {
2105
2298
  isSerializablePayload(payload) {
2106
2299
  return typeof payload === "string" || Buffer.isBuffer(payload);
2107
2300
  }
2108
- encodePayload(payload) {
2301
+ /**
2302
+ * Compresses the payload asynchronously if compression is enabled and the
2303
+ * payload exceeds the threshold. This avoids blocking the event loop.
2304
+ */
2305
+ async encodePayload(payload) {
2109
2306
  if (!this.compression) {
2110
2307
  return payload;
2111
2308
  }
@@ -2114,18 +2311,21 @@ var RedisLayer = class {
2114
2311
  return payload;
2115
2312
  }
2116
2313
  const header = Buffer.from(`LCZ1:${this.compression}:`);
2117
- const compressed = this.compression === "gzip" ? (0, import_node_zlib.gzipSync)(source) : (0, import_node_zlib.brotliCompressSync)(source);
2314
+ const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
2118
2315
  return Buffer.concat([header, compressed]);
2119
2316
  }
2120
- decodePayload(payload) {
2317
+ /**
2318
+ * Decompresses the payload asynchronously if a compression header is present.
2319
+ */
2320
+ async decodePayload(payload) {
2121
2321
  if (!Buffer.isBuffer(payload)) {
2122
2322
  return payload;
2123
2323
  }
2124
2324
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2125
- return (0, import_node_zlib.gunzipSync)(payload.subarray(10));
2325
+ return gunzipAsync(payload.subarray(10));
2126
2326
  }
2127
2327
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2128
- return (0, import_node_zlib.brotliDecompressSync)(payload.subarray(12));
2328
+ return brotliDecompressAsync(payload.subarray(12));
2129
2329
  }
2130
2330
  return payload;
2131
2331
  }
@@ -2141,11 +2341,13 @@ var DiskLayer = class {
2141
2341
  isLocal = true;
2142
2342
  directory;
2143
2343
  serializer;
2344
+ maxFiles;
2144
2345
  constructor(options) {
2145
2346
  this.directory = options.directory;
2146
2347
  this.defaultTtl = options.ttl;
2147
2348
  this.name = options.name ?? "disk";
2148
2349
  this.serializer = options.serializer ?? new JsonSerializer();
2350
+ this.maxFiles = options.maxFiles;
2149
2351
  }
2150
2352
  async get(key) {
2151
2353
  return unwrapStoredValue(await this.getEntry(key));
@@ -2174,11 +2376,15 @@ var DiskLayer = class {
2174
2376
  async set(key, value, ttl = this.defaultTtl) {
2175
2377
  await import_node_fs2.promises.mkdir(this.directory, { recursive: true });
2176
2378
  const entry = {
2379
+ key,
2177
2380
  value,
2178
2381
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2179
2382
  };
2180
2383
  const payload = this.serializer.serialize(entry);
2181
2384
  await import_node_fs2.promises.writeFile(this.keyToPath(key), payload);
2385
+ if (this.maxFiles !== void 0) {
2386
+ await this.enforceMaxFiles();
2387
+ }
2182
2388
  }
2183
2389
  async has(key) {
2184
2390
  const value = await this.getEntry(key);
@@ -2224,6 +2430,10 @@ var DiskLayer = class {
2224
2430
  entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete((0, import_node_path.join)(this.directory, name)))
2225
2431
  );
2226
2432
  }
2433
+ /**
2434
+ * Returns the original cache key strings stored on disk.
2435
+ * Expired entries are skipped and cleaned up during the scan.
2436
+ */
2227
2437
  async keys() {
2228
2438
  let entries;
2229
2439
  try {
@@ -2231,7 +2441,32 @@ var DiskLayer = class {
2231
2441
  } catch {
2232
2442
  return [];
2233
2443
  }
2234
- return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2444
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2445
+ const keys = [];
2446
+ await Promise.all(
2447
+ lcFiles.map(async (name) => {
2448
+ const filePath = (0, import_node_path.join)(this.directory, name);
2449
+ let raw;
2450
+ try {
2451
+ raw = await import_node_fs2.promises.readFile(filePath);
2452
+ } catch {
2453
+ return;
2454
+ }
2455
+ let entry;
2456
+ try {
2457
+ entry = this.serializer.deserialize(raw);
2458
+ } catch {
2459
+ await this.safeDelete(filePath);
2460
+ return;
2461
+ }
2462
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2463
+ await this.safeDelete(filePath);
2464
+ return;
2465
+ }
2466
+ keys.push(entry.key);
2467
+ })
2468
+ );
2469
+ return keys;
2235
2470
  }
2236
2471
  async size() {
2237
2472
  const keys = await this.keys();
@@ -2247,6 +2482,38 @@ var DiskLayer = class {
2247
2482
  } catch {
2248
2483
  }
2249
2484
  }
2485
+ /**
2486
+ * Removes the oldest files (by mtime) when the directory exceeds maxFiles.
2487
+ */
2488
+ async enforceMaxFiles() {
2489
+ if (this.maxFiles === void 0) {
2490
+ return;
2491
+ }
2492
+ let entries;
2493
+ try {
2494
+ entries = await import_node_fs2.promises.readdir(this.directory);
2495
+ } catch {
2496
+ return;
2497
+ }
2498
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2499
+ if (lcFiles.length <= this.maxFiles) {
2500
+ return;
2501
+ }
2502
+ const withStats = await Promise.all(
2503
+ lcFiles.map(async (name) => {
2504
+ const filePath = (0, import_node_path.join)(this.directory, name);
2505
+ try {
2506
+ const stat = await import_node_fs2.promises.stat(filePath);
2507
+ return { filePath, mtimeMs: stat.mtimeMs };
2508
+ } catch {
2509
+ return { filePath, mtimeMs: 0 };
2510
+ }
2511
+ })
2512
+ );
2513
+ withStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
2514
+ const toEvict = withStats.slice(0, lcFiles.length - this.maxFiles);
2515
+ await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
2516
+ }
2250
2517
  };
2251
2518
 
2252
2519
  // src/layers/MemcachedLayer.ts
@@ -2256,29 +2523,41 @@ var MemcachedLayer = class {
2256
2523
  isLocal = false;
2257
2524
  client;
2258
2525
  keyPrefix;
2526
+ serializer;
2259
2527
  constructor(options) {
2260
2528
  this.client = options.client;
2261
2529
  this.defaultTtl = options.ttl;
2262
2530
  this.name = options.name ?? "memcached";
2263
2531
  this.keyPrefix = options.keyPrefix ?? "";
2532
+ this.serializer = options.serializer ?? new JsonSerializer();
2264
2533
  }
2265
2534
  async get(key) {
2535
+ return unwrapStoredValue(await this.getEntry(key));
2536
+ }
2537
+ async getEntry(key) {
2266
2538
  const result = await this.client.get(this.withPrefix(key));
2267
2539
  if (!result || result.value === null) {
2268
2540
  return null;
2269
2541
  }
2270
2542
  try {
2271
- return JSON.parse(result.value.toString("utf8"));
2543
+ return this.serializer.deserialize(result.value);
2272
2544
  } catch {
2273
2545
  return null;
2274
2546
  }
2275
2547
  }
2548
+ async getMany(keys) {
2549
+ return Promise.all(keys.map((key) => this.getEntry(key)));
2550
+ }
2276
2551
  async set(key, value, ttl = this.defaultTtl) {
2277
- const payload = JSON.stringify(value);
2552
+ const payload = this.serializer.serialize(value);
2278
2553
  await this.client.set(this.withPrefix(key), payload, {
2279
2554
  expires: ttl && ttl > 0 ? ttl : void 0
2280
2555
  });
2281
2556
  }
2557
+ async has(key) {
2558
+ const result = await this.client.get(this.withPrefix(key));
2559
+ return result !== null && result.value !== null;
2560
+ }
2282
2561
  async delete(key) {
2283
2562
  await this.client.delete(this.withPrefix(key));
2284
2563
  }
@@ -2372,6 +2651,12 @@ function createPrometheusMetricsExporter(stacks) {
2372
2651
  lines.push("# TYPE layercache_hits_by_layer_total counter");
2373
2652
  lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2374
2653
  lines.push("# TYPE layercache_misses_by_layer_total counter");
2654
+ lines.push("# HELP layercache_layer_latency_avg_ms Average read latency per layer in milliseconds");
2655
+ lines.push("# TYPE layercache_layer_latency_avg_ms gauge");
2656
+ lines.push("# HELP layercache_layer_latency_max_ms Maximum read latency per layer in milliseconds");
2657
+ lines.push("# TYPE layercache_layer_latency_max_ms gauge");
2658
+ lines.push("# HELP layercache_layer_latency_count Number of read latency samples per layer");
2659
+ lines.push("# TYPE layercache_layer_latency_count counter");
2375
2660
  for (const { stack, name } of entries) {
2376
2661
  const m = stack.getMetrics();
2377
2662
  const hr = stack.getHitRate();
@@ -2395,6 +2680,12 @@ function createPrometheusMetricsExporter(stacks) {
2395
2680
  for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2396
2681
  lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2397
2682
  }
2683
+ for (const [layerName, latency] of Object.entries(m.latencyByLayer)) {
2684
+ const layerLabel = `${label},layer="${sanitizeLabel(layerName)}"`;
2685
+ lines.push(`layercache_layer_latency_avg_ms{${layerLabel}} ${latency.avgMs.toFixed(4)}`);
2686
+ lines.push(`layercache_layer_latency_max_ms{${layerLabel}} ${latency.maxMs.toFixed(4)}`);
2687
+ lines.push(`layercache_layer_latency_count{${layerLabel}} ${latency.count}`);
2688
+ }
2398
2689
  }
2399
2690
  lines.push("");
2400
2691
  return lines.join("\n");
@@ -2405,6 +2696,7 @@ function sanitizeLabel(value) {
2405
2696
  }
2406
2697
  // Annotate the CommonJS export names for ESM import in node:
2407
2698
  0 && (module.exports = {
2699
+ CacheMissError,
2408
2700
  CacheNamespace,
2409
2701
  CacheStack,
2410
2702
  DiskLayer,
@@ -2422,6 +2714,7 @@ function sanitizeLabel(value) {
2422
2714
  cacheGraphqlResolver,
2423
2715
  createCacheStatsHandler,
2424
2716
  createCachedMethodDecorator,
2717
+ createExpressCacheMiddleware,
2425
2718
  createFastifyLayercachePlugin,
2426
2719
  createPrometheusMetricsExporter,
2427
2720
  createTrpcCacheMiddleware