A retained transaction handle keeps writing after the transaction has ended, with no error. tx.query, tx.exec, and tx.rollback all reject once the transaction is over ("Transaction is closed"), but tx.sql does not — it runs straight on the live auto-commit connection. Separately, when a transaction callback throws, the handle is never marked closed at all, so tx.query, tx.exec, and tx.sql all keep working on the auto-commit connection afterward. A write a caller believes was rolled back is instead persisted.
Environment:
@electric-sql/pglite 0.5.4 (latest on npm), also present on current main
- Node v22.23.1, Linux
Reproduction:
npm i @electric-sql/pglite@0.5.4
node repro.mjs
repro.mjs
import { PGlite } from '@electric-sql/pglite'
const db = await PGlite.create()
await db.exec(`CREATE TABLE t (id int primary key, v text);`)
// (1) tx.sql after an explicit rollback
let h1
try {
await db.transaction(async (tx) => { h1 = tx; await tx.rollback() })
} catch {}
try { await h1.sql`INSERT INTO t VALUES (99, 'after-rollback')` } catch {}
// (2) after the callback throws, the handle is never closed
let h2
try {
await db.transaction(async (tx) => { h2 = tx; throw new Error('boom') })
} catch {}
try { await h2.query(`INSERT INTO t VALUES (97, 'q')`) } catch {}
try { await h2.exec(`INSERT INTO t VALUES (96, 'e')`) } catch {}
try { await h2.sql`INSERT INTO t VALUES (98, 's')` } catch {}
const rows = await db.query(`SELECT id FROM t ORDER BY id`)
console.log('rows persisted after two ended transactions:', rows.rows.map((r) => r.id), '(expected [])')
await db.close()
Observed output:
rows persisted after two ended transactions: [ 96, 97, 98, 99 ] (expected [])
Expected: once a transaction has committed or rolled back, its handle should reject any further use, as tx.query/tx.exec/tx.rollback already do and as the transaction docs describe. https://pglite.dev/docs/api#transaction
This appears to come from two spots in packages/pglite/src/base.ts: tx.sql (around line 476) is the one handle method that does not call checkClosed() before running, and the callback catch/rollback path (around lines 520-523) runs ROLLBACK but never sets closed = true the way the commit path does. Calling checkClosed() in tx.sql, and setting closed = true in the catch path, would close both holes.
A retained transaction handle keeps writing after the transaction has ended, with no error.
tx.query,tx.exec, andtx.rollbackall reject once the transaction is over ("Transaction is closed"), buttx.sqldoes not — it runs straight on the live auto-commit connection. Separately, when a transaction callback throws, the handle is never marked closed at all, sotx.query,tx.exec, andtx.sqlall keep working on the auto-commit connection afterward. A write a caller believes was rolled back is instead persisted.Environment:
@electric-sql/pglite0.5.4 (latest on npm), also present on currentmainReproduction:
repro.mjs
Observed output:
Expected: once a transaction has committed or rolled back, its handle should reject any further use, as
tx.query/tx.exec/tx.rollbackalready do and as the transaction docs describe. https://pglite.dev/docs/api#transactionThis appears to come from two spots in
packages/pglite/src/base.ts:tx.sql(around line 476) is the one handle method that does not callcheckClosed()before running, and the callback catch/rollback path (around lines 520-523) runsROLLBACKbut never setsclosed = truethe way the commit path does. CallingcheckClosed()intx.sql, and settingclosed = truein the catch path, would close both holes.