@blamejs/core 0.13.37 → 0.13.38
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 +2 -0
- package/lib/cache.js +33 -24
- package/lib/cluster-storage.js +114 -10
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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
|
@@ -511,32 +511,41 @@ function _clusterBackend(cfg) {
|
|
|
511
511
|
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
512
512
|
var now = clock();
|
|
513
513
|
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
514
|
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
515
|
+
// The value UPSERT and the tag-index rewrite (DELETE prior tags, then
|
|
516
|
+
// INSERT the new set) must commit as ONE unit. Done as separate
|
|
517
|
+
// statements they race: two concurrent set()s on the same key can
|
|
518
|
+
// interleave their DELETE/INSERT pairs, leaving a tag index that no
|
|
519
|
+
// longer matches the value row — so a later invalidateTag misses the
|
|
520
|
+
// key (a stale, possibly authorization-bearing, value survives a wipe).
|
|
521
|
+
// Wrapping them in a transaction makes a concurrent set see either the
|
|
522
|
+
// whole prior state or the whole new state, never a mix.
|
|
523
|
+
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
524
|
+
await clusterStorage.transaction(async function (tx) {
|
|
525
|
+
await tx.execute(
|
|
526
|
+
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
527
|
+
"VALUES (?, ?, ?, ?) " +
|
|
528
|
+
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
529
|
+
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
530
|
+
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
531
|
+
);
|
|
532
|
+
// Drop any prior tags for this key (tags can change across sets),
|
|
533
|
+
// then INSERT the new ones. The PRIMARY KEY on (cacheKey, tag) makes
|
|
534
|
+
// the INSERT idempotent if duplicate tags sneak in.
|
|
535
|
+
await tx.execute(
|
|
536
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
537
|
+
[ck]
|
|
538
|
+
);
|
|
539
|
+
if (tags && tags.length > 0) {
|
|
540
|
+
for (var i = 0; i < tags.length; i++) {
|
|
541
|
+
await tx.execute(
|
|
542
|
+
"INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
|
|
543
|
+
"ON CONFLICT (cacheKey, tag) DO NOTHING",
|
|
544
|
+
[ck, tags[i]]
|
|
545
|
+
);
|
|
546
|
+
}
|
|
538
547
|
}
|
|
539
|
-
}
|
|
548
|
+
});
|
|
540
549
|
}
|
|
541
550
|
|
|
542
551
|
async function del(key) {
|
package/lib/cluster-storage.js
CHANGED
|
@@ -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.
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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,
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:fc7082b3-a639-421e-8e57-fac70e7ced00",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-29T16:34:17.392Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.38",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.38",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.13.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|