@blamejs/core 0.13.37 → 0.13.39

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.39 (2026-05-29) — **Dual-control approvals are atomic — no quorum bypass or double-consume under concurrency.** The dual-control approval store read a grant, mutated it in memory, and wrote it back with an await in between — a non-atomic read-modify-write. Under concurrent calls (two approvals, or a retried one) each could act on a stale snapshot: the same approver could be appended twice and reach the M-of-N quorum with a single human, or a single-use grant could be consumed twice. approve / consume / revoke / cancel now commit through a new atomic cache.update primitive, so the check and the mutation are one indivisible step. The new b.cache.update is available to application code too — the memory backend is atomic by single-thread, and the cluster backend uses a transaction with compare-and-set so a concurrent writer on another node cannot lose an update. **Added:** *b.cache.update(key, mutatorFn, opts?) — atomic read-modify-write* — Reads the current value, calls `mutatorFn(current | null)`, and commits the result in one operation so a concurrent writer cannot clobber the change — the lost-update race that makes a plain `get` → mutate → `set` unsafe for counters, sets, and quorum state. The memory backend is atomic by single-thread; the cluster backend runs a transaction with a compare-and-set (and retries on contention) so the guarantee holds across nodes. `mutatorFn` returns `{ value }` to commit, `{ abort: data }` to leave the entry untouched and surface `data`, or `{ delete: true }` to remove it; the call resolves to `{ updated, value }`, `{ updated, deleted }`, or `{ aborted }`. **Security:** *Dual-control quorum and single-use guarantees hold under concurrent approvals* — `b.dualControl` persisted each grant through a cache read → in-memory mutate → write-back. Because the read and the write were separate awaited steps, two concurrent `approve` calls (or a retried one behind a load balancer) could each read the same pre-approval snapshot, so the duplicate-approver guard passed twice and the same approver was counted toward the M-of-N quorum twice — reaching quorum with one human. The same shape let two concurrent `consume` calls each see an unconsumed grant and both run the destructive operation. `approve` / `consume` / `revoke` / `cancel` now perform the check-and-mutate atomically via `cache.update`, so exactly one concurrent caller wins each transition.
12
+
13
+ - v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
14
+
11
15
  - v0.13.37 (2026-05-29) — **Encrypted-mode DB refuses writes before a full tmpfs corrupts it.** In encrypted-at-rest mode the live SQLite working copy is on a tmpfs (Docker's /dev/shm defaults to 64 MiB). If that fills — an append-only audit chain and session rows grow over time — SQLite hits ENOSPC and the working copy is corrupted. Earlier releases made that corruption self-heal on the next boot by rolling back to the last encrypted snapshot, but the rollback still loses writes since the last flush. This adds the prevention side: a periodic free-space probe refuses growth writes (INSERT / UPDATE / REPLACE) with a clear db/storage-low error once free space drops below a threshold, before the tmpfs fills. DELETE and reads stay available so retention can reclaim space and the application keeps serving, and the refusal lifts automatically once free space recovers. The threshold defaults to 16 MiB of headroom and is tunable; the guard is encrypted-mode only. **Security:** *Free-space guard refuses growth writes before the tmpfs working copy fills* — `b.db` in encrypted-at-rest mode now probes free space on the tmpfs holding the working copy and, when it falls below `minFreeBytes` (default 16 MiB), refuses `INSERT` / `UPDATE` / `REPLACE` with a clear `db/storage-low` error instead of letting the write run the mount out of space and corrupt the database. `DELETE`, reads, and DDL stay available so retention can prune and the app keeps serving; the refusal clears automatically when free space recovers. The error message points at the cause (Docker's 64 MiB `/dev/shm` default) and the fix (`shm_size` / `--shm-size`, or pruning). Set `minFreeBytes` to tune the headroom, or `0` to disable. This complements the existing boot-time recovery: prevention (fail clear, keep recent writes) ahead of recovery (roll back to the last snapshot).
12
16
 
