polen
Version:
A framework for delightful GraphQL developer portals
646 lines (592 loc) • 19 kB
text/typescript
//
//
//
//
//
// Holding Module for Missing @wollybeard/kit Functionality
//
// ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
//
// Code here is meant to be migrated eventually to @wollybeard/kit.
//
//
//
import { Arr, Err, Fs, Http, Path, Undefined } from '@wollybeard/kit'
import type { ResolveHookContext } from 'node:module'
export const arrayEquals = (a: any[], b: any[]) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export const ensureOptionalAbsoluteWithCwd = (pathExp: string | undefined): string => {
if (Undefined.is(pathExp)) return process.cwd()
return Path.ensureAbsolute(pathExp, process.cwd())
}
export const ensureOptionalAbsolute = (pathExp: string | undefined, basePathExp: string): string => {
assertPathAbsolute(basePathExp)
if (Undefined.is(pathExp)) return basePathExp
return Path.ensureAbsolute(pathExp, basePathExp)
}
export const assertPathAbsolute = (pathExpression: string): void => {
if (Path.isAbsolute(pathExpression)) return
throw new Error(`Path must be absolute: ${pathExpression}`)
}
export const assertOptionalPathAbsolute = (pathExpression: string | undefined, message?: string): void => {
if (Undefined.is(pathExpression)) return
if (Path.isAbsolute(pathExpression)) return
const message_ = message ?? `Path must be absolute: ${pathExpression}`
throw new Error(message_)
}
export const pickFirstPathExisting = async (paths: string[]): Promise<string | undefined> => {
const checks = await Promise.all(paths.map(path => Fs.exists(path).then(exists => exists ? path : undefined)))
return checks.find(maybePath => maybePath !== undefined)
}
export const isSpecifierFromPackage = (specifier: string, packageName: string): boolean => {
return specifier === packageName || specifier.startsWith(packageName + `/`)
}
export interface ImportEvent {
specifier: string
context: ResolveHookContext
}
// dprint-ignore
export type ObjPolicyFilter<
$Object extends object,
$Key extends Keyof<$Object>,
Mode extends `allow` | `deny`,
> = Mode extends `allow`
? Pick<$Object, Extract<$Key, keyof $Object>>
: Omit<$Object, Extract<$Key, keyof $Object>>
/**
* Like keyof but returns PropertyKey for object
*/
type Keyof<$Object extends object> = object extends $Object ? PropertyKey : (keyof $Object)
/**
* Filter object properties based on a policy mode and set of keys
*
* @param mode - 'allow' to keep only specified keys, 'deny' to remove specified keys
* @param obj - The object to filter
* @param keys - The keys to process
* @returns A filtered object with proper type inference
*
* @example
* ```ts
* const obj = { a: 1, b: 2, c: 3 }
*
* // Allow mode: keep only 'a' and 'c'
* objPolicyFilter('allow', obj, ['a', 'c']) // { a: 1, c: 3 }
*
* // Deny mode: remove 'a' and 'c'
* objPolicyFilter('deny', obj, ['a', 'c']) // { b: 2 }
* ```
*/
export const objPolicyFilter = <
obj extends object,
keyUnion extends Keyof<obj>,
mode extends `allow` | `deny`,
>(
mode: mode,
obj: obj,
keys: readonly keyUnion[],
): ObjPolicyFilter<obj, keyUnion, mode> => {
const result: any = mode === `deny` ? { ...obj } : {}
if (mode === `allow`) {
// For allow mode, only add specified keys
for (const key of keys) {
if (key in obj) {
// @ts-expect-error
result[key] = obj[key]
}
}
} else {
// For deny mode, remove specified keys
for (const key of keys) {
delete result[key]
}
}
return result
}
/**
* Filter an object using a predicate function
*
* @param obj - The object to filter
* @param predicate - Function that returns true to keep a key/value pair
* @returns A new object with only the key/value pairs where predicate returned true
*
* @example
* ```ts
* const obj = { a: 1, b: 2, c: 3 }
* objFilter(obj, (k, v) => v > 1) // { b: 2, c: 3 }
* objFilter(obj, k => k !== 'b') // { a: 1, c: 3 }
* ```
*/
export const objFilter = <T extends object>(
obj: T,
predicate: (key: keyof T, value: T[keyof T], obj: T) => boolean,
): Partial<T> => {
const result = {} as Partial<T>
// Use Object.keys to get all enumerable own properties
// This matches the behavior of for...in but only for own properties
for (const key of Object.keys(obj) as (keyof T)[]) {
if (predicate(key, obj[key], obj)) {
result[key] = obj[key]
}
}
return result
}
export const ObjPick = <T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> => {
return objPolicyFilter(`allow`, obj, keys) as any
}
export const ObjOmit = <T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K> => {
return objPolicyFilter(`deny`, obj, keys) as any
}
export const ObjPartition = <T extends object, K extends keyof T>(
obj: T,
keys: readonly K[],
): { omitted: Omit<T, K>; picked: Pick<T, K> } => {
return keys.reduce((acc, key) => {
if (key in acc.omitted) {
// @ts-expect-error omitted already at type level
delete acc.omitted[key]
acc.picked[key] = obj[key]
}
return acc
}, {
omitted: { ...obj } as Omit<T, K>,
picked: {} as Pick<T, K>,
})
}
export const ensureEnd = (string: string, ending: string) => {
if (string.endsWith(ending)) return string
return string + ending
}
export const ResponseInternalServerError = () =>
new Response(null, {
status: Http.Status.InternalServerError.code,
statusText: Http.Status.InternalServerError.description,
})
/**
* Execute an operation on multiple items, continuing even if some fail
*/
export async function tryCatchMany<item, result>(
items: item[],
operation: (item: item) => Promise<result>,
): Promise<[result[], (Error & { context: { item: item } })[]]> {
const partitionedResults = await Promise.all(items.map(async (item) => {
const result = await Err.tryCatch(() => operation(item))
if (Err.is(result)) {
const error = result as Error & { context: { item: item } }
error.context = { item }
return error
}
return result
})).then(Arr.partitionErrors)
return partitionedResults as any
}
/**
* Type-level helper to check if two types are exactly the same (invariant).
*/
export type IsExact<T, U> = T extends U ? U extends T ? true : false : false
// dprint-ignore
export type ExtendsExact<$Input, $Constraint> =
$Input extends $Constraint
? $Constraint extends $Input
? $Input
: never
: never
/**
* Split an array into chunks of specified size
*
* @param array - The array to chunk
* @param size - The size of each chunk
* @returns Array of chunks
*
* @example
* ```ts
* chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
* chunk(['a', 'b', 'c'], 3) // [['a', 'b', 'c']]
* ```
*/
export const chunk = <T>(array: readonly T[], size: number): T[][] => {
if (size <= 0) throw new Error(`Chunk size must be greater than 0`)
if (array.length === 0) return []
const chunks: T[][] = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
export interface AsyncParallelOptions {
/**
* Maximum number of items to process concurrently
* @default 10
*/
concurrency?: number
/**
* If true, stops processing on first error
* If false, continues processing all items even if some fail
* @default false
*/
failFast?: boolean
/**
* Size of batches to process items in
* If not specified, all items are processed with the specified concurrency
*/
batchSize?: number
}
export interface AsyncParallelResult<T, R> {
/** Successfully processed results */
results: R[]
/** Errors that occurred during processing */
errors: (Error & { item: T })[]
/** Whether all items were processed successfully */
success: boolean
}
/**
* Process items in parallel with configurable options
*
* @param items - Items to process
* @param operation - Async function to apply to each item (with optional index)
* @param options - Configuration options
* @returns Results and errors from processing
*
* @example
* ```ts
* const items = [1, 2, 3, 4, 5]
* const result = await asyncParallel(items, async (n, index) => n * 2, {
* concurrency: 2,
* batchSize: 3,
* failFast: false
* })
* // result.results: [2, 4, 6, 8, 10]
* // result.errors: []
* // result.success: true
* ```
*/
export const asyncParallel = async <T, R>(
items: readonly T[],
operation: (item: T, index: number) => Promise<R>,
options: AsyncParallelOptions = {},
): Promise<AsyncParallelResult<T, R>> => {
const { concurrency = 10, failFast = false, batchSize } = options
if (items.length === 0) {
return { results: [], errors: [], success: true }
}
const allResults: R[] = []
const allErrors: (Error & { item: T })[] = []
// If batchSize is specified, process in batches
if (batchSize !== undefined) {
const batches = chunk(items, batchSize)
let globalIndex = 0
for (const batch of batches) {
const batchResult = await processBatch(batch, operation, concurrency, failFast, globalIndex)
allResults.push(...batchResult.results)
allErrors.push(...batchResult.errors)
globalIndex += batch.length
if (failFast && batchResult.errors.length > 0) {
break
}
}
} else {
// Process all items with specified concurrency
const result = await processBatch(items, operation, concurrency, failFast, 0)
allResults.push(...result.results)
allErrors.push(...result.errors)
}
return {
results: allResults,
errors: allErrors,
success: allErrors.length === 0,
}
}
/**
* Process a batch of items with limited concurrency
*/
const processBatch = async <T, R>(
items: readonly T[],
operation: (item: T, index: number) => Promise<R>,
concurrency: number,
failFast: boolean,
startIndex = 0,
): Promise<AsyncParallelResult<T, R>> => {
const results: R[] = []
const errors: (Error & { item: T })[] = []
// Process items in chunks based on concurrency limit
const chunks = chunk(items, concurrency)
let currentIndex = startIndex
for (const chunkItems of chunks) {
const promises = chunkItems.map(async (item, chunkIndex) => {
const globalIndex = currentIndex + chunkIndex
try {
const result = await operation(item, globalIndex)
return { success: true, result, item }
} catch (error) {
const enhancedError = error instanceof Error ? error : new Error(String(error))
Object.assign(enhancedError, { item })
return { success: false, error: enhancedError as Error & { item: T }, item }
}
})
currentIndex += chunkItems.length
const chunkResults = await Promise.allSettled(promises)
for (const promiseResult of chunkResults) {
if (promiseResult.status === `fulfilled`) {
const { success, result, error, item } = promiseResult.value
if (success) {
results.push(result!)
} else {
errors.push(error!)
if (failFast) {
return { results, errors, success: false }
}
}
} else {
// This shouldn't happen since we're catching errors above
// But handle it just in case
const error = new Error(`Unexpected promise rejection`) as Error & { item: any }
errors.push(error)
if (failFast) {
return { results, errors, success: false }
}
}
}
}
return { results, errors, success: errors.length === 0 }
}
// /**
// * Reduce an array asynchronously, processing each item in sequence
// *
// * @param items - Array of items to process
// * @param reducer - Async function that takes accumulator and current item
// * @param initial - Initial value for the accumulator
// * @returns Final accumulated value
// *
// * @example
// * ```ts
// * const numbers = [1, 2, 3, 4]
// * const sum = await asyncReduce(numbers, async (acc, n) => acc + n, 0)
// * // sum: 10
// *
// * const transforms = [addHeader, addFooter, minify]
// * const html = await asyncReduce(transforms, async (html, transform) => transform(html), initialHtml)
// * ```
// */
// export const asyncReduce = async <T, R>(
// items: readonly T[],
// reducer: (accumulator: R, current: T, index: number) => Promise<R> | R,
// initial: R,
// ): Promise<R> => {
// let result = initial
// for (let i = 0; i < items.length; i++) {
// const item = items[i]!
// result = await reducer(result, item, i)
// }
// return result
// }
// /**
// * Curried version of asyncReduce for functions that transform a value
// *
// * @param transformers - Array of transformer functions
// * @returns A function that takes an initial value and applies all transformers
// *
// * @example
// * ```ts
// * const transformers = [addHeader, addFooter, minify]
// * const applyTransforms = asyncReduceWith(transformers)
// * const finalHtml = await applyTransforms(initialHtml)
// *
// * // For simple pipelines where each function transforms the same type
// * const htmlPipeline = asyncReduceWith([
// * (html) => html.replace('foo', 'bar'),
// * async (html) => await prettify(html),
// * (html) => html.trim()
// * ])
// * ```
// */
// export const asyncReduceWith = <T>(
// transformers: readonly ((value: T) => Promise<T> | T)[],
// ) => {
// return async (initial: T): Promise<T> => {
// return asyncReduce(transformers, (value, transform) => transform(value), initial)
// }
// }
/**
* Reduce an array asynchronously with context, processing each item in sequence
*
* @param items - Array of items to process
* @param reducer - Async function that takes accumulator, current item, and context
* @param initial - Initial value for the accumulator
* @param context - Context object passed to each reducer call
* @returns Final accumulated value
*
* @example
* ```ts
* const transformers = [transformer1, transformer2]
* const ctx = { request: req, response: res }
* const result = await asyncReduceWithContext(
* transformers,
* async (html, transformer) => transformer(html, ctx),
* initialHtml,
* ctx
* )
* ```
*/
export const asyncReduce = async <T, R, C>(
items: readonly T[],
reducer: (accumulator: R, current: T, context: C, index: number) => Promise<R> | R,
initial: R,
context: C,
): Promise<R> => {
let result = initial
for (let i = 0; i < items.length; i++) {
const item = items[i]!
result = await reducer(result, item, context, i)
}
return result
}
/**
* Curried version of asyncReduceWithContext for functions that transform a value with context
*
* @param transformers - Array of transformer functions that take value and context
* @returns A function that takes an initial value and context, and applies all transformers
*
* @example
* ```ts
* const transformers = [
* (html, ctx) => html.replace('{{url}}', ctx.req.url),
* async (html, ctx) => await ctx.minify(html),
* ]
* const applyTransforms = asyncReduceWithContextWith(transformers)
* const finalHtml = await applyTransforms(initialHtml, ctx)
* ```
*/
export const asyncReduceWith = <T, C>(
transformers: readonly ((value: T, context: C) => Promise<T> | T)[],
context: C,
) => {
return async (initial: T): Promise<T> => {
return asyncReduce(
transformers,
(value, transform, ctx) => transform(value, ctx),
initial,
context,
)
}
}
/**
* Create a branded type that provides nominal typing in TypeScript.
*
* Branded types allow you to create distinct types from primitives or other types,
* preventing accidental mixing of values that are structurally identical but
* semantically different.
*
* @template $BaseType - The underlying type to brand (e.g., string, number)
* @template $BrandName - A unique string literal to distinguish this brand
*
* @example
* ```ts
* // Create distinct ID types that can't be mixed
* type UserId = Brand<string, 'UserId'>
* type PostId = Brand<string, 'PostId'>
*
* function getUser(id: UserId) { ... }
*
* const userId = 'u123' as UserId
* const postId = 'p456' as PostId
*
* getUser(userId) // ✓ OK
* getUser(postId) // ✗ Type error - can't use PostId where UserId expected
* ```
*
* @example
* ```ts
* // Brand primitive types for domain modeling
* type Email = Brand<string, 'Email'>
* type Url = Brand<string, 'Url'>
* type PositiveNumber = Brand<number, 'PositiveNumber'>
* ```
*/
export type Brand<$BaseType, $BrandName extends string> =
& $BaseType
& {
readonly __brand: $BrandName
}
/**
* Helper function to create a branded value.
*
* This is a simple type assertion helper. For runtime validation,
* combine with validation functions or schemas.
*
* @template $BaseType - The underlying type to brand
* @template $BrandName - The brand name to apply
* @param value - The value to brand
* @returns The value with the brand type applied
*
* @example
* ```ts
* type UserId = Brand<string, 'UserId'>
*
* // Simple branding (no runtime validation)
* const id = brand<string, 'UserId'>('u123')
*
* // With validation (recommended)
* function createUserId(value: string): UserId {
* if (!value.startsWith('u')) {
* throw new Error('User IDs must start with "u"')
* }
* return brand<string, 'UserId'>(value)
* }
* ```
*/
export const brand = <$BaseType, $BrandName extends string>(
value: $BaseType,
): Brand<$BaseType, $BrandName> => {
return value as Brand<$BaseType, $BrandName>
}
/**
* Shallow merge objects while omitting undefined values.
*
* This utility simplifies the common pattern of conditionally spreading objects
* to avoid including undefined values that would override existing values.
*
* @param objects - Objects to merge (later objects override earlier ones). Undefined objects are ignored.
* @returns Merged object with undefined values omitted
*
* @example
* ```ts
* // Instead of:
* const overrides = {
* ...(value1 ? { key1: value1 } : {}),
* ...(value2 ? { key2: value2 } : {}),
* }
*
* // Use:
* const overrides = mergeShallow({
* key1: value1,
* key2: value2,
* })
*
* // Example with config merging:
* const config = mergeShallow(
* defaultConfig,
* userConfig,
* { debug: args.debug, base: args.base }
* )
* // undefined values in the last object won't override earlier values
* ```
*/
export const spreadShallow = <T extends object>(...objects: (T | undefined)[]): T => {
const result = {} as T
for (const obj of objects) {
if (obj === undefined) continue
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (value !== undefined) {
result[key] = value
}
}
}
}
return result
}