@tim-code/my-util 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tim Sprowl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # my-util
2
+
3
+ This library includes common util functions. It does not have any dependencies.
4
+
5
+ This may include rewritten functionality from Lodash in the future (i.e. orderBy). groupBy should be available when using Node 22.
6
+
7
+ ## First Time Setup
8
+
9
+ `npm install`
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tim-code/my-util",
3
+ "version": "0.0.1",
4
+ "description": "",
5
+ "type": "module",
6
+ "author": "",
7
+ "license": "MIT",
8
+ "main": "src/index.js",
9
+ "scripts": {
10
+ "test": "node --no-warnings --experimental-vm-modules node_modules/.bin/jest"
11
+ },
12
+ "eslintConfig": {
13
+ "root": true,
14
+ "extends": [
15
+ "@tim-code"
16
+ ]
17
+ },
18
+ "devDependencies": {
19
+ "@tim-code/eslint-config": "^1.1.7",
20
+ "@jest/globals": "^29.7.0",
21
+ "@types/node": ">=20 <21",
22
+ "jest": "^29.7.0"
23
+ },
24
+ "jest": {
25
+ "transform": {}
26
+ }
27
+ }
package/src/array.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Converts an array into an array of arrays, where each subarray has a maximum size of chunkSize.
3
+ * Note the last subarray may have a length less than chunkSize.
4
+ * @param {Array} array
5
+ * @param {number=} chunkSize If not provided, defaults to the length of array, returning the input array as one chunk.
6
+ * @returns {Array}
7
+ */
8
+ export function chunk(array, chunkSize = array.length) {
9
+ if (!array.length) {
10
+ return []
11
+ }
12
+ if (chunkSize <= 0 || chunkSize % 1 !== 0) {
13
+ throw new Error("chunkSize must be a positive integer")
14
+ }
15
+ const chunked = []
16
+ for (let i = 0; i < array.length; i += chunkSize) {
17
+ chunked.push(array.slice(i, i + chunkSize))
18
+ }
19
+ return chunked
20
+ }
21
+
22
+ /**
23
+ * Shorthand for returning all unique elements in an array.
24
+ * @param {Array} array
25
+ * @returns {Array}
26
+ */
27
+ export function unique(array) {
28
+ return [...new Set(array)]
29
+ }
@@ -0,0 +1,71 @@
1
+ // src/array.test.js
2
+ import { describe, expect, it } from "@jest/globals"
3
+
4
+ const { chunk, unique } = await import("./array.js")
5
+
6
+ describe("chunk", () => {
7
+ it("splits array into chunks of specified size", () => {
8
+ expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]])
9
+ expect(chunk([1, 2, 3, 4, 5, 6], 3)).toEqual([
10
+ [1, 2, 3],
11
+ [4, 5, 6],
12
+ ])
13
+ })
14
+
15
+ it("returns empty array when input is empty", () => {
16
+ expect(chunk([], 3)).toEqual([])
17
+ })
18
+
19
+ it("returns the entire array as one chunk if chunkSize >= array.length", () => {
20
+ expect(chunk([1, 2], 5)).toEqual([[1, 2]])
21
+ expect(chunk([1, 2], 2)).toEqual([[1, 2]])
22
+ })
23
+
24
+ it("returns the entire array as one chunk if chunkSize is omitted", () => {
25
+ expect(chunk([1, 2, 3])).toEqual([[1, 2, 3]])
26
+ })
27
+
28
+ it("throws if chunkSize is not a positive integer", () => {
29
+ expect(() => chunk([1, 2, 3], 0)).toThrow("chunkSize must be a positive integer")
30
+ expect(() => chunk([1, 2, 3], -1)).toThrow("chunkSize must be a positive integer")
31
+ expect(() => chunk([1, 2, 3], 1.5)).toThrow("chunkSize must be a positive integer")
32
+ })
33
+
34
+ it("handles chunkSize of 1 (each element in its own chunk)", () => {
35
+ expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]])
36
+ })
37
+
38
+ it("returns empty array when input is empty and chunkSize is omitted", () => {
39
+ expect(chunk([])).toEqual([])
40
+ })
41
+
42
+ it("returns one chunk if chunkSize is much larger than array length", () => {
43
+ expect(chunk([1, 2, 3], 100)).toEqual([[1, 2, 3]])
44
+ })
45
+ })
46
+
47
+ describe("unique", () => {
48
+ it("returns only unique elements, preserving order", () => {
49
+ expect(unique([1, 2, 2, 3, 1, 4])).toEqual([1, 2, 3, 4])
50
+ expect(unique(["a", "b", "a", "c"])).toEqual(["a", "b", "c"])
51
+ })
52
+
53
+ it("returns empty array when input is empty", () => {
54
+ expect(unique([])).toEqual([])
55
+ })
56
+
57
+ it("returns the same array if all elements are unique", () => {
58
+ expect(unique([1, 2, 3])).toEqual([1, 2, 3])
59
+ })
60
+
61
+ it("handles arrays with different types", () => {
62
+ expect(unique([1, "1", 1, "1"])).toEqual([1, "1"])
63
+ expect(unique([true, false, true])).toEqual([true, false])
64
+ })
65
+
66
+ it("handles arrays with objects (reference equality)", () => {
67
+ const a = {}
68
+ const b = {}
69
+ expect(unique([a, b, a])).toEqual([a, b])
70
+ })
71
+ })
package/src/fs.js ADDED
@@ -0,0 +1,67 @@
1
+ import { readFile, stat } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { promisify } from "node:util"
4
+ import { gunzip as _gunzip } from "node:zlib"
5
+
6
+ const gunzip = promisify(_gunzip)
7
+
8
+ // the integration tests for this file are the s3-fs integration tests in lambda-integrations
9
+ // changes to this file should be tested against lambda-integrations' integration tests
10
+
11
+ /**
12
+ * Get JSON from a path.
13
+ * @param {string} path
14
+ * @returns {Object|Array}
15
+ */
16
+ export async function getJSON(path) {
17
+ const buffer = await readFile(path)
18
+ return JSON.parse(buffer.toString())
19
+ }
20
+
21
+ /**
22
+ * Get gzipped JSON from a path.
23
+ * @param {string} path
24
+ * @returns {Object|Array}
25
+ */
26
+ export async function getCompressedJSON(path) {
27
+ const buffer = await readFile(path)
28
+ const uncompressed = await gunzip(buffer)
29
+ return JSON.parse(uncompressed.toString())
30
+ }
31
+
32
+ /**
33
+ * Checks if the path exists using stat(). Returns stat() result if so.
34
+ * @param {string} path
35
+ * @param {Object} $1
36
+ * @param {number=} $1.maxAge Max age to consider in milliseconds.
37
+ * @param {boolean=} $1.throws Whether the function should throw or not if not found
38
+ * @returns {Object|false}
39
+ */
40
+ export async function pathExists(path, { maxAge = undefined, throws = false } = {}) {
41
+ try {
42
+ const stats = await stat(path)
43
+ if (typeof maxAge === "number") {
44
+ const age = Date.now() - stats.mtime.getTime()
45
+ if (age <= maxAge) {
46
+ return stats
47
+ }
48
+ return false
49
+ }
50
+ return stats
51
+ } catch (error) {
52
+ // if caller wants all errors or the error isn't related to the path not being found
53
+ if (throws || error.code !== "ENOENT") {
54
+ throw error
55
+ }
56
+ }
57
+ return false
58
+ }
59
+
60
+ /**
61
+ * Make a path to a temporary directory.
62
+ * @returns {string}
63
+ */
64
+ export function makeTempDirectory() {
65
+ const path = tmpdir()
66
+ return path
67
+ }
package/src/fs.test.js ADDED
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals"
2
+
3
+ const readFileMock = jest.fn()
4
+ const statMock = jest.fn()
5
+ const tmpdirMock = jest.fn()
6
+ const gunzipMock = jest.fn()
7
+ jest.unstable_mockModule("node:fs/promises", () => ({
8
+ readFile: readFileMock,
9
+ stat: statMock,
10
+ }))
11
+ jest.unstable_mockModule("node:os", () => ({
12
+ tmpdir: tmpdirMock,
13
+ }))
14
+ jest.unstable_mockModule("node:zlib", () => ({
15
+ gunzip: gunzipMock,
16
+ }))
17
+ jest.unstable_mockModule("node:util", () => ({
18
+ promisify: (mock) => mock,
19
+ }))
20
+
21
+ // Now import the module under test
22
+ const mod = await import("./fs.js")
23
+ const { getJSON, getCompressedJSON, pathExists, makeTempDirectory } = mod
24
+
25
+ describe("getJSON", () => {
26
+ beforeEach(() => jest.clearAllMocks())
27
+ it("parses JSON from file", async () => {
28
+ readFileMock.mockResolvedValue(Buffer.from('{"a":1}'))
29
+ const result = await getJSON("foo.json")
30
+ expect(result).toEqual({ a: 1 })
31
+ expect(readFileMock).toHaveBeenCalledWith("foo.json")
32
+ })
33
+ })
34
+
35
+ describe("getCompressedJSON", () => {
36
+ beforeEach(() => jest.clearAllMocks())
37
+ it("reads, decompresses, and parses JSON", async () => {
38
+ readFileMock.mockResolvedValue(Buffer.from("gzipped"))
39
+ gunzipMock.mockResolvedValue(Buffer.from('{"b":2}'))
40
+ const result = await getCompressedJSON("bar.gz")
41
+ expect(result).toEqual({ b: 2 })
42
+ expect(readFileMock).toHaveBeenCalledWith("bar.gz")
43
+ expect(gunzipMock).toHaveBeenCalled()
44
+ })
45
+ })
46
+
47
+ describe("pathExists", () => {
48
+ beforeEach(() => {
49
+ jest.clearAllMocks()
50
+ })
51
+ it("returns stats if file exists", async () => {
52
+ const stats = { mtime: new Date(Date.now() - 1000) }
53
+ statMock.mockResolvedValue(stats)
54
+ const result = await pathExists("file.txt")
55
+ expect(result).toBe(stats)
56
+ expect(statMock).toHaveBeenCalledWith("file.txt")
57
+ })
58
+ it("returns false if file is too old for maxAge", async () => {
59
+ const stats = { mtime: new Date(Date.now() - 2000) }
60
+ statMock.mockResolvedValue(stats)
61
+ const result = await pathExists("file.txt", { maxAge: 1000 })
62
+ expect(result).toBe(false)
63
+ })
64
+ it("returns stats if file is within maxAge", async () => {
65
+ const stats = { mtime: new Date(Date.now() - 500) }
66
+ statMock.mockResolvedValue(stats)
67
+ const result = await pathExists("file.txt", { maxAge: 1000 })
68
+ expect(result).toBe(stats)
69
+ })
70
+ it("returns false if file does not exist and throws=false", async () => {
71
+ const err = Object.assign(new Error("not found"), { code: "ENOENT" })
72
+ statMock.mockRejectedValue(err)
73
+ const result = await pathExists("nope.txt", { throws: false })
74
+ expect(result).toBe(false)
75
+ })
76
+ it("throws if file does not exist and throws=true", async () => {
77
+ const err = Object.assign(new Error("not found"), { code: "ENOENT" })
78
+ statMock.mockRejectedValue(err)
79
+ await expect(pathExists("nope.txt", { throws: true })).rejects.toThrow("not found")
80
+ })
81
+ it("throws if stat throws for other reasons", async () => {
82
+ const err = Object.assign(new Error("bad"), { code: "EACCES" })
83
+ statMock.mockRejectedValue(err)
84
+ await expect(pathExists("bad.txt")).rejects.toThrow("bad")
85
+ })
86
+ it("returns stats if maxAge is undefined", async () => {
87
+ const stats = { mtime: new Date(Date.now() - 999999) }
88
+ statMock.mockResolvedValue(stats)
89
+ const result = await pathExists("file.txt", {})
90
+ expect(result).toBe(stats)
91
+ })
92
+ })
93
+
94
+ describe("makeTempDirectory", () => {
95
+ it("returns the temp directory", () => {
96
+ tmpdirMock.mockReturnValue("/tmp")
97
+ expect(makeTempDirectory()).toBe("/tmp")
98
+ expect(tmpdirMock).toHaveBeenCalled()
99
+ })
100
+ })
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./array.js"
2
+ export * from "./fs.js"
3
+ export * from "./promise.js"
4
+ export * from "./run.js"
5
+ export * from "./time.js"
package/src/promise.js ADDED
@@ -0,0 +1,120 @@
1
+ import { chunk } from "./array.js"
2
+
3
+ /**
4
+ * Calls a function immediately and then every X milliseconds until the function does not return undefined, null or false.
5
+ * 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. I may add a "max attempt" or "timeout" option at some point.
7
+ * @param {Function} callback
8
+ * @param {number} milliseconds
9
+ * @returns The result of the callback
10
+ */
11
+ export function poll(callback, milliseconds) {
12
+ return new Promise((resolve, reject) => {
13
+ const resolver = async () => {
14
+ try {
15
+ const result = await callback()
16
+ if (result !== undefined && result !== null && result !== false) {
17
+ resolve(result)
18
+ } else {
19
+ setTimeout(resolver, milliseconds)
20
+ }
21
+ } catch (error) {
22
+ reject(error)
23
+ }
24
+ }
25
+ resolver()
26
+ })
27
+ }
28
+
29
+ /**
30
+ * Sleep for X milliseconds.
31
+ * @param {number} milliseconds
32
+ */
33
+ export async function sleep(milliseconds) {
34
+ await new Promise((resolve) => {
35
+ setTimeout(resolve, milliseconds)
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Parallelize executions of a function using `Promise.allSettled()`.
41
+ * This is useful because usually you want to set a limit to the number of parallel requests possible at once.
42
+ * The output of this function contains the outcome of each call of the callback in a variety of formats.
43
+ * - "results": Essentially the return value from Promise.allSettled().
44
+ * - "values": The "value" property of each "result" object returned from allSettled(); this will be undefined if the associated promise rejects.
45
+ * - "returned": The "value" property for each "result" object where the "status" property has a value of fulfilled.
46
+ * This is arguably more useful than "values" unless you need to be able to index into the array based on the index of the promise.
47
+ * - "errors": The "reason" property for each "result" object that did not have a status of "fulfilled".
48
+ * @param {Object} $1
49
+ * @param {Array} $1.array
50
+ * @param {number=} $1.limit If not provided, each call is done in parallel.
51
+ * @param {boolean=} $1.flatten Flattens values before returning; useful if promises return arrays
52
+ * @param {Function} callback
53
+ * @returns {Object} {results, values, returned, errors}
54
+ */
55
+ export async function allSettled({ array, limit, flatten = false }, callback) {
56
+ const results = []
57
+ let returned = []
58
+ let values = []
59
+ const errors = []
60
+ const chunked = chunk(array, limit)
61
+ for (const elements of chunked) {
62
+ const promises = elements.map(callback)
63
+ const _results = await Promise.allSettled(promises)
64
+ for (const result of _results) {
65
+ const { value, status, reason } = result
66
+ results.push(result)
67
+ values.push(value)
68
+ if (status === "fulfilled") {
69
+ returned.push(value)
70
+ } else {
71
+ errors.push(reason)
72
+ }
73
+ }
74
+ }
75
+ if (flatten) {
76
+ values = values.flat()
77
+ returned = returned.flat()
78
+ }
79
+ return { values, returned, errors, results }
80
+ }
81
+
82
+ /**
83
+ * A convenience method to throw the result of allSettled().
84
+ * Useful in testing contexts when simply propagating the error is enough.
85
+ * @param {Object} result An object with an "errors" property i.e. awaited return value of allSettled()
86
+ * @returns {Object} result
87
+ */
88
+ export function alert(result) {
89
+ const { errors } = result ?? {}
90
+ if (errors && errors.length) {
91
+ throw new Error(JSON.stringify(errors, undefined, 2))
92
+ }
93
+ return result
94
+ }
95
+
96
+ /**
97
+ * Parallelize executions of a function using `Promise.all()`.
98
+ * This is useful because usually you want to set a limit to the number of parallel requests possible at once.
99
+ * To maintain parity with allSettled(), this function provides both "values" and "returned" as output but they are the same array.
100
+ * Since this function throws on the first error (same behavior as Promise.all()), there isn't a situation where this function returns but an individual call errored.
101
+ * @param {Object} $1
102
+ * @param {Array} $1.array
103
+ * @param {number=} $1.limit If not provided, each call is done in parallel.
104
+ * @param {boolean=} $1.flatten Flattens values before returning; useful if promises return arrays
105
+ * @param {Function} callback
106
+ * @returns {Object} {values, returned}
107
+ */
108
+ export async function throwFirstReject({ array, limit, flatten = false }, callback) {
109
+ let values = []
110
+ const chunked = chunk(array, limit)
111
+ for (const elements of chunked) {
112
+ const promises = elements.map(callback)
113
+ const _values = await Promise.all(promises)
114
+ values = values.concat(_values)
115
+ }
116
+ if (flatten) {
117
+ values = values.flat()
118
+ }
119
+ return { values, returned: values }
120
+ }
@@ -0,0 +1,174 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ /* eslint-disable prefer-promise-reject-errors */
3
+ import { jest } from "@jest/globals"
4
+
5
+ const { poll, sleep, allSettled, alert, throwFirstReject } = await import("./promise.js")
6
+
7
+ describe("poll", () => {
8
+ it("resolves immediately if callback returns a non-undefined/null/false value", async () => {
9
+ const cb = jest.fn().mockReturnValue(42)
10
+ const promise = poll(cb, 1000)
11
+ await expect(promise).resolves.toBe(42)
12
+ expect(cb).toHaveBeenCalledTimes(1)
13
+ })
14
+
15
+ it("resolves after several attempts when callback returns undefined/null/false before a value", async () => {
16
+ const cb = jest
17
+ .fn()
18
+ .mockReturnValueOnce(undefined)
19
+ .mockReturnValueOnce(null)
20
+ .mockReturnValueOnce(false)
21
+ .mockReturnValueOnce(0)
22
+ const promise = poll(cb, 500)
23
+ // Advance timers for 3 unsuccessful attempts (undefined, null, false)
24
+ const before = Date.now()
25
+ // The fourth call returns 0, which should resolve
26
+ await expect(promise).resolves.toBe(0)
27
+ const after = Date.now()
28
+ expect((after - before) / 1000).toBeCloseTo(1.5, 1)
29
+ expect(cb).toHaveBeenCalledTimes(4)
30
+ })
31
+
32
+ it('resolves if callback returns "" or NaN (should not treat as "keep polling")', async () => {
33
+ const cb = jest.fn().mockReturnValueOnce("").mockReturnValueOnce(NaN)
34
+ const promise1 = poll(cb, 100)
35
+ await expect(promise1).resolves.toBe("")
36
+ expect(cb).toHaveBeenCalledTimes(1)
37
+ cb.mockClear()
38
+ const promise2 = poll(cb, 100)
39
+ await expect(promise2).resolves.toBe(NaN)
40
+ expect(cb).toHaveBeenCalledTimes(1)
41
+ })
42
+
43
+ it("rejects if callback throws", async () => {
44
+ const error = new Error("fail")
45
+ const cb = jest.fn().mockImplementation(() => {
46
+ throw error
47
+ })
48
+ await expect(poll(cb, 100)).rejects.toBe(error)
49
+ expect(cb).toHaveBeenCalledTimes(1)
50
+ })
51
+
52
+ it("rejects if callback returns a rejected promise", async () => {
53
+ const error = new Error("async fail")
54
+ const cb = jest.fn().mockReturnValue(Promise.reject(error))
55
+ const promise = poll(cb, 100)
56
+ await expect(promise).rejects.toBe(error)
57
+ expect(cb).toHaveBeenCalledTimes(1)
58
+ })
59
+ })
60
+
61
+ describe("sleep", () => {
62
+ it("resolves after the specified milliseconds", async () => {
63
+ const before = Date.now()
64
+ const promise = sleep(500)
65
+ await expect(promise).resolves.toBeUndefined()
66
+ const after = Date.now()
67
+ expect((after - before) / 1000).toBeCloseTo(0.5, 1)
68
+ })
69
+ })
70
+
71
+ describe("allSettled", () => {
72
+ it("returns correct structure for all fulfilled", async () => {
73
+ const arr = [1, 2, 3]
74
+ const cb = (x) => x * 2
75
+ const result = await allSettled({ array: arr }, cb)
76
+ expect(result.values).toEqual([2, 4, 6])
77
+ expect(result.returned).toEqual([2, 4, 6])
78
+ expect(result.errors).toEqual([])
79
+ expect(result.results.every((r) => r.status === "fulfilled")).toBe(true)
80
+ })
81
+
82
+ it("handles rejected promises and collects errors", async () => {
83
+ const arr = [1, 2, 3]
84
+ const cb = (x) => (x === 2 ? Promise.reject("fail") : x + 1)
85
+ const result = await allSettled({ array: arr }, cb)
86
+ expect(result.values.length).toBe(3)
87
+ expect(result.returned).toEqual([2, 4])
88
+ expect(result.errors).toEqual(["fail"])
89
+ expect(result.results[1].status).toBe("rejected")
90
+ })
91
+
92
+ it("respects limit and processes in chunks", async () => {
93
+ const arr = [1, 2, 3, 4]
94
+ const calls = []
95
+ const cb = (x) => {
96
+ calls.push(x)
97
+ return x
98
+ }
99
+ const result = await allSettled({ array: arr, limit: 2 }, cb)
100
+ expect(result.values).toEqual([1, 2, 3, 4])
101
+ expect(calls).toEqual([1, 2, 3, 4])
102
+ })
103
+
104
+ it("flattens values and returned if flatten=true", async () => {
105
+ const arr = [1, 2]
106
+ const cb = (x) => [x, x + 1]
107
+ const result = await allSettled({ array: arr, flatten: true }, cb)
108
+ expect(result.values).toEqual([1, 2, 2, 3])
109
+ expect(result.returned).toEqual([1, 2, 2, 3])
110
+ })
111
+
112
+ it("handles empty array", async () => {
113
+ const result = await allSettled({ array: [] }, () => 1)
114
+ expect(result.values).toEqual([])
115
+ expect(result.returned).toEqual([])
116
+ expect(result.errors).toEqual([])
117
+ expect(result.results).toEqual([])
118
+ })
119
+ })
120
+
121
+ describe("alert", () => {
122
+ it("returns result if errors is empty or missing", () => {
123
+ expect(alert({ errors: [] })).toEqual({ errors: [] })
124
+ expect(alert({})).toEqual({})
125
+ expect(alert(undefined)).toBeUndefined()
126
+ })
127
+
128
+ it("throws if errors is non-empty", () => {
129
+ const errors = ["fail", "bad"]
130
+ expect(() => alert({ errors })).toThrow(JSON.stringify(errors, undefined, 2))
131
+ })
132
+ })
133
+
134
+ describe("throwFirstReject", () => {
135
+ it("returns values and returned as same array for all fulfilled", async () => {
136
+ const arr = [1, 2, 3]
137
+ const cb = (x) => x * 3
138
+ const result = await throwFirstReject({ array: arr }, cb)
139
+ expect(result.values).toEqual([3, 6, 9])
140
+ expect(result.returned).toEqual([3, 6, 9])
141
+ })
142
+
143
+ it("throws on first rejection", async () => {
144
+ const arr = [1, 2, 3]
145
+ const cb = (x) => (x === 2 ? Promise.reject("fail") : x)
146
+ await expect(throwFirstReject({ array: arr }, cb)).rejects.toBe("fail")
147
+ })
148
+
149
+ it("respects limit and processes in chunks", async () => {
150
+ const arr = [1, 2, 3, 4]
151
+ const calls = []
152
+ const cb = (x) => {
153
+ calls.push(x)
154
+ return x
155
+ }
156
+ const result = await throwFirstReject({ array: arr, limit: 2 }, cb)
157
+ expect(result.values).toEqual([1, 2, 3, 4])
158
+ expect(calls).toEqual([1, 2, 3, 4])
159
+ })
160
+
161
+ it("flattens values if flatten=true", async () => {
162
+ const arr = [1, 2]
163
+ const cb = (x) => [x, x + 1]
164
+ const result = await throwFirstReject({ array: arr, flatten: true }, cb)
165
+ expect(result.values).toEqual([1, 2, 2, 3])
166
+ expect(result.returned).toEqual([1, 2, 2, 3])
167
+ })
168
+
169
+ it("handles empty array", async () => {
170
+ const result = await throwFirstReject({ array: [] }, (x) => x)
171
+ expect(result.values).toEqual([])
172
+ expect(result.returned).toEqual([])
173
+ })
174
+ })
package/src/run.js ADDED
@@ -0,0 +1,24 @@
1
+ import { pathToFileURL } from "url"
2
+
3
+ /**
4
+ * Basically allows the file where this is called from to be run as a script.
5
+ * @param {string} importMetaUrl Intended to be literally `import.meta.url`
6
+ * @param {array} args Names for command line args that will be used to name keys in the object passed to callback
7
+ * @param {Function} callback A (wrapper) function to execute the file.
8
+ * @returns The result of the callback
9
+ */
10
+ export function runnable(importMetaUrl, args, callback) {
11
+ if (importMetaUrl === pathToFileURL(process.argv[1]).href) {
12
+ // module was not imported but called directly
13
+ const parameter = {}
14
+ for (let i = 0; i < args.length; i++) {
15
+ parameter[args[i]] = process.argv[i + 2]
16
+ }
17
+ console.log("starting", parameter)
18
+ return callback(parameter).then((result) => {
19
+ const formatted = JSON.stringify(JSON.parse(result.body), undefined, 2)
20
+ console.log("complete", parameter, formatted)
21
+ })
22
+ }
23
+ return undefined
24
+ }
package/src/time.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Gets various ways of representing the current time in EDT. Floors to nearest second by default.
3
+ * @param {Object} $1
4
+ * @param {number} $1.days An offset in days to the current time
5
+ * @param {boolean} $1.floorMinute If true, floors to the nearest minute. If false, floors to the nearest second.
6
+ * @returns {Object} { timestamp, date, time, minute, datetime }
7
+ */
8
+ export function getEasternTime({ days = 0, floorMinute = false } = {}) {
9
+ const now = new Date()
10
+ if (days) {
11
+ now.setDate(now.getDate() + days)
12
+ }
13
+ const timestamp = floorMinute
14
+ ? Math.floor(now.getTime() / 1000 / 60) * 60
15
+ : Math.floor(now.getTime() / 1000)
16
+ const string = new Date(timestamp * 1000).toLocaleString("en-US", {
17
+ timeZone: "America/New_York",
18
+ hour12: false,
19
+ year: "numeric",
20
+ month: "2-digit",
21
+ day: "2-digit",
22
+ hour: "2-digit",
23
+ minute: "2-digit",
24
+ second: "2-digit",
25
+ })
26
+ const [americanDate, time] = string.split(", ")
27
+ const [month, day, year] = americanDate.split("/")
28
+ const date = [year, month, day].join("-")
29
+ const minute = parseInt(time.split(":")[1], 10)
30
+ const datetime = `${date} ${time}`
31
+ return { timestamp, date, time, minute, datetime }
32
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "@jest/globals"
2
+ import { getEasternTime } from "./time.js"
3
+
4
+ describe("getEasternTime", () => {
5
+ test("returns correct structure and types", () => {
6
+ const result = getEasternTime()
7
+ expect(typeof result.timestamp).toBe("number")
8
+ expect(typeof result.date).toBe("string")
9
+ expect(typeof result.time).toBe("string")
10
+ expect(typeof result.minute).toBe("number")
11
+ expect(typeof result.datetime).toBe("string")
12
+ })
13
+
14
+ test("floors to minute if floorMinute is true", () => {
15
+ const r1 = getEasternTime()
16
+ const r2 = getEasternTime({ floorMinute: true })
17
+ expect(r2.timestamp % 60).toBe(0)
18
+ expect(r2.timestamp).toBeLessThanOrEqual(r1.timestamp)
19
+ })
20
+
21
+ test("adds days offset", () => {
22
+ const today = getEasternTime()
23
+ const tomorrow = getEasternTime({ days: 1 })
24
+ const [y, m, d] = today.date.split("-").map(Number)
25
+ const [y2, m2, d2] = tomorrow.date.split("-").map(Number)
26
+ expect(new Date(y2, m2 - 1, d2).getTime() - new Date(y, m - 1, d).getTime()).toBeCloseTo(
27
+ 24 * 60 * 60 * 1000,
28
+ -2
29
+ )
30
+ })
31
+
32
+ test("returns correct format for different days and floorMinute", () => {
33
+ const base = getEasternTime()
34
+ const plus2 = getEasternTime({ days: 2, floorMinute: true })
35
+ expect(plus2.date).not.toEqual(base.date)
36
+ expect(plus2.timestamp % 60).toBe(0)
37
+ })
38
+
39
+ // The following test ensures all code paths are covered, including when days=0 and floorMinute=false (the defaults).
40
+ test("default parameters yield consistent output", () => {
41
+ const def = getEasternTime()
42
+ const explicit = getEasternTime({ days: 0, floorMinute: false })
43
+ expect(def.date).toEqual(explicit.date)
44
+ expect(def.time).toEqual(explicit.time)
45
+ expect(def.timestamp).toEqual(explicit.timestamp)
46
+ expect(def.datetime).toEqual(explicit.datetime)
47
+ })
48
+ })