Skip to content

fix(postgres): reject work that caught a failed statement instead of committing nothing - #32

Merged
kamilmysliwiec merged 3 commits into
masterfrom
fix/pg-transaction-commit
Oct 19, 2026
Merged

kamilmysliwiec merged 3 commits into
masterfrom
fix/pg-transaction-commit

Conversation

@kamilmysliwiec

Copy link
Copy Markdown
Member

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Other... Please describe:

What is the current behavior?

Issue Number: N/A

Three issues the review of #31 found in the PostgreSQL executors' transactions, none introduced by it:

  • PostgreSQL ends a transaction at its first failed statement and answers its COMMIT with a ROLLBACK, no error. A store's work that catches a statement's error (an insert-if-absent catching 23505, a "skip if locked" catching 55P03) and resolves makes transaction() resolve as if committed, while every write in it is gone. This is true of every PostgreSQL executor: fromPg (pool and client), fromDrizzle, fromKysely, fromTypeOrm, fromPrisma. The same work is fine on MySQL, which doesn't end a transaction at a failed statement.
  • fromPg() checks the isolation level only after pool.connect(), inside BEGIN's try. An unknown level waits for a connection, can surface as a pool timeout instead of the TypeError, and has the pool destroy the healthy connection it took. Every other executor checks it first.
  • fromPg() marks every failed COMMIT broken, so the pool destroys connections PostgreSQL left idle and usable after an ERROR at commit (a serialization failure under SERIALIZABLE, a deferred constraint): a retry loop reconnects on each one.

What is the new behavior?

  • Each PostgreSQL executor runs the work through runTransactionWork(). When the work's last statement failed, one statement more tells whether PostgreSQL ended the transaction (a statement the driver failed before sending leaves it as it was). If it did, the transaction rolls back and rejects, with the statement's error as the cause. Work that recovered with ROLLBACK TO SAVEPOINT commits as before. The usual path costs nothing.
  • The contract's transactions case (@nestjs/store-kit/testing) now covers both: work that swallowed a failed statement must reject and leave no rows, and work that rolled back to a savepoint must commit. SqlExecutor.transaction()'s doc says so.
  • fromPg() builds BEGIN before taking a connection (PgClientExecutor.transaction() is async now, so the refusal is still a rejection). A failed COMMIT keeps its connection when one more statement shows it's idle.

Checked locally against PGlite, as the sandbox here can't start PostgreSQL. The contract ran through fromDrizzle(PGlite) as in CI. The other executors ran through node-postgres bridged in memory to PGlite: the full contract for fromPg (pool and client), and the transactions case for Kysely, TypeORM and Prisma. Every executor fails the new case without the change and passes with it. Also checked: a deferred-constraint COMMIT failure keeps the pool's connection, and an unknown level rejects at once while the pool is exhausted. Both fail on master. CI's PostgreSQL run is the first on a real server.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

transaction() now rejects work it used to resolve, but that work committed nothing, so a caller only loses a false success. A third-party executor that runs the published contract gets the new requirement in its transactions case.

This conflicts with #31 in PgPoolExecutor.transaction() and runTransaction() (#31 checks the connection out through pg-pool's callback and wraps query). Whichever merges second needs a rebase: runTransaction() takes the BEGIN text, and the wrapper #31 passes it has no getTransactionStatus(), which the failed-COMMIT check treats as idle once its statement succeeds.

…committing nothing

PostgreSQL ends a transaction at its first failed statement and answers its
COMMIT with a ROLLBACK, no error. So a store's work that caught a statement's
error (an insert-if-absent catching 23505, say) and resolved made
transaction() resolve as if committed, while every write in it was gone; on
every PostgreSQL executor. Each now runs the work through
runTransactionWork(): when the work's last statement failed, one statement
more tells whether PostgreSQL ended the transaction, and the transaction then
rolls back and rejects, with the statement's error as the cause. Work that
recovered with ROLLBACK TO SAVEPOINT commits as before, and the normal path
costs nothing. The contract's transactions case covers both, for every
executor that runs it.

fromPg(): an unknown isolation level is refused before the transaction takes
a connection (it waited for one, and the pool then destroyed it), and a
COMMIT that fails with an ERROR (a serialization failure, a deferred
constraint) keeps a connection that one more statement shows is idle,
instead of reconnecting for every such failure.
…t it fails an executor committing caught failures
…commit

# Conflicts:
#	lib/postgres/executors/pg.executor.ts
@kamilmysliwiec
kamilmysliwiec merged commit 1f8112a into master Oct 19, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant