Reference solution
The findings below are ordered by priority.
Findings
1. Process-wide lifetime and its cache cross viewer boundaries
Location: src/project-batcher.ts:13-29,58-63
getProjectBatcher retains the first instance for the process, including its database, pending queue, and ID-only cache. Later requests can reuse a promise resolved for another tenant or viewer, and tenant-local IDs collide in that cache, creating a cross-request data leak.
Construct one loader in each GraphQL request context, bind it to that request's single viewer and tenant, and keep its cache within that lifetime.
2. Any project permission authorizes every loaded row
Location: src/project-batcher.ts:50-54
The check asks only whether the viewer's allowed list is non-empty. A viewer allowed to see one project can receive a different project returned in the same or later batch.
Check allowedProjectIds for the specific requested ID before returning its matching row.
3. Database order is mistaken for input-key order
Location: src/project-batcher.ts:44-54
The implementation pairs pending[index] with rows[index], but database ordering is unspecified and missing IDs change the result shape. Resolvers can receive the wrong project's data even before authorization is considered.
Index rows by their tenant-qualified ID and map every original pending entry back through that index in input order.
4. A database rejection strands every pending promise
Location: src/project-batcher.ts:39-55
#flush has no rejection handling, and callers intentionally discard the flush promise. If the query rejects, none of the stored reject callbacks run, cached promises remain pending forever, and the failure becomes unhandled.
Catch the batch failure, reject every captured entry, evict affected cache entries, and leave scheduling state ready for later loads.
5. A single database call exceeds the distinct-key cap
Location: src/project-batcher.ts:41-49
The flush sends every distinct pending ID in one query without the 100-ID cap. A wide GraphQL request can therefore create an oversized database parameter list instead of continuing excess keys in later batches.
Issue chunks of at most 100 distinct IDs and resolve each pending load from the indexed fetched values. The existing per-loader promise cache already lets repeated loads for one ID share that value.
Reasonable non-findings
- Scheduling a microtask is a reasonable way to collect field loads from the same turn.
- Returning null for both missing and unauthorized projects matches the non-enumeration contract.
- Querying a batch of IDs in one call is the intended N+1 optimization once tenant scope and alignment are correct.