Ultimate

N+1 detection

A statement issued once per row of a set — one findById per member of a page where one in query would answer, one insert per row where one insertAll would answer — is the loop this detector exists to catch. As of 2026-08, dev-only: it costs a production process nothing.

The two codes

CodeWhat it means
X_N_PLUS_ONE_QUERYa read repeated once per row — members.findById called fifty times where one in statement would do
X_N_PLUS_ONE_WRITEa write repeated once per row — one insert, update or delete per row of a set, where one statement writes all of them

Both codes are declared and composed by @ultimat3/entity (nPlusOne() in packages/entity/src/errors.ts); nothing else builds the code/cause/fix text, so x dev's findings, the timeline, the overlay and a failing test all read the identical message.

Who raises it is a separate question, and the answer differs by surface:

SurfaceWhat happens at the threshold
testStatements (@ultimat3/testing)records nPlusOne(…) per shape and throws it at disposal, so the test fails — a bus subscriber's throw is contained, never the read's failure
x dev (packages/cli/src/dev-n-plus-one.ts)records a verdict and logs one warning per request per code — it never throws, because a reporting-only observer that threw would break the app it is watching
/_x and the browser overlayrender the recorded verdict; they raise nothing

packages/cli/src/statement-loop.ts is what keeps them identical: it calls @ultimat3/entity's nPlusOne and reads code/cause/fix off the resulting error rather than restating any of it. Full row, verbatim: Error codes → Entity and database.

Before / after

// naive: one statement per row
const authors = [];
for (const post of posts) {
  authors.push(await db.members.findById(post.authorId));
}
// one statement for the page
const page = await db.posts.where({ orgId }).preload('author').page();

preload('author') is derived from the references() column the entity already declares — there is no separate list of valid relation names to keep in sync, so a name that doesn't resolve to one is X_PRELOAD_UNKNOWN_RELATION, not a silent miss. Detail on preload's own semantics — scoping, concurrency, projection — is Batching and preloading; the row-by-row shape it collapses is also covered in Entities and migrations → Point lookups batch themselves.

The write side's fix is the equivalent bulk call: db.<entity>.insertAll(rows), db.<entity>.updateWhere(filter, patch), db.<entity>.deleteWhere(filter) — named directly in the error's fix: line, keyed off which of the three the loop was.

How a finding reaches you: four surfaces, one verdict

A statement shape that crosses the threshold inside one request is reported everywhere at once — never one surface first and the others eventually:

SurfaceWhere
x dev's findingsprinted in the terminal, and in --json
/_x/timelinethe nPlusOne field alongside repeatedSql, the admin dev panel
the browser dev overlayrendered next to the error as a notice, same code/cause/fix
the loggerone logger.warn line, per request, per code

All four read the same StatementLoopFact (packages/cli/src/statement-loop.ts) built from one RepeatedStatement, so a report can never say something different on one surface than another. The log line is intentionally coarser than the findings list: a request that loops three different shapes of read still gets one X_N_PLUS_ONE_QUERY warning, because a log is read to learn that this request looped — the three shapes themselves are what the other three surfaces are for.

The threshold is 5 statements of one shape (N_PLUS_ONE_THRESHOLD, packages/entity/src/n-plus-one.ts), and it is the one thing the two detectors genuinely share: x dev's ledger and testStatements both import that constant, so neither can be tuned without the other moving with it.

Everything else about them differs, deliberately:

x dev's ledgertestStatements
Counting windowone Ctx — a WeakMap keyed on the request, which dies with itfixture creation → disposal, including statements sent outside any request
At the thresholdrecords a verdict, logs once per request per coderecords a verdict, once per shape; disposal throws the first
expectedQueryLoop statementsnot counted at all — this ledger is the verdictcounted by count() and shapes(), excluded only from the unexpected tally the verdict reads

So a shape can be visible in a test's shapes() and absent from x dev's verdict list for the same run, and neither is wrong: one is measuring, the other is judging.

The seam it rides on

None of this reaches into the driver. Every statement is a db.statement event on core's instrumentation bus, emitted by traceStatement() (packages/db/src/statement-span.ts), which both statement funnels (runOn in statement-funnel.ts, statement() in pglite.ts) call after every statement settles, success or failure; the memory driver emits one per repository operation. subscribe('db.statement', handler) hears it, beside every other subscriber. The emitter guards first — subscribed('db.statement') — and builds a DbStatementEvent only when something listens: one map read with no subscriber, which is what makes this free in production (axiom 6). serve.ts subscribes none. The statement's db.<verb> span opens either way. As of 2026-11 (v31).

