import type { AsyncFunc } from '../types.ts'; import { markWrapped } from '../_helpers.ts'; import { serializer } from '../misc/index.ts'; import { attemptSync } from './attempt.ts'; import { SingleFlight } from './singleflight.ts'; /** * Configuration options for in-flight promise deduplication. */ export interface InflightOptions { /** * Optional key generation function. If omitted, uses a built-in serializer that handles: * - Primitives (string, number, boolean, null, undefined, bigint) * - Objects (plain objects with sorted keys for consistency) * - Arrays (preserves order) * - Dates (serialized as timestamps) * - RegExp (serialized as source/flags) * - Maps and Sets (serialized with sorted entries) * - Circular references (detected and marked) * * Unsupported types (serialized but may cause collisions): * - Functions: Serialized as "[Function]" - all functions collide * - Symbols: Serialized by description - symbols with same description collide * - WeakMap/WeakSet: Serialized as "[WeakMap/WeakSet]" - all instances collide * - Errors: Serialized by name and message only * * For identity-based deduplication, functions as arguments, or performance-critical * hot paths, provide a custom generateKey that extracts only discriminating fields * (e.g., `(id, _opts) => id`). */ generateKey?: (...args: Args) => Key; /** * Pre-serialization check. Return false to bypass deduplication * and execute the producer directly. * * This is called BEFORE key generation/serialization. Use this for conditional * deduplication based on request context or parameters. * * @example * ```typescript * const fetcher = withInflightDedup(fetchData, { * shouldDedupe: (url, opts) => !opts?.bustCache * }); * ``` */ shouldDedupe?: (...args: Args) => boolean; /** * Called when first caller starts producer for this key (before producer executes). * Must be synchronous. Errors are silently caught. */ onStart?: (key: Key) => void; /** * Called when subsequent caller joins existing in-flight promise. * Must be synchronous. Errors are silently caught. */ onJoin?: (key: Key) => void; /** * Called when shared promise resolves successfully. * Must be synchronous. Errors are silently caught. */ onResolve?: (key: Key, value: Value) => void; /** * Called when shared promise rejects. * Must be synchronous. Errors are silently caught. */ onReject?: (key: Key, error: unknown) => void; } /** * Wrap an async function so concurrent calls with the same key share the same in-flight promise. * * **What it does:** * - Deduplicates concurrent async calls with identical arguments (or custom key) * - First call starts the producer; concurrent calls join the same promise * - No caching after settlement - each new request starts fresh * - Automatic cleanup on resolve/reject * * **What it doesn't do:** * - No memoization/TTL/stale-while-revalidate (use `memoize` for that) * - No AbortController handling (callers manage their own cancellation) * - No request queuing (all concurrent calls share the same promise) * * **When to use:** * - Deduplicating database queries that might be triggered multiple times * - Preventing duplicate API calls during component re-renders * - Sharing expensive computations across concurrent callers * - Hot paths where multiple parts of code request the same resource * * **Performance notes:** * - Default key generation: O(n) in argument structure size * - For hot paths or complex args, use custom `keyFn` to extract only discriminating fields * - For functions as arguments, MUST use custom `keyFn` (functions always collide in default serializer) * * @template Args - Function argument types * @template Value - Function return type (unwrapped from Promise) * @template Key - Key type (defaults to string) * @param producer - Async function to wrap * @param opts - Optional configuration (keyFn, hooks) * @returns Wrapped function with in-flight deduplication * * @example * ```typescript * // Basic usage - database query deduplication * const fetchUser = async (id: string) => db.users.findById(id); * const getUser = withInflightDedup(fetchUser); * * // Three concurrent calls → one database query * const [user1, user2, user3] = await Promise.all([ * getUser("42"), * getUser("42"), * getUser("42") * ]); * ``` * * @example * ```typescript * // With hooks for observability * const search = async (q: string) => api.search(q); * const dedupedSearch = withInflightDedup(search, { * onStart: (k) => logger.debug("search started", k), * onJoin: (k) => logger.debug("joined existing search", k), * onResolve: (k) => logger.debug("search completed", k), * onReject: (k, e) => logger.error("search failed", k, e), * }); * ``` * * @example * ```typescript * // Custom key - ignore volatile parameters * const fetchData = async (id: string, opts: { timestamp?: number }) => { }; * const dedupedFetch = withInflightDedup(fetchData, { * generateKey: (id) => id // Only dedupe by id, ignore opts * }); * ``` * * @example * ```typescript * // Hot path optimization - extract discriminating field only * const getProfile = async (req: { userId: string; meta: LargeObject }) => { }; * const dedupedGetProfile = withInflightDedup(getProfile, { * generateKey: (req) => req.userId // Avoid serializing large meta object * }); * ``` * * @example * ```typescript * // Functions as arguments - MUST use custom generateKey * const fetchWithTransform = async (url: string, transform: (data: any) => any) => { }; * const dedupedFetch = withInflightDedup(fetchWithTransform, { * generateKey: (url) => url // Only dedupe by URL, ignore transform function * }); * ``` * * @example * ```typescript * // Conditional deduplication - bypass for cache-busting requests * const fetchData = async (url: string, opts?: { bustCache?: boolean }) => { }; * const smartFetch = withInflightDedup(fetchData, { * shouldDedupe: (url, opts) => !opts?.bustCache * }); * * // These two calls are deduped * await Promise.all([ * smartFetch('/api/data'), * smartFetch('/api/data') * ]); * * // This call bypasses deduplication and executes directly * await smartFetch('/api/data', { bustCache: true }); * ``` */ export function withInflightDedup( producer: AsyncFunc, opts?: InflightOptions ): AsyncFunc { const flight = new SingleFlight({ // Disable cleanup timer since we're only using inflight, not cache cleanupInterval: 0 }); const wrapped = async (...args: Args): Promise => { if (opts?.shouldDedupe) { const [shouldDedupeResult, shouldDedupeError] = attemptSync(() => opts.shouldDedupe!(...args)); if (!shouldDedupeError && !shouldDedupeResult) { return producer(...args); } } const key = opts?.generateKey ? String(opts.generateKey(...args)) : serializer(args); const existing = flight.getInflight(key); if (existing) { flight.joinInflight(key); safeHook(opts?.onJoin, opts?.generateKey ? opts.generateKey(...args) : key as Key); return existing.promise; } safeHook(opts?.onStart, opts?.generateKey ? opts.generateKey(...args) : key as Key); // Store cleanup function in closure for use in finally let cleanup: (() => void) | undefined; const promise = producer(...args) .then(value => { safeHook(opts?.onResolve, opts?.generateKey ? opts.generateKey(...args) : key as Key, value); return value; }) .catch(error => { safeHook(opts?.onReject, opts?.generateKey ? opts.generateKey(...args) : key as Key, error); throw error; }) .finally(() => { cleanup?.(); }); cleanup = flight.trackInflight(key, promise); return promise; }; markWrapped(producer, wrapped, 'inflight'); return wrapped; } /** * Helper function to safely execute hooks with error suppression. * Hooks must not break the deduplication logic. * * @param hook - Optional hook function to execute * @param args - Arguments to pass to the hook */ function safeHook(hook: Function | undefined, ...args: any[]): void { if (!hook) return; attemptSync(() => hook(...args)); }