@tim-code/my-util 0.6.5 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tim-code/my-util",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "author": "Tim Sprowl",
package/src/math.js CHANGED
@@ -144,6 +144,24 @@ export function range(start, end, increment = 1) {
144
144
  return results
145
145
  }
146
146
 
147
+ /**
148
+ * Create an array of numbers progressing from start up to and including end.
149
+ * @param {number} start
150
+ * @param {number=} end
151
+ * @param {number=} increment
152
+ * @returns {number[]}
153
+ */
154
+ export function between(start, end, increment = 1) {
155
+ if (!(increment > 0)) {
156
+ return []
157
+ }
158
+ const results = []
159
+ for (let i = start; i <= end; i += increment) {
160
+ results.push(i)
161
+ }
162
+ return results
163
+ }
164
+
147
165
  /**
148
166
  * Check if the argument is a number.
149
167
  * This excludes Infinity and NaN, but otherwise is equivalent to `typeof number === "number"`.
package/src/math.test.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "@jest/globals"
2
+ import { between } from "./math.js"
2
3
 
3
4
  // EXPORTED FUNCTIONS UNDER TEST:
4
5
  // - mod
@@ -314,6 +315,52 @@ describe("range", () => {
314
315
  })
315
316
  })
316
317
 
318
+ describe("between", () => {
319
+ it("returns an empty array if start >= end", () => {
320
+ expect(between(7, 5)).toEqual([])
321
+ expect(between(10, 5)).toEqual([])
322
+
323
+ expect(between(5, 5)).toEqual([5])
324
+ })
325
+
326
+ it("returns a sequence from start to end with default increment 1", () => {
327
+ expect(between(0, 3)).toEqual([0, 1, 2, 3])
328
+ expect(between(2, 6)).toEqual([2, 3, 4, 5, 6])
329
+ })
330
+
331
+ it("returns a sequence with a custom positive increment", () => {
332
+ expect(between(0, 5, 2)).toEqual([0, 2, 4])
333
+ expect(between(1, 8, 3)).toEqual([1, 4, 7])
334
+ })
335
+ it("works with negative start and end", () => {
336
+ expect(between(-3, 1)).toEqual([-3, -2, -1, 0, 1])
337
+ expect(between(-5, -2)).toEqual([-5, -4, -3, -2])
338
+ })
339
+
340
+ it("returns an empty array if increment is zero", () => {
341
+ expect(between(0, 5, 0)).toEqual([])
342
+ })
343
+
344
+ it("returns an empty array if increment is negative and start < end", () => {
345
+ expect(between(0, 5, -1)).toEqual([])
346
+ expect(between(2, 6, -2)).toEqual([])
347
+ })
348
+
349
+ it("returns an empty array if increment is negative and start > end", () => {
350
+ expect(between(5, 0, -1)).toEqual([])
351
+ expect(between(3, -2, -2)).toEqual([])
352
+ })
353
+
354
+ it("returns an empty array if increment is positive and start > end", () => {
355
+ expect(between(5, 0, 1)).toEqual([])
356
+ expect(between(2, -2, 2)).toEqual([])
357
+ })
358
+
359
+ it("handles floating point increments", () => {
360
+ expect(between(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75, 1])
361
+ })
362
+ })
363
+
317
364
  describe("isNumber", () => {
318
365
  it("returns true for finite numbers", () => {
319
366
  expect(isNumber(0)).toBe(true)
package/src/promise.js CHANGED
@@ -2,6 +2,8 @@ import { chunk } from "./array.js"
2
2
 
3
3
  export class PollError extends Error {}
4
4
 
5
+ export class PromiseAllError extends Error {}
6
+
5
7
  /**
6
8
  * Calls a function immediately and then every X milliseconds until the function does not return undefined, null or false.
7
9
  * Note that other falsy values such as 0 or "" or NaN will resolve and be returned.
@@ -70,14 +72,17 @@ export async function sleep(ms) {
70
72
  * @param {number=} $1.limit The number of calls to do in parallel. If not provided, each call is done in parallel.
71
73
  * @param {Function=} $1.limiter A function awaited after a group of parallel calls is processed.
72
74
  * 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.
73
- * @param {boolean=} $1.flatten Flattens "values" and "returned" before returning; useful if promises return arrays
75
+ * @param {boolean=} $1.flatten If true, flattens "values" and "returned" before returning; useful if promises return arrays.
76
+ * `null` and `undefined` return values are passed through.
74
77
  * @param {boolean=} $1.abort If true, will return early if there are errors.
75
78
  * If false (default), will process all elements in the array (like Promise.allSettled()).
79
+ * @param {boolean=} $1.throws If true, will collect error messages, if any, together into one PromiseAllError object and throw it.
80
+ * Sets the PromiseAllError's stack from one of the collected errors, if available.
76
81
  * @param {Function} callback Default is identity function to enable passing promises as "array".
77
82
  * @returns {Object} {results, values, returned, errors}
78
83
  */
