@tim-code/my-util 0.3.0 → 0.4.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 +1 -1
- package/src/fs.js +13 -20
- package/src/fs.test.js +25 -17
- package/src/promise.js +34 -5
- package/src/promise.test.js +63 -1
package/package.json
CHANGED
package/src/fs.js
CHANGED
|
@@ -1,47 +1,40 @@
|
|
|
1
1
|
import { readFile, stat, writeFile } from "node:fs/promises"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { promisify } from "node:util"
|
|
4
|
-
import { gunzip as _gunzip } from "node:zlib"
|
|
4
|
+
import { gunzip as _gunzip, gzip as _gzip } from "node:zlib"
|
|
5
5
|
|
|
6
6
|
const gunzip = promisify(_gunzip)
|
|
7
|
+
const gzip = promisify(_gzip)
|
|
7
8
|
|
|
8
9
|
// the integration tests for this file are the s3-fs integration tests in lambda-integrations
|
|
9
10
|
// changes to this file should be tested against lambda-integrations' integration tests
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* Get JSON from a path.
|
|
13
|
+
* Get JSON from a path. If path ends with .gz, will automatically decompress data.
|
|
13
14
|
* @param {string} path
|
|
14
15
|
* @returns {Object|Array}
|
|
15
16
|
*/
|
|
16
|
-
export async function
|
|
17
|
-
|
|
17
|
+
export async function readJSON(path) {
|
|
18
|
+
let buffer = await readFile(path)
|
|
19
|
+
if (path.endsWith(".gz")) {
|
|
20
|
+
buffer = await gunzip(buffer)
|
|
21
|
+
}
|
|
18
22
|
return JSON.parse(buffer.toString())
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
* Write JSON to a path.
|
|
26
|
+
* Write JSON to a path. If path ends with .gz, will automatically compress data.
|
|
23
27
|
* @param {string} path
|
|
24
28
|
* @param {Object|Array} object
|
|
25
29
|
* @param {Object} $1
|
|
26
30
|
* @param {number=} $1.indent Indent used to format JSON object. Default 2. If 0, does not indent object.
|
|
27
31
|
*/
|
|
28
32
|
export async function writeJSON(path, object, { indent = 2 } = {}) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get gzipped JSON from a path.
|
|
35
|
-
* @param {string} path
|
|
36
|
-
* @returns {Object|Array}
|
|
37
|
-
*/
|
|
38
|
-
export async function getCompressedJSON(path) {
|
|
39
|
-
if (!path.endsWith(".gz")) {
|
|
40
|
-
throw new Error("path does not suggest a compressed file")
|
|
33
|
+
let data = JSON.stringify(object, undefined, indent)
|
|
34
|
+
if (path.endsWith(".gz")) {
|
|
35
|
+
data = await gzip(data)
|
|
41
36
|
}
|
|
42
|
-
|
|
43
|
-
const uncompressed = await gunzip(buffer)
|
|
44
|
-
return JSON.parse(uncompressed.toString())
|
|
37
|
+
await writeFile(path, data)
|
|
45
38
|
}
|
|
46
39
|
|
|
47
40
|
/**
|
package/src/fs.test.js
CHANGED
|
@@ -4,6 +4,7 @@ const readFileMock = jest.fn()
|
|
|
4
4
|
const statMock = jest.fn()
|
|
5
5
|
const tmpdirMock = jest.fn()
|
|
6
6
|
const gunzipMock = jest.fn()
|
|
7
|
+
const gzipMock = jest.fn()
|
|
7
8
|
const writeFileMock = jest.fn()
|
|
8
9
|
jest.unstable_mockModule("node:fs/promises", () => ({
|
|
9
10
|
readFile: readFileMock,
|
|
@@ -15,6 +16,7 @@ jest.unstable_mockModule("node:os", () => ({
|
|
|
15
16
|
}))
|
|
16
17
|
jest.unstable_mockModule("node:zlib", () => ({
|
|
17
18
|
gunzip: gunzipMock,
|
|
19
|
+
gzip: gzipMock,
|
|
18
20
|
}))
|
|
19
21
|
jest.unstable_mockModule("node:util", () => ({
|
|
20
22
|
promisify: (mock) => mock,
|
|
@@ -22,16 +24,24 @@ jest.unstable_mockModule("node:util", () => ({
|
|
|
22
24
|
|
|
23
25
|
// Now import the module under test
|
|
24
26
|
const mod = await import("./fs.js")
|
|
25
|
-
const {
|
|
27
|
+
const { readJSON, writeJSON, pathExists, makeTempDirectory } = mod
|
|
26
28
|
|
|
27
|
-
describe("
|
|
29
|
+
describe("readJSON", () => {
|
|
28
30
|
beforeEach(() => jest.clearAllMocks())
|
|
29
31
|
it("parses JSON from file", async () => {
|
|
30
32
|
readFileMock.mockResolvedValue(Buffer.from('{"a":1}'))
|
|
31
|
-
const result = await
|
|
33
|
+
const result = await readJSON("foo.json")
|
|
32
34
|
expect(result).toEqual({ a: 1 })
|
|
33
35
|
expect(readFileMock).toHaveBeenCalledWith("foo.json")
|
|
34
36
|
})
|
|
37
|
+
it("decompresses and parses JSON from .gz file", async () => {
|
|
38
|
+
readFileMock.mockResolvedValue(Buffer.from("gzipped"))
|
|
39
|
+
gunzipMock.mockResolvedValue(Buffer.from('{"b":2}'))
|
|
40
|
+
const result = await readJSON("bar.gz")
|
|
41
|
+
expect(result).toEqual({ b: 2 })
|
|
42
|
+
expect(readFileMock).toHaveBeenCalledWith("bar.gz")
|
|
43
|
+
expect(gunzipMock).toHaveBeenCalledWith(Buffer.from("gzipped"))
|
|
44
|
+
})
|
|
35
45
|
})
|
|
36
46
|
|
|
37
47
|
describe("writeJSON", () => {
|
|
@@ -55,20 +65,18 @@ describe("writeJSON", () => {
|
|
|
55
65
|
await writeJSON("foo.json", { x: 2 }, { indent: 0 })
|
|
56
66
|
expect(writeFileMock).toHaveBeenCalledWith("foo.json", '{"x":2}')
|
|
57
67
|
})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(
|
|
69
|
-
|
|
70
|
-
it("throws for bad filename", async () => {
|
|
71
|
-
await expect(getCompressedJSON("bar")).rejects.toThrow(/a compressed file/u)
|
|
68
|
+
it("compresses and writes JSON to .gz file", async () => {
|
|
69
|
+
gzipMock.mockResolvedValue(Buffer.from("compressed"))
|
|
70
|
+
await writeJSON("foo.gz", { y: 3 })
|
|
71
|
+
// Should gzip the JSON string and write the compressed buffer
|
|
72
|
+
expect(gzipMock).toHaveBeenCalledWith(JSON.stringify({ y: 3 }, undefined, 2))
|
|
73
|
+
expect(writeFileMock).toHaveBeenCalledWith("foo.gz", Buffer.from("compressed"))
|
|
74
|
+
})
|
|
75
|
+
it("compresses and writes JSON with custom indent to .gz file", async () => {
|
|
76
|
+
gzipMock.mockResolvedValue(Buffer.from("compressed"))
|
|
77
|
+
await writeJSON("foo.gz", { y: 3 }, { indent: 0 })
|
|
78
|
+
expect(gzipMock).toHaveBeenCalledWith(JSON.stringify({ y: 3 }, undefined, 0))
|
|
79
|
+
expect(writeFileMock).toHaveBeenCalledWith("foo.gz", Buffer.from("compressed"))
|
|
72
80
|
})
|
|
73
81
|
})
|
|
74
82
|
|
package/src/promise.js
CHANGED
|
@@ -44,11 +44,14 @@ export function poll({ ms, wait = false, attempts = undefined }, callback) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Sleep for X milliseconds.
|
|
47
|
-
* @param {number}
|
|
47
|
+
* @param {number} ms Milliseconds; returns immediately if negative
|
|
48
48
|
*/
|
|
49
|
-
export async function sleep(
|
|
49
|
+
export async function sleep(ms) {
|
|
50
|
+
if (ms < 0) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
50
53
|
await new Promise((resolve) => {
|
|
51
|
-
setTimeout(resolve,
|
|
54
|
+
setTimeout(resolve, ms)
|
|
52
55
|
})
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -63,12 +66,14 @@ export async function sleep(milliseconds) {
|
|
|
63
66
|
* - "errors": The "reason" property for each "result" object that did not have a status of "fulfilled".
|
|
64
67
|
* @param {Object} $1
|
|
65
68
|
* @param {Array} $1.array
|
|
66
|
-
* @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.
|
|
67
72
|
* @param {boolean=} $1.flatten Flattens values before returning; useful if promises return arrays
|
|
68
73
|
* @param {Function} callback
|
|
69
74
|
* @returns {Object} {results, values, returned, errors}
|
|
70
75
|
*/
|
|
71
|
-
export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
76
|
+
export async function allSettled({ array, limit, limiter, flatten = false }, callback) {
|
|
72
77
|
const results = []
|
|
73
78
|
let returned = []
|
|
74
79
|
let values = []
|
|
@@ -87,6 +92,7 @@ export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
|
87
92
|
errors.push(reason)
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
await limiter?.(elements.length)
|
|
90
96
|
}
|
|
91
97
|
if (flatten) {
|
|
92
98
|
values = values.flat()
|
|
@@ -95,6 +101,28 @@ export async function allSettled({ array, limit, flatten = false }, callback) {
|
|
|
95
101
|
return { values, returned, errors, results }
|
|
96
102
|
}
|
|
97
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
|
+
|
|
98
126
|
/**
|
|
99
127
|
* A convenience method to throw the result of allSettled().
|
|
100
128
|
* Useful in testing contexts when simply propagating the error is enough.
|
|
@@ -111,6 +139,7 @@ export function alert(result) {
|
|
|
111
139
|
return result
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
// unused but included for reference
|
|
114
143
|
/**
|
|
115
144
|
* Parallelize executions of a function using `Promise.all()`.
|
|
116
145
|
* This is useful because usually you want to set a limit to the number of parallel requests possible at once.
|
package/src/promise.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* eslint-disable prefer-promise-reject-errors */
|
|
3
3
|
import { jest } from "@jest/globals"
|
|
4
4
|
|
|
5
|
-
import { alert, allSettled, poll, PollError, sleep, throwFirstReject } from "./promise.js"
|
|
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 () => {
|
|
@@ -104,6 +104,14 @@ describe("sleep", () => {
|
|
|
104
104
|
// Allow for some jitter
|
|
105
105
|
expect(after - before).toBeGreaterThanOrEqual(5)
|
|
106
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)
|
|
114
|
+
})
|
|
107
115
|
})
|
|
108
116
|
|
|
109
117
|
describe("allSettled", () => {
|
|
@@ -154,6 +162,60 @@ describe("allSettled", () => {
|
|
|
154
162
|
expect(result.errors).toEqual([])
|
|
155
163
|
expect(result.results).toEqual([])
|
|
156
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
|
+
})
|
|
157
219
|
})
|
|
158
220
|
|
|
159
221
|
describe("alert", () => {
|