@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.
- package/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- 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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
73
|
-
* @
|
|
74
|
-
* @
|
|
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
|
-
|
|
113
|
-
let
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 =
|
|
140
|
-
|
|
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
|
-
|
|
156
|
-
lastArgs = undefined;
|
|
159
|
+
stop();
|
|
160
|
+
lastArgs = lastThis = undefined;
|
|
157
161
|
};
|
|
158
162
|
debounced.flush = () => {
|
|
159
|
-
|
|
163
|
+
stop();
|
|
160
164
|
invoke();
|
|
161
165
|
};
|
|
162
|
-
debounced.pending = () =>
|
|
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}
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
277
|
+
const key = resolver ? resolver(...args) : argsKey(args);
|
|
275
278
|
const hit = cache.get(key);
|
|
276
|
-
if (hit
|
|
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:
|
|
283
|
-
if (
|
|
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(
|
|
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
|
|
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) :
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|