@blamejs/core 0.15.9 → 0.15.11

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.
@@ -356,7 +356,18 @@ function create(config) {
356
356
  });
357
357
  }
358
358
 
359
- function deleteKey(key) {
359
+ function deleteKey(key, opts) {
360
+ opts = opts || {};
361
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
362
+ // sigv4-only today. Refuse loudly rather than silently delete the current
363
+ // blob — a silent drop on an erasure path would let a caller believe a
364
+ // specific version was shredded when it was not.
365
+ if (opts.versionId) {
366
+ throw _err("VERSIONID_UNSUPPORTED",
367
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the Azure " +
368
+ "Blob backend has no version surface here. Use a sigv4 backend for " +
369
+ "Object-Lock version erasure.", true);
370
+ }
360
371
  var url = _blobUrl(key);
361
372
  var headers = _signed("DELETE", url, {});
362
373
  return _httpRequest("DELETE", url, headers, null, reqOpts).then(
@@ -281,7 +281,18 @@ function create(config) {
281
281
  };
282
282
  }
283
283
 
284
- async function deleteKey(key) {
284
+ async function deleteKey(key, opts) {
285
+ opts = opts || {};
286
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
287
+ // sigv4-only today. Refuse loudly rather than silently delete the live
288
+ // object — a silent drop on an erasure path would let a caller believe a
289
+ // specific version was shredded when it was not.
290
+ if (opts.versionId) {
291
+ throw _err("VERSIONID_UNSUPPORTED",
292
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the GCS " +
293
+ "backend has no version surface here. Use a sigv4 backend for Object-Lock " +
294
+ "version erasure.", true);
295
+ }
285
296
  var token = await _ensureToken();
286
297
  var url = _objectUrl(key);
287
298
  try {
@@ -110,7 +110,17 @@ function create(config) {
110
110
  });
111
111
  }
112
112
 
113
- function deleteKey(key) {
113
+ function deleteKey(key, opts) {
114
+ opts = opts || {};
115
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
116
+ // sigv4-only. A bare PUT target has no version surface, so refuse loudly
117
+ // rather than issue a plain DELETE and report a specific version erased —
118
+ // a silent drop on an erasure path is the footgun.
119
+ if (opts.versionId) {
120
+ throw _err("VERSIONID_UNSUPPORTED",
121
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the http-put " +
122
+ "backend has no version surface. Use a sigv4 backend for Object-Lock version erasure.", true);
123
+ }
114
124
  var url = _keyToUrl(baseUrl, key);
115
125
  return _request("DELETE", url, null, headers, reqOpts).then(
116
126
  function () { return true; },
@@ -122,6 +122,10 @@ function buildBackend(config) {
122
122
  head: wrap("head"),
123
123
  delete: wrap("delete"),
124
124
  list: wrap("list"),
125
+ // listVersions is S3/sigv4-only (the ?versions subresource backs the
126
+ // WORM erasure workflow). Backends without it expose null so callers can
127
+ // feature-detect rather than hit a "wrap of undefined" at boot.
128
+ listVersions: typeof raw.listVersions === "function" ? wrap("listVersions") : null,
125
129
  // presigned*Url are sync URL-builders (no network call), so they
126
130
  // bypass retry + circuit-breaker — propagate any throw directly.
127
131
  presignedUploadUrl: typeof raw.presignedUploadUrl === "function"
@@ -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,
@@ -147,7 +147,7 @@ function parse(input, opts) {
147
147
  if (eqIdx < 0) {
148
148
  throw new SafeEnvError("missing '=' separator", "env/bad-line", lineNumber);
149
149
  }
150
- var key = trimmed.substring(0, eqIdx).replace(safeBuffer.TRAILING_HSPACE_RE, "");
150
+ var key = safeBuffer.stripTrailingHspace(trimmed.substring(0, eqIdx));
151
151
  var rest = trimmed.substring(eqIdx + 1);
152
152
 
153
153
  if (key.length === 0) {
@@ -186,10 +186,13 @@ function parse(input, opts) {
186
186
  // The comment marker MUST be preceded by whitespace to count
187
187
  // (so a value like `KEY=color#red` keeps the literal `#`).
188
188
  var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
189
+ // stripTrailingHspace is a linear char-scan; .replace(/[ \t]+$/) is O(n^2)
190
+ // in V8 and the env parser only caps TOTAL bytes, not per-line, so a
191
+ // single huge-whitespace value line would otherwise hang the parser.
189
192
  if (commentMatch) {
190
- value = commentMatch[1].replace(safeBuffer.TRAILING_HSPACE_RE, "");
193
+ value = safeBuffer.stripTrailingHspace(commentMatch[1]);
191
194
  } else {
192
- value = rest.replace(safeBuffer.TRAILING_HSPACE_RE, "");
195
+ value = safeBuffer.stripTrailingHspace(rest);
193
196
  }
194
197
  // Reject `$VAR` style references — explicit error so operators
195
198
  // see the policy rather than silently getting unexpanded text.
@@ -185,7 +185,7 @@ function parse(input, opts) {
185
185
  var raw = rawLines[i];
186
186
  // Strip trailing whitespace from the line for analysis (block-scalar
187
187
  // content paths preserve their own internal spacing separately).
188
- var trimmed = raw.replace(safeBuffer.TRAILING_HSPACE_RE, "");
188
+ var trimmed = safeBuffer.stripTrailingHspace(raw);
189
189
  var indent = 0;
190
190
  while (indent < raw.length && raw.charAt(indent) === " ") indent += 1;
191
191
  if (indent < raw.length && raw.charAt(indent) === "\t") {
@@ -332,7 +332,7 @@ function parse(input, opts) {
332
332
  if (raw.charAt(0) === "'") {
333
333
  return _decodeSingleQuoted(raw, lineNumber, col);
334
334
  }
335
- var trimmed = raw.replace(safeBuffer.TRAILING_HSPACE_RE, "");
335
+ var trimmed = safeBuffer.stripTrailingHspace(raw);
336
336
  if (POISONED_KEYS.has(trimmed)) {
337
337
  throw new SafeYamlError("forbidden key '" + trimmed + "'",
338
338
  "yaml/poisoned-key", lineNumber, col);
@@ -506,7 +506,7 @@ function parse(input, opts) {
506
506
  return sq;
507
507
  }
508
508
  // Plain scalar — strip trailing space, resolve type.
509
- return _resolveScalar(t.replace(safeBuffer.TRAILING_HSPACE_RE, ""));
509
+ return _resolveScalar(safeBuffer.stripTrailingHspace(t));
510
510
  }
511
511
 
512
512
  // For quoted-string termination check on a single-line value.
@@ -622,7 +622,7 @@ function parse(input, opts) {
622
622
  if (c === "," || c === "}" || c === "]") break;
623
623
  p += 1;
624
624
  }
625
- var raw = text.substring(start, p).replace(safeBuffer.TRAILING_HSPACE_RE, "");
625
+ var raw = safeBuffer.stripTrailingHspace(text.substring(start, p));
626
626
  return { value: _resolveScalar(raw), nextPos: p };
627
627
  }
628
628
 
@@ -645,7 +645,7 @@ function parse(input, opts) {
645
645
  if (c === ":" || c === "," || c === "}" || c === "]") break;
646
646
  p += 1;
647
647
  }
648
- return { key: text.substring(start, p).replace(safeBuffer.TRAILING_HSPACE_RE, ""), nextPos: p };
648
+ return { key: safeBuffer.stripTrailingHspace(text.substring(start, p)), nextPos: p };
649
649
  }
650
650
 
651
651
  function _findMatchingBracket(text, start, open, close, lineNumber, col) {
@@ -772,7 +772,7 @@ function parse(input, opts) {
772
772
  function _stripEolComment(text) {
773
773
  // Strip ` #...` comments (must be preceded by whitespace) at end of line.
774
774
  var match = text.match(/^(.*?)(\s+#.*)?$/);
775
- return (match && match[1] != null ? match[1] : text).replace(safeBuffer.TRAILING_HSPACE_RE, "");
775
+ return safeBuffer.stripTrailingHspace(match && match[1] != null ? match[1] : text);
776
776
  }
777
777
 
778
778
  // ---- Block scalars (| literal, > folded) ----
@@ -494,7 +494,74 @@ var TRAILING_HSPACE_RE = /[ \t]+$/;
494
494
  */
495
495
  function stripTrailingHspace(s) {
496
496
  if (typeof s !== "string") return s;
497
- return s.replace(TRAILING_HSPACE_RE, "");
497
+ // Linear backward scan over trailing spaces/tabs — NOT s.replace(/[ \t]+$/).
498
+ // The `$`-after-greedy-`[ \t]+` regex is O(n^2) in V8 on adversarial input
499
+ // (a long run of spaces followed by a non-space: the engine retries the
500
+ // greedy match from every offset). normalizeText callers cap TOTAL bytes but
501
+ // not per-line, so a single ~500K-space value hangs (~85s). The char-scan is
502
+ // O(trailing-whitespace) and byte-identical to the regex on every input
503
+ // (JS `$` without /m matches only the absolute end, so a trailing \n is not
504
+ // stripped — the scan stops at it too).
505
+ var e = s.length;
506
+ while (e > 0) {
507
+ var c = s.charCodeAt(e - 1);
508
+ if (c === 0x20 || c === 0x09) { e -= 1; } else { break; }
509
+ }
510
+ return e === s.length ? s : s.slice(0, e);
511
+ }
512
+
513
+ /**
514
+ * @primitive b.safeBuffer.indexAfterOpenTag
515
+ * @signature b.safeBuffer.indexAfterOpenTag(html, tagName)
516
+ * @since 0.15.11
517
+ * @related b.safeBuffer.stripTrailingHspace
518
+ *
519
+ * Find the offset in `html` just past the first `<tagName ...>` opening
520
+ * tag (case-insensitive), or `-1` when the tag is absent or unterminated.
521
+ * The insertion point a response rewriter uses to splice content right
522
+ * after `<body>` / `<head>` without a regex.
523
+ *
524
+ * This replaces the `html.match(/<body[^>]*>/i)` shape, which is O(n^2)
525
+ * in V8: a body carrying many `<body` starts with no closing `>` (e.g.
526
+ * rendered user content) makes the engine retry the greedy `[^>]*` from
527
+ * every offset — a `<body`-repeated 200K-char body benchmarks in
528
+ * seconds. This is a single forward `indexOf` walk: linear in the input,
529
+ * and stricter than the regex — it requires a real tag boundary after
530
+ * the name (whitespace, `>`, or `/`), so `<bodyfoo>` is not mistaken for
531
+ * `<body>`. Non-string input returns `-1`.
532
+ *
533
+ * @example
534
+ * var b = require("blamejs");
535
+ * b.safeBuffer.indexAfterOpenTag("<html><body class=x>hi", "body");
536
+ * // → 19 (just past the '>' of <body class=x>)
537
+ *
538
+ * b.safeBuffer.indexAfterOpenTag("<p>no body here</p>", "body");
539
+ * // → -1
540
+ */
541
+ function indexAfterOpenTag(html, tagName) {
542
+ if (typeof html !== "string" || typeof tagName !== "string" || tagName.length === 0) return -1;
543
+ var needle = "<" + tagName.toLowerCase();
544
+ var nlen = needle.length;
545
+ // One O(n) lowercase pass keeps the case-insensitive search linear; the
546
+ // forward indexOf walk below never re-scans a region it has passed.
547
+ var lower = html.toLowerCase();
548
+ var from = 0;
549
+ for (;;) {
550
+ var lt = lower.indexOf(needle, from);
551
+ if (lt === -1) return -1;
552
+ var after = lt + nlen;
553
+ var boundary = after < html.length ? html.charCodeAt(after) : -1;
554
+ // The char after "<tag" must end the tag name — '>' (0x3e), '/' (0x2f),
555
+ // or ASCII whitespace — else this is a longer name like "<bodyfoo".
556
+ if (boundary === 0x3e || boundary === 0x2f ||
557
+ boundary === 0x20 || boundary === 0x09 ||
558
+ boundary === 0x0a || boundary === 0x0d || boundary === 0x0c) {
559
+ var gt = html.indexOf(">", after);
560
+ if (gt === -1) return -1; // unterminated opening tag — no insertion point
561
+ return gt + 1;
562
+ }
563
+ from = lt + 1;
564
+ }
498
565
  }
499
566
 
500
567
  /**
@@ -612,6 +679,7 @@ module.exports = {
612
679
  hasCrlf: hasCrlf,
613
680
  stripCrlf: stripCrlf,
614
681
  stripTrailingHspace: stripTrailingHspace,
682
+ indexAfterOpenTag: indexAfterOpenTag,
615
683
  HEX_RE: HEX_RE,
616
684
  BASE64URL_RE: BASE64URL_RE,
617
685
  BASE64_RE: BASE64_RE,
@@ -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
 
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,