Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import crypto from 'node:crypto';
2import { setTimeout } from 'node:timers';
3import { promisify } from 'node:util';
4import _ from 'lodash';
5import { type Simplify, type SnakeCasedProperties } from 'type-fest';
6
7import {
8 type CamelToSnakeCase,
9 type PickEach,
10 type RenameEach,
11 type SnakeCasedPropertiesDeepWithArrays,
12 type SnakeToCamelCase,
13} from './typescript-types.js';
14
15const { pick } = _;
16
17export function pad(padWithChar: string, targetLength: number, str: string) {
18 const strFinal = String(str);
19 const padding = padWithChar.repeat(
20 Math.max(0, targetLength - strFinal.length),
21 );
22 return padding + strFinal;
23}
24
25/**
26 * Takes a function, calls it, and returns whether the function threw
27 * (synchronously).
28 */
29export function doesThrow(fn: () => unknown) {
30 try {
31 fn();
32 return false;
33 } catch (e) {
34 return true;
35 }
36}
37
38export const __throw = (x: unknown): never => {
39 throw x;
40};
41
42export const thrownValueToString = (e: unknown) =>
43 e && typeof e === 'object' && 'message' in e ? String(e.message) : undefined;
44
45/**
46 * Identical to lodash.pick, except with more type safety.
47 *
48 * Lodash's pick has an overload in the type definition which allows one of its
49 * generic parameters to fall back to being assigned without any constraint,
50 * which defeats type safefty and loses autocomplete. This function just calls
51 * pick, but has a safer signature for type inference.
52 */
53export function safePick<T extends object, U extends keyof T>(
54 obj: T,
55 props: readonly U[],
56): Simplify<PickEach<T, U>> {
57 return pick(obj, props) satisfies object as PickEach<T, U>;
58}
59
60/**
61 * This is a function that's used to help TS warn us if a union type that we
62 * should've handled all cases for in fact has some cases unhandled.
63 *
64 * After handling all cases, you call `assertUnreachable(unionTypeVar)` and, if
65 * you don't get a compiler error, it means that all the cases have truly been
66 * handled, because TS has narrowed the type of unionTypeVar down to `never`.
67 *
68 * At runtime, this just throws an error, which is appropriate because it should
69 * never be reached.
70 */
71export function assertUnreachable(
72 _x: never,
73 message: string = "Didn't expect to get here",
74): never {
75 throw new Error(message);
76}
77
78// TODO: replace w/ Object.hasOwn when it's in lib.d.ts and we're all on latest Node.
79// https://github.com/microsoft/TypeScript/issues/44253
80export function hasOwn(obj: object, key: string | symbol) {
81 return Object.prototype.hasOwnProperty.call(obj, key);
82}
83
84/**
85 * Returns a promise that resolves after `ms` milliseconds.
86 *
87 * @param {number} ms Number of miliseconds before resolution.
88 */
89export async function sleep(ms: number) {
90 return new Promise<void>((resolve) => {
91 return setTimeout(resolve, ms).unref();
92 });
93}
94
95/**
96 * Returns a promise that resolves when the (optionally async) predicate becomes
97 * true. Waits `pollingIntervalMs` between tests of the predicate's truthiness;
98 * rejects if `timeoutMs` is reached.
99 */
100export async function waitFor(
101 predicate: () => Promise<boolean>,
102 opts: { pollingIntervalMs: number; timeoutMs?: number },
103): Promise<void> {
104 const { pollingIntervalMs, timeoutMs = Infinity } = opts;
105
106 let timeoutElapsed = false;
107 if (timeoutMs !== Infinity && timeoutMs > 0) {
108 setTimeout(() => {
109 timeoutElapsed = true;
110 }, timeoutMs).unref();
111 }
112
113 while (!timeoutElapsed) {
114 if (await predicate()) {
115 return;
116 }
117
118 await sleep(pollingIntervalMs);
119 }
120
121 throw new Error('Timeout reached.');
122}
123
124export function snakeToCamelCase<S extends string>(s: S) {
125 return s.replace(/_(.)/g, (_m, p1) =>
126 p1.toUpperCase(),
127 ) as SnakeToCamelCase<S>;
128}
129
130export function camelToSnakeCase<S extends string>(s: S) {
131 return s
132 .split(/(?=[A-Z])/)
133 .join('_')
134 .toLowerCase() as CamelToSnakeCase<S>;
135}
136
137/**
138 * NB: If you pass in an array, any objects within that array won't have their
139 * keys snake cased.
140 */
141export function camelCaseObjectKeysToSnakeCase<O extends object>(o: O) {
142 return Object.fromEntries(
143 Object.entries(o).map(([k, v]) => [camelToSnakeCase(k), v]),
144 ) as SnakeCasedProperties<O>;
145}
146
147/**
148 * Takes an object or an array and recursively snake cases the key names of any
149 * plain objects within the value.
150 *
151 * @param it
152 * @returns
153 */
154export function camelCaseObjectKeysToSnakeCaseDeep<O extends object>(it: O) {
155 return _camelCaseObjectKeysToSnakeCaseDeepHelper(it);
156}
157
158function _camelCaseObjectKeysToSnakeCaseDeepHelper<O>(
159 o: O,
160): SnakeCasedPropertiesDeepWithArrays<O> {
161 return Array.isArray(o)
162 ? (o.map((it) =>
163 _camelCaseObjectKeysToSnakeCaseDeepHelper(it),
164 ) as SnakeCasedPropertiesDeepWithArrays<O>)
165 : _.isPlainObject(o)
166 ? (Object.fromEntries(
167 Object.entries(o as { [k: string]: unknown }).map(([k, v]) => [
168 camelToSnakeCase(k),
169 _camelCaseObjectKeysToSnakeCaseDeepHelper(v),
170 ]),
171 ) as SnakeCasedPropertiesDeepWithArrays<O>)
172 : (o as SnakeCasedPropertiesDeepWithArrays<O>);
173}
174
175/**
176 * This function merges the data from patch into object. It's very similar to
177 * `Object.assign` _except_ that if the `patch` has an enumerable, own property
178 * whose value is undefined, then the property from the `patch` is ignored and
179 * the original value from `object` (if any) is left in place. Object.assign, by
180 * contrast, would copy the value `undefined`. It's also similar to lodash's
181 * merge, except it's not recursive.
182 *
183 * This is useful for working with GraphQL argument objects, where we
184 * might pick some subset of known argument names into a new object, but give
185 * them an undefined value in the process. E.g., imagine `someQuery(id: true)`,
186 * where `id` is not mutable, and there are also optional arguments `y` and `z`.
187 * So, the original args argument looked like `{ id: true }` and simply had no
188 * key for `y` or `z`. But, if we do `{ id, y, z } = args`, and then
189 * `patchInPlace(await getObjectById(id), { y, z })`, suddenly there are keys
190 * for `y` and `z`, just with undefined values that we want to ignore.
191 *
192 * @param object The object to mutate by assigning the patch's fields to it.
193 * @param patch An object of fields to set on the object.
194 * @returns
195 */
196export function patchInPlace<T extends object>(object: T, patch: Partial<T>) {
197 for (const k of Object.keys(patch) as (keyof T)[]) {
198 if (typeof patch[k] !== 'undefined') {
199 object[k] = patch[k]!;
200 }
201 }
202}
203
204export function removeUndefinedKeys<T extends object>(object: T) {
205 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
206 return _.pickBy(object, (v) => v !== undefined);
207}
208
209/**
210 * Gets the value of a property at a path, but short-circuits if any key along
211 * the way isn't found. It's like `?.`, except it returns a magic symbol to
212 * represent the None/short circuit case, so you can differentiate that from the
213 * property's value actually being null or undefined.
214 *
215 * This function is also much more convenient than `?.` when handling `unknown`
216 * values with TS (as you'd get in a catch block, where nothing's guaranteed
217 * about the thrown value), as TS won't let you do `unknownValue?.someProp` even
218 * though this can't crash at runtime (even if the LHS is a primitive) and
219 * should probably just produce `unknown`.
220 *
221 * For example, imagine you have some value `err` that's `unknown`, but you
222 * expect it might well be `{ response: { status: number } }`, and you want to
223 * check something like `e.response.status === 500`. Doing this in a way that TS
224 * will accept is incredibly cumbersome, and requires:
225 *
226 * ```ts
227 * if(typeof e === 'object' && e !== null &&
228 * typeof (e as { response?: unknown }).response === 'object' &&
229 * (e as { response: { status?: unknown } | null }).response !== null &&
230 * (e as { response: { status?: unknown } }).response.status === 500) {
231 * // ok
232 * }
233 * ```
234 *
235 * No sane person would write the above. Instead, one can do
236 * `safeGet(e, ['response', 'status'])`.
237 */
238export function safeGet(value: unknown, path: readonly string[]): unknown {
239 if (path.length === 0) {
240 return value;
241 }
242
243 if (typeof value === 'object' && value !== null) {
244 const [firstKey, ...remainingKeys] = path;
245
246 return !(firstKey in value)
247 ? noPropertyValueFound
248 : safeGet((value as { [k: string]: unknown })[firstKey], remainingKeys);
249 } else {
250 return noPropertyValueFound;
251 }
252}
253
254export const noPropertyValueFound = Symbol();
255
256export function pickFromKeys<
257 T extends object,
258 U extends { [K in string]: keyof T },
259>(object: T, newNamesToKeys: U) {
260 const newEntries = Object.entries(newNamesToKeys).map(([newName, key]) => [
261 newName,
262 object[key],
263 ]);
264 return Object.fromEntries(newEntries) as RenameEach<
265 PickEach<T, U[keyof U]>,
266 { [K in keyof U]: U[K] }
267 >;
268}
269
270/**
271 * This takes a retry policy and a function and returns a version of the
272 * function that, when called, will be automatically retried according to the
273 * policy.
274 *
275 * @param retryPolicy.maxRetries - The maximum number of times to retry the
276 * function.
277 * @param retryPolicy.initialTimeMsBetweenRetries - How long to wait after the
278 * first failure before retrying, in milliseconds.
279 * @param retryPolicy.maxTimeMsBetweenRetries - The maximum time to wait between
280 * retries, in milliseconds. This can be useful b/c the time between retries
281 * grows by defualt, to implement the standard exponential backoff pattern.
282 * @param retryPolicy.jitter - Whether to randomly vary the time between retries
283 * slightly, to prevent many retrying clients that are all using the same
284 * (standard) exponential backoff strategy from inadvertently overloading the
285 * service/resource that the function requires. Jitter is implemented as full
286 * jitter as defined here by AWS:
287 * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
288 * @param retryPolicy.nextRetryWaitTimeMultiple - How much to multiply the wait
289 * time from the last retry to determine the wait time for the next retry.
290 * This defaults to 2 -- i.e., the wait time doubles between retries -- which
291 * gives the standard exponential backoff behavior.
292 * @param retryPolicy.isRetryableError - If the function being subject to
293 * retries returns a promise that rejects, the rejection could've been because
294 * of an error that is not retryable, in which case the retry process should
295 * bail early. This option accepts a predicate function that receives the
296 * rejection value and returns whether the error is retryable. By default, all
297 * errors are considered retryable.
298 * @param fn The function that will be run and retried as needed.
299 * @returns A version of the function with automatic retries.
300 */
301export function withRetries<Args extends unknown[], Return>(
302 retryPolicy: {
303 maxRetries: number;
304 initialTimeMsBetweenRetries: number;
305 maxTimeMsBetweenRetries: number;
306 jitter?: boolean;
307 nextRetryWaitTimeMultiple?: number;
308 isRetryableError?: (rejectionValue: unknown) => boolean;
309 },
310 fn: (this: void, ...args: Args) => Promise<Return>,
311): (...args: Args) => Promise<Return> {
312 const {
313 maxRetries,
314 initialTimeMsBetweenRetries,
315 maxTimeMsBetweenRetries,
316 jitter = true,
317 nextRetryWaitTimeMultiple = 2,
318 isRetryableError = () => true,
319 } = retryPolicy;
320 return async (...args) => {
321 for (let i = 0; i <= maxRetries; ++i) {
322 try {
323 return await fn(...args);
324 } catch (ex) {
325 if (i === maxRetries || !isRetryableError(ex)) {
326 throw ex;
327 }
328 const waitTimeMs = Math.min(
329 maxTimeMsBetweenRetries,
330 (jitter ? Math.random() : 1) *
331 (initialTimeMsBetweenRetries * nextRetryWaitTimeMultiple ** i),
332 );
333 await sleep(waitTimeMs);
334 }
335 }
336 throw new Error('Invalid retry attempts');
337 };
338}
339
340export const asyncRandomBytes = promisify(crypto.randomBytes);