/* eslint-disable @typescript-eslint/no-non-null-assertion */ type BatchItem = { aborted: boolean; key: TKey; resolve: ((value: TValue) => void) | null; reject: ((error: Error) => void) | null; batch: Batch | null; }; type Batch = { items: BatchItem[]; }; export type BatchLoader = { validate: (keys: TKey[]) => boolean; fetch: (keys: TKey[]) => Promise[]>; }; /** * A function that should never be called unless we messed something up. */ const throwFatalError = () => { throw new Error( 'Something went wrong. Please submit an issue at https://github.com/trpc/trpc/issues/new', ); }; /** * Dataloader that's very inspired by https://github.com/graphql/dataloader * Less configuration, no caching, and allows you to cancel requests * When cancelling a single fetch the whole batch will be cancelled only when _all_ items are cancelled */ export function dataLoader( batchLoader: BatchLoader, ) { let pendingItems: BatchItem[] | null = null; let dispatchTimer: ReturnType | null = null; const destroyTimerAndPendingItems = () => { clearTimeout(dispatchTimer as any); dispatchTimer = null; pendingItems = null; }; /** * Iterate through the items and split them into groups based on the `batchLoader`'s validate function */ function groupItems(items: BatchItem[]) { const groupedItems: BatchItem[][] = [[]]; let index = 0; while (true) { const item = items[index]; if (!item) { // we're done break; } const lastGroup = groupedItems[groupedItems.length - 1]!; if (item.aborted) { // Item was aborted before it was dispatched item.reject?.(new Error('Aborted')); index++; continue; } const isValid = batchLoader.validate( lastGroup.concat(item).map((it) => it.key), ); if (isValid) { lastGroup.push(item); index++; continue; } if (lastGroup.length === 0) { item.reject?.(new Error('Input is too big for a single dispatch')); index++; continue; } // Create new group, next iteration will try to add the item to that groupedItems.push([]); } return groupedItems; } function dispatch() { const groupedItems = groupItems(pendingItems!); destroyTimerAndPendingItems(); // Create batches for each group of items for (const items of groupedItems) { if (!items.length) { continue; } const batch: Batch = { items, }; for (const item of items) { item.batch = batch; } const promise = batchLoader.fetch(batch.items.map((_item) => _item.key)); promise .then(async (result) => { await Promise.all( result.map(async (valueOrPromise, index) => { const item = batch.items[index]!; try { const value = await Promise.resolve(valueOrPromise); item.resolve?.(value); } catch (cause) { item.reject?.(cause as Error); } item.batch = null; item.reject = null; item.resolve = null; }), ); for (const item of batch.items) { item.reject?.(new Error('Missing result')); item.batch = null; } }) .catch((cause) => { for (const item of batch.items) { item.reject?.(cause); item.batch = null; } }); } } function load(key: TKey): Promise { const item: BatchItem = { aborted: false, key, batch: null, resolve: throwFatalError, reject: throwFatalError, }; const promise = new Promise((resolve, reject) => { item.reject = reject; item.resolve = resolve; pendingItems ??= []; pendingItems.push(item); }); dispatchTimer ??= setTimeout(dispatch); return promise; } return { load, }; }