@blamejs/core 0.4.10 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/lib/cache.js +304 -50
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.10** (2026-04-30) — bodyParser multipart: fileFilter + per-field maxBytes/mimeTypes
|
|
11
12
|
- **0.4.9** (2026-04-30) — Origin-Agent-Cluster + DNS-Prefetch-Control headers; b.auth.lockout primitive
|
|
12
13
|
- **0.4.8** (2026-04-30) — wiki SEO surface: per-page OG / Twitter / JSON-LD + sitemap.xml + robots.txt
|
|
13
14
|
- **0.4.7** (2026-04-30) — audit-fix the welcome page's "what's in the box" table
|
package/lib/cache.js
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* backend: "memory",
|
|
8
8
|
* ttlMs: C.TIME.minutes(5),
|
|
9
9
|
* maxEntries: 10000,
|
|
10
|
-
*
|
|
10
|
+
* maxBytes: C.BYTES.mib(100), // memory backend only
|
|
11
|
+
* sizeOf: function (v) { return v.byteLength; }, // optional override
|
|
12
|
+
* slidingTtl: true, // bump expiresAt on hit
|
|
13
|
+
* audit: b.audit, // optional
|
|
11
14
|
* });
|
|
12
15
|
*
|
|
13
|
-
* await cache.set("u-42", record);
|
|
16
|
+
* await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
|
|
14
17
|
* var hit = await cache.get("u-42");
|
|
15
18
|
*
|
|
16
19
|
* // Memoize / read-through:
|
|
@@ -18,15 +21,21 @@
|
|
|
18
21
|
* return db.users.findOne({ _id: "u-42" });
|
|
19
22
|
* });
|
|
20
23
|
*
|
|
24
|
+
* // Bulk invalidate (memory backend):
|
|
25
|
+
* await cache.invalidateTag("user:42"); // purges every entry tagged user:42
|
|
26
|
+
*
|
|
21
27
|
* Surface (returned by create):
|
|
22
28
|
*
|
|
23
29
|
* get(key) → value | undefined
|
|
24
|
-
* set(key, value, opts?) → void (opts: { ttlMs })
|
|
30
|
+
* set(key, value, opts?) → void (opts: { ttlMs, tags })
|
|
25
31
|
* del(key) → boolean (existed)
|
|
26
32
|
* has(key) → boolean (does NOT bump LRU recency)
|
|
27
33
|
* clear(opts?) → number (purged) (opts: { req, context })
|
|
28
34
|
* size() → number
|
|
35
|
+
* bytes() → number (memory backend only — total stored bytes)
|
|
29
36
|
* wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
|
|
37
|
+
* invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
|
|
38
|
+
* getTags(key) → string[] | null
|
|
30
39
|
* close() → void
|
|
31
40
|
*
|
|
32
41
|
* Backends:
|
|
@@ -59,14 +68,26 @@
|
|
|
59
68
|
* would drown at any reasonable QPS)
|
|
60
69
|
*
|
|
61
70
|
* The cache supports single-flight wrap (concurrent calls collapse),
|
|
62
|
-
* stale-while-revalidate, LRU eviction on the memory backend,
|
|
63
|
-
*
|
|
71
|
+
* stale-while-revalidate, LRU + bytes eviction on the memory backend,
|
|
72
|
+
* sliding TTL on hit, tag-based bulk invalidation (memory backend), a
|
|
73
|
+
* shared cluster backend, and a custom-backend escape hatch.
|
|
74
|
+
*
|
|
75
|
+
* What is NOT in the box:
|
|
64
76
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
77
|
+
* - Tag invalidation on the cluster backend — invalidating tagged
|
|
78
|
+
* entries across cluster nodes ties to a future distributed-pubsub
|
|
79
|
+
* slice. invalidateTag against a cluster-backend cache throws
|
|
80
|
+
* NOT_SUPPORTED today; operators wanting cluster-scope tag wipe
|
|
81
|
+
* run their own DELETE against _blamejs_cache.
|
|
82
|
+
* - maxBytes on the cluster backend — per-row size accounting against
|
|
83
|
+
* a shared table would mean an aggregate query on every set. The
|
|
84
|
+
* operator controls cluster-table size with their own pruning if
|
|
85
|
+
* bytes pressure surfaces.
|
|
86
|
+
* - Per-entry exact slidingTtl on the cluster backend — sliding works
|
|
87
|
+
* on cluster but extends by the cache's defaultTtlMs (we don't
|
|
88
|
+
* store per-row ttl). Operators with mixed-TTL writes wanting
|
|
89
|
+
* strict per-entry sliding use the memory backend or extend at
|
|
90
|
+
* the application layer.
|
|
70
91
|
*/
|
|
71
92
|
|
|
72
93
|
var clusterStorage = require("./cluster-storage");
|
|
@@ -84,8 +105,10 @@ var DEFAULTS = Object.freeze({
|
|
|
84
105
|
backend: "memory",
|
|
85
106
|
ttlMs: C.TIME.minutes(5),
|
|
86
107
|
maxEntries: 10000,
|
|
108
|
+
maxBytes: Infinity,
|
|
87
109
|
sweepIntervalMs: C.TIME.minutes(1),
|
|
88
110
|
staleWhileRevalidate: false,
|
|
111
|
+
slidingTtl: false,
|
|
89
112
|
auditFailures: true,
|
|
90
113
|
auditClear: true,
|
|
91
114
|
});
|
|
@@ -118,6 +141,29 @@ function _validateMaxEntries(value) {
|
|
|
118
141
|
}
|
|
119
142
|
}
|
|
120
143
|
|
|
144
|
+
function _validateMaxBytes(value) {
|
|
145
|
+
if (value === Infinity) return;
|
|
146
|
+
if (!_isFiniteNonNegative(value) || value < 1) {
|
|
147
|
+
throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
|
|
148
|
+
JSON.stringify(value));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Default sizeOf — best-effort byte estimate. Operators with structured
|
|
153
|
+
// values (large objects, custom classes) should pass their own sizeOf
|
|
154
|
+
// for accuracy.
|
|
155
|
+
function _defaultSizeOf(value) {
|
|
156
|
+
if (value === null || value === undefined) return 0;
|
|
157
|
+
if (Buffer.isBuffer(value)) return value.length;
|
|
158
|
+
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
|
|
159
|
+
if (typeof value === "number" || typeof value === "boolean") return 8;
|
|
160
|
+
// Fallback: round-trip through JSON. Cost is real; documented in the
|
|
161
|
+
// DEFAULTS docstring so operators with hot-path size accounting know
|
|
162
|
+
// to supply their own sizeOf.
|
|
163
|
+
try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
|
|
164
|
+
catch (_e) { return 0; }
|
|
165
|
+
}
|
|
166
|
+
|
|
121
167
|
function _validateBackendObject(backend) {
|
|
122
168
|
var required = ["get", "set", "del", "clear", "size", "close"];
|
|
123
169
|
if (typeof backend !== "object" || backend === null) {
|
|
@@ -156,6 +202,13 @@ function _validateCreateOpts(opts) {
|
|
|
156
202
|
}
|
|
157
203
|
if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
|
|
158
204
|
if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
|
|
205
|
+
if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
|
|
206
|
+
if (opts.sizeOf !== undefined && typeof opts.sizeOf !== "function") {
|
|
207
|
+
throw _err("BAD_OPT", "cache.create: sizeOf must be a function (value) → bytes");
|
|
208
|
+
}
|
|
209
|
+
if (opts.slidingTtl !== undefined && typeof opts.slidingTtl !== "boolean") {
|
|
210
|
+
throw _err("BAD_OPT", "cache.create: slidingTtl must be a boolean");
|
|
211
|
+
}
|
|
159
212
|
if (opts.sweepIntervalMs !== undefined) {
|
|
160
213
|
if (!_isFiniteNonNegative(opts.sweepIntervalMs) || opts.sweepIntervalMs < 1000) {
|
|
161
214
|
throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be a finite number ≥ 1000ms, got " +
|
|
@@ -199,24 +252,54 @@ function _validateKey(key, ctx) {
|
|
|
199
252
|
// re-inserting a key on hit moves it to the most-recent position).
|
|
200
253
|
|
|
201
254
|
function _memoryBackend(cfg) {
|
|
202
|
-
var entries = new Map();
|
|
255
|
+
var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
|
|
203
256
|
var maxEntries = cfg.maxEntries;
|
|
204
|
-
var
|
|
205
|
-
var
|
|
206
|
-
var
|
|
257
|
+
var maxBytes = cfg.maxBytes;
|
|
258
|
+
var sizeOf = cfg.sizeOf;
|
|
259
|
+
var slidingTtl = cfg.slidingTtl;
|
|
260
|
+
var clock = cfg.clock;
|
|
261
|
+
var emitObs = cfg.emitObs;
|
|
262
|
+
var namespace = cfg.namespace;
|
|
207
263
|
var sweepTimer = null;
|
|
264
|
+
var totalBytes = 0;
|
|
265
|
+
|
|
266
|
+
// tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
|
|
267
|
+
var tagIndex = new Map();
|
|
208
268
|
|
|
209
269
|
function _isExpired(entry, now) {
|
|
210
270
|
return entry.expiresAt !== Infinity && entry.expiresAt <= now;
|
|
211
271
|
}
|
|
212
272
|
|
|
213
|
-
function
|
|
214
|
-
if (
|
|
215
|
-
|
|
273
|
+
function _untrack(key, entry) {
|
|
274
|
+
if (!entry) return;
|
|
275
|
+
totalBytes -= entry.bytes || 0;
|
|
276
|
+
if (totalBytes < 0) totalBytes = 0;
|
|
277
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
278
|
+
for (var i = 0; i < entry.tags.length; i++) {
|
|
279
|
+
var s = tagIndex.get(entry.tags[i]);
|
|
280
|
+
if (s) {
|
|
281
|
+
s.delete(key);
|
|
282
|
+
if (s.size === 0) tagIndex.delete(entry.tags[i]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _evictByCounts() {
|
|
289
|
+
while (maxEntries !== Infinity && entries.size > maxEntries) {
|
|
216
290
|
var oldest = entries.keys().next().value;
|
|
291
|
+
var e = entries.get(oldest);
|
|
292
|
+
_untrack(oldest, e);
|
|
217
293
|
entries.delete(oldest);
|
|
218
294
|
emitObs("cache.eviction.size", { namespace: namespace });
|
|
219
295
|
}
|
|
296
|
+
while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
|
|
297
|
+
var oldestB = entries.keys().next().value;
|
|
298
|
+
var eb = entries.get(oldestB);
|
|
299
|
+
_untrack(oldestB, eb);
|
|
300
|
+
entries.delete(oldestB);
|
|
301
|
+
emitObs("cache.eviction.bytes", { namespace: namespace });
|
|
302
|
+
}
|
|
220
303
|
}
|
|
221
304
|
|
|
222
305
|
async function get(key) {
|
|
@@ -224,32 +307,63 @@ function _memoryBackend(cfg) {
|
|
|
224
307
|
var entry = entries.get(key);
|
|
225
308
|
if (!entry) return undefined;
|
|
226
309
|
if (_isExpired(entry, now)) {
|
|
310
|
+
_untrack(key, entry);
|
|
227
311
|
entries.delete(key);
|
|
228
312
|
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
229
313
|
return undefined;
|
|
230
314
|
}
|
|
315
|
+
// Sliding TTL: extend lifetime on each successful read by the
|
|
316
|
+
// entry's original ttlMs. Infinity stays Infinity.
|
|
317
|
+
if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
|
|
318
|
+
entry.expiresAt = now + entry.ttlMs;
|
|
319
|
+
}
|
|
231
320
|
// LRU recency bump: re-insert moves to the most-recent slot.
|
|
232
321
|
entries.delete(key);
|
|
233
322
|
entries.set(key, entry);
|
|
234
323
|
return entry.value;
|
|
235
324
|
}
|
|
236
325
|
|
|
237
|
-
async function set(key, value, expiresAt) {
|
|
238
|
-
// Existing key replacement:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
326
|
+
async function set(key, value, expiresAt, meta) {
|
|
327
|
+
// Existing key replacement: untrack first to rebalance bytes + tags.
|
|
328
|
+
var prior = entries.get(key);
|
|
329
|
+
if (prior) {
|
|
330
|
+
_untrack(key, prior);
|
|
331
|
+
entries.delete(key);
|
|
332
|
+
}
|
|
333
|
+
var bytes = sizeOf(value) || 0;
|
|
334
|
+
var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
|
|
335
|
+
var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
|
|
336
|
+
entries.set(key, {
|
|
337
|
+
value: value,
|
|
338
|
+
expiresAt: expiresAt,
|
|
339
|
+
ttlMs: ttlMs,
|
|
340
|
+
bytes: bytes,
|
|
341
|
+
tags: tags,
|
|
342
|
+
});
|
|
343
|
+
totalBytes += bytes;
|
|
344
|
+
if (tags && tags.length > 0) {
|
|
345
|
+
for (var i = 0; i < tags.length; i++) {
|
|
346
|
+
var s = tagIndex.get(tags[i]);
|
|
347
|
+
if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
|
|
348
|
+
s.add(key);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
_evictByCounts();
|
|
243
352
|
}
|
|
244
353
|
|
|
245
354
|
async function del(key) {
|
|
246
|
-
|
|
355
|
+
var entry = entries.get(key);
|
|
356
|
+
if (!entry) return false;
|
|
357
|
+
_untrack(key, entry);
|
|
358
|
+
entries.delete(key);
|
|
359
|
+
return true;
|
|
247
360
|
}
|
|
248
361
|
|
|
249
362
|
async function has(key) {
|
|
250
363
|
var entry = entries.get(key);
|
|
251
364
|
if (!entry) return false;
|
|
252
365
|
if (_isExpired(entry, clock())) {
|
|
366
|
+
_untrack(key, entry);
|
|
253
367
|
entries.delete(key);
|
|
254
368
|
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
255
369
|
return false;
|
|
@@ -260,6 +374,8 @@ function _memoryBackend(cfg) {
|
|
|
260
374
|
async function clear() {
|
|
261
375
|
var n = entries.size;
|
|
262
376
|
entries.clear();
|
|
377
|
+
tagIndex.clear();
|
|
378
|
+
totalBytes = 0;
|
|
263
379
|
return n;
|
|
264
380
|
}
|
|
265
381
|
|
|
@@ -273,19 +389,45 @@ function _memoryBackend(cfg) {
|
|
|
273
389
|
return live;
|
|
274
390
|
}
|
|
275
391
|
|
|
392
|
+
async function invalidateTag(tag) {
|
|
393
|
+
var keys = tagIndex.get(tag);
|
|
394
|
+
if (!keys || keys.size === 0) return 0;
|
|
395
|
+
var purged = 0;
|
|
396
|
+
var toDelete = Array.from(keys);
|
|
397
|
+
for (var i = 0; i < toDelete.length; i++) {
|
|
398
|
+
var k = toDelete[i];
|
|
399
|
+
var entry = entries.get(k);
|
|
400
|
+
if (entry) {
|
|
401
|
+
_untrack(k, entry);
|
|
402
|
+
entries.delete(k);
|
|
403
|
+
purged++;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return purged;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function getTags(key) {
|
|
410
|
+
var entry = entries.get(key);
|
|
411
|
+
if (!entry) return null;
|
|
412
|
+
return entry.tags ? entry.tags.slice() : [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function bytes() {
|
|
416
|
+
return totalBytes;
|
|
417
|
+
}
|
|
418
|
+
|
|
276
419
|
function _sweep() {
|
|
277
420
|
var now = clock();
|
|
278
421
|
var purged = 0;
|
|
279
422
|
for (var k of Array.from(entries.keys())) {
|
|
280
423
|
var e = entries.get(k);
|
|
281
424
|
if (_isExpired(e, now)) {
|
|
425
|
+
_untrack(k, e);
|
|
282
426
|
entries.delete(k);
|
|
283
427
|
purged++;
|
|
284
428
|
}
|
|
285
429
|
}
|
|
286
430
|
if (purged > 0) {
|
|
287
|
-
// Single observability event per sweep cycle is enough — operators
|
|
288
|
-
// see "purge happened with N evictions" via labels in dashboards.
|
|
289
431
|
for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
|
|
290
432
|
}
|
|
291
433
|
}
|
|
@@ -299,20 +441,25 @@ function _memoryBackend(cfg) {
|
|
|
299
441
|
async function close() {
|
|
300
442
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
301
443
|
entries.clear();
|
|
444
|
+
tagIndex.clear();
|
|
445
|
+
totalBytes = 0;
|
|
302
446
|
}
|
|
303
447
|
|
|
304
448
|
return {
|
|
305
|
-
name:
|
|
306
|
-
get:
|
|
307
|
-
set:
|
|
308
|
-
del:
|
|
309
|
-
has:
|
|
310
|
-
clear:
|
|
311
|
-
size:
|
|
312
|
-
|
|
313
|
-
|
|
449
|
+
name: "memory",
|
|
450
|
+
get: get,
|
|
451
|
+
set: set,
|
|
452
|
+
del: del,
|
|
453
|
+
has: has,
|
|
454
|
+
clear: clear,
|
|
455
|
+
size: size,
|
|
456
|
+
bytes: bytes,
|
|
457
|
+
invalidateTag: invalidateTag,
|
|
458
|
+
getTags: getTags,
|
|
459
|
+
close: close,
|
|
460
|
+
_startSweep: _startSweep,
|
|
314
461
|
// Test hook: raw entries map for state inspection
|
|
315
|
-
_entries:
|
|
462
|
+
_entries: entries,
|
|
316
463
|
};
|
|
317
464
|
}
|
|
318
465
|
|
|
@@ -321,9 +468,11 @@ function _memoryBackend(cfg) {
|
|
|
321
468
|
// value serialization. UPSERT via ON CONFLICT for atomic set.
|
|
322
469
|
|
|
323
470
|
function _clusterBackend(cfg) {
|
|
324
|
-
var namespace
|
|
325
|
-
var clock
|
|
326
|
-
var emitObs
|
|
471
|
+
var namespace = cfg.namespace;
|
|
472
|
+
var clock = cfg.clock;
|
|
473
|
+
var emitObs = cfg.emitObs;
|
|
474
|
+
var slidingTtl = cfg.slidingTtl;
|
|
475
|
+
var defaultTtlMs = cfg.defaultTtlMs;
|
|
327
476
|
|
|
328
477
|
// Composite cluster key. Namespace was validated to not contain ":"
|
|
329
478
|
// at create time, so the split is unambiguous.
|
|
@@ -348,6 +497,18 @@ function _clusterBackend(cfg) {
|
|
|
348
497
|
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
349
498
|
return undefined;
|
|
350
499
|
}
|
|
500
|
+
// Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
|
|
501
|
+
// store per-row ttl). Operators with mixed-TTL writes wanting strict
|
|
502
|
+
// per-entry sliding use the memory backend or extend at app layer.
|
|
503
|
+
// Fire-and-forget — best-effort lifetime extension.
|
|
504
|
+
if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
|
|
505
|
+
var newExpires = now + defaultTtlMs;
|
|
506
|
+
clusterStorage.execute(
|
|
507
|
+
"UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
|
|
508
|
+
"WHERE cacheKey = ? AND expiresAt > ?",
|
|
509
|
+
[newExpires, now, _composedKey(key), now]
|
|
510
|
+
).catch(function () { /* best-effort */ });
|
|
511
|
+
}
|
|
351
512
|
try { return JSON.parse(row.valueJson); }
|
|
352
513
|
catch (_e) { return undefined; }
|
|
353
514
|
}
|
|
@@ -451,7 +612,10 @@ function _customBackend(operatorBackend, cfg) {
|
|
|
451
612
|
return {
|
|
452
613
|
name: "custom",
|
|
453
614
|
get: function (key) { return operatorBackend.get(key); },
|
|
454
|
-
set: function (key, value, expiresAt
|
|
615
|
+
set: function (key, value, expiresAt, meta) {
|
|
616
|
+
// Older 3-arg backends remain compatible — meta is opt-in.
|
|
617
|
+
return operatorBackend.set(key, value, expiresAt, meta);
|
|
618
|
+
},
|
|
455
619
|
del: function (key) { return operatorBackend.del(key); },
|
|
456
620
|
has: function (key) {
|
|
457
621
|
// Optional has() — fall back to get-and-coerce if operator didn't
|
|
@@ -461,6 +625,18 @@ function _customBackend(operatorBackend, cfg) {
|
|
|
461
625
|
},
|
|
462
626
|
clear: function () { return operatorBackend.clear(); },
|
|
463
627
|
size: function () { return operatorBackend.size(); },
|
|
628
|
+
bytes: function () {
|
|
629
|
+
if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
|
|
630
|
+
return Promise.resolve(0);
|
|
631
|
+
},
|
|
632
|
+
invalidateTag: function (tag) {
|
|
633
|
+
if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
|
|
634
|
+
return Promise.resolve(0);
|
|
635
|
+
},
|
|
636
|
+
getTags: function (key) {
|
|
637
|
+
if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
|
|
638
|
+
return Promise.resolve(null);
|
|
639
|
+
},
|
|
464
640
|
close: function () { return operatorBackend.close(); },
|
|
465
641
|
_startSweep: function () { /* operator backend manages its own sweep */ },
|
|
466
642
|
};
|
|
@@ -471,8 +647,8 @@ function _customBackend(operatorBackend, cfg) {
|
|
|
471
647
|
function create(opts) {
|
|
472
648
|
opts = opts || {};
|
|
473
649
|
validateOpts(opts, [
|
|
474
|
-
"namespace", "backend", "ttlMs", "maxEntries",
|
|
475
|
-
"sweepIntervalMs", "staleWhileRevalidate",
|
|
650
|
+
"namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
|
|
651
|
+
"sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
|
|
476
652
|
"auditFailures", "auditClear",
|
|
477
653
|
"audit", "observability", "clock",
|
|
478
654
|
], "cache");
|
|
@@ -482,8 +658,11 @@ function create(opts) {
|
|
|
482
658
|
var backendKind = opts.backend || DEFAULTS.backend;
|
|
483
659
|
var defaultTtlMs = (opts.ttlMs === undefined) ? DEFAULTS.ttlMs : opts.ttlMs;
|
|
484
660
|
var maxEntries = (opts.maxEntries === undefined) ? DEFAULTS.maxEntries : opts.maxEntries;
|
|
661
|
+
var maxBytes = (opts.maxBytes === undefined) ? DEFAULTS.maxBytes : opts.maxBytes;
|
|
662
|
+
var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
|
|
485
663
|
var sweepIntervalMs = (opts.sweepIntervalMs === undefined) ? DEFAULTS.sweepIntervalMs : opts.sweepIntervalMs;
|
|
486
664
|
var staleRevalidate = (opts.staleWhileRevalidate === undefined) ? DEFAULTS.staleWhileRevalidate : opts.staleWhileRevalidate;
|
|
665
|
+
var slidingTtl = (opts.slidingTtl === undefined) ? DEFAULTS.slidingTtl : opts.slidingTtl;
|
|
487
666
|
var auditFailures = (opts.auditFailures === undefined) ? DEFAULTS.auditFailures : opts.auditFailures;
|
|
488
667
|
var auditClear = (opts.auditClear === undefined) ? DEFAULTS.auditClear : opts.auditClear;
|
|
489
668
|
var audit = opts.audit || null;
|
|
@@ -528,11 +707,15 @@ function create(opts) {
|
|
|
528
707
|
|
|
529
708
|
// Resolve backend
|
|
530
709
|
var cfg = {
|
|
531
|
-
namespace:
|
|
532
|
-
maxEntries:
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
710
|
+
namespace: namespace,
|
|
711
|
+
maxEntries: maxEntries,
|
|
712
|
+
maxBytes: maxBytes,
|
|
713
|
+
sizeOf: sizeOf,
|
|
714
|
+
slidingTtl: slidingTtl,
|
|
715
|
+
defaultTtlMs: defaultTtlMs,
|
|
716
|
+
clock: clock,
|
|
717
|
+
emitObs: emitObs,
|
|
718
|
+
_sweepTimer: null,
|
|
536
719
|
};
|
|
537
720
|
var backend;
|
|
538
721
|
if (backendKind === "memory") {
|
|
@@ -598,7 +781,15 @@ function create(opts) {
|
|
|
598
781
|
var ttlMs = _resolveTtl(callerOpts, "set");
|
|
599
782
|
if (ttlMs === 0) return; // 0 means "do not cache"
|
|
600
783
|
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
601
|
-
|
|
784
|
+
var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
|
|
785
|
+
if (tags) {
|
|
786
|
+
for (var i = 0; i < tags.length; i++) {
|
|
787
|
+
if (typeof tags[i] !== "string" || tags[i].length === 0) {
|
|
788
|
+
throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
|
|
602
793
|
catch (e) {
|
|
603
794
|
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
604
795
|
_backendFailedAudit("set", e);
|
|
@@ -670,6 +861,66 @@ function create(opts) {
|
|
|
670
861
|
}
|
|
671
862
|
}
|
|
672
863
|
|
|
864
|
+
async function bytes() {
|
|
865
|
+
_ensureOpen("bytes");
|
|
866
|
+
try {
|
|
867
|
+
if (typeof backend.bytes !== "function") return 0;
|
|
868
|
+
return await backend.bytes();
|
|
869
|
+
} catch (e) {
|
|
870
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
|
|
871
|
+
_backendFailedAudit("bytes", e);
|
|
872
|
+
throw e;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function invalidateTag(tag, callerOpts) {
|
|
877
|
+
_ensureOpen("invalidateTag");
|
|
878
|
+
if (typeof tag !== "string" || tag.length === 0) {
|
|
879
|
+
throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
|
|
880
|
+
}
|
|
881
|
+
if (typeof backend.invalidateTag !== "function") {
|
|
882
|
+
throw _err("NOT_SUPPORTED",
|
|
883
|
+
"cache.invalidateTag: backend '" + (backend.name || "custom") +
|
|
884
|
+
"' does not support tag invalidation. Tags are memory-backend only " +
|
|
885
|
+
"in this release; the cluster backend gains tag support alongside " +
|
|
886
|
+
"the distributed-invalidation slice.");
|
|
887
|
+
}
|
|
888
|
+
var purged;
|
|
889
|
+
try { purged = await backend.invalidateTag(tag); }
|
|
890
|
+
catch (e) {
|
|
891
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
|
|
892
|
+
_backendFailedAudit("invalidateTag", e);
|
|
893
|
+
throw e;
|
|
894
|
+
}
|
|
895
|
+
emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
|
|
896
|
+
if (auditClear && purged > 0) {
|
|
897
|
+
emitAudit("cache.tag.invalidated", {
|
|
898
|
+
actor: _actor(callerOpts),
|
|
899
|
+
resource: { kind: "cache.tag", id: namespace + ":" + tag },
|
|
900
|
+
outcome: "success",
|
|
901
|
+
metadata: { tag: tag, itemCount: purged },
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
// Drop in-flight wrap promises whose key WOULD have just been
|
|
905
|
+
// invalidated. We don't track per-key tags inflight, so a coarse
|
|
906
|
+
// drop matches clear()'s safer-than-stale posture.
|
|
907
|
+
inflight.clear();
|
|
908
|
+
swrInflight.clear();
|
|
909
|
+
return purged;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function getTags(key) {
|
|
913
|
+
_ensureOpen("getTags");
|
|
914
|
+
_validateKey(key, "cache.getTags");
|
|
915
|
+
if (typeof backend.getTags !== "function") return null;
|
|
916
|
+
try { return await backend.getTags(key); }
|
|
917
|
+
catch (e) {
|
|
918
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
|
|
919
|
+
_backendFailedAudit("getTags", e);
|
|
920
|
+
throw e;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
673
924
|
function _backgroundRefresh(key, fn, ttlMs) {
|
|
674
925
|
if (swrInflight.has(key)) return; // already refreshing
|
|
675
926
|
var p = (async function () {
|
|
@@ -710,7 +961,7 @@ function create(opts) {
|
|
|
710
961
|
}
|
|
711
962
|
// Backend write — failure surfaces via observability + audit but
|
|
712
963
|
// doesn't bubble (caller already has the computed value).
|
|
713
|
-
backend.set(key, value, expiresAt).catch(function (e) {
|
|
964
|
+
backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
|
|
714
965
|
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
715
966
|
_backendFailedAudit("set", e);
|
|
716
967
|
});
|
|
@@ -765,7 +1016,7 @@ function create(opts) {
|
|
|
765
1016
|
_writeWithSwr(key, computed, ttlMs);
|
|
766
1017
|
} else {
|
|
767
1018
|
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
768
|
-
try { await backend.set(key, computed, expiresAt); }
|
|
1019
|
+
try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
|
|
769
1020
|
catch (e) {
|
|
770
1021
|
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
771
1022
|
_backendFailedAudit("set", e);
|
|
@@ -803,7 +1054,10 @@ function create(opts) {
|
|
|
803
1054
|
has: has,
|
|
804
1055
|
clear: clear,
|
|
805
1056
|
size: size,
|
|
1057
|
+
bytes: bytes,
|
|
806
1058
|
wrap: wrap,
|
|
1059
|
+
invalidateTag: invalidateTag,
|
|
1060
|
+
getTags: getTags,
|
|
807
1061
|
close: close,
|
|
808
1062
|
namespace: namespace,
|
|
809
1063
|
// Test hooks
|