@blamejs/core 0.7.18 → 0.7.19

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 (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
package/lib/safe-async.js CHANGED
@@ -1,735 +1,735 @@
1
- "use strict";
2
- /**
3
- * Async resilience + safety primitives.
4
- *
5
- * The framework's async surfaces (external-db queries, cluster
6
- * coordination, queue operations, audit chain writes) all share the
7
- * same hazards: races between interleaved awaits, unbounded retries
8
- * masking real failures, hangs from unresponsive backends, and partial
9
- * results from operator-supplied drivers. This module collects the
10
- * primitives the framework uses to handle those hazards consistently.
11
- *
12
- * Surface includes:
13
- * - Async coordination: withTimeout, withSignal, sleep, repeating,
14
- * flushLoop, safeAwait, asyncRetry
15
- * - Async state objects: Mutex, Semaphore, Once, CircuitBreaker
16
- * - Sync helpers used by async pipelines: safeInvoke (callback
17
- * wrapper with optional onError), makeDropCallback (factory for
18
- * log-stream-style onDrop callbacks), makeScheduledFlush
19
- * (idempotent setTimeout coalesce-and-flush helper)
20
- *
21
- * Design posture:
22
- *
23
- * - **AbortSignal everywhere.** Every primitive that takes time
24
- * accepts an `AbortSignal` and aborts cleanly when the signal
25
- * fires. This is the modern Node.js convention (Node 18+) and
26
- * replaces the older "cancellation token" pattern. Operators who
27
- * don't pass a signal get the legacy non-cancellable behaviour.
28
- *
29
- * - **Error.cause preserved.** Wrapper errors set `.cause` to the
30
- * original failure so debugging traces back to the root. Callers
31
- * who walk `.cause` chains see the full picture.
32
- *
33
- * - **No leaked Promises.** Mutex / Semaphore release on path-out
34
- * in finally blocks — even cancellation. No pending acquirer
35
- * stays referenced after its abort.
36
- *
37
- * - **Bounded by default.** Semaphore / Queue have explicit limits
38
- * and reject acquisitions over the limit rather than growing
39
- * unboundedly. Operators size limits explicitly for their workload.
40
- *
41
- * - **Fail loud.** Errors propagate; primitives never silently
42
- * swallow. safeAwait() opt-in for callers who need {error, value}
43
- * tuples; everything else throws / rejects.
44
- *
45
- * Public API:
46
- *
47
- * withTimeout(promise, ms, opts?) promise; rejects on timeout
48
- * withSignal(promise, signal) promise; rejects on abort
49
- * withTimeoutSignal(signal, ms) AbortSignal composing user
50
- * signal + a fresh timeout. Used
51
- * by I/O primitives that already
52
- * accept a signal and want to
53
- * add a wall-clock deadline.
54
- * sleep(ms, opts?) promise that resolves after ms;
55
- * opts.signal aborts mid-sleep,
56
- * timer is unref'd so a pending
57
- * sleep doesn't keep the process
58
- * alive
59
- * safeAwait(promise) [error, value] never throws
60
- *
61
- * Mutex class; .runExclusive(fn)
62
- * Semaphore(limit) class; .runWith(fn)
63
- * Once(fn) class; .invoke()
64
- *
65
- * asyncRetry(fn, opts?) re-export from object-store-retry
66
- * CircuitBreaker(name, opts?) re-export from object-store-retry
67
- *
68
- * SafeAsyncError error class
69
- *
70
- * Best-practice notes for callers:
71
- *
72
- * - Always pair `withTimeout` with the external-db / network calls
73
- * where operator-supplied drivers might hang. The framework's
74
- * external-db wrapper already retries; timeout puts a ceiling on
75
- * each individual attempt.
76
- *
77
- * - Wrap chain-writes with Mutex.runExclusive. Audit chain hashing
78
- * reads the previous tip and writes a successor; without
79
- * serialization, concurrent awaiting record() calls can hash
80
- * against the same prev-tip and produce a forked chain. Mutex
81
- * prevents this in single-process; for cross-process coordination
82
- * the cluster module's leader election is the correct primitive.
83
- *
84
- * - Use Once for boot-time lazy init (counter primer, schema
85
- * check). Multiple concurrent first-callers correctly all wait
86
- * on the same in-flight init Promise rather than each starting
87
- * their own.
88
- *
89
- * - Use safeAwait for fire-and-forget paths (audit hooks in
90
- * middleware) that previously used try/catch — preserves the
91
- * "log + continue" pattern without unhandled-rejection warnings.
92
- *
93
- * - Prefer Promise.allSettled over Promise.all when partial failure
94
- * is acceptable (e.g. emitting to multiple log sinks; one sink
95
- * down shouldn't block the others). The framework's log-stream
96
- * dispatcher already does this.
97
- */
98
-
99
- var { FrameworkError } = require("./framework-error");
100
-
101
- class SafeAsyncError extends FrameworkError {
102
- constructor(message, code, cause) {
103
- super(message);
104
- this.name = "SafeAsyncError";
105
- this.code = code || "async/invalid";
106
- if (cause !== undefined) this.cause = cause;
107
- this.isSafeAsyncError = true;
108
- }
109
- }
110
-
111
- // ---- withTimeout ----
112
- //
113
- // Race the promise against a timer. On timeout, the wrapper rejects with
114
- // SafeAsyncError(code=async/timeout). The original promise continues
115
- // running in the background — the framework cannot cancel an arbitrary
116
- // async operation; only signal-aware ones can be aborted (see withSignal).
117
- //
118
- // opts.signal: AbortSignal — aborting the signal also rejects the wrapper
119
- // with code=async/aborted.
120
- // opts.name: diagnostic label included in the timeout message.
121
-
122
- function withTimeout(promise, ms, opts) {
123
- opts = opts || {};
124
- if (typeof ms !== "number" || ms <= 0 || !Number.isFinite(ms)) {
125
- throw new SafeAsyncError("withTimeout: ms must be a positive finite number", "async/bad-arg");
126
- }
127
- return new Promise(function (resolve, reject) {
128
- var settled = false;
129
- // The timer is ref'd intentionally: while a withTimeout is pending,
130
- // the process should stay alive until either the underlying promise
131
- // settles or the timeout fires. unref'ing here would let Node exit
132
- // mid-await and the awaited Promise would never resolve.
133
- var timer = setTimeout(function () {
134
- if (settled) return;
135
- settled = true;
136
- reject(new SafeAsyncError(
137
- "operation timed out after " + ms + "ms" + (opts.name ? " (" + opts.name + ")" : ""),
138
- "async/timeout"
139
- ));
140
- }, ms);
141
-
142
- function _onAbort() {
143
- if (settled) return;
144
- settled = true;
145
- clearTimeout(timer);
146
- reject(new SafeAsyncError(
147
- "operation aborted" + (opts.name ? " (" + opts.name + ")" : ""),
148
- "async/aborted",
149
- opts.signal && opts.signal.reason
150
- ));
151
- }
152
- if (opts.signal) {
153
- if (opts.signal.aborted) { _onAbort(); return; }
154
- opts.signal.addEventListener("abort", _onAbort, { once: true });
155
- }
156
-
157
- Promise.resolve(promise).then(function (v) {
158
- if (settled) return;
159
- settled = true;
160
- clearTimeout(timer);
161
- if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
162
- resolve(v);
163
- }, function (e) {
164
- if (settled) return;
165
- settled = true;
166
- clearTimeout(timer);
167
- if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
168
- reject(e);
169
- });
170
- });
171
- }
172
-
173
- // ---- withSignal ----
174
- //
175
- // Race the promise against an AbortSignal. The original promise continues
176
- // running in the background; only the wrapper's resolution is short-
177
- // circuited. Useful for plumbing a single signal through a chain of awaits.
178
-
179
- function withSignal(promise, signal) {
180
- if (!signal) return Promise.resolve(promise);
181
- return new Promise(function (resolve, reject) {
182
- var settled = false;
183
- function _onAbort() {
184
- if (settled) return;
185
- settled = true;
186
- reject(new SafeAsyncError(
187
- "operation aborted",
188
- "async/aborted",
189
- signal.reason
190
- ));
191
- }
192
- if (signal.aborted) { _onAbort(); return; }
193
- signal.addEventListener("abort", _onAbort, { once: true });
194
- Promise.resolve(promise).then(function (v) {
195
- if (settled) return;
196
- settled = true;
197
- signal.removeEventListener("abort", _onAbort);
198
- resolve(v);
199
- }, function (e) {
200
- if (settled) return;
201
- settled = true;
202
- signal.removeEventListener("abort", _onAbort);
203
- reject(e);
204
- });
205
- });
206
- }
207
-
208
- // ---- sleep ----
209
- //
210
- // Promise that resolves after `ms` milliseconds. opts.signal aborts the
211
- // sleep cleanly — the wrapper rejects with SafeAsyncError(async/aborted).
212
- //
213
- // opts.unref (default false): if true, the timer is unref'd so a pending
214
- // sleep does NOT keep the process alive. Use for fire-and-forget /
215
- // heartbeat-style patterns where the program should be free to exit if
216
- // nothing else is keeping it busy. The default is ref'd to match the
217
- // natural meaning of `await sleep(ms)` — "I'm waiting, this IS my work,
218
- // don't exit out from under me." (An unref'd-only event loop in Node
219
- // exits even with pending awaits — the unref'd timer is not enough to
220
- // hold the loop alive, so the awaiting promise never resolves.)
221
- //
222
- // ms <= 0 resolves immediately (matches setTimeout's clamp-to-1ms but
223
- // without the wasted tick). Non-finite ms rejects.
224
-
225
- function sleep(ms, opts) {
226
- if (typeof ms !== "number" || !Number.isFinite(ms)) {
227
- return Promise.reject(new SafeAsyncError(
228
- "sleep: ms must be a finite number", "async/bad-arg"
229
- ));
230
- }
231
- if (ms <= 0) return Promise.resolve();
232
-
233
- var signal = opts && opts.signal;
234
- if (signal && signal.aborted) {
235
- return Promise.reject(new SafeAsyncError(
236
- "sleep aborted before start", "async/aborted", signal.reason
237
- ));
238
- }
239
-
240
- return new Promise(function (resolve, reject) {
241
- var settled = false;
242
- var timer = setTimeout(function () {
243
- if (settled) return;
244
- settled = true;
245
- if (signal) signal.removeEventListener("abort", _onAbort);
246
- resolve();
247
- }, ms);
248
- if (opts && opts.unref) timer.unref();
249
-
250
- function _onAbort() {
251
- if (settled) return;
252
- settled = true;
253
- clearTimeout(timer);
254
- reject(new SafeAsyncError(
255
- "sleep aborted", "async/aborted", signal.reason
256
- ));
257
- }
258
- if (signal) signal.addEventListener("abort", _onAbort, { once: true });
259
- });
260
- }
261
-
262
- // ---- withTimeoutSignal ----
263
- //
264
- // Returns an AbortSignal that fires when EITHER the input signal aborts
265
- // OR `ms` milliseconds elapse. Wraps the I/O-primitive composition that
266
- // http-client was doing inline twice (h1 + h2 paths).
267
- //
268
- // var sig = withTimeoutSignal(userSignal, 5000);
269
- // await fetch(url, { signal: sig });
270
- //
271
- // Edge cases:
272
- // - userSignal == null and ms not positive → returns null
273
- // (caller's "no signal needed" path)
274
- // - userSignal == null → returns AbortSignal.timeout(ms)
275
- // - ms not positive (0/undefined) → returns userSignal unchanged
276
- // - both → AbortSignal.any([user, timeout])
277
- //
278
- // Returning null when neither is requested means the caller can pass
279
- // the result straight to APIs that treat null as "no abort" (the http
280
- // `signal` option does), with no special-case branching needed.
281
-
282
- function withTimeoutSignal(signal, ms) {
283
- var hasTimeout = typeof ms === "number" && ms > 0 && Number.isFinite(ms);
284
- if (!signal && !hasTimeout) return null;
285
- if (!signal) return AbortSignal.timeout(ms);
286
- if (!hasTimeout) return signal;
287
- return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
288
- }
289
-
290
- // ---- safeAwait ----
291
- //
292
- // Go-style [error, value] tuple. Never throws. Lets callers handle the
293
- // "expected may fail; log and continue" pattern without try/catch
294
- // scaffolding.
295
- //
296
- // var [err, value] = await safeAwait(somePromise);
297
- // if (err) { /* log + continue */ }
298
-
299
- async function safeAwait(promise) {
300
- try {
301
- var v = await promise;
302
- return [null, v];
303
- } catch (e) {
304
- return [e, null];
305
- }
306
- }
307
-
308
- // safeInvoke — call an operator-supplied callback with one payload arg.
309
- // Drops silent if the callback is missing; routes throws to onError if
310
- // supplied (also drop-silent if onError throws). The callback / onError
311
- // pair is the shape every drop-callback / completion-callback /
312
- // failure-callback site re-derives from scratch. Centralizing here so
313
- // the contract is one definition.
314
- //
315
- // safeInvoke(opts.onDrop, { reason: "buffer-full", batch: rows },
316
- // function (e) { log.warn("onDrop threw: " + e.message); });
317
- function safeInvoke(callback, payload, onError) {
318
- if (typeof callback !== "function") return;
319
- try { callback(payload); }
320
- catch (e) {
321
- if (typeof onError === "function") {
322
- try { onError(e); } catch (_e2) { /* nested error handler must not bubble */ }
323
- }
324
- }
325
- }
326
-
327
- // makeDropCallback — every log-stream sink (cloudwatch / otlp-grpc /
328
- // otlp-http / syslog / webhook) previously defined its own
329
- // `_emitDrop(reason, batch, err)` 3-line wrapper. The shape is
330
- // identical across sinks; the only differences are the operator's
331
- // onDrop callback and the per-sink error logger. Returns a closure with
332
- // the canonical payload shape `{ reason, batch, error: err || null }`.
333
- //
334
- // var _emitDrop = safeAsync.makeDropCallback(onDrop,
335
- // function (e) { log.warn("onDrop-callback-failed: " + e.message); });
336
- // _emitDrop("buffer-full", batch, err);
337
- function makeDropCallback(onDrop, onError) {
338
- return function (reason, batch, err) {
339
- safeInvoke(onDrop, { reason: reason, batch: batch, error: err || null }, onError);
340
- };
341
- }
342
-
343
- // makeScheduledFlush — idempotent setTimeout scheduler used by every
344
- // log-stream sink (cloudwatch / otlp-grpc / otlp-http / syslog /
345
- // webhook) to coalesce buffered writes. Each sink previously rolled
346
- // its own:
347
- //
348
- // var flushTimer = null;
349
- // function _scheduleFlush() {
350
- // if (flushTimer) return;
351
- // flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, delayMs);
352
- // flushTimer.unref();
353
- // }
354
- //
355
- // Returns { schedule, cancel, isPending }. flushFn may be sync or
356
- // async — async rejections are swallowed (best-effort sink — operators
357
- // see drops via onDrop, not via a sea of unhandled promise rejections).
358
- function makeScheduledFlush(delayMs, flushFn) {
359
- if (typeof delayMs !== "number" || !isFinite(delayMs) || delayMs < 0) {
360
- throw new TypeError("safeAsync.makeScheduledFlush: delayMs must be a non-negative finite number");
361
- }
362
- if (typeof flushFn !== "function") {
363
- throw new TypeError("safeAsync.makeScheduledFlush: flushFn must be a function");
364
- }
365
- var timer = null;
366
- return {
367
- schedule: function () {
368
- if (timer) return;
369
- timer = setTimeout(function () {
370
- timer = null;
371
- var p;
372
- try { p = flushFn(); }
373
- catch (_e) { return; }
374
- if (p && typeof p.catch === "function") {
375
- p.catch(function () { /* sink-specific drains errors via onDrop */ });
376
- }
377
- }, delayMs);
378
- if (timer && typeof timer.unref === "function") timer.unref();
379
- },
380
- cancel: function () {
381
- if (timer) { clearTimeout(timer); timer = null; }
382
- },
383
- isPending: function () { return timer !== null; },
384
- };
385
- }
386
-
387
- // ---- Mutex ----
388
- //
389
- // Async mutex — only one async region holds the lock at a time. Acquirers
390
- // queue in arrival order. .runExclusive(fn) is the recommended call form
391
- // (lock release is automatic via finally even if fn throws); .acquire()
392
- // + .release() are exposed for callers needing finer control.
393
- //
394
- // Implementation note: a queued acquirer that's never released (operator
395
- // bug) blocks the entire mutex. We don't add a hard-coded timeout because
396
- // timeouts mask bugs and the real fix is releasing properly. If a caller
397
- // wants a deadline, wrap the runExclusive call with withTimeout.
398
-
399
- class Mutex {
400
- constructor() {
401
- this._waiters = []; // [{ resolve, reject, signal, onAbort }]
402
- this._held = false;
403
- }
404
-
405
- // acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
406
- // When the signal fires, the waiter is removed from the queue and
407
- // its Promise rejects with async/aborted. The slot it WOULD have
408
- // taken stays free for the next waiter. If the mutex isn't held,
409
- // acquire returns immediately regardless of signal state.
410
- acquire(opts) {
411
- var self = this;
412
- var signal = opts && opts.signal;
413
- if (!self._held) {
414
- self._held = true;
415
- return Promise.resolve();
416
- }
417
- if (signal && signal.aborted) {
418
- return Promise.reject(new SafeAsyncError(
419
- "Mutex.acquire aborted", "async/aborted", signal.reason
420
- ));
421
- }
422
- return new Promise(function (resolve, reject) {
423
- var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
424
- if (signal) {
425
- entry.onAbort = function () {
426
- var idx = self._waiters.indexOf(entry);
427
- if (idx === -1) return; // already taken or already aborted
428
- self._waiters.splice(idx, 1);
429
- reject(new SafeAsyncError(
430
- "Mutex.acquire aborted while waiting", "async/aborted", signal.reason
431
- ));
432
- };
433
- signal.addEventListener("abort", entry.onAbort, { once: true });
434
- }
435
- self._waiters.push(entry);
436
- });
437
- }
438
-
439
- release() {
440
- if (!this._held) {
441
- throw new SafeAsyncError("release on unheld Mutex", "async/bad-release");
442
- }
443
- if (this._waiters.length > 0) {
444
- var next = this._waiters.shift();
445
- if (next.signal && next.onAbort) {
446
- next.signal.removeEventListener("abort", next.onAbort);
447
- }
448
- next.resolve();
449
- } else {
450
- this._held = false;
451
- }
452
- }
453
-
454
- async runExclusive(fn, opts) {
455
- await this.acquire(opts);
456
- try {
457
- return await fn();
458
- } finally {
459
- this.release();
460
- }
461
- }
462
-
463
- isHeld() { return this._held; }
464
- pendingCount() { return this._waiters.length; }
465
- }
466
-
467
- // ---- Semaphore ----
468
- //
469
- // Bounded concurrency: at most `limit` simultaneous holders. Acquirers
470
- // over the limit wait their turn. Use cases: limit external-db query
471
- // concurrency, throttle outbound webhook fan-out, cap parallel file I/O.
472
- //
473
- // .runWith(fn) is the recommended form (release on finally); .acquire/
474
- // .release are exposed for finer control.
475
-
476
- class Semaphore {
477
- constructor(limit) {
478
- if (typeof limit !== "number" || limit < 1 || !Number.isInteger(limit)) {
479
- throw new SafeAsyncError("Semaphore limit must be a positive integer", "async/bad-arg");
480
- }
481
- this._limit = limit;
482
- this._inFlight = 0;
483
- this._waiters = []; // [{ resolve, reject, signal, onAbort }]
484
- }
485
-
486
- // acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
487
- acquire(opts) {
488
- var self = this;
489
- var signal = opts && opts.signal;
490
- if (self._inFlight < self._limit) {
491
- self._inFlight += 1;
492
- return Promise.resolve();
493
- }
494
- if (signal && signal.aborted) {
495
- return Promise.reject(new SafeAsyncError(
496
- "Semaphore.acquire aborted", "async/aborted", signal.reason
497
- ));
498
- }
499
- return new Promise(function (resolve, reject) {
500
- var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
501
- if (signal) {
502
- entry.onAbort = function () {
503
- var idx = self._waiters.indexOf(entry);
504
- if (idx === -1) return;
505
- self._waiters.splice(idx, 1);
506
- reject(new SafeAsyncError(
507
- "Semaphore.acquire aborted while waiting", "async/aborted", signal.reason
508
- ));
509
- };
510
- signal.addEventListener("abort", entry.onAbort, { once: true });
511
- }
512
- self._waiters.push(entry);
513
- });
514
- }
515
-
516
- release() {
517
- if (this._inFlight === 0) {
518
- throw new SafeAsyncError("release on idle Semaphore", "async/bad-release");
519
- }
520
- if (this._waiters.length > 0) {
521
- var next = this._waiters.shift();
522
- if (next.signal && next.onAbort) {
523
- next.signal.removeEventListener("abort", next.onAbort);
524
- }
525
- next.resolve();
526
- } else {
527
- this._inFlight -= 1;
528
- }
529
- }
530
-
531
- async runWith(fn, opts) {
532
- await this.acquire(opts);
533
- try {
534
- return await fn();
535
- } finally {
536
- this.release();
537
- }
538
- }
539
-
540
- inFlight() { return this._inFlight; }
541
- pendingCount() { return this._waiters.length; }
542
- }
543
-
544
- // ---- Once ----
545
- //
546
- // Single-flight memoization. The first .invoke() call runs the function;
547
- // subsequent calls (concurrent or later) await the same in-flight Promise
548
- // and receive the same result. If the first invocation throws, the cached
549
- // result is the rejected Promise — re-invocation will NOT retry.
550
- //
551
- // Use case: lazy boot-time init where multiple call sites might first-
552
- // touch concurrently (counter primer, schema-check, key load). Without
553
- // Once, the second concurrent caller would start its own init and produce
554
- // double-initialization.
555
- //
556
- // .reset() clears the cached Promise so the next .invoke() runs fresh.
557
- // Useful for tests, hot-reload, and operator-driven re-init after a
558
- // transient init failure. To make resets explicit (avoid silent stale
559
- // state), reset() does NOT cancel an in-flight first invocation —
560
- // callers awaiting the prior invoke continue to see its result.
561
-
562
- class Once {
563
- constructor(fn) {
564
- if (typeof fn !== "function") {
565
- throw new SafeAsyncError("Once: argument must be a function", "async/bad-arg");
566
- }
567
- this._fn = fn;
568
- this._promise = null;
569
- }
570
-
571
- invoke() {
572
- if (this._promise === null) {
573
- this._promise = Promise.resolve().then(this._fn);
574
- }
575
- return this._promise;
576
- }
577
-
578
- reset() {
579
- this._promise = null;
580
- }
581
-
582
- hasInvoked() { return this._promise !== null; }
583
- }
584
-
585
- // ---- repeating ----
586
- //
587
- // Bounded-cadence interval timer with consistent unref + cancel semantics.
588
- // Replaces the scattered setInterval ceremony where each caller hand-rolled
589
- // `var t = setInterval(...); t.unref();` and a corresponding clearInterval
590
- // in shutdown — easy to forget the unref and silently block process exit.
591
- //
592
- // var sweep = b.safeAsync.repeating(function () {
593
- // return cleanup();
594
- // }, b.constants.TIME.seconds(30), { name: "cache-sweep" });
595
- // ...
596
- // sweep.stop();
597
- //
598
- // fn may be sync or async. If fn returns a Promise, the next tick fires
599
- // `intervalMs` after the prior fn() *started* (matching setInterval's
600
- // fixed-rate semantics, not after-completion). Promise rejections are
601
- // captured by the optional onError callback; if none provided, they're
602
- // silently dropped — a repeating timer is by definition fire-and-forget,
603
- // and an unhandled rejection here would crash the process.
604
- //
605
- // opts.unref defaults true: most repeating timers are background sweepers
606
- // that should NOT keep the process alive. Cluster heartbeat etc. set
607
- // `unref: false` so the lease keeps the leader from exiting silently.
608
-
609
- function repeating(fn, intervalMs, opts) {
610
- if (typeof fn !== "function") {
611
- throw new SafeAsyncError("repeating: fn must be a function", "async/bad-arg");
612
- }
613
- if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
614
- throw new SafeAsyncError("repeating: intervalMs must be a positive finite number, got " + intervalMs,
615
- "async/bad-arg");
616
- }
617
- opts = opts || {};
618
- var unref = opts.unref !== false; // default true
619
- var onError = typeof opts.onError === "function" ? opts.onError : null;
620
-
621
- var stopped = false;
622
- function _tick() {
623
- if (stopped) return;
624
- var result;
625
- try { result = fn(); } catch (e) {
626
- if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return;
627
- }
628
- if (result && typeof result.then === "function") {
629
- result.then(null, function (e) {
630
- if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
631
- });
632
- }
633
- }
634
- var timer = setInterval(_tick, intervalMs);
635
- if (unref && typeof timer.unref === "function") timer.unref();
636
-
637
- return {
638
- stop: function () {
639
- if (stopped) return;
640
- stopped = true;
641
- clearInterval(timer);
642
- },
643
- };
644
- }
645
-
646
- // ---- flushLoop ----
647
- //
648
- // Schedule fn(), wait for it to settle (resolve or reject), then schedule
649
- // the next fn() `intervalMs` later. Differs from `repeating` (fixed-rate,
650
- // fire-and-forget) — flushLoop is the after-completion pattern most
651
- // background flushers want: never overlap two flushes, and don't accumulate
652
- // backlog if a flush is slow.
653
- //
654
- // var loop = b.safeAsync.flushLoop(function () {
655
- // return otelExporter.flush();
656
- // }, b.constants.TIME.seconds(15), { name: "otel-flush" });
657
- // ...
658
- // loop.stop();
659
- //
660
- // Always unref'd — a pending flush should never keep the process alive
661
- // (the operator's b.appShutdown drives the final drain explicitly).
662
- // onError catches rejections; without one, they're silently dropped.
663
-
664
- function flushLoop(fn, intervalMs, opts) {
665
- if (typeof fn !== "function") {
666
- throw new SafeAsyncError("flushLoop: fn must be a function", "async/bad-arg");
667
- }
668
- if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
669
- throw new SafeAsyncError("flushLoop: intervalMs must be a positive finite number, got " + intervalMs,
670
- "async/bad-arg");
671
- }
672
- opts = opts || {};
673
- var onError = typeof opts.onError === "function" ? opts.onError : null;
674
-
675
- var stopped = false;
676
- var timer = null;
677
-
678
- function _schedule() {
679
- if (stopped) return;
680
- timer = setTimeout(function () {
681
- timer = null;
682
- if (stopped) return;
683
- var settled;
684
- try { settled = Promise.resolve(fn()); }
685
- catch (e) {
686
- if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
687
- _schedule();
688
- return;
689
- }
690
- settled.then(null, function (e) {
691
- if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
692
- }).then(_schedule);
693
- }, intervalMs);
694
- if (typeof timer.unref === "function") timer.unref();
695
- }
696
- _schedule();
697
-
698
- return {
699
- stop: function () {
700
- if (stopped) return;
701
- stopped = true;
702
- if (timer) { clearTimeout(timer); timer = null; }
703
- },
704
- };
705
- }
706
-
707
- // ---- Re-exports of resilience primitives from lib/retry.js ----
708
- //
709
- // withRetry + CircuitBreaker live in lib/retry.js (the canonical home).
710
- // We re-export them here under safe-async-shaped names so call sites
711
- // reaching for async safety primitives find them in one place.
712
-
713
- var retryHelper = require("./retry");
714
-
715
- var asyncRetry = retryHelper.withRetry;
716
- var CircuitBreaker = retryHelper.CircuitBreaker;
717
-
718
- module.exports = {
719
- withTimeout: withTimeout,
720
- withSignal: withSignal,
721
- withTimeoutSignal: withTimeoutSignal,
722
- sleep: sleep,
723
- repeating: repeating,
724
- flushLoop: flushLoop,
725
- safeAwait: safeAwait,
726
- safeInvoke: safeInvoke,
727
- makeDropCallback: makeDropCallback,
728
- makeScheduledFlush: makeScheduledFlush,
729
- Mutex: Mutex,
730
- Semaphore: Semaphore,
731
- Once: Once,
732
- asyncRetry: asyncRetry,
733
- CircuitBreaker: CircuitBreaker,
734
- SafeAsyncError: SafeAsyncError,
735
- };
1
+ "use strict";
2
+ /**
3
+ * Async resilience + safety primitives.
4
+ *
5
+ * The framework's async surfaces (external-db queries, cluster
6
+ * coordination, queue operations, audit chain writes) all share the
7
+ * same hazards: races between interleaved awaits, unbounded retries
8
+ * masking real failures, hangs from unresponsive backends, and partial
9
+ * results from operator-supplied drivers. This module collects the
10
+ * primitives the framework uses to handle those hazards consistently.
11
+ *
12
+ * Surface includes:
13
+ * - Async coordination: withTimeout, withSignal, sleep, repeating,
14
+ * flushLoop, safeAwait, asyncRetry
15
+ * - Async state objects: Mutex, Semaphore, Once, CircuitBreaker
16
+ * - Sync helpers used by async pipelines: safeInvoke (callback
17
+ * wrapper with optional onError), makeDropCallback (factory for
18
+ * log-stream-style onDrop callbacks), makeScheduledFlush
19
+ * (idempotent setTimeout coalesce-and-flush helper)
20
+ *
21
+ * Design posture:
22
+ *
23
+ * - **AbortSignal everywhere.** Every primitive that takes time
24
+ * accepts an `AbortSignal` and aborts cleanly when the signal
25
+ * fires. This is the modern Node.js convention (Node 18+) and
26
+ * replaces the older "cancellation token" pattern. Operators who
27
+ * don't pass a signal get the legacy non-cancellable behaviour.
28
+ *
29
+ * - **Error.cause preserved.** Wrapper errors set `.cause` to the
30
+ * original failure so debugging traces back to the root. Callers
31
+ * who walk `.cause` chains see the full picture.
32
+ *
33
+ * - **No leaked Promises.** Mutex / Semaphore release on path-out
34
+ * in finally blocks — even cancellation. No pending acquirer
35
+ * stays referenced after its abort.
36
+ *
37
+ * - **Bounded by default.** Semaphore / Queue have explicit limits
38
+ * and reject acquisitions over the limit rather than growing
39
+ * unboundedly. Operators size limits explicitly for their workload.
40
+ *
41
+ * - **Fail loud.** Errors propagate; primitives never silently
42
+ * swallow. safeAwait() opt-in for callers who need {error, value}
43
+ * tuples; everything else throws / rejects.
44
+ *
45
+ * Public API:
46
+ *
47
+ * withTimeout(promise, ms, opts?) promise; rejects on timeout
48
+ * withSignal(promise, signal) promise; rejects on abort
49
+ * withTimeoutSignal(signal, ms) AbortSignal composing user
50
+ * signal + a fresh timeout. Used
51
+ * by I/O primitives that already
52
+ * accept a signal and want to
53
+ * add a wall-clock deadline.
54
+ * sleep(ms, opts?) promise that resolves after ms;
55
+ * opts.signal aborts mid-sleep,
56
+ * timer is unref'd so a pending
57
+ * sleep doesn't keep the process
58
+ * alive
59
+ * safeAwait(promise) [error, value] never throws
60
+ *
61
+ * Mutex class; .runExclusive(fn)
62
+ * Semaphore(limit) class; .runWith(fn)
63
+ * Once(fn) class; .invoke()
64
+ *
65
+ * asyncRetry(fn, opts?) re-export from object-store-retry
66
+ * CircuitBreaker(name, opts?) re-export from object-store-retry
67
+ *
68
+ * SafeAsyncError error class
69
+ *
70
+ * Best-practice notes for callers:
71
+ *
72
+ * - Always pair `withTimeout` with the external-db / network calls
73
+ * where operator-supplied drivers might hang. The framework's
74
+ * external-db wrapper already retries; timeout puts a ceiling on
75
+ * each individual attempt.
76
+ *
77
+ * - Wrap chain-writes with Mutex.runExclusive. Audit chain hashing
78
+ * reads the previous tip and writes a successor; without
79
+ * serialization, concurrent awaiting record() calls can hash
80
+ * against the same prev-tip and produce a forked chain. Mutex
81
+ * prevents this in single-process; for cross-process coordination
82
+ * the cluster module's leader election is the correct primitive.
83
+ *
84
+ * - Use Once for boot-time lazy init (counter primer, schema
85
+ * check). Multiple concurrent first-callers correctly all wait
86
+ * on the same in-flight init Promise rather than each starting
87
+ * their own.
88
+ *
89
+ * - Use safeAwait for fire-and-forget paths (audit hooks in
90
+ * middleware) that previously used try/catch — preserves the
91
+ * "log + continue" pattern without unhandled-rejection warnings.
92
+ *
93
+ * - Prefer Promise.allSettled over Promise.all when partial failure
94
+ * is acceptable (e.g. emitting to multiple log sinks; one sink
95
+ * down shouldn't block the others). The framework's log-stream
96
+ * dispatcher already does this.
97
+ */
98
+
99
+ var { FrameworkError } = require("./framework-error");
100
+
101
+ class SafeAsyncError extends FrameworkError {
102
+ constructor(message, code, cause) {
103
+ super(message);
104
+ this.name = "SafeAsyncError";
105
+ this.code = code || "async/invalid";
106
+ if (cause !== undefined) this.cause = cause;
107
+ this.isSafeAsyncError = true;
108
+ }
109
+ }
110
+
111
+ // ---- withTimeout ----
112
+ //
113
+ // Race the promise against a timer. On timeout, the wrapper rejects with
114
+ // SafeAsyncError(code=async/timeout). The original promise continues
115
+ // running in the background — the framework cannot cancel an arbitrary
116
+ // async operation; only signal-aware ones can be aborted (see withSignal).
117
+ //
118
+ // opts.signal: AbortSignal — aborting the signal also rejects the wrapper
119
+ // with code=async/aborted.
120
+ // opts.name: diagnostic label included in the timeout message.
121
+
122
+ function withTimeout(promise, ms, opts) {
123
+ opts = opts || {};
124
+ if (typeof ms !== "number" || ms <= 0 || !Number.isFinite(ms)) {
125
+ throw new SafeAsyncError("withTimeout: ms must be a positive finite number", "async/bad-arg");
126
+ }
127
+ return new Promise(function (resolve, reject) {
128
+ var settled = false;
129
+ // The timer is ref'd intentionally: while a withTimeout is pending,
130
+ // the process should stay alive until either the underlying promise
131
+ // settles or the timeout fires. unref'ing here would let Node exit
132
+ // mid-await and the awaited Promise would never resolve.
133
+ var timer = setTimeout(function () {
134
+ if (settled) return;
135
+ settled = true;
136
+ reject(new SafeAsyncError(
137
+ "operation timed out after " + ms + "ms" + (opts.name ? " (" + opts.name + ")" : ""),
138
+ "async/timeout"
139
+ ));
140
+ }, ms);
141
+
142
+ function _onAbort() {
143
+ if (settled) return;
144
+ settled = true;
145
+ clearTimeout(timer);
146
+ reject(new SafeAsyncError(
147
+ "operation aborted" + (opts.name ? " (" + opts.name + ")" : ""),
148
+ "async/aborted",
149
+ opts.signal && opts.signal.reason
150
+ ));
151
+ }
152
+ if (opts.signal) {
153
+ if (opts.signal.aborted) { _onAbort(); return; }
154
+ opts.signal.addEventListener("abort", _onAbort, { once: true });
155
+ }
156
+
157
+ Promise.resolve(promise).then(function (v) {
158
+ if (settled) return;
159
+ settled = true;
160
+ clearTimeout(timer);
161
+ if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
162
+ resolve(v);
163
+ }, function (e) {
164
+ if (settled) return;
165
+ settled = true;
166
+ clearTimeout(timer);
167
+ if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
168
+ reject(e);
169
+ });
170
+ });
171
+ }
172
+
173
+ // ---- withSignal ----
174
+ //
175
+ // Race the promise against an AbortSignal. The original promise continues
176
+ // running in the background; only the wrapper's resolution is short-
177
+ // circuited. Useful for plumbing a single signal through a chain of awaits.
178
+
179
+ function withSignal(promise, signal) {
180
+ if (!signal) return Promise.resolve(promise);
181
+ return new Promise(function (resolve, reject) {
182
+ var settled = false;
183
+ function _onAbort() {
184
+ if (settled) return;
185
+ settled = true;
186
+ reject(new SafeAsyncError(
187
+ "operation aborted",
188
+ "async/aborted",
189
+ signal.reason
190
+ ));
191
+ }
192
+ if (signal.aborted) { _onAbort(); return; }
193
+ signal.addEventListener("abort", _onAbort, { once: true });
194
+ Promise.resolve(promise).then(function (v) {
195
+ if (settled) return;
196
+ settled = true;
197
+ signal.removeEventListener("abort", _onAbort);
198
+ resolve(v);
199
+ }, function (e) {
200
+ if (settled) return;
201
+ settled = true;
202
+ signal.removeEventListener("abort", _onAbort);
203
+ reject(e);
204
+ });
205
+ });
206
+ }
207
+
208
+ // ---- sleep ----
209
+ //
210
+ // Promise that resolves after `ms` milliseconds. opts.signal aborts the
211
+ // sleep cleanly — the wrapper rejects with SafeAsyncError(async/aborted).
212
+ //
213
+ // opts.unref (default false): if true, the timer is unref'd so a pending
214
+ // sleep does NOT keep the process alive. Use for fire-and-forget /
215
+ // heartbeat-style patterns where the program should be free to exit if
216
+ // nothing else is keeping it busy. The default is ref'd to match the
217
+ // natural meaning of `await sleep(ms)` — "I'm waiting, this IS my work,
218
+ // don't exit out from under me." (An unref'd-only event loop in Node
219
+ // exits even with pending awaits — the unref'd timer is not enough to
220
+ // hold the loop alive, so the awaiting promise never resolves.)
221
+ //
222
+ // ms <= 0 resolves immediately (matches setTimeout's clamp-to-1ms but
223
+ // without the wasted tick). Non-finite ms rejects.
224
+
225
+ function sleep(ms, opts) {
226
+ if (typeof ms !== "number" || !Number.isFinite(ms)) {
227
+ return Promise.reject(new SafeAsyncError(
228
+ "sleep: ms must be a finite number", "async/bad-arg"
229
+ ));
230
+ }
231
+ if (ms <= 0) return Promise.resolve();
232
+
233
+ var signal = opts && opts.signal;
234
+ if (signal && signal.aborted) {
235
+ return Promise.reject(new SafeAsyncError(
236
+ "sleep aborted before start", "async/aborted", signal.reason
237
+ ));
238
+ }
239
+
240
+ return new Promise(function (resolve, reject) {
241
+ var settled = false;
242
+ var timer = setTimeout(function () {
243
+ if (settled) return;
244
+ settled = true;
245
+ if (signal) signal.removeEventListener("abort", _onAbort);
246
+ resolve();
247
+ }, ms);
248
+ if (opts && opts.unref) timer.unref();
249
+
250
+ function _onAbort() {
251
+ if (settled) return;
252
+ settled = true;
253
+ clearTimeout(timer);
254
+ reject(new SafeAsyncError(
255
+ "sleep aborted", "async/aborted", signal.reason
256
+ ));
257
+ }
258
+ if (signal) signal.addEventListener("abort", _onAbort, { once: true });
259
+ });
260
+ }
261
+
262
+ // ---- withTimeoutSignal ----
263
+ //
264
+ // Returns an AbortSignal that fires when EITHER the input signal aborts
265
+ // OR `ms` milliseconds elapse. Wraps the I/O-primitive composition that
266
+ // http-client was doing inline twice (h1 + h2 paths).
267
+ //
268
+ // var sig = withTimeoutSignal(userSignal, 5000);
269
+ // await fetch(url, { signal: sig });
270
+ //
271
+ // Edge cases:
272
+ // - userSignal == null and ms not positive → returns null
273
+ // (caller's "no signal needed" path)
274
+ // - userSignal == null → returns AbortSignal.timeout(ms)
275
+ // - ms not positive (0/undefined) → returns userSignal unchanged
276
+ // - both → AbortSignal.any([user, timeout])
277
+ //
278
+ // Returning null when neither is requested means the caller can pass
279
+ // the result straight to APIs that treat null as "no abort" (the http
280
+ // `signal` option does), with no special-case branching needed.
281
+
282
+ function withTimeoutSignal(signal, ms) {
283
+ var hasTimeout = typeof ms === "number" && ms > 0 && Number.isFinite(ms);
284
+ if (!signal && !hasTimeout) return null;
285
+ if (!signal) return AbortSignal.timeout(ms);
286
+ if (!hasTimeout) return signal;
287
+ return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
288
+ }
289
+
290
+ // ---- safeAwait ----
291
+ //
292
+ // Go-style [error, value] tuple. Never throws. Lets callers handle the
293
+ // "expected may fail; log and continue" pattern without try/catch
294
+ // scaffolding.
295
+ //
296
+ // var [err, value] = await safeAwait(somePromise);
297
+ // if (err) { /* log + continue */ }
298
+
299
+ async function safeAwait(promise) {
300
+ try {
301
+ var v = await promise;
302
+ return [null, v];
303
+ } catch (e) {
304
+ return [e, null];
305
+ }
306
+ }
307
+
308
+ // safeInvoke — call an operator-supplied callback with one payload arg.
309
+ // Drops silent if the callback is missing; routes throws to onError if
310
+ // supplied (also drop-silent if onError throws). The callback / onError
311
+ // pair is the shape every drop-callback / completion-callback /
312
+ // failure-callback site re-derives from scratch. Centralizing here so
313
+ // the contract is one definition.
314
+ //
315
+ // safeInvoke(opts.onDrop, { reason: "buffer-full", batch: rows },
316
+ // function (e) { log.warn("onDrop threw: " + e.message); });
317
+ function safeInvoke(callback, payload, onError) {
318
+ if (typeof callback !== "function") return;
319
+ try { callback(payload); }
320
+ catch (e) {
321
+ if (typeof onError === "function") {
322
+ try { onError(e); } catch (_e2) { /* nested error handler must not bubble */ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // makeDropCallback — every log-stream sink (cloudwatch / otlp-grpc /
328
+ // otlp-http / syslog / webhook) previously defined its own
329
+ // `_emitDrop(reason, batch, err)` 3-line wrapper. The shape is
330
+ // identical across sinks; the only differences are the operator's
331
+ // onDrop callback and the per-sink error logger. Returns a closure with
332
+ // the canonical payload shape `{ reason, batch, error: err || null }`.
333
+ //
334
+ // var _emitDrop = safeAsync.makeDropCallback(onDrop,
335
+ // function (e) { log.warn("onDrop-callback-failed: " + e.message); });
336
+ // _emitDrop("buffer-full", batch, err);
337
+ function makeDropCallback(onDrop, onError) {
338
+ return function (reason, batch, err) {
339
+ safeInvoke(onDrop, { reason: reason, batch: batch, error: err || null }, onError);
340
+ };
341
+ }
342
+
343
+ // makeScheduledFlush — idempotent setTimeout scheduler used by every
344
+ // log-stream sink (cloudwatch / otlp-grpc / otlp-http / syslog /
345
+ // webhook) to coalesce buffered writes. Each sink previously rolled
346
+ // its own:
347
+ //
348
+ // var flushTimer = null;
349
+ // function _scheduleFlush() {
350
+ // if (flushTimer) return;
351
+ // flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, delayMs);
352
+ // flushTimer.unref();
353
+ // }
354
+ //
355
+ // Returns { schedule, cancel, isPending }. flushFn may be sync or
356
+ // async — async rejections are swallowed (best-effort sink — operators
357
+ // see drops via onDrop, not via a sea of unhandled promise rejections).
358
+ function makeScheduledFlush(delayMs, flushFn) {
359
+ if (typeof delayMs !== "number" || !isFinite(delayMs) || delayMs < 0) {
360
+ throw new TypeError("safeAsync.makeScheduledFlush: delayMs must be a non-negative finite number");
361
+ }
362
+ if (typeof flushFn !== "function") {
363
+ throw new TypeError("safeAsync.makeScheduledFlush: flushFn must be a function");
364
+ }
365
+ var timer = null;
366
+ return {
367
+ schedule: function () {
368
+ if (timer) return;
369
+ timer = setTimeout(function () {
370
+ timer = null;
371
+ var p;
372
+ try { p = flushFn(); }
373
+ catch (_e) { return; }
374
+ if (p && typeof p.catch === "function") {
375
+ p.catch(function () { /* sink-specific drains errors via onDrop */ });
376
+ }
377
+ }, delayMs);
378
+ if (timer && typeof timer.unref === "function") timer.unref();
379
+ },
380
+ cancel: function () {
381
+ if (timer) { clearTimeout(timer); timer = null; }
382
+ },
383
+ isPending: function () { return timer !== null; },
384
+ };
385
+ }
386
+
387
+ // ---- Mutex ----
388
+ //
389
+ // Async mutex — only one async region holds the lock at a time. Acquirers
390
+ // queue in arrival order. .runExclusive(fn) is the recommended call form
391
+ // (lock release is automatic via finally even if fn throws); .acquire()
392
+ // + .release() are exposed for callers needing finer control.
393
+ //
394
+ // Implementation note: a queued acquirer that's never released (operator
395
+ // bug) blocks the entire mutex. We don't add a hard-coded timeout because
396
+ // timeouts mask bugs and the real fix is releasing properly. If a caller
397
+ // wants a deadline, wrap the runExclusive call with withTimeout.
398
+
399
+ class Mutex {
400
+ constructor() {
401
+ this._waiters = []; // [{ resolve, reject, signal, onAbort }]
402
+ this._held = false;
403
+ }
404
+
405
+ // acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
406
+ // When the signal fires, the waiter is removed from the queue and
407
+ // its Promise rejects with async/aborted. The slot it WOULD have
408
+ // taken stays free for the next waiter. If the mutex isn't held,
409
+ // acquire returns immediately regardless of signal state.
410
+ acquire(opts) {
411
+ var self = this;
412
+ var signal = opts && opts.signal;
413
+ if (!self._held) {
414
+ self._held = true;
415
+ return Promise.resolve();
416
+ }
417
+ if (signal && signal.aborted) {
418
+ return Promise.reject(new SafeAsyncError(
419
+ "Mutex.acquire aborted", "async/aborted", signal.reason
420
+ ));
421
+ }
422
+ return new Promise(function (resolve, reject) {
423
+ var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
424
+ if (signal) {
425
+ entry.onAbort = function () {
426
+ var idx = self._waiters.indexOf(entry);
427
+ if (idx === -1) return; // already taken or already aborted
428
+ self._waiters.splice(idx, 1);
429
+ reject(new SafeAsyncError(
430
+ "Mutex.acquire aborted while waiting", "async/aborted", signal.reason
431
+ ));
432
+ };
433
+ signal.addEventListener("abort", entry.onAbort, { once: true });
434
+ }
435
+ self._waiters.push(entry);
436
+ });
437
+ }
438
+
439
+ release() {
440
+ if (!this._held) {
441
+ throw new SafeAsyncError("release on unheld Mutex", "async/bad-release");
442
+ }
443
+ if (this._waiters.length > 0) {
444
+ var next = this._waiters.shift();
445
+ if (next.signal && next.onAbort) {
446
+ next.signal.removeEventListener("abort", next.onAbort);
447
+ }
448
+ next.resolve();
449
+ } else {
450
+ this._held = false;
451
+ }
452
+ }
453
+
454
+ async runExclusive(fn, opts) {
455
+ await this.acquire(opts);
456
+ try {
457
+ return await fn();
458
+ } finally {
459
+ this.release();
460
+ }
461
+ }
462
+
463
+ isHeld() { return this._held; }
464
+ pendingCount() { return this._waiters.length; }
465
+ }
466
+
467
+ // ---- Semaphore ----
468
+ //
469
+ // Bounded concurrency: at most `limit` simultaneous holders. Acquirers
470
+ // over the limit wait their turn. Use cases: limit external-db query
471
+ // concurrency, throttle outbound webhook fan-out, cap parallel file I/O.
472
+ //
473
+ // .runWith(fn) is the recommended form (release on finally); .acquire/
474
+ // .release are exposed for finer control.
475
+
476
+ class Semaphore {
477
+ constructor(limit) {
478
+ if (typeof limit !== "number" || limit < 1 || !Number.isInteger(limit)) {
479
+ throw new SafeAsyncError("Semaphore limit must be a positive integer", "async/bad-arg");
480
+ }
481
+ this._limit = limit;
482
+ this._inFlight = 0;
483
+ this._waiters = []; // [{ resolve, reject, signal, onAbort }]
484
+ }
485
+
486
+ // acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
487
+ acquire(opts) {
488
+ var self = this;
489
+ var signal = opts && opts.signal;
490
+ if (self._inFlight < self._limit) {
491
+ self._inFlight += 1;
492
+ return Promise.resolve();
493
+ }
494
+ if (signal && signal.aborted) {
495
+ return Promise.reject(new SafeAsyncError(
496
+ "Semaphore.acquire aborted", "async/aborted", signal.reason
497
+ ));
498
+ }
499
+ return new Promise(function (resolve, reject) {
500
+ var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
501
+ if (signal) {
502
+ entry.onAbort = function () {
503
+ var idx = self._waiters.indexOf(entry);
504
+ if (idx === -1) return;
505
+ self._waiters.splice(idx, 1);
506
+ reject(new SafeAsyncError(
507
+ "Semaphore.acquire aborted while waiting", "async/aborted", signal.reason
508
+ ));
509
+ };
510
+ signal.addEventListener("abort", entry.onAbort, { once: true });
511
+ }
512
+ self._waiters.push(entry);
513
+ });
514
+ }
515
+
516
+ release() {
517
+ if (this._inFlight === 0) {
518
+ throw new SafeAsyncError("release on idle Semaphore", "async/bad-release");
519
+ }
520
+ if (this._waiters.length > 0) {
521
+ var next = this._waiters.shift();
522
+ if (next.signal && next.onAbort) {
523
+ next.signal.removeEventListener("abort", next.onAbort);
524
+ }
525
+ next.resolve();
526
+ } else {
527
+ this._inFlight -= 1;
528
+ }
529
+ }
530
+
531
+ async runWith(fn, opts) {
532
+ await this.acquire(opts);
533
+ try {
534
+ return await fn();
535
+ } finally {
536
+ this.release();
537
+ }
538
+ }
539
+
540
+ inFlight() { return this._inFlight; }
541
+ pendingCount() { return this._waiters.length; }
542
+ }
543
+
544
+ // ---- Once ----
545
+ //
546
+ // Single-flight memoization. The first .invoke() call runs the function;
547
+ // subsequent calls (concurrent or later) await the same in-flight Promise
548
+ // and receive the same result. If the first invocation throws, the cached
549
+ // result is the rejected Promise — re-invocation will NOT retry.
550
+ //
551
+ // Use case: lazy boot-time init where multiple call sites might first-
552
+ // touch concurrently (counter primer, schema-check, key load). Without
553
+ // Once, the second concurrent caller would start its own init and produce
554
+ // double-initialization.
555
+ //
556
+ // .reset() clears the cached Promise so the next .invoke() runs fresh.
557
+ // Useful for tests, hot-reload, and operator-driven re-init after a
558
+ // transient init failure. To make resets explicit (avoid silent stale
559
+ // state), reset() does NOT cancel an in-flight first invocation —
560
+ // callers awaiting the prior invoke continue to see its result.
561
+
562
+ class Once {
563
+ constructor(fn) {
564
+ if (typeof fn !== "function") {
565
+ throw new SafeAsyncError("Once: argument must be a function", "async/bad-arg");
566
+ }
567
+ this._fn = fn;
568
+ this._promise = null;
569
+ }
570
+
571
+ invoke() {
572
+ if (this._promise === null) {
573
+ this._promise = Promise.resolve().then(this._fn);
574
+ }
575
+ return this._promise;
576
+ }
577
+
578
+ reset() {
579
+ this._promise = null;
580
+ }
581
+
582
+ hasInvoked() { return this._promise !== null; }
583
+ }
584
+
585
+ // ---- repeating ----
586
+ //
587
+ // Bounded-cadence interval timer with consistent unref + cancel semantics.
588
+ // Replaces the scattered setInterval ceremony where each caller hand-rolled
589
+ // `var t = setInterval(...); t.unref();` and a corresponding clearInterval
590
+ // in shutdown — easy to forget the unref and silently block process exit.
591
+ //
592
+ // var sweep = b.safeAsync.repeating(function () {
593
+ // return cleanup();
594
+ // }, b.constants.TIME.seconds(30), { name: "cache-sweep" });
595
+ // ...
596
+ // sweep.stop();
597
+ //
598
+ // fn may be sync or async. If fn returns a Promise, the next tick fires
599
+ // `intervalMs` after the prior fn() *started* (matching setInterval's
600
+ // fixed-rate semantics, not after-completion). Promise rejections are
601
+ // captured by the optional onError callback; if none provided, they're
602
+ // silently dropped — a repeating timer is by definition fire-and-forget,
603
+ // and an unhandled rejection here would crash the process.
604
+ //
605
+ // opts.unref defaults true: most repeating timers are background sweepers
606
+ // that should NOT keep the process alive. Cluster heartbeat etc. set
607
+ // `unref: false` so the lease keeps the leader from exiting silently.
608
+
609
+ function repeating(fn, intervalMs, opts) {
610
+ if (typeof fn !== "function") {
611
+ throw new SafeAsyncError("repeating: fn must be a function", "async/bad-arg");
612
+ }
613
+ if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
614
+ throw new SafeAsyncError("repeating: intervalMs must be a positive finite number, got " + intervalMs,
615
+ "async/bad-arg");
616
+ }
617
+ opts = opts || {};
618
+ var unref = opts.unref !== false; // default true
619
+ var onError = typeof opts.onError === "function" ? opts.onError : null;
620
+
621
+ var stopped = false;
622
+ function _tick() {
623
+ if (stopped) return;
624
+ var result;
625
+ try { result = fn(); } catch (e) {
626
+ if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return;
627
+ }
628
+ if (result && typeof result.then === "function") {
629
+ result.then(null, function (e) {
630
+ if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
631
+ });
632
+ }
633
+ }
634
+ var timer = setInterval(_tick, intervalMs);
635
+ if (unref && typeof timer.unref === "function") timer.unref();
636
+
637
+ return {
638
+ stop: function () {
639
+ if (stopped) return;
640
+ stopped = true;
641
+ clearInterval(timer);
642
+ },
643
+ };
644
+ }
645
+
646
+ // ---- flushLoop ----
647
+ //
648
+ // Schedule fn(), wait for it to settle (resolve or reject), then schedule
649
+ // the next fn() `intervalMs` later. Differs from `repeating` (fixed-rate,
650
+ // fire-and-forget) — flushLoop is the after-completion pattern most
651
+ // background flushers want: never overlap two flushes, and don't accumulate
652
+ // backlog if a flush is slow.
653
+ //
654
+ // var loop = b.safeAsync.flushLoop(function () {
655
+ // return otelExporter.flush();
656
+ // }, b.constants.TIME.seconds(15), { name: "otel-flush" });
657
+ // ...
658
+ // loop.stop();
659
+ //
660
+ // Always unref'd — a pending flush should never keep the process alive
661
+ // (the operator's b.appShutdown drives the final drain explicitly).
662
+ // onError catches rejections; without one, they're silently dropped.
663
+
664
+ function flushLoop(fn, intervalMs, opts) {
665
+ if (typeof fn !== "function") {
666
+ throw new SafeAsyncError("flushLoop: fn must be a function", "async/bad-arg");
667
+ }
668
+ if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
669
+ throw new SafeAsyncError("flushLoop: intervalMs must be a positive finite number, got " + intervalMs,
670
+ "async/bad-arg");
671
+ }
672
+ opts = opts || {};
673
+ var onError = typeof opts.onError === "function" ? opts.onError : null;
674
+
675
+ var stopped = false;
676
+ var timer = null;
677
+
678
+ function _schedule() {
679
+ if (stopped) return;
680
+ timer = setTimeout(function () {
681
+ timer = null;
682
+ if (stopped) return;
683
+ var settled;
684
+ try { settled = Promise.resolve(fn()); }
685
+ catch (e) {
686
+ if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
687
+ _schedule();
688
+ return;
689
+ }
690
+ settled.then(null, function (e) {
691
+ if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
692
+ }).then(_schedule);
693
+ }, intervalMs);
694
+ if (typeof timer.unref === "function") timer.unref();
695
+ }
696
+ _schedule();
697
+
698
+ return {
699
+ stop: function () {
700
+ if (stopped) return;
701
+ stopped = true;
702
+ if (timer) { clearTimeout(timer); timer = null; }
703
+ },
704
+ };
705
+ }
706
+
707
+ // ---- Re-exports of resilience primitives from lib/retry.js ----
708
+ //
709
+ // withRetry + CircuitBreaker live in lib/retry.js (the canonical home).
710
+ // We re-export them here under safe-async-shaped names so call sites
711
+ // reaching for async safety primitives find them in one place.
712
+
713
+ var retryHelper = require("./retry");
714
+
715
+ var asyncRetry = retryHelper.withRetry;
716
+ var CircuitBreaker = retryHelper.CircuitBreaker;
717
+
718
+ module.exports = {
719
+ withTimeout: withTimeout,
720
+ withSignal: withSignal,
721
+ withTimeoutSignal: withTimeoutSignal,
722
+ sleep: sleep,
723
+ repeating: repeating,
724
+ flushLoop: flushLoop,
725
+ safeAwait: safeAwait,
726
+ safeInvoke: safeInvoke,
727
+ makeDropCallback: makeDropCallback,
728
+ makeScheduledFlush: makeScheduledFlush,
729
+ Mutex: Mutex,
730
+ Semaphore: Semaphore,
731
+ Once: Once,
732
+ asyncRetry: asyncRetry,
733
+ CircuitBreaker: CircuitBreaker,
734
+ SafeAsyncError: SafeAsyncError,
735
+ };