Skip to content

tx.sql on an ended transaction handle silently writes to the auto-commit connection #1049

Description

@viswa-abe

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions