import crypto from 'node:crypto'; import { setTimeout } from 'node:timers'; import { promisify } from 'node:util'; import _ from 'lodash'; import { type Simplify, type SnakeCasedProperties } from 'type-fest'; import { type CamelToSnakeCase, type PickEach, type RenameEach, type SnakeCasedPropertiesDeepWithArrays, type SnakeToCamelCase, } from './typescript-types.js'; const { pick } = _; export function pad(padWithChar: string, targetLength: number, str: string) { const strFinal = String(str); const padding = padWithChar.repeat( Math.max(0, targetLength - strFinal.length), ); return padding + strFinal; } /** * Takes a function, calls it, and returns whether the function threw * (synchronously). */ export function doesThrow(fn: () => unknown) { try { fn(); return false; } catch (e) { return true; } } export const __throw = (x: unknown): never => { throw x; }; export const thrownValueToString = (e: unknown) => e && typeof e === 'object' && 'message' in e ? String(e.message) : undefined; /** * Identical to lodash.pick, except with more type safety. * * Lodash's pick has an overload in the type definition which allows one of its * generic parameters to fall back to being assigned without any constraint, * which defeats type safefty and loses autocomplete. This function just calls * pick, but has a safer signature for type inference. */ export function safePick( obj: T, props: readonly U[], ): Simplify> { return pick(obj, props) satisfies object as PickEach; } /** * This is a function that's used to help TS warn us if a union type that we * should've handled all cases for in fact has some cases unhandled. * * After handling all cases, you call `assertUnreachable(unionTypeVar)` and, if * you don't get a compiler error, it means that all the cases have truly been * handled, because TS has narrowed the type of unionTypeVar down to `never`. * * At runtime, this just throws an error, which is appropriate because it should * never be reached. */ export function assertUnreachable( _x: never, message: string = "Didn't expect to get here", ): never { throw new Error(message); } // TODO: replace w/ Object.hasOwn when it's in lib.d.ts and we're all on latest Node. // https://github.com/microsoft/TypeScript/issues/44253 export function hasOwn(obj: object, key: string | symbol) { return Object.prototype.hasOwnProperty.call(obj, key); } /** * Returns a promise that resolves after `ms` milliseconds. * * @param {number} ms Number of miliseconds before resolution. */ export async function sleep(ms: number) { return new Promise((resolve) => { return setTimeout(resolve, ms).unref(); }); } /** * Returns a promise that resolves when the (optionally async) predicate becomes * true. Waits `pollingIntervalMs` between tests of the predicate's truthiness; * rejects if `timeoutMs` is reached. */ export async function waitFor( predicate: () => Promise, opts: { pollingIntervalMs: number; timeoutMs?: number }, ): Promise { const { pollingIntervalMs, timeoutMs = Infinity } = opts; let timeoutElapsed = false; if (timeoutMs !== Infinity && timeoutMs > 0) { setTimeout(() => { timeoutElapsed = true; }, timeoutMs).unref(); } while (!timeoutElapsed) { if (await predicate()) { return; } await sleep(pollingIntervalMs); } throw new Error('Timeout reached.'); } export function snakeToCamelCase(s: S) { return s.replace(/_(.)/g, (_m, p1) => p1.toUpperCase(), ) as SnakeToCamelCase; } export function camelToSnakeCase(s: S) { return s .split(/(?=[A-Z])/) .join('_') .toLowerCase() as CamelToSnakeCase; } /** * NB: If you pass in an array, any objects within that array won't have their * keys snake cased. */ export function camelCaseObjectKeysToSnakeCase(o: O) { return Object.fromEntries( Object.entries(o).map(([k, v]) => [camelToSnakeCase(k), v]), ) as SnakeCasedProperties; } /** * Takes an object or an array and recursively snake cases the key names of any * plain objects within the value. * * @param it * @returns */ export function camelCaseObjectKeysToSnakeCaseDeep(it: O) { return _camelCaseObjectKeysToSnakeCaseDeepHelper(it); } function _camelCaseObjectKeysToSnakeCaseDeepHelper( o: O, ): SnakeCasedPropertiesDeepWithArrays { return Array.isArray(o) ? (o.map((it) => _camelCaseObjectKeysToSnakeCaseDeepHelper(it), ) as SnakeCasedPropertiesDeepWithArrays) : _.isPlainObject(o) ? (Object.fromEntries( Object.entries(o as { [k: string]: unknown }).map(([k, v]) => [ camelToSnakeCase(k), _camelCaseObjectKeysToSnakeCaseDeepHelper(v), ]), ) as SnakeCasedPropertiesDeepWithArrays) : (o as SnakeCasedPropertiesDeepWithArrays); } /** * This function merges the data from patch into object. It's very similar to * `Object.assign` _except_ that if the `patch` has an enumerable, own property * whose value is undefined, then the property from the `patch` is ignored and * the original value from `object` (if any) is left in place. Object.assign, by * contrast, would copy the value `undefined`. It's also similar to lodash's * merge, except it's not recursive. * * This is useful for working with GraphQL argument objects, where we * might pick some subset of known argument names into a new object, but give * them an undefined value in the process. E.g., imagine `someQuery(id: true)`, * where `id` is not mutable, and there are also optional arguments `y` and `z`. * So, the original args argument looked like `{ id: true }` and simply had no * key for `y` or `z`. But, if we do `{ id, y, z } = args`, and then * `patchInPlace(await getObjectById(id), { y, z })`, suddenly there are keys * for `y` and `z`, just with undefined values that we want to ignore. * * @param object The object to mutate by assigning the patch's fields to it. * @param patch An object of fields to set on the object. * @returns */ export function patchInPlace(object: T, patch: Partial) { for (const k of Object.keys(patch) as (keyof T)[]) { if (typeof patch[k] !== 'undefined') { object[k] = patch[k]!; } } } export function removeUndefinedKeys(object: T) { return _.pickBy(object, (v) => v !== undefined); } /** * Gets the value of a property at a path, but short-circuits if any key along * the way isn't found. It's like `?.`, except it returns a magic symbol to * represent the None/short circuit case, so you can differentiate that from the * property's value actually being null or undefined. * * This function is also much more convenient than `?.` when handling `unknown` * values with TS (as you'd get in a catch block, where nothing's guaranteed * about the thrown value), as TS won't let you do `unknownValue?.someProp` even * though this can't crash at runtime (even if the LHS is a primitive) and * should probably just produce `unknown`. * * For example, imagine you have some value `err` that's `unknown`, but you * expect it might well be `{ response: { status: number } }`, and you want to * check something like `e.response.status === 500`. Doing this in a way that TS * will accept is incredibly cumbersome, and requires: * * ```ts * if(typeof e === 'object' && e !== null && * typeof (e as { response?: unknown }).response === 'object' && * (e as { response: { status?: unknown } | null }).response !== null && * (e as { response: { status?: unknown } }).response.status === 500) { * // ok * } * ``` * * No sane person would write the above. Instead, one can do * `safeGet(e, ['response', 'status'])`. */ export function safeGet(value: unknown, path: readonly string[]): unknown { if (path.length === 0) { return value; } if (typeof value === 'object' && value !== null) { const [firstKey, ...remainingKeys] = path; return !(firstKey in value) ? noPropertyValueFound : safeGet((value as { [k: string]: unknown })[firstKey], remainingKeys); } else { return noPropertyValueFound; } } export const noPropertyValueFound = Symbol(); export function pickFromKeys< T extends object, U extends { [K in string]: keyof T }, >(object: T, newNamesToKeys: U) { const newEntries = Object.entries(newNamesToKeys).map(([newName, key]) => [ newName, object[key], ]); return Object.fromEntries(newEntries) as RenameEach< PickEach, { [K in keyof U]: U[K] } >; } /** * This takes a retry policy and a function and returns a version of the * function that, when called, will be automatically retried according to the * policy. * * @param retryPolicy.maxRetries - The maximum number of times to retry the * function. * @param retryPolicy.initialTimeMsBetweenRetries - How long to wait after the * first failure before retrying, in milliseconds. * @param retryPolicy.maxTimeMsBetweenRetries - The maximum time to wait between * retries, in milliseconds. This can be useful b/c the time between retries * grows by defualt, to implement the standard exponential backoff pattern. * @param retryPolicy.jitter - Whether to randomly vary the time between retries * slightly, to prevent many retrying clients that are all using the same * (standard) exponential backoff strategy from inadvertently overloading the * service/resource that the function requires. Jitter is implemented as full * jitter as defined here by AWS: * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ * @param retryPolicy.nextRetryWaitTimeMultiple - How much to multiply the wait * time from the last retry to determine the wait time for the next retry. * This defaults to 2 -- i.e., the wait time doubles between retries -- which * gives the standard exponential backoff behavior. * @param retryPolicy.isRetryableError - If the function being subject to * retries returns a promise that rejects, the rejection could've been because * of an error that is not retryable, in which case the retry process should * bail early. This option accepts a predicate function that receives the * rejection value and returns whether the error is retryable. By default, all * errors are considered retryable. * @param fn The function that will be run and retried as needed. * @returns A version of the function with automatic retries. */ export function withRetries( retryPolicy: { maxRetries: number; initialTimeMsBetweenRetries: number; maxTimeMsBetweenRetries: number; jitter?: boolean; nextRetryWaitTimeMultiple?: number; isRetryableError?: (rejectionValue: unknown) => boolean; }, fn: (this: void, ...args: Args) => Promise, ): (...args: Args) => Promise { const { maxRetries, initialTimeMsBetweenRetries, maxTimeMsBetweenRetries, jitter = true, nextRetryWaitTimeMultiple = 2, isRetryableError = () => true, } = retryPolicy; return async (...args) => { for (let i = 0; i <= maxRetries; ++i) { try { return await fn(...args); } catch (ex) { if (i === maxRetries || !isRetryableError(ex)) { throw ex; } const waitTimeMs = Math.min( maxTimeMsBetweenRetries, (jitter ? Math.random() : 1) * (initialTimeMsBetweenRetries * nextRetryWaitTimeMultiple ** i), ); await sleep(waitTimeMs); } } throw new Error('Invalid retry attempts'); }; } export const asyncRandomBytes = promisify(crypto.randomBytes);