@blamejs/blamejs-shop 0.2.15 → 0.2.18

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/README.md +2 -2
  3. package/lib/admin.js +81 -3
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
@@ -53,6 +53,34 @@ services:
53
53
  ports: !override []
54
54
  expose:
55
55
  - "3008"
56
+ # Encrypted-at-rest mode (WIKI_DB_AT_REST=encrypted) keeps the live
57
+ # SQLite working copy in a tmpfs (BLAMEJS_TMPDIR=/dev/shm below);
58
+ # plaintext only ever touches RAM. Docker's default /dev/shm is just
59
+ # 64 MiB, which the DB outgrows over time (append-only audit chain,
60
+ # sessions) — and a tmpfs overflow truncates the working copy mid-
61
+ # write, surfacing as "database disk image is malformed" on the next
62
+ # boot. Give it real headroom.
63
+ shm_size: '512m'
64
+ # Persist the encrypted-at-rest copy (db.enc) + sealed keys so they
65
+ # survive `docker rm`, host reboot, and image redeploys, and give a
66
+ # restore point. Without a volume, /data lives in the container's
67
+ # writable layer and is destroyed on every recreate — a corrupt boot
68
+ # then has nothing to roll back to.
69
+ volumes:
70
+ - wiki-data:/data
71
+ # Stop grace must exceed the app's 30s shutdown budget (graceMs) plus
72
+ # the forced-exit watchdog margin, so a `docker stop` / rolling redeploy
73
+ # lets the DB re-encrypt phase finish before SIGKILL — otherwise the
74
+ # final flush is lost and the next boot rolls back to the prior db.enc
75
+ # snapshot. (Inherited from the base compose; restated here because the
76
+ # data-loss risk is highest in encrypted mode.)
77
+ stop_grace_period: 40s
78
+ # NOTE for PaaS platforms (Coolify, Dokku, CapRover, …) that
79
+ # regenerate this compose file on deploy: set the above via the platform
80
+ # UI instead of hand-editing here — a "Persistent Storage" mount for
81
+ # /data, a custom docker run option `--shm-size 512m`, and a stop grace
82
+ # period of at least 40s. Edits to this file are otherwise overwritten
83
+ # on the next deploy.
56
84
  # Full operator-facing environment surface. Every value is
57
85
  # overridable from the .env file; defaults match the framework's
58
86
  # built-in defaults so an operator who just wants the basics can
@@ -227,5 +255,6 @@ services:
227
255
  condition: service_healthy
228
256
 
229
257
  volumes:
258
+ wiki-data: # encrypted-at-rest DB (db.enc) + sealed keys — persistent, survives recreate/redeploy
230
259
  caddy-data:
231
260
  caddy-config:
@@ -17,6 +17,13 @@ services:
17
17
  image: blamejs-wiki:0.11.45
18
18
  container_name: blamejs-wiki
19
19
  restart: unless-stopped
20
+ # Graceful-shutdown budget. The app's shutdown orchestrator runs its
21
+ # phases within a 30s grace (graceMs) — drain in-flight, close the HTTP
22
+ # server, then the DB phase that WAL-checkpoints and (in encrypted mode)
23
+ # re-encrypts to db.enc. Docker's default 10s stop grace SIGKILLs before
24
+ # the DB phase gets to run, losing the final flush. Allow more than
25
+ # graceMs plus the forced-exit watchdog margin.
26
+ stop_grace_period: 40s
20
27
  ports:
21
28
  - "3008:3008"
22
29
  environment:
@@ -68,6 +68,16 @@ var bCrypto = require("./crypto");
68
68
  var safeJson = require("./safe-json");
69
69
  var guardIdempotencyKey = require("./guard-idempotency-key");
70
70
  var agentAudit = require("./agent-audit");
