Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 339 lines 12 kB view raw
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);