drizzle-team / drizzle-team/drizzle-orm

[FEATURE]: eslint-plugin-drizzle rule to ban `Promise.all` over `tx.*` queries

Open
#5,814 0 comments 1 reaction 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.