@ix-xs/node-comfort 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Func.js CHANGED
@@ -10,14 +10,16 @@
10
10
  * const [error, user] = await func.to(getUser(id));
11
11
  */
12
12
 
13
+ const { types } = require("node:util");
13
14
  const { TimeoutError, AbortError } = require("./errors.js");
15
+ const { toMs, sleep: _sleep, setLongTimeout, argsKey } = require("./internal/shared.js");
14
16
 
15
17
  /**
16
18
  * Options for `debounce()`.
17
19
  * @typedef {object} DebounceOptions
18
20
  * @property {boolean} [leading] Call right away at the start of a burst.
19
21
  * @property {boolean} [trailing] Call once the burst is over. Defaults to `true`.
20
- * @property {number} [maxWait] Never postpone a call longer than this, in ms, even if calls keep coming.
22
+ * @property {number | string} [maxWait] Never postpone a call longer than this, even if calls keep coming. In ms, or like `"2s"`.
21
23
  */
22
24
 
23
25
  /**
@@ -43,8 +45,8 @@ const { TimeoutError, AbortError } = require("./errors.js");
43
45
  /**
44
46
  * Options for `memoize()`.
45
47
  * @typedef {object} MemoizeOptions
46
- * @property {(...args: any[]) => unknown} [resolver] Builds the cache key from the arguments. Defaults to the arguments as JSON.
47
- * @property {number} [ttl] How long a result stays cached, in ms.
48
+ * @property {(...args: any[]) => unknown} [resolver] Builds the cache key from the arguments. By default, primitives are compared by value, plain objects and arrays by content, anything else by identity.
49
+ * @property {number | string} [ttl] How long a result stays cached, in ms or like `"5m"`.
48
50
  * @property {number} [max] Maximum number of results; the least recently used goes first.
49
51
  * @property {boolean} [cacheRejections] Keep rejected promises. By default they're dropped so the next call tries again.
50
52
  */
@@ -59,9 +61,9 @@ const { TimeoutError, AbortError } = require("./errors.js");
59
61
  * Options for `retry()`.
60
62
  * @typedef {object} RetryOptions
61
63
  * @property {number} [attempts] Total attempts, the first one included. Defaults to `3`.
62
- * @property {number | ((attempt: number, error: unknown) => number)} [delay] Wait before the next attempt, in ms, or a function that computes it.
64
+ * @property {number | string | ((attempt: number, error: unknown) => number)} [delay] Wait before the next attempt, in ms or like `"1s"`, or a function that computes it.
63
65
  * @property {number} [backoff] Multiplies the delay after each failure: `2` gives 200, 400, 800... Defaults to `1`.
64
- * @property {number} [maxDelay] Cap for the computed delay, in ms.
66
+ * @property {number | string} [maxDelay] Cap for the computed delay.
65
67
  * @property {number} [jitter] Randomizes the delay, from `0` (none) to `1` (anywhere between 0 and 2x). Stops many clients from retrying in sync.
66
68
  * @property {(error: unknown, attempt: number) => boolean | Promise<boolean>} [shouldRetry] Return `false` to give up early, for example on a 4xx error.
67
69
  * @property {(error: unknown, attempt: number, delay: number) => void} [onRetry] Called before each wait, for logs or metrics.
@@ -69,23 +71,18 @@ const { TimeoutError, AbortError } = require("./errors.js");
69
71
  */
70
72
 
71
73
  /**
72
- * @param {number} ms
73
- * @param {AbortSignal} [signal]
74
- * @returns {Promise<void>}
74
+ * Options for `batch()`.
75
+ * @typedef {object} BatchOptions
76
+ * @property {number | string} [wait] How long to collect calls before running. By default, calls made in the same tick go together.
77
+ * @property {number} [max] Run as soon as this many calls are waiting.
78
+ */
79
+
80
+ /**
81
+ * A batched function. `flush()` runs the waiting calls now.
82
+ * @template I
83
+ * @template R
84
+ * @typedef {((item: I) => Promise<R>) & { flush(): void }} BatchedFunction
75
85
  */