71
+ var { boundedMap } = require("./bounded-map");
72
+
73
+ // The default in-memory backend is keyed on (method, actorId, keyHash) —
74
+ // the key hash comes from request-supplied idempotency keys, so a flood of
75
+ // distinct keys would grow the Map without bound (gc only reclaims EXPIRED
76
+ // rows, and only if the operator wires a scheduler to call it). Cap it.
77
+ // Evict-oldest degrades gracefully under flood: the worst case is a dropped
78
+ // dedup record, so a retry of that one key re-executes — never an OOM.
79
+ // Operators who need a hard guarantee at scale supply a durable `opts.store`.
80
+ var DEFAULT_IN_MEMORY_MAX_ENTRIES = 100000;
71
81
 
72
82
  var audit = lazyRequire(function () { return require("./audit"); });
73
83
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
@@ -130,7 +140,7 @@ function _ensureSealTable() {
130
140
  */
131
141
  function create(opts) {
132
142
  opts = opts || {};
133
- var store = opts.store || _inMemoryBackend();
143
+ var store = opts.store || _inMemoryBackend(opts.maxInMemoryEntries);
134
144
  if (typeof store.get !== "function" || typeof store.put !== "function" ||
135
145
  typeof store.delete !== "function") {
136
146
  throw new AgentIdempotencyError("agent-idempotency/bad-store",
@@ -470,8 +480,10 @@ function _checkArgs(method, actorId, key) {
470
480
  // key is validated separately via guardIdempotencyKey.validate.
471
481
  }
472
482
 
473
- function _inMemoryBackend() {
474
- var map = new Map();
483
+ function _inMemoryBackend(maxEntries) {
484
+ // boundedMap validates maxEntries (throws bounded-map/bad-max-entries on a
485
+ // non-positive-int); undefined falls back to the default ceiling.
486
+ var map = boundedMap({ maxEntries: maxEntries || DEFAULT_IN_MEMORY_MAX_ENTRIES, policy: "evict-oldest" });
475
487
  function _k(method, actorId, hash) { return method + "\0" + actorId + "\0" + hash; }
476
488
  return {
477
489
  get: function (method, actorId, hash) {
@@ -64,6 +64,7 @@ var cluster = lazyRequire(function () { return require("./cluster"); }
64
64
  var vault = lazyRequire(function () { return require("./vault"); });
65
65
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
66
66
  var safeJson = require("./safe-json");
67
+ var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
67
68
 
68
69
  var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
69
70
 
@@ -168,6 +169,12 @@ function create(opts) {
168
169
  cluster: clusterImpl,
169
170
  audit: auditImpl,
170
171
  permissions: permissions,
172
+ // When true, registry reads (list / lookup) are scoped to the actor's
173
+ // tenant — an actor only sees / resolves agents in its own tenant
174
+ // unless it holds the cross-tenant-admin scope. Mirrors the tenant
175
+ // scoping agent-event-bus enforces on subscribe / delivery. Off by
176
+ // default (single-tenant deployments are unaffected).
177
+ tenantScope: opts.tenantScope === true,
171
178
  spawnedConsumers: [],
172
179
  streams: new Map(),
173
180
  elections: new Map(),
@@ -358,6 +365,19 @@ async function _unregister(ctx, name, args) {
358
365
  async function _lookup(ctx, name, args) {
359
366
  guardAgentRegistry.validate({ kind: "lookup", name: name }, {});
360
367
  _checkPermission(ctx, args.actor, "agent-registry:read");
368
+ // Tenant-scope gate: the row's declared tenant gates access even to a
369
+ // live in-process ref, so an actor can't acquire a handle to another
370
+ // tenant's agent. Consult the backend row (it exists as a metadata
371
+ // declaration even where a live ref is hydrated).
372
+ if (ctx.tenantScope) {
373
+ var sealedRow = await ctx.backend.get(name);
374
+ var declRow = sealedRow ? _unsealRegistryRow(sealedRow) : null;
375
+ if (declRow && !_tenantAllows(ctx, args.actor, declRow.tenantId)) {
376
+ _safeAudit(ctx, "agent.orchestrator.lookup_denied", args.actor,
377
+ { name: name, reason: "cross-tenant" });
378
+ return null;
379
+ }
380
+ }
361
381
  // Live agent ref lives in-process; the backend row exists only as
362
382
  // a metadata declaration. In multi-process deployments each process
363
383
  // hydrates its own liveAgents map by calling register() locally.
@@ -383,6 +403,10 @@ async function _list(ctx, args) {
383
403
  return rows.filter(function (r) {
384
404
  if (args.kind && r.kind !== args.kind) return false;
385
405
  if (args.tenantId && r.tenantId !== args.tenantId) return false;
406
+ // Tenant-scope gate: drop rows the actor's tenant may not see, so
407
+ // enumeration can't disclose other tenants' agents. The args.tenantId
408
+ // above is a caller-supplied FILTER, not an authorization boundary.
409
+ if (!_tenantAllows(ctx, args.actor, r.tenantId)) return false;
386
410
  return true;
387
411
  }).map(function (r) {
388
412
  return {
@@ -753,6 +777,21 @@ function _checkPermission(ctx, actor, scope) {
753
777
  }
754
778
  }
755
779
 
780
+ // Tenant-scope gate for registry reads. Returns true when the actor may
781
+ // see / resolve an agent row in `rowTenantId`: scoping disabled, the actor
782
+ // holds the cross-tenant-admin scope, or the actor's tenant matches the
783
+ // row's. Mirrors agent-tenant's CROSS_TENANT_ADMIN_SCOPE check so registry
784
+ // enumeration can't leak agents (or hand out live refs) across tenants.
785
+ function _tenantAllows(ctx, actor, rowTenantId) {
786
+ if (!ctx.tenantScope) return true;
787
+ if (ctx.permissions && actor &&
788
+ ctx.permissions.check(actor, agentTenant().CROSS_TENANT_ADMIN_SCOPE)) {
789
+ return true;
790
+ }
791
+ var actorTenant = (actor && actor.tenantId) || null;
792
+ return actorTenant !== null && actorTenant === (rowTenantId || null);
793
+ }
794
+
756
795
  function _safeAudit(ctx, action, actor, metadata) {
757
796
  agentAudit.safeAudit(ctx.audit, action, actor, metadata);
758
797
  }
@@ -53,6 +53,10 @@ var AppShutdownError = defineClass("AppShutdownError", { alwaysPermanent: true }
53
53
  var log = boot("app-shutdown");
54
54
 
55
55
  var DEFAULT_GRACE_MS = C.TIME.seconds(30);
56
+ // Headroom between the shutdown grace budget and the hard forced-exit
57
+ // watchdog, so the watchdog fires before a container supervisor's own
58
+ // stop-grace SIGKILL (set stop_grace_period > graceMs + this margin).
59
+ var FORCE_EXIT_MARGIN_MS = C.TIME.seconds(5);
56
60
 
57
61
  /**
58
62
  * @primitive b.appShutdown.create
@@ -71,6 +75,7 @@ var DEFAULT_GRACE_MS = C.TIME.seconds(30);
71
75
  *
72
76
  * @opts
73
77
  * graceMs: number, // total budget across all phases (default 30000)
78
+ * forceExitMarginMs: number, // headroom after graceMs before the signal-handler watchdog forces exit (default 5000); set the container stop grace above graceMs + this
74
79
  * phases: array, // [{ name, run: async fn, timeoutMs? }]
75
80
  * installSignalHandlers: boolean, // wire SIGTERM/SIGINT (default false)
76
81
  * signals: array, // signal names (default ["SIGTERM","SIGINT"])
@@ -94,6 +99,9 @@ function create(opts) {
94
99
  numericBounds.requirePositiveFiniteIntIfPresent(opts.graceMs,
95
100
  "app-shutdown.create: opts.graceMs", AppShutdownError, "app-shutdown/bad-grace-ms");
96
101
  var graceMs = opts.graceMs !== undefined ? opts.graceMs : DEFAULT_GRACE_MS;
102
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.forceExitMarginMs,
103
+ "app-shutdown.create: opts.forceExitMarginMs", AppShutdownError, "app-shutdown/bad-force-exit-margin-ms");
104
+ var forceExitMarginMs = opts.forceExitMarginMs !== undefined ? opts.forceExitMarginMs : FORCE_EXIT_MARGIN_MS;
97
105
  var phases = Array.isArray(opts.phases) ? opts.phases.slice() : [];
98
106
  var installSignalHandlers = !!opts.installSignalHandlers;
99
107
  for (var i = 0; i < phases.length; i++) {
@@ -224,6 +232,30 @@ function create(opts) {
224
232
  function _signalCallback(sig) {
225
233
  return function () {
226
234
  log("received " + sig + " — initiating graceful shutdown");
235
+ // Hard-deadline safety net. Per-phase budgets use a SOFT timeout
236
+ // (withTimeout lets the underlying work keep running on expiry), so
237
+ // shutdown() RESOLVING does not guarantee the process EXITS: a hung
238
+ // phase's leaked handle (a socket that won't close, a timer that keeps
239
+ // firing) can hold the event loop alive past the grace window, after
240
+ // which the supervisor SIGKILLs us and the final DB re-encrypt is lost.
241
+ // Arm an unref'd watchdog that forces a clean exit at the deadline.
242
+ // It is deliberately NOT cleared when shutdown() resolves — the whole
243
+ // point is to catch the case where the orchestration finished but the
244
+ // process won't die. unref() so it never itself keeps us alive: a clean
245
+ // shutdown with no leaked handles exits naturally well before it fires.
246
+ // process.exit() runs the registered exit handlers (db re-encrypts
247
+ // there), so the last flush still happens.
248
+ var watchdog = setTimeout(function () {
249
+ log.error("shutdown exceeded " + (graceMs + forceExitMarginMs) +
250
+ "ms without the process exiting — forcing exit (exit handlers run " +
251
+ "the final DB flush) before the supervisor SIGKILLs");
252
+ // Bounded forced exit after the grace deadline, armed ONLY inside the
253
+ // signal handler (operator opted into installSignalHandlers,
254
+ // delegating process lifecycle to the orchestrator).
255
+ // allow:process-exit — operator-delegated lifecycle, watchdog only
256
+ process.exit(process.exitCode || 1);
257
+ }, graceMs + forceExitMarginMs);
258
+ if (typeof watchdog.unref === "function") watchdog.unref();
227
259
  shutdown().then(function (result) {
228
260
  if (process.exitCode === undefined || process.exitCode === 0) {
229
261
  process.exitCode = result.ok ? 0 : 1;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ /**
3
+ * bounded-map — a Map facade that caps its entry count.
4
+ *
5
+ * Defends the unbounded-in-bounded resource-exhaustion class: an in-memory
6
+ * store keyed on request-derived input (a locale, a hostname, an
7
+ * idempotency key, a replay nonce) grows without limit between sweeps
8
+ * unless something enforces a ceiling. A periodic TTL sweep alone does not
9
+ * bound peak memory — a flood of unique keys arrives faster than the sweep
10
+ * interval. This adds the missing ceiling.
11
+ *
12
+ * Two policies for what happens on `set` when already at `maxEntries`:
13
+ *
14
+ * "evict-oldest" (default) — drop the oldest entry (insertion order)
15
+ * to make room, then store the new one. For caches whose entries are
16
+ * re-derivable on demand (Intl formatters, DNS results, idempotency
17
+ * records) eviction is cheap — the worst case is a recomputed value or
18
+ * a missed dedup under active flood, never a correctness or security
19
+ * hole. `set` always stores and returns true.
20
+ *
21
+ * "reject" — refuse the new entry (do NOT evict a live one) and return
22
+ * false. For stores where evicting an unexpired entry would be unsafe:
23
+ * a replay-protection nonce store must not drop a live nonce to admit a
24
+ * new one, because that reopens a replay window for the dropped nonce.
25
+ * The caller fails closed on a false return (treats the request as
26
+ * un-recordable → reject it). Callers should purge expired entries
27
+ * before relying on this so the ceiling is hit only under genuine flood.
28
+ *
29
+ * This is deliberately NOT `b.cache` — that is an operator-facing primitive
30
+ * with TTL, LRU-touch, observability, and pluggable backends. This is the
31
+ * minimal internal ceiling the framework's own request-keyed Maps need, and
32
+ * leaves TTL/expiry semantics to the caller (which already owns them).
33
+ *
34
+ * `onEvict(key, value)` (optional) fires when an entry is dropped to make
35
+ * room under "evict-oldest" — for an observability counter, say. It never
36
+ * fires under "reject" (nothing is evicted; the new entry is dropped).
37
+ */
38
+
39
+ var numericBounds = require("./numeric-bounds");
40
+ var { defineClass } = require("./framework-error");
41
+
42
+ var BoundedMapError = defineClass("BoundedMapError");
43
+
44
+ /**
45
+ * @param {object} opts
46
+ * @param {number} opts.maxEntries - hard ceiling; throws if not a positive finite int
47
+ * @param {string} [opts.policy] - "evict-oldest" (default) | "reject"
48
+ * @param {function} [opts.onEvict] - (key, value) called on eviction under "evict-oldest"
49
+ * @returns Map-like facade: get/has/set/delete/clear, size getter, keys/values/entries/forEach, [Symbol.iterator]
50
+ */
51
+ function boundedMap(opts) {
52
+ opts = opts || {};
53
+ if (!numericBounds.isPositiveFiniteInt(opts.maxEntries)) {
54
+ throw new BoundedMapError("bounded-map/bad-max-entries",
55
+ "boundedMap: opts.maxEntries must be a positive finite integer, got " + JSON.stringify(opts.maxEntries));
56
+ }
57
+ var maxEntries = opts.maxEntries;
58
+ var policy = opts.policy || "evict-oldest";
59
+ if (policy !== "evict-oldest" && policy !== "reject") {
60
+ throw new BoundedMapError("bounded-map/bad-policy",
61
+ "boundedMap: opts.policy must be 'evict-oldest' | 'reject', got " + JSON.stringify(policy));
62
+ }
63
+ var onEvict = typeof opts.onEvict === "function" ? opts.onEvict : null;
64
+ var inner = new Map();
65
+
66
+ function set(key, value) {
67
+ // Updating an existing key never grows the map — always allowed.
68
+ if (inner.has(key)) { inner.set(key, value); return true; }
69
+ if (inner.size >= maxEntries) {
70
+ if (policy === "reject") return false;
71
+ // evict-oldest: the first key in insertion order is the oldest.
72
+ var oldest = inner.keys().next().value;
73
+ if (oldest !== undefined || inner.has(oldest)) {
74
+ var evictedVal = inner.get(oldest);
75
+ inner.delete(oldest);
76
+ if (onEvict) { try { onEvict(oldest, evictedVal); } catch (_e) { /* obs hook — drop-silent */ } }
77
+ }
78
+ }
79
+ inner.set(key, value);
80
+ return true;
81
+ }
82
+
83
+ return {
84
+ get: function (k) { return inner.get(k); },
85
+ has: function (k) { return inner.has(k); },
86
+ set: set,
87
+ delete: function (k) { return inner.delete(k); },
88
+ clear: function () { inner.clear(); },
89
+ keys: function () { return inner.keys(); },
90
+ values: function () { return inner.values(); },
91
+ entries: function () { return inner.entries(); },
92
+ forEach: function (fn, thisArg) { return inner.forEach(fn, thisArg); },
93
+ get size() { return inner.size; },
94
+ get maxEntries() { return maxEntries; },
95
+ get policy() { return policy; },
96
+ // Iterable like a Map, so `for (var e of bmap)` yields [key, value]
97
+ // entries — callers that iterate a plain Map keep working unchanged.
98
+ [Symbol.iterator]: function () { return inner[Symbol.iterator](); },
99
+ };
100
+ }
101
+
102
+ module.exports = { boundedMap: boundedMap, BoundedMapError: BoundedMapError };
@@ -331,6 +331,25 @@ function _memoryBackend(cfg) {
331
331
  return true;
332
332
  }
333
333
 
334
+ // Atomic read-modify-write. Single-process V8 is single-threaded and the
335
+ // read + mutatorFn decision run with no `await` before the write, so no
336
+ // concurrent task can interleave between reading the current value and
337
+ // committing the new one. Same contract as the cluster backend's update.
338
+ async function _updateEntry(key, mutatorFn, expiresAt, meta) {
339
+ var now = clock();
340
+ var entry = entries.get(key);
341
+ var current = (entry && !_isExpired(entry, now)) ? entry.value : null;
342
+ var decision = mutatorFn(current);
343
+ if (decision && decision.abort !== undefined) return { aborted: decision.abort };
344
+ if (decision && decision.delete === true) {
345
+ if (entry) { _untrack(key, entry); entries.delete(key); }
346
+ return { updated: true, deleted: true };
347
+ }
348
+ var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
349
+ await set(key, decision.value, effExpires, meta);
350
+ return { updated: true, value: decision.value };
351
+ }
352
+
334
353
  async function has(key) {
335
354
  var entry = entries.get(key);
336
355
  if (!entry) return false;
@@ -420,6 +439,7 @@ function _memoryBackend(cfg) {
420
439
  name: "memory",
421
440
  get: get,
422
441
  set: set,
442
+ update: _updateEntry,
423
443
  del: del,
424
444
  has: has,
425
445
  clear: clear,
@@ -511,32 +531,109 @@ function _clusterBackend(cfg) {
511
531
  var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
512
532
  var now = clock();
513
533
  var ck = _composedKey(key);
514
- // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
515
- await clusterStorage.execute(
516
- "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
517
- "VALUES (?, ?, ?, ?) " +
518
- "ON CONFLICT (cacheKey) DO UPDATE SET " +
519
- "valueJson = ?, expiresAt = ?, updatedAt = ?",
520
- [ck, json, storedExpires, now, json, storedExpires, now]
521
- );
522
- // Tag handling: drop any prior tags for this key (tags can change
523
- // across sets), then INSERT the new ones. The PRIMARY KEY on
524
- // (cacheKey, tag) makes the INSERT idempotent if duplicate tags
525
- // sneak in.
526
534
  var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
527
- await clusterStorage.execute(
528
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
529
- [ck]
530
- );
531
- if (tags && tags.length > 0) {
532
- for (var i = 0; i < tags.length; i++) {
533
- await clusterStorage.execute(
534
- "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
535
- "ON CONFLICT (cacheKey, tag) DO NOTHING",
536
- [ck, tags[i]]
537
- );
535
+ // The value UPSERT and the tag-index rewrite (DELETE prior tags, then
536
+ // INSERT the new set) must commit as ONE unit. Done as separate
537
+ // statements they race: two concurrent set()s on the same key can
538
+ // interleave their DELETE/INSERT pairs, leaving a tag index that no
539
+ // longer matches the value row — so a later invalidateTag misses the
540
+ // key (a stale, possibly authorization-bearing, value survives a wipe).
541
+ // Wrapping them in a transaction makes a concurrent set see either the
542
+ // whole prior state or the whole new state, never a mix.
543
+ // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
544
+ await clusterStorage.transaction(async function (tx) {
545
+ await tx.execute(
546
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
547
+ "VALUES (?, ?, ?, ?) " +
548
+ "ON CONFLICT (cacheKey) DO UPDATE SET " +
549
+ "valueJson = ?, expiresAt = ?, updatedAt = ?",
550
+ [ck, json, storedExpires, now, json, storedExpires, now]
551
+ );
552
+ // Drop any prior tags for this key (tags can change across sets),
553
+ // then INSERT the new ones. The PRIMARY KEY on (cacheKey, tag) makes
554
+ // the INSERT idempotent if duplicate tags sneak in.
555
+ await tx.execute(
556
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
557
+ [ck]
558
+ );
559
+ if (tags && tags.length > 0) {
560
+ for (var i = 0; i < tags.length; i++) {
561
+ await tx.execute(
562
+ "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
563
+ "ON CONFLICT (cacheKey, tag) DO NOTHING",
564
+ [ck, tags[i]]
565
+ );
566
+ }
538
567
  }
568
+ });
569
+ }
570
+
571
+ // Atomic read-modify-write. Reads the current value, calls mutatorFn,
572
+ // and commits the result in one transaction — with a compare-and-set
573
+ // (UPDATE ... WHERE valueJson = <the exact bytes we read>) so a
574
+ // concurrent writer on another node cannot clobber the change (lost
575
+ // update). On a CAS miss the whole thing retries against the fresh
576
+ // value. Single-node serializes via clusterStorage.transaction, so the
577
+ // CAS never misses there; the retry only fires in cluster mode.
578
+ // mutatorFn(current|null) returns { value } to commit, { abort: data }
579
+ // to leave the row untouched and surface `data`, or { delete: true }.
580
+ async function _updateRow(key, mutatorFn, expiresAt, meta) {
581
+ var ck = _composedKey(key);
582
+ var maxRetries = 5;
583
+ for (var attempt = 0; attempt < maxRetries; attempt++) {
584
+ var outcome = await clusterStorage.transaction(async function (tx) {
585
+ var now = clock();
586
+ var row = await tx.executeOne(
587
+ "SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?", [ck]);
588
+ var oldRaw = null;
589
+ var current = null;
590
+ if (row && row.expiresAt > now) {
591
+ oldRaw = row.valueJson;
592
+ var stored = row.valueJson;
593
+ if (typeof stored === "string" && stored.indexOf(CACHE_SEAL_PREFIX) === 0) {
594
+ stored = vault().unseal(stored.substring(CACHE_SEAL_PREFIX.length));
595
+ }
596
+ current = safeJson.parse(stored, { maxBytes: C.BYTES.mib(64) });
597
+ }
598
+ var decision = mutatorFn(current);
599
+ if (decision && decision.abort !== undefined) return { aborted: decision.abort };
600
+ if (decision && decision.delete === true) {
601
+ if (oldRaw !== null) {
602
+ await tx.execute("DELETE FROM _blamejs_cache WHERE cacheKey = ? AND valueJson = ?", [ck, oldRaw]);
603
+ await tx.execute("DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?", [ck]);
604
+ }
605
+ return { updated: true, deleted: true };
606
+ }
607
+ var json = safeJson.stringify(decision.value);
608
+ if (meta && meta.seal === true) json = CACHE_SEAL_PREFIX + vault().seal(json);
609
+ // The mutator may pin the entry's expiry to the value's own
610
+ // lifetime (e.g. a grant whose expiresAt the mutator just read);
611
+ // otherwise the caller-resolved ttl applies.
612
+ var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
613
+ var storedExpires = (effExpires === Infinity) ? Number.MAX_SAFE_INTEGER : effExpires;
614
+ if (oldRaw === null) {
615
+ // Row was absent/expired — insert, but lose the race if another
616
+ // writer inserted concurrently (ON CONFLICT DO NOTHING → 0 rows).
617
+ var ins = await tx.execute(
618
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
619
+ "VALUES (?, ?, ?, ?) ON CONFLICT (cacheKey) DO NOTHING",
620
+ [ck, json, storedExpires, now]);
621
+ if (!ins || ins.rowCount !== 1) return { conflict: true };
622
+ } else {
623
+ // CAS: only commit if the row still holds the exact bytes we read.
624
+ var upd = await tx.execute(
625
+ "UPDATE _blamejs_cache SET valueJson = ?, expiresAt = ?, updatedAt = ? " +
626
+ "WHERE cacheKey = ? AND valueJson = ?",
627
+ [json, storedExpires, now, ck, oldRaw]);
628
+ if (!upd || upd.rowCount !== 1) return { conflict: true };
629
+ }
630
+ return { updated: true, value: decision.value };
631
+ });
632
+ if (outcome && outcome.conflict) continue; // value moved under us — retry
633
+ return outcome;
539
634
  }
635
+ throw _err("UPDATE_CONTENTION",
636
+ "cache.update: exceeded " + maxRetries + " retries under write contention for key");
540
637
  }
541
638
 
542
639
  async function del(key) {
@@ -672,6 +769,7 @@ function _clusterBackend(cfg) {
672
769
  name: "cluster",
673
770
  get: get,
674
771
  set: set,
772
+ update: _updateRow,
675
773
  del: del,
676
774
  has: has,
677
775
  clear: clear,
@@ -1014,6 +1112,68 @@ function create(opts) {
1014
1112
  emitObs("cache.set", { namespace: namespace });
1015
1113
  }
1016
1114
 
1115
+ /**
1116
+ * @primitive b.cache.update
1117
+ * @signature b.cache.update(key, mutatorFn, opts?)
1118
+ * @since 0.13.39
1119
+ * @status stable
1120
+ * @related b.cache.create
1121
+ *
1122
+ * Atomic read-modify-write. Reads the current value, calls
1123
+ * `mutatorFn(current | null)`, and commits the result in one operation
1124
+ * so a concurrent writer cannot clobber the change (lost update) — the
1125
+ * race that makes a plain `get` → mutate → `set` unsafe for counters,
1126
+ * sets, and quorum state. The memory backend is atomic by single-thread;
1127
+ * the cluster backend uses a transaction with compare-and-set + retry.
1128
+ *
1129
+ * `mutatorFn` returns one of: `{ value }` to commit the new value,
1130
+ * `{ abort: data }` to leave the entry untouched and surface `data` to
1131
+ * the caller, or `{ delete: true }` to remove the entry. The call
1132
+ * resolves to `{ updated: true, value }`, `{ updated: true, deleted: true }`,
1133
+ * or `{ aborted: data }`.
1134
+ *
1135
+ * @opts
1136
+ * ttlMs: number | Infinity, // lifetime of the written value; default the instance ttlMs
1137
+ * seal: boolean, // cluster backend only — seal the value at rest
1138
+ *
1139
+ * @example
1140
+ * await counters.update("hits", function (n) {
1141
+ * return { value: (n || 0) + 1 };
1142
+ * });
1143
+ */
1144
+ async function update(key, mutatorFn, callerOpts) {
1145
+ _ensureOpen("update");
1146
+ _validateKey(key, "cache.update");
1147
+ if (typeof mutatorFn !== "function") {
1148
+ throw _err("BAD_OPT", "cache.update: mutatorFn must be a function, got " + typeof mutatorFn);
1149
+ }
1150
+ if (typeof backend.update !== "function") {
1151
+ throw _err("UNSUPPORTED",
1152
+ "cache.update is unsupported by the '" + (backend.name || "custom") + "' backend " +
1153
+ "(memory + cluster implement it; a custom backend must provide update for atomic RMW).");
1154
+ }
1155
+ var ttlMs = _resolveTtl(callerOpts, "update");
1156
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
1157
+ var seal = !!(callerOpts && callerOpts.seal === true);
1158
+ if (seal && backend.name !== "cluster") {
1159
+ throw _err("BAD_OPT",
1160
+ "cache.update: seal: true is only supported on the cluster backend " +
1161
+ "(this cache instance uses '" + (backend.name || "custom") + "').");
1162
+ }
1163
+ var result;
1164
+ try { result = await backend.update(key, mutatorFn, expiresAt, { ttlMs: ttlMs, seal: seal }); }
1165
+ catch (e) {
1166
+ emitObs("cache.backend.failed", { namespace: namespace, op: "update" });
1167
+ _backendFailedAudit("update", e);
1168
+ throw e;
1169
+ }
1170
+ if (result && (result.updated || result.deleted)) {
1171
+ emitObs("cache.update", { namespace: namespace });
1172
+ if (result.deleted) { softExpiry.delete(key); _publishInvalidation({ kind: "del", key: key }); }
1173
+ }
1174
+ return result;
1175
+ }
1176
+
1017
1177
  async function del(key) {
1018
1178
  _ensureOpen("del");
1019
1179
  _validateKey(key, "cache.del");
@@ -1308,6 +1468,7 @@ function create(opts) {
1308
1468
  return {
1309
1469
  get: get,
1310
1470
  set: set,
1471
+ update: update,
1311
1472
  del: del,
1312
1473
  has: has,
1313
1474
  clear: clear,