@blamejs/blamejs-shop 0.2.15 → 0.2.17

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +1 -1
  3. package/lib/asset-manifest.json +5 -1
  4. package/lib/customers.js +71 -4
  5. package/lib/storefront.js +462 -0
  6. package/lib/vendor/MANIFEST.json +2 -2
  7. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  9. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  11. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  13. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  14. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  15. package/lib/vendor/blamejs/lib/cache.js +184 -23
  16. package/lib/vendor/blamejs/lib/cert.js +68 -11
  17. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  18. package/lib/vendor/blamejs/lib/db.js +205 -15
  19. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  20. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  21. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  22. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  23. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  24. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  25. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  26. package/lib/vendor/blamejs/lib/subject.js +8 -1
  27. package/lib/vendor/blamejs/package.json +1 -1
  28. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  29. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  38. package/lib/vendor/blamejs/test/20-db.js +191 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  48. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  51. package/package.json +1 -1
@@ -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,
@@ -54,6 +54,7 @@ var atomicFile = require("./atomic-file");
54
54
  var safeJson = require("./safe-json");
55
55
  var { defineClass } = require("./framework-error");
56
56
  var C = require("./constants");
57
+ var { boot } = require("./log");
57
58
 
58
59
  var acme = lazyRequire(function () { return require("./acme"); });
59
60
  var vault = lazyRequire(function () { return require("./vault"); });
@@ -62,6 +63,7 @@ var networkTls = lazyRequire(function () { return require("./network-tls"); }
62
63
  var bCrypto = lazyRequire(function () { return require("./crypto"); });
63
64
 
64
65
  var CertError = defineClass("CertError");
66
+ var log = boot("cert");
65
67
 
66
68
  var DEFAULT_RENEW_INTERVAL_MS = C.TIME.hours(6);
67
69
  var DEFAULT_MIN_DAYS_BEFORE_EXPIRY = 14;
@@ -140,8 +142,13 @@ function _createSealedDiskStorage(opts) {
140
142
  if (!nodeFs.existsSync(p)) return null;
141
143
  try { return safeJson.parse(nodeFs.readFileSync(p, "utf8"), { maxBytes: C.BYTES.kib(16) }); }
142
144
  catch (e) {
143
- throw new CertError("cert/bad-meta",
144
- "cert: meta.json for '" + certName + "' is corrupt: " + e.message);
145
+ // meta.json is a derived index (expiry + fingerprint), not a
146
+ // source of truth the sealed cert is. A corrupt meta must not
147
+ // block renewal: treat it as absent so _ensureCert re-derives it
148
+ // from a fresh issue rather than throwing out of start().
149
+ log.warn("cert: meta.json for '" + certName + "' unreadable (" +
150
+ e.message + ") — treating as absent, will re-derive");
151
+ return null;
145
152
  }
146
153
  },
147
154
 
@@ -413,8 +420,21 @@ function create(opts) {
413
420
  ? nodeFs.readFileSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
414
421
  : null;
415
422
  if (sealedBuf) {
416
- var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
417
- var jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
423
+ var jwk;
424
+ try {
425
+ var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
426
+ jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
427
+ } catch (e) {
428
+ // The ACME account key binds existing order + authorization
429
+ // history, so it is NOT auto-regenerated on corruption (unlike a
430
+ // re-issuable cert) — that would silently abandon the account.
431
+ // Fail with an actionable error naming the file + recovery instead
432
+ // of letting a raw decrypt/parse error escape out of start().
433
+ throw new CertError("cert/account-key-unreadable",
434
+ "cert: ACME account key 'account/jwk.json.sealed' is unreadable (" +
435
+ e.message + "). Restore it from backup, or delete it to register " +
436
+ "a fresh ACME account (this abandons prior order history).");
437
+ }
418
438
  return _accountKeyFromJwk(jwk);
419
439
  }
420
440
  var pair = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
@@ -555,18 +575,55 @@ function create(opts) {
555
575
  // for emergency reissue / key rollover when the existing cert is
556
576
  // structurally fine but operationally compromised (suspected key
557
577
  // disclosure, CA misissuance investigation, posture-driven rotation).
578
+ // A corrupt sealed cert/key is RECOVERABLE state — the CA re-issues on
579
+ // demand. Treat an unreadable sealed file like a missing one (log +
580
+ // re-issue) rather than letting a raw unseal/decrypt error escape out of
581
+ // start(): on a managed restart the same corrupt file is read on every
582
+ // boot, so throwing here is an unrecoverable crash loop, and a corrupt
583
+ // file must never be handled worse than an absent one (which already
584
+ // falls through to issue). The ACME account key is the one piece NOT
585
+ // auto-recovered this way — see _loadOrGenerateAccountKey.
586
+ async function _readSealedOrReissue(relPath, certName) {
587
+ try {
588
+ return await storage.readSealed(relPath);
589
+ } catch (e) {
590
+ log.warn("cert: sealed '" + relPath + "' unreadable (" + e.message +
591
+ ") — re-issuing as if absent");
592
+ _emitAudit("cert.sealed.corrupt", "recovered", { path: relPath, name: certName });
593
+ return null;
594
+ }
595
+ }
596
+
558
597
  async function _ensureCert(certManifest, forceIssue) {
559
598
  var meta = await storage.readMeta(certManifest.name);
560
- var certBuf = await storage.readSealed(certManifest.name + "/cert.pem");
561
- var keyBuf = await storage.readSealed(certManifest.name + "/key.pem");
562
- if (!forceIssue && meta && certBuf && keyBuf &&
563
- meta.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
564
- // Cached, not due for renewal yet.
599
+ var certBuf = await _readSealedOrReissue(certManifest.name + "/cert.pem", certManifest.name);
600
+ var keyBuf = await _readSealedOrReissue(certManifest.name + "/key.pem", certManifest.name);
601
+ // Base the renewal decision on the SEALED cert's OWN notAfter, not the
602
+ // plaintext meta.json index. meta is a derived convenience copy; if it
603
+ // drifts from or is tampered relative to — the actual cert (a far-
604
+ // future meta.expiresAt over an actually-expiring cert), trusting it
605
+ // would skip renewal and serve an expired cert. Re-derive expiry +
606
+ // fingerprint from the cert itself; if it won't parse, treat it as a
607
+ // corrupt sealed cert and re-issue (same recovery as an unreadable one).
608
+ var actual = null;
609
+ if (certBuf) {
610
+ try { actual = _certMeta(certBuf.toString("utf8")); }
611
+ catch (e) {
612
+ log.warn("cert: sealed cert for '" + certManifest.name + "' will not parse (" +
613
+ e.message + ") — re-issuing");
614
+ _emitAudit("cert.sealed.corrupt", "recovered",
615
+ { path: certManifest.name + "/cert.pem", name: certManifest.name });
616
+ certBuf = null;
617
+ }
618
+ }
619
+ if (!forceIssue && actual && certBuf && keyBuf &&
620
+ actual.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
621
+ // Cached, and the cert's own notAfter is comfortably in the future.
565
622
  loadedContexts[certManifest.name] = {
566
623
  cert: certBuf.toString("utf8"),
567
624
  key: keyBuf.toString("utf8"),
568
- expiresAt: meta.expiresAt,
569
- fingerprintSha256: meta.fingerprintSha256,
625
+ expiresAt: actual.expiresAt,
626
+ fingerprintSha256: actual.fingerprintSha256,
570
627
  sniNames: certManifest.domains.slice(),
571
628
  };
572
629
  return loadedContexts[certManifest.name];
@@ -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,