@ultimat3/entity's postgresRepo (packages/entity/src/pg-driver.ts) is what attaches an entity/op pair to each statement, via withStatementAttribution(entity.$name, op, send). It is the last frame that still knows both a repository is calling findById on members before the SQL is compiled — below it there's only text, which is why hand-written SQL and job-queue statements show up unattributed rather than misattributed. The queue's own per-step write — one insert … on conflict into x_job_steps per step.run — arrives expected: steps.ts declares it one-per-step by design, so a job of five steps is not a verdict against the framework.

Where it installs: dev only

x dev — startDev() in packages/cli/src/cmd-dev.ts — builds a StatementLedger (createStatementLedger(), packages/cli/src/dev-n-plus-one.ts) and subscribes it at boot (subscribe('db.statement', …) in packages/cli/src/dev-boot.ts), before the app's own modules load, alongside the trace recorder. The ledger keys its per-shape counts on the request's Ctx object in a WeakMap, so state dies with the request it belongs to and nothing needs sweeping. packages/cli/src/serve.ts — the production entry point — never subscribes it. There is no flag to disable this in dev and no flag to enable it in prod; the wiring itself is the switch.

Silencing a genuine one-per-row loop

Some loops really are optimal one-statement-per-row — a handful of independent indexed lookups is sometimes cheaper than one unindexed OR across all of them. The one way to say so is expectedQueryLoop(reason, fn) (packages/db/src/expected-loop.ts):

// one indexed lookup per search field beats one unindexed OR across all of them
return expectedQueryLoop('search runs one indexed lookup per field', async () => {
  for (const field of fields) hits.push(...(await repo.list({ where: [eq(field, term)] })));
  return hits;
});

reason is required and must be non-blank, because an exemption with no argument is a pragma with extra steps and the whole point is that the argument is written down next to the loop it defends. A blank one throws X_INVARIANT at the call, carrying its own repair:

CodeX_INVARIANT (borrowed from @ultimat3/core's assert, not declared by db)
CauseexpectedQueryLoop() was given a blank reason, so the loop it silences carries no argument
fix:pass why the loop is optimal: expectedQueryLoop('one indexed lookup per field', fn)
x errors explain X_INVARIANT --json

The scope rides an AsyncLocalStorage, so it survives every await inside fn, at any depth, and two loops running concurrently never read each other's reason; nesting keeps the innermost one.

What it suppresses is the verdict, not the statement: the SQL still sends, the observer still sees it, the trace still opens a span. A detector counting repeats is told the author already reasoned about this one and stays quiet; anything that only measures — the timeline, a span, a metric — keeps showing the loop exactly as before. The framework's own deliberate loops declare themselves the same way: migrate()/rollback() (packages/db/src/migrate.ts) apply and reverse one migration per transaction, one statement at a time, inside a scope naming why.

Failing the test instead of warning in a terminal nobody is watching

x dev warns; CI is not attended. @ultimat3/testing's statements fixture (testStatements, packages/testing/src/fixture-statements.ts) installs the identical detector in strict mode for the length of one test — its verdict fails the test at teardown — destructuring statements is the whole opt-in, there is no strict: true flag to forget:

test('the feed reads its authors once', async ({ statements }) => {
  await loadFeed();
  // X_N_PLUS_ONE_QUERY: members.findById ran 5 times in one request — one read per row
  // fix: db.posts.preload('author')   # one statement for the whole page
  expect(statements.count('posts.findMany')).toBe(1);
});

It counts from the moment the fixture is built, not from a request boundary — a plain unit test calling posts.findById(id) in a loop has no Ctx anywhere, and that is exactly the loop this fixture exists to catch, unlike the dev ledger which only counts inside a request. The verdict still only counts unexpected statements — an expectedQueryLoop scope suppresses the verdict the same way it suppresses the dev warning — while count() and shapes() report every statement sent, expected ones included, because a test asserting "this page issues two statements" must not see a different number depending on who declared what. One verdict per shape, recorded at the statement that crosses the threshold and raised when the fixture is disposed — the bus contains a subscriber's throw, so an observer can never fail the read it observes. The error names the shape and its count; verdicts() reads them before teardown, and shapes() reports the whole loop.

Why this costs production nothing

Three separate facts add up to zero production cost, not one flag: the db.statement emitter is guard-first and allocates nothing with no subscriber; withStatementAttribution is the same shape, guarding before it even opens its AsyncLocalStorage scope; and serve.ts — the only production entry point — never subscribes the ledger. Turning the detector on is entirely a fact about which binary is running: x dev wires it; apps/web/server.ts under any ROLE never does. There is no x serve command — the production entry is the scaffolded server.ts, run directly or through x build's artifact.

See also

Edit this page on GitHubThis page as markdown