@warlock.js/cache 5.19.0 → 5.20.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/cjs/index.cjs +1439 -331
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/cache-manager.d.mts +4 -0
  5. package/esm/cache-manager.d.mts.map +1 -1
  6. package/esm/cache-manager.mjs +23 -5
  7. package/esm/cache-manager.mjs.map +1 -1
  8. package/esm/cached/auto-key.d.mts +14 -14
  9. package/esm/cached/auto-key.d.mts.map +1 -1
  10. package/esm/cached/auto-key.mjs +50 -34
  11. package/esm/cached/auto-key.mjs.map +1 -1
  12. package/esm/cached/cached.mjs +4 -0
  13. package/esm/cached/cached.mjs.map +1 -1
  14. package/esm/drivers/base-cache-driver.d.mts +84 -8
  15. package/esm/drivers/base-cache-driver.d.mts.map +1 -1
  16. package/esm/drivers/base-cache-driver.mjs +152 -42
  17. package/esm/drivers/base-cache-driver.mjs.map +1 -1
  18. package/esm/drivers/file-cache-driver.d.mts +15 -0
  19. package/esm/drivers/file-cache-driver.d.mts.map +1 -1
  20. package/esm/drivers/file-cache-driver.mjs +68 -18
  21. package/esm/drivers/file-cache-driver.mjs.map +1 -1
  22. package/esm/drivers/in-memory-tag-index.d.mts +44 -0
  23. package/esm/drivers/in-memory-tag-index.d.mts.map +1 -0
  24. package/esm/drivers/in-memory-tag-index.mjs +73 -0
  25. package/esm/drivers/in-memory-tag-index.mjs.map +1 -0
  26. package/esm/drivers/index.d.mts +1 -1
  27. package/esm/drivers/index.mjs +1 -1
  28. package/esm/drivers/lru-memory-cache-driver.d.mts +43 -0
  29. package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
  30. package/esm/drivers/lru-memory-cache-driver.mjs +96 -0
  31. package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
  32. package/esm/drivers/memory-cache-driver.d.mts +87 -30
  33. package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
  34. package/esm/drivers/memory-cache-driver.mjs +214 -96
  35. package/esm/drivers/memory-cache-driver.mjs.map +1 -1
  36. package/esm/drivers/memory-extended-cache-driver.d.mts +9 -5
  37. package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -1
  38. package/esm/drivers/memory-extended-cache-driver.mjs +10 -23
  39. package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -1
  40. package/esm/drivers/mock-cache-driver.d.mts +41 -0
  41. package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
  42. package/esm/drivers/mock-cache-driver.mjs +105 -8
  43. package/esm/drivers/mock-cache-driver.mjs.map +1 -1
  44. package/esm/drivers/null-cache-driver.d.mts +8 -2
  45. package/esm/drivers/null-cache-driver.d.mts.map +1 -1
  46. package/esm/drivers/null-cache-driver.mjs +10 -2
  47. package/esm/drivers/null-cache-driver.mjs.map +1 -1
  48. package/esm/drivers/pg-cache-driver.d.mts +77 -3
  49. package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
  50. package/esm/drivers/pg-cache-driver.mjs +229 -7
  51. package/esm/drivers/pg-cache-driver.mjs.map +1 -1
  52. package/esm/drivers/redis-cache-driver.d.mts +86 -1
  53. package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
  54. package/esm/drivers/redis-cache-driver.mjs +252 -16
  55. package/esm/drivers/redis-cache-driver.mjs.map +1 -1
  56. package/esm/index.d.mts +2 -2
  57. package/esm/index.mjs +2 -2
  58. package/esm/list/memory-cache-list.d.mts +11 -8
  59. package/esm/list/memory-cache-list.d.mts.map +1 -1
  60. package/esm/list/memory-cache-list.mjs +39 -37
  61. package/esm/list/memory-cache-list.mjs.map +1 -1
  62. package/esm/scoped-cache.d.mts +9 -3
  63. package/esm/scoped-cache.d.mts.map +1 -1
  64. package/esm/scoped-cache.mjs +19 -7
  65. package/esm/scoped-cache.mjs.map +1 -1
  66. package/esm/tagged-cache.d.mts +20 -0
  67. package/esm/tagged-cache.d.mts.map +1 -1
  68. package/esm/tagged-cache.mjs +62 -24
  69. package/esm/tagged-cache.mjs.map +1 -1
  70. package/esm/tagged-scoped-cache.d.mts +7 -3
  71. package/esm/tagged-scoped-cache.d.mts.map +1 -1
  72. package/esm/tagged-scoped-cache.mjs +19 -7
  73. package/esm/tagged-scoped-cache.mjs.map +1 -1
  74. package/esm/types.d.mts +41 -3
  75. package/esm/types.d.mts.map +1 -1
  76. package/esm/types.mjs.map +1 -1
  77. package/esm/utils.d.mts +9 -3
  78. package/esm/utils.d.mts.map +1 -1
  79. package/esm/utils.mjs +16 -6
  80. package/esm/utils.mjs.map +1 -1
  81. package/llms-full.txt +1685 -1666
  82. package/llms.txt +18 -18
  83. package/package.json +4 -4
  84. package/skills/apply-cache-patterns/SKILL.md +98 -97
  85. package/skills/configure-pg-cache/SKILL.md +6 -5
  86. package/skills/configure-set-options/SKILL.md +102 -96
  87. package/skills/handle-cache-errors/SKILL.md +92 -92
  88. package/skills/index.md +20 -0
  89. package/skills/observe-cache/SKILL.md +103 -103
  90. package/skills/pick-cache-driver/SKILL.md +129 -123
  91. package/skills/test-cache-code/SKILL.md +219 -219
  92. package/skills/use-cache-atomic/SKILL.md +57 -65
  93. package/skills/use-cache-bulk/SKILL.md +57 -57
  94. package/skills/use-cache-list/SKILL.md +85 -85
  95. package/skills/use-cache-lock/SKILL.md +113 -104
  96. package/skills/use-cache-namespace/SKILL.md +88 -88
  97. package/skills/use-cache-similarity/SKILL.md +94 -94
  98. package/skills/use-cache-tags/SKILL.md +7 -4
  99. package/skills/use-cache-update-merge/SKILL.md +84 -84
  100. package/skills/use-cache-utils/SKILL.md +89 -89
  101. package/skills/use-cached-hof/SKILL.md +100 -99
  102. package/skills/use-swr/SKILL.md +104 -104
package/cjs/index.cjs CHANGED
@@ -29,7 +29,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
29
29
  let _mongez_reinforcements = require("@mongez/reinforcements");
30
30
  let ms = require("ms");
31
31
  ms = __toESM(ms, 1);
32
+ let node_crypto = require("node:crypto");
32
33
  let _warlock_js_logger = require("@warlock.js/logger");
34
+ let node_fs = require("node:fs");
35
+ node_fs = __toESM(node_fs, 1);
33
36
  let _warlock_js_fs = require("@warlock.js/fs");
34
37
  let path = require("path");
35
38
  path = __toESM(path, 1);
@@ -303,27 +306,37 @@ function parseCacheKey(key, options = {}) {
303
306
  * Parse a TTL value into seconds.
304
307
  *
305
308
  * Accepts:
306
- * - a number (already in seconds) — returned unchanged
309
+ * - a number (seconds) — fractions are rounded UP to whole seconds
310
+ * (so Redis `EX` never receives a fraction); `0` means "no expiry"
307
311
  * - `Infinity` — no expiration, returned unchanged
308
- * - a human-readable duration string (e.g. `"1h"`, `"30m"`, `"7d"`) — parsed via `ms` then converted to seconds
312
+ * - a bare numeric string (e.g. `"3600"`, from env vars) — treated as seconds
313
+ * - a human-readable duration string (e.g. `"1h"`, `"30m"`, `"7d"`) — parsed via `ms`,
314
+ * then converted to seconds, rounding up
309
315
  *
310
- * Throws `CacheConfigurationError` on unparseable strings or negative numbers.
316
+ * Throws `CacheConfigurationError` on NaN, negative numbers, or unparseable strings.
311
317
  *
312
318
  * @example
313
319
  * parseTtl(3600); // 3600
320
+ * parseTtl(0.5); // 1
321
+ * parseTtl("3600"); // 3600
322
+ * parseTtl("500ms"); // 1
314
323
  * parseTtl("1h"); // 3600
315
324
  * parseTtl("7d"); // 604800
316
325
  * parseTtl(Infinity); // Infinity
317
326
  */
318
327
  function parseTtl(input) {
319
328
  if (typeof input === "number") {
329
+ if (Number.isNaN(input)) throw new CacheConfigurationError("Invalid TTL: NaN.");
320
330
  if (input < 0) throw new CacheConfigurationError(`Invalid TTL: negative number (${input}).`);
321
- return input;
331
+ return Math.ceil(input);
322
332
  }
323
333
  if (typeof input !== "string" || input.trim() === "") throw new CacheConfigurationError(`Invalid TTL: expected number or duration string, got ${typeof input}.`);
324
- const milliseconds = (0, ms.default)(input);
334
+ const trimmed = input.trim();
335
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Math.ceil(Number(trimmed));
336
+ const milliseconds = (0, ms.default)(trimmed);
325
337
  if (milliseconds === void 0 || Number.isNaN(milliseconds)) throw new CacheConfigurationError(`Invalid TTL duration string: "${input}". Expected forms like "1h", "30m", "7d".`);
326
- return Math.floor(milliseconds / 1e3);
338
+ if (milliseconds < 0) throw new CacheConfigurationError(`Invalid TTL: negative duration ("${input}").`);
339
+ return Math.ceil(milliseconds / 1e3);
327
340
  }
328
341
  /**
329
342
  * Convert an absolute `expiresAt` (Date or epoch milliseconds) into a
@@ -595,17 +608,29 @@ var TaggedScopedCache = class {
595
608
  return this.scope.has(key);
596
609
  }
597
610
  /**
598
- * Remove the scoped key. The tag-index entry will eventually be cleaned up
599
- * by `invalidate()`; we don't proactively rewrite it here for cost reasons.
611
+ * Remove the scoped key and take it out of every tag index this handle
612
+ * writes to (scope tags + handle tags).
600
613
  */
601
- remove(key) {
602
- return this.scope.remove(key);
614
+ async remove(key) {
615
+ await this.scope.remove(key);
616
+ await this.detachFromTags(key);
603
617
  }
604
618
  /**
605
- * Read-and-remove the scoped key.
619
+ * Read-and-remove the scoped key, then take it out of this handle's tags.
606
620
  */
607
- pull(key) {
608
- return this.scope.pull(key);
621
+ async pull(key) {
622
+ const value = await this.scope.pull(key);
623
+ if (value !== null) await this.detachFromTags(key);
624
+ return value;
625
+ }
626
+ /**
627
+ * Remove the un-prefixed scoped key from every tag index of this handle.
628
+ */
629
+ async detachFromTags(key) {
630
+ const allTags = mergeTagSets(this.scope.defaults.tags, this.handleTags);
631
+ if (!allTags || allTags.length === 0) return;
632
+ const scopedKey = this.buildScopedKey(key);
633
+ await this.scope.source.tags(allTags).detachFromTags(parseCacheKey(scopedKey));
609
634
  }
610
635
  /**
611
636
  * Permanent write with handle tags applied. Bypasses both the scope's and
@@ -846,19 +871,31 @@ var ScopedCache = class ScopedCache {
846
871
  return this.source.decrement(this.scopedKey(key), value);
847
872
  }
848
873
  /**
849
- * Atomic read-modify-write. Falls back to the scope's `ttl` when the caller
850
- * doesn't provide one; the source still keeps the existing entry's TTL on
851
- * an update unless `options.ttl` is explicitly set.
874
+ * Atomic read-modify-write. An explicit `options.ttl` always wins. Otherwise
875
+ * the scope's default `ttl` is applied only when the key does not exist yet;
876
+ * for an existing key no ttl is passed so the source preserves the entry's
877
+ * remaining TTL.
852
878
  */
853
- update(key, fn, options) {
854
- return this.source.update(this.scopedKey(key), fn, { ttl: options?.ttl ?? this.defaults.ttl });
879
+ async update(key, fn, options) {
880
+ const scopedKey = this.scopedKey(key);
881
+ return this.source.update(scopedKey, fn, await this.writeOptions(scopedKey, options));
855
882
  }
856
883
  /**
857
884
  * Shallow-merge a partial object into the scoped entry. Same TTL semantics
858
885
  * as {@link update}.
859
886
  */
860
- merge(key, partial, options) {
861
- return this.source.merge(this.scopedKey(key), partial, { ttl: options?.ttl ?? this.defaults.ttl });
887
+ async merge(key, partial, options) {
888
+ const scopedKey = this.scopedKey(key);
889
+ return this.source.merge(scopedKey, partial, await this.writeOptions(scopedKey, options));
890
+ }
891
+ /**
892
+ * Resolve the ttl options for update/merge: explicit ttl, else the scope
893
+ * default only for keys that don't exist yet, else nothing (keep remaining).
894
+ */
895
+ async writeOptions(scopedKey, options) {
896
+ if (options?.ttl !== void 0) return { ttl: options.ttl };
897
+ if (this.defaults.ttl === void 0) return {};
898
+ return await this.source.has(scopedKey) ? {} : { ttl: this.defaults.ttl };
862
899
  }
863
900
  /**
864
901
  * Return a list accessor bound to the scoped key. The accessor itself
@@ -946,6 +983,7 @@ var ScopedCache = class ScopedCache {
946
983
  var CacheManager = class {
947
984
  constructor() {
948
985
  this.loadedDrivers = {};
986
+ this.loadingDrivers = /* @__PURE__ */ new Map();
949
987
  this.configurations = {
950
988
  drivers: {},
951
989
  options: {}
@@ -1177,6 +1215,11 @@ var CacheManager = class {
1177
1215
  this.assertNoConflictingReload(driver, runtimeOptions);
1178
1216
  return this.loadedDrivers[driver];
1179
1217
  }
1218
+ const inFlight = this.loadingDrivers.get(driver);
1219
+ if (inFlight) {
1220
+ this.assertNoConflictingReload(driver, runtimeOptions);
1221
+ return inFlight;
1222
+ }
1180
1223
  const Driver = this.configurations.drivers[driver];
1181
1224
  if (!Driver) throw new CacheConfigurationError(`Cache driver ${driver} is not found, please declare it in the cache drivers in the configurations list.`);
1182
1225
  const driverInstance = new Driver();
@@ -1185,10 +1228,18 @@ var CacheManager = class {
1185
1228
  ...configOptions,
1186
1229
  ...runtimeOptions ?? {}
1187
1230
  });
1188
- await driverInstance.connect();
1189
- this.attachGlobalListeners(driverInstance);
1190
- this.loadedDrivers[driver] = driverInstance;
1191
- return driverInstance;
1231
+ const promise = (async () => {
1232
+ try {
1233
+ await driverInstance.connect();
1234
+ this.attachGlobalListeners(driverInstance);
1235
+ this.loadedDrivers[driver] = driverInstance;
1236
+ return driverInstance;
1237
+ } finally {
1238
+ this.loadingDrivers.delete(driver);
1239
+ }
1240
+ })();
1241
+ this.loadingDrivers.set(driver, promise);
1242
+ return promise;
1192
1243
  }
1193
1244
  /**
1194
1245
  * Guard against silently dropping runtime options on a re-load. Once a
@@ -1212,7 +1263,11 @@ var CacheManager = class {
1212
1263
  * Disconnect the cache manager
1213
1264
  */
1214
1265
  async disconnect() {
1215
- if (this.currentDriver) await this.currentDriver.disconnect();
1266
+ const drivers = new Set(Object.values(this.loadedDrivers));
1267
+ if (this.currentDriver) drivers.add(this.currentDriver);
1268
+ await Promise.allSettled([...drivers].map((driver) => driver.disconnect()));
1269
+ for (const name of Object.keys(this.loadedDrivers)) delete this.loadedDrivers[name];
1270
+ this.loadingDrivers.clear();
1216
1271
  }
1217
1272
  /**
1218
1273
  * {@inheritdoc}
@@ -1426,50 +1481,65 @@ const cache = new CacheManager();
1426
1481
  /**
1427
1482
  * Derive a cache key from a prefix and a set of function arguments.
1428
1483
  *
1429
- * Rules (in order of precedence):
1430
- * 1. No args → prefix alone.
1431
- * 2. All primitives (`string`, `number`, `boolean`) or `null` / `undefined` /
1432
- * `bigint` → joined onto the prefix with dots.
1433
- * 3. Any non-primitive arg present → the full args array is `JSON.stringify`-ed
1434
- * and appended to the prefix.
1435
- * 4. Serialization throws (circular refs, `BigInt` nested in an object) → we
1436
- * re-throw as `CacheConfigurationError` so the caller sees a cache-scoped
1437
- * error rather than a cryptic `TypeError`.
1484
+ * The key is `<prefix>.<hash>` where `hash` is the first 16 hex chars of the
1485
+ * sha1 of a stable JSON encoding of the args. Hashing keeps the mapping
1486
+ * injective — raw punctuation-joined segments can be folded together by
1487
+ * `parseCacheKey` (e.g. `("1.private", "")` vs `("1", "private")`).
1488
+ *
1489
+ * Encoding rules: object keys are sorted, `Date` → ISO string, `undefined` →
1490
+ * `{"$u":1}`, `bigint` → decimal string with an `n` suffix.
1491
+ *
1492
+ * No args → the prefix alone.
1493
+ *
1494
+ * Map, Set, functions, symbols and class instances cannot be encoded stably
1495
+ * and throw `CacheConfigurationError`; supply a custom `key` function instead.
1438
1496
  *
1439
1497
  * @example
1440
- * deriveAutoKey("user", [42]); // "user.42"
1441
- * deriveAutoKey("orders", [42, "abc"]); // "orders.42.abc"
1442
- * deriveAutoKey("featured", []); // "featured"
1443
- * deriveAutoKey("search", [{ q: "hello" }]); // "search.[{\"q\":\"hello\"}]"
1444
- * deriveAutoKey("user", [null, undefined]); // "user.null.undefined"
1498
+ * deriveAutoKey("featured", []); // "featured"
1499
+ * deriveAutoKey("user", [42]); // "user.<16 hex chars>"
1445
1500
  */
1446
1501
  function deriveAutoKey(prefix, args) {
1447
1502
  if (args.length === 0) return prefix;
1448
- if (args.every(isPrimitiveOrNullish)) return prefix + "." + args.map(serializePrimitive).join(".");
1503
+ let encoded;
1449
1504
  try {
1450
- return prefix + "." + JSON.stringify(args);
1505
+ encoded = JSON.stringify(args.map((arg) => normalize(arg, prefix, /* @__PURE__ */ new Set())));
1451
1506
  } catch (error) {
1452
- throw new CacheConfigurationError(`cached(): could not derive an auto-key from args for prefix "${prefix}". The args include a value that is not JSON-serializable (circular reference, BigInt nested inside an object, or similar). Use the options form with a custom key function. Original error: ${error.message}`);
1507
+ if (error instanceof CacheConfigurationError) throw error;
1508
+ throw new CacheConfigurationError(`cached(): could not derive an auto-key from args for prefix "${prefix}". The args are not serializable. Use the options form with a custom key function. Original error: ${error.message}`);
1453
1509
  }
1510
+ return `${prefix}.${(0, node_crypto.createHash)("sha1").update(encoded).digest("hex").slice(0, 16)}`;
1454
1511
  }
1455
- /**
1456
- * Primitives and nullish values can be concatenated directly onto a key without
1457
- * JSON serialization. Adding `bigint` here avoids the `JSON.stringify` throw on
1458
- * top-level bigint args.
1459
- */
1460
- function isPrimitiveOrNullish(value) {
1461
- if (value === null || value === void 0) return true;
1462
- const type = typeof value;
1463
- return type === "string" || type === "number" || type === "boolean" || type === "bigint";
1512
+ function unsupported(prefix, what) {
1513
+ throw new CacheConfigurationError(`cached(): cannot derive an auto-key for prefix "${prefix}" from ${what}. Use the options form with a custom key function.`);
1464
1514
  }
1465
- /**
1466
- * Serialize a single primitive or nullish value to its string key-segment form.
1467
- */
1468
- function serializePrimitive(value) {
1469
- if (value === null) return "null";
1470
- if (value === void 0) return "undefined";
1471
- if (typeof value === "bigint") return value.toString();
1472
- return String(value);
1515
+ function normalize(value, prefix, seen) {
1516
+ if (value === void 0) return { $u: 1 };
1517
+ if (value === null) return null;
1518
+ switch (typeof value) {
1519
+ case "string":
1520
+ case "boolean": return value;
1521
+ case "number": return Number.isFinite(value) ? value : { $n: String(value) };
1522
+ case "bigint": return `${value.toString()}n`;
1523
+ case "function": return unsupported(prefix, "a function argument");
1524
+ case "symbol": return unsupported(prefix, "a symbol argument");
1525
+ }
1526
+ const obj = value;
1527
+ if (obj instanceof Date) return Number.isNaN(obj.getTime()) ? { $d: "invalid" } : obj.toISOString();
1528
+ if (obj instanceof Map) return unsupported(prefix, "a Map argument");
1529
+ if (obj instanceof Set) return unsupported(prefix, "a Set argument");
1530
+ if (seen.has(obj)) throw new CacheConfigurationError(`cached(): could not derive an auto-key for prefix "${prefix}": circular reference in args. Use the options form with a custom key function.`);
1531
+ seen.add(obj);
1532
+ let result;
1533
+ if (Array.isArray(obj)) result = obj.map((item) => normalize(item, prefix, seen));
1534
+ else {
1535
+ const proto = Object.getPrototypeOf(obj);
1536
+ if (proto !== Object.prototype && proto !== null) unsupported(prefix, "a class instance argument");
1537
+ const out = {};
1538
+ for (const key of Object.keys(obj).sort()) out[key] = normalize(obj[key], prefix, seen);
1539
+ result = out;
1540
+ }
1541
+ seen.delete(obj);
1542
+ return result;
1473
1543
  }
1474
1544
 
1475
1545
  //#endregion
@@ -1509,6 +1579,10 @@ function cached(fn, prefixOrOptions, maybeTtl) {
1509
1579
  });
1510
1580
  wrapper.invalidate = async (...args) => {
1511
1581
  const key = config.key(...args);
1582
+ if (config.driver) {
1583
+ await (await cache.driver(config.driver)).remove(key);
1584
+ return;
1585
+ }
1512
1586
  await cache.remove(key);
1513
1587
  };
1514
1588
  return wrapper;
@@ -1524,13 +1598,14 @@ function cached(fn, prefixOrOptions, maybeTtl) {
1524
1598
  * driver overrides `list()` to return a native-command accessor instead.
1525
1599
  *
1526
1600
  * **Role.** Fallback list accessor bound to a driver + key. Every mutation
1527
- * fetches the array, transforms it in memory, and writes it back.
1601
+ * runs through `driver.update()`, so it is serialized in-process and keeps the
1602
+ * entry's remaining TTL.
1528
1603
  *
1529
1604
  * **Responsibility.**
1530
1605
  * - Owns: translating list operations into array mutations + driver writes.
1531
- * - Does NOT own: concurrency control (callers should wrap in a distributed
1532
- * lock when multi-process writers are possible), TTL preservation across
1533
- * ops (writes use driver defaults), or tagging of list entries.
1606
+ * - Does NOT own: cross-process concurrency control (callers should wrap in a
1607
+ * distributed lock when multi-process writers are possible), or tagging of
1608
+ * list entries.
1534
1609
  *
1535
1610
  * @example
1536
1611
  * // Never constructed directly — obtained via driver.list():
@@ -1550,53 +1625,48 @@ var MemoryCacheList = class {
1550
1625
  return Array.isArray(current) ? [...current] : [];
1551
1626
  }
1552
1627
  /**
1553
- * Persist the backing array. Removes the entry when empty to keep the
1554
- * store clean.
1628
+ * Serialized read-modify-write through `driver.update()`. No ttl is passed,
1629
+ * so the driver keeps the entry's remaining TTL. An empty list removes the
1630
+ * entry to keep the store clean.
1555
1631
  */
1556
- async write(items) {
1557
- if (items.length === 0) {
1558
- await this.driver.remove(this.key);
1559
- return;
1560
- }
1561
- await this.driver.set(this.key, items);
1632
+ async mutate(mutation) {
1633
+ let result;
1634
+ await this.driver.update(this.key, (current) => {
1635
+ const items = Array.isArray(current) ? [...current] : [];
1636
+ result = mutation(items);
1637
+ return items.length === 0 ? null : items;
1638
+ });
1639
+ return result;
1562
1640
  }
1563
1641
  /**
1564
1642
  * {@inheritdoc}
1565
1643
  */
1566
- async push(...items) {
1567
- const current = await this.read();
1568
- current.push(...items);
1569
- await this.write(current);
1570
- return current.length;
1644
+ push(...items) {
1645
+ return this.mutate((current) => {
1646
+ current.push(...items);
1647
+ return current.length;
1648
+ });
1571
1649
  }
1572
1650
  /**
1573
1651
  * {@inheritdoc}
1574
1652
  */
1575
- async unshift(...items) {
1576
- const current = await this.read();
1577
- current.unshift(...items);
1578
- await this.write(current);
1579
- return current.length;
1653
+ unshift(...items) {
1654
+ return this.mutate((current) => {
1655
+ current.unshift(...items);
1656
+ return current.length;
1657
+ });
1580
1658
  }
1581
1659
  /**
1582
1660
  * {@inheritdoc}
1583
1661
  */
1584
- async pop() {
1585
- const current = await this.read();
1586
- if (current.length === 0) return null;
1587
- const value = current.pop();
1588
- await this.write(current);
1589
- return value;
1662
+ pop() {
1663
+ return this.mutate((current) => current.length === 0 ? null : current.pop());
1590
1664
  }
1591
1665
  /**
1592
1666
  * {@inheritdoc}
1593
1667
  */
1594
- async shift() {
1595
- const current = await this.read();
1596
- if (current.length === 0) return null;
1597
- const value = current.shift();
1598
- await this.write(current);
1599
- return value;
1668
+ shift() {
1669
+ return this.mutate((current) => current.length === 0 ? null : current.shift());
1600
1670
  }
1601
1671
  /**
1602
1672
  * {@inheritdoc}
@@ -1617,11 +1687,17 @@ var MemoryCacheList = class {
1617
1687
  return (await this.read()).length;
1618
1688
  }
1619
1689
  /**
1620
- * {@inheritdoc}
1690
+ * Keep the inclusive range `[start, end]` like Redis LTRIM; negative
1691
+ * indexes count from the end.
1621
1692
  */
1622
1693
  async trim(start, end) {
1623
- const trimmed = (await this.read()).slice(start, end + 1);
1624
- await this.write(trimmed);
1694
+ await this.mutate((current) => {
1695
+ const length = current.length;
1696
+ const from = start < 0 ? Math.max(length + start, 0) : start;
1697
+ const to = end < 0 ? length + end : end;
1698
+ const kept = current.slice(from, to + 1);
1699
+ current.splice(0, current.length, ...kept);
1700
+ });
1625
1701
  }
1626
1702
  /**
1627
1703
  * {@inheritdoc}
@@ -1637,7 +1713,7 @@ var MemoryCacheList = class {
1637
1713
  * Tagged Cache Wrapper
1638
1714
  * Wraps a cache driver to automatically manage tag relationships
1639
1715
  */
1640
- var TaggedCache = class {
1716
+ var TaggedCache = class TaggedCache {
1641
1717
  /**
1642
1718
  * Constructor
1643
1719
  */
@@ -1670,13 +1746,52 @@ var TaggedCache = class {
1670
1746
  const indexedKey = this.indexedKey(key);
1671
1747
  for (const tag of this.cacheTags) {
1672
1748
  const tagKey = this.tagKey(tag);
1673
- const keys = await this.driver.get(tagKey) || [];
1674
- if (!keys.includes(indexedKey)) {
1675
- keys.push(indexedKey);
1676
- await this.driver.set(tagKey, keys, Infinity);
1677
- }
1749
+ await this.addToTag(tagKey, [indexedKey]);
1750
+ await this.pruneTag(tagKey, indexedKey);
1678
1751
  }
1679
1752
  }
1753
+ static {
1754
+ this.PRUNE_CHECKS_PER_WRITE = 20;
1755
+ }
1756
+ /**
1757
+ * Drop index members whose entry no longer exists (expired, evicted, or
1758
+ * removed outside a tagged handle). Checks a random window of at most
1759
+ * {@link PRUNE_CHECKS_PER_WRITE} members, so a long-lived index converges
1760
+ * to its live members without any write paying for a full scan.
1761
+ */
1762
+ async pruneTag(tagKey, justWritten) {
1763
+ const members = (await this.membersOf(tagKey)).filter((member) => member !== justWritten);
1764
+ if (members.length === 0) return;
1765
+ const limit = TaggedCache.PRUNE_CHECKS_PER_WRITE;
1766
+ const start = members.length > limit ? Math.floor(Math.random() * members.length) : 0;
1767
+ const window = Array.from({ length: Math.min(limit, members.length) }, (_, index) => members[(start + index) % members.length]);
1768
+ const missing = [];
1769
+ for (const member of window) if (!await this.driver.has(member)) missing.push(member);
1770
+ if (missing.length > 0) await this.removeFromTag(tagKey, missing);
1771
+ }
1772
+ async addToTag(tagKey, members) {
1773
+ if (typeof this.driver.tagAdd === "function") return this.driver.tagAdd(tagKey, members);
1774
+ const current = await this.legacyMembers(tagKey);
1775
+ const merged = [...new Set([...current, ...members])];
1776
+ if (merged.length !== current.length) await this.driver.set(tagKey, merged, Infinity);
1777
+ }
1778
+ async membersOf(tagKey) {
1779
+ if (typeof this.driver.tagMembers === "function") return this.driver.tagMembers(tagKey);
1780
+ return this.legacyMembers(tagKey);
1781
+ }
1782
+ async removeFromTag(tagKey, members) {
1783
+ if (typeof this.driver.tagRemove === "function") return this.driver.tagRemove(tagKey, members);
1784
+ const dropped = new Set(members);
1785
+ const current = await this.legacyMembers(tagKey);
1786
+ const kept = current.filter((member) => !dropped.has(member));
1787
+ if (kept.length === current.length) return;
1788
+ if (kept.length === 0) await this.driver.remove(tagKey);
1789
+ else await this.driver.set(tagKey, kept, Infinity);
1790
+ }
1791
+ async legacyMembers(tagKey) {
1792
+ const value = await this.driver.get(tagKey);
1793
+ return Array.isArray(value) ? value.filter((member) => typeof member === "string") : [];
1794
+ }
1680
1795
  /**
1681
1796
  * The form a key takes inside a tag index: normalized but un-prefixed.
1682
1797
  *
@@ -1695,11 +1810,7 @@ var TaggedCache = class {
1695
1810
  */
1696
1811
  async getTaggedKeys() {
1697
1812
  const allKeys = /* @__PURE__ */ new Set();
1698
- for (const tag of this.cacheTags) {
1699
- const tagKey = this.tagKey(tag);
1700
- const keys = await this.driver.get(tagKey) || [];
1701
- for (const key of keys) allKeys.add(key);
1702
- }
1813
+ for (const tag of this.cacheTags) for (const key of await this.membersOf(this.tagKey(tag))) allKeys.add(key);
1703
1814
  return allKeys;
1704
1815
  }
1705
1816
  /**
@@ -1722,19 +1833,24 @@ var TaggedCache = class {
1722
1833
  async remove(key) {
1723
1834
  const indexedKey = this.indexedKey(key);
1724
1835
  await this.driver.remove(key);
1836
+ await this.detachFromTags(indexedKey);
1837
+ }
1838
+ /**
1839
+ * Invalidate (clear) all keys associated with the current tags
1840
+ */
1841
+ async invalidate() {
1725
1842
  for (const tag of this.cacheTags) {
1726
1843
  const tagKey = this.tagKey(tag);
1727
- const updatedKeys = (await this.driver.get(tagKey) || []).filter((k) => k !== indexedKey);
1728
- await this.driver.set(tagKey, updatedKeys, Infinity);
1844
+ const members = await this.membersOf(tagKey);
1845
+ for (const member of members) await this.driver.remove(member);
1846
+ await this.removeFromTag(tagKey, members);
1729
1847
  }
1730
1848
  }
1731
1849
  /**
1732
- * Invalidate (clear) all keys associated with the current tags
1850
+ * Remove `indexedKey` from every tag of this handle.
1733
1851
  */
1734
- async invalidate() {
1735
- const keysToRemove = await this.getTaggedKeys();
1736
- for (const key of keysToRemove) await this.driver.remove(key);
1737
- for (const tag of this.cacheTags) await this.driver.remove(this.tagKey(tag));
1852
+ async detachFromTags(indexedKey) {
1853
+ for (const tag of this.cacheTags) await this.removeFromTag(this.tagKey(tag), [indexedKey]);
1738
1854
  }
1739
1855
  /**
1740
1856
  * Flush all keys associated with the current tags
@@ -1763,8 +1879,8 @@ var TaggedCache = class {
1763
1879
  * {@inheritdoc}
1764
1880
  */
1765
1881
  async pull(key) {
1766
- const value = await this.get(key);
1767
- if (value !== null) await this.remove(key);
1882
+ const value = await this.driver.pull(key);
1883
+ if (value !== null) await this.detachFromTags(this.indexedKey(key));
1768
1884
  return value;
1769
1885
  }
1770
1886
  /**
@@ -1777,10 +1893,8 @@ var TaggedCache = class {
1777
1893
  * {@inheritdoc}
1778
1894
  */
1779
1895
  async increment(key, value = 1) {
1780
- const current = await this.get(key) || 0;
1781
- if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${this.driver.parseKey(key)}`);
1782
- const newValue = current + value;
1783
- await this.set(key, newValue);
1896
+ const newValue = await this.driver.increment(key, value);
1897
+ await this.storeTaggedKey(key);
1784
1898
  return newValue;
1785
1899
  }
1786
1900
  /**
@@ -1817,7 +1931,10 @@ var BaseCacheDriver = class {
1817
1931
  constructor() {
1818
1932
  this.shouldLog = true;
1819
1933
  this.eventListeners = /* @__PURE__ */ new Map();
1820
- this.locks = /* @__PURE__ */ new Map();
1934
+ this.rememberInFlight = /* @__PURE__ */ new Map();
1935
+ this.swrFetchInFlight = /* @__PURE__ */ new Map();
1936
+ this.swrRefreshes = /* @__PURE__ */ new Map();
1937
+ this.updateChains = /* @__PURE__ */ new Map();
1821
1938
  }
1822
1939
  /**
1823
1940
  * {@inheritdoc}
@@ -1926,13 +2043,67 @@ var BaseCacheDriver = class {
1926
2043
  if (!tags || tags.length === 0) return null;
1927
2044
  const allKeys = /* @__PURE__ */ new Set();
1928
2045
  for (const tag of tags) {
1929
- const tagKey = `cache:tags:${tag}`;
1930
- const keys = await this.get(tagKey) || [];
2046
+ const keys = await this.tagMembers(`cache:tags:${tag}`);
1931
2047
  for (const k of keys) allKeys.add(this.parseKey(k));
1932
2048
  }
1933
2049
  return allKeys;
1934
2050
  }
1935
2051
  /**
2052
+ * {@inheritdoc}
2053
+ *
2054
+ * Default implementation: the index is an ordinary entry holding a string
2055
+ * array, read-modified-written under {@link runSerialized} — correct within
2056
+ * THIS process only. Drivers with a native primitive override it (redis SET,
2057
+ * pg atomic upsert, the in-process drivers' {@link InMemoryTagIndex}).
2058
+ */
2059
+ async tagAdd(tagKey, members) {
2060
+ if (members.length === 0) return;
2061
+ await this.runSerialized(this.parseKey(tagKey), async () => {
2062
+ const current = await this.readTagArray(tagKey);
2063
+ const merged = new Set(current);
2064
+ for (const member of members) merged.add(member);
2065
+ if (merged.size !== current.length) await this.set(tagKey, [...merged], Infinity);
2066
+ });
2067
+ }
2068
+ /**
2069
+ * {@inheritdoc}
2070
+ */
2071
+ async tagMembers(tagKey) {
2072
+ return this.readTagArray(tagKey);
2073
+ }
2074
+ /**
2075
+ * {@inheritdoc}
2076
+ *
2077
+ * Default implementation: serialized read-filter-write (process-local); an
2078
+ * emptied index is removed.
2079
+ */
2080
+ async tagRemove(tagKey, members) {
2081
+ if (members.length === 0) return;
2082
+ await this.runSerialized(this.parseKey(tagKey), async () => {
2083
+ const current = await this.readTagArray(tagKey);
2084
+ const dropped = new Set(members);
2085
+ const kept = current.filter((member) => !dropped.has(member));
2086
+ if (kept.length === current.length) return;
2087
+ if (kept.length === 0) await this.remove(tagKey);
2088
+ else await this.set(tagKey, kept, Infinity);
2089
+ });
2090
+ }
2091
+ /**
2092
+ * {@inheritdoc}
2093
+ */
2094
+ async tagDelete(tagKey) {
2095
+ await this.runSerialized(this.parseKey(tagKey), () => this.remove(tagKey));
2096
+ }
2097
+ /**
2098
+ * The index stored as a plain entry, or `[]`. Non-string members (a corrupted
2099
+ * or foreign value) are ignored rather than trusted.
2100
+ */
2101
+ async readTagArray(tagKey) {
2102
+ const value = await this.get(tagKey);
2103
+ if (!Array.isArray(value)) return [];
2104
+ return value.filter((member) => typeof member === "string");
2105
+ }
2106
+ /**
1936
2107
  * Apply tag relationships after a successful write. Called by drivers once
1937
2108
  * the value is in storage.
1938
2109
  *
@@ -1950,25 +2121,44 @@ var BaseCacheDriver = class {
1950
2121
  return await this.get(key) !== null;
1951
2122
  }
1952
2123
  /**
2124
+ * Run `task` once per key at a time: concurrent callers for the same
2125
+ * `parsedKey` share the in-flight promise registered in `inFlight`.
2126
+ */
2127
+ singleFlight(inFlight, parsedKey, task) {
2128
+ const existing = inFlight.get(parsedKey);
2129
+ if (existing) return existing;
2130
+ const promise = task().finally(() => {
2131
+ if (inFlight.get(parsedKey) === promise) inFlight.delete(parsedKey);
2132
+ });
2133
+ inFlight.set(parsedKey, promise);
2134
+ return promise;
2135
+ }
2136
+ /**
2137
+ * Chain `task` after every earlier serialized task for the same key, so
2138
+ * read-modify-write callers in THIS process run one at a time. This is
2139
+ * process-local: it does not coordinate with other servers.
2140
+ */
2141
+ runSerialized(parsedKey, task) {
2142
+ const next = (this.updateChains.get(parsedKey) ?? Promise.resolve()).catch(() => void 0).then(task);
2143
+ this.updateChains.set(parsedKey, next);
2144
+ next.finally(() => {
2145
+ if (this.updateChains.get(parsedKey) === next) this.updateChains.delete(parsedKey);
2146
+ }).catch(() => void 0);
2147
+ return next;
2148
+ }
2149
+ /**
1953
2150
  * {@inheritdoc}
1954
2151
  */
1955
2152
  async remember(key, ttlOrOptions, callback) {
1956
2153
  const parsedKey = this.parseKey(key);
1957
2154
  const setOptions = this.normalizeRememberOptions(ttlOrOptions);
1958
2155
  const cachedValue = await this.get(key);
1959
- if (cachedValue) return cachedValue;
1960
- const existingLock = this.locks.get(parsedKey);
1961
- if (existingLock) return existingLock;
1962
- const promise = callback().then(async (result) => {
2156
+ if (cachedValue !== null && cachedValue !== void 0) return cachedValue;
2157
+ return this.singleFlight(this.rememberInFlight, parsedKey, async () => {
2158
+ const result = await callback();
1963
2159
  await this.set(key, result, setOptions);
1964
- this.locks.delete(parsedKey);
1965
2160
  return result;
1966
- }).catch((err) => {
1967
- this.locks.delete(parsedKey);
1968
- throw err;
1969
2161
  });
1970
- this.locks.set(parsedKey, promise);
1971
- return promise;
1972
2162
  }
1973
2163
  /**
1974
2164
  * Resolve the TTL-or-options arg of `remember` into a `CacheSetOptions` object
@@ -1986,8 +2176,9 @@ var BaseCacheDriver = class {
1986
2176
  *
1987
2177
  * Default implementation: read raw entry, branch on freshness/staleness,
1988
2178
  * trigger background refresh in the stale window, fall through to
1989
- * `callback` on miss/expiry. Concurrent stale-window callers share a
1990
- * single in-flight refresh via {@link locks}.
2179
+ * `callback` on miss/expiry. Concurrent cold-miss callers share one fetch
2180
+ * ({@link swrFetchInFlight}); concurrent stale-window callers share one
2181
+ * background refresh ({@link swrRefreshes}).
1991
2182
  *
1992
2183
  * Drivers without a real {@link getEntry} override degrade gracefully —
1993
2184
  * the synthetic entry has no `staleAt`, which the freshness check treats
@@ -2002,7 +2193,7 @@ var BaseCacheDriver = class {
2002
2193
  const entry = await this.getEntry(key);
2003
2194
  const now = Date.now();
2004
2195
  const isExpired = entry?.expiresAt !== void 0 && entry.expiresAt <= now;
2005
- if (!entry || isExpired) return this.swrFetchAndStore(key, options, callback, freshSeconds, staleSeconds);
2196
+ if (!entry || isExpired) return this.singleFlight(this.swrFetchInFlight, parsedKey, () => this.swrFetchAndStore(key, options, callback, freshSeconds, staleSeconds));
2006
2197
  if (entry.staleAt === void 0 || entry.staleAt > now) return entry.data;
2007
2198
  this.scheduleSwrRefresh(parsedKey, key, options, callback, freshSeconds, staleSeconds);
2008
2199
  return entry.data;
@@ -2061,7 +2252,7 @@ var BaseCacheDriver = class {
2061
2252
  * `error` — the stale-returning caller never sees the failure.
2062
2253
  */
2063
2254
  scheduleSwrRefresh(parsedKey, key, options, callback, freshSeconds, staleSeconds) {
2064
- if (this.locks.has(parsedKey)) return;
2255
+ if (this.swrRefreshes.has(parsedKey)) return;
2065
2256
  let refresh;
2066
2257
  refresh = (async () => {
2067
2258
  try {
@@ -2078,18 +2269,25 @@ var BaseCacheDriver = class {
2078
2269
  error
2079
2270
  });
2080
2271
  } finally {
2081
- if (this.locks.get(parsedKey) === refresh) this.locks.delete(parsedKey);
2272
+ if (this.swrRefreshes.get(parsedKey) === refresh) this.swrRefreshes.delete(parsedKey);
2082
2273
  }
2083
2274
  })();
2084
- this.locks.set(parsedKey, refresh);
2275
+ this.swrRefreshes.set(parsedKey, refresh);
2085
2276
  }
2086
2277
  /**
2087
2278
  * {@inheritdoc}
2279
+ *
2280
+ * Default implementation: get-then-remove, serialized per key in THIS
2281
+ * process. Drivers with a native primitive override it to be atomic across
2282
+ * servers (redis `GETDEL`, pg `DELETE … RETURNING`) or process-atomic
2283
+ * (memory, lru, mock).
2088
2284
  */
2089
2285
  async pull(key) {
2090
- const value = await this.get(key);
2091
- if (value !== null) await this.remove(key);
2092
- return value;
2286
+ return this.runSerialized(this.parseKey(key), async () => {
2287
+ const value = await this.get(key);
2288
+ if (value !== null) await this.remove(key);
2289
+ return value;
2290
+ });
2093
2291
  }
2094
2292
  /**
2095
2293
  * {@inheritdoc}
@@ -2099,13 +2297,24 @@ var BaseCacheDriver = class {
2099
2297
  }
2100
2298
  /**
2101
2299
  * {@inheritdoc}
2300
+ *
2301
+ * Default implementation: read-add-write serialized per key in THIS process,
2302
+ * keeping the entry's remaining TTL (a missing key starts from 0 with the
2303
+ * driver default TTL). Not atomic across servers: drivers with a native
2304
+ * primitive override it (redis `INCRBY`, pg single-statement upsert).
2102
2305
  */
2103
2306
  async increment(key, value = 1) {
2104
- const current = await this.get(key) || 0;
2105
- if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${this.parseKey(key)}`);
2106
- const newValue = current + value;
2107
- await this.set(key, newValue);
2108
- return newValue;
2307
+ const parsedKey = this.parseKey(key);
2308
+ return this.runSerialized(parsedKey, async () => {
2309
+ const current = await this.get(key);
2310
+ const base = current === null || current === void 0 ? 0 : current;
2311
+ if (typeof base !== "number") throw new Error(`Cannot increment non-numeric value for key: ${parsedKey}`);
2312
+ const newValue = base + value;
2313
+ const remainingTtl = current === null ? void 0 : await this.getRemainingTtl(key);
2314
+ if (remainingTtl !== void 0) await this.set(key, newValue, { ttl: remainingTtl });
2315
+ else await this.set(key, newValue);
2316
+ return newValue;
2317
+ });
2109
2318
  }
2110
2319
  /**
2111
2320
  * {@inheritdoc}
@@ -2220,13 +2429,12 @@ var BaseCacheDriver = class {
2220
2429
  /**
2221
2430
  * {@inheritdoc}
2222
2431
  *
2223
- * Default implementation: read → transform → write under a per-key in-process
2224
- * lock. Drivers that can offer stronger semantics (Redis via `WATCH`/`MULTI`)
2225
- * should override.
2432
+ * Default implementation: read → transform → write, serialized per key in
2433
+ * THIS process only. Drivers that can offer cross-server atomicity (redis,
2434
+ * pg: compare-and-set with retries) override it.
2226
2435
  */
2227
2436
  async update(key, fn, options = {}) {
2228
- const parsedKey = this.parseKey(key);
2229
- const next = (this.locks.get(parsedKey) ?? Promise.resolve()).catch(() => void 0).then(async () => {
2437
+ return this.runSerialized(this.parseKey(key), async () => {
2230
2438
  const result = await fn(await this.get(key));
2231
2439
  if (result === null) {
2232
2440
  await this.remove(key);
@@ -2241,11 +2449,6 @@ var BaseCacheDriver = class {
2241
2449
  else await this.set(key, result);
2242
2450
  return result;
2243
2451
  });
2244
- this.locks.set(parsedKey, next);
2245
- next.finally(() => {
2246
- if (this.locks.get(parsedKey) === next) this.locks.delete(parsedKey);
2247
- });
2248
- return next;
2249
2452
  }
2250
2453
  /**
2251
2454
  * {@inheritdoc}
@@ -2275,12 +2478,16 @@ var BaseCacheDriver = class {
2275
2478
  * The lock value is the resolved `owner` (defaults to `pid.<process.pid>`).
2276
2479
  *
2277
2480
  * Always releases in `finally`, even if `fn` throws — the thrown error
2278
- * propagates to the caller unchanged.
2481
+ * propagates to the caller unchanged, and a failed release is logged rather
2482
+ * than replacing `fn`'s result or error. The ttl must be finite and
2483
+ * positive; there is no renewal, so it must exceed the worst-case duration.
2279
2484
  */
2280
2485
  async lock(key, ttlOrOptions, fn) {
2281
2486
  const { ttl, owner } = this.normalizeLockOptions(ttlOrOptions);
2282
- const lockOwner = owner ?? `pid.${process.pid}`;
2283
- const setResult = await this.set(key, lockOwner, {
2487
+ const ttlSeconds = parseTtl(ttl);
2488
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new CacheConfigurationError(`cache.lock() requires a finite, positive ttl (got ${String(ttl)}). The ttl must exceed the worst-case duration of the locked work.`);
2489
+ const lockValue = `${owner ?? `pid.${process.pid}`}#${(0, node_crypto.randomUUID)()}`;
2490
+ const setResult = await this.set(key, lockValue, {
2284
2491
  onConflict: "create",
2285
2492
  ttl
2286
2493
  });
@@ -2291,10 +2498,26 @@ var BaseCacheDriver = class {
2291
2498
  value: await fn()
2292
2499
  };
2293
2500
  } finally {
2294
- await this.remove(key);
2501
+ try {
2502
+ await this.deleteIfEquals(key, lockValue);
2503
+ } catch (error) {
2504
+ this.logError(`lock release failed for ${this.parseKey(key)}`, error);
2505
+ }
2295
2506
  }
2296
2507
  }
2297
2508
  /**
2509
+ * Compare-and-delete: remove `key` only when its current value equals
2510
+ * `expected`. Returns whether a delete happened.
2511
+ *
2512
+ * This default is get-then-remove and is NOT atomic across processes; drivers
2513
+ * with a real primitive (memory, redis, pg) override it.
2514
+ */
2515
+ async deleteIfEquals(key, expected) {
2516
+ if (await this.get(key) !== expected) return false;
2517
+ await this.remove(key);
2518
+ return true;
2519
+ }
2520
+ /**
2298
2521
  * {@inheritdoc}
2299
2522
  *
2300
2523
  * Default implementation throws {@link CacheUnsupportedError}. Drivers that
@@ -2357,6 +2580,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
2357
2580
  * only ever produces a single filesystem component here.
2358
2581
  */
2359
2582
  keyDirectory(parsedKey) {
2583
+ if (parsedKey === "") throw new CacheConfigurationError("Cache key must not be empty.");
2360
2584
  return this.containedPath(this.encodeKeySegment(parsedKey));
2361
2585
  }
2362
2586
  /**
@@ -2421,18 +2645,17 @@ var FileCacheDriver = class extends BaseCacheDriver {
2421
2645
  }
2422
2646
  /**
2423
2647
  * {@inheritdoc}
2648
+ *
2649
+ * `onConflict: "create"` is exclusive (temp file + hard link, so `lock()`
2650
+ * is safe across concurrent writers). `onConflict: "update"` is NOT atomic:
2651
+ * the existence probe and the write are separate steps.
2424
2652
  */
2425
2653
  async set(key, value, ttlOrOptions) {
2426
2654
  const parsedKey = this.parseKey(key);
2427
2655
  const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
2428
2656
  if (vector) throw new CacheUnsupportedError("'file' driver does not support similarity retrieval — use a memory driver, 'pg' (with pgvector), or 'redis' (with RediSearch).");
2429
2657
  this.log("caching", parsedKey);
2430
- const existing = onConflict === "upsert" ? null : await this.get(key);
2431
- const exists = existing !== null;
2432
- if (onConflict === "create" && exists) return {
2433
- wasSet: false,
2434
- existing
2435
- };
2658
+ const exists = (onConflict === "upsert" || onConflict === "create" ? null : await this.get(key)) !== null;
2436
2659
  if (onConflict === "update" && !exists) return {
2437
2660
  wasSet: false,
2438
2661
  existing: null
@@ -2440,7 +2663,14 @@ var FileCacheDriver = class extends BaseCacheDriver {
2440
2663
  const data = this.prepareDataForStorage(value, ttl, staleAt);
2441
2664
  const fileDirectory = this.keyDirectory(parsedKey);
2442
2665
  await (0, _warlock_js_fs.ensureDirectoryAsync)(fileDirectory);
2443
- await (0, _warlock_js_fs.putJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName), data);
2666
+ const filePath = path.default.resolve(fileDirectory, this.fileName);
2667
+ if (onConflict === "create") {
2668
+ const conflict = await this.writeExclusive(filePath, data);
2669
+ if (conflict) return {
2670
+ wasSet: false,
2671
+ existing: conflict.existing
2672
+ };
2673
+ } else await (0, _warlock_js_fs.atomicWriteJsonAsync)(filePath, data);
2444
2674
  if (tags && tags.length > 0) await this.applyTags(key, tags);
2445
2675
  this.log("cached", parsedKey);
2446
2676
  await this.emit("set", {
@@ -2455,6 +2685,44 @@ var FileCacheDriver = class extends BaseCacheDriver {
2455
2685
  return this;
2456
2686
  }
2457
2687
  /**
2688
+ * Create `filePath` only if it does not exist. Writes a temp file, then
2689
+ * hard-links it to the target (EEXIST = already present). An existing but
2690
+ * expired entry is removed (the file only, never the directory) and the
2691
+ * link retried once.
2692
+ *
2693
+ * Returns `null` when written, or `{ existing }` on conflict.
2694
+ */
2695
+ async writeExclusive(filePath, data) {
2696
+ const tempPath = `${filePath}.${(0, node_crypto.randomBytes)(6).toString("hex")}.tmp`;
2697
+ try {
2698
+ await node_fs.default.promises.writeFile(tempPath, JSON.stringify(data, null, 2));
2699
+ for (let attempt = 0; attempt < 2; attempt++) {
2700
+ try {
2701
+ await node_fs.default.promises.link(tempPath, filePath);
2702
+ return null;
2703
+ } catch (error) {
2704
+ if (error?.code !== "EEXIST") throw error;
2705
+ }
2706
+ let current;
2707
+ try {
2708
+ current = await (0, _warlock_js_fs.getJsonFileAsync)(filePath);
2709
+ } catch {
2710
+ current = void 0;
2711
+ }
2712
+ const expired = current?.expiresAt !== void 0 && current.expiresAt !== null && current.expiresAt <= Date.now();
2713
+ if (current && !expired) return { existing: current.data ?? null };
2714
+ if (attempt === 0 && expired) {
2715
+ await node_fs.default.promises.rm(filePath, { force: true });
2716
+ continue;
2717
+ }
2718
+ return { existing: null };
2719
+ }
2720
+ return { existing: null };
2721
+ } finally {
2722
+ await node_fs.default.promises.unlink(tempPath).catch(() => void 0);
2723
+ }
2724
+ }
2725
+ /**
2458
2726
  * {@inheritdoc}
2459
2727
  *
2460
2728
  * File driver does not yet ship with a file-lock primitive, so concurrent
@@ -2495,21 +2763,24 @@ var FileCacheDriver = class extends BaseCacheDriver {
2495
2763
  const parsedKey = this.parseKey(key);
2496
2764
  this.log("fetching", parsedKey);
2497
2765
  const fileDirectory = this.keyDirectory(parsedKey);
2766
+ let value;
2498
2767
  try {
2499
- const value = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
2500
- const result = await this.parseCachedData(parsedKey, value);
2501
- if (result === null) await this.emit("miss", { key: parsedKey });
2502
- else await this.emit("hit", {
2503
- key: parsedKey,
2504
- value: result
2505
- });
2506
- return result;
2507
- } catch (error) {
2768
+ value = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
2769
+ } catch {
2770
+ value = void 0;
2771
+ }
2772
+ if (value === void 0 || value === null || typeof value !== "object") {
2508
2773
  this.log("notFound", parsedKey);
2509
2774
  await this.emit("miss", { key: parsedKey });
2510
- await this.remove(key);
2511
2775
  return null;
2512
2776
  }
2777
+ const result = await this.parseCachedData(parsedKey, value);
2778
+ if (result === null) await this.emit("miss", { key: parsedKey });
2779
+ else await this.emit("hit", {
2780
+ key: parsedKey,
2781
+ value: result
2782
+ });
2783
+ return result;
2513
2784
  }
2514
2785
  /**
2515
2786
  * {@inheritdoc}
@@ -2545,6 +2816,77 @@ var FileCacheDriver = class extends BaseCacheDriver {
2545
2816
  }
2546
2817
  };
2547
2818
 
2819
+ //#endregion
2820
+ //#region ../cache/src/drivers/in-memory-tag-index.ts
2821
+ /**
2822
+ * Tag index for the in-process drivers (memory, memoryExtended, lru, mock).
2823
+ *
2824
+ * Lives beside the driver's entry storage, never inside it: index sets are not
2825
+ * entries, so they are never subject to `maxSize` / LRU eviction or TTL
2826
+ * sweeps, and every operation is synchronous — concurrent tagged writes in one
2827
+ * process can never drop a member.
2828
+ *
2829
+ * Keys are the driver-parsed tag keys (`<prefix>.cache.tags.<tag>`); members
2830
+ * are the caller's normalized, un-prefixed cache keys.
2831
+ */
2832
+ var InMemoryTagIndex = class {
2833
+ constructor() {
2834
+ this.sets = /* @__PURE__ */ new Map();
2835
+ }
2836
+ /**
2837
+ * Add members to a tag set, creating it on first use.
2838
+ */
2839
+ add(tagKey, members) {
2840
+ if (members.length === 0) return;
2841
+ let set = this.sets.get(tagKey);
2842
+ if (!set) {
2843
+ set = /* @__PURE__ */ new Set();
2844
+ this.sets.set(tagKey, set);
2845
+ }
2846
+ for (const member of members) set.add(member);
2847
+ }
2848
+ /**
2849
+ * The members of a tag set (a copy; empty when the set does not exist).
2850
+ */
2851
+ members(tagKey) {
2852
+ return [...this.sets.get(tagKey) ?? []];
2853
+ }
2854
+ /**
2855
+ * Remove members from a tag set; an emptied set is dropped.
2856
+ */
2857
+ remove(tagKey, members) {
2858
+ const set = this.sets.get(tagKey);
2859
+ if (!set) return;
2860
+ for (const member of members) set.delete(member);
2861
+ if (set.size === 0) this.sets.delete(tagKey);
2862
+ }
2863
+ /**
2864
+ * Drop a whole tag set.
2865
+ */
2866
+ delete(tagKey) {
2867
+ this.sets.delete(tagKey);
2868
+ }
2869
+ /**
2870
+ * Drop every tag set whose key equals `namespace` or sits under
2871
+ * `namespace.` — the same rule the drivers use for entries. An empty
2872
+ * namespace clears everything.
2873
+ */
2874
+ removeNamespace(namespace) {
2875
+ if (namespace === "") {
2876
+ this.sets.clear();
2877
+ return;
2878
+ }
2879
+ const prefix = namespace + ".";
2880
+ for (const key of [...this.sets.keys()]) if (key === namespace || key.startsWith(prefix)) this.sets.delete(key);
2881
+ }
2882
+ /**
2883
+ * Drop every tag set.
2884
+ */
2885
+ clear() {
2886
+ this.sets.clear();
2887
+ }
2888
+ };
2889
+
2548
2890
  //#endregion
2549
2891
  //#region ../cache/src/drivers/lru-memory-cache-driver.ts
2550
2892
  var CacheNode = class {
@@ -2573,6 +2915,7 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2573
2915
  super();
2574
2916
  this.name = "lru";
2575
2917
  this.cache = /* @__PURE__ */ new Map();
2918
+ this.tagIndex = new InMemoryTagIndex();
2576
2919
  this.head = new CacheNode("", null);
2577
2920
  this.tail = new CacheNode("", null);
2578
2921
  this.init();
@@ -2618,6 +2961,7 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2618
2961
  const parsedNamespace = this.parseKey(namespace);
2619
2962
  this.log("clearing", parsedNamespace || "(all)");
2620
2963
  const removed = [];
2964
+ this.tagIndex.removeNamespace(parsedNamespace);
2621
2965
  if (parsedNamespace === "") for (const key of this.cache.keys()) removed.push(key);
2622
2966
  else {
2623
2967
  const prefix = parsedNamespace + ".";
@@ -2732,6 +3076,72 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2732
3076
  };
2733
3077
  }
2734
3078
  /**
3079
+ * Live node for `parsedKey`, or `undefined` when missing / expired (an
3080
+ * expired node is unlinked). Synchronous.
3081
+ */
3082
+ liveNode(parsedKey) {
3083
+ const node = this.cache.get(parsedKey);
3084
+ if (!node) return;
3085
+ if (node.isExpired) {
3086
+ this.removeNode(node);
3087
+ this.cache.delete(parsedKey);
3088
+ return;
3089
+ }
3090
+ return node;
3091
+ }
3092
+ /**
3093
+ * {@inheritdoc}
3094
+ *
3095
+ * Read, add and write run synchronously (no `await` in between), so
3096
+ * concurrent increments in this process never lose an update. An existing
3097
+ * node keeps its expiry; a missing key starts from 0 with the default TTL.
3098
+ */
3099
+ async increment(key, value = 1) {
3100
+ const parsedKey = this.parseKey(key);
3101
+ const node = this.liveNode(parsedKey);
3102
+ const current = node ? node.value : 0;
3103
+ if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${parsedKey}`);
3104
+ const newValue = current + value;
3105
+ if (node) {
3106
+ node.value = newValue;
3107
+ this.moveHead(node);
3108
+ } else {
3109
+ const newNode = new CacheNode(parsedKey, newValue, this.ttl);
3110
+ this.cache.set(parsedKey, newNode);
3111
+ this.addNode(newNode);
3112
+ if (this.cache.size > this.capacity) this.removeTail();
3113
+ }
3114
+ await this.emit("set", {
3115
+ key: parsedKey,
3116
+ value: newValue,
3117
+ ttl: this.ttl
3118
+ });
3119
+ return newValue;
3120
+ }
3121
+ /**
3122
+ * {@inheritdoc}
3123
+ *
3124
+ * Read and unlink run synchronously, so a value is handed out exactly once
3125
+ * even when concurrent callers pull the same key.
3126
+ */
3127
+ async pull(key) {
3128
+ const parsedKey = this.parseKey(key);
3129
+ const node = this.liveNode(parsedKey);
3130
+ if (!node) {
3131
+ await this.emit("miss", { key: parsedKey });
3132
+ return null;
3133
+ }
3134
+ this.removeNode(node);
3135
+ this.cache.delete(parsedKey);
3136
+ const value = node.value !== null && typeof node.value === "object" ? structuredClone(node.value) : node.value;
3137
+ await this.emit("hit", {
3138
+ key: parsedKey,
3139
+ value
3140
+ });
3141
+ await this.emit("removed", { key: parsedKey });
3142
+ return value;
3143
+ }
3144
+ /**
2735
3145
  * {@inheritdoc}
2736
3146
  */
2737
3147
  async get(key) {
@@ -2792,6 +3202,32 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2792
3202
  /**
2793
3203
  * {@inheritdoc}
2794
3204
  *
3205
+ * Synchronous in-process set, never evicted or expired with the entries.
3206
+ */
3207
+ async tagAdd(tagKey, members) {
3208
+ this.tagIndex.add(this.parseKey(tagKey), members);
3209
+ }
3210
+ /**
3211
+ * {@inheritdoc}
3212
+ */
3213
+ async tagMembers(tagKey) {
3214
+ return this.tagIndex.members(this.parseKey(tagKey));
3215
+ }
3216
+ /**
3217
+ * {@inheritdoc}
3218
+ */
3219
+ async tagRemove(tagKey, members) {
3220
+ this.tagIndex.remove(this.parseKey(tagKey), members);
3221
+ }
3222
+ /**
3223
+ * {@inheritdoc}
3224
+ */
3225
+ async tagDelete(tagKey) {
3226
+ this.tagIndex.delete(this.parseKey(tagKey));
3227
+ }
3228
+ /**
3229
+ * {@inheritdoc}
3230
+ *
2795
3231
  * When a `globalPrefix` is configured, `flush` scopes itself to that prefix
2796
3232
  * so multi-tenant caches don't accidentally wipe sibling tenants. Without
2797
3233
  * a prefix, clears everything.
@@ -2801,6 +3237,7 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2801
3237
  if (this.options.globalPrefix) await this.removeNamespace("");
2802
3238
  else {
2803
3239
  this.cache.clear();
3240
+ this.tagIndex.clear();
2804
3241
  this.init();
2805
3242
  }
2806
3243
  this.log("flushed");
@@ -2859,29 +3296,50 @@ var LRUMemoryCacheDriver = class extends BaseCacheDriver {
2859
3296
 
2860
3297
  //#endregion
2861
3298
  //#region ../cache/src/drivers/memory-cache-driver.ts
3299
+ /**
3300
+ * Clone non-primitive values so cached state can't be mutated through
3301
+ * references held by callers. Primitives are immutable and returned as-is.
3302
+ */
3303
+ function cloneValue(value) {
3304
+ if (value === null || value === void 0) return value;
3305
+ const type = typeof value;
3306
+ if (type === "string" || type === "number" || type === "boolean") return value;
3307
+ return structuredClone(value);
3308
+ }
2862
3309
  var MemoryCacheDriver = class extends BaseCacheDriver {
3310
+ /**
3311
+ * Read-only object view of the stored entries, keyed by parsed key.
3312
+ * Kept for introspection; the values are the live stored wrappers.
3313
+ */
3314
+ get data() {
3315
+ return Object.fromEntries(this.entries);
3316
+ }
2863
3317
  /**
2864
3318
  * {@inheritdoc}
2865
3319
  */
2866
3320
  constructor() {
2867
3321
  super();
2868
3322
  this.name = "memory";
2869
- this.data = {};
2870
- this.temporaryData = {};
2871
- this.accessOrder = [];
3323
+ this.entries = /* @__PURE__ */ new Map();
3324
+ this.expiry = /* @__PURE__ */ new Map();
2872
3325
  this.vectorIndex = /* @__PURE__ */ new Map();
3326
+ this.tagIndex = new InMemoryTagIndex();
3327
+ this.slidingExpiration = false;
2873
3328
  this.startCleanup();
2874
3329
  }
2875
3330
  /**
2876
- * Start the cleanup process whenever a data that has a cache key is set
3331
+ * Start the cleanup process for entries that carry a finite ttl
2877
3332
  */
2878
3333
  startCleanup() {
2879
3334
  if (this.cleanupInterval) clearInterval(this.cleanupInterval);
2880
3335
  this.cleanupInterval = setInterval(async () => {
2881
3336
  const now = Date.now();
2882
- for (const [key, entry] of Object.entries(this.temporaryData)) if (entry.expiresAt <= now) {
2883
- await this.remove(entry.key);
2884
- delete this.temporaryData[key];
3337
+ const expiredKeys = [];
3338
+ for (const [key, expiresAt] of this.expiry) if (expiresAt <= now) expiredKeys.push(key);
3339
+ for (const key of expiredKeys) {
3340
+ const expiresAt = this.expiry.get(key);
3341
+ if (expiresAt === void 0 || expiresAt > now) continue;
3342
+ this.dropEntry(key);
2885
3343
  this.log("expired", key);
2886
3344
  await this.emit("expired", { key });
2887
3345
  }
@@ -2889,44 +3347,94 @@ var MemoryCacheDriver = class extends BaseCacheDriver {
2889
3347
  this.cleanupInterval.unref();
2890
3348
  }
2891
3349
  /**
2892
- * {@inheritdoc}
3350
+ * Delete an entry and every side record (expiry, vector). Synchronous.
2893
3351
  */
2894
- async removeNamespace(namespace) {
2895
- this.log("clearing", namespace);
2896
- namespace = this.parseKey(namespace);
2897
- (0, _mongez_reinforcements.unset)(this.data, [namespace]);
2898
- if (namespace === "") this.vectorIndex.clear();
2899
- else {
2900
- const prefix = namespace + ".";
2901
- for (const k of [...this.vectorIndex.keys()]) if (k === namespace || k.startsWith(prefix)) this.vectorIndex.delete(k);
2902
- }
2903
- this.log("cleared", namespace);
2904
- return this;
3352
+ dropEntry(parsedKey) {
3353
+ this.entries.delete(parsedKey);
3354
+ this.expiry.delete(parsedKey);
3355
+ this.vectorIndex.delete(parsedKey);
2905
3356
  }
2906
3357
  /**
2907
- * {@inheritdoc}
3358
+ * Raw, synchronous read of a live entry. An expired entry is deleted and
3359
+ * reported as missing. Never slides TTLs, never emits, never touches LRU order.
3360
+ */
3361
+ readLive(parsedKey) {
3362
+ const entry = this.entries.get(parsedKey);
3363
+ if (!entry) return;
3364
+ if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) {
3365
+ this.dropEntry(parsedKey);
3366
+ return;
3367
+ }
3368
+ return entry;
3369
+ }
3370
+ /**
3371
+ * Mark an entry as most recently used (only tracked when `maxSize` is set).
3372
+ */
3373
+ touch(parsedKey, entry) {
3374
+ if (!this.options.maxSize) return;
3375
+ this.entries.delete(parsedKey);
3376
+ this.entries.set(parsedKey, entry);
3377
+ }
3378
+ /**
3379
+ * Invoked on a live read hit, before the value is returned. Runs only from
3380
+ * `get()` after the expiry check, so an expired entry is never resurrected,
3381
+ * and never from the internal existence check used by `onConflict`.
3382
+ */
3383
+ onRead(parsedKey, entry) {
3384
+ if (!this.slidingExpiration) return;
3385
+ const rawTtl = entry.ttl ?? this.options.ttl;
3386
+ const ttl = rawTtl !== void 0 ? parseTtl(rawTtl) : void 0;
3387
+ if (ttl && Number.isFinite(ttl)) {
3388
+ entry.expiresAt = this.getExpiresAt(ttl);
3389
+ this.expiry.set(parsedKey, entry.expiresAt);
3390
+ }
3391
+ }
3392
+ /**
3393
+ * {@inheritdoc}
3394
+ */
3395
+ async removeNamespace(namespace) {
3396
+ this.log("clearing", namespace);
3397
+ namespace = this.parseKey(namespace);
3398
+ this.tagIndex.removeNamespace(namespace);
3399
+ if (namespace === "") {
3400
+ this.entries.clear();
3401
+ this.expiry.clear();
3402
+ this.vectorIndex.clear();
3403
+ } else {
3404
+ const prefix = namespace + ".";
3405
+ for (const key of [...this.entries.keys()]) if (key === namespace || key.startsWith(prefix)) this.dropEntry(key);
3406
+ }
3407
+ this.log("cleared", namespace);
3408
+ return this;
3409
+ }
3410
+ /**
3411
+ * {@inheritdoc}
3412
+ *
3413
+ * The conditional check and the write run with no `await` in between, so
3414
+ * `onConflict` decisions are atomic within the process.
2908
3415
  */
2909
3416
  async set(key, value, ttlOrOptions) {
2910
3417
  const parsedKey = this.parseKey(key);
2911
3418
  const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
2912
3419
  this.log("caching", parsedKey);
2913
- const existingValue = onConflict === "upsert" ? null : await this.get(key);
2914
- const exists = existingValue !== null;
2915
- if (onConflict === "create" && exists) return {
3420
+ const existing = onConflict === "create" || onConflict === "update" ? this.readLive(parsedKey) : void 0;
3421
+ if (onConflict === "create" && existing) return {
2916
3422
  wasSet: false,
2917
- existing: existingValue
3423
+ existing: cloneValue(existing.data)
2918
3424
  };
2919
- if (onConflict === "update" && !exists) return {
3425
+ if (onConflict === "update" && !existing) return {
2920
3426
  wasSet: false,
2921
3427
  existing: null
2922
3428
  };
2923
- const data = this.prepareDataForStorage(value, ttl, staleAt);
2924
- if (ttl) this.setTemporaryData(key, parsedKey, ttl);
2925
- (0, _mongez_reinforcements.set)(this.data, parsedKey, data);
2926
- this.trackAccess(parsedKey);
2927
- if (!exists && this.options.maxSize) await this.enforceMaxSize();
2928
- if (tags && tags.length > 0) await this.applyTags(key, tags);
3429
+ const data = this.prepareDataForStorage(cloneValue(value), ttl, staleAt);
3430
+ this.entries.delete(parsedKey);
3431
+ this.entries.set(parsedKey, data);
3432
+ if (data.expiresAt !== void 0 && Number.isFinite(data.expiresAt)) this.expiry.set(parsedKey, data.expiresAt);
3433
+ else this.expiry.delete(parsedKey);
2929
3434
  if (vector) this.vectorIndex.set(parsedKey, vector.slice());
3435
+ else this.vectorIndex.delete(parsedKey);
3436
+ this.enforceMaxSize(parsedKey);
3437
+ if (tags && tags.length > 0) await this.applyTags(key, tags);
2930
3438
  this.log("cached", parsedKey);
2931
3439
  await this.emit("set", {
2932
3440
  key: parsedKey,
@@ -2941,38 +3449,101 @@ var MemoryCacheDriver = class extends BaseCacheDriver {
2941
3449
  }
2942
3450
  /**
2943
3451
  * {@inheritdoc}
3452
+ *
3453
+ * The check and the delete run synchronously (no `await` between them), so
3454
+ * the compare-and-delete is atomic within the process.
3455
+ */
3456
+ async deleteIfEquals(key, expected) {
3457
+ const parsedKey = this.parseKey(key);
3458
+ const entry = this.readLive(parsedKey);
3459
+ if (!entry || entry.data !== expected) return false;
3460
+ await this.remove(key);
3461
+ return true;
3462
+ }
3463
+ /**
3464
+ * {@inheritdoc}
3465
+ *
3466
+ * Read, add and write run synchronously (no `await` in between), so
3467
+ * concurrent increments in this process never lose an update. An existing
3468
+ * entry keeps its expiry; a missing key starts from 0 with the default TTL.
3469
+ */
3470
+ async increment(key, value = 1) {
3471
+ const parsedKey = this.parseKey(key);
3472
+ const entry = this.readLive(parsedKey);
3473
+ const current = entry ? entry.data : 0;
3474
+ if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${parsedKey}`);
3475
+ const newValue = current + value;
3476
+ if (entry) {
3477
+ entry.data = newValue;
3478
+ this.touch(parsedKey, entry);
3479
+ } else {
3480
+ const data = this.prepareDataForStorage(newValue, this.ttl);
3481
+ this.entries.set(parsedKey, data);
3482
+ if (data.expiresAt !== void 0 && Number.isFinite(data.expiresAt)) this.expiry.set(parsedKey, data.expiresAt);
3483
+ this.enforceMaxSize(parsedKey);
3484
+ }
3485
+ await this.emit("set", {
3486
+ key: parsedKey,
3487
+ value: newValue,
3488
+ ttl: entry?.ttl ?? this.ttl
3489
+ });
3490
+ return newValue;
3491
+ }
3492
+ /**
3493
+ * {@inheritdoc}
3494
+ *
3495
+ * Read and delete run synchronously, so a value is handed out exactly once
3496
+ * even when concurrent callers pull the same key.
3497
+ */
3498
+ async pull(key) {
3499
+ const parsedKey = this.parseKey(key);
3500
+ const entry = this.readLive(parsedKey);
3501
+ if (!entry) {
3502
+ await this.emit("miss", { key: parsedKey });
3503
+ return null;
3504
+ }
3505
+ this.dropEntry(parsedKey);
3506
+ const value = cloneValue(entry.data);
3507
+ await this.emit("hit", {
3508
+ key: parsedKey,
3509
+ value
3510
+ });
3511
+ await this.emit("removed", { key: parsedKey });
3512
+ return value;
3513
+ }
3514
+ /**
3515
+ * {@inheritdoc}
2944
3516
  */
2945
3517
  async get(key) {
2946
3518
  const parsedKey = this.parseKey(key);
2947
3519
  this.log("fetching", parsedKey);
2948
- const value = (0, _mongez_reinforcements.get)(this.data, parsedKey);
2949
- if (!value) {
3520
+ const entry = this.readLive(parsedKey);
3521
+ if (!entry) {
2950
3522
  this.log("notFound", parsedKey);
2951
3523
  await this.emit("miss", { key: parsedKey });
2952
3524
  return null;
2953
3525
  }
2954
- const result = await this.parseCachedData(parsedKey, value);
2955
- if (result === null) await this.emit("miss", { key: parsedKey });
2956
- else {
2957
- this.trackAccess(parsedKey);
2958
- await this.emit("hit", {
2959
- key: parsedKey,
2960
- value: result
2961
- });
2962
- }
3526
+ this.onRead(parsedKey, entry);
3527
+ this.touch(parsedKey, entry);
3528
+ const result = await this.parseCachedData(parsedKey, entry);
3529
+ await this.emit("hit", {
3530
+ key: parsedKey,
3531
+ value: result
3532
+ });
2963
3533
  return result;
2964
3534
  }
2965
3535
  /**
2966
- * Read the raw {@link CacheData} wrapper, including `staleAt` metadata.
3536
+ * Read the raw {@link CacheData} wrapper (cloned), including `staleAt` metadata.
2967
3537
  * Returns `null` for missing or expired entries so the SWR flow can branch
2968
3538
  * cleanly. Does not emit `hit`/`miss` events — that's `get()`'s job.
2969
3539
  */
2970
3540
  async getEntry(key) {
2971
- const parsedKey = this.parseKey(key);
2972
- const entry = (0, _mongez_reinforcements.get)(this.data, parsedKey);
3541
+ const entry = this.readLive(this.parseKey(key));
2973
3542
  if (!entry) return null;
2974
- if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) return null;
2975
- return entry;
3543
+ return {
3544
+ ...entry,
3545
+ data: cloneValue(entry.data)
3546
+ };
2976
3547
  }
2977
3548
  /**
2978
3549
  * {@inheritdoc}
@@ -2980,76 +3551,65 @@ var MemoryCacheDriver = class extends BaseCacheDriver {
2980
3551
  async remove(key) {
2981
3552
  const parsedKey = this.parseKey(key);
2982
3553
  this.log("removing", parsedKey);
2983
- (0, _mongez_reinforcements.unset)(this.data, [parsedKey]);
2984
- delete this.temporaryData[parsedKey];
2985
- this.removeFromAccessOrder(parsedKey);
2986
- this.vectorIndex.delete(parsedKey);
3554
+ this.dropEntry(parsedKey);
2987
3555
  this.log("removed", parsedKey);
2988
3556
  await this.emit("removed", { key: parsedKey });
2989
3557
  }
2990
3558
  /**
2991
3559
  * {@inheritdoc}
3560
+ *
3561
+ * Synchronous in-process set — concurrent tagged writes never drop members.
2992
3562
  */
2993
- async flush() {
2994
- this.log("flushing");
2995
- if (this.options.globalPrefix) this.removeNamespace("");
2996
- else {
2997
- this.data = {};
2998
- this.accessOrder = [];
2999
- this.vectorIndex.clear();
3000
- }
3001
- this.log("flushed");
3002
- await this.emit("flushed");
3563
+ async tagAdd(tagKey, members) {
3564
+ this.tagIndex.add(this.parseKey(tagKey), members);
3003
3565
  }
3004
3566
  /**
3005
- * Set the temporary data
3567
+ * {@inheritdoc}
3006
3568
  */
3007
- setTemporaryData(key, parsedKey, ttl) {
3008
- this.temporaryData[parsedKey] = {
3009
- key: JSON.stringify(key),
3010
- expiresAt: Date.now() + ttl * 1e3
3011
- };
3569
+ async tagMembers(tagKey) {
3570
+ return this.tagIndex.members(this.parseKey(tagKey));
3012
3571
  }
3013
3572
  /**
3014
- * Track access for LRU eviction
3573
+ * {@inheritdoc}
3015
3574
  */
3016
- trackAccess(key) {
3017
- if (!this.options.maxSize) return;
3018
- const index = this.accessOrder.indexOf(key);
3019
- if (index > -1) this.accessOrder.splice(index, 1);
3020
- this.accessOrder.push(key);
3575
+ async tagRemove(tagKey, members) {
3576
+ this.tagIndex.remove(this.parseKey(tagKey), members);
3021
3577
  }
3022
3578
  /**
3023
- * Remove key from access order tracking
3579
+ * {@inheritdoc}
3024
3580
  */
3025
- removeFromAccessOrder(key) {
3026
- const index = this.accessOrder.indexOf(key);
3027
- if (index > -1) this.accessOrder.splice(index, 1);
3581
+ async tagDelete(tagKey) {
3582
+ this.tagIndex.delete(this.parseKey(tagKey));
3028
3583
  }
3029
3584
  /**
3030
- * Enforce max size by evicting least recently used items.
3031
- *
3032
- * Recomputes the live cache size on every iteration — a single snapshot at
3033
- * the top of the loop would go stale and cause this routine to evict every
3034
- * entry in `accessOrder` (including the just-inserted key).
3585
+ * {@inheritdoc}
3035
3586
  */
3036
- async enforceMaxSize() {
3037
- if (!this.options.maxSize) return;
3038
- while (this.getCacheSize() > this.options.maxSize && this.accessOrder.length > 0) {
3039
- const lruKey = this.accessOrder.shift();
3040
- if (!lruKey) break;
3041
- this.log("removing", lruKey);
3042
- (0, _mongez_reinforcements.unset)(this.data, [lruKey]);
3043
- delete this.temporaryData[lruKey];
3044
- this.vectorIndex.delete(lruKey);
3045
- this.log("removed", lruKey);
3587
+ async flush() {
3588
+ this.log("flushing");
3589
+ if (this.options.globalPrefix) await this.removeNamespace("");
3590
+ else {
3591
+ this.entries.clear();
3592
+ this.expiry.clear();
3593
+ this.vectorIndex.clear();
3594
+ this.tagIndex.clear();
3046
3595
  }
3596
+ this.log("flushed");
3597
+ await this.emit("flushed");
3047
3598
  }
3048
3599
  /**
3049
- * Get current cache size (number of cached items)
3600
+ * Evict least recently used entries (Map insertion order) until the size is
3601
+ * within `maxSize`. Never evicts `protectedKey` (the key just written).
3050
3602
  */
3051
- getCacheSize() {
3052
- return Object.keys(this.data).length;
3603
+ enforceMaxSize(protectedKey) {
3604
+ const maxSize = this.options.maxSize;
3605
+ if (!maxSize) return;
3606
+ for (const lruKey of [...this.entries.keys()]) {
3607
+ if (this.entries.size <= maxSize) break;
3608
+ if (lruKey === protectedKey) continue;
3609
+ this.log("removing", lruKey);
3610
+ this.dropEntry(lruKey);
3611
+ this.log("removed", lruKey);
3612
+ }
3053
3613
  }
3054
3614
  /**
3055
3615
  * {@inheritdoc}
@@ -3064,16 +3624,11 @@ var MemoryCacheDriver = class extends BaseCacheDriver {
3064
3624
  async similar(vector, options) {
3065
3625
  const tagFilter = await this.getKeysForTags(options.tags);
3066
3626
  const hits = [];
3067
- for (const [parsedKey, stored] of this.vectorIndex) {
3627
+ for (const [parsedKey, stored] of [...this.vectorIndex]) {
3068
3628
  if (tagFilter && !tagFilter.has(parsedKey)) continue;
3069
- const entry = (0, _mongez_reinforcements.get)(this.data, parsedKey);
3629
+ const entry = this.readLive(parsedKey);
3070
3630
  if (!entry) continue;
3071
- if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) continue;
3072
- let value = entry.data;
3073
- if (value !== null && value !== void 0) {
3074
- const type = typeof value;
3075
- if (type !== "string" && type !== "number" && type !== "boolean") value = structuredClone(value);
3076
- }
3631
+ const value = cloneValue(entry.data);
3077
3632
  const score = cosineSimilarity(vector, stored);
3078
3633
  if (options.threshold !== void 0 && score < options.threshold) continue;
3079
3634
  hits.push({
@@ -3100,29 +3655,18 @@ var MemoryCacheDriver = class extends BaseCacheDriver {
3100
3655
 
3101
3656
  //#endregion
3102
3657
  //#region ../cache/src/drivers/memory-extended-cache-driver.ts
3658
+ /**
3659
+ * Memory driver with a sliding ttl: every live read pushes the entry's
3660
+ * expiration forward by its ttl. The sliding itself lives in
3661
+ * `MemoryCacheDriver.onRead`, which checks expiry first, so an expired entry
3662
+ * is never resurrected and the internal `onConflict` existence check never
3663
+ * slides a held lock.
3664
+ */
3103
3665
  var MemoryExtendedCacheDriver = class extends MemoryCacheDriver {
3104
- constructor(..._args) {
3105
- super(..._args);
3666
+ constructor() {
3667
+ super();
3106
3668
  this.name = "memoryExtended";
3107
- }
3108
- /**
3109
- * {@inheritdoc}
3110
- */
3111
- async get(key) {
3112
- const parsedKey = this.parseKey(key);
3113
- this.log("fetching", parsedKey);
3114
- const value = (0, _mongez_reinforcements.get)(this.data, parsedKey);
3115
- if (!value) {
3116
- this.log("notFound", parsedKey);
3117
- return null;
3118
- }
3119
- const rawTtl = value.ttl ?? this.options.ttl;
3120
- const ttl = rawTtl !== void 0 ? parseTtl(rawTtl) : void 0;
3121
- if (ttl) {
3122
- this.setTemporaryData(key, parsedKey, ttl);
3123
- value.expiresAt = this.getExpiresAt(ttl);
3124
- }
3125
- return this.parseCachedData(parsedKey, value);
3669
+ this.slidingExpiration = true;
3126
3670
  }
3127
3671
  };
3128
3672
 
@@ -3175,6 +3719,7 @@ var MockCacheDriver = class extends BaseCacheDriver {
3175
3719
  this.name = "mock";
3176
3720
  this.options = {};
3177
3721
  this.storage = /* @__PURE__ */ new Map();
3722
+ this.tagIndex = new InMemoryTagIndex();
3178
3723
  this.callLog = [];
3179
3724
  }
3180
3725
  /**
@@ -3201,6 +3746,7 @@ var MockCacheDriver = class extends BaseCacheDriver {
3201
3746
  this.recordCall("removeNamespace", parsed, [namespace]);
3202
3747
  this.log("clearing", parsed);
3203
3748
  const prefix = parsed + ".";
3749
+ this.tagIndex.removeNamespace(parsed);
3204
3750
  for (const key of [...this.storage.keys()]) if (key === parsed || key.startsWith(prefix)) this.storage.delete(key);
3205
3751
  this.log("cleared", parsed);
3206
3752
  }
@@ -3213,17 +3759,20 @@ var MockCacheDriver = class extends BaseCacheDriver {
3213
3759
  const { ttl, tags, onConflict, staleAt } = this.resolveSetOptions(ttlOrOptions);
3214
3760
  this.recordCall("set", parsedKey, [value, ttlOrOptions]);
3215
3761
  this.log("caching", parsedKey);
3216
- const existing = onConflict === "upsert" ? null : await this.get(key);
3217
- const exists = existing !== null;
3218
- if (onConflict === "create" && exists) return {
3762
+ let existing = onConflict === "create" || onConflict === "update" ? this.storage.get(parsedKey) : void 0;
3763
+ if (existing && existing.expiresAt !== void 0 && existing.expiresAt <= Date.now()) {
3764
+ this.storage.delete(parsedKey);
3765
+ existing = void 0;
3766
+ }
3767
+ if (onConflict === "create" && existing) return {
3219
3768
  wasSet: false,
3220
- existing
3769
+ existing: this.cloneValue(existing.data)
3221
3770
  };
3222
- if (onConflict === "update" && !exists) return {
3771
+ if (onConflict === "update" && !existing) return {
3223
3772
  wasSet: false,
3224
3773
  existing: null
3225
3774
  };
3226
- const data = this.prepareDataForStorage(value, ttl, staleAt);
3775
+ const data = this.prepareDataForStorage(this.cloneValue(value), ttl, staleAt);
3227
3776
  this.storage.set(parsedKey, data);
3228
3777
  if (tags && tags.length > 0) await this.applyTags(key, tags);
3229
3778
  this.log("cached", parsedKey);
@@ -3236,7 +3785,14 @@ var MockCacheDriver = class extends BaseCacheDriver {
3236
3785
  wasSet: true,
3237
3786
  existing: null
3238
3787
  };
3239
- return value;
3788
+ return this;
3789
+ }
3790
+ /**
3791
+ * Clone non-primitive values so stored state can't be mutated by callers.
3792
+ */
3793
+ cloneValue(value) {
3794
+ if (value === null || value === void 0 || typeof value !== "object") return value;
3795
+ return structuredClone(value);
3240
3796
  }
3241
3797
  /**
3242
3798
  * Standard `get` with TTL handling. Emits `hit` / `miss` events to keep
@@ -3265,6 +3821,59 @@ var MockCacheDriver = class extends BaseCacheDriver {
3265
3821
  return value;
3266
3822
  }
3267
3823
  /**
3824
+ * Live entry for `parsedKey`, or `undefined` when missing / expired (an
3825
+ * expired entry is deleted). Synchronous.
3826
+ */
3827
+ liveEntry(parsedKey) {
3828
+ const entry = this.storage.get(parsedKey);
3829
+ if (!entry) return;
3830
+ if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) {
3831
+ this.storage.delete(parsedKey);
3832
+ return;
3833
+ }
3834
+ return entry;
3835
+ }
3836
+ /**
3837
+ * Synchronous read-add-write (atomic within the process). Keeps an existing
3838
+ * entry's expiry; a missing key starts from 0 with the default TTL.
3839
+ */
3840
+ async increment(key, value = 1) {
3841
+ const parsedKey = this.parseKey(key);
3842
+ this.recordCall("increment", parsedKey, [value]);
3843
+ const entry = this.liveEntry(parsedKey);
3844
+ const current = entry ? entry.data : 0;
3845
+ if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${parsedKey}`);
3846
+ const newValue = current + value;
3847
+ if (entry) entry.data = newValue;
3848
+ else this.storage.set(parsedKey, this.prepareDataForStorage(newValue, this.ttl));
3849
+ await this.emit("set", {
3850
+ key: parsedKey,
3851
+ value: newValue,
3852
+ ttl: this.ttl
3853
+ });
3854
+ return newValue;
3855
+ }
3856
+ /**
3857
+ * Synchronous read-and-delete: a value is handed out exactly once.
3858
+ */
3859
+ async pull(key) {
3860
+ const parsedKey = this.parseKey(key);
3861
+ this.recordCall("pull", parsedKey);
3862
+ const entry = this.liveEntry(parsedKey);
3863
+ if (!entry) {
3864
+ await this.emit("miss", { key: parsedKey });
3865
+ return null;
3866
+ }
3867
+ this.storage.delete(parsedKey);
3868
+ const value = this.cloneValue(entry.data);
3869
+ await this.emit("hit", {
3870
+ key: parsedKey,
3871
+ value
3872
+ });
3873
+ await this.emit("removed", { key: parsedKey });
3874
+ return value;
3875
+ }
3876
+ /**
3268
3877
  * Read the raw {@link CacheData} wrapper from the in-memory `Map`,
3269
3878
  * including `staleAt` metadata. Returns `null` for missing or expired
3270
3879
  * entries — `swr()` consumes this to branch on freshness.
@@ -3274,7 +3883,10 @@ var MockCacheDriver = class extends BaseCacheDriver {
3274
3883
  const entry = this.storage.get(parsedKey);
3275
3884
  if (!entry) return null;
3276
3885
  if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) return null;
3277
- return entry;
3886
+ return {
3887
+ ...entry,
3888
+ data: this.cloneValue(entry.data)
3889
+ };
3278
3890
  }
3279
3891
  /**
3280
3892
  * Standard `remove` — drops the entry and emits the `removed` event.
@@ -3288,6 +3900,32 @@ var MockCacheDriver = class extends BaseCacheDriver {
3288
3900
  await this.emit("removed", { key: parsedKey });
3289
3901
  }
3290
3902
  /**
3903
+ * {@inheritdoc}
3904
+ *
3905
+ * Synchronous in-process set, never evicted or expired with the entries.
3906
+ */
3907
+ async tagAdd(tagKey, members) {
3908
+ this.tagIndex.add(this.parseKey(tagKey), members);
3909
+ }
3910
+ /**
3911
+ * {@inheritdoc}
3912
+ */
3913
+ async tagMembers(tagKey) {
3914
+ return this.tagIndex.members(this.parseKey(tagKey));
3915
+ }
3916
+ /**
3917
+ * {@inheritdoc}
3918
+ */
3919
+ async tagRemove(tagKey, members) {
3920
+ this.tagIndex.remove(this.parseKey(tagKey), members);
3921
+ }
3922
+ /**
3923
+ * {@inheritdoc}
3924
+ */
3925
+ async tagDelete(tagKey) {
3926
+ this.tagIndex.delete(this.parseKey(tagKey));
3927
+ }
3928
+ /**
3291
3929
  * Standard `flush` — wipes the entire mock store + tag index. Does NOT
3292
3930
  * touch the call log; use {@link reset} to clear that as well.
3293
3931
  */
@@ -3295,6 +3933,7 @@ var MockCacheDriver = class extends BaseCacheDriver {
3295
3933
  this.recordCall("flush", void 0);
3296
3934
  this.log("flushing");
3297
3935
  this.storage.clear();
3936
+ this.tagIndex.clear();
3298
3937
  this.log("flushed");
3299
3938
  await this.emit("flushed");
3300
3939
  }
@@ -3332,6 +3971,7 @@ var MockCacheDriver = class extends BaseCacheDriver {
3332
3971
  */
3333
3972
  reset() {
3334
3973
  this.storage.clear();
3974
+ this.tagIndex.clear();
3335
3975
  this.callLog.length = 0;
3336
3976
  }
3337
3977
  /**
@@ -3377,8 +4017,16 @@ var NullCacheDriver = class extends BaseCacheDriver {
3377
4017
  /**
3378
4018
  * {@inheritdoc}
3379
4019
  */
3380
- parseKey(_key) {
3381
- return "";
4020
+ parseKey(key) {
4021
+ return super.parseKey(key);
4022
+ }
4023
+ /**
4024
+ * {@inheritdoc}
4025
+ *
4026
+ * Nothing is stored, so always run the callback and return its own result.
4027
+ */
4028
+ async remember(_key, _ttlOrOptions, callback) {
4029
+ return callback();
3382
4030
  }
3383
4031
  /**
3384
4032
  * {@inheritdoc}
@@ -3437,6 +4085,8 @@ var NullCacheDriver = class extends BaseCacheDriver {
3437
4085
 
3438
4086
  //#endregion
3439
4087
  //#region ../cache/src/drivers/pg-cache-driver.ts
4088
+ /** Compare-and-set attempts before `update()` gives up under contention. */
4089
+ const PG_UPDATE_MAX_ATTEMPTS = 10;
3440
4090
  /**
3441
4091
  * Allowed characters in a Postgres identifier (table name). We accept the
3442
4092
  * conservative ASCII subset and reject anything else — interpolating an
@@ -3532,12 +4182,94 @@ var PgCacheDriver = class extends BaseCacheDriver {
3532
4182
  return this.options.client;
3533
4183
  }
3534
4184
  /**
3535
- * Compute an absolute `expires_at` Date for the given relative TTL in seconds,
3536
- * or `null` when the entry should not expire (`Infinity` / 0 / undefined).
4185
+ * {@inheritdoc}
4186
+ *
4187
+ * Single `DELETE … WHERE key AND value` — atomic in Postgres.
4188
+ */
4189
+ async deleteIfEquals(key, expected) {
4190
+ const parsedKey = this.parseKey(key);
4191
+ if ((await this.pgClient.query(`DELETE FROM ${this.table}
4192
+ WHERE key = $1 AND value = $2::jsonb AND (expires_at IS NULL OR expires_at > now())
4193
+ RETURNING 1`, [parsedKey, JSON.stringify(expected)])).rows.length === 0) return false;
4194
+ await this.emit("removed", { key: parsedKey });
4195
+ return true;
4196
+ }
4197
+ /**
4198
+ * {@inheritdoc}
4199
+ *
4200
+ * One `INSERT … ON CONFLICT DO UPDATE` that unions the stored JSON array with
4201
+ * `members`. Postgres row-locks the conflicting row, so concurrent adders on
4202
+ * any number of servers serialize and none is lost.
4203
+ *
4204
+ * The index stays a row of the main table holding a JSON array — the same
4205
+ * shape earlier versions wrote — so no schema change or migration is needed
4206
+ * and existing indexes keep working. (The `tags` column only records tags
4207
+ * passed inline to `set()`, not relationships added afterwards, so it can't
4208
+ * serve as the invalidation index.)
4209
+ */
4210
+ async tagAdd(tagKey, members) {
4211
+ if (members.length === 0) return;
4212
+ const t = this.table;
4213
+ await this.pgClient.query(`/* cache:tag-add */ INSERT INTO ${t} (key, value)
4214
+ VALUES ($1, to_jsonb($2::text[]))
4215
+ ON CONFLICT (key) DO UPDATE
4216
+ SET value = (
4217
+ SELECT COALESCE(jsonb_agg(u.member), '[]'::jsonb)
4218
+ FROM (
4219
+ SELECT x.member FROM jsonb_array_elements_text(
4220
+ CASE WHEN jsonb_typeof(${t}.value) = 'array' THEN ${t}.value ELSE '[]'::jsonb END
4221
+ ) AS x(member)
4222
+ UNION
4223
+ SELECT y.member FROM unnest($2::text[]) AS y(member)
4224
+ ) AS u
4225
+ ),
4226
+ expires_at = NULL`, [this.parseKey(tagKey), members]);
4227
+ }
4228
+ /**
4229
+ * {@inheritdoc}
4230
+ *
4231
+ * One `UPDATE` that filters `members` out of the stored array (members added
4232
+ * concurrently are kept), then a conditional `DELETE` of the row if it ended
4233
+ * up empty.
3537
4234
  */
3538
- ttlToExpiresAt(ttl) {
4235
+ async tagRemove(tagKey, members) {
4236
+ if (members.length === 0) return;
4237
+ const t = this.table;
4238
+ const parsedKey = this.parseKey(tagKey);
4239
+ await this.pgClient.query(`/* cache:tag-remove */ UPDATE ${t}
4240
+ SET value = (
4241
+ SELECT COALESCE(jsonb_agg(x.member), '[]'::jsonb)
4242
+ FROM jsonb_array_elements_text(
4243
+ CASE WHEN jsonb_typeof(${t}.value) = 'array' THEN ${t}.value ELSE '[]'::jsonb END
4244
+ ) AS x(member)
4245
+ WHERE x.member <> ALL($2::text[])
4246
+ )
4247
+ WHERE key = $1`, [parsedKey, members]);
4248
+ await this.pgClient.query(`/* cache:tag-drop-empty */ DELETE FROM ${t} WHERE key = $1 AND value = '[]'::jsonb`, [parsedKey]);
4249
+ }
4250
+ /**
4251
+ * TTL in seconds bound to `now() + make_interval(secs => $n)`, so expiry is
4252
+ * computed on the DB clock (immune to app/DB clock skew). `null` — from
4253
+ * `Infinity` / 0 / undefined — yields a NULL `expires_at` (never expires).
4254
+ */
4255
+ ttlToSeconds(ttl) {
3539
4256
  if (!ttl || ttl === Infinity) return null;
3540
- return new Date(Date.now() + ttl * 1e3);
4257
+ return ttl;
4258
+ }
4259
+ /**
4260
+ * Delete expired rows in batches of `limit`. Returns how many were removed.
4261
+ * Also invoked opportunistically (~1 in 200 writes) by `set()`.
4262
+ */
4263
+ async prune(limit = 1e3) {
4264
+ const t = this.table;
4265
+ return (await this.pgClient.query(`DELETE FROM ${t} WHERE ctid IN (SELECT ctid FROM ${t} WHERE expires_at < now() LIMIT $1)`, [limit])).rowCount ?? 0;
4266
+ }
4267
+ /**
4268
+ * Fire-and-forget `prune()` on roughly 1 in 200 writes; errors are logged.
4269
+ */
4270
+ maybePrune() {
4271
+ if (Math.random() >= 1 / 200) return;
4272
+ this.prune().catch((error) => this.logError("prune failed", error));
3541
4273
  }
3542
4274
  /**
3543
4275
  * Return the SQL needed to provision the cache table + index. Run once via
@@ -3603,7 +4335,7 @@ var PgCacheDriver = class extends BaseCacheDriver {
3603
4335
  await this.ensureVectorReady();
3604
4336
  }
3605
4337
  this.log("caching", parsedKey);
3606
- const expiresAt = this.ttlToExpiresAt(ttl);
4338
+ const expiresAt = this.ttlToSeconds(ttl);
3607
4339
  const staleAtDate = staleAt !== void 0 ? new Date(staleAt) : null;
3608
4340
  const tagsArr = tags ?? [];
3609
4341
  const serialized = JSON.stringify(value);
@@ -3619,7 +4351,7 @@ var PgCacheDriver = class extends BaseCacheDriver {
3619
4351
  const placeholders = [
3620
4352
  "$1",
3621
4353
  "$2::jsonb",
3622
- "$3",
4354
+ "now() + make_interval(secs => $3::double precision)",
3623
4355
  "$4",
3624
4356
  "$5"
3625
4357
  ];
@@ -3652,6 +4384,7 @@ var PgCacheDriver = class extends BaseCacheDriver {
3652
4384
  };
3653
4385
  if (tags && tags.length > 0) await this.applyTags(key, tags);
3654
4386
  this.log("cached", parsedKey);
4387
+ this.maybePrune();
3655
4388
  await this.emit("set", {
3656
4389
  key: parsedKey,
3657
4390
  value,
@@ -3673,6 +4406,7 @@ var PgCacheDriver = class extends BaseCacheDriver {
3673
4406
  };
3674
4407
  if (tags && tags.length > 0) await this.applyTags(key, tags);
3675
4408
  this.log("cached", parsedKey);
4409
+ this.maybePrune();
3676
4410
  await this.emit("set", {
3677
4411
  key: parsedKey,
3678
4412
  value,
@@ -3689,6 +4423,7 @@ var PgCacheDriver = class extends BaseCacheDriver {
3689
4423
  SET ${setClause}`, params);
3690
4424
  if (tags && tags.length > 0) await this.applyTags(key, tags);
3691
4425
  this.log("cached", parsedKey);
4426
+ this.maybePrune();
3692
4427
  await this.emit("set", {
3693
4428
  key: parsedKey,
3694
4429
  value,
@@ -3765,6 +4500,140 @@ var PgCacheDriver = class extends BaseCacheDriver {
3765
4500
  return entry;
3766
4501
  }
3767
4502
  /**
4503
+ * Decode a JSONB column value (node-postgres usually parses it already;
4504
+ * some pools hand back a string).
4505
+ */
4506
+ decodeJson(value) {
4507
+ if (typeof value === "string") try {
4508
+ return JSON.parse(value);
4509
+ } catch {
4510
+ return value;
4511
+ }
4512
+ return value;
4513
+ }
4514
+ /**
4515
+ * {@inheritdoc}
4516
+ *
4517
+ * One atomic `INSERT … ON CONFLICT DO UPDATE` statement, so concurrent
4518
+ * increments from any number of servers never lose an update. A live row
4519
+ * keeps its `expires_at`; a missing or expired row restarts from `value`
4520
+ * with the driver default TTL. A non-numeric live value is rejected.
4521
+ */
4522
+ async increment(key, value = 1) {
4523
+ const parsedKey = this.parseKey(key);
4524
+ const t = this.table;
4525
+ const expired = `${t}.expires_at IS NOT NULL AND ${t}.expires_at <= now()`;
4526
+ const { rows } = await this.pgClient.query(`/* cache:increment */ INSERT INTO ${t}(key, value, expires_at)
4527
+ VALUES ($1, to_jsonb($2::numeric), now() + make_interval(secs => $3::double precision))
4528
+ ON CONFLICT (key) DO UPDATE
4529
+ SET value = CASE WHEN ${expired} THEN EXCLUDED.value
4530
+ ELSE to_jsonb((${t}.value #>> '{}')::numeric + $2::numeric) END,
4531
+ expires_at = CASE WHEN ${expired} THEN EXCLUDED.expires_at ELSE ${t}.expires_at END,
4532
+ stale_at = CASE WHEN ${expired} THEN NULL ELSE ${t}.stale_at END
4533
+ WHERE (${expired}) OR jsonb_typeof(${t}.value) = 'number'
4534
+ RETURNING value`, [
4535
+ parsedKey,
4536
+ value,
4537
+ this.ttlToSeconds(this.ttl)
4538
+ ]);
4539
+ if (rows.length === 0) throw new Error(`Cannot increment non-numeric value for key: ${parsedKey}`);
4540
+ const newValue = Number(this.decodeJson(rows[0].value));
4541
+ await this.emit("set", {
4542
+ key: parsedKey,
4543
+ value: newValue,
4544
+ ttl: this.ttl
4545
+ });
4546
+ return newValue;
4547
+ }
4548
+ /**
4549
+ * {@inheritdoc}
4550
+ *
4551
+ * One atomic `DELETE … RETURNING`, so a value is handed out exactly once
4552
+ * across servers. Expired rows count as missing.
4553
+ */
4554
+ async pull(key) {
4555
+ const parsedKey = this.parseKey(key);
4556
+ const { rows } = await this.pgClient.query(`/* cache:pull */ DELETE FROM ${this.table}
4557
+ WHERE key = $1 AND (expires_at IS NULL OR expires_at > now())
4558
+ RETURNING value`, [parsedKey]);
4559
+ if (rows.length === 0) {
4560
+ await this.emit("miss", { key: parsedKey });
4561
+ return null;
4562
+ }
4563
+ const value = this.decodeJson(rows[0].value);
4564
+ await this.emit("hit", {
4565
+ key: parsedKey,
4566
+ value
4567
+ });
4568
+ await this.emit("removed", { key: parsedKey });
4569
+ return value;
4570
+ }
4571
+ /**
4572
+ * {@inheritdoc}
4573
+ *
4574
+ * Atomic across servers via optimistic compare-and-set: read the live value,
4575
+ * run `fn`, then write with `UPDATE … WHERE value = <what was read>` (or a
4576
+ * create-only insert for a missing key), retrying when another writer got
4577
+ * there first. `fn` may run more than once under contention; after
4578
+ * {@link PG_UPDATE_MAX_ATTEMPTS} lost races it throws {@link CacheError}.
4579
+ *
4580
+ * Without an explicit `ttl` the row keeps its `expires_at`; a new key gets
4581
+ * the driver default TTL. Returning `null` deletes the key (compare-and-delete).
4582
+ */
4583
+ async update(key, fn, options = {}) {
4584
+ const parsedKey = this.parseKey(key);
4585
+ const t = this.table;
4586
+ const live = `(expires_at IS NULL OR expires_at > now())`;
4587
+ const explicitTtl = options.ttl !== void 0 ? parseTtl(options.ttl) : void 0;
4588
+ for (let attempt = 0; attempt < PG_UPDATE_MAX_ATTEMPTS; attempt++) {
4589
+ const read = await this.pgClient.query(`SELECT value FROM ${t}
4590
+ WHERE key = $1 AND ${live}`, [parsedKey]);
4591
+ const exists = read.rows.length > 0;
4592
+ const current = exists ? this.decodeJson(read.rows[0].value) : null;
4593
+ const expected = exists ? JSON.stringify(current) : null;
4594
+ const result = await fn(current);
4595
+ if (result === null) {
4596
+ if (!exists) return null;
4597
+ if ((await this.pgClient.query(`/* cache:cas-delete */ DELETE FROM ${t}
4598
+ WHERE key = $1 AND value = $2::jsonb AND ${live}
4599
+ RETURNING 1`, [parsedKey, expected])).rows.length > 0) {
4600
+ await this.emit("removed", { key: parsedKey });
4601
+ return null;
4602
+ }
4603
+ continue;
4604
+ }
4605
+ if (!exists) {
4606
+ if ((await this.set(key, result, {
4607
+ onConflict: "create",
4608
+ ...explicitTtl !== void 0 ? { ttl: explicitTtl } : {}
4609
+ })).wasSet) return result;
4610
+ continue;
4611
+ }
4612
+ const params = [
4613
+ parsedKey,
4614
+ JSON.stringify(result),
4615
+ expected
4616
+ ];
4617
+ let expirySql = "";
4618
+ if (explicitTtl !== void 0) {
4619
+ params.push(this.ttlToSeconds(explicitTtl));
4620
+ expirySql = `, expires_at = now() + make_interval(secs => $4::double precision)`;
4621
+ }
4622
+ if ((await this.pgClient.query(`/* cache:cas-update */ UPDATE ${t}
4623
+ SET value = $2::jsonb, stale_at = NULL${expirySql}
4624
+ WHERE key = $1 AND value = $3::jsonb AND ${live}
4625
+ RETURNING 1`, params)).rows.length > 0) {
4626
+ await this.emit("set", {
4627
+ key: parsedKey,
4628
+ value: result,
4629
+ ttl: explicitTtl
4630
+ });
4631
+ return result;
4632
+ }
4633
+ }
4634
+ throw new CacheError(`cache.update(): '${parsedKey}' changed on every attempt (${PG_UPDATE_MAX_ATTEMPTS} tries) — too much write contention.`);
4635
+ }
4636
+ /**
3768
4637
  * {@inheritdoc}
3769
4638
  */
3770
4639
  async remove(key) {
@@ -3900,6 +4769,42 @@ async function loadRedis() {
3900
4769
  }
3901
4770
  }
3902
4771
  loadRedis();
4772
+ /** KEYS[1]=key, ARGV[1]=expected raw value. 1 when deleted, 0 when not the owner. */
4773
+ const REDIS_COMPARE_DELETE_SCRIPT = `if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]) return 1 else return 0 end`;
4774
+ /**
4775
+ * Compare-and-set used by `update()`.
4776
+ *
4777
+ * KEYS[1]=key; ARGV[1]=expected raw value (ignored when ARGV[3]=='1');
4778
+ * ARGV[2]=new raw value; ARGV[3]='1' when the key is expected to be missing;
4779
+ * ARGV[4]=ttl seconds to apply ('0' = no expiry); ARGV[5]='keep' to keep the
4780
+ * existing TTL instead (only when the key existed).
4781
+ *
4782
+ * Returns 1 when written, 0 when the current value no longer matches.
4783
+ * `KEEPTTL` requires Redis >= 6.
4784
+ */
4785
+ const REDIS_CAS_SET_SCRIPT = `local cur = redis.call('GET', KEYS[1])
4786
+ if ARGV[3] == '1' then
4787
+ if cur then return 0 end
4788
+ elseif cur ~= ARGV[1] then
4789
+ return 0
4790
+ end
4791
+ local ttl = tonumber(ARGV[4])
4792
+ if ARGV[5] == 'keep' and ARGV[3] ~= '1' then
4793
+ redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
4794
+ elseif ttl > 0 then
4795
+ redis.call('SET', KEYS[1], ARGV[2], 'EX', ttl)
4796
+ else
4797
+ redis.call('SET', KEYS[1], ARGV[2])
4798
+ end
4799
+ return 1`;
4800
+ /** KEYS[1]=key. Fallback for clients without `getDel`: returns the value and deletes it. */
4801
+ const REDIS_GET_DEL_SCRIPT = `local v = redis.call('GET', KEYS[1])
4802
+ if v then redis.call('DEL', KEYS[1]) end
4803
+ return v`;
4804
+ /** Compare-and-set attempts before `update()` gives up under contention. */
4805
+ const UPDATE_MAX_ATTEMPTS = 10;
4806
+ /** Keys deleted per UNLINK/DEL while draining a namespace. */
4807
+ const REMOVE_BATCH_SIZE = 500;
3903
4808
  var RedisCacheDriver = class extends BaseCacheDriver {
3904
4809
  constructor(..._args) {
3905
4810
  super(..._args);
@@ -3918,19 +4823,110 @@ var RedisCacheDriver = class extends BaseCacheDriver {
3918
4823
  async removeNamespace(namespace) {
3919
4824
  namespace = this.parseKey(namespace);
3920
4825
  this.log("clearing", namespace);
3921
- const pattern = namespace.replace(/[\\*?[\]]/g, "\\$&");
3922
- const keys = [];
3923
- if (this.client) for await (const key of this.client.scanIterator({
3924
- MATCH: `${pattern}*`,
3925
- COUNT: 100
3926
- })) keys.push(key);
3927
- if (keys.length === 0) {
4826
+ const escaped = namespace.replace(/[\\*?[\]]/g, "\\$&");
4827
+ const pattern = escaped === "" ? "*" : `${escaped}.*`;
4828
+ const deleted = [];
4829
+ if (this.client) {
4830
+ if (escaped !== "") {
4831
+ if (await this.deleteKeys([namespace]) > 0) deleted.push(namespace);
4832
+ }
4833
+ let batch = [];
4834
+ for await (const scanned of this.client.scanIterator({
4835
+ MATCH: pattern,
4836
+ COUNT: 100
4837
+ })) {
4838
+ const keys = Array.isArray(scanned) ? scanned : [scanned];
4839
+ for (const key of keys) {
4840
+ batch.push(key);
4841
+ if (batch.length >= REMOVE_BATCH_SIZE) {
4842
+ await this.deleteKeys(batch);
4843
+ deleted.push(...batch);
4844
+ batch = [];
4845
+ }
4846
+ }
4847
+ }
4848
+ if (batch.length > 0) {
4849
+ await this.deleteKeys(batch);
4850
+ deleted.push(...batch);
4851
+ }
4852
+ }
4853
+ if (deleted.length === 0) {
3928
4854
  this.log("notFound", namespace);
3929
4855
  return;
3930
4856
  }
3931
- await this.client?.del(keys);
3932
4857
  this.log("cleared", namespace);
3933
- return keys;
4858
+ return deleted;
4859
+ }
4860
+ /**
4861
+ * Delete keys with `UNLINK` (non-blocking free), falling back to `DEL` when
4862
+ * the client lacks it. Returns the number of keys removed.
4863
+ */
4864
+ async deleteKeys(keys) {
4865
+ const client = this.client;
4866
+ if (typeof client.unlink === "function") return Number(await client.unlink(keys)) || 0;
4867
+ return Number(await client.del(keys)) || 0;
4868
+ }
4869
+ /**
4870
+ * {@inheritdoc}
4871
+ *
4872
+ * `SADD` — atomic across servers, so concurrent tagged writes on any number
4873
+ * of instances never drop a member. The set key lives inside the prefix
4874
+ * namespace, so `flush()` / `removeNamespace()` sweep it too.
4875
+ */
4876
+ async tagAdd(tagKey, members) {
4877
+ if (members.length === 0) return;
4878
+ const parsedKey = this.parseKey(tagKey);
4879
+ await this.withTagSet(parsedKey, (client) => client.sAdd(parsedKey, members));
4880
+ }
4881
+ /**
4882
+ * {@inheritdoc}
4883
+ */
4884
+ async tagMembers(tagKey) {
4885
+ const parsedKey = this.parseKey(tagKey);
4886
+ return this.withTagSet(parsedKey, async (client) => await client.sMembers(parsedKey) ?? []);
4887
+ }
4888
+ /**
4889
+ * {@inheritdoc}
4890
+ *
4891
+ * `SREM` — Redis drops the set once its last member is removed.
4892
+ */
4893
+ async tagRemove(tagKey, members) {
4894
+ if (members.length === 0) return;
4895
+ const parsedKey = this.parseKey(tagKey);
4896
+ await this.withTagSet(parsedKey, (client) => client.sRem(parsedKey, members));
4897
+ }
4898
+ /**
4899
+ * {@inheritdoc}
4900
+ */
4901
+ async tagDelete(tagKey) {
4902
+ await this.client?.del(this.parseKey(tagKey));
4903
+ }
4904
+ /**
4905
+ * Run a SET command against a tag key, upgrading a pre-5.20 index first.
4906
+ *
4907
+ * Earlier versions stored the index as a JSON-array STRING under the same
4908
+ * key, which makes any SET command fail with `WRONGTYPE`. On that error the
4909
+ * legacy array is read, the string deleted, its members re-added with
4910
+ * `SADD`, and the command retried once.
4911
+ */
4912
+ async withTagSet(parsedKey, run) {
4913
+ const client = this.client;
4914
+ try {
4915
+ return await run(client);
4916
+ } catch (error) {
4917
+ if (!/WRONGTYPE/i.test(String(error?.message ?? error))) throw error;
4918
+ const raw = await client.get(parsedKey);
4919
+ let legacy = [];
4920
+ try {
4921
+ legacy = raw === null ? [] : JSON.parse(raw);
4922
+ } catch {
4923
+ legacy = [];
4924
+ }
4925
+ await client.del(parsedKey);
4926
+ const members = Array.isArray(legacy) ? legacy.filter((member) => typeof member === "string") : [];
4927
+ if (members.length > 0) await client.sAdd(parsedKey, members);
4928
+ return run(client);
4929
+ }
3934
4930
  }
3935
4931
  /**
3936
4932
  * {@inheritDoc}
@@ -3958,7 +4954,8 @@ var RedisCacheDriver = class extends BaseCacheDriver {
3958
4954
  existing: onConflict === "create" ? await this.get(key) : null
3959
4955
  };
3960
4956
  if (tags && tags.length > 0) await this.applyTags(key, tags);
3961
- if (staleAt !== void 0) {
4957
+ if (staleAt === void 0) await this.client?.del(this.swrMetaKey(parsedKey));
4958
+ else {
3962
4959
  const sidecarOptions = {};
3963
4960
  if (hasExpiry) sidecarOptions.EX = ttl;
3964
4961
  await this.client?.set(this.swrMetaKey(parsedKey), String(staleAt), sidecarOptions);
@@ -3976,11 +4973,28 @@ var RedisCacheDriver = class extends BaseCacheDriver {
3976
4973
  return value;
3977
4974
  }
3978
4975
  /**
4976
+ * {@inheritdoc}
4977
+ *
4978
+ * Values are stored as `JSON.stringify(value)`, so the compare runs against
4979
+ * the serialized form inside Lua.
4980
+ */
4981
+ async deleteIfEquals(key, expected) {
4982
+ const parsedKey = this.parseKey(key);
4983
+ const deleted = await this.client.eval(REDIS_COMPARE_DELETE_SCRIPT, {
4984
+ keys: [parsedKey],
4985
+ arguments: [JSON.stringify(expected)]
4986
+ });
4987
+ if (Number(deleted) !== 1) return false;
4988
+ await this.client?.del(this.swrMetaKey(parsedKey));
4989
+ await this.emit("removed", { key: parsedKey });
4990
+ return true;
4991
+ }
4992
+ /**
3979
4993
  * Build the sidecar key Redis uses to track SWR freshness without
3980
4994
  * wrapping the main value JSON.
3981
4995
  */
3982
4996
  swrMetaKey(parsedKey) {
3983
- return `__swrmeta:${parsedKey}`;
4997
+ return `${parsedKey}::swrmeta`;
3984
4998
  }
3985
4999
  /**
3986
5000
  * Read the raw {@link CacheData} wrapper, fetching the value and the
@@ -4060,17 +5074,22 @@ var RedisCacheDriver = class extends BaseCacheDriver {
4060
5074
  async remove(key) {
4061
5075
  key = this.parseKey(key);
4062
5076
  this.log("removing", key);
4063
- await this.client?.del([key, this.swrMetaKey(key)]);
5077
+ await this.client?.del(key);
5078
+ await this.client?.del(this.swrMetaKey(key));
4064
5079
  this.log("removed", key);
4065
5080
  await this.emit("removed", { key });
4066
5081
  }
4067
5082
  /**
4068
5083
  * {@inheritDoc}
5084
+ *
5085
+ * WARNING: without a `globalPrefix` this runs `FLUSHDB`, which clears the
5086
+ * WHOLE selected Redis database (including keys not written by this driver).
5087
+ * Configure a `globalPrefix` to only delete this driver's keys.
4069
5088
  */
4070
5089
  async flush() {
4071
5090
  this.log("flushing");
4072
5091
  if (this.options.globalPrefix) await this.removeNamespace("");
4073
- else await this.client?.flushAll();
5092
+ else await this.client?.flushDb();
4074
5093
  this.log("flushed");
4075
5094
  await this.emit("flushed");
4076
5095
  }
@@ -4104,6 +5123,8 @@ var RedisCacheDriver = class extends BaseCacheDriver {
4104
5123
  } catch (error) {
4105
5124
  _warlock_js_logger.log.fatal("cache", "redis", "Failed to connect", safeErrorInfo(error));
4106
5125
  await this.emit("error", { error });
5126
+ this.clientDriver = void 0;
5127
+ throw error;
4107
5128
  }
4108
5129
  }
4109
5130
  /**
@@ -4122,6 +5143,89 @@ var RedisCacheDriver = class extends BaseCacheDriver {
4122
5143
  await this.emit("disconnected");
4123
5144
  }
4124
5145
  /**
5146
+ * {@inheritdoc}
5147
+ *
5148
+ * Atomic across servers: `GETDEL` (Redis >= 6.2), or an equivalent Lua
5149
+ * script when the client has no `getDel`. A value is handed out once.
5150
+ */
5151
+ async pull(key) {
5152
+ const parsedKey = this.parseKey(key);
5153
+ const client = this.client;
5154
+ const raw = typeof client.getDel === "function" ? await client.getDel(parsedKey) : await client.eval(REDIS_GET_DEL_SCRIPT, {
5155
+ keys: [parsedKey],
5156
+ arguments: []
5157
+ });
5158
+ if (raw === null || raw === void 0) {
5159
+ await this.emit("miss", { key: parsedKey });
5160
+ return null;
5161
+ }
5162
+ await this.client?.del(this.swrMetaKey(parsedKey));
5163
+ const value = JSON.parse(raw);
5164
+ await this.emit("hit", {
5165
+ key: parsedKey,
5166
+ value
5167
+ });
5168
+ await this.emit("removed", { key: parsedKey });
5169
+ return value;
5170
+ }
5171
+ /**
5172
+ * {@inheritdoc}
5173
+ *
5174
+ * Atomic across servers via optimistic compare-and-set: read the raw value,
5175
+ * run `fn`, then write only if the value is still what was read (Lua), and
5176
+ * retry otherwise. `fn` may therefore run more than once under contention;
5177
+ * after {@link UPDATE_MAX_ATTEMPTS} lost races it throws {@link CacheError}.
5178
+ *
5179
+ * Without an explicit `ttl` the existing expiry is kept (`KEEPTTL`); a new
5180
+ * key gets the driver default TTL. Returning `null` deletes the key
5181
+ * (compare-and-delete).
5182
+ */
5183
+ async update(key, fn, options = {}) {
5184
+ const parsedKey = this.parseKey(key);
5185
+ const client = this.client;
5186
+ const explicitTtl = options.ttl !== void 0 ? parseTtl(options.ttl) : void 0;
5187
+ for (let attempt = 0; attempt < UPDATE_MAX_ATTEMPTS; attempt++) {
5188
+ const raw = await client.get(parsedKey) ?? null;
5189
+ const result = await fn(raw === null ? null : JSON.parse(raw));
5190
+ if (result === null) {
5191
+ if (raw === null) return null;
5192
+ const deleted = await client.eval(REDIS_COMPARE_DELETE_SCRIPT, {
5193
+ keys: [parsedKey],
5194
+ arguments: [raw]
5195
+ });
5196
+ if (Number(deleted) === 1) {
5197
+ await this.client?.del(this.swrMetaKey(parsedKey));
5198
+ await this.emit("removed", { key: parsedKey });
5199
+ return null;
5200
+ }
5201
+ continue;
5202
+ }
5203
+ const isNew = raw === null;
5204
+ const ttlSeconds = explicitTtl ?? (isNew ? this.ttl : 0);
5205
+ const keep = explicitTtl === void 0 && !isNew;
5206
+ const written = await client.eval(REDIS_CAS_SET_SCRIPT, {
5207
+ keys: [parsedKey],
5208
+ arguments: [
5209
+ raw ?? "",
5210
+ JSON.stringify(result),
5211
+ isNew ? "1" : "0",
5212
+ String(Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 0),
5213
+ keep ? "keep" : ""
5214
+ ]
5215
+ });
5216
+ if (Number(written) === 1) {
5217
+ await this.client?.del(this.swrMetaKey(parsedKey));
5218
+ await this.emit("set", {
5219
+ key: parsedKey,
5220
+ value: result,
5221
+ ttl: ttlSeconds
5222
+ });
5223
+ return result;
5224
+ }
5225
+ }
5226
+ throw new CacheError(`cache.update(): '${parsedKey}' changed on every attempt (${UPDATE_MAX_ATTEMPTS} tries) — too much write contention.`);
5227
+ }
5228
+ /**
4125
5229
  * Atomic increment using Redis native INCRBY command
4126
5230
  * {@inheritdoc}
4127
5231
  */
@@ -4129,6 +5233,7 @@ var RedisCacheDriver = class extends BaseCacheDriver {
4129
5233
  const parsedKey = this.parseKey(key);
4130
5234
  this.log("caching", parsedKey);
4131
5235
  const result = await this.client?.incrBy(parsedKey, value);
5236
+ if (this.ttl !== Infinity && this.ttl > 0) await this.client?.expire(parsedKey, this.ttl, "NX");
4132
5237
  this.log("cached", parsedKey);
4133
5238
  await this.emit("set", {
4134
5239
  key: parsedKey,
@@ -4199,6 +5304,9 @@ exports.MemoryExtendedCacheDriver = MemoryExtendedCacheDriver;
4199
5304
  exports.MockCacheDriver = MockCacheDriver;
4200
5305
  exports.NullCacheDriver = NullCacheDriver;
4201
5306
  exports.PgCacheDriver = PgCacheDriver;
5307
+ exports.REDIS_CAS_SET_SCRIPT = REDIS_CAS_SET_SCRIPT;
5308
+ exports.REDIS_COMPARE_DELETE_SCRIPT = REDIS_COMPARE_DELETE_SCRIPT;
5309
+ exports.REDIS_GET_DEL_SCRIPT = REDIS_GET_DEL_SCRIPT;
4202
5310
  exports.RedisCacheDriver = RedisCacheDriver;
4203
5311
  exports.ScopedCache = ScopedCache;
4204
5312
  exports.TaggedCache = TaggedCache;