A transfer function with manual BEGIN/COMMIT. An early-return path between them. The transaction never commits — silent rollback after the writes. Mesrai flagged it.
The vulnerable diff
// billing/transfer.ts
async function transfer(from: string, to: string, amount: number) {
const tx = await db.$transactionStart();
await debit(tx, from, amount);
await credit(tx, to, amount);
if (await shouldDelay(from, to)) {
return; // BUG: returns without commit — silent rollback
}
await audit(tx, from, to, amount);
await tx.commit();
}What is wrong
Manual transaction management requires explicit commit/rollback on every exit path. Early returns, thrown errors, and any code path that skips the commit cause the transaction to roll back when the connection is returned to the pool. The fix is to use a managed-transaction wrapper that handles commit/rollback automatically based on whether the callback throws — `db.$transaction(async tx => {...})` in Prisma, similar in other ORMs.
The attack
Symptom:
transfer(A, B, 100) called.
debit succeeds.
credit succeeds.
shouldDelay returns true.
function returns without commit.
Connection released → implicit rollback.
State: A still has the money, B never received. No error, no log.
Customer: "where's my transfer?" Hard to find because no exception, just missing data.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Logic] [Transaction] [critical]
Early return between BEGIN and COMMIT silently rolls back the
transaction.
Two fixes:
// Use a managed transaction wrapper (Prisma):
async function transfer(from, to, amount) {
if (await shouldDelay(from, to)) return;
await db.$transaction(async (tx) => {
await debit(tx, from, amount);
await credit(tx, to, amount);
await audit(tx, from, to, amount);
// commit auto on return; rollback auto on throw
});
}
// Or with manual BEGIN, always finalize:
const tx = await db.$transactionStart();
try {
if (await shouldDelay(from, to)) return;
await debit(tx, from, amount);
await credit(tx, to, amount);
await audit(tx, from, to, amount);
await tx.commit();
} catch (err) {
await tx.rollback();
throw err;
}
Move the conditional check OUTSIDE the transaction whenever possible.The fix
// billing/transfer.ts — fixed (managed transaction)
async function transfer(from: string, to: string, amount: number) {
if (await shouldDelay(from, to)) return;
await db.$transaction(async (tx) => {
await debit(tx, from, amount);
await credit(tx, to, amount);
await audit(tx, from, to, amount);
});
}Conditional check before the transaction starts. Inside, the wrapper handles commit on success and rollback on throw. No silent rollback possible.
Why human review missed it
Transaction-commit bugs are subtle because the writes look complete in the application code. Mesrai catches manual BEGIN/COMMIT patterns with return paths between them and recommends the managed-transaction form.
Related rules + further reading
Mesrai rule pack: logic/transaction-must-commit — flags manual BEGIN with non-throw return paths to non-commit.
Prisma docs: Interactive Transactions.
Common in legacy hand-rolled transaction code.
Takeaway
Managed transactions handle commit/rollback by callback exit. Manual transactions need finalize on every path. Mesrai catches the gap.