@blamejs/core 0.4.10 → 0.4.12

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - **0.4.11** (2026-04-30) — b.cache: bytes-cap eviction, sliding TTL, tag invalidation
12
+ - **0.4.10** (2026-04-30) — bodyParser multipart: fileFilter + per-field maxBytes/mimeTypes
11
13
  - **0.4.9** (2026-04-30) — Origin-Agent-Cluster + DNS-Prefetch-Control headers; b.auth.lockout primitive
12
14
  - **0.4.8** (2026-04-30) — wiki SEO surface: per-page OG / Twitter / JSON-LD + sitemap.xml + robots.txt
13
15
  - **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
- * audit: b.audit, // optional
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, a shared
63
- * cluster backend, and a custom-backend escape hatch.
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
- * Distributed pubsub invalidation, tag-based invalidation, and
66
- * compression for the cluster backend are not built in — the cluster
67
- * backend is always-fresh-by-shared-table, memory caches go
68
- * stale-on-other-nodes by design, and read-through is what `wrap()`
69
- * is for.
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(); // key → { value, expiresAt }
255
+ var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
203
256
  var maxEntries = cfg.maxEntries;
204
- var clock = cfg.clock;
205
- var emitObs = cfg.emitObs;
206
- var namespace = cfg.namespace;
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 _evictOldestIfFull() {
214
- if (maxEntries === Infinity) return;
215
- while (entries.size > maxEntries) {
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: delete first so re-insert lands at the
239
- // most-recent position (LRU on overwrite).
240
- entries.delete(key);
241
- entries.set(key, { value: value, expiresAt: expiresAt });
242
- _evictOldestIfFull();
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
- return entries.delete(key);
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: "memory",
306
- get: get,
307
- set: set,
308
- del: del,
309
- has: has,
310
- clear: clear,
311
- size: size,
312
- close: close,
313
- _startSweep: _startSweep,
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: 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 = cfg.namespace;
325
- var clock = cfg.clock;
326
- var emitObs = cfg.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) { return operatorBackend.set(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: namespace,
532
- maxEntries: maxEntries,
533
- clock: clock,
534
- emitObs: emitObs,
535
- _sweepTimer: null,
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
- try { await backend.set(key, value, expiresAt); }
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
package/lib/log.js CHANGED
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Each line is one JSON object on a single line, terminated with `\n`.
11
11
  * Levels: debug (0) < info (1) < warn (2) < error (3) < fatal (4).
12
- * info-and-below routes to stdout; warn-and-up routes to stderr.
12
+ * Default routing: debug / info / warn stdout; error / fatal stderr.
13
+ * Multi-sink config (`sinks: [...]`) takes full control of routing.
13
14
  *
14
15
  * var log = b.log.create({
15
16
  * level: "info", // env LOG_LEVEL > opts.level > "info"
@@ -17,6 +18,19 @@
17
18
  * redact: true, // run extras through lib/redact
18
19
  * });
19
20
  *
21
+ * // Multi-sink: each sink gets every line at-or-above its own level.
22
+ * // Default (no `sinks` opt) splits info-and-below to stdout and
23
+ * // warn-and-up to stderr — same as before.
24
+ * var log = b.log.create({
25
+ * level: "debug",
26
+ * sinks: [
27
+ * { stream: process.stdout, level: "info" },
28
+ * { stream: fs.createWriteStream("./logs/debug.log"), level: "debug" },
29
+ * { stream: fs.createWriteStream("./logs/errors.log"), level: "error" },
30
+ * ],
31
+ * });
32
+ * // sinks: [...] is mutually exclusive with destination/errorDestination.
33
+ *
20
34
  * log.info("user logged in", { userId: "u-1" });
21
35
  * log.error("payment failed", { orderId, err: e.message });
22
36
  *
@@ -116,10 +130,56 @@ function _mergeExtras(into, extras, redactExtras) {
116
130
  return clobberAttempt;
117
131
  }
118
132
 
133
+ function _resolveSinks(opts) {
134
+ // Three input shapes — pick exactly one:
135
+ // (a) opts.sinks: [{ stream, level }, ...]
136
+ // (b) opts.destination + opts.errorDestination (legacy two-sink split)
137
+ // (c) neither — defaults to stdout for info-and-below, stderr for warn-and-up
138
+ if (Array.isArray(opts.sinks)) {
139
+ if (opts.destination !== undefined || opts.errorDestination !== undefined) {
140
+ throw new LogError("log/conflicting-sinks",
141
+ "log.create: pass either { sinks: [...] } OR { destination, errorDestination }, not both");
142
+ }
143
+ if (opts.sinks.length === 0) {
144
+ throw new LogError("log/no-sinks",
145
+ "log.create: sinks: [] would silently drop every line — pass at least one sink");
146
+ }
147
+ return opts.sinks.map(function (s, i) {
148
+ if (!s || typeof s !== "object") {
149
+ throw new LogError("log/bad-sink", "sinks[" + i + "]: expected object with { stream, level? }");
150
+ }
151
+ var allowed = ["stream", "level"];
152
+ var keys = Object.keys(s);
153
+ for (var j = 0; j < keys.length; j++) {
154
+ if (allowed.indexOf(keys[j]) === -1) {
155
+ throw new LogError("log/bad-sink",
156
+ "sinks[" + i + "]: unknown key '" + keys[j] + "' (allowed: " + allowed.join(", ") + ")");
157
+ }
158
+ }
159
+ var stream = _normalizeDestination(s.stream, null);
160
+ if (!stream) {
161
+ throw new LogError("log/bad-sink", "sinks[" + i + "]: stream is required");
162
+ }
163
+ // Per-sink level: missing → no filter beyond the global; present → must be valid.
164
+ var minLevel = (s.level === undefined) ? null : _normalizeLevel(s.level);
165
+ return { stream: stream, minLevel: minLevel };
166
+ });
167
+ }
168
+ // Legacy / default — synthesize the two-sink split.
169
+ var stdoutDest = _normalizeDestination(opts.destination, process.stdout);
170
+ var stderrDest = _normalizeDestination(opts.errorDestination, process.stderr);
171
+ return [
172
+ // Order matters for emit fan-out: stdout sink catches debug-info-warn;
173
+ // stderr catches error-and-up. Existing behavior — same boundary.
174
+ { stream: stdoutDest, minLevel: null, _maxLevelExclusive: LEVELS.error },
175
+ { stream: stderrDest, minLevel: LEVELS.error },
176
+ ];
177
+ }
178
+
119
179
  function create(opts) {
120
180
  opts = opts || {};
121
181
  validateOpts(opts, [
122
- "level", "destination", "errorDestination",
182
+ "level", "destination", "errorDestination", "sinks",
123
183
  "format", "redact", "base", "clock",
124
184
  ], "b.log");
125
185
 
@@ -134,8 +194,7 @@ function create(opts) {
134
194
  level = LEVELS.info;
135
195
  }
136
196
 
137
- var stdoutDest = _normalizeDestination(opts.destination, process.stdout);
138
- var stderrDest = _normalizeDestination(opts.errorDestination, process.stderr);
197
+ var sinks = _resolveSinks(opts);
139
198
 
140
199
  var format = opts.format || "json"; // reserved for future formats
141
200
  if (format !== "json") {
@@ -197,9 +256,16 @@ function create(opts) {
197
256
  }) + "\n";
198
257
  }
199
258
 
200
- var dest = (LEVELS[levelName] >= LEVELS.error) ? stderrDest : stdoutDest;
201
- try { dest.write(line); }
202
- catch (_e) { /* destination write best-effort — never throw out of a log call */ }
259
+ var lvlNum = LEVELS[levelName];
260
+ for (var s = 0; s < sinks.length; s++) {
261
+ var sink = sinks[s];
262
+ if (sink.minLevel !== null && lvlNum < sink.minLevel) continue;
263
+ // Legacy default-sinks split uses an exclusive upper bound so the
264
+ // stdout sink catches only info-and-below (warn+ goes to stderr).
265
+ if (sink._maxLevelExclusive !== undefined && lvlNum >= sink._maxLevelExclusive) continue;
266
+ try { sink.stream.write(line); }
267
+ catch (_e) { /* sink write best-effort — never throw out of a log call */ }
268
+ }
203
269
  }
204
270
 
205
271
  function _makeInstance(boundChain) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",