@blamejs/core 0.7.4 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. 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
+ };