@blamejs/core 0.13.39 → 0.13.40

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.13.x
10
10
 
11
+ - v0.13.40 (2026-05-29) — **Redis client stops leaking a socket and blocking exit after close; DB exit-handler registers once.** Two handle-lifecycle fixes. The Redis client's reconnect backoff used an untracked, non-unref'd timer: during a backoff window it alone could keep the event loop alive (a process that won't exit), and a reconnect scheduled before close() fired afterward and opened a fresh socket because the connect path didn't re-check the closing flag. The timer is now tracked, unref'd, cancelled in close(), and the connect path refuses to re-open once closing. Separately, the encrypted database registered its process-exit final-flush handler on every init(), so repeated init/close cycles (long test runs, hot reload) accumulated 'exit' listeners toward the MaxListenersExceeded warning; it now registers once for the process lifetime. **Fixed:** *Redis client cancels its reconnect timer on close and won't re-open a closed connection* — The reconnect backoff scheduled `setTimeout(reconnect, delay)` without keeping a handle, without `unref()`, and the reconnect path checked only `connected`/`connecting` — not `closing`. So a backoff window could by itself hold the process open (it won't exit), and a reconnect scheduled before `close()` would fire afterward and open a fresh socket with listeners — a leak after explicit close. The timer is now tracked and `unref()`'d (a backoff no longer blocks exit), cancelled in `close()`, and the connect path returns early once closing so no socket is opened after close. · *Encrypted DB registers its process-exit flush handler once, not per init()* — `b.db.init()` in encrypted mode added a `process.on("exit")` final-flush handler on every call. Across repeated init/close cycles — long test suites, hot reload, embedded re-inits — these accumulated and tripped Node's MaxListenersExceeded warning (and grew memory slightly). The handler is now registered once for the process lifetime, guarded by a module flag, and still flushes whichever encrypted DB is open at exit time.
12
+
11
13
  - 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
14
 
13
15
  - 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.
package/lib/db.js CHANGED
@@ -142,6 +142,12 @@ var storageProbeTimer = null; // periodic free-space probe handle
142
142
  var writesRefused = false; // true when free space < minFreeBytes
143
143
  var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
144
144
  var statfsProbe = null; // free-space reader (fs.statfsSync; injectable for tests)
145
+ // The process-exit final-flush handler is registered ONCE at first
146
+ // encrypted init. Re-registering per init() leaked an 'exit' listener on
147
+ // every init/close cycle (MaxListenersExceeded in long test runs / hot
148
+ // reload); the flag makes it idempotent. The handler reads live module
149
+ // state at exit time, so a later re-init is still covered.
150
+ var _exitHandlerRegistered = false;
145
151
  var dataDir = null;
146
152
  var initialized = false;
147
153
  var dataResidency = null; // operator's declared region config (validated by storage backends)
@@ -1436,10 +1442,16 @@ async function init(opts) {
1436
1442
 
1437
1443
  // Final encrypt on process exit. We don't try to unlink the plaintext
1438
1444
  // here — the SQLite handle may still be open, and the OS reclaims tmpfs
1439
- // on reboot anyway. close() does the orderly shutdown.
1440
- process.on("exit", function () {
1441
- try { encryptToDisk(); } catch (_e) { /* exit handler silent */ }
1442
- });
1445
+ // on reboot anyway. close() does the orderly shutdown. Registered ONCE
1446
+ // (guarded by the module flag) — re-registering per init() leaked an
1447
+ // 'exit' listener on every init/close cycle. The handler reads live
1448
+ // module state, so it still flushes whatever DB is open at exit.
1449
+ if (!_exitHandlerRegistered) {
1450
+ _exitHandlerRegistered = true;
1451
+ process.on("exit", function () {
1452
+ try { if (atRest === "encrypted") encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
1453
+ });
1454
+ }
1443
1455
  }
1444
1456
 
1445
1457
  log("ready (mode: " + atRest + ", path: " + dbPath + ")");
@@ -182,6 +182,11 @@ function create(opts) {
182
182
  var connected = false;
183
183
  var connecting = false;
184
184
  var closing = false;
185
+ // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
186
+ // pending backoff (otherwise a reconnect scheduled before close fires
187
+ // after it and opens a fresh socket); unref'd so a backoff window doesn't
188
+ // by itself keep the event loop alive (the process-won't-exit class).
189
+ var reconnectTimer = null;
185
190
  var rxBuffer = Buffer.alloc(0);
186
191
  // FIFO of in-flight commands awaiting a response
187
192
  var pending = [];
@@ -210,7 +215,11 @@ function create(opts) {
210
215
  }
211
216
  reconnectAttempt++;
212
217
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
213
- setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
218
+ reconnectTimer = setTimeout(function () {
219
+ reconnectTimer = null;
220
+ _connect().catch(function () { /* will reschedule */ });
221
+ }, delay);
222
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
214
223
  }
215
224
 
216
225
  function _drainPending(err) {
@@ -290,6 +299,9 @@ function create(opts) {
290
299
  }
291
300
 
292
301
  async function _connect() {
302
+ // A reconnect timer scheduled before close() can still fire afterward;
303
+ // refuse to re-open once closing so it doesn't leak a fresh socket.
304
+ if (closing) return;
293
305
  if (connected) return;
294
306
  if (connecting) {
295
307
  // Wait until current connect attempt resolves
@@ -423,6 +435,7 @@ function create(opts) {
423
435
 
424
436
  async function close() {
425
437
  closing = true;
438
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
426
439
  var err = _err("CLOSED", "redis client closed");
427
440
  _drainPending(err);
428
441
  if (socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.39",
3
+ "version": "0.13.40",
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:8c1e3aad-b831-4d99-b528-7ca57a3a6ba3",
5
+ "serialNumber": "urn:uuid:949239a7-6f00-4f14-9e96-43c3b5935fb9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T17:14:11.141Z",
8
+ "timestamp": "2026-05-29T17:53:59.118Z",
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.39",
22
+ "bom-ref": "@blamejs/core@0.13.40",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.39",
25
+ "version": "0.13.40",
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.39",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.40",
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.39",
57
+ "ref": "@blamejs/core@0.13.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]