drizzle-team / drizzle-team/drizzle-orm
[FEATURE]: eslint-plugin-drizzle rule to ban `Promise.all` over `tx.*` queries
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
## Problem
`db.transaction(async (tx) => ...)` pins ONE `pg.Client` per transaction (node-postgres driver). Concurrent queries on a single client serialize on the same wire socket. So:
```ts
await db.transaction(async (tx) => {
// Anti-pattern — looks parallel, isn't:
const [a, b, c] = await Promise.all([
tx.select().from(usersTable),
tx.select().from(postsTable),
tx.select().from(commentsTable),
]);
});
```
…does NOT buy parallelism. It buys:
1. The `Calling client.query() when the client is already executing a query is deprecated` warning from node-postgres on every concurrent call.
2. A race where whichever query loses queue position surfaces as a random `Failed query: select ... from ` failure — different table every run because queue order isn't deterministic.
This pattern is easy to write reflexively if you're used to Promise.all'ing pool queries (which IS correct — each gets its own pool connection). The TypeScript types don't distinguish `Tx` from `Db` strongly enough to nudge programmers away.
## Why this should be a rule in `eslint-plugin-drizzle`
- Generalizes to every Drizzle + node-postgres user, no project-specific assumption beyond the convention of naming the transaction parameter `tx`.
- Bug class actively ships: we hit it in production with a ~30% failure rate on session-setup reads under load (random table failing each time).
- Type-level prevention isn't feasible without making `db.transaction`'s parameter type so distinct that pool-vs-tx becomes a different SDK shape — much bigger change. A lint rule is the right layer.
## Proposed implementation
We shipped a working version under `common/eslint/rules/drizzle/no-parallel-tx-queries.js` in our own project. The rule:
- Detects `Promise.all(...)` and `Promise.allSettled(...)` where the array elements either:
- Root at a call chain on `tx.*` (covers `tx.select().from(...).where(...)` direct chains).
- Are call expressions that pass `tx` as an argument (covers `Promise.all([fetchA(tx), fetchB(tx)])` helper-funnel pattern).
- Receiver name `tx` is configurable via rule options (`receiverNames: ["tx"]` default).
- Identifier-name match (not type-based) — fast, matches the convention `eslint-plugin-drizzle` already uses for its other rules.
Implementation (BSD-style, freely transferable):
https://github.com/hesoyam-zip/common/blob/main/eslint/rules/drizzle/no-parallel-tx-queries.js
Happy to open a PR against this repo if there's interest in landing it. The rule file is self-contained and ~120 lines including JSDoc.
## Originated from
ink-and-quill production bug, captured at https://github.com/hesoyam-zip/ink-and-quill/issues/194 — 30% session-setup failure rate from this pattern on `Promise.all([tx.select()..., tx.select()...])` inside `db.transaction()`.
## Related
- Issue [#3908](https://github.com/drizzle-team/drizzle-orm/issues/3908) (`Unhandled error event on pg.Client crashes process`) is the same client-lifetime surface — related but distinct.
- Existing rules in `eslint-plugin-drizzle`: `enforce-delete-with-where`, `enforce-update-with-where`. Same shape (ban-easy-foot-gun-via-AST-selector), same scope (Drizzle-specific patterns the types can't catch).
Contributor guide
Assessment
This issue has not been assessed yet.