76
- const _sleep = (ms, signal) =>
77
- new Promise((resolve, reject) => {
78
- if (signal?.aborted) return reject(new AbortError(undefined, { cause: signal.reason }));
79
- const timer = setTimeout(() => {
80
- signal?.removeEventListener("abort", onAbort);
81
- resolve();
82
- }, ms);
83
- const onAbort = () => {
84
- clearTimeout(timer);
85
- reject(new AbortError(undefined, { cause: signal?.reason }));
86
- };
87
- signal?.addEventListener("abort", onAbort, { once: true });
88
- });
89
86
 
90
87
  /**
91
88
  * Waits for a pause of `wait` ms between calls, then runs once with the
@@ -99,18 +96,20 @@ const _sleep = (ms, signal) =>
99
96
  *
100
97
  * @template {(...args: any[]) => any} F
101
98
  * @param {F} fn
102
- * @param {number} wait Pause needed, in ms.
99
+ * @param {number | string} wait Pause needed, in ms or like `"300ms"`.
103
100
  * @param {DebounceOptions} [options]
104
101
  * @returns {DebouncedFunction<F>}
105
102
  */
106
103
  function debounce(fn, wait, options = {}) {
104
+ const delay = toMs(wait, "wait");
107
105
  const leading = options.leading ?? false;
108
106
  const trailing = options.trailing ?? true;
109
- const maxWait = options.maxWait;
107
+ const maxWait = options.maxWait === undefined ? undefined : toMs(options.maxWait, "maxWait");
110
108
  /** @type {NodeJS.Timeout | undefined} */
111
109
  let timer;
112
- /** @type {NodeJS.Timeout | undefined} */
113
- let maxTimer;
110
+ let active = false;
111
+ let lastCall = 0;
112
+ let burstStart = 0;
114
113
  /** @type {any[] | undefined} */
115
114
  let lastArgs;
116
115
  /** @type {unknown} */
@@ -123,43 +122,48 @@ function debounce(fn, wait, options = {}) {
123
122
  lastThis = undefined;
124
123
  if (args) fn.apply(self, args);
125
124
  };
126
- const clearTimers = () => {
127
- clearTimeout(timer);
128
- clearTimeout(maxTimer);
129
- timer = undefined;
130
- maxTimer = undefined;
125
+ // One timer per burst: it checks how long ago the last call was instead of
126
+ // being reset on every call, which keeps busy call sites cheap.
127
+ const check = () => {
128
+ const now = Date.now();
129
+ const quiet = now - lastCall;
130
+ const overdue = maxWait !== undefined && now - burstStart >= maxWait;
131
+ if (quiet >= delay || quiet < 0 || overdue) {
132
+ timer = undefined;
133
+ active = false;
134
+ if (trailing || overdue) invoke();
135
+ else lastArgs = lastThis = undefined;
136
+ return;
137
+ }
138
+ const untilMax = maxWait === undefined ? Infinity : maxWait - (now - burstStart);
139
+ timer = setTimeout(check, Math.min(delay - quiet, untilMax));
131
140
  };
132
141
 
133
142
  /** @type {any} */
134
143
  const debounced = /** @this {any} */ function(/** @type {any[]} */ ...args) {
135
- const isFirst = timer === undefined;
136
144
  lastArgs = args;
137
145
  lastThis = this;
146
+ lastCall = Date.now();
147
+ if (active) return;
148
+ active = true;
149
+ burstStart = lastCall;
150
+ timer = setTimeout(check, maxWait === undefined ? delay : Math.min(delay, maxWait));
151
+ if (leading) invoke();
152
+ };
153
+ const stop = () => {
138
154
  clearTimeout(timer);
139
- timer = setTimeout(() => {
140
- clearTimers();
141
- if (trailing) invoke();
142
- else lastArgs = undefined;
143
- }, wait);
144
- if (maxWait !== undefined && maxTimer === undefined) {
145
- maxTimer = setTimeout(() => {
146
- clearTimers();
147
- invoke();
148
- }, maxWait);
149
- }
150
- if (leading && isFirst) {
151
- invoke();
152
- }
155
+ timer = undefined;
156
+ active = false;
153
157
  };
154
158
  debounced.cancel = () => {
155
- clearTimers();
156
- lastArgs = undefined;
159
+ stop();
160
+ lastArgs = lastThis = undefined;
157
161
  };
158
162
  debounced.flush = () => {
159
- clearTimers();
163
+ stop();
160
164
  invoke();
161
165
  };
162
- debounced.pending = () => timer !== undefined && lastArgs !== undefined;
166
+ debounced.pending = () => active && lastArgs !== undefined;
163
167
  return debounced;
164
168
  }
165
169
 
@@ -172,11 +176,12 @@ function debounce(fn, wait, options = {}) {
172
176
  *
173
177
  * @template {(...args: any[]) => any} F
174
178
  * @param {F} fn
175
- * @param {number} wait Minimum gap between calls, in ms.
179
+ * @param {number | string} interval Minimum gap between calls, in ms or like `"1s"`.
176
180
  * @param {ThrottleOptions} [options]
177
181
  * @returns {ThrottledFunction<F>}
178
182
  */
179
- function throttle(fn, wait, options = {}) {
183
+ function throttle(fn, interval, options = {}) {
184
+ const wait = toMs(interval, "wait");
180
185
  const leading = options.leading ?? true;
181
186
  const trailing = options.trailing ?? true;
182
187
  let last = 0;
@@ -260,27 +265,37 @@ function once(fn) {
260
265
  */
261
266
  function memoize(fn, options = {}) {
262
267
  const opts = typeof options === "function" ? { resolver: options } : options;
268
+ const ttl = opts.ttl === undefined ? 0 : toMs(opts.ttl, "ttl");
269
+ const max = opts.max;
270
+ const resolver = opts.resolver;
263
271
  /** @type {Map<unknown, { value: any, expires: number }>} */
264
272
  const cache = new Map();
265
- /** @type {(args: any[]) => unknown} */
266
- const keyOf = (args) => {
267
- if (opts.resolver) return opts.resolver(...args);
268
- if (args.length === 1 && (args[0] === null || typeof args[0] !== "object")) return args[0];
269
- return JSON.stringify(args);
270
- };
273
+ let sweepAt = 256;
271
274
 
272
275
  /** @type {any} */
273
276
  const memoized = /** @this {any} */ function(/** @type {any[]} */ ...args) {
274
- const key = keyOf(args);
277
+ const key = resolver ? resolver(...args) : argsKey(args);
275
278
  const hit = cache.get(key);
276
- if (hit && (hit.expires === 0 || hit.expires > Date.now())) {
279
+ if (hit !== undefined) {
280
+ if (hit.expires === 0 || hit.expires > Date.now()) {
281
+ if (max !== undefined) {
282
+ cache.delete(key);
283
+ cache.set(key, hit);
284
+ }
285
+ return hit.value;
286
+ }
277
287
  cache.delete(key);
278
- cache.set(key, hit);
279
- return hit.value;
280
288
  }
281
289
  const value = fn.apply(this, args);
282
- cache.set(key, { value, expires: opts.ttl ? Date.now() + opts.ttl : 0 });
283
- if (opts.max !== undefined && cache.size > opts.max) cache.delete(cache.keys().next().value);
290
+ cache.set(key, { value, expires: ttl ? Date.now() + ttl : 0 });
291
+ if (max !== undefined) {
292
+ if (cache.size > max) cache.delete(cache.keys().next().value);
293
+ } else if (ttl && cache.size >= sweepAt) {
294
+ // Without a size limit, expired results would pile up: drop them now and then.
295
+ const now = Date.now();
296
+ for (const [k, entry] of cache) if (entry.expires <= now) cache.delete(k);
297
+ sweepAt = Math.max(256, cache.size * 2);
298
+ }
284
299
  if (!opts.cacheRejections && value && typeof value.then === "function") {
285
300
  value.then(undefined, () => {
286
301
  if (cache.get(key)?.value === value) cache.delete(key);
@@ -290,7 +305,7 @@ function memoize(fn, options = {}) {
290
305
  };
291
306
  memoized.cache = cache;
292
307
  memoized.clear = () => cache.clear();
293
- memoized.delete = (/** @type {any[]} */ ...args) => cache.delete(keyOf(args));
308
+ memoized.delete = (/** @type {any[]} */ ...args) => cache.delete(resolver ? resolver(...args) : argsKey(args));
294
309
  return memoized;
295
310
  }
296
311
 
@@ -315,7 +330,8 @@ function memoize(fn, options = {}) {
315
330
  async function retry(fn, options = {}) {
316
331
  const attempts = Math.max(1, options.attempts ?? 3);
317
332
  const backoff = options.backoff ?? 1;
318
- const maxDelay = options.maxDelay ?? Infinity;
333
+ const maxDelay = options.maxDelay === undefined ? Infinity : toMs(options.maxDelay, "maxDelay");
334
+ const delay = typeof options.delay === "function" || options.delay === undefined ? 0 : toMs(options.delay, "delay");
319
335
  const jitter = Math.min(1, Math.max(0, options.jitter ?? 0));
320
336
  /** @type {unknown} */
321
337
  let lastError;
@@ -326,8 +342,10 @@ async function retry(fn, options = {}) {
326
342
  } catch (error) {
327
343
  lastError = error;
328
344
  if (attempt >= attempts) break;
345
+ // A cancelled operation was stopped on purpose: trying again would undo that.
346
+ if (error instanceof Error && error.name === "AbortError") break;
329
347
  if (options.shouldRetry && !(await options.shouldRetry(error, attempt))) break;
330
- const base = typeof options.delay === "function" ? options.delay(attempt, error) : (options.delay ?? 0) * backoff ** (attempt - 1);
348
+ const base = typeof options.delay === "function" ? options.delay(attempt, error) : delay * backoff ** (attempt - 1);
331
349
  const wait = Math.max(0, Math.round(Math.min(base, maxDelay) * (1 + jitter * (Math.random() * 2 - 1))));
332
350
  options.onRetry?.(error, attempt, wait);
333
351
  if (wait > 0) await _sleep(wait, options.signal);
@@ -337,27 +355,36 @@ async function retry(fn, options = {}) {
337
355
  }
338
356
 
339
357
  /**
340
- * Rejects with a `TimeoutError` if the promise takes longer than `ms`.
358
+ * Rejects with a `TimeoutError` if the promise takes longer than `ms`. Pass
359
+ * a function to get an `AbortSignal` that fires when time is up, so the work
360
+ * actually stops instead of running on in the background.
341
361
  *
342
362
  * @example
343
363
  * const res = await func.timeout(fetch(url), 5000);
344
364
  * const rows = await func.timeout(() => slowQuery(), 2000, "Database too slow");
345
365
  *
346
366
  * @template T
347
- * @param {PromiseLike<T> | (() => PromiseLike<T> | T)} promise A promise, or a function that returns one.
348
- * @param {number} ms
367
+ * @param {PromiseLike<T> | ((signal: AbortSignal) => PromiseLike<T> | T)} promise A promise, or a function that returns one. The function gets a signal that aborts on timeout.
368
+ * @param {number | string} ms The limit, in ms or like `"5s"`. `Infinity` means no limit.
349
369
  * @param {string} [message]
350
370
  * @returns {Promise<Awaited<T>>}
351
371
  * @throws {TimeoutError}
352
372
  */
353
373
  function timeout(promise, ms, message) {
354
- /** @type {NodeJS.Timeout | undefined} */
355
- let timer;
374
+ const limit = toMs(ms, "timeout");
375
+ const controller = typeof promise === "function" ? new AbortController() : undefined;
376
+ const target = Promise.resolve().then(() => (typeof promise === "function" ? promise(/** @type {AbortController} */ (controller).signal) : promise));
377
+ if (limit === Infinity) return /** @type {Promise<Awaited<T>>} */ (target);
378
+ /** @type {() => void} */
379
+ let cancel = () => {};
356
380
  const expired = new Promise((_, reject) => {
357
- timer = setTimeout(() => reject(new TimeoutError(message ?? `Operation timed out after ${ms}ms`, { timeout: ms })), ms);
381
+ cancel = setLongTimeout(() => {
382
+ const error = new TimeoutError(message ?? `Operation timed out after ${limit}ms`, { timeout: limit });
383
+ controller?.abort(error);
384
+ reject(error);
385
+ }, limit);
358
386
  });
359
- const target = Promise.resolve().then(() => (typeof promise === "function" ? promise() : promise));
360
- return /** @type {Promise<Awaited<T>>} */ (Promise.race([target, expired]).finally(() => clearTimeout(timer)));
387
+ return /** @type {Promise<Awaited<T>>} */ (Promise.race([target, expired]).finally(() => cancel()));
361
388
  }
362
389
 
363
390
  /**
@@ -368,12 +395,12 @@ function timeout(promise, ms, message) {
368
395
  *
369
396
  * @template {(...args: any[]) => any} F
370
397
  * @param {F} fn
371
- * @param {number} ms
398
+ * @param {number | string} ms In ms, or like `"2s"`.
372
399
  * @param {Parameters<F>} args
373
400
  * @returns {Promise<Awaited<ReturnType<F>>>}
374
401
  */
375
402
  async function delay(fn, ms, ...args) {
376
- await _sleep(ms);
403
+ await _sleep(toMs(ms, "delay"));
377
404
  return fn(...args);
378
405
  }
379
406
 
@@ -558,6 +585,126 @@ function compose(...fns) {
558
585
  return /** @type {(...f: Array<(...args: any[]) => any>) => (...args: any[]) => any} */ (pipe)(...[...fns].reverse());
559
586
  }
560
587
 
588
+ /**
589
+ * Like `pipe()`, for functions that may be async: each one is awaited
590
+ * before the next runs.
591
+ *
592
+ * @example
593
+ * const publish = func.pipeAsync(loadDraft, renderMarkdown, uploadToCDN);
594
+ * const url = await publish(postId);
595
+ *
596
+ * @param {...((...args: any[]) => any)} fns
597
+ * @returns {(...args: any[]) => Promise<any>}
598
+ */
599
+ function pipeAsync(...fns) {
600
+ return async (...args) => {
601
+ if (!fns.length) return args[0];
602
+ let result = await fns[0](...args);
603
+ for (let i = 1; i < fns.length; i++) result = await fns[i](result);
604
+ return result;
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Turns many single calls into few grouped ones. Calls made close together
610
+ * are collected, `fn` runs once with all their items, and each caller gets
611
+ * its own result. The classic cure for "N+1 queries".
612
+ *
613
+ * @example
614
+ * const loadUser = func.batch(async (ids) => {
615
+ * const users = await db.getAll("users", { id: ids });
616
+ * return ids.map((id) => users.find((user) => user.id === id));
617
+ * });
618
+ * // One query, however many places ask at once:
619
+ * const [ada, bob] = await Promise.all([loadUser(1), loadUser(2)]);
620
+ *
621
+ * @template I
622
+ * @template R
623
+ * @param {(items: I[]) => ReadonlyArray<R | Error> | Map<I, R | Error> | PromiseLike<ReadonlyArray<R | Error> | Map<I, R | Error>>} fn Gets every waiting item and returns one result per item, in the same order, or a `Map` keyed by item. An `Error` as a result rejects that call only.
624
+ * @param {BatchOptions} [options]
625
+ * @returns {BatchedFunction<I, R>}
626
+ */
627
+ function batch(fn, options = {}) {
628
+ const wait = options.wait === undefined ? 0 : toMs(options.wait, "wait");
629
+ const max = Math.max(1, options.max ?? Infinity);
630
+ /** @type {Array<{ item: I, resolve: (value: R) => void, reject: (reason: unknown) => void }>} */
631
+ let waiting = [];
632
+ /** @type {NodeJS.Timeout | undefined} */
633
+ let timer;
634
+ let scheduled = false;
635
+
636
+ const flush = () => {
637
+ scheduled = false;
638
+ clearTimeout(timer);
639
+ const current = waiting;
640
+ waiting = [];
641
+ if (!current.length) return;
642
+ Promise.resolve()
643
+ .then(() => fn(current.map((entry) => entry.item)))
644
+ .then((results) => {
645
+ const byItem = types.isMap(results);
646
+ if (!byItem && (!Array.isArray(results) || results.length !== current.length)) {
647
+ throw new TypeError(`batch(): expected ${current.length} results, one per item, got ${Array.isArray(results) ? results.length : typeof results}`);
648
+ }
649
+ current.forEach((entry, i) => {
650
+ const result = byItem ? /** @type {Map<I, R | Error>} */ (results).get(entry.item) : /** @type {ReadonlyArray<R | Error>} */ (results)[i];
651
+ if (types.isNativeError(result)) entry.reject(result);
652
+ else entry.resolve(/** @type {R} */ (result));
653
+ });
654
+ })
655
+ .catch((error) => {
656
+ for (const entry of current) entry.reject(error);
657
+ });
658
+ };
659
+
660
+ /** @type {any} */
661
+ const batched = (/** @type {I} */ item) =>
662
+ new Promise((resolve, reject) => {
663
+ waiting.push({ item, resolve, reject });
664
+ if (waiting.length >= max) return flush();
665
+ if (scheduled) return;
666
+ scheduled = true;
667
+ if (wait > 0) timer = setTimeout(flush, wait);
668
+ // After the promise jobs of this tick, so calls made from resolved promises join the batch too.
669
+ else void Promise.resolve().then(() => process.nextTick(flush));
670
+ });
671
+ batched.flush = flush;
672
+ return batched;
673
+ }
674
+
675
+ /**
676
+ * Makes concurrent calls with the same arguments share one run. Once it
677
+ * settles, the next call starts a new one: nothing is cached.
678
+ *
679
+ * @example
680
+ * const refreshToken = func.dedupe(async () => (await api.post("auth/refresh")).data);
681
+ * // Ten requests hit a 401 at once: one refresh, ten answers.
682
+ *
683
+ * @template {(...args: any[]) => any} F
684
+ * @param {F} fn
685
+ * @param {(...args: Parameters<F>) => unknown} [keyOf] Builds the key that identifies a call. Defaults to its arguments.
686
+ * @returns {(...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>}
687
+ */
688
+ function dedupe(fn, keyOf) {
689
+ /** @type {Map<unknown, Promise<any>>} */
690
+ const running = new Map();
691
+ return /** @this {any} */ function(...args) {
692
+ const key = keyOf ? keyOf(...args) : argsKey(args);
693
+ const current = running.get(key);
694
+ if (current) return current;
695
+ /** @type {Promise<any>} */
696
+ let started;
697
+ try {
698
+ started = Promise.resolve(fn.apply(this, args));
699
+ } catch (error) {
700
+ started = Promise.reject(error);
701
+ }
702
+ const promise = started.finally(() => running.delete(key));
703
+ running.set(key, promise);
704
+ return promise;
705
+ };
706
+ }
707
+
561
708
  /**
562
709
  * Lets you pass a function's arguments a few at a time.
563
710
  *
@@ -680,7 +827,10 @@ module.exports = {
680
827
  to,
681
828
  attempt,
682
829
  promisify,
830
+ batch,
831
+ dedupe,
683
832
  pipe,
833
+ pipeAsync,
684
834
  compose,
685
835
  curry,
686
836
  partial,