79
84
  export async function allSettled(
80
- { array, iterable = array, limit, limiter, flatten = false, abort = false },
85
+ { array, iterable = array, limit, limiter, flatten = false, abort = false, throws = false },
81
86
  callback = (promise) => promise
82
87
  ) {
83
88
  const results = []
@@ -103,13 +108,35 @@ export async function allSettled(
103
108
  }
104
109
  await limiter?.(elements.length)
105
110
  }
111
+ if (throws && errors.length) {
112
+ const string = errors.map((error) => error.message ?? error).join("; ")
113
+ const resultError = new PromiseAllError(string)
114
+ const { trace } = errors.find((error) => Array.isArray(error.trace)) ?? {}
115
+ if (trace) {
116
+ resultError.stack = trace.join("\n")
117
+ }
118
+ throw resultError
119
+ }
106
120
  if (flatten) {
107
- values = values.flat()
108
- returned = returned.flat()
121
+ values = values?.flat()
122
+ returned = returned?.flat()
109
123
  }
110
124
  return { values, returned, errors, results }
111
125
  }
112
126
 
127
+ /**
128
+ * Parallelize awaiting an array of promises using `Promise.allSettled()` but throw if an error, similar to Promise.all().
129
+ * Also like Promise.all(), returns the return values of passed promises as an array.
130
+ * If multiple errors, joins error messages together and tries to keep a stack trace from the first error with a trace.
131
+ * @param {Array<Promise>} promises
132
+ * @param {Object} $1
133
+ * @param {boolean=} $1.flatten If true, flattens values before returning; useful if promises return arrays.
134
+ * @returns {Array}
135
+ */
136
+ export async function allPatiently(promises, { flatten }) {
137
+ return (await allSettled({ iterable: promises, flatten, throws: true })).returned
138
+ }
139
+
113
140
  /**
114
141
  * Creates a function that can be used with allSettled to limit the number of elements processed in a time interval.
115
142
  * Once the limit is reached, waits until the start of a new interval before returning.
@@ -3,11 +3,11 @@ import { jest } from "@jest/globals"
3
3
 
4
4
  // Exported API under test:
5
5
  // - class PollError
6
- // - functions: poll, sleep, allSettled, intervalLimiter, alert, throwFirstReject
7
- // Code changes in this diff affect: allSettled (added "iterable" param and generalized to Iterable)
6
+ // - functions: poll, sleep, allSettled, allPatiently, intervalLimiter, alert, throwFirstReject
8
7
 
9
8
  import {
10
9
  alert,
10
+ allPatiently,
11
11
  allSettled,
12
12
  intervalLimiter,
13
13
  poll,
@@ -69,7 +69,6 @@ describe("poll", () => {
69
69
  it("waits before first call if wait=true", async () => {
70
70
  const cb = jest.fn().mockReturnValue(1)
71
71
  const promise = poll({ ms: 2, wait: true }, cb)
72
- // Wait a little longer than ms to ensure callback is called
73
72
  await sleep(3)
74
73
  await expect(promise).resolves.toBe(1)
75
74
  expect(cb).toHaveBeenCalledTimes(1)
@@ -113,7 +112,6 @@ describe("sleep", () => {
113
112
  const promise = sleep(5)
114
113
  await expect(promise).resolves.toBeUndefined()
115
114
  const after = Date.now()
116
- // Allow for some jitter
117
115
  expect(after - before).toBeGreaterThanOrEqual(5)
118
116
  })
119
117
 
@@ -176,6 +174,20 @@ describe("allSettled", () => {
176
174
  expect(result.returned).toEqual([1, 2, 2, 3])
177
175
  })
178
176
 
177
+ it("passes through null/undefined when flatten=true", async () => {
178
+ const arr = [0, 1, 2, 3]
179
+ const cb = (x) => {
180
+ if (x === 0) return [1, null]
181
+ if (x === 1) return undefined
182
+ if (x === 2) return [3]
183
+ return null
184
+ }
185
+ const result = await allSettled({ array: arr, flatten: true }, cb)
186
+ expect(result.values).toEqual([1, null, undefined, 3, null])
187
+ expect(result.returned).toEqual([1, null, undefined, 3, null])
188
+ expect(result.errors).toEqual([])
189
+ })
190
+
179
191
  it("handles empty array", async () => {
180
192
  const result = await allSettled({ array: [] }, () => 1)
181
193
  expect(result.values).toEqual([])
@@ -194,7 +206,6 @@ describe("allSettled", () => {
194
206
  }
195
207
  const limiter = jest.fn(async (n) => {
196
208
  limiterCalls.push(n)
197
- // simulate async delay
198
209
  await sleep(1)
199
210
  })
200
211
  const result = await allSettled({ array: arr, limit: 2, limiter }, cb)
@@ -205,19 +216,14 @@ describe("allSettled", () => {
205
216
  })
206
217
 
207
218
  it("returns early if abort=true and any error occurs", async () => {
208
- // Should process only up to the first chunk with a rejection, then stop
209
219
  const arr = [1, 2, 3, 4, 5, 6]
210
220
  const cb = jest
211
221
  .fn()
212
222
  .mockImplementation((x) => (x === 2 || x === 4 ? Promise.reject(`fail${x}`) : x))
213
- // limit=2 so chunks: [1,2], [3,4], [5,6]
214
223
  const result = await allSettled({ array: arr, limit: 2, abort: true }, cb)
215
- // The first chunk: [1,2] => 1 fulfilled, 1 rejected
216
- // Should stop after first chunk with error
217
224
  expect(result.values.length).toBe(2)
218
225
  expect(result.errors).toEqual(["fail2"])
219
226
  expect(cb).toHaveBeenCalledTimes(2)
220
- // Should not process [3,4] or [5,6]
221
227
  })
222
228
 
223
229
  it("accepts non-Array iterable via iterable option (Map)", async () => {
@@ -233,6 +239,41 @@ describe("allSettled", () => {
233
239
  expect(result.errors).toEqual([])
234
240
  expect(result.results.every((r) => r.status === "fulfilled")).toBe(true)
235
241
  })
242
+
243
+ it("throws joined error message when throws=true and adopts trace stack if provided", async () => {
244
+ const eWithTrace = new Error("e1")
245
+ eWithTrace.trace = ["trace line 1", "trace line 2"]
246
+ const e2 = new Error("third")
247
+ const arr = [1, 2, 3]
248
+ const cb = (x) => {
249
+ if (x === 1) return Promise.reject(eWithTrace)
250
+ if (x === 2) return x // fulfilled
251
+ return Promise.reject(e2)
252
+ }
253
+ let thrown
254
+ try {
255
+ await allSettled({ array: arr, throws: true }, cb)
256
+ } catch (e) {
257
+ thrown = e
258
+ }
259
+ expect(thrown).toBeInstanceOf(Error)
260
+ expect(thrown.message).toBe("e1; third")
261
+ expect(thrown.stack).toBe("trace line 1\ntrace line 2")
262
+ })
263
+ })
264
+
265
+ describe("allPatiently", () => {
266
+ it("returns flattened values when all promises resolve", async () => {
267
+ const promises = [Promise.resolve([1]), Promise.resolve([2, 3])]
268
+ const result = await allPatiently(promises, { flatten: true })
269
+ expect(result).toEqual([1, 2, 3])
270
+ })
271
+
272
+ it("throws with joined messages when any promise rejects", async () => {
273
+ const bad = new Error("bad")
274
+ const promises = [Promise.resolve(1), Promise.reject(bad), Promise.reject("oops")]
275
+ await expect(allPatiently(promises, { flatten: false })).rejects.toThrow("bad; oops")
276
+ })
236
277
  })
237
278
 
238
279
  describe("intervalLimiter", () => {