@tim-code/my-util 0.2.7 → 0.3.1
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/package.json +8 -2
- package/src/promise.js +57 -13
- package/src/promise.test.js +123 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tim-code/my-util",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tim Sprowl",
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
"main": "src/index.js",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.js",
|
|
12
|
+
"./array": "./src/array.js",
|
|
13
|
+
"./find": "./src/find.js",
|
|
12
14
|
"./fs": "./src/fs.js",
|
|
13
|
-
"./
|
|
15
|
+
"./math": "./src/math.js",
|
|
16
|
+
"./object": "./src/object.js",
|
|
17
|
+
"./promise": "./src/promise.js",
|
|
18
|
+
"./run": "./src/run.js",
|
|
19
|
+
"./time": "./src/time.js"
|
|
14
20
|
},
|
|
15
21
|
"scripts": {
|
|
16
22
|
"test": "node --no-warnings --experimental-vm-modules node_modules/.bin/jest"
|
package/src/promise.js
CHANGED
|
@@ -1,38 +1,57 @@
|
|
|
1
1
|
import { chunk } from "./array.js"
|
|
2
2
|
|
|
3
|
+
export class PollError extends Error {}
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Calls a function immediately and then every X milliseconds until the function does not return undefined, null or false.
|
|
5
7
|
* Note that other falsy values such as 0 or "" or NaN will resolve and be returned.
|
|
6
|
-
* This will never resolve if callback always returns undefined, null, or false.
|
|
7
|
-
* @param {
|
|
8
|
-
* @param {number} milliseconds
|
|
9
|
-
* @
|
|
8
|
+
* This will never resolve if callback always returns undefined, null, or false.
|
|
9
|
+
* @param {number} ms Milliseconds to wait between invocations
|
|
10
|
+
* @param {boolean|number=} wait If true, waits before initially calling the callback. If a number, waits that many milliseconds.
|
|
11
|
+
* @param {number=} attempts If a number, limits to that many invocations of callback before throwing a PollError.
|
|
12
|
+
* @param {Function} callback The argument is the number of times the callback has been called previously.
|
|
13
|
+
* @returns {any} The result of the callback
|
|
10
14
|
*/
|
|
11
|
-
export function poll(
|
|
15
|
+
export function poll({ ms, wait = false, attempts = undefined }, callback) {
|
|
12
16
|
return new Promise((resolve, reject) => {
|
|
17
|
+
let attemptIndex = 0
|
|
13
18
|
const resolver = async () => {
|
|
19
|
+
if (typeof attempts === "number" && attemptIndex >= attempts) {
|
|
20
|
+
reject(new PollError("max attempts reached"))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
14
23
|
try {
|
|
15
|
-
const result = await callback()
|
|
24
|
+
const result = await callback(attemptIndex)
|
|
25
|
+
attemptIndex++
|
|
16
26
|
if (result !== undefined && result !== null && result !== false) {
|
|
17
27
|
resolve(result)
|
|
18
28
|
} else {
|
|
19
|
-
setTimeout(resolver,
|
|
29
|
+
setTimeout(resolver, ms)
|
|
20
30
|
}
|
|
21
31
|
} catch (error) {
|
|
22
32
|
reject(error)
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
|
-
|
|
35
|
+
if (typeof wait === "number") {
|
|
36
|
+
setTimeout(resolver, wait)
|
|
37
|
+
} else if (wait === true) {
|
|
38
|
+
setTimeout(resolver, ms)
|
|
39
|
+
} else {
|
|
40
|
+
resolver()
|
|
41
|
+
}
|
|
26
42
|
})
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
/**
|
|
30
46
|
* Sleep for X milliseconds.
|
|
31
|
-
* @param {number}
|
|
47
|
+
* @param {number} ms Milliseconds; returns immediately if negative
|
|
32
48
|
*/
|
|
33
|
-
export async function sleep(
|
|
49
|
+
export async function sleep(ms) {
|
|
50
|
+
if (ms < 0) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
34
53
|
await new Promise((resolve) => {
|
|
35
|
-
setTimeout(resolve,
|
|
54
|
+
setTimeout(resolve, ms)
|
|
36
55
|
})
|
|
37
56
|
}
|
|
38
57
|
|
|
@@ -47,12 +66,14 @@ export async function sleep(milliseconds) {
|
|
|
47
66
|
* - "errors": The "reason" property for each "result" object that did not have a status of "fulfilled".
|
|
48
67
|
* @param {Object} $1
|
|
49
68
|
* @param {Array} $1.array
|
|
50
|
-
* @param {number=} $1.limit If not provided, each call is done in parallel.
|
|
69
|
+
* @param {number=} $1.limit The number of calls to do in parallel. If not provided, each call is done in parallel.
|
|
70
|
+
* @param {Function=} $1.limiter A function awaited after a group of parallel calls is processed.
|
|
71
|
+
* It is called with the number of parallel calls processed. Could be as simple as `() => sleep(10000)` if you wanted to wait 10 seconds between.
|
|
51
72
|
* @param {boolean=} $1.flatten Flattens values before returning; useful if promises return arrays
|
|
52
73
|
* @param {Function} callback
|
|
53
74
|
* @returns {Object} {results, values, returned, errors}
|
|
54
75
|
*/
|
|
55
|
-
export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
76
|
+
export async function allSettled({ array, limit, limiter, flatten = false }, callback) {
|
|
56
77
|
const results = []
|
|
57
78
|
let returned = []
|
|
58
79
|
let values = []
|
|
@@ -71,6 +92,7 @@ export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
|
71
92
|
errors.push(reason)
|
|
72
93
|
}
|
|
73
94
|
}
|
|
95
|
+
await limiter?.(elements.length)
|
|
74
96
|
}
|
|
75
97
|
if (flatten) {
|
|
76
98
|
values = values.flat()
|
|
@@ -79,6 +101,28 @@ export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
|
79
101
|
return { values, returned, errors, results }
|
|
80
102
|
}
|
|
81
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Creates a function that can be used with allSettled to limit the number of elements processed in a time interval.
|
|
106
|
+
* Once the limit is reached, waits until the start of a new interval before returning.
|
|
107
|
+
* @param {Object} $1
|
|
108
|
+
* @param {number} $1.limit The maximum number of elements to be processed in the interval
|
|
109
|
+
* @param {Function=} $1.interval The length of the interval in milliseconds. Default is one minute.
|
|
110
|
+
* @returns {Function} Returned function expects to be called with the number of elements added since last call.
|
|
111
|
+
*/
|
|
112
|
+
export function intervalLimiter({ limit, interval = 1000 * 60 }) {
|
|
113
|
+
let count = 0
|
|
114
|
+
let startTimestamp = Date.now()
|
|
115
|
+
return async (added) => {
|
|
116
|
+
count += added
|
|
117
|
+
if (count >= limit) {
|
|
118
|
+
const currentTimestamp = Date.now()
|
|
119
|
+
await sleep(interval - (currentTimestamp - startTimestamp))
|
|
120
|
+
startTimestamp = Date.now()
|
|
121
|
+
count = 0
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* A convenience method to throw the result of allSettled().
|
|
84
128
|
* Useful in testing contexts when simply propagating the error is enough.
|
package/src/promise.test.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
/* eslint-disable prefer-promise-reject-errors */
|
|
3
3
|
import { jest } from "@jest/globals"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { alert, allSettled, poll, PollError, sleep, throwFirstReject, intervalLimiter } from "./promise.js"
|
|
6
6
|
|
|
7
7
|
describe("poll", () => {
|
|
8
8
|
it("resolves immediately if callback returns a non-undefined/null/false value", async () => {
|
|
9
9
|
const cb = jest.fn().mockReturnValue(42)
|
|
10
|
-
const promise = poll(
|
|
10
|
+
const promise = poll({ ms: 1 }, cb)
|
|
11
11
|
await expect(promise).resolves.toBe(42)
|
|
12
12
|
expect(cb).toHaveBeenCalledTimes(1)
|
|
13
|
+
expect(cb).toHaveBeenCalledWith(0)
|
|
13
14
|
})
|
|
14
15
|
|
|
15
16
|
it("resolves after several attempts when callback returns undefined/null/false before a value", async () => {
|
|
@@ -19,23 +20,19 @@ describe("poll", () => {
|
|
|
19
20
|
.mockReturnValueOnce(null)
|
|
20
21
|
.mockReturnValueOnce(false)
|
|
21
22
|
.mockReturnValueOnce(0)
|
|
22
|
-
const promise = poll(
|
|
23
|
-
// Advance timers for 3 unsuccessful attempts (undefined, null, false)
|
|
24
|
-
const before = Date.now()
|
|
25
|
-
// The fourth call returns 0, which should resolve
|
|
23
|
+
const promise = poll({ ms: 2 }, cb)
|
|
26
24
|
await expect(promise).resolves.toBe(0)
|
|
27
|
-
const after = Date.now()
|
|
28
|
-
expect((after - before) / 1000).toBeCloseTo(1.5, 1)
|
|
29
25
|
expect(cb).toHaveBeenCalledTimes(4)
|
|
26
|
+
expect(cb.mock.calls.map((args) => args[0])).toEqual([0, 1, 2, 3])
|
|
30
27
|
})
|
|
31
28
|
|
|
32
29
|
it('resolves if callback returns "" or NaN (should not treat as "keep polling")', async () => {
|
|
33
30
|
const cb = jest.fn().mockReturnValueOnce("").mockReturnValueOnce(NaN)
|
|
34
|
-
const promise1 = poll(
|
|
31
|
+
const promise1 = poll({ ms: 1 }, cb)
|
|
35
32
|
await expect(promise1).resolves.toBe("")
|
|
36
33
|
expect(cb).toHaveBeenCalledTimes(1)
|
|
37
34
|
cb.mockClear()
|
|
38
|
-
const promise2 = poll(
|
|
35
|
+
const promise2 = poll({ ms: 1 }, cb)
|
|
39
36
|
await expect(promise2).resolves.toBe(NaN)
|
|
40
37
|
expect(cb).toHaveBeenCalledTimes(1)
|
|
41
38
|
})
|
|
@@ -45,26 +42,75 @@ describe("poll", () => {
|
|
|
45
42
|
const cb = jest.fn().mockImplementation(() => {
|
|
46
43
|
throw error
|
|
47
44
|
})
|
|
48
|
-
await expect(poll(
|
|
45
|
+
await expect(poll({ ms: 1 }, cb)).rejects.toBe(error)
|
|
49
46
|
expect(cb).toHaveBeenCalledTimes(1)
|
|
50
47
|
})
|
|
51
48
|
|
|
52
49
|
it("rejects if callback returns a rejected promise", async () => {
|
|
53
50
|
const error = new Error("async fail")
|
|
54
51
|
const cb = jest.fn().mockReturnValue(Promise.reject(error))
|
|
55
|
-
const promise = poll(
|
|
52
|
+
const promise = poll({ ms: 1 }, cb)
|
|
56
53
|
await expect(promise).rejects.toBe(error)
|
|
57
54
|
expect(cb).toHaveBeenCalledTimes(1)
|
|
58
55
|
})
|
|
56
|
+
|
|
57
|
+
it("waits before first call if wait=true", async () => {
|
|
58
|
+
const cb = jest.fn().mockReturnValue(1)
|
|
59
|
+
const promise = poll({ ms: 2, wait: true }, cb)
|
|
60
|
+
// Wait a little longer than ms to ensure callback is called
|
|
61
|
+
await sleep(3)
|
|
62
|
+
await expect(promise).resolves.toBe(1)
|
|
63
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("waits specified ms before first call if wait is a number", async () => {
|
|
67
|
+
const cb = jest.fn().mockReturnValue(1)
|
|
68
|
+
const promise = poll({ ms: 2, wait: 5 }, cb)
|
|
69
|
+
await sleep(4)
|
|
70
|
+
expect(cb).not.toHaveBeenCalled()
|
|
71
|
+
await sleep(2)
|
|
72
|
+
await expect(promise).resolves.toBe(1)
|
|
73
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("rejects with PollError if attempts is reached", async () => {
|
|
77
|
+
const cb = jest.fn().mockReturnValue(undefined)
|
|
78
|
+
const promise = poll({ ms: 1, attempts: 3 }, cb)
|
|
79
|
+
await expect(promise).rejects.toBeInstanceOf(PollError)
|
|
80
|
+
await expect(promise).rejects.toThrow("max attempts reached")
|
|
81
|
+
expect(cb).toHaveBeenCalledTimes(3)
|
|
82
|
+
expect(cb.mock.calls.map((args) => args[0])).toEqual([0, 1, 2])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("resolves if callback returns a value before reaching max attempts", async () => {
|
|
86
|
+
const cb = jest
|
|
87
|
+
.fn()
|
|
88
|
+
.mockReturnValueOnce(undefined)
|
|
89
|
+
.mockReturnValueOnce(undefined)
|
|
90
|
+
.mockReturnValueOnce(5)
|
|
91
|
+
const promise = poll({ ms: 1, attempts: 5 }, cb)
|
|
92
|
+
await expect(promise).resolves.toBe(5)
|
|
93
|
+
expect(cb).toHaveBeenCalledTimes(3)
|
|
94
|
+
expect(cb.mock.calls.map((args) => args[0])).toEqual([0, 1, 2])
|
|
95
|
+
})
|
|
59
96
|
})
|
|
60
97
|
|
|
61
98
|
describe("sleep", () => {
|
|
62
99
|
it("resolves after the specified milliseconds", async () => {
|
|
63
100
|
const before = Date.now()
|
|
64
|
-
const promise = sleep(
|
|
101
|
+
const promise = sleep(5)
|
|
65
102
|
await expect(promise).resolves.toBeUndefined()
|
|
66
103
|
const after = Date.now()
|
|
67
|
-
|
|
104
|
+
// Allow for some jitter
|
|
105
|
+
expect(after - before).toBeGreaterThanOrEqual(5)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("resolves immediately if ms is negative", async () => {
|
|
109
|
+
const before = Date.now()
|
|
110
|
+
const promise = sleep(-10)
|
|
111
|
+
await expect(promise).resolves.toBeUndefined()
|
|
112
|
+
const after = Date.now()
|
|
113
|
+
expect(after - before).toBeLessThan(5)
|
|
68
114
|
})
|
|
69
115
|
})
|
|
70
116
|
|
|
@@ -116,6 +162,60 @@ describe("allSettled", () => {
|
|
|
116
162
|
expect(result.errors).toEqual([])
|
|
117
163
|
expect(result.results).toEqual([])
|
|
118
164
|
})
|
|
165
|
+
|
|
166
|
+
it("calls limiter after each chunk if provided", async () => {
|
|
167
|
+
const arr = [1, 2, 3, 4, 5]
|
|
168
|
+
const calls = []
|
|
169
|
+
const limiterCalls = []
|
|
170
|
+
const cb = (x) => {
|
|
171
|
+
calls.push(x)
|
|
172
|
+
return x
|
|
173
|
+
}
|
|
174
|
+
const limiter = jest.fn(async (n) => {
|
|
175
|
+
limiterCalls.push(n)
|
|
176
|
+
// simulate async delay
|
|
177
|
+
await sleep(1)
|
|
178
|
+
})
|
|
179
|
+
const result = await allSettled({ array: arr, limit: 2, limiter }, cb)
|
|
180
|
+
expect(result.values).toEqual([1, 2, 3, 4, 5])
|
|
181
|
+
expect(calls).toEqual([1, 2, 3, 4, 5])
|
|
182
|
+
expect(limiter).toHaveBeenCalledTimes(3)
|
|
183
|
+
expect(limiterCalls).toEqual([2, 2, 1])
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("intervalLimiter", () => {
|
|
188
|
+
it("does not delay until limit is reached", async () => {
|
|
189
|
+
const limiter = intervalLimiter({ limit: 3, interval: 10 })
|
|
190
|
+
const before = Date.now()
|
|
191
|
+
await limiter(1)
|
|
192
|
+
await limiter(1)
|
|
193
|
+
await limiter(1) // should reach limit here, triggers wait
|
|
194
|
+
const after = Date.now()
|
|
195
|
+
expect(after - before).toBeGreaterThanOrEqual(10)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("resets count and interval after waiting", async () => {
|
|
199
|
+
const limiter = intervalLimiter({ limit: 2, interval: 5 })
|
|
200
|
+
const before = Date.now()
|
|
201
|
+
await limiter(1)
|
|
202
|
+
await limiter(1) // triggers wait
|
|
203
|
+
const afterFirst = Date.now()
|
|
204
|
+
await limiter(1)
|
|
205
|
+
await limiter(1) // triggers wait again
|
|
206
|
+
const afterSecond = Date.now()
|
|
207
|
+
expect(afterFirst - before).toBeGreaterThanOrEqual(5)
|
|
208
|
+
expect(afterSecond - afterFirst).toBeGreaterThanOrEqual(5)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("handles added < limit with no delay", async () => {
|
|
212
|
+
const limiter = intervalLimiter({ limit: 10, interval: 5 })
|
|
213
|
+
const before = Date.now()
|
|
214
|
+
await limiter(3)
|
|
215
|
+
await limiter(3)
|
|
216
|
+
const after = Date.now()
|
|
217
|
+
expect(after - before).toBeLessThan(5)
|
|
218
|
+
})
|
|
119
219
|
})
|
|
120
220
|
|
|
121
221
|
describe("alert", () => {
|
|
@@ -172,3 +272,12 @@ describe("throwFirstReject", () => {
|
|
|
172
272
|
expect(result.returned).toEqual([])
|
|
173
273
|
})
|
|
174
274
|
})
|
|
275
|
+
|
|
276
|
+
describe("PollError", () => {
|
|
277
|
+
it("is an Error subclass", () => {
|
|
278
|
+
const err = new PollError("oops")
|
|
279
|
+
expect(err).toBeInstanceOf(Error)
|
|
280
|
+
expect(err).toBeInstanceOf(PollError)
|
|
281
|
+
expect(err.message).toBe("oops")
|
|
282
|
+
})
|
|
283
|
+
})
|