yatta/batcher
DataLoader batching, so fifty concurrent calls are one query.
7 exported symbols and 13 members, read from src/types/batcher.ts.
Construct
batchBy
functionA loader over rows that need a key to join on.
For when the batch function returns rows in whatever order the database gave them rather than one per requested key — a join, an aggregation, anything with a `WHERE id IN (...)`.
batchBy<K, V>(batch: (keys: readonly K[]) => Promise<readonly V[]>, keyOf: (value: V) => K, options?: LoaderOptions): DataLoader<K, V>hasLoaderScope
functionWhether this code is inside a loader scope.
hasLoaderScope(): booleanloaderFor
functionA loader scoped to the current request.
The same name returns the same loader within one scope, so two components asking for the same key share one query and one cached value. Outside a scope a loader is created per call and behaves correctly but shares nothing — which is right for a script and means a server route must remember to wrap itself.
loaderFor<K, V>(name: string, batch: BatchFn<K, V>, options?: LoaderOptions): DataLoader<K, V>withLoaders
functionRuns `fn` with a fresh set of loaders, discarded afterwards.
This is what makes batching safe on a server. A loader cached per user would be a cache of one user's data inside another user's request; cached on the app it would be shared by every concurrent request. Scoped to the call, it lives exactly as long as the work does.
withLoaders<T>(fn: () => T): T// app/api/[[...path]]/route.tsexport const GET = withLoaders((req) => handle(req));DataLoader
classBatches and caches keys into single calls to `batch`.
```ts const loader = new DataLoader(async (ids: readonly string[]) => { const rows = await db.users.findMany({ where: { id: { in: [...ids] } } }); return new Map(rows.map((row) => [row.id, row])); }); // Three concurrent calls: one query, not three. const [a, b, c] = await Promise.all(ids.map((id) => loader.load(id))); ```
class DataLoaderconst loader = new DataLoader(async (ids: readonly string[]) => { const rows = await db.users.findMany({ where: { id: { in: [...ids] } } }); return new Map(rows.map((row) => [row.id, row]));}); // Three concurrent calls: one query, not three.const [a, b, c] = await Promise.all(ids.map((id) => loader.load(id)));10 members
cachepropertycache:queuepropertyqueue: K[]queuedpropertyqueued:timerpropertytimer: ReturnType<typeof setTimeout> | undefinedloadload(key: K): Promise<V>Loads one key.
Two calls for the same key in the same window share one request — the second gets the first's promise rather than queueing a duplicate key.
scheduleschedule(): voidResolves on the next tick. Batching is not useful for a single call.
flushflush(): Promise<void>Runs the batch. Public so a caller can force it at a known boundary.
settlesettle(key: K, value: V | undefined, error: Error | undefined): voidclearclear(key?: K): voidEmpties the cache. The batch function is unchanged.
hashas(key: K): booleanWhether a key already has a settled or in-flight value.
Types
LoaderOptions
interfaceinterface LoaderOptions3 members
cachepropertycache?: booleanWhether to cache results per key.
On by default. Off for anything that changes often enough that a stale value is worse than a second query — a stock price, a queue depth.
maxBatchMspropertymaxBatchMs?: numberWindow in which calls are collected, in milliseconds.
Defaults to 0, which collects everything queued in the current tick and flushes on the microtask. Raising it batches a burst across several ticks at the cost of adding that much latency to the first call in the window.
onMissingpropertyonMissing?: (key: unknown) => ErrorReported when `batch` resolves for fewer keys than it was given.
The default throws, because a missing key means the caller's `load()` rejects and the failure surfaces far from the batch that lost it.
BatchFn
typeFetches many keys at once. Receives the keys collected in one tick.
type BatchFn<K, V> = (keys: readonly K[]) => Promise<ReadonlyMap<K, V>> | Promise<V[]>AuthConfig or JobPayload — declaring your schema once is enough for the rest to follow. See Typed keys.