13
17
  - v0.13.36 (2026-05-29) — **Certificate renewal trusts the sealed cert's own expiry, not the plaintext index.** The managed-certificate renewal check decided a cached cert was still fresh by reading expiresAt from the plaintext meta.json index that sits beside the sealed cert, rather than from the certificate itself. If that index drifted from — or was tampered relative to — the actual cert (a far-future expiry recorded over a certificate that is in fact near expiry), the manager would skip renewal and keep serving a cert that was about to expire or already had. Renewal now re-derives the expiry and fingerprint from the sealed certificate itself; meta.json is treated as an advisory convenience copy. A sealed cert that no longer parses is re-issued (the same recovery as an unreadable one), and a corrupt meta.json over a valid cert now loads cleanly from the cert instead of forcing a needless re-issue. The local job queue also bounds the size of a job payload it parses back from a stored row, matching the cap the dead-letter listing already used. **Fixed:** *Local job queue bounds the size of a payload parsed back from a stored row* — When the local queue leased a job or re-enqueued a repeating one, it parsed the job payload back from its stored row without an upper size bound, unlike the dead-letter listing which already capped it. A row with an oversized payload (a corrupted or tampered store) could force an unbounded parse. Both paths now cap the parse at the same 64 MiB ceiling the dead-letter path uses. **Security:** *Cert renewal re-derives expiry from the sealed certificate, not the meta.json index* — `b.cert`'s renewal short-circuit read `expiresAt` from the plaintext `meta.json` written beside each sealed cert. Because that index can drift from the actual certificate (or be altered independently of it), a far-future value over an actually-expiring cert would suppress renewal and serve a cert past — or about to pass — its validity. The renewal decision now parses the expiry and fingerprint from the sealed certificate itself on load, so `meta.json` is advisory only. A sealed cert that will not parse is treated as corrupt and re-issued; a corrupt `meta.json` over an otherwise-valid cert loads from the cert without a needless re-issue.
package/lib/cache.js CHANGED
@@ -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,
@@ -261,6 +261,33 @@ function placeholderize(sql, dialect) {
261
261
  * );
262
262
  * // → { rows: [ { counter: 43, row_hash: "..." } ], rowCount: 1 }
263
263
  */
