@blamejs/core 0.15.8 → 0.15.10

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.
@@ -101,7 +101,17 @@ function create(config) {
101
101
  });
102
102
  }
103
103
 
104
- function deleteKey(key) {
104
+ function deleteKey(key, opts) {
105
+ opts = opts || {};
106
+ // A filesystem has no object versions; a versioned-delete request can only
107
+ // be a caller mistake. Refuse loudly rather than unlink the single on-disk
108
+ // file and report a version was erased.
109
+ if (opts.versionId) {
110
+ throw _err("VERSIONID_UNSUPPORTED",
111
+ "deleteKey: versioned delete (opts.versionId) is not supported on the " +
112
+ "filesystem backend — a local file has no version history. Use a sigv4 " +
113
+ "(S3 Object-Lock) backend for version erasure.", true);
114
+ }
105
115
  cluster.requireLeader();
106
116
  var full = _resolveSafe(rootDir, key);
107
117
  if (!nodeFs.existsSync(full)) return Promise.resolve(false);
@@ -499,7 +499,15 @@ function create(config) {
499
499
  var headers = _makeSigned("PUT", url, payloadHash, extra);
500
500
  return _request("PUT", url, headers, buf, reqOpts).then(function (res) {
501
501
  _verifySseResponse(sseRequested, res.headers);
502
- return { size: buf.length, etag: res.headers.etag };
502
+ return {
503
+ size: buf.length,
504
+ etag: res.headers.etag,
505
+ // On a versioning-enabled (Object-Lock) bucket S3/MinIO returns the
506
+ // version this PUT created. Surface it so callers can target the
507
+ // exact version for a later versioned delete / erasure — without it
508
+ // the only way to find the version is a separate listVersions() call.
509
+ versionId: res.headers && res.headers["x-amz-version-id"] || null,
510
+ };
503
511
  });
504
512
  }
505
513
 
@@ -601,7 +609,12 @@ function create(config) {
601
609
  // response may or may not echo the header depending on vendor;
602
610
  // re-verifying here would double-fault on otherwise-fine setups.
603
611
  var result = completeDoc.CompleteMultipartUploadResult || {};
604
- return { size: totalSize, etag: result.ETag || completeRes.headers.etag, multipart: true };
612
+ return {
613
+ size: totalSize,
614
+ etag: result.ETag || completeRes.headers.etag,
615
+ multipart: true,
616
+ versionId: completeRes.headers && completeRes.headers["x-amz-version-id"] || null,
617
+ };
605
618
  } catch (e) {
606
619
  // Abort cleans up server-side storage for the partial upload.
607
620
  // Failures here are silently swallowed — the caller's original
@@ -639,6 +652,11 @@ function create(config) {
639
652
  function getResponse(key, opts) {
640
653
  opts = opts || {};
641
654
  var url = _keyToUrl(key);
655
+ // Reading a specific version (opts.versionId) is the read half of the
656
+ // WORM erasure workflow — verify a protected version is present before /
657
+ // gone after a versioned delete. Set it before signing so the query
658
+ // param is in the SigV4 canonical request.
659
+ if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
642
660
  var headers = _makeSigned("GET", url, sha256Hex(Buffer.alloc(0)));
643
661
  if (opts.range) {
644
662
  headers["Range"] = "bytes=" + opts.range.start + "-" + opts.range.end;
@@ -674,8 +692,10 @@ function create(config) {
674
692
  });
675
693
  }
676
694
 
677
- function head(key) {
695
+ function head(key, opts) {
696
+ opts = opts || {};
678
697
  var url = _keyToUrl(key);
698
+ if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
679
699
  var headers = _makeSigned("HEAD", url, sha256Hex(Buffer.alloc(0)));
680
700
  return _request("HEAD", url, headers, null, reqOpts).then(function (res) {
681
701
  return {
@@ -696,9 +716,25 @@ function create(config) {
696
716
  });
697
717
  }
698
718
 
699
- function deleteKey(key) {
719
+ // deleteKey(key, opts?) — opts.versionId targets a specific version;
720
+ // opts.bypassGovernanceRetention signs x-amz-bypass-governance-retention so
721
+ // a GOVERNANCE-mode retention can be lifted by a caller with the permission
722
+ // (COMPLIANCE mode is immutable to everyone and stays refused).
723
+ //
724
+ // WORM-awareness: an UNVERSIONED delete on a versioning-enabled bucket only
725
+ // writes a delete-marker — the data version survives and the call still
726
+ // resolves true. To actually erase a version (e.g. crypto-shred / GDPR Art.
727
+ // 17 on an Object-Lock bucket) pass the versionId from put()/listVersions().
728
+ // A delete refused by an active retention surfaces as a thrown error (S3 403
729
+ // / MinIO 400), never a silent success, so the caller learns the version is
730
+ // still protected.
731
+ function deleteKey(key, opts) {
732
+ opts = opts || {};
700
733
  var url = _keyToUrl(key);
701
- var headers = _makeSigned("DELETE", url, sha256Hex(Buffer.alloc(0)));
734
+ if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
735
+ var extra = {};
736
+ if (opts.bypassGovernanceRetention) extra["x-amz-bypass-governance-retention"] = "true";
737
+ var headers = _makeSigned("DELETE", url, sha256Hex(Buffer.alloc(0)), extra);
702
738
  return _request("DELETE", url, headers, null, reqOpts).then(
703
739
  function () { return true; },
704
740
  function (e) { if (e.statusCode === 404) return false; throw e; }
@@ -953,6 +989,50 @@ function create(config) {
953
989
  });
954
990
  }
955
991
 
992
+ // listVersions(prefix, opts?) — enumerate every object VERSION and
993
+ // delete-marker under prefix (S3 ListObjectVersions / the ?versions
994
+ // subresource). Plain list() only sees current versions; to erase prior
995
+ // versions on a versioning / Object-Lock bucket you first need their
996
+ // versionIds, which only this call surfaces. Each item carries
997
+ // { key, versionId, isLatest, deleteMarker, size, lastModified, etag };
998
+ // deleteMarker:true rows are tombstones (no data, size null). Pagination
999
+ // walks (keyMarker, versionIdMarker) the way list() walks continuationToken.
1000
+ function listVersions(prefix, opts) {
1001
+ opts = opts || {};
1002
+ var params = { versions: "" };
1003
+ if (prefix) params["prefix"] = prefix;
1004
+ if (opts.maxResults) params["max-keys"] = String(opts.maxResults);
1005
+ if (opts.keyMarker) params["key-marker"] = opts.keyMarker;
1006
+ if (opts.versionIdMarker) params["version-id-marker"] = opts.versionIdMarker;
1007
+
1008
+ var url = _bucketUrl(params);
1009
+ var headers = _makeSigned("GET", url, sha256Hex(Buffer.alloc(0)));
1010
+ return _request("GET", url, headers, null, reqOpts).then(function (res) {
1011
+ var doc = safeXml.parse(res.body, LIST_PARSE_OPTS);
1012
+ var result = doc.ListVersionsResult || {};
1013
+ function _mapEntry(e, isDeleteMarker) {
1014
+ return {
1015
+ key: e.Key,
1016
+ versionId: e.VersionId != null ? String(e.VersionId) : null,
1017
+ isLatest: e.IsLatest === "true",
1018
+ deleteMarker: isDeleteMarker,
1019
+ size: isDeleteMarker ? null : (e.Size != null ? parseInt(e.Size, 10) : null),
1020
+ lastModified: e.LastModified ? Date.parse(e.LastModified) : null,
1021
+ etag: isDeleteMarker ? null : (e.ETag || null),
1022
+ };
1023
+ }
1024
+ var versions = _arrayify(result.Version).map(function (v) { return _mapEntry(v, false); });
1025
+ var markers = _arrayify(result.DeleteMarker).map(function (m) { return _mapEntry(m, true); });
1026
+ var items = versions.concat(markers).filter(function (it) { return it.key; });
1027
+ return {
1028
+ items: items,
1029
+ truncated: result.IsTruncated === "true",
1030
+ keyMarker: result.NextKeyMarker || null,
1031
+ versionIdMarker: result.NextVersionIdMarker || null,
1032
+ };
1033
+ });
1034
+ }
1035
+
956
1036
  return {
957
1037
  protocol: "sigv4",
958
1038
  endpoint: endpoint,
@@ -966,6 +1046,7 @@ function create(config) {
966
1046
  head: head,
967
1047
  delete: deleteKey,
968
1048
  list: list,
1049
+ listVersions: listVersions,
969
1050
  presignedUploadUrl: presignedUploadUrl,
970
1051
  presignedDownloadUrl: presignedDownloadUrl,
971
1052
  presignedUploadPolicy: presignedUploadPolicy,
@@ -119,7 +119,7 @@ function swap(opts) {
119
119
  // Step 1: rename current dataDir → rollback nodePath. Skipped on first
120
120
  // restore (no existing dataDir).
121
121
  if (hadDataDir) {
122
- try { nodeFs.renameSync(opts.dataDir, rollbackPath); }
122
+ try { atomicFile.renameWithRetry(opts.dataDir, rollbackPath); }
123
123
  catch (e) {
124
124
  throw new RestoreRollbackError("restore-rollback/rename-existing-failed",
125
125
  "swap: could not move existing dataDir to rollback: " + ((e && e.message) || String(e)));
@@ -127,11 +127,11 @@ function swap(opts) {
127
127
  }
128
128
 
129
129
  // Step 2: rename staging → dataDir
130
- try { nodeFs.renameSync(opts.stagingDir, opts.dataDir); }
130
+ try { atomicFile.renameWithRetry(opts.stagingDir, opts.dataDir); }
131
131
  catch (e) {
132
132
  // Step 2 failed — try to undo step 1 so the operator's dataDir is back
133
133
  if (hadDataDir) {
134
- try { nodeFs.renameSync(rollbackPath, opts.dataDir); }
134
+ try { atomicFile.renameWithRetry(rollbackPath, opts.dataDir); }
135
135
  catch (_e) { /* dataDir is now in rollbackPath; operator must recover manually */ }
136
136
  }
137
137
  throw new RestoreRollbackError("restore-rollback/rename-staging-failed",
@@ -220,7 +220,7 @@ async function rollback(opts) {
220
220
  atomicFile.ensureDir(rollbackRoot);
221
221
  discardedAt = atomicFile.pathTimestamp();
222
222
  var discardedPath = nodePath.join(rollbackRoot, "discarded-" + discardedAt);
223
- try { nodeFs.renameSync(opts.dataDir, discardedPath); }
223
+ try { atomicFile.renameWithRetry(opts.dataDir, discardedPath); }
224
224
  catch (e) {
225
225
  throw new RestoreRollbackError("restore-rollback/rename-existing-failed",
226
226
  "rollback: could not move current dataDir aside: " + ((e && e.message) || String(e)));
@@ -229,7 +229,7 @@ async function rollback(opts) {
229
229
  }
230
230
 
231
231
  // Rename the rollback dir back into dataDir's place
232
- try { nodeFs.renameSync(opts.rollbackPath, opts.dataDir); }
232
+ try { atomicFile.renameWithRetry(opts.rollbackPath, opts.dataDir); }
233
233
  catch (e) {
234
234
  throw new RestoreRollbackError("restore-rollback/rollback-rename-failed",
235
235
  "rollback: could not move rollback into dataDir: " + ((e && e.message) || String(e)) +
@@ -171,18 +171,9 @@ function safeDecompress(input, opts) {
171
171
  JSON.stringify(opts.algorithm));
172
172
  }
173
173
 
174
- // maxOutputBytes — required, positive finite integer. Inline gate
175
- // is intentional: it's a REQUIRED opt (not optional), so the
176
- // `requirePositiveFiniteIntIfPresent` helper doesn't apply (it skips
177
- // when undefined). The numericBounds.requirePositiveFiniteInt helper
178
- // would fit, but the existing call surface across the framework
179
- // uses the inline shape for required-opt validation.
180
- if (!numericBounds.isPositiveFiniteInt(opts.maxOutputBytes)) { // allow:inline-numeric-bounds-cascade — required (non-optional) opt; requirePositiveFiniteIntIfPresent skips when undefined
181
- throw new SafeDecompressError(
182
- "safe-decompress/bad-arg",
183
- "safeDecompress: maxOutputBytes must be a positive finite integer; got " +
184
- numericBounds.shape(opts.maxOutputBytes));
185
- }
174
+ // maxOutputBytes — required, positive finite integer.
175
+ numericBounds.requirePositiveFiniteInt(opts.maxOutputBytes,
176
+ "safeDecompress: maxOutputBytes", SafeDecompressError, "safe-decompress/bad-arg");
186
177
 
187
178
  // Input shape
188
179
  var buf;
package/lib/seeders.js CHANGED
@@ -546,46 +546,40 @@ function create(opts) {
546
546
 
547
547
  try {
548
548
 
549
- await (async function () {
550
- // Per-seed transaction: SQLite txns are sync, but the
551
- // seed's run() may be async so we begin/commit around
552
- // an awaited body. Failures roll back this seed only.
553
- _runSql(db, "BEGIN");
554
- try {
555
- await mod.run(db, ctx);
556
- var nowIso = new Date(clock()).toISOString();
557
- var writeBuilt;
558
- if (alreadyApplied && mod.rerunnable) {
559
- writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
560
- .set({ appliedAt: nowIso, description: mod.description || "",
561
- rerunnable: mod.rerunnable ? 1 : 0 })
562
- .where("env", env).where("name", name).toSql();
563
- } else if (alreadyApplied && force) {
564
- writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
565
- .set({ appliedAt: nowIso, description: mod.description || "" })
566
- .where("env", env).where("name", name).toSql();
567
- } else {
568
- writeBuilt = sql.insert(_seedersTable(), _sqlOpts(db))
569
- .values({ env: env, name: name, description: mod.description || "",
570
- appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
571
- .toSql();
572
- }
573
- var writeStmt = db.prepare(writeBuilt.sql);
574
- writeStmt.run.apply(writeStmt, writeBuilt.params);
575
- _runSql(db, "COMMIT");
576
- } catch (e) {
577
- try { _runSql(db, "ROLLBACK"); }
578
- catch (rollbackErr) {
579
- log.debug("rollback-failed", {
580
- op: "seed-apply",
581
- env: env,
582
- name: name,
583
- error: rollbackErr && rollbackErr.message,
584
- });
585
- }
586
- throw e;
549
+ // Per-seed transaction: SQLite txns are sync, but the seed's
550
+ // run() may be async runInTransactionAsync wraps BEGIN/COMMIT
551
+ // around the awaited body and rolls back this seed only on failure.
552
+ await dbSchema.runInTransactionAsync(db, async function () {
553
+ await mod.run(db, ctx);
554
+ var nowIso = new Date(clock()).toISOString();
555
+ var writeBuilt;
556
+ if (alreadyApplied && mod.rerunnable) {
557
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
558
+ .set({ appliedAt: nowIso, description: mod.description || "",
559
+ rerunnable: mod.rerunnable ? 1 : 0 })
560
+ .where("env", env).where("name", name).toSql();
561
+ } else if (alreadyApplied && force) {
562
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
563
+ .set({ appliedAt: nowIso, description: mod.description || "" })
564
+ .where("env", env).where("name", name).toSql();
565
+ } else {
566
+ writeBuilt = sql.insert(_seedersTable(), _sqlOpts(db))
567
+ .values({ env: env, name: name, description: mod.description || "",
568
+ appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
569
+ .toSql();
587
570
  }
588
- })();
571
+ var writeStmt = db.prepare(writeBuilt.sql);
572
+ writeStmt.run.apply(writeStmt, writeBuilt.params);
573
+ }, {
574
+ onRollbackFail: function (rollbackErr) {
575
+ log.debug("rollback-failed", {
576
+ op: "seed-apply",
577
+ env: env,
578
+ name: name,
579
+ error: rollbackErr && rollbackErr.message,
580
+ });
581
+ },
582
+ });
589
583
  applied.push(name);
590
584
  appliedSet.add(name);
591
585
 
@@ -676,7 +676,7 @@ async function swap(opts) {
676
676
  // don't silently lose both binaries (the prior best-effort comment
677
677
  // swallowed the rollback exception — SSDF RV.1 violation).
678
678
  try {
679
- nodeFs.renameSync(from, to);
679
+ atomicFile.renameWithRetry(from, to);
680
680
  } catch (e) {
681
681
  if (e && e.code === "EXDEV") {
682
682
  // Cross-device — copy + unlink. Use atomicFile.copy for the safety
package/lib/session.js CHANGED
@@ -65,6 +65,9 @@ var { SessionError } = require("./framework-error");
65
65
  // the cookie-side sid so the wire token is ciphertext rather than
66
66
  // plaintext (sealed-cookie default since v0.8.61).
67
67
  var vault = lazyRequire(function () { return require("./vault"); });
68
+ // Lazy — b.session.logout composes the Clear-Site-Data header builder; keep it
69
+ // out of the boot require graph (no cycle, but session is a low-level primitive).
70
+ var clearSiteData = lazyRequire(function () { return require("./middleware/clear-site-data"); });
68
71
 
69
72
  // Pluggable session-storage backend. Default uses cluster-storage (which
70
73
  // in turn dispatches to the framework's main DB or external DB). An
@@ -705,6 +708,66 @@ async function destroy(token) {
705
708
  return await _deleteBySidHash(_hashSid(sid));
706
709
  }
707
710
 
711
+ /**
712
+ * @primitive b.session.logout
713
+ * @signature b.session.logout(res, token, opts?)
714
+ * @since 0.15.9
715
+ * @status stable
716
+ * @related b.session.destroy, b.middleware.clearSiteData
717
+ *
718
+ * Secure logout in one call: destroy the server-side session AND tell the
719
+ * browser to wipe its client-side state. It emits an RFC 9527 Clear-Site-Data
720
+ * response header (cookies + storage + cache + executionContexts by default)
721
+ * and expires the session cookie, then destroys the session row. `destroy()`
722
+ * alone is a store operation with no `res`, so it cannot wipe the browser's
723
+ * cached pages / storage / any stale tab still holding the now-revoked cookie;
724
+ * this composes the secure-default logout the middleware otherwise had to be
725
+ * mounted by hand. Returns whether a session was destroyed. Leader-only.
726
+ *
727
+ * @opts
728
+ * cookieName: string, // default: "sid" — the session cookie to expire
729
+ * types: string[], // default: the RFC 9527 Clear-Site-Data directive set
730
+ *
731
+ * @example
732
+ * app.post("/logout", async function (req, res) {
733
+ * await b.session.logout(res, req.cookies.sid);
734
+ * res.end("logged out");
735
+ * });
736
+ * // → emits Clear-Site-Data + expires the sid cookie + destroys the session
737
+ */
738
+ async function logout(res, token, opts) {
739
+ if (!res || typeof res.setHeader !== "function") {
740
+ throw new SessionError("session/bad-res",
741
+ "b.session.logout: res must be an HTTP response with setHeader()");
742
+ }
743
+ opts = opts || {};
744
+ var cookieName = opts.cookieName === undefined ? "sid" : opts.cookieName;
745
+ if (typeof cookieName !== "string" || cookieName.length === 0) {
746
+ throw new SessionError("session/bad-cookie-name",
747
+ "b.session.logout: opts.cookieName must be a non-empty string");
748
+ }
749
+ var csd = clearSiteData();
750
+ var types = opts.types === undefined ? csd.DEFAULT_TYPES : opts.types;
751
+ // Build (and validate) the RFC 9527 header BEFORE any side effect — an
752
+ // unknown directive throws here, queuing nothing.
753
+ var clearSiteDataValue = csd.headerValue(types, "b.session.logout");
754
+
755
+ // Revoke the server-side session FIRST. If destroy() throws (a follower
756
+ // failing cluster.requireLeader(), or a store/DB error), no client-wipe
757
+ // headers have been queued — an error response can't then expire the
758
+ // browser cookie + Clear-Site-Data while the session row is still live,
759
+ // which would leave a copied token usable server-side.
760
+ var destroyed = await destroy(token);
761
+
762
+ // Now wipe the client-side state: RFC 9527 Clear-Site-Data (cookies /
763
+ // storage / cache) + expire the session cookie (belt-and-suspenders with the
764
+ // "cookies" directive, and effective even if the client ignores the header).
765
+ res.setHeader("Clear-Site-Data", clearSiteDataValue);
766
+ res.setHeader("Set-Cookie",
767
+ cookieName + "=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0");
768
+ return destroyed;
769
+ }
770
+
708
771
  async function _deleteBySidHash(sidHash) {
709
772
  var built = sql.delete(_sessionSqlTable(), _sessionSqlOpts())
710
773
  .where("sidHash", sidHash)
@@ -1245,6 +1308,7 @@ module.exports = {
1245
1308
  create: create,
1246
1309
  verify: verify,
1247
1310
  destroy: destroy,
1311
+ logout: logout,
1248
1312
  destroyAllForUser: destroyAllForUser,
1249
1313
  touch: touch,
1250
1314
  rotate: rotate,
package/lib/storage.js CHANGED
@@ -425,7 +425,7 @@ async function getFileStream(key, sealedKey, opts) {
425
425
  * @example
426
426
  * b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
427
427
  * var saved = await b.storage.saveRaw(Buffer.from("public-bytes"), "logo.png");
428
- * // → { storedPath: "logo.png", backend: "default" }
428
+ * // → { storedPath: "logo.png", backend: "default", versionId: null }
429
429
  */
430
430
  async function saveRaw(buffer, key, opts) {
431
431
  _requireInit();
@@ -443,7 +443,9 @@ async function saveRaw(buffer, key, opts) {
443
443
  raw: true,
444
444
  },
445
445
  });
446
- return { storedPath: key, backend: picked.backend.name };
446
+ // versionId is non-null only on a versioning-enabled (S3 Object-Lock)
447
+ // backend; capture it to target the exact version for later erasure.
448
+ return { storedPath: key, backend: picked.backend.name, versionId: result.versionId || null };
447
449
  }
448
450
 
449
451
  /**
@@ -486,14 +488,24 @@ async function getRawBuffer(key, opts) {
486
488
  * Remove `key` from the routed backend. Returns `true` when the
487
489
  * object existed and was removed, `false` when it was already
488
490
  * absent. Emits `system.storage.delete` with `{ backend, key,
489
- * existed }` so the audit chain records GDPR right-to-erasure
491
+ * existed, versionId }` so the audit chain records GDPR right-to-erasure
490
492
  * flows. The sealed encryption key the caller persisted alongside
491
493
  * the row should be discarded by the caller after a successful
492
494
  * delete — without the bytes, the key has no recovery value.
493
495
  *
496
+ * On a versioning-enabled (S3 Object-Lock) backend an unversioned
497
+ * delete only writes a delete-marker — the data version survives. To
498
+ * erase a specific version pass `versionId` (from `saveRaw`'s return or
499
+ * `listVersions`); a version under an active retention is refused (the
500
+ * call throws), and `bypassGovernanceRetention` lifts a GOVERNANCE-mode
501
+ * retention for callers with the permission (COMPLIANCE stays immutable).
502
+ * `versionId` is S3/sigv4-only and is refused on other backends.
503
+ *
494
504
  * @opts
495
505
  * classification: string, // route to a backend serving this classification
496
506
  * backend: string, // explicit backend by name
507
+ * versionId: string, // erase a specific object version (S3 Object-Lock)
508
+ * bypassGovernanceRetention: boolean, // lift GOVERNANCE retention (not COMPLIANCE)
497
509
  *
498
510
  * @example
499
511
  * b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
@@ -507,12 +519,16 @@ async function deleteFile(key, opts) {
507
519
  _requireInit();
508
520
  opts = opts || {};
509
521
  var picked = _pickBackend(opts);
510
- var result = await picked.backend.delete(key);
522
+ var result = await picked.backend.delete(key, {
523
+ versionId: opts.versionId,
524
+ bypassGovernanceRetention: opts.bypassGovernanceRetention,
525
+ });
511
526
  _emit("system.storage.delete", {
512
527
  metadata: {
513
- backend: picked.backend.name,
514
- key: key,
515
- existed: result,
528
+ backend: picked.backend.name,
529
+ key: key,
530
+ existed: result,
531
+ versionId: opts.versionId || null,
516
532
  },
517
533
  });
518
534
  return result;
@@ -556,6 +572,53 @@ async function exists(key, opts) {
556
572
  }
557
573
  }
558
574
 
575
+ /**
576
+ * @primitive b.storage.listVersions
577
+ * @signature b.storage.listVersions(prefix, opts?)
578
+ * @since 0.15.10
579
+ * @status stable
580
+ * @compliance gdpr, sox-404, soc2
581
+ * @related b.storage.deleteFile, b.storage.saveRaw, b.worm.create
582
+ *
583
+ * Enumerate every object VERSION and delete-marker under `prefix` on a
584
+ * versioning-enabled (S3 Object-Lock) backend. Plain reads only see the
585
+ * current version; right-to-erasure / crypto-shred on an Object-Lock
586
+ * bucket must target prior versions by `versionId`, which only this call
587
+ * surfaces. Each item is `{ key, versionId, isLatest, deleteMarker, size,
588
+ * lastModified, etag }`; `deleteMarker: true` rows are tombstones with no
589
+ * data. Pair with `deleteFile(key, { versionId })` to erase a version.
590
+ *
591
+ * Versioning is an S3/sigv4 feature — a backend without a version surface
592
+ * (filesystem, and the current Azure/GCS adapters) throws
593
+ * `VERSIONS_UNSUPPORTED` rather than silently returning the current view,
594
+ * so an erasure workflow can never mistake a single-version backend for a
595
+ * fully-enumerated one.
596
+ *
597
+ * @opts
598
+ * classification: string, // route to a backend serving this classification
599
+ * backend: string, // explicit backend by name
600
+ * maxResults: number, // page size
601
+ * keyMarker: string, // pagination cursor (from a prior page)
602
+ * versionIdMarker: string, // pagination cursor (from a prior page)
603
+ *
604
+ * @example
605
+ * var page = await b.storage.listVersions("filings/2026/");
606
+ * for (var v of page.items) {
607
+ * if (!v.isLatest) await b.storage.deleteFile(v.key, { versionId: v.versionId });
608
+ * }
609
+ */
610
+ async function listVersions(prefix, opts) {
611
+ _requireInit();
612
+ opts = opts || {};
613
+ var picked = _pickBackend(opts);
614
+ if (typeof picked.backend.listVersions !== "function") {
615
+ throw _err("VERSIONS_UNSUPPORTED",
616
+ "listVersions: backend '" + picked.backend.name + "' has no version surface " +
617
+ "(S3/sigv4 only). A filesystem / single-version backend cannot enumerate versions.", true);
618
+ }
619
+ return picked.backend.listVersions(prefix, opts);
620
+ }
621
+
559
622
  /**
560
623
  * @primitive b.storage.listBackends
561
624
  * @signature b.storage.listBackends()
@@ -1266,6 +1329,7 @@ module.exports = {
1266
1329
  getRawBuffer: getRawBuffer,
1267
1330
  deleteFile: deleteFile,
1268
1331
  exists: exists,
1332
+ listVersions: listVersions,
1269
1333
  presignedUploadUrl: presignedUploadUrl,
1270
1334
  presignedDownloadUrl: presignedDownloadUrl,
1271
1335
  presignedUploadPolicy: presignedUploadPolicy,
@@ -169,7 +169,7 @@ async function seal(opts) {
169
169
  }
170
170
 
171
171
  // Step 3: atomic rename sealed.tmp → sealed
172
- nodeFs.renameSync(p.sealedTmp, p.sealed);
172
+ atomicFile.renameWithRetry(p.sealedTmp, p.sealed);
173
173
  atomicFile.fsyncDir(opts.dataDir);
174
174
 
175
175
  // Step 4: delete plaintext (unless keepPlaintext)
@@ -220,7 +220,7 @@ async function unseal(opts) {
220
220
  }
221
221
 
222
222
  // Step 3: atomic rename plaintext.tmp → plaintext
223
- nodeFs.renameSync(p.plaintextTmp, p.plaintext);
223
+ atomicFile.renameWithRetry(p.plaintextTmp, p.plaintext);
224
224
  atomicFile.fsyncDir(opts.dataDir);
225
225
 
226
226
  // Step 4: delete sealed file
@@ -293,7 +293,7 @@ async function rotate(opts) {
293
293
  }
294
294
 
295
295
  // Step 3: atomic rename — swap in the new sealed file
296
- nodeFs.renameSync(p.sealedTmp, p.sealed);
296
+ atomicFile.renameWithRetry(p.sealedTmp, p.sealed);
297
297
  atomicFile.fsyncDir(opts.dataDir);
298
298
 
299
299
  return { sealedPath: p.sealed };
@@ -544,12 +544,6 @@ function _walkAndReSeal(node, oldKeys, newKeys) {
544
544
  return { value: node, changed: false };
545
545
  }
546
546
 
547
- // Transaction-control statements only (BEGIN / COMMIT / ROLLBACK) - fixed
548
- // keywords, no identifier / value, so they stay verbatim rather than route
549
- // through b.sql (the builder has no transaction-control verb). The param is
550
- // named `stmtText` so it does not shadow the module-level `sql` builder.
551
- function _runStmt(db, stmtText) { db.prepare(stmtText).run(); }
552
-
553
547
  function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
554
548
  // Every statement composes through b.sql (sqlite dialect, quoteName so
555
549
  // the concrete handle's table is quoted, not left bare for a cluster
@@ -655,8 +649,9 @@ function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warni
655
649
  var rows = sel.all(lastId);
656
650
  if (rows.length === 0) break;
657
651
 
658
- _runStmt(db, "BEGIN");
659
- try {
652
+ // One transaction per page via the shared wrapper — the rotate worker
653
+ // no longer hand-rolls the BEGIN/COMMIT/ROLLBACK skeleton.
654
+ dbSchema.runInTransaction(db, function () {
660
655
  for (var i = 0; i < rows.length; i++) {
661
656
  var row = rows[i];
662
657
  var doc;
@@ -671,11 +666,7 @@ function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warni
671
666
  var rv = _walkAndReSeal(doc, oldKeys, newKeys);
672
667
  if (rv.changed) upd.run(JSON.stringify(rv.value), row._id);
673
668
  }
674
- _runStmt(db, "COMMIT");
675
- } catch (e) {
676
- _runStmt(db, "ROLLBACK");
677
- throw e;
678
- }
669
+ });
679
670
  processed += rows.length;
680
671
  lastId = rows[rows.length - 1]._id;
681
672
  _emit(progress, { phase: "rotate_overflow", table: table, rowsProcessed: processed, rowsTotal: total });
package/lib/watcher.js CHANGED
@@ -316,6 +316,14 @@ function create(opts) {
316
316
  _validateOpts(opts);
317
317
 
318
318
  var root = nodePath.resolve(opts.root);
319
+ // Canonicalize to the real long path. On Windows a path with an 8.3
320
+ // short-name component (os.tmpdir() commonly resolves to C:\Users\RUNNER~1\…)
321
+ // makes the native recursive backend (ReadDirectoryChangesW) deliver
322
+ // long-name event paths that no longer prefix-match the watched root, which
323
+ // trips a libuv fs-event assertion and aborts the process on some Node builds.
324
+ // realpathSync.native expands short names and resolves symlinks; guarded so a
325
+ // non-existent root still falls through to the watcher's own not-found error.
326
+ try { root = nodeFs.realpathSync.native(root); } catch (_e) { /* keep resolved path */ }
319
327
  var debounceMs = (opts.debounceMs !== undefined) ? opts.debounceMs : DEFAULT_DEBOUNCE_MS;
320
328
  var maxPending = (opts.maxPending !== undefined) ? opts.maxPending : DEFAULT_MAX_PENDING;
321
329
  var requestedMode = opts.mode || "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.8",
3
+ "version": "0.15.10",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -54,7 +54,7 @@
54
54
  "owns-its-stack"
55
55
  ],
56
56
  "engines": {
57
- "node": ">=24.14.1"
57
+ "node": ">=24.16.0"
58
58
  },
59
59
  "files": [
60
60
  "index.js",