@chidchanun/bcp 0.2.15 → 0.2.16
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 +109 -10
- package/docs/README.md +41 -34
- package/docs/api-manifest.json +4 -4
- package/docs/api-reference.md +62 -3
- package/docs/cache-platform-v2.md +487 -0
- package/docs/docs-web-manifest.json +5 -3
- package/docs/platform-manifest.json +18 -4
- package/docs/releases/0.2.16.md +147 -0
- package/package.json +2 -2
- package/packages/cache/src/platform-v2.ts +1705 -0
- package/packages/client/src/cache.mjs +1554 -0
- package/packages/client/src/cache.ts +29 -0
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
// packages/cache/src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
AsyncLocalStorage
|
|
4
|
+
} from "node:async_hooks";
|
|
5
|
+
import {
|
|
6
|
+
createHash
|
|
7
|
+
} from "node:crypto";
|
|
8
|
+
var CACHE_RUNTIME_SYMBOL = /* @__PURE__ */ Symbol.for(
|
|
9
|
+
"bcp.framework.cache.runtime"
|
|
10
|
+
);
|
|
11
|
+
var globalRecord = globalThis;
|
|
12
|
+
var runtimeState = globalRecord[CACHE_RUNTIME_SYMBOL];
|
|
13
|
+
if (!runtimeState) {
|
|
14
|
+
runtimeState = {
|
|
15
|
+
persistentCache: /* @__PURE__ */ new Map(),
|
|
16
|
+
inFlight: /* @__PURE__ */ new Map(),
|
|
17
|
+
requestStorage: new AsyncLocalStorage()
|
|
18
|
+
};
|
|
19
|
+
globalRecord[CACHE_RUNTIME_SYMBOL] = runtimeState;
|
|
20
|
+
}
|
|
21
|
+
var persistentCache = runtimeState.persistentCache;
|
|
22
|
+
var inFlight = runtimeState.inFlight;
|
|
23
|
+
var requestStorage = runtimeState.requestStorage;
|
|
24
|
+
function dedupe(fn, key) {
|
|
25
|
+
const namespace = key ?? fn.name ?? "anonymous";
|
|
26
|
+
return async (...args) => {
|
|
27
|
+
const context = ensureRequestContext();
|
|
28
|
+
const cacheKey = `request:${namespace}:${hashArguments(args)}`;
|
|
29
|
+
const existing = context.memo.get(
|
|
30
|
+
cacheKey
|
|
31
|
+
);
|
|
32
|
+
if (existing) {
|
|
33
|
+
return await existing;
|
|
34
|
+
}
|
|
35
|
+
const promise = Promise.resolve(
|
|
36
|
+
fn(...args)
|
|
37
|
+
);
|
|
38
|
+
context.memo.set(
|
|
39
|
+
cacheKey,
|
|
40
|
+
promise
|
|
41
|
+
);
|
|
42
|
+
try {
|
|
43
|
+
return await promise;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
context.memo.delete(
|
|
46
|
+
cacheKey
|
|
47
|
+
);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function cache(fn, options = {}) {
|
|
53
|
+
const namespace = options.key ?? fn.name;
|
|
54
|
+
if (!namespace) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"BCP Framework: cache() requires options.key when the wrapped function has no name."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
validateRevalidate(
|
|
60
|
+
options.revalidate
|
|
61
|
+
);
|
|
62
|
+
validateTags(
|
|
63
|
+
options.tags
|
|
64
|
+
);
|
|
65
|
+
return async (...args) => {
|
|
66
|
+
if (options.revalidate === 0) {
|
|
67
|
+
return await fn(
|
|
68
|
+
...args
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const cacheKey = `data:${namespace}:${hashArguments(args)}`;
|
|
72
|
+
const existingEntry = readCacheEntry(
|
|
73
|
+
cacheKey
|
|
74
|
+
);
|
|
75
|
+
if (existingEntry.hit) {
|
|
76
|
+
return existingEntry.value;
|
|
77
|
+
}
|
|
78
|
+
const pending = inFlight.get(
|
|
79
|
+
cacheKey
|
|
80
|
+
);
|
|
81
|
+
if (pending) {
|
|
82
|
+
return await pending;
|
|
83
|
+
}
|
|
84
|
+
const promise = Promise.resolve(
|
|
85
|
+
fn(...args)
|
|
86
|
+
);
|
|
87
|
+
inFlight.set(
|
|
88
|
+
cacheKey,
|
|
89
|
+
promise
|
|
90
|
+
);
|
|
91
|
+
try {
|
|
92
|
+
const value = await promise;
|
|
93
|
+
const currentPath = requestStorage.getStore()?.pathname ?? null;
|
|
94
|
+
const paths = new Set(
|
|
95
|
+
(options.paths ?? []).map(
|
|
96
|
+
normalizePath
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
if (currentPath) {
|
|
100
|
+
paths.add(
|
|
101
|
+
currentPath
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
setCacheEntry(
|
|
105
|
+
cacheKey,
|
|
106
|
+
value,
|
|
107
|
+
{
|
|
108
|
+
revalidate: options.revalidate,
|
|
109
|
+
tags: options.tags,
|
|
110
|
+
paths: Array.from(
|
|
111
|
+
paths
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
return value;
|
|
116
|
+
} finally {
|
|
117
|
+
inFlight.delete(
|
|
118
|
+
cacheKey
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function setCacheEntry(key, value, options = {}) {
|
|
124
|
+
validateRevalidate(
|
|
125
|
+
options.revalidate
|
|
126
|
+
);
|
|
127
|
+
validateTags(
|
|
128
|
+
options.tags
|
|
129
|
+
);
|
|
130
|
+
if (options.revalidate === 0) {
|
|
131
|
+
persistentCache.delete(
|
|
132
|
+
key
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const expiresAt = options.revalidate === void 0 || options.revalidate === false ? null : Date.now() + options.revalidate * 1e3;
|
|
137
|
+
persistentCache.set(
|
|
138
|
+
key,
|
|
139
|
+
{
|
|
140
|
+
value,
|
|
141
|
+
expiresAt,
|
|
142
|
+
tags: new Set(
|
|
143
|
+
options.tags ?? []
|
|
144
|
+
),
|
|
145
|
+
paths: new Set(
|
|
146
|
+
(options.paths ?? []).map(
|
|
147
|
+
normalizePath
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
function revalidateTag(tag) {
|
|
154
|
+
const normalized = normalizeTag(tag);
|
|
155
|
+
let removed = 0;
|
|
156
|
+
for (const [
|
|
157
|
+
key,
|
|
158
|
+
entry
|
|
159
|
+
] of persistentCache) {
|
|
160
|
+
if (entry.tags.has(
|
|
161
|
+
normalized
|
|
162
|
+
)) {
|
|
163
|
+
persistentCache.delete(
|
|
164
|
+
key
|
|
165
|
+
);
|
|
166
|
+
removed++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return removed;
|
|
170
|
+
}
|
|
171
|
+
function revalidatePath(pathname) {
|
|
172
|
+
const normalized = normalizePath(pathname);
|
|
173
|
+
let removed = 0;
|
|
174
|
+
for (const [
|
|
175
|
+
key,
|
|
176
|
+
entry
|
|
177
|
+
] of persistentCache) {
|
|
178
|
+
const matches = Array.from(
|
|
179
|
+
entry.paths
|
|
180
|
+
).some(
|
|
181
|
+
(cachedPath) => pathMatches(
|
|
182
|
+
cachedPath,
|
|
183
|
+
normalized
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
if (matches) {
|
|
187
|
+
persistentCache.delete(
|
|
188
|
+
key
|
|
189
|
+
);
|
|
190
|
+
removed++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return removed;
|
|
194
|
+
}
|
|
195
|
+
function clearCache() {
|
|
196
|
+
persistentCache.clear();
|
|
197
|
+
inFlight.clear();
|
|
198
|
+
}
|
|
199
|
+
function getCacheStats() {
|
|
200
|
+
pruneExpiredEntries();
|
|
201
|
+
return {
|
|
202
|
+
entries: persistentCache.size,
|
|
203
|
+
inFlight: inFlight.size
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function normalizeRevalidate(value) {
|
|
207
|
+
if (value === void 0) {
|
|
208
|
+
return void 0;
|
|
209
|
+
}
|
|
210
|
+
if (value === false) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
throw new Error(
|
|
217
|
+
"BCP Framework: revalidate must be false or a non-negative number of seconds."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
function readCacheEntry(key) {
|
|
221
|
+
const entry = persistentCache.get(
|
|
222
|
+
key
|
|
223
|
+
);
|
|
224
|
+
if (!entry) {
|
|
225
|
+
return {
|
|
226
|
+
hit: false
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
230
|
+
persistentCache.delete(
|
|
231
|
+
key
|
|
232
|
+
);
|
|
233
|
+
return {
|
|
234
|
+
hit: false
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
hit: true,
|
|
239
|
+
value: entry.value
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function ensureRequestContext() {
|
|
243
|
+
const existing = requestStorage.getStore();
|
|
244
|
+
if (existing) {
|
|
245
|
+
return existing;
|
|
246
|
+
}
|
|
247
|
+
const context = {
|
|
248
|
+
pathname: null,
|
|
249
|
+
memo: /* @__PURE__ */ new Map()
|
|
250
|
+
};
|
|
251
|
+
requestStorage.enterWith(
|
|
252
|
+
context
|
|
253
|
+
);
|
|
254
|
+
return context;
|
|
255
|
+
}
|
|
256
|
+
function pruneExpiredEntries() {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
for (const [
|
|
259
|
+
key,
|
|
260
|
+
entry
|
|
261
|
+
] of persistentCache) {
|
|
262
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now) {
|
|
263
|
+
persistentCache.delete(
|
|
264
|
+
key
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function validateRevalidate(value) {
|
|
270
|
+
normalizeRevalidate(
|
|
271
|
+
value
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
function validateTags(tags) {
|
|
275
|
+
for (const tag of tags ?? []) {
|
|
276
|
+
normalizeTag(
|
|
277
|
+
tag
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function normalizeTag(value) {
|
|
282
|
+
const normalized = value.trim();
|
|
283
|
+
if (!normalized) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
"BCP Framework: cache tags cannot be empty."
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return normalized;
|
|
289
|
+
}
|
|
290
|
+
function normalizePath(value) {
|
|
291
|
+
let pathname = value.trim();
|
|
292
|
+
if (!pathname) {
|
|
293
|
+
return "/";
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
if (pathname.startsWith(
|
|
297
|
+
"http://"
|
|
298
|
+
) || pathname.startsWith(
|
|
299
|
+
"https://"
|
|
300
|
+
)) {
|
|
301
|
+
pathname = new URL(
|
|
302
|
+
pathname
|
|
303
|
+
).pathname;
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
pathname = pathname.split("?")[0].split("#")[0];
|
|
308
|
+
if (!pathname.startsWith("/")) {
|
|
309
|
+
pathname = `/${pathname}`;
|
|
310
|
+
}
|
|
311
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
312
|
+
pathname = pathname.slice(
|
|
313
|
+
0,
|
|
314
|
+
-1
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return pathname;
|
|
318
|
+
}
|
|
319
|
+
function pathMatches(cachedPath, invalidatedPath) {
|
|
320
|
+
if (invalidatedPath === "/") {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
return cachedPath === invalidatedPath || cachedPath.startsWith(
|
|
324
|
+
`${invalidatedPath}/`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
function hashArguments(args) {
|
|
328
|
+
return createHash(
|
|
329
|
+
"sha256"
|
|
330
|
+
).update(
|
|
331
|
+
stableSerialize(
|
|
332
|
+
args
|
|
333
|
+
)
|
|
334
|
+
).digest(
|
|
335
|
+
"hex"
|
|
336
|
+
).slice(
|
|
337
|
+
0,
|
|
338
|
+
24
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
function stableSerialize(value, seen = /* @__PURE__ */ new WeakSet()) {
|
|
342
|
+
if (value === null) {
|
|
343
|
+
return "null";
|
|
344
|
+
}
|
|
345
|
+
switch (typeof value) {
|
|
346
|
+
case "undefined":
|
|
347
|
+
return "undefined";
|
|
348
|
+
case "string":
|
|
349
|
+
return JSON.stringify(
|
|
350
|
+
value
|
|
351
|
+
);
|
|
352
|
+
case "number":
|
|
353
|
+
case "boolean":
|
|
354
|
+
return String(value);
|
|
355
|
+
case "bigint":
|
|
356
|
+
return `${value.toString()}n`;
|
|
357
|
+
case "symbol":
|
|
358
|
+
case "function":
|
|
359
|
+
throw new Error(
|
|
360
|
+
`BCP Framework: cache keys cannot include ${typeof value} values.`
|
|
361
|
+
);
|
|
362
|
+
case "object":
|
|
363
|
+
break;
|
|
364
|
+
default:
|
|
365
|
+
return String(value);
|
|
366
|
+
}
|
|
367
|
+
const object = value;
|
|
368
|
+
if (seen.has(object)) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
"BCP Framework: cache keys cannot include circular values."
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
seen.add(object);
|
|
374
|
+
try {
|
|
375
|
+
if (value instanceof Date) {
|
|
376
|
+
return `Date(${value.toISOString()})`;
|
|
377
|
+
}
|
|
378
|
+
if (Array.isArray(value)) {
|
|
379
|
+
return `[${value.map(
|
|
380
|
+
(item) => stableSerialize(
|
|
381
|
+
item,
|
|
382
|
+
seen
|
|
383
|
+
)
|
|
384
|
+
).join(",")}]`;
|
|
385
|
+
}
|
|
386
|
+
const record = value;
|
|
387
|
+
return `{${Object.keys(record).sort().map(
|
|
388
|
+
(key) => `${JSON.stringify(key)}:${stableSerialize(
|
|
389
|
+
record[key],
|
|
390
|
+
seen
|
|
391
|
+
)}`
|
|
392
|
+
).join(",")}}`;
|
|
393
|
+
} finally {
|
|
394
|
+
seen.delete(object);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// packages/cache/src/platform-v2.ts
|
|
399
|
+
import {
|
|
400
|
+
randomUUID
|
|
401
|
+
} from "node:crypto";
|
|
402
|
+
var DEFAULT_LOCK_TTL_MS = 3e4;
|
|
403
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
404
|
+
var DEFAULT_POLL_INTERVAL_MS = 25;
|
|
405
|
+
function createMemoryCacheAdapter(options = {}) {
|
|
406
|
+
const now = options.now ?? Date.now;
|
|
407
|
+
const records = /* @__PURE__ */ new Map();
|
|
408
|
+
const adapter = {
|
|
409
|
+
get size() {
|
|
410
|
+
prune();
|
|
411
|
+
return records.size;
|
|
412
|
+
},
|
|
413
|
+
async get(rawKey) {
|
|
414
|
+
const key = normalizeKey(rawKey);
|
|
415
|
+
const entry = records.get(key);
|
|
416
|
+
if (!entry) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
if (isExpired(entry, now())) {
|
|
420
|
+
records.delete(key);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return cloneEntry(entry);
|
|
424
|
+
},
|
|
425
|
+
async set(rawKey, value, setOptions = {}) {
|
|
426
|
+
const key = normalizeKey(rawKey);
|
|
427
|
+
const timestamp = now();
|
|
428
|
+
assertFiniteTimestamp(timestamp, "cache timestamp");
|
|
429
|
+
const current = records.get(key);
|
|
430
|
+
const expiresAt = normalizeExpiresAt(
|
|
431
|
+
setOptions.expiresAt
|
|
432
|
+
);
|
|
433
|
+
records.set(key, {
|
|
434
|
+
key,
|
|
435
|
+
value,
|
|
436
|
+
expiresAt,
|
|
437
|
+
tags: normalizeTags(setOptions.tags),
|
|
438
|
+
paths: normalizePaths(setOptions.paths),
|
|
439
|
+
createdAt: current?.createdAt ?? timestamp,
|
|
440
|
+
updatedAt: timestamp
|
|
441
|
+
});
|
|
442
|
+
},
|
|
443
|
+
async delete(rawKey) {
|
|
444
|
+
return records.delete(
|
|
445
|
+
normalizeKey(rawKey)
|
|
446
|
+
);
|
|
447
|
+
},
|
|
448
|
+
async clear() {
|
|
449
|
+
const count = records.size;
|
|
450
|
+
records.clear();
|
|
451
|
+
return count;
|
|
452
|
+
},
|
|
453
|
+
async revalidateTag(rawTag) {
|
|
454
|
+
const tag = normalizeTag2(rawTag);
|
|
455
|
+
let removed = 0;
|
|
456
|
+
for (const [key, record] of records) {
|
|
457
|
+
if (record.tags.includes(tag)) {
|
|
458
|
+
records.delete(key);
|
|
459
|
+
removed++;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return removed;
|
|
463
|
+
},
|
|
464
|
+
async revalidatePath(rawPath) {
|
|
465
|
+
const pathname = normalizePath2(rawPath);
|
|
466
|
+
let removed = 0;
|
|
467
|
+
for (const [key, record] of records) {
|
|
468
|
+
if (record.paths.some(
|
|
469
|
+
(path) => pathMatches2(path, pathname)
|
|
470
|
+
)) {
|
|
471
|
+
records.delete(key);
|
|
472
|
+
removed++;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return removed;
|
|
476
|
+
},
|
|
477
|
+
async entries() {
|
|
478
|
+
prune();
|
|
479
|
+
return records.size;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
return adapter;
|
|
483
|
+
function prune() {
|
|
484
|
+
const timestamp = now();
|
|
485
|
+
for (const [key, record] of records) {
|
|
486
|
+
if (isExpired(record, timestamp)) {
|
|
487
|
+
records.delete(key);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function createMemoryCacheLockAdapter(options = {}) {
|
|
493
|
+
const now = options.now ?? Date.now;
|
|
494
|
+
const locks = /* @__PURE__ */ new Map();
|
|
495
|
+
const adapter = {
|
|
496
|
+
get size() {
|
|
497
|
+
prune();
|
|
498
|
+
return locks.size;
|
|
499
|
+
},
|
|
500
|
+
async acquire(rawKey, rawOwnerId, ttlMs) {
|
|
501
|
+
const key = normalizeKey(rawKey);
|
|
502
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
503
|
+
const ttl = positiveInteger(ttlMs, "lock ttlMs");
|
|
504
|
+
const timestamp = now();
|
|
505
|
+
const current = locks.get(key);
|
|
506
|
+
if (current && current.expiresAt > timestamp) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
locks.set(key, {
|
|
510
|
+
ownerId,
|
|
511
|
+
expiresAt: timestamp + ttl
|
|
512
|
+
});
|
|
513
|
+
return true;
|
|
514
|
+
},
|
|
515
|
+
async extend(rawKey, rawOwnerId, ttlMs) {
|
|
516
|
+
const key = normalizeKey(rawKey);
|
|
517
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
518
|
+
const ttl = positiveInteger(ttlMs, "lock ttlMs");
|
|
519
|
+
const timestamp = now();
|
|
520
|
+
const current = locks.get(key);
|
|
521
|
+
if (!current || current.ownerId !== ownerId || current.expiresAt <= timestamp) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
current.expiresAt = timestamp + ttl;
|
|
525
|
+
return true;
|
|
526
|
+
},
|
|
527
|
+
async release(rawKey, rawOwnerId) {
|
|
528
|
+
const key = normalizeKey(rawKey);
|
|
529
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
530
|
+
const current = locks.get(key);
|
|
531
|
+
if (!current || current.ownerId !== ownerId) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
locks.delete(key);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
return adapter;
|
|
539
|
+
function prune() {
|
|
540
|
+
const timestamp = now();
|
|
541
|
+
for (const [key, record] of locks) {
|
|
542
|
+
if (record.expiresAt <= timestamp) {
|
|
543
|
+
locks.delete(key);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function createCacheStore(options = {}) {
|
|
549
|
+
const adapter = options.adapter ?? createMemoryCacheAdapter({
|
|
550
|
+
now: options.now
|
|
551
|
+
});
|
|
552
|
+
const lock = options.lock;
|
|
553
|
+
const now = options.now ?? Date.now;
|
|
554
|
+
const idFactory = options.idFactory ?? randomUUID;
|
|
555
|
+
const metrics = options.metrics;
|
|
556
|
+
const inFlight2 = /* @__PURE__ */ new Map();
|
|
557
|
+
const counters = {
|
|
558
|
+
hits: 0,
|
|
559
|
+
misses: 0,
|
|
560
|
+
sets: 0,
|
|
561
|
+
deletes: 0,
|
|
562
|
+
invalidations: 0,
|
|
563
|
+
loads: 0,
|
|
564
|
+
loadErrors: 0,
|
|
565
|
+
lockAcquired: 0,
|
|
566
|
+
lockContentions: 0,
|
|
567
|
+
lockTimeouts: 0
|
|
568
|
+
};
|
|
569
|
+
let closed = false;
|
|
570
|
+
const store = {
|
|
571
|
+
adapter,
|
|
572
|
+
lock,
|
|
573
|
+
async get(rawKey) {
|
|
574
|
+
assertOpen();
|
|
575
|
+
const key = normalizeKey(rawKey);
|
|
576
|
+
const entry = await adapter.get(key);
|
|
577
|
+
if (!entry) {
|
|
578
|
+
count("misses", "miss");
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now()) {
|
|
582
|
+
await adapter.delete(key);
|
|
583
|
+
count("misses", "miss");
|
|
584
|
+
return void 0;
|
|
585
|
+
}
|
|
586
|
+
count("hits", "hit");
|
|
587
|
+
return entry.value;
|
|
588
|
+
},
|
|
589
|
+
async set(rawKey, value, setOptions = {}) {
|
|
590
|
+
assertOpen();
|
|
591
|
+
const key = normalizeKey(rawKey);
|
|
592
|
+
if (setOptions.ttlMs === 0) {
|
|
593
|
+
await store.delete(key);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const expiresAt = setOptions.ttlMs === void 0 || setOptions.ttlMs === false ? null : now() + positiveInteger(
|
|
597
|
+
setOptions.ttlMs,
|
|
598
|
+
"cache ttlMs"
|
|
599
|
+
);
|
|
600
|
+
await adapter.set(
|
|
601
|
+
key,
|
|
602
|
+
value,
|
|
603
|
+
{
|
|
604
|
+
expiresAt,
|
|
605
|
+
tags: normalizeTags(setOptions.tags),
|
|
606
|
+
paths: normalizePaths(setOptions.paths)
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
count("sets", "set");
|
|
610
|
+
},
|
|
611
|
+
async delete(rawKey) {
|
|
612
|
+
assertOpen();
|
|
613
|
+
const removed = await adapter.delete(
|
|
614
|
+
normalizeKey(rawKey)
|
|
615
|
+
);
|
|
616
|
+
if (removed) {
|
|
617
|
+
count("deletes", "delete");
|
|
618
|
+
}
|
|
619
|
+
return removed;
|
|
620
|
+
},
|
|
621
|
+
async clear() {
|
|
622
|
+
assertOpen();
|
|
623
|
+
const removed = await adapter.clear();
|
|
624
|
+
if (removed > 0) {
|
|
625
|
+
counters.invalidations += removed;
|
|
626
|
+
metrics?.record("invalidate", removed);
|
|
627
|
+
}
|
|
628
|
+
return removed;
|
|
629
|
+
},
|
|
630
|
+
async revalidateTag(rawTag) {
|
|
631
|
+
assertOpen();
|
|
632
|
+
const removed = await adapter.revalidateTag(
|
|
633
|
+
normalizeTag2(rawTag)
|
|
634
|
+
);
|
|
635
|
+
if (removed > 0) {
|
|
636
|
+
counters.invalidations += removed;
|
|
637
|
+
metrics?.record("invalidate", removed);
|
|
638
|
+
}
|
|
639
|
+
return removed;
|
|
640
|
+
},
|
|
641
|
+
async revalidatePath(rawPath) {
|
|
642
|
+
assertOpen();
|
|
643
|
+
const removed = await adapter.revalidatePath(
|
|
644
|
+
normalizePath2(rawPath)
|
|
645
|
+
);
|
|
646
|
+
if (removed > 0) {
|
|
647
|
+
counters.invalidations += removed;
|
|
648
|
+
metrics?.record("invalidate", removed);
|
|
649
|
+
}
|
|
650
|
+
return removed;
|
|
651
|
+
},
|
|
652
|
+
async getOrSet(rawKey, loader, loadOptions = {}) {
|
|
653
|
+
assertOpen();
|
|
654
|
+
if (typeof loader !== "function") {
|
|
655
|
+
throw new TypeError(
|
|
656
|
+
"BCP Cache: loader must be a function."
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const key = normalizeKey(rawKey);
|
|
660
|
+
const cached = await store.get(key);
|
|
661
|
+
if (cached !== void 0) {
|
|
662
|
+
return cached;
|
|
663
|
+
}
|
|
664
|
+
const pending = inFlight2.get(key);
|
|
665
|
+
if (pending) {
|
|
666
|
+
return await pending;
|
|
667
|
+
}
|
|
668
|
+
const promise = loadWithStampedeProtection(
|
|
669
|
+
key,
|
|
670
|
+
loader,
|
|
671
|
+
loadOptions
|
|
672
|
+
);
|
|
673
|
+
inFlight2.set(key, promise);
|
|
674
|
+
metrics?.setInFlight?.(inFlight2.size);
|
|
675
|
+
try {
|
|
676
|
+
return await promise;
|
|
677
|
+
} finally {
|
|
678
|
+
inFlight2.delete(key);
|
|
679
|
+
metrics?.setInFlight?.(inFlight2.size);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
async stats() {
|
|
683
|
+
assertOpen();
|
|
684
|
+
return {
|
|
685
|
+
...counters,
|
|
686
|
+
inFlight: inFlight2.size,
|
|
687
|
+
adapterEntries: adapter.entries ? await adapter.entries() : void 0
|
|
688
|
+
};
|
|
689
|
+
},
|
|
690
|
+
async close() {
|
|
691
|
+
if (closed) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
closed = true;
|
|
695
|
+
inFlight2.clear();
|
|
696
|
+
metrics?.setInFlight?.(0);
|
|
697
|
+
const lockClose = lock && !Object.is(lock, adapter) ? lock.close?.() : void 0;
|
|
698
|
+
await Promise.all([
|
|
699
|
+
adapter.close?.(),
|
|
700
|
+
lockClose
|
|
701
|
+
]);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
return store;
|
|
705
|
+
async function loadWithStampedeProtection(key, loader, loadOptions) {
|
|
706
|
+
if (!lock) {
|
|
707
|
+
return executeLoader(
|
|
708
|
+
key,
|
|
709
|
+
loader,
|
|
710
|
+
loadOptions
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const lockTtlMs = positiveInteger(
|
|
714
|
+
loadOptions.lockTtlMs ?? DEFAULT_LOCK_TTL_MS,
|
|
715
|
+
"lock ttlMs"
|
|
716
|
+
);
|
|
717
|
+
const waitTimeoutMs = nonNegativeInteger(
|
|
718
|
+
loadOptions.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
719
|
+
"lock waitTimeoutMs"
|
|
720
|
+
);
|
|
721
|
+
const pollIntervalMs = positiveInteger(
|
|
722
|
+
loadOptions.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
723
|
+
"lock pollIntervalMs"
|
|
724
|
+
);
|
|
725
|
+
const ownerId = normalizeOwnerId(idFactory());
|
|
726
|
+
const lockKey = `cache-load:${key}`;
|
|
727
|
+
if (await lock.acquire(
|
|
728
|
+
lockKey,
|
|
729
|
+
ownerId,
|
|
730
|
+
lockTtlMs
|
|
731
|
+
)) {
|
|
732
|
+
count(
|
|
733
|
+
"lockAcquired",
|
|
734
|
+
"lock-acquired"
|
|
735
|
+
);
|
|
736
|
+
return executeUnderLock(
|
|
737
|
+
key,
|
|
738
|
+
ownerId,
|
|
739
|
+
lockKey,
|
|
740
|
+
lockTtlMs,
|
|
741
|
+
loader,
|
|
742
|
+
loadOptions
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
count(
|
|
746
|
+
"lockContentions",
|
|
747
|
+
"lock-contention"
|
|
748
|
+
);
|
|
749
|
+
const waitStartedAt = Date.now();
|
|
750
|
+
while (Date.now() - waitStartedAt < waitTimeoutMs) {
|
|
751
|
+
await sleep(pollIntervalMs);
|
|
752
|
+
const cached = await store.get(key);
|
|
753
|
+
if (cached !== void 0) {
|
|
754
|
+
return cached;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
count("lockTimeouts", "lock-timeout");
|
|
758
|
+
if (loadOptions.onLockTimeout === "error") {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`BCP Cache: timed out waiting for distributed cache lock for "${key}".`
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (await lock.acquire(
|
|
764
|
+
lockKey,
|
|
765
|
+
ownerId,
|
|
766
|
+
lockTtlMs
|
|
767
|
+
)) {
|
|
768
|
+
count(
|
|
769
|
+
"lockAcquired",
|
|
770
|
+
"lock-acquired"
|
|
771
|
+
);
|
|
772
|
+
return executeUnderLock(
|
|
773
|
+
key,
|
|
774
|
+
ownerId,
|
|
775
|
+
lockKey,
|
|
776
|
+
lockTtlMs,
|
|
777
|
+
loader,
|
|
778
|
+
loadOptions
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
return executeLoader(
|
|
782
|
+
key,
|
|
783
|
+
loader,
|
|
784
|
+
loadOptions
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
async function executeUnderLock(key, ownerId, lockKey, lockTtlMs, loader, loadOptions) {
|
|
788
|
+
let timer;
|
|
789
|
+
let active = true;
|
|
790
|
+
if (lock?.extend) {
|
|
791
|
+
const intervalMs = Math.max(
|
|
792
|
+
1,
|
|
793
|
+
Math.floor(lockTtlMs / 3)
|
|
794
|
+
);
|
|
795
|
+
const heartbeat = async () => {
|
|
796
|
+
if (!active) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
await lock.extend?.(
|
|
801
|
+
lockKey,
|
|
802
|
+
ownerId,
|
|
803
|
+
lockTtlMs
|
|
804
|
+
);
|
|
805
|
+
} catch {
|
|
806
|
+
} finally {
|
|
807
|
+
if (active) {
|
|
808
|
+
timer = setTimeout(
|
|
809
|
+
heartbeat,
|
|
810
|
+
intervalMs
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
timer = setTimeout(
|
|
816
|
+
heartbeat,
|
|
817
|
+
intervalMs
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
const cached = await store.get(key);
|
|
822
|
+
if (cached !== void 0) {
|
|
823
|
+
return cached;
|
|
824
|
+
}
|
|
825
|
+
return await executeLoader(
|
|
826
|
+
key,
|
|
827
|
+
loader,
|
|
828
|
+
loadOptions
|
|
829
|
+
);
|
|
830
|
+
} finally {
|
|
831
|
+
active = false;
|
|
832
|
+
if (timer) {
|
|
833
|
+
clearTimeout(timer);
|
|
834
|
+
}
|
|
835
|
+
await lock?.release(
|
|
836
|
+
lockKey,
|
|
837
|
+
ownerId
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async function executeLoader(key, loader, loadOptions) {
|
|
842
|
+
count("loads", "load");
|
|
843
|
+
try {
|
|
844
|
+
const value = await loader();
|
|
845
|
+
await store.set(
|
|
846
|
+
key,
|
|
847
|
+
value,
|
|
848
|
+
loadOptions
|
|
849
|
+
);
|
|
850
|
+
return value;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
count(
|
|
853
|
+
"loadErrors",
|
|
854
|
+
"load-error"
|
|
855
|
+
);
|
|
856
|
+
throw error;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function count(key, event, value = 1) {
|
|
860
|
+
counters[key] += value;
|
|
861
|
+
metrics?.record(event, value);
|
|
862
|
+
}
|
|
863
|
+
function assertOpen() {
|
|
864
|
+
if (closed) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
"BCP Cache: cache store is closed."
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function createCacheMetrics(registry, options = {}) {
|
|
872
|
+
const prefix = normalizeMetricPrefix(
|
|
873
|
+
options.prefix ?? "bcp_cache"
|
|
874
|
+
);
|
|
875
|
+
const operations = registry.counter(
|
|
876
|
+
`${prefix}_operations_total`,
|
|
877
|
+
{
|
|
878
|
+
help: "BCP Cache operations by event.",
|
|
879
|
+
labelNames: [
|
|
880
|
+
"event"
|
|
881
|
+
]
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
const inFlight2 = registry.gauge(
|
|
885
|
+
`${prefix}_in_flight`,
|
|
886
|
+
{
|
|
887
|
+
help: "BCP Cache local in-flight loaders."
|
|
888
|
+
}
|
|
889
|
+
);
|
|
890
|
+
return {
|
|
891
|
+
record(event, value = 1) {
|
|
892
|
+
operations.inc(
|
|
893
|
+
value,
|
|
894
|
+
{
|
|
895
|
+
event
|
|
896
|
+
}
|
|
897
|
+
);
|
|
898
|
+
},
|
|
899
|
+
setInFlight(value) {
|
|
900
|
+
inFlight2.set(value);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function createRedisCacheAdapter(options) {
|
|
905
|
+
assertRedisClient(options.client);
|
|
906
|
+
const namespace = normalizeNamespace(
|
|
907
|
+
options.namespace ?? "bcp:{cache}"
|
|
908
|
+
);
|
|
909
|
+
const now = options.now ?? Date.now;
|
|
910
|
+
const serialize = options.serialize ?? JSON.stringify;
|
|
911
|
+
const deserialize = options.deserialize ?? JSON.parse;
|
|
912
|
+
const client = options.client;
|
|
913
|
+
const adapter = {
|
|
914
|
+
namespace,
|
|
915
|
+
async get(rawKey) {
|
|
916
|
+
const key = normalizeKey(rawKey);
|
|
917
|
+
const entryKey = redisEntryKey(
|
|
918
|
+
namespace,
|
|
919
|
+
key
|
|
920
|
+
);
|
|
921
|
+
const raw = redisString(
|
|
922
|
+
await client.sendCommand([
|
|
923
|
+
"GET",
|
|
924
|
+
entryKey
|
|
925
|
+
])
|
|
926
|
+
);
|
|
927
|
+
if (raw === null) {
|
|
928
|
+
await client.sendCommand([
|
|
929
|
+
"SREM",
|
|
930
|
+
redisAllKeysKey(namespace),
|
|
931
|
+
entryKey
|
|
932
|
+
]);
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
const stored = parseStoredRecord(raw);
|
|
936
|
+
if (stored.expiresAt !== null && stored.expiresAt <= now()) {
|
|
937
|
+
await deleteStoredRecord(
|
|
938
|
+
stored,
|
|
939
|
+
entryKey
|
|
940
|
+
);
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
key: stored.key,
|
|
945
|
+
value: deserialize(stored.value),
|
|
946
|
+
expiresAt: stored.expiresAt,
|
|
947
|
+
tags: [...stored.tags],
|
|
948
|
+
paths: [...stored.paths],
|
|
949
|
+
createdAt: stored.createdAt,
|
|
950
|
+
updatedAt: stored.updatedAt
|
|
951
|
+
};
|
|
952
|
+
},
|
|
953
|
+
async set(rawKey, value, setOptions = {}) {
|
|
954
|
+
const key = normalizeKey(rawKey);
|
|
955
|
+
const entryKey = redisEntryKey(
|
|
956
|
+
namespace,
|
|
957
|
+
key
|
|
958
|
+
);
|
|
959
|
+
const timestamp = now();
|
|
960
|
+
const tags = normalizeTags(setOptions.tags);
|
|
961
|
+
const paths = normalizePaths(setOptions.paths);
|
|
962
|
+
const current = await adapter.get(key);
|
|
963
|
+
const stored = {
|
|
964
|
+
key,
|
|
965
|
+
value: serializeValue(
|
|
966
|
+
value,
|
|
967
|
+
serialize
|
|
968
|
+
),
|
|
969
|
+
expiresAt: normalizeExpiresAt(
|
|
970
|
+
setOptions.expiresAt
|
|
971
|
+
),
|
|
972
|
+
tags,
|
|
973
|
+
paths,
|
|
974
|
+
tagIndexKeys: tags.map(
|
|
975
|
+
(tag) => redisTagKey(
|
|
976
|
+
namespace,
|
|
977
|
+
tag
|
|
978
|
+
)
|
|
979
|
+
),
|
|
980
|
+
pathIndexPairs: paths.map((pathname) => ({
|
|
981
|
+
path: pathname,
|
|
982
|
+
key: redisPathKey(
|
|
983
|
+
namespace,
|
|
984
|
+
pathname
|
|
985
|
+
)
|
|
986
|
+
})),
|
|
987
|
+
createdAt: current?.createdAt ?? timestamp,
|
|
988
|
+
updatedAt: timestamp
|
|
989
|
+
};
|
|
990
|
+
const ttlMs = stored.expiresAt === null ? 0 : Math.max(
|
|
991
|
+
1,
|
|
992
|
+
stored.expiresAt - timestamp
|
|
993
|
+
);
|
|
994
|
+
await evalRedis(
|
|
995
|
+
client,
|
|
996
|
+
REDIS_SET_SCRIPT,
|
|
997
|
+
[
|
|
998
|
+
entryKey,
|
|
999
|
+
redisAllKeysKey(namespace),
|
|
1000
|
+
redisPathsIndexKey(namespace)
|
|
1001
|
+
],
|
|
1002
|
+
[
|
|
1003
|
+
JSON.stringify(stored),
|
|
1004
|
+
String(ttlMs)
|
|
1005
|
+
]
|
|
1006
|
+
);
|
|
1007
|
+
},
|
|
1008
|
+
async delete(rawKey) {
|
|
1009
|
+
const key = normalizeKey(rawKey);
|
|
1010
|
+
const entryKey = redisEntryKey(
|
|
1011
|
+
namespace,
|
|
1012
|
+
key
|
|
1013
|
+
);
|
|
1014
|
+
const result = await evalRedis(
|
|
1015
|
+
client,
|
|
1016
|
+
REDIS_DELETE_SCRIPT,
|
|
1017
|
+
[
|
|
1018
|
+
entryKey,
|
|
1019
|
+
redisAllKeysKey(namespace),
|
|
1020
|
+
redisPathsIndexKey(namespace)
|
|
1021
|
+
],
|
|
1022
|
+
[]
|
|
1023
|
+
);
|
|
1024
|
+
return Number(result) > 0;
|
|
1025
|
+
},
|
|
1026
|
+
async clear() {
|
|
1027
|
+
const entryKeys = redisStringArray(
|
|
1028
|
+
await client.sendCommand([
|
|
1029
|
+
"SMEMBERS",
|
|
1030
|
+
redisAllKeysKey(namespace)
|
|
1031
|
+
])
|
|
1032
|
+
);
|
|
1033
|
+
let removed = 0;
|
|
1034
|
+
for (const entryKey of entryKeys) {
|
|
1035
|
+
const result = await evalRedis(
|
|
1036
|
+
client,
|
|
1037
|
+
REDIS_DELETE_SCRIPT,
|
|
1038
|
+
[
|
|
1039
|
+
entryKey,
|
|
1040
|
+
redisAllKeysKey(namespace),
|
|
1041
|
+
redisPathsIndexKey(namespace)
|
|
1042
|
+
],
|
|
1043
|
+
[]
|
|
1044
|
+
);
|
|
1045
|
+
removed += Number(result) > 0 ? 1 : 0;
|
|
1046
|
+
}
|
|
1047
|
+
await client.sendCommand([
|
|
1048
|
+
"DEL",
|
|
1049
|
+
redisAllKeysKey(namespace),
|
|
1050
|
+
redisPathsIndexKey(namespace)
|
|
1051
|
+
]);
|
|
1052
|
+
return removed;
|
|
1053
|
+
},
|
|
1054
|
+
async revalidateTag(rawTag) {
|
|
1055
|
+
const tag = normalizeTag2(rawTag);
|
|
1056
|
+
const tagKey = redisTagKey(
|
|
1057
|
+
namespace,
|
|
1058
|
+
tag
|
|
1059
|
+
);
|
|
1060
|
+
const result = await evalRedis(
|
|
1061
|
+
client,
|
|
1062
|
+
REDIS_INVALIDATE_SET_SCRIPT,
|
|
1063
|
+
[
|
|
1064
|
+
tagKey,
|
|
1065
|
+
redisAllKeysKey(namespace),
|
|
1066
|
+
redisPathsIndexKey(namespace)
|
|
1067
|
+
],
|
|
1068
|
+
[]
|
|
1069
|
+
);
|
|
1070
|
+
return Number(result) || 0;
|
|
1071
|
+
},
|
|
1072
|
+
async revalidatePath(rawPath) {
|
|
1073
|
+
const pathname = normalizePath2(rawPath);
|
|
1074
|
+
const registeredPaths = redisStringArray(
|
|
1075
|
+
await client.sendCommand([
|
|
1076
|
+
"SMEMBERS",
|
|
1077
|
+
redisPathsIndexKey(namespace)
|
|
1078
|
+
])
|
|
1079
|
+
);
|
|
1080
|
+
const matches = registeredPaths.filter(
|
|
1081
|
+
(path) => pathMatches2(path, pathname)
|
|
1082
|
+
).map((path) => ({
|
|
1083
|
+
path,
|
|
1084
|
+
key: redisPathKey(
|
|
1085
|
+
namespace,
|
|
1086
|
+
path
|
|
1087
|
+
)
|
|
1088
|
+
}));
|
|
1089
|
+
if (matches.length === 0) {
|
|
1090
|
+
return 0;
|
|
1091
|
+
}
|
|
1092
|
+
const result = await evalRedis(
|
|
1093
|
+
client,
|
|
1094
|
+
REDIS_INVALIDATE_PATHS_SCRIPT,
|
|
1095
|
+
[
|
|
1096
|
+
redisAllKeysKey(namespace),
|
|
1097
|
+
redisPathsIndexKey(namespace)
|
|
1098
|
+
],
|
|
1099
|
+
[
|
|
1100
|
+
JSON.stringify(matches)
|
|
1101
|
+
]
|
|
1102
|
+
);
|
|
1103
|
+
return Number(result) || 0;
|
|
1104
|
+
},
|
|
1105
|
+
async entries() {
|
|
1106
|
+
return Number(
|
|
1107
|
+
await client.sendCommand([
|
|
1108
|
+
"SCARD",
|
|
1109
|
+
redisAllKeysKey(namespace)
|
|
1110
|
+
])
|
|
1111
|
+
) || 0;
|
|
1112
|
+
},
|
|
1113
|
+
async close() {
|
|
1114
|
+
await options.close?.();
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
return adapter;
|
|
1118
|
+
async function deleteStoredRecord(_stored, entryKey) {
|
|
1119
|
+
await evalRedis(
|
|
1120
|
+
client,
|
|
1121
|
+
REDIS_DELETE_SCRIPT,
|
|
1122
|
+
[
|
|
1123
|
+
entryKey,
|
|
1124
|
+
redisAllKeysKey(namespace),
|
|
1125
|
+
redisPathsIndexKey(namespace)
|
|
1126
|
+
],
|
|
1127
|
+
[]
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function createRedisCacheLockAdapter(options) {
|
|
1132
|
+
assertRedisClient(options.client);
|
|
1133
|
+
const namespace = normalizeNamespace(
|
|
1134
|
+
options.namespace ?? "bcp:{cache}"
|
|
1135
|
+
);
|
|
1136
|
+
const client = options.client;
|
|
1137
|
+
return {
|
|
1138
|
+
namespace,
|
|
1139
|
+
async acquire(rawKey, rawOwnerId, ttlMs) {
|
|
1140
|
+
const key = redisLockKey(
|
|
1141
|
+
namespace,
|
|
1142
|
+
normalizeKey(rawKey)
|
|
1143
|
+
);
|
|
1144
|
+
const ownerId = normalizeOwnerId(rawOwnerId);
|
|
1145
|
+
const ttl = positiveInteger(
|
|
1146
|
+
ttlMs,
|
|
1147
|
+
"lock ttlMs"
|
|
1148
|
+
);
|
|
1149
|
+
const result = redisString(
|
|
1150
|
+
await client.sendCommand([
|
|
1151
|
+
"SET",
|
|
1152
|
+
key,
|
|
1153
|
+
ownerId,
|
|
1154
|
+
"NX",
|
|
1155
|
+
"PX",
|
|
1156
|
+
String(ttl)
|
|
1157
|
+
])
|
|
1158
|
+
);
|
|
1159
|
+
return result === "OK";
|
|
1160
|
+
},
|
|
1161
|
+
async extend(rawKey, rawOwnerId, ttlMs) {
|
|
1162
|
+
const result = await evalRedis(
|
|
1163
|
+
client,
|
|
1164
|
+
REDIS_LOCK_EXTEND_SCRIPT,
|
|
1165
|
+
[
|
|
1166
|
+
redisLockKey(
|
|
1167
|
+
namespace,
|
|
1168
|
+
normalizeKey(rawKey)
|
|
1169
|
+
)
|
|
1170
|
+
],
|
|
1171
|
+
[
|
|
1172
|
+
normalizeOwnerId(rawOwnerId),
|
|
1173
|
+
String(
|
|
1174
|
+
positiveInteger(
|
|
1175
|
+
ttlMs,
|
|
1176
|
+
"lock ttlMs"
|
|
1177
|
+
)
|
|
1178
|
+
)
|
|
1179
|
+
]
|
|
1180
|
+
);
|
|
1181
|
+
return Number(result) > 0;
|
|
1182
|
+
},
|
|
1183
|
+
async release(rawKey, rawOwnerId) {
|
|
1184
|
+
const result = await evalRedis(
|
|
1185
|
+
client,
|
|
1186
|
+
REDIS_LOCK_RELEASE_SCRIPT,
|
|
1187
|
+
[
|
|
1188
|
+
redisLockKey(
|
|
1189
|
+
namespace,
|
|
1190
|
+
normalizeKey(rawKey)
|
|
1191
|
+
)
|
|
1192
|
+
],
|
|
1193
|
+
[
|
|
1194
|
+
normalizeOwnerId(rawOwnerId)
|
|
1195
|
+
]
|
|
1196
|
+
);
|
|
1197
|
+
return Number(result) > 0;
|
|
1198
|
+
},
|
|
1199
|
+
async close() {
|
|
1200
|
+
await options.close?.();
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
function cloneEntry(entry) {
|
|
1205
|
+
return {
|
|
1206
|
+
...entry,
|
|
1207
|
+
tags: [...entry.tags],
|
|
1208
|
+
paths: [...entry.paths]
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function isExpired(entry, now) {
|
|
1212
|
+
return entry.expiresAt !== null && entry.expiresAt <= now;
|
|
1213
|
+
}
|
|
1214
|
+
function normalizeExpiresAt(value) {
|
|
1215
|
+
if (value === void 0 || value === null) {
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
assertFiniteTimestamp(value, "cache expiresAt");
|
|
1219
|
+
return value;
|
|
1220
|
+
}
|
|
1221
|
+
function normalizeKey(value) {
|
|
1222
|
+
const normalized = String(value ?? "").trim();
|
|
1223
|
+
if (!normalized) {
|
|
1224
|
+
throw new TypeError(
|
|
1225
|
+
"BCP Cache: key must be a non-empty string."
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
return normalized;
|
|
1229
|
+
}
|
|
1230
|
+
function normalizeOwnerId(value) {
|
|
1231
|
+
const normalized = String(value ?? "").trim();
|
|
1232
|
+
if (!normalized) {
|
|
1233
|
+
throw new TypeError(
|
|
1234
|
+
"BCP Cache: lock ownerId must be a non-empty string."
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
return normalized;
|
|
1238
|
+
}
|
|
1239
|
+
function normalizeTag2(value) {
|
|
1240
|
+
const normalized = String(value ?? "").trim();
|
|
1241
|
+
if (!normalized) {
|
|
1242
|
+
throw new TypeError(
|
|
1243
|
+
"BCP Cache: cache tags cannot be empty."
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
return normalized;
|
|
1247
|
+
}
|
|
1248
|
+
function normalizeTags(values) {
|
|
1249
|
+
return Array.from(
|
|
1250
|
+
new Set(
|
|
1251
|
+
(values ?? []).map(normalizeTag2)
|
|
1252
|
+
)
|
|
1253
|
+
).sort();
|
|
1254
|
+
}
|
|
1255
|
+
function normalizePath2(value) {
|
|
1256
|
+
let pathname = String(value ?? "").trim();
|
|
1257
|
+
if (!pathname) {
|
|
1258
|
+
return "/";
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
if (pathname.startsWith("http://") || pathname.startsWith("https://")) {
|
|
1262
|
+
pathname = new URL(pathname).pathname;
|
|
1263
|
+
}
|
|
1264
|
+
} catch {
|
|
1265
|
+
}
|
|
1266
|
+
pathname = pathname.split("?")[0].split("#")[0];
|
|
1267
|
+
if (!pathname.startsWith("/")) {
|
|
1268
|
+
pathname = `/${pathname}`;
|
|
1269
|
+
}
|
|
1270
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
1271
|
+
pathname = pathname.slice(0, -1);
|
|
1272
|
+
}
|
|
1273
|
+
return pathname;
|
|
1274
|
+
}
|
|
1275
|
+
function normalizePaths(values) {
|
|
1276
|
+
return Array.from(
|
|
1277
|
+
new Set(
|
|
1278
|
+
(values ?? []).map(normalizePath2)
|
|
1279
|
+
)
|
|
1280
|
+
).sort();
|
|
1281
|
+
}
|
|
1282
|
+
function pathMatches2(cachedPath, invalidatedPath) {
|
|
1283
|
+
if (invalidatedPath === "/") {
|
|
1284
|
+
return true;
|
|
1285
|
+
}
|
|
1286
|
+
return cachedPath === invalidatedPath || cachedPath.startsWith(
|
|
1287
|
+
`${invalidatedPath}/`
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
function positiveInteger(value, field) {
|
|
1291
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1292
|
+
throw new TypeError(
|
|
1293
|
+
`BCP Cache: ${field} must be a positive integer.`
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
return value;
|
|
1297
|
+
}
|
|
1298
|
+
function nonNegativeInteger(value, field) {
|
|
1299
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1300
|
+
throw new TypeError(
|
|
1301
|
+
`BCP Cache: ${field} must be a non-negative integer.`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
return value;
|
|
1305
|
+
}
|
|
1306
|
+
function assertFiniteTimestamp(value, field) {
|
|
1307
|
+
if (!Number.isFinite(value)) {
|
|
1308
|
+
throw new TypeError(
|
|
1309
|
+
`BCP Cache: ${field} must be a finite number.`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
function sleep(ms) {
|
|
1314
|
+
return new Promise((resolve) => {
|
|
1315
|
+
setTimeout(resolve, ms);
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
function normalizeMetricPrefix(value) {
|
|
1319
|
+
const normalized = value.trim();
|
|
1320
|
+
if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(normalized)) {
|
|
1321
|
+
throw new TypeError(
|
|
1322
|
+
"BCP Cache: metrics prefix contains unsupported characters."
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
return normalized;
|
|
1326
|
+
}
|
|
1327
|
+
function normalizeNamespace(value) {
|
|
1328
|
+
const normalized = value.trim();
|
|
1329
|
+
if (!normalized) {
|
|
1330
|
+
throw new TypeError(
|
|
1331
|
+
"BCP Cache: Redis namespace must be a non-empty string."
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
return normalized.replace(/:+$/g, "");
|
|
1335
|
+
}
|
|
1336
|
+
function encodeRedisPart(value) {
|
|
1337
|
+
return encodeURIComponent(value);
|
|
1338
|
+
}
|
|
1339
|
+
function redisEntryKey(namespace, key) {
|
|
1340
|
+
return `${namespace}:entry:${encodeRedisPart(key)}`;
|
|
1341
|
+
}
|
|
1342
|
+
function redisTagKey(namespace, tag) {
|
|
1343
|
+
return `${namespace}:tag:${encodeRedisPart(tag)}`;
|
|
1344
|
+
}
|
|
1345
|
+
function redisPathKey(namespace, pathname) {
|
|
1346
|
+
return `${namespace}:path:${encodeRedisPart(pathname)}`;
|
|
1347
|
+
}
|
|
1348
|
+
function redisAllKeysKey(namespace) {
|
|
1349
|
+
return `${namespace}:entries`;
|
|
1350
|
+
}
|
|
1351
|
+
function redisPathsIndexKey(namespace) {
|
|
1352
|
+
return `${namespace}:paths`;
|
|
1353
|
+
}
|
|
1354
|
+
function redisLockKey(namespace, key) {
|
|
1355
|
+
return `${namespace}:lock:${encodeRedisPart(key)}`;
|
|
1356
|
+
}
|
|
1357
|
+
function serializeValue(value, serialize) {
|
|
1358
|
+
const result = serialize(value);
|
|
1359
|
+
if (typeof result !== "string") {
|
|
1360
|
+
throw new TypeError(
|
|
1361
|
+
"BCP Cache: Redis serializer must return a string."
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
function parseStoredRecord(value) {
|
|
1367
|
+
const parsed = JSON.parse(value);
|
|
1368
|
+
if (!parsed || typeof parsed.key !== "string" || typeof parsed.value !== "string" || !Array.isArray(parsed.tags) || !Array.isArray(parsed.paths) || !Array.isArray(parsed.tagIndexKeys) || !Array.isArray(parsed.pathIndexPairs) || typeof parsed.createdAt !== "number" || typeof parsed.updatedAt !== "number") {
|
|
1369
|
+
throw new Error(
|
|
1370
|
+
"BCP Cache: invalid Redis cache record."
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
return parsed;
|
|
1374
|
+
}
|
|
1375
|
+
function assertRedisClient(client) {
|
|
1376
|
+
if (!client || typeof client.sendCommand !== "function") {
|
|
1377
|
+
throw new TypeError(
|
|
1378
|
+
"BCP Cache: Redis adapter requires a sendCommand client."
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
function redisString(value) {
|
|
1383
|
+
if (value === null || value === void 0) {
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
if (typeof value === "string") {
|
|
1387
|
+
return value;
|
|
1388
|
+
}
|
|
1389
|
+
if (value instanceof Uint8Array) {
|
|
1390
|
+
return new TextDecoder().decode(value);
|
|
1391
|
+
}
|
|
1392
|
+
return String(value);
|
|
1393
|
+
}
|
|
1394
|
+
function redisStringArray(value) {
|
|
1395
|
+
if (!Array.isArray(value)) {
|
|
1396
|
+
return [];
|
|
1397
|
+
}
|
|
1398
|
+
return value.map(redisString).filter(
|
|
1399
|
+
(item) => item !== null
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
async function evalRedis(client, script, keys, args) {
|
|
1403
|
+
return client.sendCommand([
|
|
1404
|
+
"EVAL",
|
|
1405
|
+
script,
|
|
1406
|
+
String(keys.length),
|
|
1407
|
+
...keys,
|
|
1408
|
+
...args
|
|
1409
|
+
]);
|
|
1410
|
+
}
|
|
1411
|
+
var REDIS_SET_SCRIPT = `
|
|
1412
|
+
local old = redis.call('GET', KEYS[1])
|
|
1413
|
+
if old then
|
|
1414
|
+
local previous = cjson.decode(old)
|
|
1415
|
+
for _, tagKey in ipairs(previous.tagIndexKeys or {}) do
|
|
1416
|
+
redis.call('SREM', tagKey, KEYS[1])
|
|
1417
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1418
|
+
end
|
|
1419
|
+
for _, pair in ipairs(previous.pathIndexPairs or {}) do
|
|
1420
|
+
redis.call('SREM', pair.key, KEYS[1])
|
|
1421
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1422
|
+
redis.call('DEL', pair.key)
|
|
1423
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
end
|
|
1427
|
+
local record = cjson.decode(ARGV[1])
|
|
1428
|
+
if tonumber(ARGV[2]) > 0 then
|
|
1429
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
1430
|
+
else
|
|
1431
|
+
redis.call('SET', KEYS[1], ARGV[1])
|
|
1432
|
+
end
|
|
1433
|
+
redis.call('SADD', KEYS[2], KEYS[1])
|
|
1434
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1435
|
+
redis.call('SADD', tagKey, KEYS[1])
|
|
1436
|
+
end
|
|
1437
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1438
|
+
redis.call('SADD', pair.key, KEYS[1])
|
|
1439
|
+
redis.call('SADD', KEYS[3], pair.path)
|
|
1440
|
+
end
|
|
1441
|
+
return 1
|
|
1442
|
+
`;
|
|
1443
|
+
var REDIS_DELETE_SCRIPT = `
|
|
1444
|
+
local raw = redis.call('GET', KEYS[1])
|
|
1445
|
+
if not raw then
|
|
1446
|
+
redis.call('SREM', KEYS[2], KEYS[1])
|
|
1447
|
+
return 0
|
|
1448
|
+
end
|
|
1449
|
+
local record = cjson.decode(raw)
|
|
1450
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1451
|
+
redis.call('SREM', tagKey, KEYS[1])
|
|
1452
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1453
|
+
end
|
|
1454
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1455
|
+
redis.call('SREM', pair.key, KEYS[1])
|
|
1456
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1457
|
+
redis.call('DEL', pair.key)
|
|
1458
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1459
|
+
end
|
|
1460
|
+
end
|
|
1461
|
+
redis.call('DEL', KEYS[1])
|
|
1462
|
+
redis.call('SREM', KEYS[2], KEYS[1])
|
|
1463
|
+
return 1
|
|
1464
|
+
`;
|
|
1465
|
+
var REDIS_INVALIDATE_SET_SCRIPT = `
|
|
1466
|
+
local members = redis.call('SMEMBERS', KEYS[1])
|
|
1467
|
+
local removed = 0
|
|
1468
|
+
for _, entryKey in ipairs(members) do
|
|
1469
|
+
local raw = redis.call('GET', entryKey)
|
|
1470
|
+
if raw then
|
|
1471
|
+
local record = cjson.decode(raw)
|
|
1472
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1473
|
+
redis.call('SREM', tagKey, entryKey)
|
|
1474
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1475
|
+
end
|
|
1476
|
+
for _, pair in ipairs(record.pathIndexPairs or {}) do
|
|
1477
|
+
redis.call('SREM', pair.key, entryKey)
|
|
1478
|
+
if redis.call('SCARD', pair.key) == 0 then
|
|
1479
|
+
redis.call('DEL', pair.key)
|
|
1480
|
+
redis.call('SREM', KEYS[3], pair.path)
|
|
1481
|
+
end
|
|
1482
|
+
end
|
|
1483
|
+
redis.call('DEL', entryKey)
|
|
1484
|
+
redis.call('SREM', KEYS[2], entryKey)
|
|
1485
|
+
removed = removed + 1
|
|
1486
|
+
else
|
|
1487
|
+
redis.call('SREM', KEYS[2], entryKey)
|
|
1488
|
+
end
|
|
1489
|
+
end
|
|
1490
|
+
redis.call('DEL', KEYS[1])
|
|
1491
|
+
return removed
|
|
1492
|
+
`;
|
|
1493
|
+
var REDIS_INVALIDATE_PATHS_SCRIPT = `
|
|
1494
|
+
local pairs = cjson.decode(ARGV[1])
|
|
1495
|
+
local seen = {}
|
|
1496
|
+
local removed = 0
|
|
1497
|
+
for _, pair in ipairs(pairs) do
|
|
1498
|
+
local members = redis.call('SMEMBERS', pair.key)
|
|
1499
|
+
for _, entryKey in ipairs(members) do
|
|
1500
|
+
if not seen[entryKey] then
|
|
1501
|
+
seen[entryKey] = true
|
|
1502
|
+
local raw = redis.call('GET', entryKey)
|
|
1503
|
+
if raw then
|
|
1504
|
+
local record = cjson.decode(raw)
|
|
1505
|
+
for _, tagKey in ipairs(record.tagIndexKeys or {}) do
|
|
1506
|
+
redis.call('SREM', tagKey, entryKey)
|
|
1507
|
+
if redis.call('SCARD', tagKey) == 0 then redis.call('DEL', tagKey) end
|
|
1508
|
+
end
|
|
1509
|
+
for _, pathPair in ipairs(record.pathIndexPairs or {}) do
|
|
1510
|
+
redis.call('SREM', pathPair.key, entryKey)
|
|
1511
|
+
if redis.call('SCARD', pathPair.key) == 0 then
|
|
1512
|
+
redis.call('DEL', pathPair.key)
|
|
1513
|
+
redis.call('SREM', KEYS[2], pathPair.path)
|
|
1514
|
+
end
|
|
1515
|
+
end
|
|
1516
|
+
redis.call('DEL', entryKey)
|
|
1517
|
+
redis.call('SREM', KEYS[1], entryKey)
|
|
1518
|
+
removed = removed + 1
|
|
1519
|
+
else
|
|
1520
|
+
redis.call('SREM', KEYS[1], entryKey)
|
|
1521
|
+
end
|
|
1522
|
+
end
|
|
1523
|
+
end
|
|
1524
|
+
redis.call('DEL', pair.key)
|
|
1525
|
+
redis.call('SREM', KEYS[2], pair.path)
|
|
1526
|
+
end
|
|
1527
|
+
return removed
|
|
1528
|
+
`;
|
|
1529
|
+
var REDIS_LOCK_RELEASE_SCRIPT = `
|
|
1530
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
1531
|
+
return redis.call('DEL', KEYS[1])
|
|
1532
|
+
end
|
|
1533
|
+
return 0
|
|
1534
|
+
`;
|
|
1535
|
+
var REDIS_LOCK_EXTEND_SCRIPT = `
|
|
1536
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
1537
|
+
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
1538
|
+
end
|
|
1539
|
+
return 0
|
|
1540
|
+
`;
|
|
1541
|
+
export {
|
|
1542
|
+
cache,
|
|
1543
|
+
clearCache,
|
|
1544
|
+
createCacheMetrics,
|
|
1545
|
+
createCacheStore,
|
|
1546
|
+
createMemoryCacheAdapter,
|
|
1547
|
+
createMemoryCacheLockAdapter,
|
|
1548
|
+
createRedisCacheAdapter,
|
|
1549
|
+
createRedisCacheLockAdapter,
|
|
1550
|
+
dedupe,
|
|
1551
|
+
getCacheStats,
|
|
1552
|
+
revalidatePath,
|
|
1553
|
+
revalidateTag
|
|
1554
|
+
};
|