264
+ // Single-node transaction serialization. node:sqlite is synchronous and
265
+ // the framework shares ONE local connection, so a SQLite transaction is
266
+ // connection-global: any statement that runs between this connection's
267
+ // BEGIN and COMMIT lands INSIDE the transaction. `_activeTx` is a promise
268
+ // held for the duration of a single-node transaction(); execute() waits it
269
+ // out before running so a concurrent statement can't interleave into the
270
+ // open transaction on the shared connection. It is null in cluster mode
271
+ // (the pool gives each transaction its own connection, so the DB enforces
272
+ // isolation and no global lock is needed).
273
+ var _activeTx = null;
274
+
275
+ // Raw local exec — synchronous, no transaction-lock wait. Used by execute()
276
+ // AFTER the lock wait and by transaction() for its own statements (which
277
+ // must NOT wait on the lock they themselves hold). Because node:sqlite is
278
+ // synchronous this runs atomically to completion with no interleaving.
279
+ function _localExec(sql, params) {
280
+ var stmt = _localDb().prepare(sql);
281
+ // Heuristic: if the statement returns rows (SELECT or has RETURNING),
282
+ // use .all(); otherwise .run() and report changes as rowCount.
283
+ if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
284
+ var rows = stmt.all.apply(stmt, params || []);
285
+ return { rows: rows, rowCount: rows.length };
286
+ }
287
+ var info = stmt.run.apply(stmt, params || []);
288
+ return { rows: [], rowCount: info.changes };
289
+ }
290
+
264
291
  async function execute(sql, params) {
265
292
  if (typeof sql !== "string") {
266
293
  throw new ClusterStorageError("sql must be a string", "cluster-storage/bad-arg");
@@ -275,17 +302,93 @@ async function execute(sql, params) {
275
302
  return result;
276
303
  }
277
304
 
278
- // Local SQLite path. node:sqlite is sync wrap in a resolved Promise
279
- // so callers always see the same shape regardless of mode.
280
- var stmt = _localDb().prepare(sql);
281
- // Heuristic: if the statement returns rows (SELECT or has RETURNING),
282
- // use .all(); otherwise .run() and report changes as rowCount.
283
- if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
284
- var rows = stmt.all.apply(stmt, params);
285
- return { rows: rows, rowCount: rows.length };
305
+ // Local SQLite path. Wait out any open single-node transaction so this
306
+ // statement can't interleave into it on the shared connection. The loop
307
+ // re-checks after each wait (a new transaction may have started while we
308
+ // waited); once it exits, `_localExec` runs synchronously to completion,
309
+ // so no transaction can begin between the check and the statement.
310
+ while (_activeTx) { try { await _activeTx; } catch (_e) { /* tx failed — proceed */ } }
311
+ return _localExec(sql, params);
312
+ }
313
+
314
+ /**
315
+ * @primitive b.clusterStorage.transaction
316
+ * @signature b.clusterStorage.transaction(fn)
317
+ * @since 0.13.38
318
+ * @status stable
319
+ * @related b.clusterStorage.execute
320
+ *
321
+ * Run `fn` inside an atomic transaction against the active backend, so a
322
+ * multi-statement read-modify-write commits all-or-nothing. `fn` receives a
323
+ * transaction handle exposing the same `execute` / `executeOne` /
324
+ * `executeAll` surface as the module — but scoped to the open transaction.
325
+ * Use the handle's methods inside `fn`; calling the module-level
326
+ * `b.clusterStorage.execute` from within `fn` would deadlock single-node
327
+ * (it waits for the very transaction `fn` is running).
328
+ *
329
+ * Cluster mode dispatches to the external DB's transaction (its own pooled
330
+ * connection + deadlock retry). Single-node serializes against other
331
+ * transactions and against `execute` on the shared SQLite connection.
332
+ *
333
+ * @example
334
+ * await b.clusterStorage.transaction(async function (tx) {
335
+ * var row = await tx.executeOne("SELECT v FROM t WHERE k = ?", ["x"]);
336
+ * await tx.execute("UPDATE t SET v = ? WHERE k = ?", [row.v + 1, "x"]);
337
+ * });
338
+ */
339
+ async function transaction(fn) {
340
+ if (typeof fn !== "function") {
341
+ throw new ClusterStorageError("transaction requires a function", "cluster-storage/bad-arg");
342
+ }
343
+
344
+ if (cluster.isClusterMode()) {
345
+ var dialect = cluster.dialect();
346
+ return await externalDb.transaction(async function (txClient) {
347
+ function txExec(sql, params) {
348
+ var translated = placeholderize(resolveTables(sql), dialect);
349
+ return txClient.query(translated, params || []);
350
+ }
351
+ var txHandle = {
352
+ execute: txExec,
353
+ executeOne: async function (sql, params) {
354
+ var r = await txExec(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
355
+ },
356
+ executeAll: async function (sql, params) {
357
+ var r = await txExec(sql, params); return r.rows;
358
+ },
359
+ };
360
+ return await fn(txHandle);
361
+ }, { backend: cluster.externalDbBackend() });
362
+ }
363
+
364
+ // Single-node: serialize this transaction behind any other open one, then
365
+ // hold `_activeTx` so concurrent execute()/transaction() calls wait.
366
+ while (_activeTx) { try { await _activeTx; } catch (_e) { /* prior tx failed */ } }
367
+ var releaseTx;
368
+ _activeTx = new Promise(function (resolve) { releaseTx = resolve; });
369
+ function txExecLocal(sql, params) { return Promise.resolve(_localExec(sql, params)); }
370
+ var localHandle = {
371
+ execute: txExecLocal,
372
+ executeOne: async function (sql, params) {
373
+ var r = await txExecLocal(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
374
+ },
375
+ executeAll: async function (sql, params) {
376
+ var r = await txExecLocal(sql, params); return r.rows;
377
+ },
378
+ };
379
+ try {
380
+ _localExec("BEGIN", []);
381
+ try {
382
+ var result = await fn(localHandle);
383
+ _localExec("COMMIT", []);
384
+ return result;
385
+ } catch (e) {
386
+ try { _localExec("ROLLBACK", []); } catch (_e) { /* already errored */ }
387
+ throw e;
388
+ }
389
+ } finally {
390
+ var r = releaseTx; _activeTx = null; r();
286
391
  }
287
- var info = stmt.run.apply(stmt, params);
288
- return { rows: [], rowCount: info.changes };
289
392
  }
290
393
 
291
394
  // Convenience wrappers for the two common patterns.
@@ -344,6 +447,7 @@ module.exports = {
344
447
  execute: execute,
345
448
  executeOne: executeOne,
346
449
  executeAll: executeAll,
450
+ transaction: transaction,
347
451
  tableName: tableName,
348
452
  resolveTables: resolveTables,
349
453
  placeholderize: placeholderize,
@@ -301,27 +301,27 @@ function create(opts) {
301
301
 
302
302
  async function cancel(args) {
303
303
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "cancel: args required");
304
- var record = await _load(args.grantId);
305
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
306
- if (record.consumedAt !== null) return { error: "grant-already-consumed", grantId: record.grantId };
307
- if (record.revokedAt !== null) return { error: "grant-revoked", grantId: record.grantId };
308
- if (record.cancelledAt !== null) return { error: "grant-already-cancelled", grantId: record.grantId };
309
304
  var actorId = _actorIdOf(args.cancelledBy);
310
- if (actorId !== record.requestedBy) {
311
- // Cancellation by anyone other than the requester is a revoke,
312
- // not a cancel. Surface explicitly.
313
- return { error: "only-requester-can-cancel", grantId: record.grantId,
314
- requestedBy: record.requestedBy };
315
- }
316
- record.cancelledAt = Date.now();
317
- record.cancelledReason = args.reason || null;
318
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
319
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
305
+ var outcome = await cache.update(_key(args.grantId), function (record) {
306
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
307
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
308
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId } } };
309
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-already-cancelled", grantId: record.grantId } } };
310
+ // Cancellation by anyone other than the requester is a revoke, not a
311
+ // cancel — surface explicitly.
312
+ if (actorId !== record.requestedBy) {
313
+ return { abort: { response: { error: "only-requester-can-cancel", grantId: record.grantId, requestedBy: record.requestedBy } } };
314
+ }
315
+ record.cancelledAt = Date.now();
316
+ record.cancelledReason = args.reason || null;
317
+ return { value: record, expiresAt: record.expiresAt };
318
+ }, { ttlMs: ttlMs });
319
+ if (outcome.aborted) return outcome.aborted.response;
320
+ var rec = outcome.value;
320
321
  _emit("dual.grant.cancelled",
321
- { grantId: record.grantId, action: record.action,
322
- cancelledBy: actorId, reason: args.reason || null },
322
+ { grantId: rec.grantId, action: rec.action, cancelledBy: actorId, reason: args.reason || null },
323
323
  "success", args.req);
324
- return { grantId: record.grantId, status: "cancelled" };
324
+ return { grantId: rec.grantId, status: "cancelled" };
325
325
  }
326
326
 
327
327
  async function _load(grantId) {
@@ -334,156 +334,152 @@ function create(opts) {
334
334
 
335
335
  async function approve(args) {
336
336
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "approve: args required");
337
- var record = await _load(args.grantId);
338
- if (!record) {
339
- return { error: "grant-not-found", grantId: args.grantId };
340
- }
341
- if (record.consumedAt !== null) {
342
- return { error: "grant-already-consumed", grantId: record.grantId };
343
- }
344
- if (record.revokedAt !== null) {
345
- return { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason };
346
- }
347
- if (record.cancelledAt !== null) {
348
- return { error: "grant-cancelled", grantId: record.grantId };
349
- }
350
- if (record.expiresAt < Date.now()) {
351
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
352
- "failure", args.req);
353
- await cache.del(_key(record.grantId));
354
- return { error: "grant-expired", grantId: record.grantId };
355
- }
356
337
  var approverId = _actorIdOf(args.approver);
357
338
  if (!approverId) throw _err("BAD_ARG", "approve: args.approver must be an actor with a stable id");
358
- if (forbidSelfApprove && approverId === record.requestedBy) {
359
- _emit("dual.grant.self_approval_denied",
360
- { grantId: record.grantId, action: record.action, approver: approverId },
361
- "denied", args.req);
362
- return { error: "self-approval-forbidden", grantId: record.grantId };
363
- }
364
- if (!_approverRoleOk(args.approver)) {
365
- _emit("dual.grant.role_denied",
366
- { grantId: record.grantId, action: record.action, approver: approverId,
367
- requiredRoles: approverRoles,
368
- actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] },
369
- "denied", args.req);
370
- return { error: "approver-role-required", grantId: record.grantId,
371
- requiredRoles: approverRoles };
372
- }
373
- if (record.approvedBy.indexOf(approverId) !== -1) {
374
- return { error: "already-approved-by-this-actor", grantId: record.grantId,
375
- approvedBy: record.approvedBy };
376
- }
339
+ // Pre-compute the pure, record-independent checks so the mutator stays
340
+ // side-effect-free (it may re-run under cluster CAS contention).
341
+ var roleOk = _approverRoleOk(args.approver);
377
342
  var reasonProblem = _checkReason(args.reason, "approve");
378
- if (reasonProblem) {
379
- return Object.assign({ grantId: record.grantId }, reasonProblem);
380
- }
381
- record.approvedBy.push(approverId);
382
- record.approvalsAt.push(Date.now());
383
- record.approvalReasons.push(args.reason || null);
384
- if (approverRoles && args.approver && Array.isArray(args.approver.roles)) {
385
- // Record which of the required roles satisfied the approval —
386
- // useful when an audit reviewer needs to confirm the actor
387
- // approved as e.g. their security-officer role and not their
388
- // engineer role.
389
- var hits = args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; });
390
- record.approverRoleHits.push(hits);
391
- }
392
- var status = "pending";
393
- if (record.approvedBy.length >= record.minApprovers) {
394
- status = "approved";
395
- if (record.quorumReachedAt === null) record.quorumReachedAt = Date.now();
343
+ var roleHits = (approverRoles && args.approver && Array.isArray(args.approver.roles))
344
+ ? args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; })
345
+ : null;
346
+
347
+ // Atomic read-modify-write: the duplicate-approver guard and the
348
+ // approvedBy append commit together, so two concurrent approvals (or a
349
+ // retried one) cannot each read a stale snapshot and append the same
350
+ // approver twice the quorum-bypass the get/set version allowed.
351
+ var outcome = await cache.update(_key(args.grantId), function (record) {
352
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
353
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
354
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason } } };
355
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-cancelled", grantId: record.grantId } } };
356
+ if (record.expiresAt < Date.now()) {
357
+ return { abort: { response: { error: "grant-expired", grantId: record.grantId },
358
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
359
+ }
360
+ if (forbidSelfApprove && approverId === record.requestedBy) {
361
+ return { abort: { response: { error: "self-approval-forbidden", grantId: record.grantId },
362
+ event: "dual.grant.self_approval_denied",
363
+ meta: { grantId: record.grantId, action: record.action, approver: approverId }, outcome: "denied" } };
364
+ }
365
+ if (!roleOk) {
366
+ return { abort: { response: { error: "approver-role-required", grantId: record.grantId, requiredRoles: approverRoles },
367
+ event: "dual.grant.role_denied",
368
+ meta: { grantId: record.grantId, action: record.action, approver: approverId, requiredRoles: approverRoles,
369
+ actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] }, outcome: "denied" } };
370
+ }
371
+ if (record.approvedBy.indexOf(approverId) !== -1) {
372
+ return { abort: { response: { error: "already-approved-by-this-actor", grantId: record.grantId, approvedBy: record.approvedBy.slice() } } };
373
+ }
374
+ if (reasonProblem) return { abort: { response: Object.assign({ grantId: record.grantId }, reasonProblem) } };
375
+ record.approvedBy.push(approverId);
376
+ record.approvalsAt.push(Date.now());
377
+ record.approvalReasons.push(args.reason || null);
378
+ // Record which required role satisfied the approval — an audit
379
+ // reviewer can confirm the actor approved as e.g. security-officer.
380
+ if (roleHits) record.approverRoleHits.push(roleHits);
381
+ if (record.approvedBy.length >= record.minApprovers && record.quorumReachedAt === null) {
382
+ record.quorumReachedAt = Date.now();
383
+ }
384
+ return { value: record, expiresAt: record.expiresAt };
385
+ }, { ttlMs: ttlMs });
386
+
387
+ if (outcome.aborted) {
388
+ var ab = outcome.aborted;
389
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
390
+ return ab.response;
396
391
  }
