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 return _.pickBy(object, (v) => v !== undefined);
206}
207
208/**
209 * Gets the value of a property at a path, but short-circuits if any key along
210 * the way isn't found. It's like `?.`, except it returns a magic symbol to
211 * represent the None/short circuit case, so you can differentiate that from the
212 * property's value actually being null or undefined.
213 *
214 * This function is also much more convenient than `?.` when handling `unknown`
215 * values with TS (as you'd get in a catch block, where nothing's guaranteed
216 * about the thrown value), as TS won't let you do `unknownValue?.someProp` even
217 * though this can't crash at runtime (even if the LHS is a primitive) and
218 * should probably just produce `unknown`.
219 *
220 * For example, imagine you have some value `err` that's `unknown`, but you
221 * expect it might well be `{ response: { status: number } }`, and you want to
222 * check something like `e.response.status === 500`. Doing this in a way that TS
223 * will accept is incredibly cumbersome, and requires:
224 *
225 * ```ts
226 * if(typeof e === 'object' && e !== null &&
227 * typeof (e as { response?: unknown }).response === 'object' &&
228 * (e as { response: { status?: unknown } | null }).response !== null &&
229 * (e as { response: { status?: unknown } }).response.status === 500) {
230 * // ok
231 * }
232 * ```
233 *
234 * No sane person would write the above. Instead, one can do
235 * `safeGet(e, ['response', 'status'])`.
236 */
237export function safeGet(value: unknown, path: readonly string[]): unknown {
238 if (path.length === 0) {
239 return value;
240 }
241
242 if (typeof value === 'object' && value !== null) {
243 const [firstKey, ...remainingKeys] = path;
244
245 return !(firstKey in value)
246 ? noPropertyValueFound
247 : safeGet((value as { [k: string]: unknown })[firstKey], remainingKeys);
248 } else {
249 return noPropertyValueFound;
250 }
251}
252
253export const noPropertyValueFound = Symbol();
254
255export function pickFromKeys<
256 T extends object,
257 U extends { [K in string]: keyof T },
258>(object: T, newNamesToKeys: U) {
259 const newEntries = Object.entries(newNamesToKeys).map(([newName, key]) => [
260 newName,
261 object[key],
262 ]);
263 return Object.fromEntries(newEntries) as RenameEach<
264 PickEach<T, U[keyof U]>,
265 { [K in keyof U]: U[K] }
266 >;
267}
268
269/**
270 * This takes a retry policy and a function and returns a version of the
271 * function that, when called, will be automatically retried according to the
272 * policy.
273 *
274 * @param retryPolicy.maxRetries - The maximum number of times to retry the
275 * function.
276 * @param retryPolicy.initialTimeMsBetweenRetries - How long to wait after the
277 * first failure before retrying, in milliseconds.
278 * @param retryPolicy.maxTimeMsBetweenRetries - The maximum time to wait between
279 * retries, in milliseconds. This can be useful b/c the time between retries
280 * grows by defualt, to implement the standard exponential backoff pattern.
281 * @param retryPolicy.jitter - Whether to randomly vary the time between retries
282 * slightly, to prevent many retrying clients that are all using the same
283 * (standard) exponential backoff strategy from inadvertently overloading the
284 * service/resource that the function requires. Jitter is implemented as full
285 * jitter as defined here by AWS:
286 * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
287 * @param retryPolicy.nextRetryWaitTimeMultiple - How much to multiply the wait
288 * time from the last retry to determine the wait time for the next retry.
289 * This defaults to 2 -- i.e., the wait time doubles between retries -- which
290 * gives the standard exponential backoff behavior.
291 * @param retryPolicy.isRetryableError - If the function being subject to
292 * retries returns a promise that rejects, the rejection could've been because
293 * of an error that is not retryable, in which case the retry process should
294 * bail early. This option accepts a predicate function that receives the
295 * rejection value and returns whether the error is retryable. By default, all
296 * errors are considered retryable.
297 * @param fn The function that will be run and retried as needed.
298 * @returns A version of the function with automatic retries.
299 */
300export function withRetries<Args extends unknown[], Return>(
301 retryPolicy: {
302 maxRetries: number;
303 initialTimeMsBetweenRetries: number;
304 maxTimeMsBetweenRetries: number;
305 jitter?: boolean;
306 nextRetryWaitTimeMultiple?: number;
307 isRetryableError?: (rejectionValue: unknown) => boolean;
308 },
309 fn: (this: void, ...args: Args) => Promise<Return>,
310): (...args: Args) => Promise<Return> {
311 const {
312 maxRetries,
313 initialTimeMsBetweenRetries,
314 maxTimeMsBetweenRetries,
315 jitter = true,
316 nextRetryWaitTimeMultiple = 2,
317 isRetryableError = () => true,
318 } = retryPolicy;
319 return async (...args) => {
320 for (let i = 0; i <= maxRetries; ++i) {
321 try {
322 return await fn(...args);
323 } catch (ex) {
324 if (i === maxRetries || !isRetryableError(ex)) {
325 throw ex;
326 }
327 const waitTimeMs = Math.min(
328 maxTimeMsBetweenRetries,
329 (jitter ? Math.random() : 1) *
330 (initialTimeMsBetweenRetries * nextRetryWaitTimeMultiple ** i),
331 );
332 await sleep(waitTimeMs);
333 }
334 }
335 throw new Error('Invalid retry attempts');
336 };
337}
338
339export const asyncRandomBytes = promisify(crypto.randomBytes);