Reproduction
On main 5f271d5 with Bun 1.4.1, an afterCommit callback that throws an error matching the retry classifier causes a successfully committed transaction to execute again.
Reproduced on disposable native SQLite, the SQLite wrapper, MySQL 8.4.5 and Postgres 16. A transaction inserts one synthetic row, with retries: 1. Its afterCommit callback throws new Error('database is locked: downstream callback'). Result: two committed rows, two callback executions and a rollback notification, even though both attempts committed.
Cause
opts.afterCommit() runs inside the same try/catch as runWith(). That catch handles database rollback/retry, but the driver has already committed when the callback runs. The error classifier therefore applies to the wrong phase. Async callbacks are also not awaited, despite the documented async example in docs/guide/transactions.md.
Fix and acceptance
- Move callback execution outside the transaction retry/rollback catch.
- Await synchronous or asynchronous
afterCommitcallbacks and propagate their original error without replaying writes or invoking rollback handlers. - Keep genuine failed transaction attempts retryable and preserve the callback's successful return value.
- Required cross-driver regression tests; no schema or migration changes.
This is separate from query-cache isolation (#1143). The failure is in transaction completion handling, independent of caching.