397
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
398
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
392
+ var rec = outcome.value;
393
+ var status = (rec.approvedBy.length >= rec.minApprovers) ? "approved" : "pending";
394
+ var consumeUnlockAt = rec.quorumReachedAt !== null ? rec.quorumReachedAt + rec.consumeLockMs : null;
399
395
  _emit("dual.grant.approved",
400
- { grantId: record.grantId, action: record.action, approver: approverId,
401
- approverCount: record.approvedBy.length, needs: record.minApprovers,
402
- status: status, reason: args.reason || null,
403
- consumeUnlockAt: record.quorumReachedAt !== null
404
- ? record.quorumReachedAt + record.consumeLockMs : null },
396
+ { grantId: rec.grantId, action: rec.action, approver: approverId,
397
+ approverCount: rec.approvedBy.length, needs: rec.minApprovers,
398
+ status: status, reason: args.reason || null, consumeUnlockAt: consumeUnlockAt },
405
399
  "success", args.req);
406
400
  return {
407
- grantId: record.grantId,
401
+ grantId: rec.grantId,
408
402
  status: status,
409
- approvedBy: record.approvedBy.slice(),
410
- needs: record.minApprovers,
411
- expiresAt: record.expiresAt,
412
- consumeUnlockAt: record.quorumReachedAt !== null
413
- ? record.quorumReachedAt + record.consumeLockMs : null,
403
+ approvedBy: rec.approvedBy.slice(),
404
+ needs: rec.minApprovers,
405
+ expiresAt: rec.expiresAt,
406
+ consumeUnlockAt: consumeUnlockAt,
414
407
  };
