@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.
- package/CHANGELOG.md +55 -0
- package/cjs/index.cjs +1439 -331
- package/cjs/index.cjs.map +1 -1
- package/esm/cache-manager.d.mts +4 -0
- package/esm/cache-manager.d.mts.map +1 -1
- package/esm/cache-manager.mjs +23 -5
- package/esm/cache-manager.mjs.map +1 -1
- package/esm/cached/auto-key.d.mts +14 -14
- package/esm/cached/auto-key.d.mts.map +1 -1
- package/esm/cached/auto-key.mjs +50 -34
- package/esm/cached/auto-key.mjs.map +1 -1
- package/esm/cached/cached.mjs +4 -0
- package/esm/cached/cached.mjs.map +1 -1
- package/esm/drivers/base-cache-driver.d.mts +84 -8
- package/esm/drivers/base-cache-driver.d.mts.map +1 -1
- package/esm/drivers/base-cache-driver.mjs +152 -42
- package/esm/drivers/base-cache-driver.mjs.map +1 -1
- package/esm/drivers/file-cache-driver.d.mts +15 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -1
- package/esm/drivers/file-cache-driver.mjs +68 -18
- package/esm/drivers/file-cache-driver.mjs.map +1 -1
- package/esm/drivers/in-memory-tag-index.d.mts +44 -0
- package/esm/drivers/in-memory-tag-index.d.mts.map +1 -0
- package/esm/drivers/in-memory-tag-index.mjs +73 -0
- package/esm/drivers/in-memory-tag-index.mjs.map +1 -0
- package/esm/drivers/index.d.mts +1 -1
- package/esm/drivers/index.mjs +1 -1
- package/esm/drivers/lru-memory-cache-driver.d.mts +43 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/lru-memory-cache-driver.mjs +96 -0
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/memory-cache-driver.d.mts +87 -30
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -1
- package/esm/drivers/memory-cache-driver.mjs +214 -96
- package/esm/drivers/memory-cache-driver.mjs.map +1 -1
- package/esm/drivers/memory-extended-cache-driver.d.mts +9 -5
- package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -1
- package/esm/drivers/memory-extended-cache-driver.mjs +10 -23
- package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -1
- package/esm/drivers/mock-cache-driver.d.mts +41 -0
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -1
- package/esm/drivers/mock-cache-driver.mjs +105 -8
- package/esm/drivers/mock-cache-driver.mjs.map +1 -1
- package/esm/drivers/null-cache-driver.d.mts +8 -2
- package/esm/drivers/null-cache-driver.d.mts.map +1 -1
- package/esm/drivers/null-cache-driver.mjs +10 -2
- package/esm/drivers/null-cache-driver.mjs.map +1 -1
- package/esm/drivers/pg-cache-driver.d.mts +77 -3
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -1
- package/esm/drivers/pg-cache-driver.mjs +229 -7
- package/esm/drivers/pg-cache-driver.mjs.map +1 -1
- package/esm/drivers/redis-cache-driver.d.mts +86 -1
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
- package/esm/drivers/redis-cache-driver.mjs +252 -16
- package/esm/drivers/redis-cache-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/index.mjs +2 -2
- package/esm/list/memory-cache-list.d.mts +11 -8
- package/esm/list/memory-cache-list.d.mts.map +1 -1
- package/esm/list/memory-cache-list.mjs +39 -37
- package/esm/list/memory-cache-list.mjs.map +1 -1
- package/esm/scoped-cache.d.mts +9 -3
- package/esm/scoped-cache.d.mts.map +1 -1
- package/esm/scoped-cache.mjs +19 -7
- package/esm/scoped-cache.mjs.map +1 -1
- package/esm/tagged-cache.d.mts +20 -0
- package/esm/tagged-cache.d.mts.map +1 -1
- package/esm/tagged-cache.mjs +62 -24
- package/esm/tagged-cache.mjs.map +1 -1
- package/esm/tagged-scoped-cache.d.mts +7 -3
- package/esm/tagged-scoped-cache.d.mts.map +1 -1
- package/esm/tagged-scoped-cache.mjs +19 -7
- package/esm/tagged-scoped-cache.mjs.map +1 -1
- package/esm/types.d.mts +41 -3
- package/esm/types.d.mts.map +1 -1
- package/esm/types.mjs.map +1 -1
- package/esm/utils.d.mts +9 -3
- package/esm/utils.d.mts.map +1 -1
- package/esm/utils.mjs +16 -6
- package/esm/utils.mjs.map +1 -1
- package/llms-full.txt +1685 -1666
- package/llms.txt +18 -18
- package/package.json +4 -4
- package/skills/apply-cache-patterns/SKILL.md +98 -97
- package/skills/configure-pg-cache/SKILL.md +6 -5
- package/skills/configure-set-options/SKILL.md +102 -96
- package/skills/handle-cache-errors/SKILL.md +92 -92
- package/skills/index.md +20 -0
- package/skills/observe-cache/SKILL.md +103 -103
- package/skills/pick-cache-driver/SKILL.md +129 -123
- package/skills/test-cache-code/SKILL.md +219 -219
- package/skills/use-cache-atomic/SKILL.md +57 -65
- package/skills/use-cache-bulk/SKILL.md +57 -57
- package/skills/use-cache-list/SKILL.md +85 -85
- package/skills/use-cache-lock/SKILL.md +113 -104
- package/skills/use-cache-namespace/SKILL.md +88 -88
- package/skills/use-cache-similarity/SKILL.md +94 -94
- package/skills/use-cache-tags/SKILL.md +7 -4
- package/skills/use-cache-update-merge/SKILL.md +84 -84
- package/skills/use-cache-utils/SKILL.md +89 -89
- package/skills/use-cached-hof/SKILL.md +100 -99
- 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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
599
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
850
|
-
*
|
|
851
|
-
* an
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
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("
|
|
1441
|
-
* deriveAutoKey("
|
|
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
|
-
|
|
1503
|
+
let encoded;
|
|
1449
1504
|
try {
|
|
1450
|
-
|
|
1505
|
+
encoded = JSON.stringify(args.map((arg) => normalize(arg, prefix, /* @__PURE__ */ new Set())));
|
|
1451
1506
|
} catch (error) {
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
*
|
|
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
|
|
1532
|
-
* lock when multi-process writers are possible),
|
|
1533
|
-
*
|
|
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
|
-
*
|
|
1554
|
-
*
|
|
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
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
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
|
|
1728
|
-
await this.driver.
|
|
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
|
-
*
|
|
1850
|
+
* Remove `indexedKey` from every tag of this handle.
|
|
1733
1851
|
*/
|
|
1734
|
-
async
|
|
1735
|
-
const
|
|
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.
|
|
1767
|
-
if (value !== null) await this.
|
|
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
|
|
1781
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1961
|
-
|
|
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
|
|
1990
|
-
*
|
|
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.
|
|
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.
|
|
2272
|
+
if (this.swrRefreshes.get(parsedKey) === refresh) this.swrRefreshes.delete(parsedKey);
|
|
2082
2273
|
}
|
|
2083
2274
|
})();
|
|
2084
|
-
this.
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
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
|
-
|
|
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
|
|
2283
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
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.
|
|
2870
|
-
this.
|
|
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
|
|
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
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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
|
-
*
|
|
3350
|
+
* Delete an entry and every side record (expiry, vector). Synchronous.
|
|
2893
3351
|
*/
|
|
2894
|
-
|
|
2895
|
-
this.
|
|
2896
|
-
|
|
2897
|
-
|
|
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
|
-
*
|
|
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
|
|
2914
|
-
|
|
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:
|
|
3423
|
+
existing: cloneValue(existing.data)
|
|
2918
3424
|
};
|
|
2919
|
-
if (onConflict === "update" && !
|
|
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
|
-
|
|
2925
|
-
|
|
2926
|
-
this.
|
|
2927
|
-
|
|
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
|
|
2949
|
-
if (!
|
|
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
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
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
|
|
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
|
-
|
|
2975
|
-
|
|
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
|
-
|
|
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
|
|
2994
|
-
this.
|
|
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
|
-
*
|
|
3567
|
+
* {@inheritdoc}
|
|
3006
3568
|
*/
|
|
3007
|
-
|
|
3008
|
-
this.
|
|
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
|
-
*
|
|
3573
|
+
* {@inheritdoc}
|
|
3015
3574
|
*/
|
|
3016
|
-
|
|
3017
|
-
|
|
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
|
-
*
|
|
3579
|
+
* {@inheritdoc}
|
|
3024
3580
|
*/
|
|
3025
|
-
|
|
3026
|
-
|
|
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
|
-
*
|
|
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
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
this.
|
|
3042
|
-
|
|
3043
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
3052
|
-
|
|
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 =
|
|
3629
|
+
const entry = this.readLive(parsedKey);
|
|
3070
3630
|
if (!entry) continue;
|
|
3071
|
-
|
|
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(
|
|
3105
|
-
super(
|
|
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
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
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" && !
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
3536
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
3922
|
-
const
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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?.
|
|
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;
|