@warlock.js/cache 4.0.174 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/cjs/index.cjs +4088 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/cache-manager.d.mts +314 -0
- package/esm/cache-manager.d.mts.map +1 -0
- package/esm/cache-manager.mjs +486 -0
- package/esm/cache-manager.mjs.map +1 -0
- package/esm/cached/auto-key.d.mts +25 -0
- package/esm/cached/auto-key.d.mts.map +1 -0
- package/esm/cached/auto-key.mjs +55 -0
- package/esm/cached/auto-key.mjs.map +1 -0
- package/esm/cached/cached.d.mts +54 -0
- package/esm/cached/cached.d.mts.map +1 -0
- package/esm/cached/cached.mjs +25 -0
- package/esm/cached/cached.mjs.map +1 -0
- package/esm/cached/index.d.mts +3 -0
- package/esm/cached/index.mjs +5 -0
- package/esm/cached/normalize-args.d.mts +51 -0
- package/esm/cached/normalize-args.d.mts.map +1 -0
- package/esm/cached/normalize-args.mjs +26 -0
- package/esm/cached/normalize-args.mjs.map +1 -0
- package/esm/drivers/base-cache-driver.d.mts +322 -0
- package/esm/drivers/base-cache-driver.d.mts.map +1 -0
- package/esm/drivers/base-cache-driver.mjs +522 -0
- package/esm/drivers/base-cache-driver.mjs.map +1 -0
- package/esm/drivers/file-cache-driver.d.mts +68 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -0
- package/esm/drivers/file-cache-driver.mjs +174 -0
- package/esm/drivers/file-cache-driver.mjs.map +1 -0
- package/esm/drivers/index.d.mts +9 -0
- package/esm/drivers/index.mjs +11 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts +136 -0
- package/esm/drivers/lru-memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/lru-memory-cache-driver.mjs +317 -0
- package/esm/drivers/lru-memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-cache-driver.d.mts +112 -0
- package/esm/drivers/memory-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-cache-driver.mjs +241 -0
- package/esm/drivers/memory-cache-driver.mjs.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts +17 -0
- package/esm/drivers/memory-extended-cache-driver.d.mts.map +1 -0
- package/esm/drivers/memory-extended-cache-driver.mjs +34 -0
- package/esm/drivers/memory-extended-cache-driver.mjs.map +1 -0
- package/esm/drivers/mock-cache-driver.d.mts +137 -0
- package/esm/drivers/mock-cache-driver.d.mts.map +1 -0
- package/esm/drivers/mock-cache-driver.mjs +226 -0
- package/esm/drivers/mock-cache-driver.mjs.map +1 -0
- package/esm/drivers/null-cache-driver.d.mts +69 -0
- package/esm/drivers/null-cache-driver.d.mts.map +1 -0
- package/esm/drivers/null-cache-driver.mjs +92 -0
- package/esm/drivers/null-cache-driver.mjs.map +1 -0
- package/esm/drivers/pg-cache-driver.d.mts +148 -0
- package/esm/drivers/pg-cache-driver.d.mts.map +1 -0
- package/esm/drivers/pg-cache-driver.mjs +437 -0
- package/esm/drivers/pg-cache-driver.mjs.map +1 -0
- package/esm/drivers/redis-cache-driver.d.mts +86 -0
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -0
- package/esm/drivers/redis-cache-driver.mjs +312 -0
- package/esm/drivers/redis-cache-driver.mjs.map +1 -0
- package/esm/index.d.mts +21 -0
- package/esm/index.mjs +24 -0
- package/esm/list/index.d.mts +1 -0
- package/esm/list/memory-cache-list.d.mts +77 -0
- package/esm/list/memory-cache-list.d.mts.map +1 -0
- package/esm/list/memory-cache-list.mjs +119 -0
- package/esm/list/memory-cache-list.mjs.map +1 -0
- package/esm/metrics.d.mts +118 -0
- package/esm/metrics.d.mts.map +1 -0
- package/esm/metrics.mjs +197 -0
- package/esm/metrics.mjs.map +1 -0
- package/esm/scoped-cache.d.mts +205 -0
- package/esm/scoped-cache.d.mts.map +1 -0
- package/esm/scoped-cache.mjs +274 -0
- package/esm/scoped-cache.mjs.map +1 -0
- package/esm/tagged-cache.d.mts +89 -0
- package/esm/tagged-cache.d.mts.map +1 -0
- package/esm/tagged-cache.mjs +147 -0
- package/esm/tagged-cache.mjs.map +1 -0
- package/esm/tagged-scoped-cache.d.mts +111 -0
- package/esm/tagged-scoped-cache.d.mts.map +1 -0
- package/esm/tagged-scoped-cache.mjs +142 -0
- package/esm/tagged-scoped-cache.mjs.map +1 -0
- package/esm/types.d.mts +1067 -0
- package/esm/types.d.mts.map +1 -0
- package/esm/types.mjs +62 -0
- package/esm/types.mjs.map +1 -0
- package/esm/utils.d.mts +161 -0
- package/esm/utils.d.mts.map +1 -0
- package/esm/utils.mjs +222 -0
- package/esm/utils.mjs.map +1 -0
- package/llms-full.txt +2071 -0
- package/llms.txt +28 -0
- package/package.json +53 -39
- package/skills/apply-cache-patterns/SKILL.md +97 -0
- package/skills/cache-basics/SKILL.md +121 -0
- package/skills/configure-pg-cache/SKILL.md +115 -0
- package/skills/configure-set-options/SKILL.md +96 -0
- package/skills/handle-cache-errors/SKILL.md +91 -0
- package/skills/observe-cache/SKILL.md +103 -0
- package/skills/overview/SKILL.md +69 -0
- package/skills/pick-cache-driver/SKILL.md +115 -0
- package/skills/test-cache-code/SKILL.md +219 -0
- package/skills/use-cache-atomic/SKILL.md +67 -0
- package/skills/use-cache-bulk/SKILL.md +57 -0
- package/skills/use-cache-list/SKILL.md +85 -0
- package/skills/use-cache-lock/SKILL.md +104 -0
- package/skills/use-cache-namespace/SKILL.md +88 -0
- package/skills/use-cache-similarity/SKILL.md +94 -0
- package/skills/use-cache-tags/SKILL.md +85 -0
- package/skills/use-cache-update-merge/SKILL.md +84 -0
- package/skills/use-cache-utils/SKILL.md +89 -0
- package/skills/use-cached-hof/SKILL.md +102 -0
- package/skills/use-swr/SKILL.md +104 -0
- package/cjs/cache-manager.d.ts +0 -163
- package/cjs/cache-manager.d.ts.map +0 -1
- package/cjs/cache-manager.js +0 -322
- package/cjs/cache-manager.js.map +0 -1
- package/cjs/drivers/base-cache-driver.d.ts +0 -152
- package/cjs/drivers/base-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/base-cache-driver.js +0 -321
- package/cjs/drivers/base-cache-driver.js.map +0 -1
- package/cjs/drivers/file-cache-driver.d.ts +0 -45
- package/cjs/drivers/file-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/file-cache-driver.js +0 -133
- package/cjs/drivers/file-cache-driver.js.map +0 -1
- package/cjs/drivers/index.d.ts +0 -8
- package/cjs/drivers/index.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/cjs/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/lru-memory-cache-driver.js +0 -252
- package/cjs/drivers/lru-memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-cache-driver.d.ts +0 -82
- package/cjs/drivers/memory-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-cache-driver.js +0 -218
- package/cjs/drivers/memory-cache-driver.js.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/cjs/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/memory-extended-cache-driver.js +0 -25
- package/cjs/drivers/memory-extended-cache-driver.js.map +0 -1
- package/cjs/drivers/null-cache-driver.d.ts +0 -58
- package/cjs/drivers/null-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/null-cache-driver.js +0 -84
- package/cjs/drivers/null-cache-driver.js.map +0 -1
- package/cjs/drivers/redis-cache-driver.d.ts +0 -57
- package/cjs/drivers/redis-cache-driver.d.ts.map +0 -1
- package/cjs/drivers/redis-cache-driver.js +0 -263
- package/cjs/drivers/redis-cache-driver.js.map +0 -1
- package/cjs/index.d.ts +0 -6
- package/cjs/index.d.ts.map +0 -1
- package/cjs/index.js +0 -1
- package/cjs/index.js.map +0 -1
- package/cjs/tagged-cache.d.ts +0 -77
- package/cjs/tagged-cache.d.ts.map +0 -1
- package/cjs/tagged-cache.js +0 -160
- package/cjs/tagged-cache.js.map +0 -1
- package/cjs/types.d.ts +0 -391
- package/cjs/types.d.ts.map +0 -1
- package/cjs/types.js +0 -36
- package/cjs/types.js.map +0 -1
- package/cjs/utils.d.ts +0 -50
- package/cjs/utils.d.ts.map +0 -1
- package/cjs/utils.js +0 -55
- package/cjs/utils.js.map +0 -1
- package/esm/cache-manager.d.ts +0 -163
- package/esm/cache-manager.d.ts.map +0 -1
- package/esm/cache-manager.js +0 -322
- package/esm/cache-manager.js.map +0 -1
- package/esm/drivers/base-cache-driver.d.ts +0 -152
- package/esm/drivers/base-cache-driver.d.ts.map +0 -1
- package/esm/drivers/base-cache-driver.js +0 -321
- package/esm/drivers/base-cache-driver.js.map +0 -1
- package/esm/drivers/file-cache-driver.d.ts +0 -45
- package/esm/drivers/file-cache-driver.d.ts.map +0 -1
- package/esm/drivers/file-cache-driver.js +0 -133
- package/esm/drivers/file-cache-driver.js.map +0 -1
- package/esm/drivers/index.d.ts +0 -8
- package/esm/drivers/index.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.d.ts +0 -98
- package/esm/drivers/lru-memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/lru-memory-cache-driver.js +0 -252
- package/esm/drivers/lru-memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-cache-driver.d.ts +0 -82
- package/esm/drivers/memory-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-cache-driver.js +0 -218
- package/esm/drivers/memory-cache-driver.js.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.d.ts +0 -13
- package/esm/drivers/memory-extended-cache-driver.d.ts.map +0 -1
- package/esm/drivers/memory-extended-cache-driver.js +0 -25
- package/esm/drivers/memory-extended-cache-driver.js.map +0 -1
- package/esm/drivers/null-cache-driver.d.ts +0 -58
- package/esm/drivers/null-cache-driver.d.ts.map +0 -1
- package/esm/drivers/null-cache-driver.js +0 -84
- package/esm/drivers/null-cache-driver.js.map +0 -1
- package/esm/drivers/redis-cache-driver.d.ts +0 -57
- package/esm/drivers/redis-cache-driver.d.ts.map +0 -1
- package/esm/drivers/redis-cache-driver.js +0 -263
- package/esm/drivers/redis-cache-driver.js.map +0 -1
- package/esm/index.d.ts +0 -6
- package/esm/index.d.ts.map +0 -1
- package/esm/index.js +0 -1
- package/esm/index.js.map +0 -1
- package/esm/tagged-cache.d.ts +0 -77
- package/esm/tagged-cache.d.ts.map +0 -1
- package/esm/tagged-cache.js +0 -160
- package/esm/tagged-cache.js.map +0 -1
- package/esm/types.d.ts +0 -391
- package/esm/types.d.ts.map +0 -1
- package/esm/types.js +0 -36
- package/esm/types.js.map +0 -1
- package/esm/utils.d.ts +0 -50
- package/esm/utils.d.ts.map +0 -1
- package/esm/utils.js +0 -55
- package/esm/utils.js.map +0 -1
package/cjs/index.cjs
ADDED
|
@@ -0,0 +1,4088 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let _mongez_reinforcements = require("@mongez/reinforcements");
|
|
30
|
+
let ms = require("ms");
|
|
31
|
+
ms = __toESM(ms, 1);
|
|
32
|
+
let _warlock_js_logger = require("@warlock.js/logger");
|
|
33
|
+
let _warlock_js_fs = require("@warlock.js/fs");
|
|
34
|
+
let path = require("path");
|
|
35
|
+
path = __toESM(path, 1);
|
|
36
|
+
|
|
37
|
+
//#region ../../@warlock.js/cache/src/metrics.ts
|
|
38
|
+
/**
|
|
39
|
+
* Default size of the circular latency-sample buffer. 1000 samples covers
|
|
40
|
+
* "tell me the current p95" for every realistic workload while keeping the
|
|
41
|
+
* memory cost negligible (8KB at 8 bytes per number).
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_LATENCY_BUFFER_SIZE = 1e3;
|
|
44
|
+
/**
|
|
45
|
+
* Listens to `CacheManager` events and accumulates running counters + a
|
|
46
|
+
* circular latency buffer per driver. Returned to consumers via
|
|
47
|
+
* `cache.metrics()` as a {@link CacheMetricsSnapshot}.
|
|
48
|
+
*
|
|
49
|
+
* **Role.** Single-instance observability layer attached to the manager.
|
|
50
|
+
* Subscribes once at construction; survives `cache.use()` driver switches
|
|
51
|
+
* because the global event registry on the manager re-attaches handlers to
|
|
52
|
+
* every loaded driver.
|
|
53
|
+
*
|
|
54
|
+
* **Responsibility.**
|
|
55
|
+
* - Owns: per-driver and aggregate counters (`hits`, `misses`, `sets`,
|
|
56
|
+
* `removed`, `errors`), the latency circular buffer, and snapshot
|
|
57
|
+
* computation including hit-rate + percentile calculation.
|
|
58
|
+
* - Does NOT own: event emission (driven by drivers via `BaseCacheDriver.emit`),
|
|
59
|
+
* timing instrumentation (done at the manager level via `recordLatency`),
|
|
60
|
+
* or persistence — every metric resets on `resetMetrics()` and on process
|
|
61
|
+
* restart.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* cache.metrics();
|
|
65
|
+
* // {
|
|
66
|
+
* // hits: 9821, misses: 173, hitRate: 0.983,
|
|
67
|
+
* // latencyMs: { p50: 0.4, p95: 2.1, p99: 8.2, samples: 1000 },
|
|
68
|
+
* // byDriver: { memory: { ... }, redis: { ... } },
|
|
69
|
+
* // startedAt: 1714185600000,
|
|
70
|
+
* // }
|
|
71
|
+
*/
|
|
72
|
+
var CacheMetricsCollector = class {
|
|
73
|
+
constructor(bufferSize = DEFAULT_LATENCY_BUFFER_SIZE) {
|
|
74
|
+
this.byDriver = /* @__PURE__ */ new Map();
|
|
75
|
+
this.startedAt = Date.now();
|
|
76
|
+
this.bufferSize = bufferSize;
|
|
77
|
+
this.aggregate = this.createCounters();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Increment the appropriate counters for a cache event. Called from the
|
|
81
|
+
* manager's global listeners (one per event type).
|
|
82
|
+
*/
|
|
83
|
+
recordEvent(event, data) {
|
|
84
|
+
const driverBucket = this.bucketFor(data.driver);
|
|
85
|
+
const aggregate = this.aggregate;
|
|
86
|
+
switch (event) {
|
|
87
|
+
case "hit":
|
|
88
|
+
aggregate.hits += 1;
|
|
89
|
+
driverBucket.hits += 1;
|
|
90
|
+
break;
|
|
91
|
+
case "miss":
|
|
92
|
+
aggregate.misses += 1;
|
|
93
|
+
driverBucket.misses += 1;
|
|
94
|
+
break;
|
|
95
|
+
case "set":
|
|
96
|
+
aggregate.sets += 1;
|
|
97
|
+
driverBucket.sets += 1;
|
|
98
|
+
break;
|
|
99
|
+
case "removed":
|
|
100
|
+
aggregate.removed += 1;
|
|
101
|
+
driverBucket.removed += 1;
|
|
102
|
+
break;
|
|
103
|
+
case "error":
|
|
104
|
+
aggregate.errors += 1;
|
|
105
|
+
driverBucket.errors += 1;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Append a latency sample for `driver`. Called by the manager from its
|
|
111
|
+
* timed wrappers around `get` / `set` / `remove`. Uses circular-buffer
|
|
112
|
+
* semantics: oldest samples are overwritten once the buffer is full.
|
|
113
|
+
*/
|
|
114
|
+
recordLatency(driver, durationMs) {
|
|
115
|
+
this.appendLatency(this.aggregate, durationMs);
|
|
116
|
+
this.appendLatency(this.bucketFor(driver), durationMs);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Compute and return the current snapshot. Latency percentiles are
|
|
120
|
+
* derived from a sorted copy of the buffer at call time — O(N log N)
|
|
121
|
+
* on N=1000 is cheap enough that we don't bother caching.
|
|
122
|
+
*/
|
|
123
|
+
snapshot() {
|
|
124
|
+
const byDriver = {};
|
|
125
|
+
for (const [driverName, bucket] of this.byDriver) byDriver[driverName] = this.toRow(bucket);
|
|
126
|
+
return {
|
|
127
|
+
...this.toRow(this.aggregate),
|
|
128
|
+
byDriver,
|
|
129
|
+
startedAt: this.startedAt
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Wipe every counter, drop every latency sample, and reset `startedAt`.
|
|
134
|
+
* The collector itself stays subscribed to events.
|
|
135
|
+
*/
|
|
136
|
+
reset() {
|
|
137
|
+
this.resetCounters(this.aggregate);
|
|
138
|
+
this.byDriver.clear();
|
|
139
|
+
this.startedAt = Date.now();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Locate the per-driver bucket, creating it on first reference. Driver
|
|
143
|
+
* names are taken verbatim from `CacheEventData.driver`.
|
|
144
|
+
*/
|
|
145
|
+
bucketFor(driverName) {
|
|
146
|
+
let bucket = this.byDriver.get(driverName);
|
|
147
|
+
if (!bucket) {
|
|
148
|
+
bucket = this.createCounters();
|
|
149
|
+
this.byDriver.set(driverName, bucket);
|
|
150
|
+
}
|
|
151
|
+
return bucket;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Convert raw counters into the public snapshot row shape. Computes
|
|
155
|
+
* `hitRate` and the latency percentiles on the fly.
|
|
156
|
+
*/
|
|
157
|
+
toRow(bucket) {
|
|
158
|
+
const totalReads = bucket.hits + bucket.misses;
|
|
159
|
+
const hitRate = totalReads === 0 ? 0 : bucket.hits / totalReads;
|
|
160
|
+
const latency = this.computeLatency(bucket.latencySamples);
|
|
161
|
+
return {
|
|
162
|
+
hits: bucket.hits,
|
|
163
|
+
misses: bucket.misses,
|
|
164
|
+
sets: bucket.sets,
|
|
165
|
+
removed: bucket.removed,
|
|
166
|
+
errors: bucket.errors,
|
|
167
|
+
hitRate,
|
|
168
|
+
latencyMs: latency,
|
|
169
|
+
startedAt: this.startedAt
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Sort a copy of the buffer and pick the percentile entries directly.
|
|
174
|
+
* Empty buffers return zeroed percentiles so consumers can render
|
|
175
|
+
* dashboards without null-checking.
|
|
176
|
+
*/
|
|
177
|
+
computeLatency(samples) {
|
|
178
|
+
if (samples.length === 0) return {
|
|
179
|
+
p50: 0,
|
|
180
|
+
p95: 0,
|
|
181
|
+
p99: 0,
|
|
182
|
+
samples: 0
|
|
183
|
+
};
|
|
184
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
185
|
+
const pick = (quantile) => {
|
|
186
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(quantile * sorted.length))];
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
p50: pick(.5),
|
|
190
|
+
p95: pick(.95),
|
|
191
|
+
p99: pick(.99),
|
|
192
|
+
samples: sorted.length
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Append a latency sample using circular-buffer semantics — overwrite the
|
|
197
|
+
* oldest entry once the buffer is full instead of growing unbounded.
|
|
198
|
+
*/
|
|
199
|
+
appendLatency(bucket, durationMs) {
|
|
200
|
+
if (bucket.latencySamples.length < this.bufferSize) {
|
|
201
|
+
bucket.latencySamples.push(durationMs);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
bucket.latencySamples[bucket.latencyCursor] = durationMs;
|
|
205
|
+
bucket.latencyCursor = (bucket.latencyCursor + 1) % this.bufferSize;
|
|
206
|
+
}
|
|
207
|
+
/** Build a fresh counter row with zeroed totals and an empty buffer. */
|
|
208
|
+
createCounters() {
|
|
209
|
+
return {
|
|
210
|
+
hits: 0,
|
|
211
|
+
misses: 0,
|
|
212
|
+
sets: 0,
|
|
213
|
+
removed: 0,
|
|
214
|
+
errors: 0,
|
|
215
|
+
latencySamples: [],
|
|
216
|
+
latencyCursor: 0
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Reset an existing counter row in place. Used by `reset()` for the aggregate. */
|
|
220
|
+
resetCounters(bucket) {
|
|
221
|
+
bucket.hits = 0;
|
|
222
|
+
bucket.misses = 0;
|
|
223
|
+
bucket.sets = 0;
|
|
224
|
+
bucket.removed = 0;
|
|
225
|
+
bucket.errors = 0;
|
|
226
|
+
bucket.latencySamples.length = 0;
|
|
227
|
+
bucket.latencyCursor = 0;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region ../../@warlock.js/cache/src/types.ts
|
|
233
|
+
/**
|
|
234
|
+
* Base error class for cache-related errors
|
|
235
|
+
*/
|
|
236
|
+
var CacheError = class extends Error {
|
|
237
|
+
constructor(message) {
|
|
238
|
+
super(message);
|
|
239
|
+
this.name = "CacheError";
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Error thrown when cache connection fails
|
|
244
|
+
*/
|
|
245
|
+
var CacheConnectionError = class extends CacheError {
|
|
246
|
+
constructor(message) {
|
|
247
|
+
super(message);
|
|
248
|
+
this.name = "CacheConnectionError";
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* Error thrown when cache driver configuration is invalid
|
|
253
|
+
*/
|
|
254
|
+
var CacheConfigurationError = class extends CacheError {
|
|
255
|
+
constructor(message) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = "CacheConfigurationError";
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Error thrown when cache driver is not initialized
|
|
262
|
+
*/
|
|
263
|
+
var CacheDriverNotInitializedError = class extends CacheError {
|
|
264
|
+
constructor(message = "No cache driver initialized. Call cache.init() or cache.use() first.") {
|
|
265
|
+
super(message);
|
|
266
|
+
this.name = "CacheDriverNotInitializedError";
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Error thrown when a driver does not implement a requested operation.
|
|
271
|
+
*
|
|
272
|
+
* Raised when a caller invokes a method the driver cannot fulfill —
|
|
273
|
+
* e.g. `update()` on the file driver before the file-lock primitive lands.
|
|
274
|
+
*/
|
|
275
|
+
var CacheUnsupportedError = class extends CacheError {
|
|
276
|
+
constructor(message) {
|
|
277
|
+
super(message);
|
|
278
|
+
this.name = "CacheUnsupportedError";
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Error thrown when an optimistic-concurrency update exhausts its retry budget.
|
|
283
|
+
*/
|
|
284
|
+
var CacheConcurrencyError = class extends CacheError {
|
|
285
|
+
constructor(message) {
|
|
286
|
+
super(message);
|
|
287
|
+
this.name = "CacheConcurrencyError";
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region ../../@warlock.js/cache/src/utils.ts
|
|
293
|
+
/**
|
|
294
|
+
* Make a proper key for the cache
|
|
295
|
+
*/
|
|
296
|
+
function parseCacheKey(key, options = {}) {
|
|
297
|
+
if (typeof key === "object") key = JSON.stringify(key);
|
|
298
|
+
key = key.replace(/[{}"[\]]/g, "").replaceAll(/[:,]/g, ".");
|
|
299
|
+
const cachePrefix = typeof options.globalPrefix === "function" ? options.globalPrefix() : options.globalPrefix;
|
|
300
|
+
return (0, _mongez_reinforcements.rtrim)(String(cachePrefix ? (0, _mongez_reinforcements.rtrim)(cachePrefix, ".") + "." + key : key), ".");
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Parse a TTL value into seconds.
|
|
304
|
+
*
|
|
305
|
+
* Accepts:
|
|
306
|
+
* - a number (already in seconds) — returned unchanged
|
|
307
|
+
* - `Infinity` — no expiration, returned unchanged
|
|
308
|
+
* - a human-readable duration string (e.g. `"1h"`, `"30m"`, `"7d"`) — parsed via `ms` then converted to seconds
|
|
309
|
+
*
|
|
310
|
+
* Throws `CacheConfigurationError` on unparseable strings or negative numbers.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* parseTtl(3600); // 3600
|
|
314
|
+
* parseTtl("1h"); // 3600
|
|
315
|
+
* parseTtl("7d"); // 604800
|
|
316
|
+
* parseTtl(Infinity); // Infinity
|
|
317
|
+
*/
|
|
318
|
+
function parseTtl(input) {
|
|
319
|
+
if (typeof input === "number") {
|
|
320
|
+
if (input < 0) throw new CacheConfigurationError(`Invalid TTL: negative number (${input}).`);
|
|
321
|
+
return input;
|
|
322
|
+
}
|
|
323
|
+
if (typeof input !== "string" || input.trim() === "") throw new CacheConfigurationError(`Invalid TTL: expected number or duration string, got ${typeof input}.`);
|
|
324
|
+
const milliseconds = (0, ms.default)(input);
|
|
325
|
+
if (milliseconds === void 0 || Number.isNaN(milliseconds)) throw new CacheConfigurationError(`Invalid TTL duration string: "${input}". Expected forms like "1h", "30m", "7d".`);
|
|
326
|
+
return Math.floor(milliseconds / 1e3);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Convert an absolute `expiresAt` (Date or epoch milliseconds) into a
|
|
330
|
+
* relative TTL in seconds.
|
|
331
|
+
*
|
|
332
|
+
* Throws {@link CacheConfigurationError} when the deadline is in the past —
|
|
333
|
+
* the caller almost certainly has a bug (stale timestamp, wrong unit, etc.)
|
|
334
|
+
* and silently storing an already-expired entry would hide it.
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* expiresAtToTtl(new Date(Date.now() + 60_000)); // ~60
|
|
338
|
+
* expiresAtToTtl(Date.now() + 30 * 60 * 1000); // ~1800
|
|
339
|
+
*/
|
|
340
|
+
function expiresAtToTtl(expiresAt) {
|
|
341
|
+
const deadline = expiresAt instanceof Date ? expiresAt.getTime() : expiresAt;
|
|
342
|
+
const relativeMs = deadline - Date.now();
|
|
343
|
+
if (relativeMs <= 0) throw new CacheConfigurationError(`\`expiresAt\` must be in the future; got ${new Date(deadline).toISOString()}.`);
|
|
344
|
+
return Math.ceil(relativeMs / 1e3);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Coerce the polymorphic 3rd `set` argument into a uniform `CacheSetOptions`
|
|
348
|
+
* shape. Lets callers (and `BaseCacheDriver.resolveSetOptions`) skip per-shape
|
|
349
|
+
* branching.
|
|
350
|
+
*
|
|
351
|
+
* - `undefined` / `null` → `{}` (resolves to driver-level defaults later)
|
|
352
|
+
* - `number` / `string` (positional TTL) → `{ ttl }`
|
|
353
|
+
* - already an options object → returned as-is
|
|
354
|
+
*/
|
|
355
|
+
function normalizeToOptions(input) {
|
|
356
|
+
if (input === void 0 || input === null) return {};
|
|
357
|
+
if (typeof input === "number" || typeof input === "string") return { ttl: input };
|
|
358
|
+
return input;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Sibling of {@link normalizeToOptions} for the `remember()` call site, where
|
|
362
|
+
* the polymorphic 2nd argument is `CacheTtl | RememberOptions` (no `expiresAt`,
|
|
363
|
+
* no `onConflict`). Returns the same shape so callers can `{ ...opts, ... }`
|
|
364
|
+
* without branching.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* normalizeToRememberOptions(60); // { ttl: 60 }
|
|
368
|
+
* normalizeToRememberOptions("1h"); // { ttl: "1h" }
|
|
369
|
+
* normalizeToRememberOptions({ ttl: "1h", tags: ["x"] }); // returned as-is
|
|
370
|
+
*/
|
|
371
|
+
function normalizeToRememberOptions(input) {
|
|
372
|
+
if (input === void 0 || input === null) return {};
|
|
373
|
+
if (typeof input === "number" || typeof input === "string") return { ttl: input };
|
|
374
|
+
return input;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Resolve the final TTL in seconds for a `set` call. Precedence:
|
|
378
|
+
*
|
|
379
|
+
* 1. Caller's `ttl` (number or duration string) wins.
|
|
380
|
+
* 2. Otherwise, caller's `expiresAt` is converted to relative seconds.
|
|
381
|
+
* 3. Otherwise, `fallback` is used (driver-level default — typically
|
|
382
|
+
* `Infinity` when no default is configured, meaning "never expires").
|
|
383
|
+
*
|
|
384
|
+
* Throws {@link CacheConfigurationError} when `ttl` and `expiresAt` are
|
|
385
|
+
* supplied together (mutually exclusive).
|
|
386
|
+
*/
|
|
387
|
+
function resolveTtl(ttl, expiresAt, fallback) {
|
|
388
|
+
if (ttl !== void 0 && expiresAt !== void 0) throw new CacheConfigurationError("Cache set options cannot specify both `ttl` and `expiresAt` — choose one.");
|
|
389
|
+
if (ttl !== void 0) return parseTtl(ttl);
|
|
390
|
+
if (expiresAt !== void 0) return expiresAtToTtl(expiresAt);
|
|
391
|
+
return fallback;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Combine any number of tag lists into a single deduped array, dropping
|
|
395
|
+
* `undefined`/empty entries. Returns `undefined` when no tags survive — lets
|
|
396
|
+
* callers skip emitting empty `tags: []` into option payloads.
|
|
397
|
+
*
|
|
398
|
+
* Used by scoped-cache merging where scope tags + handle tags + per-call tags
|
|
399
|
+
* must union additively without duplicates.
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* mergeTagSets(["a", "b"], ["b", "c"]); // ["a", "b", "c"]
|
|
403
|
+
* mergeTagSets(undefined, ["x"]); // ["x"]
|
|
404
|
+
* mergeTagSets(undefined, undefined); // undefined
|
|
405
|
+
* mergeTagSets([], []); // undefined
|
|
406
|
+
*/
|
|
407
|
+
function mergeTagSets(...lists) {
|
|
408
|
+
const flat = [];
|
|
409
|
+
for (const list of lists) {
|
|
410
|
+
if (!list || list.length === 0) continue;
|
|
411
|
+
flat.push(...list);
|
|
412
|
+
}
|
|
413
|
+
if (flat.length === 0) return;
|
|
414
|
+
return Array.from(new Set(flat));
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Add extra tags to any option-bag that already shapes `tags?: string[]`.
|
|
418
|
+
* Pure — clones the input shape, never mutates. Tags are appended (caller
|
|
419
|
+
* is responsible for de-duplication if needed; pair with {@link mergeTagSets}).
|
|
420
|
+
*
|
|
421
|
+
* @example
|
|
422
|
+
* injectTags({ ttl: "1h" }, ["unread"]); // { ttl: "1h", tags: ["unread"] }
|
|
423
|
+
* injectTags({ tags: ["a"] }, ["b"]); // { tags: ["a", "b"] }
|
|
424
|
+
*/
|
|
425
|
+
function injectTags(options, extraTags) {
|
|
426
|
+
if (extraTags.length === 0) return options;
|
|
427
|
+
return {
|
|
428
|
+
...options,
|
|
429
|
+
tags: [...options.tags ?? [], ...extraTags]
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Cosine similarity between two equal-length numeric vectors.
|
|
434
|
+
*
|
|
435
|
+
* Returns a value in `[-1, 1]` where `1` means perfectly aligned, `0` means
|
|
436
|
+
* orthogonal, and `-1` means opposing. For typical embedding spaces (where
|
|
437
|
+
* vectors live in the positive cone) the practical range is `[0, 1]`.
|
|
438
|
+
*
|
|
439
|
+
* Throws {@link CacheConfigurationError} on dimension mismatch — fail loud at
|
|
440
|
+
* the call site rather than silently returning a misleading score. A zero-norm
|
|
441
|
+
* vector on either side returns `0` (no defined direction to compare).
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* cosineSimilarity([1, 0, 0], [1, 0, 0]); // 1
|
|
445
|
+
* cosineSimilarity([1, 0, 0], [0, 1, 0]); // 0
|
|
446
|
+
*/
|
|
447
|
+
function cosineSimilarity(a, b) {
|
|
448
|
+
if (a.length !== b.length) throw new CacheConfigurationError(`Vector dimension mismatch: got ${a.length} and ${b.length}.`);
|
|
449
|
+
if (a.length === 0) throw new CacheConfigurationError("Vector dimension mismatch: empty vector cannot be compared.");
|
|
450
|
+
let dot = 0;
|
|
451
|
+
let normA = 0;
|
|
452
|
+
let normB = 0;
|
|
453
|
+
for (let i = 0; i < a.length; i++) {
|
|
454
|
+
const x = a[i];
|
|
455
|
+
const y = b[i];
|
|
456
|
+
dot += x * y;
|
|
457
|
+
normA += x * x;
|
|
458
|
+
normB += y * y;
|
|
459
|
+
}
|
|
460
|
+
if (normA === 0 || normB === 0) return 0;
|
|
461
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
462
|
+
}
|
|
463
|
+
let CACHE_FOR = /* @__PURE__ */ function(CACHE_FOR) {
|
|
464
|
+
/**
|
|
465
|
+
* Cache for 30 Minutes (in seconds)
|
|
466
|
+
*/
|
|
467
|
+
CACHE_FOR[CACHE_FOR["HALF_HOUR"] = 1800] = "HALF_HOUR";
|
|
468
|
+
/**
|
|
469
|
+
* Cache for 1 Hour (in seconds)
|
|
470
|
+
*/
|
|
471
|
+
CACHE_FOR[CACHE_FOR["ONE_HOUR"] = 3600] = "ONE_HOUR";
|
|
472
|
+
/**
|
|
473
|
+
* Cache for 12 Hours (in seconds)
|
|
474
|
+
*/
|
|
475
|
+
CACHE_FOR[CACHE_FOR["HALF_DAY"] = 43200] = "HALF_DAY";
|
|
476
|
+
/**
|
|
477
|
+
* Cache for 24 Hours (in seconds)
|
|
478
|
+
*/
|
|
479
|
+
CACHE_FOR[CACHE_FOR["ONE_DAY"] = 86400] = "ONE_DAY";
|
|
480
|
+
/**
|
|
481
|
+
* Cache for 7 Days (in seconds)
|
|
482
|
+
*/
|
|
483
|
+
CACHE_FOR[CACHE_FOR["ONE_WEEK"] = 604800] = "ONE_WEEK";
|
|
484
|
+
/**
|
|
485
|
+
* Cache for 15 Days (in seconds)
|
|
486
|
+
*/
|
|
487
|
+
CACHE_FOR[CACHE_FOR["HALF_MONTH"] = 1296e3] = "HALF_MONTH";
|
|
488
|
+
/**
|
|
489
|
+
* Cache for 30 Days (in seconds)
|
|
490
|
+
*/
|
|
491
|
+
CACHE_FOR[CACHE_FOR["ONE_MONTH"] = 2592e3] = "ONE_MONTH";
|
|
492
|
+
/**
|
|
493
|
+
* Cache for 60 Days (in seconds)
|
|
494
|
+
*/
|
|
495
|
+
CACHE_FOR[CACHE_FOR["TWO_MONTHS"] = 5184e3] = "TWO_MONTHS";
|
|
496
|
+
/**
|
|
497
|
+
* Cache for 180 Days (in seconds)
|
|
498
|
+
*/
|
|
499
|
+
CACHE_FOR[CACHE_FOR["SIX_MONTHS"] = 15768e3] = "SIX_MONTHS";
|
|
500
|
+
/**
|
|
501
|
+
* Cache for 365 Days (in seconds)
|
|
502
|
+
*/
|
|
503
|
+
CACHE_FOR[CACHE_FOR["ONE_YEAR"] = 31536e3] = "ONE_YEAR";
|
|
504
|
+
return CACHE_FOR;
|
|
505
|
+
}({});
|
|
506
|
+
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region ../../@warlock.js/cache/src/tagged-scoped-cache.ts
|
|
509
|
+
/**
|
|
510
|
+
* One-shot tagged write handle on top of a {@link ScopedCache}.
|
|
511
|
+
*
|
|
512
|
+
* **Role.** Returned by `scope.tags([...])`. Adds a fixed list of tags to
|
|
513
|
+
* every write produced through this handle, on top of any tags the parent
|
|
514
|
+
* scope already contributes. Stateless except for the captured tag list.
|
|
515
|
+
*
|
|
516
|
+
* **Responsibility.**
|
|
517
|
+
* - Owns: appending the handle's tags to writes, delegating tag-index
|
|
518
|
+
* bookkeeping for `setNX` (which lacks an inline `tags` knob on the driver
|
|
519
|
+
* contract), and computing the union for `invalidate()` calls.
|
|
520
|
+
* - Does NOT own: storage, prefix-prepending (delegated to the scope),
|
|
521
|
+
* default `ttl` (delegated to the scope), or any kind of long-lived state.
|
|
522
|
+
*
|
|
523
|
+
* Tags compose additively: scope tags + handle tags + per-call tags, all
|
|
524
|
+
* unioned and deduped. The handle never replaces scope tags — `invalidate()`
|
|
525
|
+
* always sees the full union.
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* // Inside application code — scope provides the per-user tag automatically:
|
|
529
|
+
* const feed = cache.namespace(`feed.${userId}`, { tags: [`user.${userId}`] });
|
|
530
|
+
*
|
|
531
|
+
* await feed.tags(["unread"]).set("messages.1", message);
|
|
532
|
+
* // → tagged with [user.<id>, unread]
|
|
533
|
+
*
|
|
534
|
+
* await feed.tags(["unread"]).invalidate();
|
|
535
|
+
* // → wipes everything tagged with user.<id> OR unread.
|
|
536
|
+
*/
|
|
537
|
+
var TaggedScopedCache = class {
|
|
538
|
+
/**
|
|
539
|
+
* Build a tagged handle. Constructed via `scope.tags([...])` — users never
|
|
540
|
+
* call this directly.
|
|
541
|
+
*/
|
|
542
|
+
constructor(scope, handleTags) {
|
|
543
|
+
this.scope = scope;
|
|
544
|
+
this.handleTags = [...handleTags];
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Write the scoped key with the handle's tags appended to whatever the
|
|
548
|
+
* caller passed. Scope-level tags are added on top by the scope itself.
|
|
549
|
+
*/
|
|
550
|
+
set(key, value, ttlOrOptions) {
|
|
551
|
+
const options = injectTags(normalizeToOptions(ttlOrOptions), this.handleTags);
|
|
552
|
+
return this.scope.set(key, value, options);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Read the scoped key. Tags don't affect reads — pass-through.
|
|
556
|
+
*/
|
|
557
|
+
get(key) {
|
|
558
|
+
return this.scope.get(key);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Check presence of the scoped key.
|
|
562
|
+
*/
|
|
563
|
+
has(key) {
|
|
564
|
+
return this.scope.has(key);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Remove the scoped key. The tag-index entry will eventually be cleaned up
|
|
568
|
+
* by `invalidate()`; we don't proactively rewrite it here for cost reasons.
|
|
569
|
+
*/
|
|
570
|
+
remove(key) {
|
|
571
|
+
return this.scope.remove(key);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Read-and-remove the scoped key.
|
|
575
|
+
*/
|
|
576
|
+
pull(key) {
|
|
577
|
+
return this.scope.pull(key);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Permanent write with handle tags applied. Bypasses both the scope's and
|
|
581
|
+
* the caller's TTL (forever means forever) — only tags get injected.
|
|
582
|
+
*/
|
|
583
|
+
forever(key, value) {
|
|
584
|
+
return this.scope.set(key, value, {
|
|
585
|
+
ttl: Infinity,
|
|
586
|
+
tags: this.handleTags
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Atomic create-or-skip with the handle's tags applied on success. The
|
|
591
|
+
* driver contract has no inline `tags` knob on `setNX`, so we register the
|
|
592
|
+
* tag relationship manually after a successful write.
|
|
593
|
+
*/
|
|
594
|
+
async setNX(key, value, ttl) {
|
|
595
|
+
if (!await this.scope.setNX(key, value, ttl)) return false;
|
|
596
|
+
const allTags = mergeTagSets(this.scope.defaults.tags, this.handleTags);
|
|
597
|
+
if (!allTags || allTags.length === 0) return true;
|
|
598
|
+
const scopedKey = this.buildScopedKey(key);
|
|
599
|
+
const parsedKey = this.scope.source.parseKey(scopedKey);
|
|
600
|
+
await this.scope.source.tags(allTags).storeTagRelationship(parsedKey);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Read-or-compute with handle tags appended on the cache-miss write.
|
|
605
|
+
*/
|
|
606
|
+
remember(key, ttlOrOptions, callback) {
|
|
607
|
+
const options = injectTags(normalizeToRememberOptions(ttlOrOptions), this.handleTags);
|
|
608
|
+
return this.scope.remember(key, options, callback);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Atomic counter increment on the scoped key. Tags aren't applied to
|
|
612
|
+
* subsequent increments — they're attached at first-write time.
|
|
613
|
+
*/
|
|
614
|
+
increment(key, value) {
|
|
615
|
+
return this.scope.increment(key, value);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Atomic counter decrement on the scoped key. See {@link increment}.
|
|
619
|
+
*/
|
|
620
|
+
decrement(key, value) {
|
|
621
|
+
return this.scope.decrement(key, value);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Wipe every entry tagged with the union of (scope tags + handle tags).
|
|
625
|
+
* Tags are global across the package, so this reaches outside the scope's
|
|
626
|
+
* prefix when scope tags are also used elsewhere.
|
|
627
|
+
*/
|
|
628
|
+
async invalidate() {
|
|
629
|
+
const allTags = mergeTagSets(this.scope.defaults.tags, this.handleTags);
|
|
630
|
+
if (!allTags || allTags.length === 0) return;
|
|
631
|
+
await this.scope.source.tags(allTags).invalidate();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Compute the source-side key the same way `ScopedCache.scopedKey` does —
|
|
635
|
+
* needed for `setNX`, where we have to register the tag relationship by
|
|
636
|
+
* hand because the driver contract doesn't accept inline tags there.
|
|
637
|
+
*/
|
|
638
|
+
buildScopedKey(key) {
|
|
639
|
+
const keyString = typeof key === "string" ? key : parseCacheKey(key);
|
|
640
|
+
if (!keyString) return this.scope.prefix;
|
|
641
|
+
return `${this.scope.prefix}.${keyString}`;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region ../../@warlock.js/cache/src/scoped-cache.ts
|
|
647
|
+
/**
|
|
648
|
+
* Scoped view over a cache source. Returned by `cache.namespace(prefix, options?)`.
|
|
649
|
+
*
|
|
650
|
+
* **Role.** A `ScopedCache` is a stateless wrapper that prepends a fixed
|
|
651
|
+
* prefix to every key and applies optional default `ttl` / `tags` to every
|
|
652
|
+
* write. Stores nothing itself — every call forwards to the underlying
|
|
653
|
+
* `source` (typically the `CacheManager`, but any `CacheDriver` works).
|
|
654
|
+
*
|
|
655
|
+
* **Responsibility.**
|
|
656
|
+
* - Owns: prefix-prepending of keys, normalization of nested-scope prefixes,
|
|
657
|
+
* merging scope defaults into write options (`ttl`, `tags`), filtering
|
|
658
|
+
* `similar()` hits to its own scope, and exposing `.clear()` as a sugar
|
|
659
|
+
* for `removeNamespace(prefix)`.
|
|
660
|
+
* - Does NOT own: actual storage, connection lifecycle, event listeners,
|
|
661
|
+
* driver selection, or tag-index bookkeeping (delegated to the source's
|
|
662
|
+
* tagged-cache machinery).
|
|
663
|
+
*
|
|
664
|
+
* Per-call options always win over scope defaults; tags merge additively
|
|
665
|
+
* across (scope defaults + per-call) layers. Nested scopes inherit and may
|
|
666
|
+
* override the parent's defaults — see {@link ScopedCache.namespace}.
|
|
667
|
+
*
|
|
668
|
+
* @example
|
|
669
|
+
* const chat = cache.namespace(`chats.${id}`, { ttl: "30d" });
|
|
670
|
+
*
|
|
671
|
+
* await chat.set("messages.10", msg); // 30d default
|
|
672
|
+
* await chat.set("draft", d, { ttl: "1h" }); // per-call override
|
|
673
|
+
* await chat.namespace("typing", { ttl: "5s" }).set("user.42", true);
|
|
674
|
+
* await chat.clear();
|
|
675
|
+
*/
|
|
676
|
+
var ScopedCache = class ScopedCache {
|
|
677
|
+
/**
|
|
678
|
+
* Build a scope. Constructed via `cache.namespace(prefix, options)` —
|
|
679
|
+
* users never call this directly.
|
|
680
|
+
*/
|
|
681
|
+
constructor(source, prefix, defaults = {}) {
|
|
682
|
+
this.source = source;
|
|
683
|
+
this.prefix = parseCacheKey(prefix);
|
|
684
|
+
this.defaults = {
|
|
685
|
+
ttl: defaults.ttl,
|
|
686
|
+
tags: defaults.tags && defaults.tags.length > 0 ? [...defaults.tags] : void 0
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Build a nested scope. The child's prefix is `parent.child`; child options
|
|
691
|
+
* override the parent's `ttl` and union into `tags`.
|
|
692
|
+
*
|
|
693
|
+
* @example
|
|
694
|
+
* const chat = cache.namespace("chats.10", { ttl: "30d" });
|
|
695
|
+
* const typing = chat.namespace("typing", { ttl: "5s" });
|
|
696
|
+
* // typing.prefix === "chats.10.typing"
|
|
697
|
+
*/
|
|
698
|
+
namespace(prefix, options = {}) {
|
|
699
|
+
const childPrefix = `${this.prefix}.${parseCacheKey(prefix)}`;
|
|
700
|
+
return new ScopedCache(this.source, childPrefix, {
|
|
701
|
+
ttl: options.ttl ?? this.defaults.ttl,
|
|
702
|
+
tags: mergeTagSets(this.defaults.tags, options.tags)
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Return a one-shot tagged write handle. The handle's tags merge additively
|
|
707
|
+
* with scope-level defaults — final tag list per write is the union of
|
|
708
|
+
* (scope tags + handle tags + per-call tags), deduped.
|
|
709
|
+
*/
|
|
710
|
+
tags(tags) {
|
|
711
|
+
return new TaggedScopedCache(this, tags);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Wipe every entry under this scope's prefix. Sugar over
|
|
715
|
+
* `source.removeNamespace(prefix)` — siblings outside the scope are
|
|
716
|
+
* untouched.
|
|
717
|
+
*/
|
|
718
|
+
clear() {
|
|
719
|
+
return this.source.removeNamespace(this.prefix);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Read the value at the scoped key. Forwards to the source after prefixing.
|
|
723
|
+
*/
|
|
724
|
+
get(key) {
|
|
725
|
+
return this.source.get(this.scopedKey(key));
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Check presence of the scoped key without fetching the value.
|
|
729
|
+
*/
|
|
730
|
+
has(key) {
|
|
731
|
+
return this.source.has(this.scopedKey(key));
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Batch-read scoped keys. Each input key is prefixed before forwarding.
|
|
735
|
+
*/
|
|
736
|
+
many(keys) {
|
|
737
|
+
return this.source.many(keys.map((key) => this.scopedKey(key)));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Read-and-remove. Returns the value or `null`; the entry is gone after.
|
|
741
|
+
*/
|
|
742
|
+
pull(key) {
|
|
743
|
+
return this.source.pull(this.scopedKey(key));
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Write the scoped key. Per-call `ttl`/`tags` win over scope defaults;
|
|
747
|
+
* `expiresAt` is preserved as-is (absolute deadlines are never overridden
|
|
748
|
+
* by the scope's relative-ttl default).
|
|
749
|
+
*/
|
|
750
|
+
set(key, value, ttlOrOptions) {
|
|
751
|
+
return this.source.set(this.scopedKey(key), value, this.mergeSetOptions(ttlOrOptions));
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Batch-write under the scope. Caller's positional `ttl` wins; otherwise
|
|
755
|
+
* the scope default is parsed to seconds (since `setMany` accepts only a
|
|
756
|
+
* numeric ttl).
|
|
757
|
+
*/
|
|
758
|
+
setMany(items, ttl) {
|
|
759
|
+
const scoped = {};
|
|
760
|
+
for (const [key, value] of Object.entries(items)) scoped[this.scopedKey(key)] = value;
|
|
761
|
+
return this.source.setMany(scoped, ttl ?? this.scopeTtlSeconds());
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Atomic create-or-skip on the scoped key. Throws when the underlying
|
|
765
|
+
* source has no `setNX` (driver-specific — Redis-only today).
|
|
766
|
+
*/
|
|
767
|
+
setNX(key, value, ttl) {
|
|
768
|
+
if (!this.source.setNX) throw new Error(`setNX is not supported by the underlying cache source: ${this.source.name}`);
|
|
769
|
+
return this.source.setNX(this.scopedKey(key), value, ttl ?? this.scopeTtlSeconds());
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Permanent write (no expiration). Bypasses the scope's `ttl` default —
|
|
773
|
+
* `forever` always means forever, regardless of scope policy.
|
|
774
|
+
*/
|
|
775
|
+
forever(key, value) {
|
|
776
|
+
return this.source.forever(this.scopedKey(key), value);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Remove a single scoped key.
|
|
780
|
+
*/
|
|
781
|
+
remove(key) {
|
|
782
|
+
return this.source.remove(this.scopedKey(key));
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Read-or-compute. Cache-miss writes pick up the scope's default `ttl`
|
|
786
|
+
* and `tags` unless the caller passed an options object that overrides.
|
|
787
|
+
*/
|
|
788
|
+
remember(key, ttlOrOptions, callback) {
|
|
789
|
+
return this.source.remember(this.scopedKey(key), this.mergeRememberOptions(ttlOrOptions), callback);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Stale-while-revalidate on the scoped key. Scope-level `tags` merge
|
|
793
|
+
* additively with `options.tags`; `freshTtl`/`staleTtl` always come from
|
|
794
|
+
* the caller (no scope-default precedence — the SWR shape is too
|
|
795
|
+
* specific to the call site to inherit).
|
|
796
|
+
*/
|
|
797
|
+
swr(key, options, callback) {
|
|
798
|
+
const merged = {
|
|
799
|
+
...options,
|
|
800
|
+
tags: mergeTagSets(this.defaults.tags, options.tags)
|
|
801
|
+
};
|
|
802
|
+
return this.source.swr(this.scopedKey(key), merged, callback);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Atomic counter increment on the scoped key. TTL is preserved by the
|
|
806
|
+
* underlying driver — scope ttl is only applied on first write via `set`.
|
|
807
|
+
*/
|
|
808
|
+
increment(key, value) {
|
|
809
|
+
return this.source.increment(this.scopedKey(key), value);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Atomic counter decrement on the scoped key. See {@link increment} for
|
|
813
|
+
* TTL semantics.
|
|
814
|
+
*/
|
|
815
|
+
decrement(key, value) {
|
|
816
|
+
return this.source.decrement(this.scopedKey(key), value);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Atomic read-modify-write. Falls back to the scope's `ttl` when the caller
|
|
820
|
+
* doesn't provide one; the source still keeps the existing entry's TTL on
|
|
821
|
+
* an update unless `options.ttl` is explicitly set.
|
|
822
|
+
*/
|
|
823
|
+
update(key, fn, options) {
|
|
824
|
+
return this.source.update(this.scopedKey(key), fn, { ttl: options?.ttl ?? this.defaults.ttl });
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Shallow-merge a partial object into the scoped entry. Same TTL semantics
|
|
828
|
+
* as {@link update}.
|
|
829
|
+
*/
|
|
830
|
+
merge(key, partial, options) {
|
|
831
|
+
return this.source.merge(this.scopedKey(key), partial, { ttl: options?.ttl ?? this.defaults.ttl });
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Return a list accessor bound to the scoped key. The accessor itself
|
|
835
|
+
* does its own read-mutate-write under the prefixed entry.
|
|
836
|
+
*/
|
|
837
|
+
list(key) {
|
|
838
|
+
return this.source.list(this.scopedKey(key));
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Acquire a distributed lock on the scoped key. Caller's TTL wins; when
|
|
842
|
+
* the options form omits `ttl`, the scope default fills in.
|
|
843
|
+
*/
|
|
844
|
+
lock(key, ttlOrOptions, fn) {
|
|
845
|
+
if (typeof ttlOrOptions === "object" && ttlOrOptions !== null) {
|
|
846
|
+
const merged = {
|
|
847
|
+
...ttlOrOptions,
|
|
848
|
+
ttl: ttlOrOptions.ttl ?? this.defaults.ttl ?? ttlOrOptions.ttl
|
|
849
|
+
};
|
|
850
|
+
return this.source.lock(this.scopedKey(key), merged, fn);
|
|
851
|
+
}
|
|
852
|
+
return this.source.lock(this.scopedKey(key), ttlOrOptions, fn);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Similarity retrieval, scope-isolated. Hits whose keys fall outside this
|
|
856
|
+
* scope are filtered out before the result is returned. `topK` applies to
|
|
857
|
+
* the underlying retrieval — when the scope contains fewer than `topK`
|
|
858
|
+
* matches but other scopes do, the caller will see fewer hits than `topK`.
|
|
859
|
+
*/
|
|
860
|
+
async similar(vector, options) {
|
|
861
|
+
const hits = await this.source.similar(vector, options);
|
|
862
|
+
const parsedPrefix = this.source.parseKey(this.prefix);
|
|
863
|
+
return hits.filter((hit) => hit.key === parsedPrefix || hit.key.startsWith(parsedPrefix + "."));
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Build the source-side key by prepending the scope prefix. Object keys
|
|
867
|
+
* are normalized via {@link parseCacheKey} first so they compose with the
|
|
868
|
+
* prefix as plain dot-strings.
|
|
869
|
+
*/
|
|
870
|
+
scopedKey(key) {
|
|
871
|
+
const keyString = typeof key === "string" ? key : parseCacheKey(key);
|
|
872
|
+
if (!keyString) return this.prefix;
|
|
873
|
+
return `${this.prefix}.${keyString}`;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Coerce the polymorphic 3rd `set` argument into a {@link CacheSetOptions}
|
|
877
|
+
* with scope defaults filled in. Per-call values always win; tags merge
|
|
878
|
+
* additively. `expiresAt` is preserved without injecting the scope's `ttl`
|
|
879
|
+
* default (absolute deadlines override relative ones).
|
|
880
|
+
*/
|
|
881
|
+
mergeSetOptions(input) {
|
|
882
|
+
const options = normalizeToOptions(input);
|
|
883
|
+
const ttl = options.ttl ?? (options.expiresAt === void 0 ? this.defaults.ttl : void 0);
|
|
884
|
+
const tags = mergeTagSets(this.defaults.tags, options.tags);
|
|
885
|
+
const merged = { ...options };
|
|
886
|
+
if (ttl !== void 0) merged.ttl = ttl;
|
|
887
|
+
if (tags !== void 0) merged.tags = tags;
|
|
888
|
+
return merged;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Same merge as {@link mergeSetOptions} but for the `remember()` shape
|
|
892
|
+
* ({@link RememberOptions} — no `expiresAt`).
|
|
893
|
+
*/
|
|
894
|
+
mergeRememberOptions(input) {
|
|
895
|
+
const options = normalizeToRememberOptions(input);
|
|
896
|
+
const ttl = options.ttl ?? this.defaults.ttl;
|
|
897
|
+
const tags = mergeTagSets(this.defaults.tags, options.tags);
|
|
898
|
+
const merged = { ...options };
|
|
899
|
+
if (ttl !== void 0) merged.ttl = ttl;
|
|
900
|
+
if (tags !== void 0) merged.tags = tags;
|
|
901
|
+
return merged;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Convert the scope's default `ttl` (which may be a duration string) into
|
|
905
|
+
* seconds, for the few methods (`setMany`, `setNX`) that accept only a
|
|
906
|
+
* numeric ttl.
|
|
907
|
+
*/
|
|
908
|
+
scopeTtlSeconds() {
|
|
909
|
+
if (this.defaults.ttl === void 0) return;
|
|
910
|
+
return parseTtl(this.defaults.ttl);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region ../../@warlock.js/cache/src/cache-manager.ts
|
|
916
|
+
var CacheManager = class {
|
|
917
|
+
constructor() {
|
|
918
|
+
this.loadedDrivers = {};
|
|
919
|
+
this.configurations = {
|
|
920
|
+
drivers: {},
|
|
921
|
+
options: {}
|
|
922
|
+
};
|
|
923
|
+
this.globalEventListeners = /* @__PURE__ */ new Map();
|
|
924
|
+
this.name = "cacheManager";
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* {@inheritdoc}
|
|
928
|
+
*/
|
|
929
|
+
get client() {
|
|
930
|
+
return this.currentDriver?.client;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Set the cache configurations
|
|
934
|
+
*/
|
|
935
|
+
setCacheConfigurations(configurations) {
|
|
936
|
+
this.configurations.default = configurations.default;
|
|
937
|
+
this.configurations.drivers = configurations.drivers;
|
|
938
|
+
this.configurations.options = configurations.options;
|
|
939
|
+
this.configurations.logging = configurations.logging;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Set logging state
|
|
943
|
+
*/
|
|
944
|
+
setLoggingState(loggingState) {
|
|
945
|
+
this.ensureDriverInitialized();
|
|
946
|
+
this.currentDriver.setLoggingState(loggingState);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Switch the manager to a registered driver, optionally injecting runtime
|
|
950
|
+
* options that merge over the static config.
|
|
951
|
+
*
|
|
952
|
+
* The string form looks the driver up in `setCacheConfigurations({ drivers })`,
|
|
953
|
+
* loads it (or returns the cached instance), and sets it as `currentDriver`.
|
|
954
|
+
* The instance form takes a pre-built driver and bypasses the registry; the
|
|
955
|
+
* `runtimeOptions` argument is silently ignored in that case because the
|
|
956
|
+
* instance was constructed externally.
|
|
957
|
+
*
|
|
958
|
+
* Runtime options merge over `config.options[name]` per-key — runtime wins.
|
|
959
|
+
* Use this for constructor-only knobs that can't live in static config
|
|
960
|
+
* (e.g. `pg`'s `client: pg.Pool`).
|
|
961
|
+
*
|
|
962
|
+
* @example
|
|
963
|
+
* const pool = new Pool({ connectionString });
|
|
964
|
+
* await cache.use("pg", { client: pool });
|
|
965
|
+
*/
|
|
966
|
+
async use(driver, runtimeOptions) {
|
|
967
|
+
if (typeof driver === "string") {
|
|
968
|
+
const driverInstance = await this.load(driver, runtimeOptions);
|
|
969
|
+
if (!driverInstance) throw new CacheConfigurationError(`Cache driver ${driver} is not found, please declare it in the cache drivers in the configurations list.`);
|
|
970
|
+
driver = driverInstance;
|
|
971
|
+
}
|
|
972
|
+
this.attachGlobalListeners(driver);
|
|
973
|
+
if (this.configurations.logging !== void 0) driver.setLoggingState(this.configurations.logging);
|
|
974
|
+
this.currentDriver = driver;
|
|
975
|
+
return this;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Ensure driver is initialized before operations
|
|
979
|
+
*/
|
|
980
|
+
ensureDriverInitialized() {
|
|
981
|
+
if (!this.currentDriver) throw new CacheDriverNotInitializedError();
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Return the running metrics snapshot — counters, hit-rate, latency
|
|
985
|
+
* percentiles, per-driver breakdowns. Lazy-attaches the collector on
|
|
986
|
+
* first call so apps that never read metrics pay zero cost.
|
|
987
|
+
*
|
|
988
|
+
* @example
|
|
989
|
+
* const m = cache.metrics();
|
|
990
|
+
* console.log(`hit rate: ${(m.hitRate * 100).toFixed(1)}%`);
|
|
991
|
+
* console.log(`p95: ${m.latencyMs.p95.toFixed(2)}ms`);
|
|
992
|
+
*/
|
|
993
|
+
metrics() {
|
|
994
|
+
return this.ensureMetricsCollector().snapshot();
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Wipe every counter + latency sample and reset `startedAt` to now.
|
|
998
|
+
* The collector itself stays subscribed to events.
|
|
999
|
+
*/
|
|
1000
|
+
resetMetrics() {
|
|
1001
|
+
this.ensureMetricsCollector().reset();
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Lazy-construct the metrics collector and wire it to the global event
|
|
1005
|
+
* bus. Subsequent calls return the same instance — survives `cache.use()`
|
|
1006
|
+
* driver switches because handlers attach via `on()` and re-bind to every
|
|
1007
|
+
* loaded driver.
|
|
1008
|
+
*/
|
|
1009
|
+
ensureMetricsCollector() {
|
|
1010
|
+
if (this.metricsCollector) return this.metricsCollector;
|
|
1011
|
+
const collector = new CacheMetricsCollector();
|
|
1012
|
+
this.on("hit", (data) => collector.recordEvent("hit", data));
|
|
1013
|
+
this.on("miss", (data) => collector.recordEvent("miss", data));
|
|
1014
|
+
this.on("set", (data) => collector.recordEvent("set", data));
|
|
1015
|
+
this.on("removed", (data) => collector.recordEvent("removed", data));
|
|
1016
|
+
this.on("error", (data) => collector.recordEvent("error", data));
|
|
1017
|
+
this.metricsCollector = collector;
|
|
1018
|
+
return collector;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Time the body, record the elapsed milliseconds against the metrics
|
|
1022
|
+
* collector for the given driver (defaults to the current driver's name).
|
|
1023
|
+
* Pass-through if the collector hasn't been instantiated yet — apps that
|
|
1024
|
+
* don't read metrics never pay for sample collection.
|
|
1025
|
+
*/
|
|
1026
|
+
async timed(body, driverName) {
|
|
1027
|
+
if (!this.metricsCollector) return body();
|
|
1028
|
+
const start = performance.now();
|
|
1029
|
+
try {
|
|
1030
|
+
return await body();
|
|
1031
|
+
} finally {
|
|
1032
|
+
const elapsed = performance.now() - start;
|
|
1033
|
+
const name = driverName ?? this.currentDriver?.name ?? "unknown";
|
|
1034
|
+
this.metricsCollector.recordLatency(name, elapsed);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* {@inheritdoc}
|
|
1039
|
+
*/
|
|
1040
|
+
async get(key) {
|
|
1041
|
+
this.ensureDriverInitialized();
|
|
1042
|
+
return this.timed(() => this.currentDriver.get(key));
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Set a value in the cache.
|
|
1046
|
+
*
|
|
1047
|
+
* Accepts a positional TTL (number of seconds or duration string like `"1h"`)
|
|
1048
|
+
* or a rich {@link CacheSetOptions} object supporting `ttl`, `expiresAt`,
|
|
1049
|
+
* `tags`, `onConflict`, `namespace`, and per-call `driver` overrides.
|
|
1050
|
+
*/
|
|
1051
|
+
async set(key, value, ttlOrOptions) {
|
|
1052
|
+
this.ensureDriverInitialized();
|
|
1053
|
+
const driverOverride = ttlOrOptions && typeof ttlOrOptions === "object" && "driver" in ttlOrOptions ? ttlOrOptions.driver : void 0;
|
|
1054
|
+
if (driverOverride) {
|
|
1055
|
+
const driver = await this.load(driverOverride);
|
|
1056
|
+
return this.timed(() => driver.set(key, value, ttlOrOptions), driver.name);
|
|
1057
|
+
}
|
|
1058
|
+
return this.timed(() => this.currentDriver.set(key, value, ttlOrOptions));
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* {@inheritdoc}
|
|
1062
|
+
*/
|
|
1063
|
+
async remove(key) {
|
|
1064
|
+
this.ensureDriverInitialized();
|
|
1065
|
+
return this.timed(() => this.currentDriver.remove(key));
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* {@inheritdoc}
|
|
1069
|
+
*/
|
|
1070
|
+
async removeNamespace(namespace) {
|
|
1071
|
+
this.ensureDriverInitialized();
|
|
1072
|
+
return this.currentDriver.removeNamespace(namespace);
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* {@inheritdoc}
|
|
1076
|
+
*/
|
|
1077
|
+
async flush() {
|
|
1078
|
+
this.ensureDriverInitialized();
|
|
1079
|
+
return this.currentDriver.flush();
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* {@inheritdoc}
|
|
1083
|
+
*/
|
|
1084
|
+
async connect() {
|
|
1085
|
+
this.ensureDriverInitialized();
|
|
1086
|
+
return this.currentDriver.connect();
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* {@inheritdoc}
|
|
1090
|
+
*/
|
|
1091
|
+
parseKey(key) {
|
|
1092
|
+
this.ensureDriverInitialized();
|
|
1093
|
+
return this.currentDriver.parseKey(key);
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* {@inheritdoc}
|
|
1097
|
+
*/
|
|
1098
|
+
get options() {
|
|
1099
|
+
this.ensureDriverInitialized();
|
|
1100
|
+
return this.currentDriver.options;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* {@inheritdoc}
|
|
1104
|
+
*/
|
|
1105
|
+
setOptions(options) {
|
|
1106
|
+
this.ensureDriverInitialized();
|
|
1107
|
+
return this.currentDriver.setOptions(options || {});
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Return the loaded driver instance for `driverName`, loading it on first
|
|
1111
|
+
* call. Optional `runtimeOptions` follow the same merge-over-config rules
|
|
1112
|
+
* as {@link load}; passing options after the driver has already been
|
|
1113
|
+
* loaded throws to avoid silent swallowing.
|
|
1114
|
+
*/
|
|
1115
|
+
async driver(driverName, runtimeOptions) {
|
|
1116
|
+
if (this.loadedDrivers[driverName]) {
|
|
1117
|
+
this.assertNoConflictingReload(driverName, runtimeOptions);
|
|
1118
|
+
return this.loadedDrivers[driverName];
|
|
1119
|
+
}
|
|
1120
|
+
return this.load(driverName, runtimeOptions);
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Initialize the cache manager and pick the default driver
|
|
1124
|
+
*/
|
|
1125
|
+
async init() {
|
|
1126
|
+
const defaultCacheDriverName = this.configurations.default;
|
|
1127
|
+
if (!defaultCacheDriverName) return;
|
|
1128
|
+
const driver = await this.driver(defaultCacheDriverName);
|
|
1129
|
+
await this.use(driver);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Load and connect the registered driver named `driver`. First-call wins —
|
|
1133
|
+
* subsequent calls without `runtimeOptions` return the cached instance, and
|
|
1134
|
+
* subsequent calls *with* `runtimeOptions` throw {@link CacheConfigurationError}
|
|
1135
|
+
* to avoid silently dropping the new options.
|
|
1136
|
+
*
|
|
1137
|
+
* `runtimeOptions` merge over `config.options[driver]` per-key (runtime wins),
|
|
1138
|
+
* letting consumers split static knobs (table, ttl, globalPrefix) from
|
|
1139
|
+
* constructor-only ones (pg's `client`, custom adapters, etc.).
|
|
1140
|
+
*
|
|
1141
|
+
* @example
|
|
1142
|
+
* const pool = new Pool({ connectionString });
|
|
1143
|
+
* const pg = await cache.load("pg", { client: pool });
|
|
1144
|
+
*/
|
|
1145
|
+
async load(driver, runtimeOptions) {
|
|
1146
|
+
if (this.loadedDrivers[driver]) {
|
|
1147
|
+
this.assertNoConflictingReload(driver, runtimeOptions);
|
|
1148
|
+
return this.loadedDrivers[driver];
|
|
1149
|
+
}
|
|
1150
|
+
const Driver = this.configurations.drivers[driver];
|
|
1151
|
+
if (!Driver) throw new CacheConfigurationError(`Cache driver ${driver} is not found, please declare it in the cache drivers in the configurations list.`);
|
|
1152
|
+
const driverInstance = new Driver();
|
|
1153
|
+
const configOptions = this.configurations.options[driver] || {};
|
|
1154
|
+
driverInstance.setOptions({
|
|
1155
|
+
...configOptions,
|
|
1156
|
+
...runtimeOptions ?? {}
|
|
1157
|
+
});
|
|
1158
|
+
await driverInstance.connect();
|
|
1159
|
+
this.attachGlobalListeners(driverInstance);
|
|
1160
|
+
this.loadedDrivers[driver] = driverInstance;
|
|
1161
|
+
return driverInstance;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Guard against silently dropping runtime options on a re-load. Once a
|
|
1165
|
+
* driver has been instantiated, its options are frozen — calling `load` /
|
|
1166
|
+
* `driver` / `use` again with a non-empty `runtimeOptions` would otherwise
|
|
1167
|
+
* appear to work but actually use the original options. We throw instead
|
|
1168
|
+
* so the misuse surfaces at the call site.
|
|
1169
|
+
*/
|
|
1170
|
+
assertNoConflictingReload(driverName, runtimeOptions) {
|
|
1171
|
+
if (runtimeOptions === void 0) return;
|
|
1172
|
+
if (Object.keys(runtimeOptions).length === 0) return;
|
|
1173
|
+
throw new CacheConfigurationError(`Cache driver '${driverName}' is already loaded; runtime options on subsequent calls are ignored — register a second driver name if you need a different configuration.`);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Register and bind a driver
|
|
1177
|
+
*/
|
|
1178
|
+
registerDriver(driverName, driverClass) {
|
|
1179
|
+
this.configurations.drivers[driverName] = driverClass;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Disconnect the cache manager
|
|
1183
|
+
*/
|
|
1184
|
+
async disconnect() {
|
|
1185
|
+
if (this.currentDriver) await this.currentDriver.disconnect();
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* {@inheritdoc}
|
|
1189
|
+
*/
|
|
1190
|
+
async has(key) {
|
|
1191
|
+
this.ensureDriverInitialized();
|
|
1192
|
+
return this.currentDriver.has(key);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* {@inheritdoc}
|
|
1196
|
+
*/
|
|
1197
|
+
async remember(key, ttlOrOptions, callback) {
|
|
1198
|
+
this.ensureDriverInitialized();
|
|
1199
|
+
const driverOverride = ttlOrOptions && typeof ttlOrOptions === "object" && "driver" in ttlOrOptions ? ttlOrOptions.driver : void 0;
|
|
1200
|
+
if (driverOverride) return (await this.load(driverOverride)).remember(key, ttlOrOptions, callback);
|
|
1201
|
+
return this.currentDriver.remember(key, ttlOrOptions, callback);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Stale-while-revalidate. Returns cached when fresh, returns the stale
|
|
1205
|
+
* value plus a background refresh when within `freshTtl..staleTtl`,
|
|
1206
|
+
* blocks like a normal miss past `staleTtl`. Honors per-call `driver`
|
|
1207
|
+
* override the same way `remember()` does.
|
|
1208
|
+
*
|
|
1209
|
+
* @example
|
|
1210
|
+
* const product = await cache.swr(
|
|
1211
|
+
* "product.42",
|
|
1212
|
+
* { freshTtl: "1m", staleTtl: "1h" },
|
|
1213
|
+
* () => db.products.find(42),
|
|
1214
|
+
* );
|
|
1215
|
+
*/
|
|
1216
|
+
async swr(key, options, callback) {
|
|
1217
|
+
this.ensureDriverInitialized();
|
|
1218
|
+
const driverOverride = options.driver;
|
|
1219
|
+
if (driverOverride) return (await this.load(driverOverride)).swr(key, options, callback);
|
|
1220
|
+
return this.currentDriver.swr(key, options, callback);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* {@inheritdoc}
|
|
1224
|
+
*/
|
|
1225
|
+
async pull(key) {
|
|
1226
|
+
this.ensureDriverInitialized();
|
|
1227
|
+
return this.currentDriver.pull(key);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* {@inheritdoc}
|
|
1231
|
+
*/
|
|
1232
|
+
async forever(key, value) {
|
|
1233
|
+
this.ensureDriverInitialized();
|
|
1234
|
+
return this.currentDriver.forever(key, value);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* {@inheritdoc}
|
|
1238
|
+
*/
|
|
1239
|
+
async increment(key, value) {
|
|
1240
|
+
this.ensureDriverInitialized();
|
|
1241
|
+
return this.currentDriver.increment(key, value);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* {@inheritdoc}
|
|
1245
|
+
*/
|
|
1246
|
+
async decrement(key, value) {
|
|
1247
|
+
this.ensureDriverInitialized();
|
|
1248
|
+
return this.currentDriver.decrement(key, value);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* {@inheritdoc}
|
|
1252
|
+
*/
|
|
1253
|
+
async many(keys) {
|
|
1254
|
+
this.ensureDriverInitialized();
|
|
1255
|
+
return this.currentDriver.many(keys);
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* {@inheritdoc}
|
|
1259
|
+
*/
|
|
1260
|
+
async setMany(items, ttl) {
|
|
1261
|
+
this.ensureDriverInitialized();
|
|
1262
|
+
return this.currentDriver.setMany(items, ttl);
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Register a global event listener (applies to all drivers)
|
|
1266
|
+
*/
|
|
1267
|
+
on(event, handler) {
|
|
1268
|
+
if (!this.globalEventListeners.has(event)) this.globalEventListeners.set(event, /* @__PURE__ */ new Set());
|
|
1269
|
+
this.globalEventListeners.get(event).add(handler);
|
|
1270
|
+
if (this.currentDriver) this.currentDriver.on(event, handler);
|
|
1271
|
+
for (const driver of Object.values(this.loadedDrivers)) driver.on(event, handler);
|
|
1272
|
+
return this;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Remove a global event listener
|
|
1276
|
+
*/
|
|
1277
|
+
off(event, handler) {
|
|
1278
|
+
const handlers = this.globalEventListeners.get(event);
|
|
1279
|
+
if (handlers) handlers.delete(handler);
|
|
1280
|
+
if (this.currentDriver) this.currentDriver.off(event, handler);
|
|
1281
|
+
for (const driver of Object.values(this.loadedDrivers)) driver.off(event, handler);
|
|
1282
|
+
return this;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Register a one-time global event listener
|
|
1286
|
+
*/
|
|
1287
|
+
once(event, handler) {
|
|
1288
|
+
const onceHandler = async (data) => {
|
|
1289
|
+
await handler(data);
|
|
1290
|
+
this.off(event, onceHandler);
|
|
1291
|
+
};
|
|
1292
|
+
return this.on(event, onceHandler);
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Attach global listeners to a driver
|
|
1296
|
+
*/
|
|
1297
|
+
attachGlobalListeners(driver) {
|
|
1298
|
+
for (const [event, handlers] of this.globalEventListeners) for (const handler of handlers) driver.on(event, handler);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Set if not exists (atomic operation)
|
|
1302
|
+
* Returns true if key was set, false if key already existed
|
|
1303
|
+
* Note: Only supported by drivers that implement setNX (e.g., Redis)
|
|
1304
|
+
*/
|
|
1305
|
+
async setNX(key, value, ttl) {
|
|
1306
|
+
this.ensureDriverInitialized();
|
|
1307
|
+
if (!this.currentDriver.setNX) throw new Error(`setNX is not supported by the current cache driver: ${this.currentDriver.name}`);
|
|
1308
|
+
return this.currentDriver.setNX(key, value, ttl);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Create a tagged cache instance for the given tags
|
|
1312
|
+
*/
|
|
1313
|
+
tags(tags) {
|
|
1314
|
+
this.ensureDriverInitialized();
|
|
1315
|
+
return this.currentDriver.tags(tags);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Atomically read, transform, and write a cached value. Delegates to the current driver.
|
|
1319
|
+
*/
|
|
1320
|
+
async update(key, fn, options) {
|
|
1321
|
+
this.ensureDriverInitialized();
|
|
1322
|
+
return this.currentDriver.update(key, fn, options);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Shallow-merge a partial object into a cached value.
|
|
1326
|
+
*/
|
|
1327
|
+
async merge(key, partial, options) {
|
|
1328
|
+
this.ensureDriverInitialized();
|
|
1329
|
+
return this.currentDriver.merge(key, partial, options);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Obtain a list accessor bound to the current driver.
|
|
1333
|
+
*/
|
|
1334
|
+
list(key) {
|
|
1335
|
+
this.ensureDriverInitialized();
|
|
1336
|
+
return this.currentDriver.list(key);
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Acquire a distributed lock, run `fn`, and auto-release. Returns a
|
|
1340
|
+
* {@link LockOutcome} discriminated union so callers can distinguish
|
|
1341
|
+
* "ran and got this value" from "skipped because someone else holds it".
|
|
1342
|
+
*
|
|
1343
|
+
* Honors the `driver` option for per-call driver override, same as `set`
|
|
1344
|
+
* and `remember`.
|
|
1345
|
+
*
|
|
1346
|
+
* @example
|
|
1347
|
+
* const outcome = await cache.lock("lock.import", "5m", async () => {
|
|
1348
|
+
* await runImport();
|
|
1349
|
+
* return "done";
|
|
1350
|
+
* });
|
|
1351
|
+
* if (!outcome.acquired) {
|
|
1352
|
+
* console.log("another worker is already importing");
|
|
1353
|
+
* }
|
|
1354
|
+
*/
|
|
1355
|
+
async lock(key, ttlOrOptions, fn) {
|
|
1356
|
+
this.ensureDriverInitialized();
|
|
1357
|
+
const driverOverride = ttlOrOptions && typeof ttlOrOptions === "object" && "driver" in ttlOrOptions ? ttlOrOptions.driver : void 0;
|
|
1358
|
+
return (driverOverride ? await this.load(driverOverride) : this.currentDriver).lock(key, ttlOrOptions, fn);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Similarity retrieval. Delegates to the current driver's `similar()` impl.
|
|
1362
|
+
*
|
|
1363
|
+
* Drivers that lack a similarity index throw {@link CacheUnsupportedError}.
|
|
1364
|
+
*
|
|
1365
|
+
* @example
|
|
1366
|
+
* const hits = await cache.similar(await embed(query), { topK: 5, threshold: 0.7 });
|
|
1367
|
+
*/
|
|
1368
|
+
/**
|
|
1369
|
+
* Create a scoped view over the cache. Every key written through the
|
|
1370
|
+
* returned scope is automatically prefixed with `prefix`; optional defaults
|
|
1371
|
+
* (`ttl`, `tags`) flow through every write inside the scope.
|
|
1372
|
+
*
|
|
1373
|
+
* Per-call options always win over scope defaults. Scope tags merge
|
|
1374
|
+
* additively with per-call tags. Nested scopes inherit from the parent.
|
|
1375
|
+
*
|
|
1376
|
+
* @example
|
|
1377
|
+
* const chat = cache.namespace("chats.10", { ttl: "30d" });
|
|
1378
|
+
* await chat.set("messages.1", msg); // → "chats.10.messages.1", 30d
|
|
1379
|
+
* await chat.set("draft", d, { ttl: "1h" }); // per-call ttl wins
|
|
1380
|
+
* await chat.namespace("typing", { ttl: "5s" }).set("user.42", true);
|
|
1381
|
+
* await chat.clear(); // wipe the whole scope
|
|
1382
|
+
*/
|
|
1383
|
+
namespace(prefix, options) {
|
|
1384
|
+
this.ensureDriverInitialized();
|
|
1385
|
+
return new ScopedCache(this, prefix, options);
|
|
1386
|
+
}
|
|
1387
|
+
async similar(vector, options) {
|
|
1388
|
+
this.ensureDriverInitialized();
|
|
1389
|
+
return this.currentDriver.similar(vector, options);
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
const cache = new CacheManager();
|
|
1393
|
+
|
|
1394
|
+
//#endregion
|
|
1395
|
+
//#region ../../@warlock.js/cache/src/cached/auto-key.ts
|
|
1396
|
+
/**
|
|
1397
|
+
* Derive a cache key from a prefix and a set of function arguments.
|
|
1398
|
+
*
|
|
1399
|
+
* Rules (in order of precedence):
|
|
1400
|
+
* 1. No args → prefix alone.
|
|
1401
|
+
* 2. All primitives (`string`, `number`, `boolean`) or `null` / `undefined` /
|
|
1402
|
+
* `bigint` → joined onto the prefix with dots.
|
|
1403
|
+
* 3. Any non-primitive arg present → the full args array is `JSON.stringify`-ed
|
|
1404
|
+
* and appended to the prefix.
|
|
1405
|
+
* 4. Serialization throws (circular refs, `BigInt` nested in an object) → we
|
|
1406
|
+
* re-throw as `CacheConfigurationError` so the caller sees a cache-scoped
|
|
1407
|
+
* error rather than a cryptic `TypeError`.
|
|
1408
|
+
*
|
|
1409
|
+
* @example
|
|
1410
|
+
* deriveAutoKey("user", [42]); // "user.42"
|
|
1411
|
+
* deriveAutoKey("orders", [42, "abc"]); // "orders.42.abc"
|
|
1412
|
+
* deriveAutoKey("featured", []); // "featured"
|
|
1413
|
+
* deriveAutoKey("search", [{ q: "hello" }]); // "search.[{\"q\":\"hello\"}]"
|
|
1414
|
+
* deriveAutoKey("user", [null, undefined]); // "user.null.undefined"
|
|
1415
|
+
*/
|
|
1416
|
+
function deriveAutoKey(prefix, args) {
|
|
1417
|
+
if (args.length === 0) return prefix;
|
|
1418
|
+
if (args.every(isPrimitiveOrNullish)) return prefix + "." + args.map(serializePrimitive).join(".");
|
|
1419
|
+
try {
|
|
1420
|
+
return prefix + "." + JSON.stringify(args);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
throw new CacheConfigurationError(`cached(): could not derive an auto-key from args for prefix "${prefix}". The args include a value that is not JSON-serializable (circular reference, BigInt nested inside an object, or similar). Use the options form with a custom key function. Original error: ${error.message}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Primitives and nullish values can be concatenated directly onto a key without
|
|
1427
|
+
* JSON serialization. Adding `bigint` here avoids the `JSON.stringify` throw on
|
|
1428
|
+
* top-level bigint args.
|
|
1429
|
+
*/
|
|
1430
|
+
function isPrimitiveOrNullish(value) {
|
|
1431
|
+
if (value === null || value === void 0) return true;
|
|
1432
|
+
const type = typeof value;
|
|
1433
|
+
return type === "string" || type === "number" || type === "boolean" || type === "bigint";
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Serialize a single primitive or nullish value to its string key-segment form.
|
|
1437
|
+
*/
|
|
1438
|
+
function serializePrimitive(value) {
|
|
1439
|
+
if (value === null) return "null";
|
|
1440
|
+
if (value === void 0) return "undefined";
|
|
1441
|
+
if (typeof value === "bigint") return value.toString();
|
|
1442
|
+
return String(value);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region ../../@warlock.js/cache/src/cached/normalize-args.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* Resolve the positional-or-options arguments of `cached()` into a single
|
|
1449
|
+
* normalized config. Keeps the wrapper body free of shape-checks.
|
|
1450
|
+
*/
|
|
1451
|
+
function normalizeCachedArgs(prefixOrOptions, maybeTtl) {
|
|
1452
|
+
if (typeof prefixOrOptions === "string") {
|
|
1453
|
+
const prefix = prefixOrOptions;
|
|
1454
|
+
return {
|
|
1455
|
+
key: (...args) => deriveAutoKey(prefix, args),
|
|
1456
|
+
ttl: maybeTtl
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
key: prefixOrOptions.key,
|
|
1461
|
+
ttl: prefixOrOptions.ttl,
|
|
1462
|
+
tags: prefixOrOptions.tags,
|
|
1463
|
+
driver: prefixOrOptions.driver
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region ../../@warlock.js/cache/src/cached/cached.ts
|
|
1469
|
+
function cached(fn, prefixOrOptions, maybeTtl) {
|
|
1470
|
+
const config = normalizeCachedArgs(prefixOrOptions, maybeTtl);
|
|
1471
|
+
const buildRememberOptions = () => ({
|
|
1472
|
+
ttl: config.ttl,
|
|
1473
|
+
tags: config.tags,
|
|
1474
|
+
driver: config.driver
|
|
1475
|
+
});
|
|
1476
|
+
const wrapper = (async (...args) => {
|
|
1477
|
+
const key = config.key(...args);
|
|
1478
|
+
return cache.remember(key, buildRememberOptions(), () => fn(...args));
|
|
1479
|
+
});
|
|
1480
|
+
wrapper.invalidate = async (...args) => {
|
|
1481
|
+
const key = config.key(...args);
|
|
1482
|
+
await cache.remove(key);
|
|
1483
|
+
};
|
|
1484
|
+
return wrapper;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
//#endregion
|
|
1488
|
+
//#region ../../@warlock.js/cache/src/list/memory-cache-list.ts
|
|
1489
|
+
/**
|
|
1490
|
+
* Generic array-backed {@link CacheListAccessor}.
|
|
1491
|
+
*
|
|
1492
|
+
* Stores the full list as a single cache entry and performs read-mutate-write
|
|
1493
|
+
* for every operation. Correct for any driver, but O(n) per op. The Redis
|
|
1494
|
+
* driver overrides `list()` to return a native-command accessor instead.
|
|
1495
|
+
*
|
|
1496
|
+
* **Role.** Fallback list accessor bound to a driver + key. Every mutation
|
|
1497
|
+
* fetches the array, transforms it in memory, and writes it back.
|
|
1498
|
+
*
|
|
1499
|
+
* **Responsibility.**
|
|
1500
|
+
* - Owns: translating list operations into array mutations + driver writes.
|
|
1501
|
+
* - Does NOT own: concurrency control (callers should wrap in a distributed
|
|
1502
|
+
* lock when multi-process writers are possible), TTL preservation across
|
|
1503
|
+
* ops (writes use driver defaults), or tagging of list entries.
|
|
1504
|
+
*
|
|
1505
|
+
* @example
|
|
1506
|
+
* // Never constructed directly — obtained via driver.list():
|
|
1507
|
+
* const list = cache.list<Event>("recent-events");
|
|
1508
|
+
* await list.push(event);
|
|
1509
|
+
*/
|
|
1510
|
+
var MemoryCacheList = class {
|
|
1511
|
+
constructor(driver, key) {
|
|
1512
|
+
this.driver = driver;
|
|
1513
|
+
this.key = key;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Read the backing array from the driver. Returns an empty array on miss.
|
|
1517
|
+
*/
|
|
1518
|
+
async read() {
|
|
1519
|
+
const current = await this.driver.get(this.key);
|
|
1520
|
+
return Array.isArray(current) ? [...current] : [];
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Persist the backing array. Removes the entry when empty to keep the
|
|
1524
|
+
* store clean.
|
|
1525
|
+
*/
|
|
1526
|
+
async write(items) {
|
|
1527
|
+
if (items.length === 0) {
|
|
1528
|
+
await this.driver.remove(this.key);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
await this.driver.set(this.key, items);
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* {@inheritdoc}
|
|
1535
|
+
*/
|
|
1536
|
+
async push(...items) {
|
|
1537
|
+
const current = await this.read();
|
|
1538
|
+
current.push(...items);
|
|
1539
|
+
await this.write(current);
|
|
1540
|
+
return current.length;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* {@inheritdoc}
|
|
1544
|
+
*/
|
|
1545
|
+
async unshift(...items) {
|
|
1546
|
+
const current = await this.read();
|
|
1547
|
+
current.unshift(...items);
|
|
1548
|
+
await this.write(current);
|
|
1549
|
+
return current.length;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* {@inheritdoc}
|
|
1553
|
+
*/
|
|
1554
|
+
async pop() {
|
|
1555
|
+
const current = await this.read();
|
|
1556
|
+
if (current.length === 0) return null;
|
|
1557
|
+
const value = current.pop();
|
|
1558
|
+
await this.write(current);
|
|
1559
|
+
return value;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* {@inheritdoc}
|
|
1563
|
+
*/
|
|
1564
|
+
async shift() {
|
|
1565
|
+
const current = await this.read();
|
|
1566
|
+
if (current.length === 0) return null;
|
|
1567
|
+
const value = current.shift();
|
|
1568
|
+
await this.write(current);
|
|
1569
|
+
return value;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* {@inheritdoc}
|
|
1573
|
+
*/
|
|
1574
|
+
async slice(start, end) {
|
|
1575
|
+
return (await this.read()).slice(start, end);
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* {@inheritdoc}
|
|
1579
|
+
*/
|
|
1580
|
+
async all() {
|
|
1581
|
+
return this.read();
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* {@inheritdoc}
|
|
1585
|
+
*/
|
|
1586
|
+
async length() {
|
|
1587
|
+
return (await this.read()).length;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* {@inheritdoc}
|
|
1591
|
+
*/
|
|
1592
|
+
async trim(start, end) {
|
|
1593
|
+
const trimmed = (await this.read()).slice(start, end + 1);
|
|
1594
|
+
await this.write(trimmed);
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* {@inheritdoc}
|
|
1598
|
+
*/
|
|
1599
|
+
async clear() {
|
|
1600
|
+
await this.driver.remove(this.key);
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
//#endregion
|
|
1605
|
+
//#region ../../@warlock.js/cache/src/tagged-cache.ts
|
|
1606
|
+
/**
|
|
1607
|
+
* Tagged Cache Wrapper
|
|
1608
|
+
* Wraps a cache driver to automatically manage tag relationships
|
|
1609
|
+
*/
|
|
1610
|
+
var TaggedCache = class {
|
|
1611
|
+
/**
|
|
1612
|
+
* Constructor
|
|
1613
|
+
*/
|
|
1614
|
+
constructor(tags, driver) {
|
|
1615
|
+
this.cacheTags = tags;
|
|
1616
|
+
this.driver = driver;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Get the tag key prefix for storing tag-key relationships
|
|
1620
|
+
*/
|
|
1621
|
+
tagKey(tag) {
|
|
1622
|
+
return `cache:tags:${tag}`;
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Store tag-key relationship
|
|
1626
|
+
*/
|
|
1627
|
+
async storeTaggedKey(key) {
|
|
1628
|
+
await this.storeTagRelationship(key);
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Public alias of the tag-index writer. Called by `BaseCacheDriver.applyTags`
|
|
1632
|
+
* when tags are passed inline through `CacheSetOptions.tags`.
|
|
1633
|
+
*
|
|
1634
|
+
* @internal — public for cross-class use within this package; not part of the
|
|
1635
|
+
* stable consumer API.
|
|
1636
|
+
*/
|
|
1637
|
+
async storeTagRelationship(parsedKey) {
|
|
1638
|
+
for (const tag of this.cacheTags) {
|
|
1639
|
+
const tagKey = this.tagKey(tag);
|
|
1640
|
+
const keys = await this.driver.get(tagKey) || [];
|
|
1641
|
+
if (!keys.includes(parsedKey)) {
|
|
1642
|
+
keys.push(parsedKey);
|
|
1643
|
+
await this.driver.set(tagKey, keys, Infinity);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Get all keys associated with tags
|
|
1649
|
+
*/
|
|
1650
|
+
async getTaggedKeys() {
|
|
1651
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
1652
|
+
for (const tag of this.cacheTags) {
|
|
1653
|
+
const tagKey = this.tagKey(tag);
|
|
1654
|
+
const keys = await this.driver.get(tagKey) || [];
|
|
1655
|
+
for (const key of keys) allKeys.add(key);
|
|
1656
|
+
}
|
|
1657
|
+
return allKeys;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* {@inheritdoc}
|
|
1661
|
+
*/
|
|
1662
|
+
async set(key, value, ttlOrOptions) {
|
|
1663
|
+
const parsedKey = this.driver.parseKey(key);
|
|
1664
|
+
await this.driver.set(key, value, ttlOrOptions);
|
|
1665
|
+
await this.storeTaggedKey(parsedKey);
|
|
1666
|
+
return value;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* {@inheritdoc}
|
|
1670
|
+
*/
|
|
1671
|
+
async get(key) {
|
|
1672
|
+
return this.driver.get(key);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* {@inheritdoc}
|
|
1676
|
+
*/
|
|
1677
|
+
async remove(key) {
|
|
1678
|
+
const parsedKey = this.driver.parseKey(key);
|
|
1679
|
+
await this.driver.remove(key);
|
|
1680
|
+
for (const tag of this.cacheTags) {
|
|
1681
|
+
const tagKey = this.tagKey(tag);
|
|
1682
|
+
const updatedKeys = (await this.driver.get(tagKey) || []).filter((k) => k !== parsedKey);
|
|
1683
|
+
await this.driver.set(tagKey, updatedKeys, Infinity);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Invalidate (clear) all keys associated with the current tags
|
|
1688
|
+
*/
|
|
1689
|
+
async invalidate() {
|
|
1690
|
+
const keysToRemove = await this.getTaggedKeys();
|
|
1691
|
+
for (const key of keysToRemove) await this.driver.remove(key);
|
|
1692
|
+
for (const tag of this.cacheTags) await this.driver.remove(this.tagKey(tag));
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Flush all keys associated with the current tags
|
|
1696
|
+
* @deprecated Use invalidate() instead for better semantics
|
|
1697
|
+
*/
|
|
1698
|
+
async flush() {
|
|
1699
|
+
return this.invalidate();
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* {@inheritdoc}
|
|
1703
|
+
*/
|
|
1704
|
+
async has(key) {
|
|
1705
|
+
return this.driver.has(key);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* {@inheritdoc}
|
|
1709
|
+
*/
|
|
1710
|
+
async remember(key, ttl, callback) {
|
|
1711
|
+
const value = await this.get(key);
|
|
1712
|
+
if (value !== null) return value;
|
|
1713
|
+
const result = await callback();
|
|
1714
|
+
await this.set(key, result, ttl);
|
|
1715
|
+
return result;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* {@inheritdoc}
|
|
1719
|
+
*/
|
|
1720
|
+
async pull(key) {
|
|
1721
|
+
const value = await this.get(key);
|
|
1722
|
+
if (value !== null) await this.remove(key);
|
|
1723
|
+
return value;
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* {@inheritdoc}
|
|
1727
|
+
*/
|
|
1728
|
+
async forever(key, value) {
|
|
1729
|
+
return this.set(key, value, Infinity);
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* {@inheritdoc}
|
|
1733
|
+
*/
|
|
1734
|
+
async increment(key, value = 1) {
|
|
1735
|
+
const current = await this.get(key) || 0;
|
|
1736
|
+
if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${this.driver.parseKey(key)}`);
|
|
1737
|
+
const newValue = current + value;
|
|
1738
|
+
await this.set(key, newValue);
|
|
1739
|
+
return newValue;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* {@inheritdoc}
|
|
1743
|
+
*/
|
|
1744
|
+
async decrement(key, value = 1) {
|
|
1745
|
+
return this.increment(key, -value);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
//#endregion
|
|
1750
|
+
//#region ../../@warlock.js/cache/src/drivers/base-cache-driver.ts
|
|
1751
|
+
const messages = {
|
|
1752
|
+
clearing: "Clearing namespace",
|
|
1753
|
+
cleared: "Namespace cleared",
|
|
1754
|
+
fetching: "Fetching key",
|
|
1755
|
+
fetched: "Key fetched",
|
|
1756
|
+
caching: "Caching key",
|
|
1757
|
+
cached: "Key cached",
|
|
1758
|
+
flushing: "Flushing cache",
|
|
1759
|
+
flushed: "Cache flushed",
|
|
1760
|
+
removing: "Removing key",
|
|
1761
|
+
removed: "Key removed",
|
|
1762
|
+
expired: "Key expired",
|
|
1763
|
+
notFound: "Key not found",
|
|
1764
|
+
connecting: "Connecting to the cache engine.",
|
|
1765
|
+
connected: "Connected to the cache engine.",
|
|
1766
|
+
disconnecting: "Disconnecting from the cache engine.",
|
|
1767
|
+
disconnected: "Disconnected from the cache engine.",
|
|
1768
|
+
error: "Error occurred"
|
|
1769
|
+
};
|
|
1770
|
+
var BaseCacheDriver = class {
|
|
1771
|
+
constructor() {
|
|
1772
|
+
this.shouldLog = true;
|
|
1773
|
+
this.eventListeners = /* @__PURE__ */ new Map();
|
|
1774
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* {@inheritdoc}
|
|
1778
|
+
*/
|
|
1779
|
+
get client() {
|
|
1780
|
+
return this.clientDriver || this;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Set logging state
|
|
1784
|
+
*/
|
|
1785
|
+
setLoggingState(shouldLog) {
|
|
1786
|
+
this.shouldLog = shouldLog;
|
|
1787
|
+
return this;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Set client driver
|
|
1791
|
+
*/
|
|
1792
|
+
set client(client) {
|
|
1793
|
+
this.clientDriver = client;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* {@inheritdoc}
|
|
1797
|
+
*/
|
|
1798
|
+
parseKey(key) {
|
|
1799
|
+
return parseCacheKey(key, this.options);
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* {@inheritdoc}
|
|
1803
|
+
*/
|
|
1804
|
+
setOptions(options) {
|
|
1805
|
+
this.options = options || {};
|
|
1806
|
+
return this;
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Register an event listener
|
|
1810
|
+
*/
|
|
1811
|
+
on(event, handler) {
|
|
1812
|
+
if (!this.eventListeners.has(event)) this.eventListeners.set(event, /* @__PURE__ */ new Set());
|
|
1813
|
+
this.eventListeners.get(event).add(handler);
|
|
1814
|
+
return this;
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Remove an event listener
|
|
1818
|
+
*/
|
|
1819
|
+
off(event, handler) {
|
|
1820
|
+
const handlers = this.eventListeners.get(event);
|
|
1821
|
+
if (handlers) handlers.delete(handler);
|
|
1822
|
+
return this;
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Register a one-time event listener
|
|
1826
|
+
*/
|
|
1827
|
+
once(event, handler) {
|
|
1828
|
+
const onceHandler = async (data) => {
|
|
1829
|
+
await handler(data);
|
|
1830
|
+
this.off(event, onceHandler);
|
|
1831
|
+
};
|
|
1832
|
+
return this.on(event, onceHandler);
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Emit an event to all registered listeners
|
|
1836
|
+
*/
|
|
1837
|
+
async emit(event, data = {}) {
|
|
1838
|
+
const handlers = this.eventListeners.get(event);
|
|
1839
|
+
if (!handlers || handlers.size === 0) return;
|
|
1840
|
+
const eventData = {
|
|
1841
|
+
driver: this.name,
|
|
1842
|
+
...data
|
|
1843
|
+
};
|
|
1844
|
+
const promises = [];
|
|
1845
|
+
for (const handler of handlers) try {
|
|
1846
|
+
const result = handler(eventData);
|
|
1847
|
+
if (result instanceof Promise) promises.push(result);
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
this.logError(`Error in event handler for '${event}'`, error);
|
|
1850
|
+
}
|
|
1851
|
+
if (promises.length > 0) await Promise.allSettled(promises);
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Normalize the 3rd argument of a `set` call into a single shape every driver
|
|
1855
|
+
* can act on. Handles TTL parsing (number | string | Infinity), `expiresAt` →
|
|
1856
|
+
* relative TTL conversion, and mutual-exclusion validation.
|
|
1857
|
+
*
|
|
1858
|
+
* @throws {CacheConfigurationError} when `ttl` and `expiresAt` are passed together
|
|
1859
|
+
* or an unparseable duration string is supplied.
|
|
1860
|
+
*/
|
|
1861
|
+
resolveSetOptions(ttlOrOptions) {
|
|
1862
|
+
const options = normalizeToOptions(ttlOrOptions);
|
|
1863
|
+
return {
|
|
1864
|
+
ttl: resolveTtl(options.ttl, options.expiresAt, this.ttl),
|
|
1865
|
+
tags: options.tags,
|
|
1866
|
+
onConflict: options.onConflict ?? "upsert",
|
|
1867
|
+
vector: options.vector,
|
|
1868
|
+
staleAt: options.staleAt
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Resolve the union of cache keys associated with any of the given tags.
|
|
1873
|
+
* Used by `similar()` to narrow the candidate pool before similarity ranking.
|
|
1874
|
+
*
|
|
1875
|
+
* Returns `null` when no tags are passed (callers should treat that as "no filter").
|
|
1876
|
+
*/
|
|
1877
|
+
async getKeysForTags(tags) {
|
|
1878
|
+
if (!tags || tags.length === 0) return null;
|
|
1879
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
1880
|
+
for (const tag of tags) {
|
|
1881
|
+
const tagKey = `cache:tags:${tag}`;
|
|
1882
|
+
const keys = await this.get(tagKey) || [];
|
|
1883
|
+
for (const k of keys) allKeys.add(k);
|
|
1884
|
+
}
|
|
1885
|
+
return allKeys;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Apply tag relationships after a successful write. Called by drivers once
|
|
1889
|
+
* the value is in storage.
|
|
1890
|
+
*/
|
|
1891
|
+
async applyTags(parsedKey, tags) {
|
|
1892
|
+
if (tags.length === 0) return;
|
|
1893
|
+
await this.tags(tags).storeTagRelationship(parsedKey);
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* {@inheritdoc}
|
|
1897
|
+
*/
|
|
1898
|
+
async has(key) {
|
|
1899
|
+
return await this.get(key) !== null;
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* {@inheritdoc}
|
|
1903
|
+
*/
|
|
1904
|
+
async remember(key, ttlOrOptions, callback) {
|
|
1905
|
+
const parsedKey = this.parseKey(key);
|
|
1906
|
+
const setOptions = this.normalizeRememberOptions(ttlOrOptions);
|
|
1907
|
+
const cachedValue = await this.get(key);
|
|
1908
|
+
if (cachedValue) return cachedValue;
|
|
1909
|
+
const existingLock = this.locks.get(parsedKey);
|
|
1910
|
+
if (existingLock) return existingLock;
|
|
1911
|
+
const promise = callback().then(async (result) => {
|
|
1912
|
+
await this.set(key, result, setOptions);
|
|
1913
|
+
this.locks.delete(parsedKey);
|
|
1914
|
+
return result;
|
|
1915
|
+
}).catch((err) => {
|
|
1916
|
+
this.locks.delete(parsedKey);
|
|
1917
|
+
throw err;
|
|
1918
|
+
});
|
|
1919
|
+
this.locks.set(parsedKey, promise);
|
|
1920
|
+
return promise;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Resolve the TTL-or-options arg of `remember` into a `CacheSetOptions` object
|
|
1924
|
+
* that can be passed straight to `set()`. Keeps the implementation unbranched.
|
|
1925
|
+
*/
|
|
1926
|
+
normalizeRememberOptions(ttlOrOptions) {
|
|
1927
|
+
if (typeof ttlOrOptions === "number" || typeof ttlOrOptions === "string") return { ttl: ttlOrOptions };
|
|
1928
|
+
return {
|
|
1929
|
+
ttl: ttlOrOptions.ttl,
|
|
1930
|
+
tags: ttlOrOptions.tags
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* {@inheritdoc}
|
|
1935
|
+
*
|
|
1936
|
+
* Default implementation: read raw entry, branch on freshness/staleness,
|
|
1937
|
+
* trigger background refresh in the stale window, fall through to
|
|
1938
|
+
* `callback` on miss/expiry. Concurrent stale-window callers share a
|
|
1939
|
+
* single in-flight refresh via {@link locks}.
|
|
1940
|
+
*
|
|
1941
|
+
* Drivers without a real {@link getEntry} override degrade gracefully —
|
|
1942
|
+
* the synthetic entry has no `staleAt`, which the freshness check treats
|
|
1943
|
+
* as "always fresh," so SWR behaves like a TTL-only cached read on those
|
|
1944
|
+
* drivers (no background refresh, but no double-fetch either).
|
|
1945
|
+
*/
|
|
1946
|
+
async swr(key, options, callback) {
|
|
1947
|
+
const parsedKey = this.parseKey(key);
|
|
1948
|
+
const freshSeconds = parseTtl(options.freshTtl);
|
|
1949
|
+
const staleSeconds = parseTtl(options.staleTtl);
|
|
1950
|
+
if (staleSeconds <= freshSeconds) throw new Error(`cache.swr: 'staleTtl' (${staleSeconds}s) must be greater than 'freshTtl' (${freshSeconds}s).`);
|
|
1951
|
+
const entry = await this.getEntry(key);
|
|
1952
|
+
const now = Date.now();
|
|
1953
|
+
const isExpired = entry?.expiresAt !== void 0 && entry.expiresAt <= now;
|
|
1954
|
+
if (!entry || isExpired) return this.swrFetchAndStore(key, options, callback, freshSeconds, staleSeconds);
|
|
1955
|
+
if (entry.staleAt === void 0 || entry.staleAt > now) return entry.data;
|
|
1956
|
+
this.scheduleSwrRefresh(parsedKey, key, options, callback, freshSeconds, staleSeconds);
|
|
1957
|
+
return entry.data;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Read the raw {@link CacheData} wrapper for a key, including any
|
|
1961
|
+
* `expiresAt` / `staleAt` metadata. Default implementation falls back to
|
|
1962
|
+
* `get()` and synthesizes a metadata-less wrapper — drivers that store
|
|
1963
|
+
* the wrapper directly (memory, lru, file, redis, pg, mock) override
|
|
1964
|
+
* this to return real metadata so SWR can branch on freshness.
|
|
1965
|
+
*/
|
|
1966
|
+
async getEntry(key) {
|
|
1967
|
+
const value = await this.get(key);
|
|
1968
|
+
if (value === null) return null;
|
|
1969
|
+
return { data: value };
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Remaining lifetime of an existing entry, in seconds — used by TTL-preserving
|
|
1973
|
+
* writes such as `update()` / `merge()` when the caller passes no explicit
|
|
1974
|
+
* `ttl`.
|
|
1975
|
+
*
|
|
1976
|
+
* - `Infinity` — the entry exists with no expiry (preserve "never expires").
|
|
1977
|
+
* - positive number — seconds left before the entry expires.
|
|
1978
|
+
* - `undefined` — the key is missing or already past its deadline; the caller
|
|
1979
|
+
* should fall back to the driver default TTL.
|
|
1980
|
+
*
|
|
1981
|
+
* Default reads `expiresAt` from {@link getEntry}, which the metadata-aware
|
|
1982
|
+
* drivers (memory, lru, mock, pg) populate. Drivers that track TTL natively
|
|
1983
|
+
* and don't carry `expiresAt` in their payload (Redis) override this.
|
|
1984
|
+
*/
|
|
1985
|
+
async getRemainingTtl(key) {
|
|
1986
|
+
const entry = await this.getEntry(key);
|
|
1987
|
+
if (!entry) return;
|
|
1988
|
+
if (!entry.expiresAt || entry.expiresAt === Infinity) return Infinity;
|
|
1989
|
+
const remainingSeconds = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
|
|
1990
|
+
return remainingSeconds > 0 ? remainingSeconds : void 0;
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Block-and-fetch path of `swr()`: invoked on miss or past-`staleTtl`
|
|
1994
|
+
* expiry. Writes through `set()` with the SWR options translated into
|
|
1995
|
+
* standard `CacheSetOptions` (ttl = staleTtl, staleAt = now + freshTtl).
|
|
1996
|
+
*/
|
|
1997
|
+
async swrFetchAndStore(key, options, callback, freshSeconds, staleSeconds) {
|
|
1998
|
+
const result = await callback();
|
|
1999
|
+
await this.set(key, result, {
|
|
2000
|
+
ttl: staleSeconds,
|
|
2001
|
+
staleAt: Date.now() + freshSeconds * 1e3,
|
|
2002
|
+
tags: options.tags
|
|
2003
|
+
});
|
|
2004
|
+
return result;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Stale-window background refresh. Registers a single in-flight promise
|
|
2008
|
+
* per parsed key so concurrent SWR callers share one refresh. Failed
|
|
2009
|
+
* refreshes preserve the stale entry, log via `logError`, and emit on
|
|
2010
|
+
* `error` — the stale-returning caller never sees the failure.
|
|
2011
|
+
*/
|
|
2012
|
+
scheduleSwrRefresh(parsedKey, key, options, callback, freshSeconds, staleSeconds) {
|
|
2013
|
+
if (this.locks.has(parsedKey)) return;
|
|
2014
|
+
let refresh;
|
|
2015
|
+
refresh = (async () => {
|
|
2016
|
+
try {
|
|
2017
|
+
const result = await callback();
|
|
2018
|
+
await this.set(key, result, {
|
|
2019
|
+
ttl: staleSeconds,
|
|
2020
|
+
staleAt: Date.now() + freshSeconds * 1e3,
|
|
2021
|
+
tags: options.tags
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
this.logError(`SWR background refresh failed for ${parsedKey}`, error);
|
|
2025
|
+
await this.emit("error", {
|
|
2026
|
+
key: parsedKey,
|
|
2027
|
+
error
|
|
2028
|
+
});
|
|
2029
|
+
} finally {
|
|
2030
|
+
if (this.locks.get(parsedKey) === refresh) this.locks.delete(parsedKey);
|
|
2031
|
+
}
|
|
2032
|
+
})();
|
|
2033
|
+
this.locks.set(parsedKey, refresh);
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* {@inheritdoc}
|
|
2037
|
+
*/
|
|
2038
|
+
async pull(key) {
|
|
2039
|
+
const value = await this.get(key);
|
|
2040
|
+
if (value !== null) await this.remove(key);
|
|
2041
|
+
return value;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* {@inheritdoc}
|
|
2045
|
+
*/
|
|
2046
|
+
async forever(key, value) {
|
|
2047
|
+
return this.set(key, value, Infinity);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* {@inheritdoc}
|
|
2051
|
+
*/
|
|
2052
|
+
async increment(key, value = 1) {
|
|
2053
|
+
const current = await this.get(key) || 0;
|
|
2054
|
+
if (typeof current !== "number") throw new Error(`Cannot increment non-numeric value for key: ${this.parseKey(key)}`);
|
|
2055
|
+
const newValue = current + value;
|
|
2056
|
+
await this.set(key, newValue);
|
|
2057
|
+
return newValue;
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* {@inheritdoc}
|
|
2061
|
+
*/
|
|
2062
|
+
async decrement(key, value = 1) {
|
|
2063
|
+
return this.increment(key, -value);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* {@inheritdoc}
|
|
2067
|
+
*/
|
|
2068
|
+
async many(keys) {
|
|
2069
|
+
return Promise.all(keys.map((key) => this.get(key)));
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* {@inheritdoc}
|
|
2073
|
+
*/
|
|
2074
|
+
async setMany(items, ttl) {
|
|
2075
|
+
await Promise.all(Object.entries(items).map(([key, value]) => this.set(key, value, ttl)));
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Log the operation
|
|
2079
|
+
*/
|
|
2080
|
+
log(operation, key) {
|
|
2081
|
+
if (!this.shouldLog) return;
|
|
2082
|
+
if (key) key = key.replaceAll("/", ".");
|
|
2083
|
+
if (operation == "notFound" || operation == "expired") return _warlock_js_logger.log.warn("cache." + this.name, operation, (key ? key + " " : "") + messages[operation]);
|
|
2084
|
+
if (operation.endsWith("ed")) return _warlock_js_logger.log.success("cache." + this.name, operation, (key ? key + " " : "") + messages[operation]);
|
|
2085
|
+
_warlock_js_logger.log.info("cache." + this.name, operation, (key ? key + " " : "") + messages[operation]);
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Log error message
|
|
2089
|
+
*/
|
|
2090
|
+
logError(message, error) {
|
|
2091
|
+
_warlock_js_logger.log.error("cache." + this.name, "error", message);
|
|
2092
|
+
if (error) console.log(error);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Get the default TTL in seconds. Parses human-readable strings (`"1h"`, `"30m"`)
|
|
2096
|
+
* from driver options if present; falls back to `Infinity` when no default is set.
|
|
2097
|
+
*/
|
|
2098
|
+
get ttl() {
|
|
2099
|
+
if (this.options.ttl === void 0) return Infinity;
|
|
2100
|
+
return parseTtl(this.options.ttl);
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Get time to live value in milliseconds
|
|
2104
|
+
*/
|
|
2105
|
+
getExpiresAt(ttl = this.ttl) {
|
|
2106
|
+
if (ttl) return (/* @__PURE__ */ new Date()).getTime() + ttl * 1e3;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Wrap a value with TTL and optional freshness metadata for backend
|
|
2110
|
+
* storage. `staleAt` persists alongside `expiresAt` when supplied — used
|
|
2111
|
+
* by the SWR flow to mark when the entry stops being fresh.
|
|
2112
|
+
*/
|
|
2113
|
+
prepareDataForStorage(data, ttl, staleAt) {
|
|
2114
|
+
const preparedData = { data };
|
|
2115
|
+
if (ttl) {
|
|
2116
|
+
preparedData.ttl = ttl;
|
|
2117
|
+
preparedData.expiresAt = this.getExpiresAt(ttl);
|
|
2118
|
+
}
|
|
2119
|
+
if (staleAt !== void 0) preparedData.staleAt = staleAt;
|
|
2120
|
+
return preparedData;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Parse fetched data from cache
|
|
2124
|
+
*/
|
|
2125
|
+
async parseCachedData(key, data) {
|
|
2126
|
+
this.log("fetched", key);
|
|
2127
|
+
if (data.expiresAt && data.expiresAt < Date.now()) {
|
|
2128
|
+
this.remove(key);
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
const value = data.data;
|
|
2132
|
+
if (value === null || value === void 0) return value;
|
|
2133
|
+
const type = typeof value;
|
|
2134
|
+
if (type === "string" || type === "number" || type === "boolean") return value;
|
|
2135
|
+
try {
|
|
2136
|
+
return structuredClone(value);
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
console.log(value);
|
|
2139
|
+
this.logError(`Failed to clone cached value for ${key}, typeof value: ${typeof value}`, error);
|
|
2140
|
+
throw error;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* {@inheritdoc}
|
|
2145
|
+
*/
|
|
2146
|
+
async connect() {
|
|
2147
|
+
this.log("connecting");
|
|
2148
|
+
this.log("connected");
|
|
2149
|
+
await this.emit("connected");
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* {@inheritdoc}
|
|
2153
|
+
*/
|
|
2154
|
+
async disconnect() {
|
|
2155
|
+
this.log("disconnected");
|
|
2156
|
+
await this.emit("disconnected");
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Create a tagged cache instance for the given tags
|
|
2160
|
+
*/
|
|
2161
|
+
tags(tags) {
|
|
2162
|
+
return new TaggedCache(tags, this);
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* {@inheritdoc}
|
|
2166
|
+
*
|
|
2167
|
+
* Default implementation: read → transform → write under a per-key in-process
|
|
2168
|
+
* lock. Drivers that can offer stronger semantics (Redis via `WATCH`/`MULTI`)
|
|
2169
|
+
* should override.
|
|
2170
|
+
*/
|
|
2171
|
+
async update(key, fn, options = {}) {
|
|
2172
|
+
const parsedKey = this.parseKey(key);
|
|
2173
|
+
const next = (this.locks.get(parsedKey) ?? Promise.resolve()).catch(() => void 0).then(async () => {
|
|
2174
|
+
const result = await fn(await this.get(key));
|
|
2175
|
+
if (result === null) {
|
|
2176
|
+
await this.remove(key);
|
|
2177
|
+
return null;
|
|
2178
|
+
}
|
|
2179
|
+
if (options.ttl !== void 0) {
|
|
2180
|
+
await this.set(key, result, { ttl: options.ttl });
|
|
2181
|
+
return result;
|
|
2182
|
+
}
|
|
2183
|
+
const remainingTtl = await this.getRemainingTtl(key);
|
|
2184
|
+
if (remainingTtl !== void 0) await this.set(key, result, { ttl: remainingTtl });
|
|
2185
|
+
else await this.set(key, result);
|
|
2186
|
+
return result;
|
|
2187
|
+
});
|
|
2188
|
+
this.locks.set(parsedKey, next);
|
|
2189
|
+
next.finally(() => {
|
|
2190
|
+
if (this.locks.get(parsedKey) === next) this.locks.delete(parsedKey);
|
|
2191
|
+
});
|
|
2192
|
+
return next;
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* {@inheritdoc}
|
|
2196
|
+
*/
|
|
2197
|
+
async merge(key, partial, options = {}) {
|
|
2198
|
+
return await this.update(key, (current) => {
|
|
2199
|
+
return {
|
|
2200
|
+
...current ?? {},
|
|
2201
|
+
...partial
|
|
2202
|
+
};
|
|
2203
|
+
}, options);
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* {@inheritdoc}
|
|
2207
|
+
*
|
|
2208
|
+
* Default implementation: read-mutate-write array backed by the underlying
|
|
2209
|
+
* cache entry. Concrete drivers (e.g. Redis) override with native commands.
|
|
2210
|
+
*/
|
|
2211
|
+
list(key) {
|
|
2212
|
+
return new MemoryCacheList(this, key);
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* {@inheritdoc}
|
|
2216
|
+
*
|
|
2217
|
+
* Built on top of `set({ onConflict: "create" })` — Redis-native `SET … NX EX`
|
|
2218
|
+
* under the hood on Redis, emulated via key-existence check on other drivers.
|
|
2219
|
+
* The lock value is the resolved `owner` (defaults to `pid.<process.pid>`).
|
|
2220
|
+
*
|
|
2221
|
+
* Always releases in `finally`, even if `fn` throws — the thrown error
|
|
2222
|
+
* propagates to the caller unchanged.
|
|
2223
|
+
*/
|
|
2224
|
+
async lock(key, ttlOrOptions, fn) {
|
|
2225
|
+
const { ttl, owner } = this.normalizeLockOptions(ttlOrOptions);
|
|
2226
|
+
const lockOwner = owner ?? `pid.${process.pid}`;
|
|
2227
|
+
const setResult = await this.set(key, lockOwner, {
|
|
2228
|
+
onConflict: "create",
|
|
2229
|
+
ttl
|
|
2230
|
+
});
|
|
2231
|
+
if (!(typeof setResult === "object" && setResult !== null && "wasSet" in setResult ? setResult.wasSet : true)) return { acquired: false };
|
|
2232
|
+
try {
|
|
2233
|
+
return {
|
|
2234
|
+
acquired: true,
|
|
2235
|
+
value: await fn()
|
|
2236
|
+
};
|
|
2237
|
+
} finally {
|
|
2238
|
+
await this.remove(key);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* {@inheritdoc}
|
|
2243
|
+
*
|
|
2244
|
+
* Default implementation throws {@link CacheUnsupportedError}. Drivers that
|
|
2245
|
+
* support similarity retrieval (memory family, `pg`, `redis` w/ RediSearch)
|
|
2246
|
+
* override this with a real impl.
|
|
2247
|
+
*/
|
|
2248
|
+
async similar(_vector, _options) {
|
|
2249
|
+
throw new CacheUnsupportedError(`'${this.name}' driver does not support similarity retrieval. Use a memory driver, 'pg' (with pgvector), or 'redis' (with RediSearch).`);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Resolve the TTL-or-options arg of `lock` into a uniform shape.
|
|
2253
|
+
*/
|
|
2254
|
+
normalizeLockOptions(ttlOrOptions) {
|
|
2255
|
+
if (typeof ttlOrOptions === "number" || typeof ttlOrOptions === "string") return { ttl: ttlOrOptions };
|
|
2256
|
+
return {
|
|
2257
|
+
ttl: ttlOrOptions.ttl,
|
|
2258
|
+
owner: ttlOrOptions.owner
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
//#endregion
|
|
2264
|
+
//#region ../../@warlock.js/cache/src/drivers/file-cache-driver.ts
|
|
2265
|
+
var FileCacheDriver = class extends BaseCacheDriver {
|
|
2266
|
+
constructor(..._args) {
|
|
2267
|
+
super(..._args);
|
|
2268
|
+
this.name = "file";
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* {@inheritdoc}
|
|
2272
|
+
*/
|
|
2273
|
+
setOptions(options) {
|
|
2274
|
+
if (!options.directory) throw new CacheConfigurationError("File driver requires 'directory' option to be configured.");
|
|
2275
|
+
return super.setOptions(options);
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Get the cache directory
|
|
2279
|
+
*/
|
|
2280
|
+
get directory() {
|
|
2281
|
+
const directory = this.options.directory;
|
|
2282
|
+
if (typeof directory === "function") return directory();
|
|
2283
|
+
throw new CacheConfigurationError("Cache directory is not defined, please define it in the file driver options");
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Get file name
|
|
2287
|
+
*/
|
|
2288
|
+
get fileName() {
|
|
2289
|
+
const fileName = this.options.fileName;
|
|
2290
|
+
if (typeof fileName === "function") return fileName();
|
|
2291
|
+
return "cache.json";
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* {@inheritdoc}
|
|
2295
|
+
*/
|
|
2296
|
+
async removeNamespace(namespace) {
|
|
2297
|
+
this.log("clearing", namespace);
|
|
2298
|
+
try {
|
|
2299
|
+
await (0, _warlock_js_fs.removeDirectoryAsync)(path.default.resolve(this.directory, namespace));
|
|
2300
|
+
this.log("cleared", namespace);
|
|
2301
|
+
} catch (error) {}
|
|
2302
|
+
return this;
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* {@inheritdoc}
|
|
2306
|
+
*/
|
|
2307
|
+
async set(key, value, ttlOrOptions) {
|
|
2308
|
+
const parsedKey = this.parseKey(key);
|
|
2309
|
+
const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
2310
|
+
if (vector) throw new CacheUnsupportedError("'file' driver does not support similarity retrieval — use a memory driver, 'pg' (with pgvector), or 'redis' (with RediSearch).");
|
|
2311
|
+
this.log("caching", parsedKey);
|
|
2312
|
+
const existing = onConflict === "upsert" ? null : await this.get(key);
|
|
2313
|
+
const exists = existing !== null;
|
|
2314
|
+
if (onConflict === "create" && exists) return {
|
|
2315
|
+
wasSet: false,
|
|
2316
|
+
existing
|
|
2317
|
+
};
|
|
2318
|
+
if (onConflict === "update" && !exists) return {
|
|
2319
|
+
wasSet: false,
|
|
2320
|
+
existing: null
|
|
2321
|
+
};
|
|
2322
|
+
const data = this.prepareDataForStorage(value, ttl, staleAt);
|
|
2323
|
+
const fileDirectory = path.default.resolve(this.directory, parsedKey);
|
|
2324
|
+
await (0, _warlock_js_fs.ensureDirectoryAsync)(fileDirectory);
|
|
2325
|
+
await (0, _warlock_js_fs.putJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName), data);
|
|
2326
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
2327
|
+
this.log("cached", parsedKey);
|
|
2328
|
+
await this.emit("set", {
|
|
2329
|
+
key: parsedKey,
|
|
2330
|
+
value,
|
|
2331
|
+
ttl
|
|
2332
|
+
});
|
|
2333
|
+
if (onConflict === "create" || onConflict === "update") return {
|
|
2334
|
+
wasSet: true,
|
|
2335
|
+
existing: null
|
|
2336
|
+
};
|
|
2337
|
+
return this;
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* {@inheritdoc}
|
|
2341
|
+
*
|
|
2342
|
+
* File driver does not yet ship with a file-lock primitive, so concurrent
|
|
2343
|
+
* writers could clobber each other. Rather than ship an unsafe default, we
|
|
2344
|
+
* throw — consumers can fall back to memory/redis for `update` until a
|
|
2345
|
+
* proper file lock lands (tracked in `domains/cache/backlog.md`).
|
|
2346
|
+
*/
|
|
2347
|
+
async update() {
|
|
2348
|
+
throw new CacheUnsupportedError("`update()` is not supported on the file driver. Use the memory or redis driver, or wait for the file-lock primitive (see domains/cache/backlog.md).");
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* {@inheritdoc}
|
|
2352
|
+
*/
|
|
2353
|
+
async merge() {
|
|
2354
|
+
throw new CacheUnsupportedError("`merge()` is not supported on the file driver. Use the memory or redis driver.");
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Read the raw {@link CacheData} wrapper from disk, including `staleAt`
|
|
2358
|
+
* metadata. Returns `null` for missing or expired files — `swr()`
|
|
2359
|
+
* consumes this to branch on freshness.
|
|
2360
|
+
*/
|
|
2361
|
+
async getEntry(key) {
|
|
2362
|
+
const parsedKey = this.parseKey(key);
|
|
2363
|
+
const fileDirectory = path.default.resolve(this.directory, parsedKey);
|
|
2364
|
+
try {
|
|
2365
|
+
const entry = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
|
|
2366
|
+
if (!entry) return null;
|
|
2367
|
+
if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) return null;
|
|
2368
|
+
return entry;
|
|
2369
|
+
} catch {
|
|
2370
|
+
return null;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* {@inheritdoc}
|
|
2375
|
+
*/
|
|
2376
|
+
async get(key) {
|
|
2377
|
+
const parsedKey = this.parseKey(key);
|
|
2378
|
+
this.log("fetching", parsedKey);
|
|
2379
|
+
const fileDirectory = path.default.resolve(this.directory, parsedKey);
|
|
2380
|
+
try {
|
|
2381
|
+
const value = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
|
|
2382
|
+
const result = await this.parseCachedData(parsedKey, value);
|
|
2383
|
+
if (result === null) await this.emit("miss", { key: parsedKey });
|
|
2384
|
+
else await this.emit("hit", {
|
|
2385
|
+
key: parsedKey,
|
|
2386
|
+
value: result
|
|
2387
|
+
});
|
|
2388
|
+
return result;
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
this.log("notFound", parsedKey);
|
|
2391
|
+
await this.emit("miss", { key: parsedKey });
|
|
2392
|
+
await this.remove(key);
|
|
2393
|
+
return null;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* {@inheritdoc}
|
|
2398
|
+
*/
|
|
2399
|
+
async remove(key) {
|
|
2400
|
+
const parsedKey = this.parseKey(key);
|
|
2401
|
+
this.log("removing", parsedKey);
|
|
2402
|
+
const fileDirectory = path.default.resolve(this.directory, parsedKey);
|
|
2403
|
+
try {
|
|
2404
|
+
await (0, _warlock_js_fs.removeDirectoryAsync)(fileDirectory);
|
|
2405
|
+
this.log("removed", parsedKey);
|
|
2406
|
+
await this.emit("removed", { key: parsedKey });
|
|
2407
|
+
} catch (error) {}
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* {@inheritdoc}
|
|
2411
|
+
*/
|
|
2412
|
+
async flush() {
|
|
2413
|
+
this.log("flushing");
|
|
2414
|
+
if (this.options.globalPrefix) await this.removeNamespace("");
|
|
2415
|
+
else await (0, _warlock_js_fs.removeDirectoryAsync)(this.directory);
|
|
2416
|
+
this.log("flushed");
|
|
2417
|
+
await this.emit("flushed");
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* {@inheritdoc}
|
|
2421
|
+
*/
|
|
2422
|
+
async connect() {
|
|
2423
|
+
this.log("connecting");
|
|
2424
|
+
await (0, _warlock_js_fs.ensureDirectoryAsync)(this.directory);
|
|
2425
|
+
this.log("connected");
|
|
2426
|
+
await this.emit("connected");
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
//#endregion
|
|
2431
|
+
//#region ../../@warlock.js/cache/src/drivers/lru-memory-cache-driver.ts
|
|
2432
|
+
var CacheNode = class {
|
|
2433
|
+
constructor(key, value, ttl) {
|
|
2434
|
+
this.key = key;
|
|
2435
|
+
this.value = value;
|
|
2436
|
+
this.next = null;
|
|
2437
|
+
this.prev = null;
|
|
2438
|
+
if (ttl && ttl !== Infinity) this.expiresAt = Date.now() + ttl * 1e3;
|
|
2439
|
+
}
|
|
2440
|
+
get isExpired() {
|
|
2441
|
+
return this.expiresAt !== void 0 && this.expiresAt < Date.now();
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
/**
|
|
2445
|
+
* LRU Memory Cache Driver
|
|
2446
|
+
* The concept of LRU is to remove the least recently used data
|
|
2447
|
+
* whenever the cache is full
|
|
2448
|
+
* The question that resides here is how to tell the cache is full?
|
|
2449
|
+
*/
|
|
2450
|
+
var LRUMemoryCacheDriver = class extends BaseCacheDriver {
|
|
2451
|
+
/**
|
|
2452
|
+
* {@inheritdoc}
|
|
2453
|
+
*/
|
|
2454
|
+
constructor() {
|
|
2455
|
+
super();
|
|
2456
|
+
this.name = "lru";
|
|
2457
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
2458
|
+
this.head = new CacheNode("", null);
|
|
2459
|
+
this.tail = new CacheNode("", null);
|
|
2460
|
+
this.init();
|
|
2461
|
+
this.startCleanup();
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Initialize the cache
|
|
2465
|
+
*/
|
|
2466
|
+
init() {
|
|
2467
|
+
this.head.next = this.tail;
|
|
2468
|
+
this.tail.prev = this.head;
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Start the cleanup process for expired items
|
|
2472
|
+
*/
|
|
2473
|
+
startCleanup() {
|
|
2474
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
2475
|
+
this.cleanupInterval = setInterval(async () => {
|
|
2476
|
+
const now = Date.now();
|
|
2477
|
+
const expiredKeys = [];
|
|
2478
|
+
for (const [key, node] of this.cache) if (node.expiresAt && node.expiresAt <= now) expiredKeys.push(key);
|
|
2479
|
+
for (const key of expiredKeys) {
|
|
2480
|
+
const node = this.cache.get(key);
|
|
2481
|
+
if (node) {
|
|
2482
|
+
this.removeNode(node);
|
|
2483
|
+
this.cache.delete(key);
|
|
2484
|
+
this.log("expired", key);
|
|
2485
|
+
await this.emit("expired", { key });
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}, 1e3);
|
|
2489
|
+
this.cleanupInterval.unref();
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* {@inheritdoc}
|
|
2493
|
+
*
|
|
2494
|
+
* Clears every entry whose key starts with the parsed namespace (followed
|
|
2495
|
+
* by a dot) or equals it exactly. Called with an empty namespace while a
|
|
2496
|
+
* `globalPrefix` is configured, clears everything under the prefix — which
|
|
2497
|
+
* is how `flush()` scopes cleanup per tenant.
|
|
2498
|
+
*/
|
|
2499
|
+
async removeNamespace(namespace) {
|
|
2500
|
+
const parsedNamespace = this.parseKey(namespace);
|
|
2501
|
+
this.log("clearing", parsedNamespace || "(all)");
|
|
2502
|
+
const removed = [];
|
|
2503
|
+
if (parsedNamespace === "") for (const key of this.cache.keys()) removed.push(key);
|
|
2504
|
+
else {
|
|
2505
|
+
const prefix = parsedNamespace + ".";
|
|
2506
|
+
for (const key of this.cache.keys()) if (key === parsedNamespace || key.startsWith(prefix)) removed.push(key);
|
|
2507
|
+
}
|
|
2508
|
+
for (const key of removed) {
|
|
2509
|
+
const node = this.cache.get(key);
|
|
2510
|
+
if (node) {
|
|
2511
|
+
this.removeNode(node);
|
|
2512
|
+
this.cache.delete(key);
|
|
2513
|
+
}
|
|
2514
|
+
await this.emit("removed", { key });
|
|
2515
|
+
}
|
|
2516
|
+
this.log("cleared", parsedNamespace || "(all)");
|
|
2517
|
+
return removed;
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* {@inheritdoc}
|
|
2521
|
+
*/
|
|
2522
|
+
async set(key, value, ttlOrOptions) {
|
|
2523
|
+
const parsedKey = this.parseKey(key);
|
|
2524
|
+
const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
2525
|
+
this.log("caching", parsedKey);
|
|
2526
|
+
let existingNode = this.cache.get(parsedKey);
|
|
2527
|
+
if (existingNode && existingNode.isExpired) {
|
|
2528
|
+
this.removeNode(existingNode);
|
|
2529
|
+
this.cache.delete(parsedKey);
|
|
2530
|
+
existingNode = void 0;
|
|
2531
|
+
}
|
|
2532
|
+
const exists = Boolean(existingNode);
|
|
2533
|
+
if (onConflict === "create" && exists) return {
|
|
2534
|
+
wasSet: false,
|
|
2535
|
+
existing: existingNode.value
|
|
2536
|
+
};
|
|
2537
|
+
if (onConflict === "update" && !exists) return {
|
|
2538
|
+
wasSet: false,
|
|
2539
|
+
existing: null
|
|
2540
|
+
};
|
|
2541
|
+
if (existingNode) {
|
|
2542
|
+
existingNode.value = value;
|
|
2543
|
+
if (ttl && ttl !== Infinity) existingNode.expiresAt = Date.now() + ttl * 1e3;
|
|
2544
|
+
else existingNode.expiresAt = void 0;
|
|
2545
|
+
existingNode.staleAt = staleAt;
|
|
2546
|
+
if (vector) existingNode.vector = vector.slice();
|
|
2547
|
+
this.moveHead(existingNode);
|
|
2548
|
+
} else {
|
|
2549
|
+
const newNode = new CacheNode(parsedKey, value, ttl);
|
|
2550
|
+
newNode.staleAt = staleAt;
|
|
2551
|
+
if (vector) newNode.vector = vector.slice();
|
|
2552
|
+
this.cache.set(parsedKey, newNode);
|
|
2553
|
+
this.addNode(newNode);
|
|
2554
|
+
if (this.cache.size > this.capacity) this.removeTail();
|
|
2555
|
+
}
|
|
2556
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
2557
|
+
this.log("cached", parsedKey);
|
|
2558
|
+
await this.emit("set", {
|
|
2559
|
+
key: parsedKey,
|
|
2560
|
+
value,
|
|
2561
|
+
ttl
|
|
2562
|
+
});
|
|
2563
|
+
if (onConflict === "create" || onConflict === "update") return {
|
|
2564
|
+
wasSet: true,
|
|
2565
|
+
existing: null
|
|
2566
|
+
};
|
|
2567
|
+
return this;
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Move the node to the head
|
|
2571
|
+
*/
|
|
2572
|
+
moveHead(node) {
|
|
2573
|
+
this.removeNode(node);
|
|
2574
|
+
this.addNode(node);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Remove the node from the cache
|
|
2578
|
+
*/
|
|
2579
|
+
removeNode(node) {
|
|
2580
|
+
node.prev.next = node.next;
|
|
2581
|
+
node.next.prev = node.prev;
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* Add the node to the head
|
|
2585
|
+
*/
|
|
2586
|
+
addNode(node) {
|
|
2587
|
+
node.next = this.head.next;
|
|
2588
|
+
node.prev = this.head;
|
|
2589
|
+
this.head.next.prev = node;
|
|
2590
|
+
this.head.next = node;
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Remove the tail node
|
|
2594
|
+
*/
|
|
2595
|
+
removeTail() {
|
|
2596
|
+
const node = this.tail.prev;
|
|
2597
|
+
this.removeNode(node);
|
|
2598
|
+
this.cache.delete(node.key);
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Read the raw {@link CacheData} wrapper, including `staleAt` metadata.
|
|
2602
|
+
* Returns `null` for missing or expired nodes — `swr()` consumes this
|
|
2603
|
+
* to branch on freshness without going through `get()`'s clone-and-emit
|
|
2604
|
+
* path.
|
|
2605
|
+
*/
|
|
2606
|
+
async getEntry(key) {
|
|
2607
|
+
const parsedKey = this.parseKey(key);
|
|
2608
|
+
const node = this.cache.get(parsedKey);
|
|
2609
|
+
if (!node || node.isExpired) return null;
|
|
2610
|
+
return {
|
|
2611
|
+
data: node.value,
|
|
2612
|
+
expiresAt: node.expiresAt,
|
|
2613
|
+
staleAt: node.staleAt
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* {@inheritdoc}
|
|
2618
|
+
*/
|
|
2619
|
+
async get(key) {
|
|
2620
|
+
const parsedKey = this.parseKey(key);
|
|
2621
|
+
this.log("fetching", parsedKey);
|
|
2622
|
+
const node = this.cache.get(parsedKey);
|
|
2623
|
+
if (!node) {
|
|
2624
|
+
this.log("notFound", parsedKey);
|
|
2625
|
+
await this.emit("miss", { key: parsedKey });
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
if (node.isExpired) {
|
|
2629
|
+
this.removeNode(node);
|
|
2630
|
+
this.cache.delete(parsedKey);
|
|
2631
|
+
this.log("expired", parsedKey);
|
|
2632
|
+
await this.emit("expired", { key: parsedKey });
|
|
2633
|
+
await this.emit("miss", { key: parsedKey });
|
|
2634
|
+
return null;
|
|
2635
|
+
}
|
|
2636
|
+
this.moveHead(node);
|
|
2637
|
+
this.log("fetched", parsedKey);
|
|
2638
|
+
const value = node.value;
|
|
2639
|
+
if (value === null || value === void 0) return value;
|
|
2640
|
+
const type = typeof value;
|
|
2641
|
+
if (type === "string" || type === "number" || type === "boolean") {
|
|
2642
|
+
await this.emit("hit", {
|
|
2643
|
+
key: parsedKey,
|
|
2644
|
+
value
|
|
2645
|
+
});
|
|
2646
|
+
return value;
|
|
2647
|
+
}
|
|
2648
|
+
try {
|
|
2649
|
+
const clonedValue = structuredClone(value);
|
|
2650
|
+
await this.emit("hit", {
|
|
2651
|
+
key: parsedKey,
|
|
2652
|
+
value: clonedValue
|
|
2653
|
+
});
|
|
2654
|
+
return clonedValue;
|
|
2655
|
+
} catch (error) {
|
|
2656
|
+
this.logError(`Failed to clone cached value for ${parsedKey}`, error);
|
|
2657
|
+
throw error;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* {@inheritdoc}
|
|
2662
|
+
*/
|
|
2663
|
+
async remove(key) {
|
|
2664
|
+
const parsedKey = this.parseKey(key);
|
|
2665
|
+
this.log("removing", parsedKey);
|
|
2666
|
+
const node = this.cache.get(parsedKey);
|
|
2667
|
+
if (node) {
|
|
2668
|
+
this.removeNode(node);
|
|
2669
|
+
this.cache.delete(parsedKey);
|
|
2670
|
+
}
|
|
2671
|
+
this.log("removed", parsedKey);
|
|
2672
|
+
await this.emit("removed", { key: parsedKey });
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* {@inheritdoc}
|
|
2676
|
+
*
|
|
2677
|
+
* When a `globalPrefix` is configured, `flush` scopes itself to that prefix
|
|
2678
|
+
* so multi-tenant caches don't accidentally wipe sibling tenants. Without
|
|
2679
|
+
* a prefix, clears everything.
|
|
2680
|
+
*/
|
|
2681
|
+
async flush() {
|
|
2682
|
+
this.log("flushing");
|
|
2683
|
+
if (this.options.globalPrefix) await this.removeNamespace("");
|
|
2684
|
+
else {
|
|
2685
|
+
this.cache.clear();
|
|
2686
|
+
this.init();
|
|
2687
|
+
}
|
|
2688
|
+
this.log("flushed");
|
|
2689
|
+
await this.emit("flushed");
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* {@inheritdoc}
|
|
2693
|
+
*
|
|
2694
|
+
* Brute-force O(N) cosine similarity over every cached node that carries a
|
|
2695
|
+
* vector. Suitable for development and small in-memory knowledge bases —
|
|
2696
|
+
* not for production beyond ~10k entries.
|
|
2697
|
+
*
|
|
2698
|
+
* @warning Dev-only — O(N) per query.
|
|
2699
|
+
*/
|
|
2700
|
+
async similar(vector, options) {
|
|
2701
|
+
const tagFilter = await this.getKeysForTags(options.tags);
|
|
2702
|
+
const hits = [];
|
|
2703
|
+
for (const [parsedKey, node] of this.cache) {
|
|
2704
|
+
if (!node.vector) continue;
|
|
2705
|
+
if (node.isExpired) continue;
|
|
2706
|
+
if (tagFilter && !tagFilter.has(parsedKey)) continue;
|
|
2707
|
+
const score = cosineSimilarity(vector, node.vector);
|
|
2708
|
+
if (options.threshold !== void 0 && score < options.threshold) continue;
|
|
2709
|
+
let value = node.value;
|
|
2710
|
+
if (value !== null && value !== void 0) {
|
|
2711
|
+
const t = typeof value;
|
|
2712
|
+
if (t !== "string" && t !== "number" && t !== "boolean") value = structuredClone(value);
|
|
2713
|
+
}
|
|
2714
|
+
hits.push({
|
|
2715
|
+
key: parsedKey,
|
|
2716
|
+
value,
|
|
2717
|
+
score
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
hits.sort((a, b) => b.score - a.score);
|
|
2721
|
+
if (options.topK >= 0 && hits.length > options.topK) hits.length = options.topK;
|
|
2722
|
+
return hits;
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Get lru capacity
|
|
2726
|
+
*/
|
|
2727
|
+
get capacity() {
|
|
2728
|
+
return this.options.capacity || 1e3;
|
|
2729
|
+
}
|
|
2730
|
+
/**
|
|
2731
|
+
* {@inheritdoc}
|
|
2732
|
+
*/
|
|
2733
|
+
async disconnect() {
|
|
2734
|
+
if (this.cleanupInterval) {
|
|
2735
|
+
clearInterval(this.cleanupInterval);
|
|
2736
|
+
this.cleanupInterval = void 0;
|
|
2737
|
+
}
|
|
2738
|
+
await super.disconnect();
|
|
2739
|
+
}
|
|
2740
|
+
};
|
|
2741
|
+
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region ../../@warlock.js/cache/src/drivers/memory-cache-driver.ts
|
|
2744
|
+
var MemoryCacheDriver = class extends BaseCacheDriver {
|
|
2745
|
+
/**
|
|
2746
|
+
* {@inheritdoc}
|
|
2747
|
+
*/
|
|
2748
|
+
constructor() {
|
|
2749
|
+
super();
|
|
2750
|
+
this.name = "memory";
|
|
2751
|
+
this.data = {};
|
|
2752
|
+
this.temporaryData = {};
|
|
2753
|
+
this.accessOrder = [];
|
|
2754
|
+
this.vectorIndex = /* @__PURE__ */ new Map();
|
|
2755
|
+
this.startCleanup();
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Start the cleanup process whenever a data that has a cache key is set
|
|
2759
|
+
*/
|
|
2760
|
+
startCleanup() {
|
|
2761
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
2762
|
+
this.cleanupInterval = setInterval(async () => {
|
|
2763
|
+
const now = Date.now();
|
|
2764
|
+
for (const key in this.temporaryData) if (this.temporaryData[key].expiresAt <= now) {
|
|
2765
|
+
await this.remove(this.temporaryData[key].key);
|
|
2766
|
+
delete this.temporaryData[key];
|
|
2767
|
+
this.log("expired", key);
|
|
2768
|
+
await this.emit("expired", { key });
|
|
2769
|
+
}
|
|
2770
|
+
}, 1e3);
|
|
2771
|
+
this.cleanupInterval.unref();
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* {@inheritdoc}
|
|
2775
|
+
*/
|
|
2776
|
+
async removeNamespace(namespace) {
|
|
2777
|
+
this.log("clearing", namespace);
|
|
2778
|
+
namespace = this.parseKey(namespace);
|
|
2779
|
+
(0, _mongez_reinforcements.unset)(this.data, [namespace]);
|
|
2780
|
+
if (namespace === "") this.vectorIndex.clear();
|
|
2781
|
+
else {
|
|
2782
|
+
const prefix = namespace + ".";
|
|
2783
|
+
for (const k of [...this.vectorIndex.keys()]) if (k === namespace || k.startsWith(prefix)) this.vectorIndex.delete(k);
|
|
2784
|
+
}
|
|
2785
|
+
this.log("cleared", namespace);
|
|
2786
|
+
return this;
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* {@inheritdoc}
|
|
2790
|
+
*/
|
|
2791
|
+
async set(key, value, ttlOrOptions) {
|
|
2792
|
+
const parsedKey = this.parseKey(key);
|
|
2793
|
+
const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
2794
|
+
this.log("caching", parsedKey);
|
|
2795
|
+
const existingValue = onConflict === "upsert" ? null : await this.get(key);
|
|
2796
|
+
const exists = existingValue !== null;
|
|
2797
|
+
if (onConflict === "create" && exists) return {
|
|
2798
|
+
wasSet: false,
|
|
2799
|
+
existing: existingValue
|
|
2800
|
+
};
|
|
2801
|
+
if (onConflict === "update" && !exists) return {
|
|
2802
|
+
wasSet: false,
|
|
2803
|
+
existing: null
|
|
2804
|
+
};
|
|
2805
|
+
const data = this.prepareDataForStorage(value, ttl, staleAt);
|
|
2806
|
+
if (ttl) this.setTemporaryData(key, parsedKey, ttl);
|
|
2807
|
+
(0, _mongez_reinforcements.set)(this.data, parsedKey, data);
|
|
2808
|
+
this.trackAccess(parsedKey);
|
|
2809
|
+
if (!exists && this.options.maxSize) await this.enforceMaxSize();
|
|
2810
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
2811
|
+
if (vector) this.vectorIndex.set(parsedKey, vector.slice());
|
|
2812
|
+
this.log("cached", parsedKey);
|
|
2813
|
+
await this.emit("set", {
|
|
2814
|
+
key: parsedKey,
|
|
2815
|
+
value,
|
|
2816
|
+
ttl
|
|
2817
|
+
});
|
|
2818
|
+
if (onConflict === "create" || onConflict === "update") return {
|
|
2819
|
+
wasSet: true,
|
|
2820
|
+
existing: null
|
|
2821
|
+
};
|
|
2822
|
+
return this;
|
|
2823
|
+
}
|
|
2824
|
+
/**
|
|
2825
|
+
* {@inheritdoc}
|
|
2826
|
+
*/
|
|
2827
|
+
async get(key) {
|
|
2828
|
+
const parsedKey = this.parseKey(key);
|
|
2829
|
+
this.log("fetching", parsedKey);
|
|
2830
|
+
const value = (0, _mongez_reinforcements.get)(this.data, parsedKey);
|
|
2831
|
+
if (!value) {
|
|
2832
|
+
this.log("notFound", parsedKey);
|
|
2833
|
+
await this.emit("miss", { key: parsedKey });
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
const result = await this.parseCachedData(parsedKey, value);
|
|
2837
|
+
if (result === null) await this.emit("miss", { key: parsedKey });
|
|
2838
|
+
else {
|
|
2839
|
+
this.trackAccess(parsedKey);
|
|
2840
|
+
await this.emit("hit", {
|
|
2841
|
+
key: parsedKey,
|
|
2842
|
+
value: result
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
return result;
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Read the raw {@link CacheData} wrapper, including `staleAt` metadata.
|
|
2849
|
+
* Returns `null` for missing or expired entries so the SWR flow can branch
|
|
2850
|
+
* cleanly. Does not emit `hit`/`miss` events — that's `get()`'s job.
|
|
2851
|
+
*/
|
|
2852
|
+
async getEntry(key) {
|
|
2853
|
+
const parsedKey = this.parseKey(key);
|
|
2854
|
+
const entry = (0, _mongez_reinforcements.get)(this.data, parsedKey);
|
|
2855
|
+
if (!entry) return null;
|
|
2856
|
+
if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) return null;
|
|
2857
|
+
return entry;
|
|
2858
|
+
}
|
|
2859
|
+
/**
|
|
2860
|
+
* {@inheritdoc}
|
|
2861
|
+
*/
|
|
2862
|
+
async remove(key) {
|
|
2863
|
+
const parsedKey = this.parseKey(key);
|
|
2864
|
+
this.log("removing", parsedKey);
|
|
2865
|
+
(0, _mongez_reinforcements.unset)(this.data, [parsedKey]);
|
|
2866
|
+
delete this.temporaryData[parsedKey];
|
|
2867
|
+
this.removeFromAccessOrder(parsedKey);
|
|
2868
|
+
this.vectorIndex.delete(parsedKey);
|
|
2869
|
+
this.log("removed", parsedKey);
|
|
2870
|
+
await this.emit("removed", { key: parsedKey });
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* {@inheritdoc}
|
|
2874
|
+
*/
|
|
2875
|
+
async flush() {
|
|
2876
|
+
this.log("flushing");
|
|
2877
|
+
if (this.options.globalPrefix) this.removeNamespace("");
|
|
2878
|
+
else {
|
|
2879
|
+
this.data = {};
|
|
2880
|
+
this.accessOrder = [];
|
|
2881
|
+
this.vectorIndex.clear();
|
|
2882
|
+
}
|
|
2883
|
+
this.log("flushed");
|
|
2884
|
+
await this.emit("flushed");
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Set the temporary data
|
|
2888
|
+
*/
|
|
2889
|
+
setTemporaryData(key, parsedKey, ttl) {
|
|
2890
|
+
this.temporaryData[parsedKey] = {
|
|
2891
|
+
key: JSON.stringify(key),
|
|
2892
|
+
expiresAt: Date.now() + ttl * 1e3
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* Track access for LRU eviction
|
|
2897
|
+
*/
|
|
2898
|
+
trackAccess(key) {
|
|
2899
|
+
if (!this.options.maxSize) return;
|
|
2900
|
+
const index = this.accessOrder.indexOf(key);
|
|
2901
|
+
if (index > -1) this.accessOrder.splice(index, 1);
|
|
2902
|
+
this.accessOrder.push(key);
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Remove key from access order tracking
|
|
2906
|
+
*/
|
|
2907
|
+
removeFromAccessOrder(key) {
|
|
2908
|
+
const index = this.accessOrder.indexOf(key);
|
|
2909
|
+
if (index > -1) this.accessOrder.splice(index, 1);
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Enforce max size by evicting least recently used items.
|
|
2913
|
+
*
|
|
2914
|
+
* Recomputes the live cache size on every iteration — a single snapshot at
|
|
2915
|
+
* the top of the loop would go stale and cause this routine to evict every
|
|
2916
|
+
* entry in `accessOrder` (including the just-inserted key).
|
|
2917
|
+
*/
|
|
2918
|
+
async enforceMaxSize() {
|
|
2919
|
+
if (!this.options.maxSize) return;
|
|
2920
|
+
while (this.getCacheSize() > this.options.maxSize && this.accessOrder.length > 0) {
|
|
2921
|
+
const lruKey = this.accessOrder.shift();
|
|
2922
|
+
if (!lruKey) break;
|
|
2923
|
+
this.log("removing", lruKey);
|
|
2924
|
+
(0, _mongez_reinforcements.unset)(this.data, [lruKey]);
|
|
2925
|
+
delete this.temporaryData[lruKey];
|
|
2926
|
+
this.vectorIndex.delete(lruKey);
|
|
2927
|
+
this.log("removed", lruKey);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Get current cache size (number of cached items)
|
|
2932
|
+
*/
|
|
2933
|
+
getCacheSize() {
|
|
2934
|
+
return Object.keys(this.data).length;
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* {@inheritdoc}
|
|
2938
|
+
*
|
|
2939
|
+
* Brute-force O(N) cosine similarity over every entry that was written with
|
|
2940
|
+
* `set({ vector })`. Suitable for development and small in-memory knowledge
|
|
2941
|
+
* bases — not for production beyond ~10k entries. Use the `pg` driver
|
|
2942
|
+
* (with pgvector) or `redis` (with RediSearch) at scale.
|
|
2943
|
+
*
|
|
2944
|
+
* @warning Dev-only — O(N) per query.
|
|
2945
|
+
*/
|
|
2946
|
+
async similar(vector, options) {
|
|
2947
|
+
const tagFilter = await this.getKeysForTags(options.tags);
|
|
2948
|
+
const hits = [];
|
|
2949
|
+
for (const [parsedKey, stored] of this.vectorIndex) {
|
|
2950
|
+
if (tagFilter && !tagFilter.has(parsedKey)) continue;
|
|
2951
|
+
const value = await this.get(parsedKey);
|
|
2952
|
+
if (value === null) continue;
|
|
2953
|
+
const score = cosineSimilarity(vector, stored);
|
|
2954
|
+
if (options.threshold !== void 0 && score < options.threshold) continue;
|
|
2955
|
+
hits.push({
|
|
2956
|
+
key: parsedKey,
|
|
2957
|
+
value,
|
|
2958
|
+
score
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
hits.sort((a, b) => b.score - a.score);
|
|
2962
|
+
if (options.topK >= 0 && hits.length > options.topK) hits.length = options.topK;
|
|
2963
|
+
return hits;
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* {@inheritdoc}
|
|
2967
|
+
*/
|
|
2968
|
+
async disconnect() {
|
|
2969
|
+
if (this.cleanupInterval) {
|
|
2970
|
+
clearInterval(this.cleanupInterval);
|
|
2971
|
+
this.cleanupInterval = void 0;
|
|
2972
|
+
}
|
|
2973
|
+
await super.disconnect();
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
//#endregion
|
|
2978
|
+
//#region ../../@warlock.js/cache/src/drivers/memory-extended-cache-driver.ts
|
|
2979
|
+
var MemoryExtendedCacheDriver = class extends MemoryCacheDriver {
|
|
2980
|
+
constructor(..._args) {
|
|
2981
|
+
super(..._args);
|
|
2982
|
+
this.name = "memoryExtended";
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* {@inheritdoc}
|
|
2986
|
+
*/
|
|
2987
|
+
async get(key) {
|
|
2988
|
+
const parsedKey = this.parseKey(key);
|
|
2989
|
+
this.log("fetching", parsedKey);
|
|
2990
|
+
const value = (0, _mongez_reinforcements.get)(this.data, parsedKey);
|
|
2991
|
+
if (!value) {
|
|
2992
|
+
this.log("notFound", parsedKey);
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
const rawTtl = value.ttl ?? this.options.ttl;
|
|
2996
|
+
const ttl = rawTtl !== void 0 ? parseTtl(rawTtl) : void 0;
|
|
2997
|
+
if (ttl) {
|
|
2998
|
+
this.setTemporaryData(key, parsedKey, ttl);
|
|
2999
|
+
value.expiresAt = this.getExpiresAt(ttl);
|
|
3000
|
+
}
|
|
3001
|
+
return this.parseCachedData(parsedKey, value);
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
//#endregion
|
|
3006
|
+
//#region ../../@warlock.js/cache/src/drivers/mock-cache-driver.ts
|
|
3007
|
+
/**
|
|
3008
|
+
* In-memory cache driver with introspection helpers, intended for use as a
|
|
3009
|
+
* test double in downstream packages.
|
|
3010
|
+
*
|
|
3011
|
+
* **Role.** Drop-in replacement for any real driver in test setups, with
|
|
3012
|
+
* extra surface that makes behavioral assertions easy: every public op gets
|
|
3013
|
+
* recorded into {@link MockCacheDriver.callLog}, and {@link wasCalled} /
|
|
3014
|
+
* {@link getStored} / {@link reset} let tests verify side effects without
|
|
3015
|
+
* pulling in a real Redis / Postgres / file system.
|
|
3016
|
+
*
|
|
3017
|
+
* **Responsibility.**
|
|
3018
|
+
* - Owns: in-memory storage backed by a `Map`, the `callLog`, TTL handling
|
|
3019
|
+
* via `parseCachedData`, `onConflict` policies, and tag-index storage.
|
|
3020
|
+
* - Does NOT own: similarity retrieval (vectors are recorded into the
|
|
3021
|
+
* `callLog` but `similar()` throws — use `MemoryCacheDriver` for tests
|
|
3022
|
+
* that need real nearest-neighbor scoring), connection lifecycle
|
|
3023
|
+
* (no-op `connect`/`disconnect`), or eviction (no `maxSize`).
|
|
3024
|
+
*
|
|
3025
|
+
* Register it like any other driver — sub-paths are not part of the
|
|
3026
|
+
* package's export convention; the same single barrel ships it next to
|
|
3027
|
+
* the production drivers.
|
|
3028
|
+
*
|
|
3029
|
+
* @example
|
|
3030
|
+
* import { cache, MockCacheDriver } from "@warlock.js/cache";
|
|
3031
|
+
*
|
|
3032
|
+
* beforeEach(async () => {
|
|
3033
|
+
* cache.setCacheConfigurations({
|
|
3034
|
+
* default: "mock",
|
|
3035
|
+
* drivers: { mock: MockCacheDriver },
|
|
3036
|
+
* options: { mock: {} },
|
|
3037
|
+
* });
|
|
3038
|
+
* await cache.init();
|
|
3039
|
+
* });
|
|
3040
|
+
*
|
|
3041
|
+
* it("invalidates the user cache after update", async () => {
|
|
3042
|
+
* await userService.update(42, { name: "Jane" });
|
|
3043
|
+
*
|
|
3044
|
+
* const driver = cache.currentDriver as MockCacheDriver;
|
|
3045
|
+
* expect(driver.wasCalled("remove", "users.42")).toBe(true);
|
|
3046
|
+
* });
|
|
3047
|
+
*/
|
|
3048
|
+
var MockCacheDriver = class extends BaseCacheDriver {
|
|
3049
|
+
constructor(..._args) {
|
|
3050
|
+
super(..._args);
|
|
3051
|
+
this.name = "mock";
|
|
3052
|
+
this.options = {};
|
|
3053
|
+
this.storage = /* @__PURE__ */ new Map();
|
|
3054
|
+
this.callLog = [];
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Standard driver setup. Mirrors the null driver — no connection, no
|
|
3058
|
+
* resources to release.
|
|
3059
|
+
*/
|
|
3060
|
+
async connect() {
|
|
3061
|
+
this.recordCall("connect", void 0);
|
|
3062
|
+
await super.connect();
|
|
3063
|
+
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Standard driver teardown.
|
|
3066
|
+
*/
|
|
3067
|
+
async disconnect() {
|
|
3068
|
+
this.recordCall("disconnect", void 0);
|
|
3069
|
+
await super.disconnect();
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* Wipe everything under `namespace`. Matches `MemoryCacheDriver` semantics
|
|
3073
|
+
* — the namespace itself and any key with the namespace prefix is removed.
|
|
3074
|
+
*/
|
|
3075
|
+
async removeNamespace(namespace) {
|
|
3076
|
+
const parsed = this.parseKey(namespace);
|
|
3077
|
+
this.recordCall("removeNamespace", parsed, [namespace]);
|
|
3078
|
+
this.log("clearing", parsed);
|
|
3079
|
+
const prefix = parsed + ".";
|
|
3080
|
+
for (const key of [...this.storage.keys()]) if (key === parsed || key.startsWith(prefix)) this.storage.delete(key);
|
|
3081
|
+
this.log("cleared", parsed);
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Standard `set` with full `onConflict` support. Honors scope-default TTL
|
|
3085
|
+
* via {@link BaseCacheDriver.resolveSetOptions}.
|
|
3086
|
+
*/
|
|
3087
|
+
async set(key, value, ttlOrOptions) {
|
|
3088
|
+
const parsedKey = this.parseKey(key);
|
|
3089
|
+
const { ttl, tags, onConflict, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
3090
|
+
this.recordCall("set", parsedKey, [value, ttlOrOptions]);
|
|
3091
|
+
this.log("caching", parsedKey);
|
|
3092
|
+
const existing = onConflict === "upsert" ? null : await this.get(key);
|
|
3093
|
+
const exists = existing !== null;
|
|
3094
|
+
if (onConflict === "create" && exists) return {
|
|
3095
|
+
wasSet: false,
|
|
3096
|
+
existing
|
|
3097
|
+
};
|
|
3098
|
+
if (onConflict === "update" && !exists) return {
|
|
3099
|
+
wasSet: false,
|
|
3100
|
+
existing: null
|
|
3101
|
+
};
|
|
3102
|
+
const data = this.prepareDataForStorage(value, ttl, staleAt);
|
|
3103
|
+
this.storage.set(parsedKey, data);
|
|
3104
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
3105
|
+
this.log("cached", parsedKey);
|
|
3106
|
+
await this.emit("set", {
|
|
3107
|
+
key: parsedKey,
|
|
3108
|
+
value,
|
|
3109
|
+
ttl
|
|
3110
|
+
});
|
|
3111
|
+
if (onConflict === "create" || onConflict === "update") return {
|
|
3112
|
+
wasSet: true,
|
|
3113
|
+
existing: null
|
|
3114
|
+
};
|
|
3115
|
+
return value;
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Standard `get` with TTL handling. Emits `hit` / `miss` events to keep
|
|
3119
|
+
* downstream metrics tests realistic.
|
|
3120
|
+
*/
|
|
3121
|
+
async get(key) {
|
|
3122
|
+
const parsedKey = this.parseKey(key);
|
|
3123
|
+
this.recordCall("get", parsedKey);
|
|
3124
|
+
this.log("fetching", parsedKey);
|
|
3125
|
+
const data = this.storage.get(parsedKey);
|
|
3126
|
+
if (!data) {
|
|
3127
|
+
this.log("notFound", parsedKey);
|
|
3128
|
+
await this.emit("miss", { key: parsedKey });
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
const value = await this.parseCachedData(parsedKey, data);
|
|
3132
|
+
if (value === null) {
|
|
3133
|
+
this.storage.delete(parsedKey);
|
|
3134
|
+
await this.emit("miss", { key: parsedKey });
|
|
3135
|
+
return null;
|
|
3136
|
+
}
|
|
3137
|
+
await this.emit("hit", {
|
|
3138
|
+
key: parsedKey,
|
|
3139
|
+
value
|
|
3140
|
+
});
|
|
3141
|
+
return value;
|
|
3142
|
+
}
|
|
3143
|
+
/**
|
|
3144
|
+
* Read the raw {@link CacheData} wrapper from the in-memory `Map`,
|
|
3145
|
+
* including `staleAt` metadata. Returns `null` for missing or expired
|
|
3146
|
+
* entries — `swr()` consumes this to branch on freshness.
|
|
3147
|
+
*/
|
|
3148
|
+
async getEntry(key) {
|
|
3149
|
+
const parsedKey = this.parseKey(key);
|
|
3150
|
+
const entry = this.storage.get(parsedKey);
|
|
3151
|
+
if (!entry) return null;
|
|
3152
|
+
if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) return null;
|
|
3153
|
+
return entry;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Standard `remove` — drops the entry and emits the `removed` event.
|
|
3157
|
+
*/
|
|
3158
|
+
async remove(key) {
|
|
3159
|
+
const parsedKey = this.parseKey(key);
|
|
3160
|
+
this.recordCall("remove", parsedKey);
|
|
3161
|
+
this.log("removing", parsedKey);
|
|
3162
|
+
this.storage.delete(parsedKey);
|
|
3163
|
+
this.log("removed", parsedKey);
|
|
3164
|
+
await this.emit("removed", { key: parsedKey });
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Standard `flush` — wipes the entire mock store + tag index. Does NOT
|
|
3168
|
+
* touch the call log; use {@link reset} to clear that as well.
|
|
3169
|
+
*/
|
|
3170
|
+
async flush() {
|
|
3171
|
+
this.recordCall("flush", void 0);
|
|
3172
|
+
this.log("flushing");
|
|
3173
|
+
this.storage.clear();
|
|
3174
|
+
this.log("flushed");
|
|
3175
|
+
await this.emit("flushed");
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Was a given operation invoked? When `key` is provided, the match is
|
|
3179
|
+
* post-`parseKey` so callers pass the same key shape they used at the
|
|
3180
|
+
* call site — strings or objects, both resolve to the same parsed key.
|
|
3181
|
+
*
|
|
3182
|
+
* @example
|
|
3183
|
+
* driver.wasCalled("set"); // any set
|
|
3184
|
+
* driver.wasCalled("set", "users.42"); // set on this specific key
|
|
3185
|
+
* driver.wasCalled("set", { id: 42 }); // same — object key normalized
|
|
3186
|
+
*/
|
|
3187
|
+
wasCalled(operation, key) {
|
|
3188
|
+
if (key === void 0) return this.callLog.some((call) => call.operation === operation);
|
|
3189
|
+
const parsedKey = this.parseKey(key);
|
|
3190
|
+
return this.callLog.some((call) => call.operation === operation && call.key === parsedKey);
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Return the raw stored value for `key`, bypassing TTL handling and clone
|
|
3194
|
+
* protection. Useful when a test wants to assert on the persisted shape
|
|
3195
|
+
* (or assert that an entry expired without going through `get`).
|
|
3196
|
+
*
|
|
3197
|
+
* Returns `undefined` when the key isn't present.
|
|
3198
|
+
*/
|
|
3199
|
+
getStored(key) {
|
|
3200
|
+
const parsedKey = this.parseKey(key);
|
|
3201
|
+
const entry = this.storage.get(parsedKey);
|
|
3202
|
+
if (!entry) return;
|
|
3203
|
+
return entry.data;
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* Wipe everything — storage, tag index, and the call log. Pair with
|
|
3207
|
+
* Vitest's `beforeEach` to get clean isolation between tests.
|
|
3208
|
+
*/
|
|
3209
|
+
reset() {
|
|
3210
|
+
this.storage.clear();
|
|
3211
|
+
this.callLog.length = 0;
|
|
3212
|
+
}
|
|
3213
|
+
/**
|
|
3214
|
+
* Append a row to {@link callLog}. Internal helper called by every
|
|
3215
|
+
* recorded op before the actual work runs.
|
|
3216
|
+
*/
|
|
3217
|
+
recordCall(operation, key, args = []) {
|
|
3218
|
+
this.callLog.push({
|
|
3219
|
+
operation,
|
|
3220
|
+
key,
|
|
3221
|
+
args,
|
|
3222
|
+
timestamp: Date.now()
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
//#endregion
|
|
3228
|
+
//#region ../../@warlock.js/cache/src/drivers/null-cache-driver.ts
|
|
3229
|
+
var NullCacheDriver = class extends BaseCacheDriver {
|
|
3230
|
+
/**
|
|
3231
|
+
* {@inheritdoc}
|
|
3232
|
+
*/
|
|
3233
|
+
get client() {
|
|
3234
|
+
return this;
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* Constructor
|
|
3238
|
+
*/
|
|
3239
|
+
constructor(options = {}) {
|
|
3240
|
+
super();
|
|
3241
|
+
this.options = {};
|
|
3242
|
+
this.name = "null";
|
|
3243
|
+
this.data = {};
|
|
3244
|
+
this.setOptions(options);
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* {@inheritdoc}
|
|
3248
|
+
*/
|
|
3249
|
+
setOptions(options) {
|
|
3250
|
+
this.options = options;
|
|
3251
|
+
return this;
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* {@inheritdoc}
|
|
3255
|
+
*/
|
|
3256
|
+
parseKey(_key) {
|
|
3257
|
+
return "";
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* {@inheritdoc}
|
|
3261
|
+
*/
|
|
3262
|
+
async removeNamespace(namespace) {
|
|
3263
|
+
_warlock_js_logger.log.info("cache", "clearing namespace", namespace);
|
|
3264
|
+
_warlock_js_logger.log.success("cache", "namespace cleared", namespace);
|
|
3265
|
+
return this;
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* {@inheritdoc}
|
|
3269
|
+
*/
|
|
3270
|
+
async set(key, _value, _ttlOrOptions) {
|
|
3271
|
+
_warlock_js_logger.log.info("cache", "setting key", key);
|
|
3272
|
+
_warlock_js_logger.log.success("cache", "key set", key);
|
|
3273
|
+
return this;
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* {@inheritdoc}
|
|
3277
|
+
*/
|
|
3278
|
+
async get(key) {
|
|
3279
|
+
_warlock_js_logger.log.info("cache", "fetching", key);
|
|
3280
|
+
_warlock_js_logger.log.success("cache", "fetched", key);
|
|
3281
|
+
return null;
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* {@inheritdoc}
|
|
3285
|
+
*/
|
|
3286
|
+
async remove(key) {
|
|
3287
|
+
_warlock_js_logger.log.info("cache", "removing", key);
|
|
3288
|
+
_warlock_js_logger.log.success("cache", "removed", key);
|
|
3289
|
+
}
|
|
3290
|
+
/**
|
|
3291
|
+
* {@inheritdoc}
|
|
3292
|
+
*/
|
|
3293
|
+
async flush() {
|
|
3294
|
+
_warlock_js_logger.log.info("cache", "flushing", "all");
|
|
3295
|
+
_warlock_js_logger.log.success("cache", "flushed", "all");
|
|
3296
|
+
}
|
|
3297
|
+
/**
|
|
3298
|
+
* {@inheritdoc}
|
|
3299
|
+
*
|
|
3300
|
+
* The null driver is a black-hole — `similar()` mirrors `get()` and returns
|
|
3301
|
+
* an empty result set rather than throwing.
|
|
3302
|
+
*/
|
|
3303
|
+
async similar() {
|
|
3304
|
+
return [];
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* {@inheritdoc}
|
|
3308
|
+
*/
|
|
3309
|
+
async connect() {
|
|
3310
|
+
_warlock_js_logger.log.success("cache", "connected", "Connected to null cache driver");
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
|
|
3314
|
+
//#endregion
|
|
3315
|
+
//#region ../../@warlock.js/cache/src/drivers/pg-cache-driver.ts
|
|
3316
|
+
/**
|
|
3317
|
+
* Allowed characters in a Postgres identifier (table name). We accept the
|
|
3318
|
+
* conservative ASCII subset and reject anything else — interpolating an
|
|
3319
|
+
* arbitrary string into DDL would be a SQL-injection footgun.
|
|
3320
|
+
*/
|
|
3321
|
+
const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3322
|
+
/**
|
|
3323
|
+
* Postgres cache driver with optional pgvector similarity support.
|
|
3324
|
+
*
|
|
3325
|
+
* Connection lifecycle is the caller's responsibility — pass an already-built
|
|
3326
|
+
* `pg.Pool` or `pg.Client` via the `client` option. The driver never closes it
|
|
3327
|
+
* on `cache.disconnect()`, so the same pool can serve queries elsewhere in
|
|
3328
|
+
* the app.
|
|
3329
|
+
*
|
|
3330
|
+
* Schema is not auto-migrated. Call `driver.schema()` to get the DDL string
|
|
3331
|
+
* and run it through whichever migration tool you use.
|
|
3332
|
+
*
|
|
3333
|
+
* @example
|
|
3334
|
+
* import { Pool } from "pg";
|
|
3335
|
+
* import { cache, PgCacheDriver } from "@warlock.js/cache";
|
|
3336
|
+
*
|
|
3337
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
3338
|
+
*
|
|
3339
|
+
* cache.setCacheConfigurations({
|
|
3340
|
+
* default: "pg",
|
|
3341
|
+
* drivers: { pg: PgCacheDriver },
|
|
3342
|
+
* options: { pg: { client: pool, table: "warlock_cache", ttl: "1h" } },
|
|
3343
|
+
* });
|
|
3344
|
+
*
|
|
3345
|
+
* await cache.init();
|
|
3346
|
+
*
|
|
3347
|
+
* // Run once, via your own migration tooling:
|
|
3348
|
+
* // await pool.query(driver.schema());
|
|
3349
|
+
*/
|
|
3350
|
+
var PgCacheDriver = class extends BaseCacheDriver {
|
|
3351
|
+
constructor(..._args) {
|
|
3352
|
+
super(..._args);
|
|
3353
|
+
this.name = "pg";
|
|
3354
|
+
}
|
|
3355
|
+
/**
|
|
3356
|
+
* {@inheritdoc}
|
|
3357
|
+
*
|
|
3358
|
+
* Validates `client` presence and the table name before storing options.
|
|
3359
|
+
*/
|
|
3360
|
+
setOptions(options) {
|
|
3361
|
+
if (!options || !options.client || typeof options.client.query !== "function") throw new CacheConfigurationError("Pg cache driver requires a 'client' option implementing { query(text, values) } — pass a pg.Pool or pg.Client.");
|
|
3362
|
+
const table = options.table ?? "warlock_cache";
|
|
3363
|
+
if (!SAFE_IDENT.test(table)) throw new CacheConfigurationError(`Pg cache driver: invalid table name '${table}'. Allowed: [A-Za-z_][A-Za-z0-9_]*.`);
|
|
3364
|
+
if (options.vector) {
|
|
3365
|
+
const dim = options.vector.dimensions;
|
|
3366
|
+
if (!Number.isInteger(dim) || dim <= 0) throw new CacheConfigurationError(`Pg cache driver: vector.dimensions must be a positive integer; got ${dim}.`);
|
|
3367
|
+
const idx = options.vector.index ?? "hnsw";
|
|
3368
|
+
if (idx !== "hnsw" && idx !== "ivfflat") throw new CacheConfigurationError(`Pg cache driver: vector.index must be 'hnsw' or 'ivfflat'; got '${idx}'.`);
|
|
3369
|
+
}
|
|
3370
|
+
return super.setOptions({
|
|
3371
|
+
...options,
|
|
3372
|
+
table
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Lazy pgvector availability check. Runs once on the first vector op and
|
|
3377
|
+
* caches the result — subsequent ops are zero-overhead. Throws
|
|
3378
|
+
* {@link CacheConfigurationError} if the extension isn't installed; throws
|
|
3379
|
+
* {@link CacheUnsupportedError} if `vector` config wasn't provided at all.
|
|
3380
|
+
*/
|
|
3381
|
+
async ensureVectorReady() {
|
|
3382
|
+
if (!this.options.vector) throw new CacheUnsupportedError("'pg' driver: similarity retrieval requires the 'vector' config block. Set options.vector.dimensions and reconnect.");
|
|
3383
|
+
if (this.vectorReady === true) return;
|
|
3384
|
+
const { rows } = await this.pgClient.query(`SELECT 1 FROM pg_extension WHERE extname = 'vector'`);
|
|
3385
|
+
if (rows.length === 0) throw new CacheConfigurationError("'pg' driver: pgvector extension not installed. Run 'CREATE EXTENSION vector;' or remove the 'vector' config option.");
|
|
3386
|
+
this.vectorReady = true;
|
|
3387
|
+
}
|
|
3388
|
+
/**
|
|
3389
|
+
* Format a numeric vector for pgvector ingestion. The `vector` type accepts
|
|
3390
|
+
* a string literal `'[1,2,3]'` cast via `::vector` — this avoids depending
|
|
3391
|
+
* on the binary protocol and works against any pg client.
|
|
3392
|
+
*/
|
|
3393
|
+
formatVector(vector) {
|
|
3394
|
+
return `[${vector.join(",")}]`;
|
|
3395
|
+
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Resolved table name. Always defined post-`setOptions` — the validator
|
|
3398
|
+
* fills in the default.
|
|
3399
|
+
*/
|
|
3400
|
+
get table() {
|
|
3401
|
+
return this.options.table ?? "warlock_cache";
|
|
3402
|
+
}
|
|
3403
|
+
/**
|
|
3404
|
+
* The user-supplied `pg.Pool` / `pg.Client`. Use this rather than `this.client`
|
|
3405
|
+
* (which has a generic fallback to `this`) for actual queries.
|
|
3406
|
+
*/
|
|
3407
|
+
get pgClient() {
|
|
3408
|
+
return this.options.client;
|
|
3409
|
+
}
|
|
3410
|
+
/**
|
|
3411
|
+
* Compute an absolute `expires_at` Date for the given relative TTL in seconds,
|
|
3412
|
+
* or `null` when the entry should not expire (`Infinity` / 0 / undefined).
|
|
3413
|
+
*/
|
|
3414
|
+
ttlToExpiresAt(ttl) {
|
|
3415
|
+
if (!ttl || ttl === Infinity) return null;
|
|
3416
|
+
return new Date(Date.now() + ttl * 1e3);
|
|
3417
|
+
}
|
|
3418
|
+
/**
|
|
3419
|
+
* Return the SQL needed to provision the cache table + index. Run once via
|
|
3420
|
+
* the caller's migration tooling — the driver never auto-migrates.
|
|
3421
|
+
*
|
|
3422
|
+
* @example
|
|
3423
|
+
* await pool.query(driver.schema());
|
|
3424
|
+
*/
|
|
3425
|
+
schema() {
|
|
3426
|
+
const t = this.table;
|
|
3427
|
+
const vec = this.options.vector;
|
|
3428
|
+
const columns = [
|
|
3429
|
+
` key TEXT PRIMARY KEY,`,
|
|
3430
|
+
` value JSONB NOT NULL,`,
|
|
3431
|
+
` expires_at TIMESTAMPTZ,`,
|
|
3432
|
+
` stale_at TIMESTAMPTZ,`,
|
|
3433
|
+
` tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[]`
|
|
3434
|
+
];
|
|
3435
|
+
if (vec) {
|
|
3436
|
+
columns[columns.length - 1] = columns[columns.length - 1] + ",";
|
|
3437
|
+
columns.push(` embedding VECTOR(${vec.dimensions})`);
|
|
3438
|
+
}
|
|
3439
|
+
const lines = [
|
|
3440
|
+
`CREATE TABLE IF NOT EXISTS ${t} (`,
|
|
3441
|
+
...columns,
|
|
3442
|
+
`);`,
|
|
3443
|
+
`CREATE INDEX IF NOT EXISTS idx_${t}_expires_at ON ${t} (expires_at);`,
|
|
3444
|
+
`CREATE INDEX IF NOT EXISTS idx_${t}_tags ON ${t} USING GIN (tags);`
|
|
3445
|
+
];
|
|
3446
|
+
if (vec) {
|
|
3447
|
+
const idx = vec.index ?? "hnsw";
|
|
3448
|
+
lines.push(`CREATE INDEX IF NOT EXISTS idx_${t}_embedding ON ${t} USING ${idx} (embedding vector_cosine_ops);`);
|
|
3449
|
+
}
|
|
3450
|
+
return lines.join("\n");
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* {@inheritdoc}
|
|
3454
|
+
*/
|
|
3455
|
+
async connect() {
|
|
3456
|
+
this.log("connecting");
|
|
3457
|
+
this.log("connected");
|
|
3458
|
+
await this.emit("connected");
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* {@inheritdoc}
|
|
3462
|
+
*
|
|
3463
|
+
* Does NOT close the user-supplied client — lifecycle stays with the caller.
|
|
3464
|
+
*/
|
|
3465
|
+
async disconnect() {
|
|
3466
|
+
this.log("disconnected");
|
|
3467
|
+
await this.emit("disconnected");
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* {@inheritdoc}
|
|
3471
|
+
*/
|
|
3472
|
+
async set(key, value, ttlOrOptions) {
|
|
3473
|
+
const parsedKey = this.parseKey(key);
|
|
3474
|
+
const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
3475
|
+
if (vector) {
|
|
3476
|
+
if (!this.options.vector) throw new CacheUnsupportedError("'pg' driver: cannot index a vector without options.vector configuration — set { dimensions } and recreate the table via driver.schema().");
|
|
3477
|
+
const expected = this.options.vector.dimensions;
|
|
3478
|
+
if (vector.length !== expected) throw new CacheConfigurationError(`Pg cache driver: vector dimension mismatch — expected ${expected}, got ${vector.length}.`);
|
|
3479
|
+
await this.ensureVectorReady();
|
|
3480
|
+
}
|
|
3481
|
+
this.log("caching", parsedKey);
|
|
3482
|
+
const expiresAt = this.ttlToExpiresAt(ttl);
|
|
3483
|
+
const staleAtDate = staleAt !== void 0 ? new Date(staleAt) : null;
|
|
3484
|
+
const tagsArr = tags ?? [];
|
|
3485
|
+
const serialized = JSON.stringify(value);
|
|
3486
|
+
const vecLiteral = vector ? this.formatVector(vector) : null;
|
|
3487
|
+
const t = this.table;
|
|
3488
|
+
const cols = [
|
|
3489
|
+
"key",
|
|
3490
|
+
"value",
|
|
3491
|
+
"expires_at",
|
|
3492
|
+
"stale_at",
|
|
3493
|
+
"tags"
|
|
3494
|
+
];
|
|
3495
|
+
const placeholders = [
|
|
3496
|
+
"$1",
|
|
3497
|
+
"$2::jsonb",
|
|
3498
|
+
"$3",
|
|
3499
|
+
"$4",
|
|
3500
|
+
"$5"
|
|
3501
|
+
];
|
|
3502
|
+
const params = [
|
|
3503
|
+
parsedKey,
|
|
3504
|
+
serialized,
|
|
3505
|
+
expiresAt,
|
|
3506
|
+
staleAtDate,
|
|
3507
|
+
tagsArr
|
|
3508
|
+
];
|
|
3509
|
+
if (vecLiteral !== null) {
|
|
3510
|
+
cols.push("embedding");
|
|
3511
|
+
placeholders.push(`$${params.length + 1}::vector`);
|
|
3512
|
+
params.push(vecLiteral);
|
|
3513
|
+
}
|
|
3514
|
+
const colList = cols.join(", ");
|
|
3515
|
+
const valList = placeholders.join(", ");
|
|
3516
|
+
const setClause = cols.slice(1).map((c) => `${c} = EXCLUDED.${c}`).join(", ");
|
|
3517
|
+
const updateSetClause = cols.slice(1).map((c, i) => `${c} = ${placeholders[i + 1]}`).join(", ");
|
|
3518
|
+
if (onConflict === "create") {
|
|
3519
|
+
const { rows } = await this.pgClient.query(`INSERT INTO ${t}(${colList})
|
|
3520
|
+
VALUES (${valList})
|
|
3521
|
+
ON CONFLICT (key) DO UPDATE
|
|
3522
|
+
SET ${setClause}
|
|
3523
|
+
WHERE ${t}.expires_at IS NOT NULL AND ${t}.expires_at < now()
|
|
3524
|
+
RETURNING value`, params);
|
|
3525
|
+
if (rows.length === 0) return {
|
|
3526
|
+
wasSet: false,
|
|
3527
|
+
existing: await this.get(key)
|
|
3528
|
+
};
|
|
3529
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
3530
|
+
this.log("cached", parsedKey);
|
|
3531
|
+
await this.emit("set", {
|
|
3532
|
+
key: parsedKey,
|
|
3533
|
+
value,
|
|
3534
|
+
ttl
|
|
3535
|
+
});
|
|
3536
|
+
return {
|
|
3537
|
+
wasSet: true,
|
|
3538
|
+
existing: null
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
if (onConflict === "update") {
|
|
3542
|
+
const { rows } = await this.pgClient.query(`UPDATE ${t}
|
|
3543
|
+
SET ${updateSetClause}
|
|
3544
|
+
WHERE key = $1 AND (expires_at IS NULL OR expires_at > now())
|
|
3545
|
+
RETURNING value`, params);
|
|
3546
|
+
if (rows.length === 0) return {
|
|
3547
|
+
wasSet: false,
|
|
3548
|
+
existing: null
|
|
3549
|
+
};
|
|
3550
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
3551
|
+
this.log("cached", parsedKey);
|
|
3552
|
+
await this.emit("set", {
|
|
3553
|
+
key: parsedKey,
|
|
3554
|
+
value,
|
|
3555
|
+
ttl
|
|
3556
|
+
});
|
|
3557
|
+
return {
|
|
3558
|
+
wasSet: true,
|
|
3559
|
+
existing: null
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
await this.pgClient.query(`INSERT INTO ${t}(${colList})
|
|
3563
|
+
VALUES (${valList})
|
|
3564
|
+
ON CONFLICT (key) DO UPDATE
|
|
3565
|
+
SET ${setClause}`, params);
|
|
3566
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
3567
|
+
this.log("cached", parsedKey);
|
|
3568
|
+
await this.emit("set", {
|
|
3569
|
+
key: parsedKey,
|
|
3570
|
+
value,
|
|
3571
|
+
ttl
|
|
3572
|
+
});
|
|
3573
|
+
return value;
|
|
3574
|
+
}
|
|
3575
|
+
/**
|
|
3576
|
+
* {@inheritdoc}
|
|
3577
|
+
*/
|
|
3578
|
+
async get(key) {
|
|
3579
|
+
const parsedKey = this.parseKey(key);
|
|
3580
|
+
this.log("fetching", parsedKey);
|
|
3581
|
+
const t = this.table;
|
|
3582
|
+
const { rows } = await this.pgClient.query(`SELECT value FROM ${t}
|
|
3583
|
+
WHERE key = $1 AND (expires_at IS NULL OR expires_at > now())`, [parsedKey]);
|
|
3584
|
+
if (rows.length === 0) {
|
|
3585
|
+
this.log("notFound", parsedKey);
|
|
3586
|
+
await this.emit("miss", { key: parsedKey });
|
|
3587
|
+
return null;
|
|
3588
|
+
}
|
|
3589
|
+
this.log("fetched", parsedKey);
|
|
3590
|
+
let value = rows[0].value;
|
|
3591
|
+
if (typeof value === "string") try {
|
|
3592
|
+
value = JSON.parse(value);
|
|
3593
|
+
} catch {}
|
|
3594
|
+
if (value === null || value === void 0) {
|
|
3595
|
+
await this.emit("hit", {
|
|
3596
|
+
key: parsedKey,
|
|
3597
|
+
value
|
|
3598
|
+
});
|
|
3599
|
+
return value;
|
|
3600
|
+
}
|
|
3601
|
+
const type = typeof value;
|
|
3602
|
+
if (type === "string" || type === "number" || type === "boolean") {
|
|
3603
|
+
await this.emit("hit", {
|
|
3604
|
+
key: parsedKey,
|
|
3605
|
+
value
|
|
3606
|
+
});
|
|
3607
|
+
return value;
|
|
3608
|
+
}
|
|
3609
|
+
try {
|
|
3610
|
+
const cloned = structuredClone(value);
|
|
3611
|
+
await this.emit("hit", {
|
|
3612
|
+
key: parsedKey,
|
|
3613
|
+
value: cloned
|
|
3614
|
+
});
|
|
3615
|
+
return cloned;
|
|
3616
|
+
} catch (error) {
|
|
3617
|
+
this.logError(`Failed to clone cached value for ${parsedKey}`, error);
|
|
3618
|
+
throw error;
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* Read the raw {@link CacheData} wrapper, including `staleAt` metadata.
|
|
3623
|
+
* Returns `null` for missing or expired rows — `swr()` consumes this to
|
|
3624
|
+
* branch on freshness without going through `get()`'s clone-and-emit path.
|
|
3625
|
+
*/
|
|
3626
|
+
async getEntry(key) {
|
|
3627
|
+
const parsedKey = this.parseKey(key);
|
|
3628
|
+
const t = this.table;
|
|
3629
|
+
const { rows } = await this.pgClient.query(`SELECT value, expires_at, stale_at FROM ${t}
|
|
3630
|
+
WHERE key = $1 AND (expires_at IS NULL OR expires_at > now())`, [parsedKey]);
|
|
3631
|
+
if (rows.length === 0) return null;
|
|
3632
|
+
let data = rows[0].value;
|
|
3633
|
+
if (typeof data === "string") try {
|
|
3634
|
+
data = JSON.parse(data);
|
|
3635
|
+
} catch {}
|
|
3636
|
+
const entry = { data };
|
|
3637
|
+
const expiresAtRaw = rows[0].expires_at;
|
|
3638
|
+
if (expiresAtRaw) entry.expiresAt = (expiresAtRaw instanceof Date ? expiresAtRaw : new Date(expiresAtRaw)).getTime();
|
|
3639
|
+
const staleAtRaw = rows[0].stale_at;
|
|
3640
|
+
if (staleAtRaw) entry.staleAt = (staleAtRaw instanceof Date ? staleAtRaw : new Date(staleAtRaw)).getTime();
|
|
3641
|
+
return entry;
|
|
3642
|
+
}
|
|
3643
|
+
/**
|
|
3644
|
+
* {@inheritdoc}
|
|
3645
|
+
*/
|
|
3646
|
+
async remove(key) {
|
|
3647
|
+
const parsedKey = this.parseKey(key);
|
|
3648
|
+
this.log("removing", parsedKey);
|
|
3649
|
+
const t = this.table;
|
|
3650
|
+
await this.pgClient.query(`DELETE FROM ${t} WHERE key = $1`, [parsedKey]);
|
|
3651
|
+
this.log("removed", parsedKey);
|
|
3652
|
+
await this.emit("removed", { key: parsedKey });
|
|
3653
|
+
}
|
|
3654
|
+
/**
|
|
3655
|
+
* {@inheritdoc}
|
|
3656
|
+
*
|
|
3657
|
+
* Deletes all rows whose key equals the namespace exactly or starts with
|
|
3658
|
+
* `<namespace>.` — same boundary semantics as the other drivers.
|
|
3659
|
+
*/
|
|
3660
|
+
async removeNamespace(namespace) {
|
|
3661
|
+
const parsed = this.parseKey(namespace);
|
|
3662
|
+
this.log("clearing", parsed || "(all)");
|
|
3663
|
+
const t = this.table;
|
|
3664
|
+
if (parsed === "") await this.pgClient.query(`DELETE FROM ${t}`);
|
|
3665
|
+
else {
|
|
3666
|
+
const escaped = parsed.replace(/\\/g, "\\\\").replace(/_/g, "\\_").replace(/%/g, "\\%");
|
|
3667
|
+
await this.pgClient.query(`DELETE FROM ${t} WHERE key = $1 OR key LIKE $2 ESCAPE '\\'`, [parsed, `${escaped}.%`]);
|
|
3668
|
+
}
|
|
3669
|
+
this.log("cleared", parsed || "(all)");
|
|
3670
|
+
return this;
|
|
3671
|
+
}
|
|
3672
|
+
/**
|
|
3673
|
+
* {@inheritdoc}
|
|
3674
|
+
*
|
|
3675
|
+
* Honors `globalPrefix` — when configured, scopes the flush to entries
|
|
3676
|
+
* under the prefix rather than truncating the entire table (which could
|
|
3677
|
+
* wipe sibling tenants sharing the same Postgres database).
|
|
3678
|
+
*/
|
|
3679
|
+
async flush() {
|
|
3680
|
+
this.log("flushing");
|
|
3681
|
+
if (this.options.globalPrefix) await this.removeNamespace("");
|
|
3682
|
+
else await this.pgClient.query(`DELETE FROM ${this.table}`);
|
|
3683
|
+
this.log("flushed");
|
|
3684
|
+
await this.emit("flushed");
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* {@inheritdoc}
|
|
3688
|
+
*
|
|
3689
|
+
* pgvector-backed similarity. Uses the `<=>` cosine-distance operator
|
|
3690
|
+
* (lower distance = higher similarity) and converts to cosine similarity
|
|
3691
|
+
* as `1 - distance` so the returned `score` matches the rest of the
|
|
3692
|
+
* package (`[0, 1]`, higher is more similar).
|
|
3693
|
+
*
|
|
3694
|
+
* Honors `topK`, `threshold`, and an optional `tags` filter (native
|
|
3695
|
+
* `tags && $tags` overlap query — much faster than the meta-key path).
|
|
3696
|
+
*
|
|
3697
|
+
* Throws {@link CacheUnsupportedError} when `options.vector` was not
|
|
3698
|
+
* configured at driver setup; throws {@link CacheConfigurationError} when
|
|
3699
|
+
* the pgvector extension is missing or the query vector's dimension count
|
|
3700
|
+
* doesn't match the configured one.
|
|
3701
|
+
*/
|
|
3702
|
+
async similar(vector, options) {
|
|
3703
|
+
if (!this.options.vector) throw new CacheUnsupportedError("'pg' driver: similarity retrieval requires the 'vector' config block. Set options.vector.dimensions and reconnect.");
|
|
3704
|
+
const expected = this.options.vector.dimensions;
|
|
3705
|
+
if (vector.length !== expected) throw new CacheConfigurationError(`Pg cache driver: vector dimension mismatch — expected ${expected}, got ${vector.length}.`);
|
|
3706
|
+
if (!Number.isInteger(options.topK) || options.topK <= 0) throw new CacheConfigurationError(`Pg cache driver: similar.topK must be a positive integer; got ${options.topK}.`);
|
|
3707
|
+
await this.ensureVectorReady();
|
|
3708
|
+
const t = this.table;
|
|
3709
|
+
const params = [this.formatVector(vector)];
|
|
3710
|
+
let tagFilter = "";
|
|
3711
|
+
if (options.tags && options.tags.length > 0) {
|
|
3712
|
+
params.push(options.tags);
|
|
3713
|
+
tagFilter = `AND tags && $${params.length}`;
|
|
3714
|
+
}
|
|
3715
|
+
params.push(options.topK);
|
|
3716
|
+
const topKParam = `$${params.length}`;
|
|
3717
|
+
const { rows } = await this.pgClient.query(`SELECT key, value, 1 - (embedding <=> $1::vector) AS score
|
|
3718
|
+
FROM ${t}
|
|
3719
|
+
WHERE embedding IS NOT NULL
|
|
3720
|
+
AND (expires_at IS NULL OR expires_at > now())
|
|
3721
|
+
${tagFilter}
|
|
3722
|
+
ORDER BY embedding <=> $1::vector
|
|
3723
|
+
LIMIT ${topKParam}`, params);
|
|
3724
|
+
const hits = [];
|
|
3725
|
+
for (const row of rows) {
|
|
3726
|
+
const score = Number(row.score);
|
|
3727
|
+
if (options.threshold !== void 0 && score < options.threshold) continue;
|
|
3728
|
+
let value = row.value;
|
|
3729
|
+
if (typeof value === "string") try {
|
|
3730
|
+
value = JSON.parse(value);
|
|
3731
|
+
} catch {}
|
|
3732
|
+
if (value !== null && value !== void 0) {
|
|
3733
|
+
const ty = typeof value;
|
|
3734
|
+
if (ty !== "string" && ty !== "number" && ty !== "boolean") value = structuredClone(value);
|
|
3735
|
+
}
|
|
3736
|
+
hits.push({
|
|
3737
|
+
key: row.key,
|
|
3738
|
+
value,
|
|
3739
|
+
score
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
return hits;
|
|
3743
|
+
}
|
|
3744
|
+
};
|
|
3745
|
+
|
|
3746
|
+
//#endregion
|
|
3747
|
+
//#region ../../@warlock.js/cache/src/drivers/redis-cache-driver.ts
|
|
3748
|
+
/**
|
|
3749
|
+
* Cached Redis module (loaded once, reused)
|
|
3750
|
+
*/
|
|
3751
|
+
let RedisClient;
|
|
3752
|
+
let isModuleExists = null;
|
|
3753
|
+
/**
|
|
3754
|
+
* Installation instructions for Redis package
|
|
3755
|
+
*/
|
|
3756
|
+
const REDIS_INSTALL_INSTRUCTIONS = `
|
|
3757
|
+
Redis cache driver requires the redis package.
|
|
3758
|
+
Install it with:
|
|
3759
|
+
|
|
3760
|
+
npm install redis
|
|
3761
|
+
|
|
3762
|
+
Or with your preferred package manager:
|
|
3763
|
+
|
|
3764
|
+
pnpm add redis
|
|
3765
|
+
yarn add redis
|
|
3766
|
+
`.trim();
|
|
3767
|
+
/**
|
|
3768
|
+
* Load Redis module
|
|
3769
|
+
*/
|
|
3770
|
+
async function loadRedis() {
|
|
3771
|
+
try {
|
|
3772
|
+
RedisClient = await import("redis");
|
|
3773
|
+
isModuleExists = true;
|
|
3774
|
+
} catch {
|
|
3775
|
+
isModuleExists = false;
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
loadRedis();
|
|
3779
|
+
var RedisCacheDriver = class extends BaseCacheDriver {
|
|
3780
|
+
constructor(..._args) {
|
|
3781
|
+
super(..._args);
|
|
3782
|
+
this.name = "redis";
|
|
3783
|
+
}
|
|
3784
|
+
/**
|
|
3785
|
+
* {@inheritdoc}
|
|
3786
|
+
*/
|
|
3787
|
+
setOptions(options) {
|
|
3788
|
+
if (!options.url && !options.host) throw new CacheConfigurationError("Redis driver requires either 'url' or 'host' option to be configured.");
|
|
3789
|
+
return super.setOptions(options);
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* {@inheritDoc}
|
|
3793
|
+
*/
|
|
3794
|
+
async removeNamespace(namespace) {
|
|
3795
|
+
namespace = this.parseKey(namespace);
|
|
3796
|
+
this.log("clearing", namespace);
|
|
3797
|
+
const keys = await this.client?.keys(`${namespace}*`);
|
|
3798
|
+
if (!keys || keys.length === 0) {
|
|
3799
|
+
this.log("notFound", namespace);
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
await this.client?.del(keys);
|
|
3803
|
+
this.log("cleared", namespace);
|
|
3804
|
+
return keys;
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* {@inheritDoc}
|
|
3808
|
+
*/
|
|
3809
|
+
async set(key, value, ttlOrOptions) {
|
|
3810
|
+
const parsedKey = this.parseKey(key);
|
|
3811
|
+
const { ttl, tags, onConflict, vector, staleAt } = this.resolveSetOptions(ttlOrOptions);
|
|
3812
|
+
if (vector) throw new CacheUnsupportedError("'redis' driver does not yet support similarity retrieval. Phase 2 (RediSearch) is on the backlog — use a memory driver or the 'pg' driver (with pgvector) for now.");
|
|
3813
|
+
this.log("caching", parsedKey);
|
|
3814
|
+
const serialized = JSON.stringify(value);
|
|
3815
|
+
const hasExpiry = Boolean(ttl) && ttl !== Infinity;
|
|
3816
|
+
let reply;
|
|
3817
|
+
if (onConflict === "create") {
|
|
3818
|
+
const options = { NX: true };
|
|
3819
|
+
if (hasExpiry) options.EX = ttl;
|
|
3820
|
+
reply = await this.client?.set(parsedKey, serialized, options);
|
|
3821
|
+
} else if (onConflict === "update") {
|
|
3822
|
+
const options = { XX: true };
|
|
3823
|
+
if (hasExpiry) options.EX = ttl;
|
|
3824
|
+
reply = await this.client?.set(parsedKey, serialized, options);
|
|
3825
|
+
} else if (hasExpiry) reply = await this.client?.set(parsedKey, serialized, { EX: ttl });
|
|
3826
|
+
else reply = await this.client?.set(parsedKey, serialized);
|
|
3827
|
+
if ((onConflict === "create" || onConflict === "update") && !(reply === "OK")) return {
|
|
3828
|
+
wasSet: false,
|
|
3829
|
+
existing: onConflict === "create" ? await this.get(key) : null
|
|
3830
|
+
};
|
|
3831
|
+
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
3832
|
+
if (staleAt !== void 0) {
|
|
3833
|
+
const sidecarOptions = {};
|
|
3834
|
+
if (hasExpiry) sidecarOptions.EX = ttl;
|
|
3835
|
+
await this.client?.set(this.swrMetaKey(parsedKey), String(staleAt), sidecarOptions);
|
|
3836
|
+
}
|
|
3837
|
+
this.log("cached", parsedKey);
|
|
3838
|
+
await this.emit("set", {
|
|
3839
|
+
key: parsedKey,
|
|
3840
|
+
value,
|
|
3841
|
+
ttl
|
|
3842
|
+
});
|
|
3843
|
+
if (onConflict === "create" || onConflict === "update") return {
|
|
3844
|
+
wasSet: true,
|
|
3845
|
+
existing: null
|
|
3846
|
+
};
|
|
3847
|
+
return value;
|
|
3848
|
+
}
|
|
3849
|
+
/**
|
|
3850
|
+
* Build the sidecar key Redis uses to track SWR freshness without
|
|
3851
|
+
* wrapping the main value JSON.
|
|
3852
|
+
*/
|
|
3853
|
+
swrMetaKey(parsedKey) {
|
|
3854
|
+
return `__swrmeta:${parsedKey}`;
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* Read the raw {@link CacheData} wrapper, fetching the value and the
|
|
3858
|
+
* SWR sidecar in parallel. Returns `null` when the main key is missing
|
|
3859
|
+
* or expired (Redis handles expiry natively, so the absence of the
|
|
3860
|
+
* value alone tells us).
|
|
3861
|
+
*/
|
|
3862
|
+
async getEntry(key) {
|
|
3863
|
+
const parsedKey = this.parseKey(key);
|
|
3864
|
+
const [valueRaw, staleAtRaw] = await Promise.all([this.client?.get(parsedKey), this.client?.get(this.swrMetaKey(parsedKey))]);
|
|
3865
|
+
if (!valueRaw) return null;
|
|
3866
|
+
const data = JSON.parse(valueRaw);
|
|
3867
|
+
const staleAt = staleAtRaw ? Number(staleAtRaw) : void 0;
|
|
3868
|
+
return staleAt !== void 0 ? {
|
|
3869
|
+
data,
|
|
3870
|
+
staleAt
|
|
3871
|
+
} : { data };
|
|
3872
|
+
}
|
|
3873
|
+
/**
|
|
3874
|
+
* {@inheritdoc}
|
|
3875
|
+
*
|
|
3876
|
+
* Redis tracks expiry natively (the payload carries no `expiresAt`), so read
|
|
3877
|
+
* the remaining lifetime with the `TTL` command. Redis returns `-2` for a
|
|
3878
|
+
* missing key and `-1` for a key with no expiry.
|
|
3879
|
+
*/
|
|
3880
|
+
async getRemainingTtl(key) {
|
|
3881
|
+
const parsedKey = this.parseKey(key);
|
|
3882
|
+
const ttl = await this.client?.ttl(parsedKey);
|
|
3883
|
+
if (ttl === void 0 || ttl === -2) return;
|
|
3884
|
+
if (ttl === -1) return Infinity;
|
|
3885
|
+
return ttl;
|
|
3886
|
+
}
|
|
3887
|
+
/**
|
|
3888
|
+
* {@inheritDoc}
|
|
3889
|
+
*/
|
|
3890
|
+
async get(key) {
|
|
3891
|
+
key = this.parseKey(key);
|
|
3892
|
+
this.log("fetching", key);
|
|
3893
|
+
const value = await this.client?.get(key);
|
|
3894
|
+
if (!value) {
|
|
3895
|
+
this.log("notFound", key);
|
|
3896
|
+
await this.emit("miss", { key });
|
|
3897
|
+
return null;
|
|
3898
|
+
}
|
|
3899
|
+
this.log("fetched", key);
|
|
3900
|
+
const parsedValue = JSON.parse(value);
|
|
3901
|
+
if (parsedValue === null || parsedValue === void 0) {
|
|
3902
|
+
await this.emit("hit", {
|
|
3903
|
+
key,
|
|
3904
|
+
value: parsedValue
|
|
3905
|
+
});
|
|
3906
|
+
return parsedValue;
|
|
3907
|
+
}
|
|
3908
|
+
const type = typeof parsedValue;
|
|
3909
|
+
if (type === "string" || type === "number" || type === "boolean") {
|
|
3910
|
+
await this.emit("hit", {
|
|
3911
|
+
key,
|
|
3912
|
+
value: parsedValue
|
|
3913
|
+
});
|
|
3914
|
+
return parsedValue;
|
|
3915
|
+
}
|
|
3916
|
+
try {
|
|
3917
|
+
const clonedValue = structuredClone(parsedValue);
|
|
3918
|
+
await this.emit("hit", {
|
|
3919
|
+
key,
|
|
3920
|
+
value: clonedValue
|
|
3921
|
+
});
|
|
3922
|
+
return clonedValue;
|
|
3923
|
+
} catch (error) {
|
|
3924
|
+
this.logError(`Failed to clone cached value for ${key}`, error);
|
|
3925
|
+
throw error;
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* {@inheritDoc}
|
|
3930
|
+
*/
|
|
3931
|
+
async remove(key) {
|
|
3932
|
+
key = this.parseKey(key);
|
|
3933
|
+
this.log("removing", key);
|
|
3934
|
+
await this.client?.del([key, this.swrMetaKey(key)]);
|
|
3935
|
+
this.log("removed", key);
|
|
3936
|
+
await this.emit("removed", { key });
|
|
3937
|
+
}
|
|
3938
|
+
/**
|
|
3939
|
+
* {@inheritDoc}
|
|
3940
|
+
*/
|
|
3941
|
+
async flush() {
|
|
3942
|
+
this.log("flushing");
|
|
3943
|
+
if (this.options.globalPrefix) await this.removeNamespace("");
|
|
3944
|
+
else await this.client?.flushAll();
|
|
3945
|
+
this.log("flushed");
|
|
3946
|
+
await this.emit("flushed");
|
|
3947
|
+
}
|
|
3948
|
+
/**
|
|
3949
|
+
* {@inheritDoc}
|
|
3950
|
+
*/
|
|
3951
|
+
async connect() {
|
|
3952
|
+
if (this.clientDriver) return;
|
|
3953
|
+
if (!isModuleExists) throw new Error(REDIS_INSTALL_INSTRUCTIONS);
|
|
3954
|
+
const options = this.options;
|
|
3955
|
+
if (options && !options.url && options.host) {
|
|
3956
|
+
const auth = options.password || options.username ? `${options.username}:${options.password}@` : "";
|
|
3957
|
+
if (!options.url) options.url = `redis://${auth}${options.host || "localhost"}:${options.port || 6379}`;
|
|
3958
|
+
}
|
|
3959
|
+
const clientOptions = {
|
|
3960
|
+
...options,
|
|
3961
|
+
...this.options.clientOptions || {}
|
|
3962
|
+
};
|
|
3963
|
+
this.log("connecting");
|
|
3964
|
+
const { createClient } = RedisClient;
|
|
3965
|
+
this.client = createClient(clientOptions);
|
|
3966
|
+
this.client.on("error", (error) => {
|
|
3967
|
+
this.log("error", error.message);
|
|
3968
|
+
});
|
|
3969
|
+
try {
|
|
3970
|
+
await this.client.connect();
|
|
3971
|
+
this.log("connected");
|
|
3972
|
+
await this.emit("connected");
|
|
3973
|
+
} catch (error) {
|
|
3974
|
+
_warlock_js_logger.log.error("cache", "redis", error);
|
|
3975
|
+
await this.emit("error", { error });
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
/**
|
|
3979
|
+
* {@inheritDoc}
|
|
3980
|
+
*
|
|
3981
|
+
* Guards against disconnecting when the client was never created. The base
|
|
3982
|
+
* `client` getter falls back to `this` when no client is set, so we check
|
|
3983
|
+
* the backing `clientDriver` directly — using `this.client` for this guard
|
|
3984
|
+
* would always be truthy and crash with "this.quit is not a function".
|
|
3985
|
+
*/
|
|
3986
|
+
async disconnect() {
|
|
3987
|
+
if (!this.clientDriver) return;
|
|
3988
|
+
this.log("disconnecting");
|
|
3989
|
+
await this.clientDriver.quit();
|
|
3990
|
+
this.log("disconnected");
|
|
3991
|
+
await this.emit("disconnected");
|
|
3992
|
+
}
|
|
3993
|
+
/**
|
|
3994
|
+
* Atomic increment using Redis native INCRBY command
|
|
3995
|
+
* {@inheritdoc}
|
|
3996
|
+
*/
|
|
3997
|
+
async increment(key, value = 1) {
|
|
3998
|
+
const parsedKey = this.parseKey(key);
|
|
3999
|
+
this.log("caching", parsedKey);
|
|
4000
|
+
const result = await this.client?.incrBy(parsedKey, value);
|
|
4001
|
+
this.log("cached", parsedKey);
|
|
4002
|
+
await this.emit("set", {
|
|
4003
|
+
key: parsedKey,
|
|
4004
|
+
value: result,
|
|
4005
|
+
ttl: void 0
|
|
4006
|
+
});
|
|
4007
|
+
return result || 0;
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Atomic decrement using Redis native DECRBY command
|
|
4011
|
+
* {@inheritdoc}
|
|
4012
|
+
*/
|
|
4013
|
+
async decrement(key, value = 1) {
|
|
4014
|
+
const parsedKey = this.parseKey(key);
|
|
4015
|
+
this.log("caching", parsedKey);
|
|
4016
|
+
const result = await this.client?.decrBy(parsedKey, value);
|
|
4017
|
+
this.log("cached", parsedKey);
|
|
4018
|
+
await this.emit("set", {
|
|
4019
|
+
key: parsedKey,
|
|
4020
|
+
value: result,
|
|
4021
|
+
ttl: void 0
|
|
4022
|
+
});
|
|
4023
|
+
return result || 0;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Set if not exists (atomic operation)
|
|
4027
|
+
* Returns true if key was set, false if key already existed
|
|
4028
|
+
*/
|
|
4029
|
+
async setNX(key, value, ttl) {
|
|
4030
|
+
const parsedKey = this.parseKey(key);
|
|
4031
|
+
this.log("caching", parsedKey);
|
|
4032
|
+
if (ttl === void 0) ttl = this.ttl;
|
|
4033
|
+
let result;
|
|
4034
|
+
if (ttl && ttl !== Infinity) result = await this.client?.set(parsedKey, JSON.stringify(value), {
|
|
4035
|
+
NX: true,
|
|
4036
|
+
EX: ttl
|
|
4037
|
+
});
|
|
4038
|
+
else result = await this.client?.set(parsedKey, JSON.stringify(value), { NX: true });
|
|
4039
|
+
const wasSet = result === "OK";
|
|
4040
|
+
if (wasSet) {
|
|
4041
|
+
this.log("cached", parsedKey);
|
|
4042
|
+
await this.emit("set", {
|
|
4043
|
+
key: parsedKey,
|
|
4044
|
+
value,
|
|
4045
|
+
ttl
|
|
4046
|
+
});
|
|
4047
|
+
} else this.log("notFound", parsedKey);
|
|
4048
|
+
return wasSet;
|
|
4049
|
+
}
|
|
4050
|
+
};
|
|
4051
|
+
|
|
4052
|
+
//#endregion
|
|
4053
|
+
exports.BaseCacheDriver = BaseCacheDriver;
|
|
4054
|
+
exports.CACHE_FOR = CACHE_FOR;
|
|
4055
|
+
exports.CacheConcurrencyError = CacheConcurrencyError;
|
|
4056
|
+
exports.CacheConfigurationError = CacheConfigurationError;
|
|
4057
|
+
exports.CacheConnectionError = CacheConnectionError;
|
|
4058
|
+
exports.CacheDriverNotInitializedError = CacheDriverNotInitializedError;
|
|
4059
|
+
exports.CacheError = CacheError;
|
|
4060
|
+
exports.CacheManager = CacheManager;
|
|
4061
|
+
exports.CacheMetricsCollector = CacheMetricsCollector;
|
|
4062
|
+
exports.CacheUnsupportedError = CacheUnsupportedError;
|
|
4063
|
+
exports.FileCacheDriver = FileCacheDriver;
|
|
4064
|
+
exports.LRUMemoryCacheDriver = LRUMemoryCacheDriver;
|
|
4065
|
+
exports.MemoryCacheDriver = MemoryCacheDriver;
|
|
4066
|
+
exports.MemoryCacheList = MemoryCacheList;
|
|
4067
|
+
exports.MemoryExtendedCacheDriver = MemoryExtendedCacheDriver;
|
|
4068
|
+
exports.MockCacheDriver = MockCacheDriver;
|
|
4069
|
+
exports.NullCacheDriver = NullCacheDriver;
|
|
4070
|
+
exports.PgCacheDriver = PgCacheDriver;
|
|
4071
|
+
exports.RedisCacheDriver = RedisCacheDriver;
|
|
4072
|
+
exports.ScopedCache = ScopedCache;
|
|
4073
|
+
exports.TaggedCache = TaggedCache;
|
|
4074
|
+
exports.TaggedScopedCache = TaggedScopedCache;
|
|
4075
|
+
exports.cache = cache;
|
|
4076
|
+
exports.cached = cached;
|
|
4077
|
+
exports.cosineSimilarity = cosineSimilarity;
|
|
4078
|
+
exports.deriveAutoKey = deriveAutoKey;
|
|
4079
|
+
exports.expiresAtToTtl = expiresAtToTtl;
|
|
4080
|
+
exports.injectTags = injectTags;
|
|
4081
|
+
exports.mergeTagSets = mergeTagSets;
|
|
4082
|
+
exports.normalizeCachedArgs = normalizeCachedArgs;
|
|
4083
|
+
exports.normalizeToOptions = normalizeToOptions;
|
|
4084
|
+
exports.normalizeToRememberOptions = normalizeToRememberOptions;
|
|
4085
|
+
exports.parseCacheKey = parseCacheKey;
|
|
4086
|
+
exports.parseTtl = parseTtl;
|
|
4087
|
+
exports.resolveTtl = resolveTtl;
|
|
4088
|
+
//# sourceMappingURL=index.cjs.map
|