415
408
  }
416
409
 
417
410
  async function revoke(args) {
418
411
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "revoke: args required");
419
- var record = await _load(args.grantId);
420
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
421
- if (record.consumedAt !== null) {
422
- return { error: "grant-already-consumed", grantId: record.grantId };
423
- }
424
- record.revokedAt = Date.now();
425
- record.revokedReason = args.reason || null;
426
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
427
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
412
+ var revokedById = _actorIdOf(args.revokedBy);
413
+ var outcome = await cache.update(_key(args.grantId), function (record) {
414
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
415
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
416
+ record.revokedAt = Date.now();
417
+ record.revokedReason = args.reason || null;
418
+ return { value: record, expiresAt: record.expiresAt };
419
+ }, { ttlMs: ttlMs });
420
+ if (outcome.aborted) return outcome.aborted.response;
421
+ var rec = outcome.value;
428
422
  _emit("dual.grant.denied",
429
- { grantId: record.grantId, action: record.action,
430
- revokedBy: _actorIdOf(args.revokedBy), reason: args.reason || null },
423
+ { grantId: rec.grantId, action: rec.action, revokedBy: revokedById, reason: args.reason || null },
431
424
  "denied", args.req);
432
- return { grantId: record.grantId, status: "revoked" };
425
+ return { grantId: rec.grantId, status: "revoked" };
433
426
  }
