@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/redact.js CHANGED
@@ -309,6 +309,7 @@ function _redact(value, depth, maxDepth, marker, parentKey) {
309
309
  function _resetForTest() {
310
310
  sensitiveFieldsSet = new Set(SENSITIVE_FIELDS);
311
311
  customDetectors = [];
312
+ outboundInstallCount = 0;
312
313
  }
313
314
 
314
315
  // ---- Classifier presets (for outbound DLP) ----
@@ -625,6 +626,14 @@ function classifyDefaults(opts) {
625
626
 
626
627
  var OUTBOUND_INSTALL_REGISTRY = new WeakMap();
627
628
 
629
+ // Count of primitive instances currently wrapped by an outbound-DLP
630
+ // interceptor. A WeakMap can't be enumerated, so this counter is the
631
+ // cheap "is anything installed?" signal b.compliance.set reads to decide
632
+ // whether to warn that a DLP-floor posture was pinned without wiring.
633
+ // Incremented per primitive on install, decremented on uninstall; never
634
+ // negative.
635
+ var outboundInstallCount = 0;
636
+
628
637
  function _emitDlp(action, outcome, metadata) {
629
638
  try {
630
639
  audit().safeEmit({
@@ -740,15 +749,15 @@ function installOutboundDlp(opts) {
740
749
 
741
750
  if (opts.httpClient) {
742
751
  var u1 = _installHttpClient(opts.httpClient, classifier, opts);
743
- if (u1) { uninstallers.push(u1); installed.httpClient = true; }
752
+ if (u1) { uninstallers.push(u1); installed.httpClient = true; outboundInstallCount += 1; }
744
753
  }
745
754
  if (opts.mail) {
746
755
  var u2 = _installMail(opts.mail, classifier, opts);
747
- if (u2) { uninstallers.push(u2); installed.mail = true; }
756
+ if (u2) { uninstallers.push(u2); installed.mail = true; outboundInstallCount += 1; }
748
757
  }
749
758
  if (opts.webhook) {
750
759
  var u3 = _installWebhook(opts.webhook, classifier, opts);
751
- if (u3) { uninstallers.push(u3); installed.webhook = true; }
760
+ if (u3) { uninstallers.push(u3); installed.webhook = true; outboundInstallCount += 1; }
752
761
  }
753
762
 
754
763
  _emitDlp("dlp.outbound.installed", "success", {
@@ -761,12 +770,37 @@ function installOutboundDlp(opts) {
761
770
  uninstall: function () {
762
771
  while (uninstallers.length > 0) {
763
772
  var fn = uninstallers.pop();
764
- try { fn(); } catch (_e) { /* best-effort */ }
773
+ try { fn(); if (outboundInstallCount > 0) outboundInstallCount -= 1; }
774
+ catch (_e) { /* best-effort */ }
765
775
  }
766
776
  },
767
777
  };
768
778
  }
769
779
 
780
+ /**
781
+ * @primitive b.redact.isOutboundDlpInstalled
782
+ * @signature b.redact.isOutboundDlpInstalled()
783
+ * @since 0.14.27
784
+ * @compliance hipaa, pci-dss, gdpr, soc2, fapi2
785
+ * @related b.redact.installForPosture, b.redact.installOutboundDlp
786
+ *
787
+ * Returns `true` when at least one primitive instance (httpClient /
788
+ * mail / webhook) currently carries an outbound-DLP interceptor.
789
+ * `b.compliance.set` reads this to decide whether to emit the one-time
790
+ * `compliance.posture.outbound_dlp_unwired` warning when a posture whose
791
+ * floor implies outbound DLP is pinned without any wiring. Read-only.
792
+ *
793
+ * @example
794
+ * b.redact.isOutboundDlpInstalled(); // → false
795
+ * var dlp = b.redact.installForPosture("hipaa", { httpClient: myHttp });
796
+ * b.redact.isOutboundDlpInstalled(); // → true
797
+ * dlp.uninstall();
798
+ * b.redact.isOutboundDlpInstalled(); // → false
799
+ */
800
+ function isOutboundDlpInstalled() {
801
+ return outboundInstallCount > 0;
802
+ }
803
+
770
804
  function _resolvePosturePatterns(name) {
771
805
  var n = String(name).toLowerCase();
772
806
  if (n === "pci-dss" || n === "pci") {
@@ -779,6 +813,15 @@ function _resolvePosturePatterns(name) {
779
813
  return ["pan", "credit-card", "iban", "pem", "aws-access-key", "jwt", "api-key-shape"];
780
814
  }
781
815
  if (n === "soc2" || n === "gdpr") {
816
+ // Known fidelity collapse: soc2 and gdpr share one outbound-DLP
817
+ // pattern set here. They are distinct regimes — GDPR's special-
818
+ // category personal data (Art. 9) is broader than the SOC 2 Trust
819
+ // Services criteria this preset targets — but the built-in
820
+ // value-shape detectors don't yet distinguish them, so the same
821
+ // pattern list backs both. Operators needing GDPR-specific shapes
822
+ // pass an explicit classifier (classifyDefaults) rather than the
823
+ // posture preset. This is intentional, not an accidental alias —
824
+ // documented so it isn't mistaken for per-regime handling.
782
825
  return ["ssn", "ein", "pem", "ssh-private", "aws-access-key", "api-key-shape"];
783
826
  }
784
827
  throw new DlpError("redact-dlp/unknown-posture",
@@ -959,9 +1002,12 @@ function _summarizeHit(h) {
959
1002
  return { label: h.label, action: h.action, where: h.where };
960
1003
  }
961
1004
 
962
- // Posture-coordinated install — a thin wrapper used by b.compliance.set
963
- // to wire DLP automatically when the posture is set. Operators using
964
- // b.compliance can rely on this; direct callers use installOutboundDlp.
1005
+ // Posture-coordinated install — the operator passes the primitive
1006
+ // instances to wire (httpClient / mail / webhook); the posture name
1007
+ // selects the default classifier. b.compliance.set does NOT call this:
1008
+ // it holds no httpClient / mail / webhook handles, so it cannot install
1009
+ // outbound interceptors. Operators wire DLP explicitly by calling this
1010
+ // (or installOutboundDlp) with their own primitive instances.
965
1011
  /**
966
1012
  * @primitive b.redact.installForPosture
967
1013
  * @signature b.redact.installForPosture(posture, primitives)
@@ -970,10 +1016,20 @@ function _summarizeHit(h) {
970
1016
  * @compliance hipaa, pci-dss, gdpr, soc2, fapi2
971
1017
  * @related b.redact.installOutboundDlp, b.redact.classifyDefaults
972
1018
  *
973
- * Posture-coordinated install — a thin wrapper used by
974
- * `b.compliance.set` so picking a posture also wires outbound DLP
975
- * automatically. Direct callers usually want `installOutboundDlp`
976
- * because it accepts the full hook surface.
1019
+ * Posture-coordinated install — picks the default classifier for
1020
+ * `posture` and wraps the operator-supplied `primitives.httpClient` /
1021
+ * `.mail` / `.webhook` so every outbound payload runs through it. A
1022
+ * thin convenience over `installOutboundDlp`; direct callers usually
1023
+ * want `installOutboundDlp` because it accepts the full hook surface.
1024
+ *
1025
+ * The operator MUST call this with the primitive instances — pinning a
1026
+ * posture via `b.compliance.set` does NOT auto-install outbound DLP,
1027
+ * because the compliance coordinator holds no httpClient / mail /
1028
+ * webhook handles. When a posture whose floor implies outbound DLP
1029
+ * (hipaa / pci-dss / gdpr / soc2 / fapi-2.0) is pinned without this
1030
+ * call, `b.compliance.set` emits a one-time
1031
+ * `compliance.posture.outbound_dlp_unwired` audit warning so the gap is
1032
+ * grep-able in the audit chain.
977
1033
  *
978
1034
  * @example
979
1035
  * var dlp = b.redact.installForPosture("hipaa", {
@@ -999,6 +1055,7 @@ module.exports = {
999
1055
  classifyDefaults: classifyDefaults,
1000
1056
  installOutboundDlp: installOutboundDlp,
1001
1057
  installForPosture: installForPosture,
1058
+ isOutboundDlpInstalled: isOutboundDlpInstalled,
1002
1059
  CLASSIFIER_PATTERNS: CLASSIFIER_PATTERNS,
1003
1060
  MARKER: DEFAULT_MARKER,
1004
1061
  SENSITIVE_FIELDS: SENSITIVE_FIELDS,
@@ -28,7 +28,6 @@ var net = require("node:net");
28
28
  var nodeTls = require("node:tls");
29
29
  var nodeUrl = require("node:url");
30
30
  var C = require("./constants");
31
- var safeAsync = require("./safe-async");
32
31
  var validateOpts = require("./validate-opts");
33
32
  var ipUtils = require("./ip-utils");
34
33
  var { RedisError } = require("./framework-error");
@@ -215,11 +214,28 @@ function create(opts) {
215
214
  var connected = false;
216
215
  var connecting = false;
217
216
  var closing = false;
217
+ // Shared in-flight connect. Every _connect() call returns the SAME
218
+ // promise while a connect is in progress, so concurrent callers all
219
+ // observe the same resolve/reject instead of polling a flag. It is
220
+ // ALWAYS settled (resolve on ready, reject on socket-error / connect-
221
+ // timeout / AUTH-or-SELECT failure) and cleared the moment it settles
222
+ // so the next caller starts a fresh attempt — a connect that fails
223
+ // can never leave a never-settling promise behind for the next
224
+ // awaiter to wedge on.
225
+ var connectPromise = null;
218
226
  // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
219
227
  // pending backoff (otherwise a reconnect scheduled before close fires
220
228
  // after it and opens a fresh socket); unref'd so a backoff window doesn't
221
229
  // by itself keep the event loop alive (the process-won't-exit class).
230
+ // Single-flight: a non-null reconnectTimer means a backoff is already
231
+ // pending — socket-error AND socket-close firing for the same failure
232
+ // must not stack two timers (which would burn the reconnect budget at
233
+ // 2x and open redundant sockets).
222
234
  var reconnectTimer = null;
235
+ // Set once the reconnect budget is exhausted. Makes the give-up path
236
+ // idempotent (drains pending+backlog exactly once) and stops a stray
237
+ // close/error after give-up from re-draining or racing a later success.
238
+ var gaveUp = false;
223
239
  var rxBuffer = Buffer.alloc(0);
224
240
  // FIFO of in-flight commands awaiting a response
225
241
  var pending = [];
@@ -239,18 +255,31 @@ function create(opts) {
239
255
 
240
256
  function _scheduleReconnect() {
241
257
  if (closing) return;
258
+ // Single-flight: a socket failure surfaces as both an `error` and a
259
+ // `close` event. Without this guard each one schedules its own timer,
260
+ // stacking two reconnects for one failure — the budget burns at 2x
261
+ // and two fresh sockets open. A pending backoff already covers the
262
+ // failure, so a second call is a no-op.
263
+ if (reconnectTimer !== null) return;
242
264
  if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
243
- // Drain pending callbacks with a clear error
265
+ // Reconnect budget exhausted. Drain pending + backlog exactly once;
266
+ // a later stray close/error must not re-drain or race a future
267
+ // success path.
268
+ if (gaveUp) return;
269
+ gaveUp = true;
244
270
  var err = _err("RECONNECT_GAVE_UP",
245
271
  "redis: gave up after " + reconnectAttempt + " reconnect attempts");
246
272
  _drainPending(err);
247
273
  return;
248
274
  }
249
275
  reconnectAttempt++;
276
+ // Exponential backoff capped at 30s. Base 100ms is the first-retry
277
+ // delay (not a duration unit), so it stays a literal; the cap routes
278
+ // through C.TIME.
250
279
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
251
280
  reconnectTimer = setTimeout(function () {
252
281
  reconnectTimer = null;
253
- _connect().catch(function () { /* will reschedule */ });
282
+ _connect().catch(function () { /* failure reschedules via the teardown path */ });
254
283
  }, delay);
255
284
  if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
256
285
  }
@@ -261,7 +290,10 @@ function create(opts) {
261
290
  batch.forEach(function (p) { p.reject(err); });
262
291
  var bl = backlog.slice();
263
292
  backlog.length = 0;
264
- bl.forEach(function (p) { p.reject(err); });
293
+ bl.forEach(function (p) {
294
+ if (p.timer) { clearTimeout(p.timer); p.timer = null; }
295
+ p.reject(err);
296
+ });
265
297
  }
266
298
 
267
299
  function _onData(chunk) {
@@ -312,39 +344,74 @@ function create(opts) {
312
344
  }
313
345
  }
314
346
 
315
- function _onSocketError(err) {
316
- var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
317
- _drainPending(werr);
347
+ // Single teardown path for a lost socket. A failure surfaces as an
348
+ // `error` event AND a `close` event (and `error` then destroys the
349
+ // socket, which fires `close` again) — three callbacks for ONE lost
350
+ // connection. Routing all of them here, guarded by a "are we still
351
+ // attached to this socket" check, means pending is drained once and
352
+ // exactly one reconnect is scheduled (the single-flight guard in
353
+ // _scheduleReconnect absorbs the rest). `err` is the diagnostic to
354
+ // reject in-flight commands with.
355
+ function _teardownSocket(err) {
356
+ // Already torn down for this socket (the sibling event already ran).
357
+ if (!connected && socket === null) {
358
+ // Still let a stray event re-arm a reconnect if one isn't pending
359
+ // and we haven't been closed — but never re-drain pending.
360
+ if (!closing) _scheduleReconnect();
361
+ return;
362
+ }
318
363
  connected = false;
319
- try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
364
+ var dead = socket;
320
365
  socket = null;
366
+ if (dead) {
367
+ try {
368
+ dead.removeListener("error", _onSocketError);
369
+ dead.removeListener("close", _onSocketClose);
370
+ dead.removeListener("data", _onData);
371
+ dead.destroy();
372
+ } catch (_e) { /* best-effort socket teardown */ }
373
+ }
374
+ _drainPending(err);
321
375
  if (!closing) _scheduleReconnect();
322
376
  }
323
377
 
378
+ function _onSocketError(err) {
379
+ _teardownSocket(_err("SOCKET",
380
+ "redis socket error: " + ((err && err.message) || String(err))));
381
+ }
382
+
324
383
  function _onSocketClose() {
325
- connected = false;
326
- if (!closing) {
327
- var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
328
- _drainPending(err);
329
- socket = null;
330
- _scheduleReconnect();
331
- }
384
+ _teardownSocket(_err("SOCKET_CLOSED", "redis socket closed unexpectedly"));
332
385
  }
333
386
 
334
- async function _connect() {
335
- // A reconnect timer scheduled before close() can still fire afterward;
336
- // refuse to re-open once closing so it doesn't leak a fresh socket.
337
- if (closing) return;
338
- if (connected) return;
339
- if (connecting) {
340
- // Wait until current connect attempt resolves
341
- while (connecting) await safeAsync.sleep(20);
342
- return;
343
- }
387
+ // _connect() — public entry. Returns a promise that ALWAYS settles.
388
+ // Concurrent callers (and the reconnect timer) share the single
389
+ // in-flight connectPromise rather than each starting a parallel dial,
390
+ // and they all observe the same resolve/reject. A previous version
391
+ // polled a `connecting` flag in a `while (connecting) await sleep(20)`
392
+ // loop; if a failure path failed to clear that flag the waiter spun
393
+ // forever. The shared promise removes that wedge — the promise is
394
+ // cleared the instant it settles, so a failed connect can never leave
395
+ // a never-settling promise behind.
396
+ function _connect() {
397
+ if (closing) return Promise.resolve();
398
+ if (connected) return Promise.resolve();
399
+ if (connectPromise) return connectPromise;
400
+ connectPromise = _doConnect();
401
+ // Clear the shared promise once it settles (either way) so the next
402
+ // _connect() starts a fresh attempt instead of re-awaiting a stale
403
+ // settled promise.
404
+ var clear = function () { connectPromise = null; };
405
+ connectPromise.then(clear, clear);
406
+ return connectPromise;
407
+ }
408
+
409
+ async function _doConnect() {
344
410
  connecting = true;
345
411
  rxBuffer = Buffer.alloc(0);
412
+ var newSocket = null;
346
413
  try {
347
- socket = await new Promise(function (resolve, reject) {
414
+ newSocket = await new Promise(function (resolve, reject) {
348
415
  var sock;
349
416
  var timer = setTimeout(function () {
350
417
  try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
@@ -371,16 +438,19 @@ function create(opts) {
371
438
  }
372
439
  sock.once("error", onErr);
373
440
  });
441
+ socket = newSocket;
374
442
  socket.setNoDelay(true);
375
443
  socket.on("data", _onData);
376
444
  socket.on("error", _onSocketError);
377
445
  socket.on("close", _onSocketClose);
378
446
  connected = true;
379
- reconnectAttempt = 0;
380
447
 
381
448
  // Auth + select db on (re)connect — without resetting the
382
449
  // backlog of commands queued during disconnect. Send these
383
450
  // BEFORE the backlog so the server is ready when backlog flushes.
451
+ // A failure here (wrong password, server SELECT rejection, socket
452
+ // dropped mid-AUTH) must not leave connected=true on a half-open
453
+ // socket — the catch below tears the socket down and rethrows.
384
454
  if (password) {
385
455
  var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
386
456
  await _sendNoQueue(authArgs);
@@ -389,15 +459,47 @@ function create(opts) {
389
459
  await _sendNoQueue(["SELECT", String(db)]);
390
460
  }
391
461
 
392
- // Flush backlog
462
+ // Connect fully succeeded — only now reset the backoff counter +
463
+ // the give-up latch so a future disconnect gets a fresh budget.
464
+ reconnectAttempt = 0;
465
+ gaveUp = false;
466
+ connecting = false;
467
+
468
+ // Flush backlog. Clear each queued entry's not-connected timeout
469
+ // before it goes on the wire — the in-flight command timeout in
470
+ // _writeAndAwait now owns its lifetime.
393
471
  var bl = backlog.slice();
394
472
  backlog.length = 0;
395
- bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
473
+ bl.forEach(function (entry) {
474
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
475
+ _writeAndAwait(entry.args, entry.resolve, entry.reject);
476
+ });
396
477
  } catch (err) {
397
478
  connecting = false;
479
+ connected = false;
480
+ // Tear down a half-open socket (came up, then AUTH/SELECT failed)
481
+ // so we never leave connected=false with a live socket whose data/
482
+ // error/close handlers would fire against stale state. If the
483
+ // socket-error handler already ran it set socket=null.
484
+ var dead = socket || newSocket;
485
+ socket = null;
486
+ if (dead) {
487
+ try {
488
+ dead.removeListener("error", _onSocketError);
489
+ dead.removeListener("close", _onSocketClose);
490
+ dead.removeListener("data", _onData);
491
+ dead.destroy();
492
+ } catch (_e) { /* best-effort socket teardown */ }
493
+ }
494
+ // A failed dial (reset before ready) or an AUTH/SELECT failure must keep
495
+ // the reconnect loop alive. The post-ready error/close handlers that
496
+ // normally drive reconnect are not attached yet during the dial, so
497
+ // without scheduling here a connection lost mid-dial rejects the connect
498
+ // promise and the client never reconnects. Single-flight + budget-guarded
499
+ // by _scheduleReconnect; the caller still observes this attempt's rejection.
500
+ if (!closing) _scheduleReconnect();
398
501
  throw err;
399
502
  }
400
- connecting = false;
401
503
  }
402
504
 
403
505
  // Internal helper that bypasses the connect-pending backlog (used
@@ -448,7 +550,28 @@ function create(opts) {
448
550
  return;
449
551
  }
450
552
  if (!connected) {
451
- backlog.push({ args: args, resolve: resolve, reject: reject });
553
+ // Reconnect budget exhausted and no reconnect is in flight — a
554
+ // backlogged command here would never be flushed (nothing will
555
+ // reconnect to drain it) and would wedge the caller forever.
556
+ // Reject immediately instead.
557
+ if (gaveUp && reconnectTimer === null && !connecting) {
558
+ reject(_err("RECONNECT_GAVE_UP",
559
+ "redis: client disconnected and reconnect budget exhausted"));
560
+ return;
561
+ }
562
+ // Queued until the next successful connect flushes the backlog.
563
+ // Bound it with a timeout so a connect that never completes (the
564
+ // backend is down for the whole window) settles the caller with
565
+ // a clear error instead of leaving the await pending forever.
566
+ var entry = { args: args, resolve: resolve, reject: reject, timer: null };
567
+ entry.timer = setTimeout(function () {
568
+ var idx = backlog.indexOf(entry);
569
+ if (idx !== -1) backlog.splice(idx, 1);
570
+ reject(_err("COMMAND_TIMEOUT",
571
+ "redis " + args[0] + " timed out while queued (client not connected)"));
572
+ }, commandTimeoutMs);
573
+ if (typeof entry.timer.unref === "function") entry.timer.unref();
574
+ backlog.push(entry);
452
575
  return;
453
576
  }
454
577
  _writeAndAwait(args, resolve, reject);
@@ -469,6 +592,9 @@ function create(opts) {
469
592
  async function close() {
470
593
  closing = true;
471
594
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
595
+ // Drop the shared connect promise so a re-create after close (or a
596
+ // late awaiter) doesn't re-await a stale in-flight attempt.
597
+ connectPromise = null;
472
598
  var err = _err("CLOSED", "redis client closed");
473
599
  _drainPending(err);
474
600
  if (socket) {
@@ -492,8 +618,11 @@ function create(opts) {
492
618
  _state: function () {
493
619
  return {
494
620
  connected: connected, closing: closing,
621
+ connecting: connecting,
495
622
  pending: pending.length, backlog: backlog.length,
496
623
  reconnect: reconnectAttempt,
624
+ reconnectPending: reconnectTimer !== null,
625
+ gaveUp: gaveUp,
497
626
  host: host, port: port, db: db, tls: useTls,
498
627
  connectTimeoutMs: connectTimeoutMs,
499
628
  commandTimeoutMs: commandTimeoutMs,
package/lib/retention.js CHANGED
@@ -46,6 +46,7 @@ var C = require("./constants");
46
46
  var lazyRequire = require("./lazy-require");
47
47
  var validateOpts = require("./validate-opts");
48
48
  var safeSql = require("./safe-sql");
49
+ var sql = require("./sql");
49
50
  var { defineClass } = require("./framework-error");
50
51
 
51
52
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -55,6 +56,25 @@ var legalHold = lazyRequire(function () { return require("./legal-hold"); });
55
56
  var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
56
57
  var _err = RetentionError.factory;
57
58
 
59
+ // Resolve the b.sql dialect for the operator-supplied handle. The framework's
60
+ // local b.db handle is always node:sqlite (db.js pins { dialect: "sqlite",
61
+ // quoteName: true }) and exposes no .dialect, so this defaults to "sqlite" —
62
+ // every sweep statement runs against that handle via .prepare(). An operator
63
+ // handle that DOES advertise a dialect (string or () -> string) has it
64
+ // threaded through so the emitted identifier quoting + idioms match the
65
+ // backend the handle dispatches to. quoteName stays on for every retention
66
+ // statement: the rule's table / ageField / softDeleteField identifiers are
67
+ // validated then quoted by construction (no clusterStorage prefix rewrite on
68
+ // this operator-app-schema path).
69
+ function _handleDialect(db) {
70
+ if (db && typeof db.dialect === "function") {
71
+ try { var d = db.dialect(); return typeof d === "string" ? d : "sqlite"; }
72
+ catch (_e) { return "sqlite"; }
73
+ }
74
+ if (db && typeof db.dialect === "string") return db.dialect;
75
+ return "sqlite";
76
+ }
77
+
58
78
  // Identifier-level SQLi defense: every operator-supplied table name,
59
79
  // column name, and cascade FK must pass safeSql.validateIdentifier
60
80
  // before reaching SQL string concatenation. Without this gate a
@@ -196,6 +216,11 @@ function create(opts) {
196
216
  throw _err("BAD_OPT", "create: opts.db is required (a b.db handle with .prepare(sql))");
197
217
  }
198
218
  var db = opts.db;
219
+ // b.sql opts for every retention statement built against this handle. The
220
+ // dialect tracks the handle (sqlite for the framework's local b.db); the
221
+ // validated operator identifiers are quoted by construction (quoteName)
222
+ // with no clusterStorage prefix rewrite on this path.
223
+ var SQL_OPTS = { dialect: _handleDialect(db), quoteName: true };
199
224
  var auditOn = opts.audit !== false && opts.audit != null;
200
225
  var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
201
226
  var rules = {};
@@ -235,16 +260,24 @@ function create(opts) {
235
260
 
236
261
  function _hardDelete(table, rowId, dryRun) {
237
262
  if (dryRun) return { wouldDelete: 1 };
238
- var del = db.prepare("DELETE FROM \"" + table + "\" WHERE _id = ?");
239
- del.run(rowId);
263
+ // Operator app table quoteName so the validated identifier emits as a
264
+ // quoted local name; the row id binds as a placeholder.
265
+ var built = sql.delete(table, SQL_OPTS)
266
+ .where("_id", rowId)
267
+ .toSql();
268
+ var del = db.prepare(built.sql);
269
+ del.run.apply(del, built.params);
240
270
  return { deleted: 1 };
241
271
  }
242
272
 
243
273
  function _softDelete(table, rowId, softField, dryRun) {
244
274
  if (dryRun) return { wouldSoftDelete: 1 };
245
- var upd = db.prepare(
246
- "UPDATE \"" + table + "\" SET \"" + softField + "\" = ? WHERE _id = ?");
247
- upd.run(Date.now(), rowId);
275
+ var built = sql.update(table, SQL_OPTS)
276
+ .set(softField, Date.now())
277
+ .where("_id", rowId)
278
+ .toSql();
279
+ var upd = db.prepare(built.sql);
280
+ upd.run.apply(upd, built.params);
248
281
  return { softDeleted: 1 };
249
282
  }
250
283
 
@@ -261,19 +294,17 @@ function create(opts) {
261
294
  return _hardDelete(table, row._id, dryRun);
262
295
  }
263
296
  if (dryRun) return { wouldErase: 1, sealedFieldCount: sealedFields.length };
264
- var setClauses = [];
265
- var values = [];
266
- for (var si = 0; si < sealedFields.length; si++) {
267
- setClauses.push('"' + sealedFields[si] + '" = ?');
268
- values.push(null);
269
- }
270
- for (var hi = 0; hi < hashFields.length; hi++) {
271
- setClauses.push('"' + hashFields[hi] + '" = ?');
272
- values.push(null);
273
- }
274
- values.push(row._id);
275
- var upd2 = db.prepare("UPDATE \"" + table + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
276
- upd2.run.apply(upd2, values);
297
+ // NULL every sealed column + its derived-hash sibling. b.sql binds each
298
+ // null as a placeholder (the set map preserves the column ordering).
299
+ var eraseSet = {};
300
+ for (var si = 0; si < sealedFields.length; si++) eraseSet[sealedFields[si]] = null;
301
+ for (var hi = 0; hi < hashFields.length; hi++) eraseSet[hashFields[hi]] = null;
302
+ var eraseBuilt = sql.update(table, SQL_OPTS)
303
+ .set(eraseSet)
304
+ .where("_id", row._id)
305
+ .toSql();
306
+ var upd2 = db.prepare(eraseBuilt.sql);
307
+ upd2.run.apply(upd2, eraseBuilt.params);
277
308
  // Per-row-key tables (declarePerRowKey): NULLing the sealed columns
278
309
  // is not enough — WAL / replica residuals keep the old K_row cells.
279
310
  // Destroy the row's wrapped secret so K_row is unrecoverable and the
@@ -294,15 +325,20 @@ function create(opts) {
294
325
  for (var i = 0; i < rule.cascade.length; i++) {
295
326
  var c = rule.cascade[i];
296
327
  if (dryRun) {
297
- var sel = db.prepare(
298
- "SELECT COUNT(*) AS n FROM \"" + c.table + "\" WHERE \"" + c.foreignKey + "\" = ?");
299
- var n = sel.get(rowId);
328
+ var selBuilt = sql.select(c.table, SQL_OPTS)
329
+ .count("*", "n")
330
+ .where(c.foreignKey, rowId)
331
+ .toSql();
332
+ var sel = db.prepare(selBuilt.sql);
333
+ var n = sel.get.apply(sel, selBuilt.params);
300
334
  cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
301
335
  wouldDelete: (n && typeof n.n === "number") ? n.n : 0 });
302
336
  } else {
303
- var del = db.prepare(
304
- "DELETE FROM \"" + c.table + "\" WHERE \"" + c.foreignKey + "\" = ?");
305
- var result = del.run(rowId);
337
+ var delBuilt = sql.delete(c.table, SQL_OPTS)
338
+ .where(c.foreignKey, rowId)
339
+ .toSql();
340
+ var del = db.prepare(delBuilt.sql);
341
+ var result = del.run.apply(del, delBuilt.params);
306
342
  cascadeSummary.push({ table: c.table, foreignKey: c.foreignKey,
307
343
  deleted: result.changes || 0 });
308
344
  }
@@ -392,24 +428,31 @@ function create(opts) {
392
428
  while (moreRows) {
393
429
  var rows;
394
430
  // The candidate WHERE-clause: age + not-already-erased + not-on-legal-hold +
395
- // (when soft-delete is configured) not-already-soft-deleted.
396
- var whereParts = ['"' + rule.ageField + '" <= ?'];
397
- var whereArgs = [cutoff];
398
- if (rule.softDeleteField) {
399
- whereParts.push('("' + rule.softDeleteField + '" IS NULL)');
431
+ // (when soft-delete is configured) not-already-soft-deleted. Built
432
+ // through b.sql so the operator-supplied table / ageField / softDeleteField
433
+ // identifiers are quoted by construction and every value binds as a
434
+ // placeholder (the '' empty-string compare included — no embedded literal).
435
+ function _candidateBase() {
436
+ var qb = sql.select(rule.table, SQL_OPTS)
437
+ .where(rule.ageField, "<=", cutoff);
438
+ if (rule.softDeleteField) qb.whereNull(rule.softDeleteField);
439
+ return qb;
400
440
  }
401
- var sql = "SELECT * FROM \"" + rule.table + "\" " +
402
- "WHERE " + whereParts.join(" AND ") + " " +
403
- "AND (__erasedAt IS NULL OR __erasedAt = '') " +
404
- "LIMIT ?";
405
441
  var selStmt;
406
- try { selStmt = db.prepare(sql); rows = selStmt.all.apply(selStmt, whereArgs.concat([rule.batchSize])); }
407
- catch (_eA) {
442
+ try {
443
+ var built = _candidateBase()
444
+ .whereGroup(function (g) {
445
+ g.whereNull("__erasedAt").orWhereOp("__erasedAt", "=", "");
446
+ })
447
+ .limit(rule.batchSize)
448
+ .toSql();
449
+ selStmt = db.prepare(built.sql);
450
+ rows = selStmt.all.apply(selStmt, built.params);
451
+ } catch (_eA) {
408
452
  // Fallback: tables without __erasedAt
409
- var sqlPlain = "SELECT * FROM \"" + rule.table + "\" " +
410
- "WHERE " + whereParts.join(" AND ") + " LIMIT ?";
411
- var selPlain = db.prepare(sqlPlain);
412
- rows = selPlain.all.apply(selPlain, whereArgs.concat([rule.batchSize]));
453
+ var plainBuilt = _candidateBase().limit(rule.batchSize).toSql();
454
+ var selPlain = db.prepare(plainBuilt.sql);
455
+ rows = selPlain.all.apply(selPlain, plainBuilt.params);
413
456
  }
414
457
  if (!rows || rows.length === 0) { moreRows = false; break; }
415
458
  summary.scanned += rows.length;