434
427
 
435
428
  async function consume(grantId, args) {
436
429
  args = args || {};
437
- var record = await _load(grantId);
438
- if (!record) return { ready: false, reason: "grant-not-found" };
439
- if (record.revokedAt !== null) {
440
- return { ready: false, reason: "revoked" };
441
- }
442
- if (record.cancelledAt !== null) {
443
- return { ready: false, reason: "cancelled" };
444
- }
445
- if (record.consumedAt !== null) {
446
- return { ready: false, reason: "already-consumed" };
447
- }
448
- if (record.expiresAt < Date.now()) {
449
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
450
- "failure", args.req);
451
- await cache.del(_key(record.grantId));
452
- return { ready: false, reason: "expired" };
453
- }
454
- if (record.approvedBy.length < record.minApprovers) {
455
- return { ready: false, reason: "not-enough-approvers",
456
- approvedBy: record.approvedBy.slice(), needs: record.minApprovers };
457
- }
458
- // Cooling-off lock: ANY approval-quorum-reached grant can't consume
459
- // until consumeLockMs has passed since the final approval. Defends
460
- // against rapid-burst compromise of requester+approver.
461
- if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
462
- var unlockAt = record.quorumReachedAt + record.consumeLockMs;
463
- if (Date.now() < unlockAt) {
464
- _emit("dual.grant.consume_locked",
465
- { grantId: record.grantId, action: record.action,
466
- unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
467
- "denied", args.req);
468
- return { ready: false, reason: "consume-locked", unlockAt: unlockAt,
469
- waitMs: unlockAt - Date.now() };
430
+ // Atomic read-modify-write: the consumedAt check and the consumedAt set
431
+ // commit together (compare-and-set), so two concurrent consumes cannot
432
+ // both observe consumedAt === null and both proceed — exactly one wins
433
+ // and runs the destructive operation. The get/set version let a
434
+ // single-use grant be consumed twice.
435
+ var outcome = await cache.update(_key(grantId), function (record) {
436
+ if (!record) return { abort: { response: { ready: false, reason: "grant-not-found" } } };
437
+ if (record.revokedAt !== null) return { abort: { response: { ready: false, reason: "revoked" } } };
438
+ if (record.cancelledAt !== null) return { abort: { response: { ready: false, reason: "cancelled" } } };
439
+ if (record.consumedAt !== null) return { abort: { response: { ready: false, reason: "already-consumed" } } };
440
+ if (record.expiresAt < Date.now()) {
441
+ return { abort: { response: { ready: false, reason: "expired" }, del: true,
442
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
470
443
  }
444
+ if (record.approvedBy.length < record.minApprovers) {
445
+ return { abort: { response: { ready: false, reason: "not-enough-approvers",
446
+ approvedBy: record.approvedBy.slice(), needs: record.minApprovers } } };
447
+ }
448
+ // Cooling-off lock: a quorum-reached grant can't consume until
449
+ // consumeLockMs has passed since the final approval — defends against
450
+ // rapid-burst compromise of requester + approver.
451
+ if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
452
+ var unlockAt = record.quorumReachedAt + record.consumeLockMs;
453
+ if (Date.now() < unlockAt) {
454
+ return { abort: { response: { ready: false, reason: "consume-locked", unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
455
+ event: "dual.grant.consume_locked",
456
+ meta: { grantId: record.grantId, action: record.action, unlockAt: unlockAt, waitMs: unlockAt - Date.now() }, outcome: "denied" } };
457
+ }
458
+ }
459
+ record.consumedAt = Date.now();
460
+ return { value: record, expiresAt: record.expiresAt };
461
+ }, { ttlMs: ttlMs });
462
+
463
+ if (outcome.aborted) {
464
+ var ab = outcome.aborted;
465
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
466
+ if (ab.del) { try { await cache.del(_key(grantId)); } catch (_e) { /* sweep handles it */ } }
467
+ return ab.response;
471
468
  }
472
- record.consumedAt = Date.now();
473
- // Drop the grant from the cache after consume single-use by design.
474
- await cache.del(_key(record.grantId));
469
+ var rec = outcome.value;
470
+ // Single-use by design drop the (now consumed) grant from the cache.
471
+ await cache.del(_key(rec.grantId));
475
472
  _emit("dual.grant.consumed",
476
- { grantId: record.grantId, action: record.action,
477
- approvedBy: record.approvedBy.slice(),
478
- approvalReasons: record.approvalReasons.slice() },
473
+ { grantId: rec.grantId, action: rec.action,
474
+ approvedBy: rec.approvedBy.slice(), approvalReasons: rec.approvalReasons.slice() },
479
475
  "success", args.req);
480
476
  return {
481
477
  ready: true,
482
- grantId: record.grantId,
483
- action: record.action,
484
- resource: record.resource,
485
- approvedBy: record.approvedBy.slice(),
486
- requestedBy:record.requestedBy,
478
+ grantId: rec.grantId,
479
+ action: rec.action,
480
+ resource: rec.resource,
481
+ approvedBy: rec.approvedBy.slice(),
482
+ requestedBy:rec.requestedBy,
487
483
  };
488
484
  }
489
485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.37",
3
+ "version": "0.13.39",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:67acf3f3-dbcf-463e-a7d2-58115594e0d6",
5
+ "serialNumber": "urn:uuid:8c1e3aad-b831-4d99-b528-7ca57a3a6ba3",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T14:52:03.887Z",
8
+ "timestamp": "2026-05-29T17:14:11.141Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.37",
22
+ "bom-ref": "@blamejs/core@0.13.39",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.37",
25
+ "version": "0.13.39",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.37",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.39",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.37",
57
+ "ref": "@blamejs/core@0.13.39",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]