tursodatabase/turso · error · TypeError
The transaction has already completed
Error message
The transaction has already completed
What it means
Thrown by the promise-based Transaction wrapper (common/promise.ts) when any transaction-scoped method (prepare, run, batch, ...) is called after the transaction has committed or rolled back. The wrapper calls finish() on the Transaction handle once COMMIT/ROLLBACK executes (or the transaction callback returns), which sets active = false; assertActive() then rejects every further use. It exists because statements prepared inside a transaction hold the connection's execution lock, so using them after completion would interleave with other work on the shared connection.
Solutions
- Move every use of tx (prepare/run/batch) inside the db.transaction callback so it cannot outlive COMMIT/ROLLBACK.
- If you hold the handle, check the public tx.open getter before each use and skip/reopen if it is false.
- Do not cache transaction-scoped statements; prepare them from db (not tx) if you need them after the transaction.
- Audit callbacks for missing await: any floating promise that touches tx after the callback resolves will hit this.
- Wrap tx usage in try-catch on TypeError 'The transaction has already completed' to degrade gracefully in legacy code paths.
Example fix
// before
let savedTx;
await db.transaction(async (tx) => {
savedTx = tx;
await tx.run("INSERT INTO t VALUES (1)");
});
await savedTx.run("INSERT INTO t VALUES (2)"); // throws: already completed
// after
await db.transaction(async (tx) => {
await tx.run("INSERT INTO t VALUES (1)");
await tx.run("INSERT INTO t VALUES (2)"); // all uses inside the callback
}); Defensive patterns
Strategy: validation
Validate before calling
if (!tx.open) { /* transaction finished; reopen or skip */ }
await tx.run(sql); Type guard
const isTxOpen = (tx: { open: boolean }): boolean => tx.open; Try / catch
try {
await tx.run(sql);
} catch (e) {
if (e instanceof TypeError && e.message === "The transaction has already completed") {
// tx finished: reopen a new transaction instead of reusing the handle
} else throw e;
} Prevention
- Confine every tx usage to the db.transaction callback; never leak the handle outward.
- Await all promises that touch tx inside the callback so none resume after COMMIT/ROLLBACK.
- Do not cache transaction-scoped statements; re-prepare per transaction.
- Check tx.open before use when a handle must live longer.
When it happens
Trigger: Storing the tx handle passed to db.transaction(async (tx) => ...) in an outer variable and calling tx.run/tx.prepare/tx.batch after the callback has returned; calling tx methods after explicitly invoking commit/rollback inside the callback; awaiting something else between the wrapper's COMMIT and a later tx.prepare call; reusing a Transaction object captured in a closure that outlives the transaction body.
Common situations: Caching prepared transaction statements for reuse across transactions; fire-and-forget promises (not awaited inside the callback) that resume after the transaction finished; retry wrappers that replay operations on a stale tx handle; refactoring sync-style code to the async transaction API while keeping the old handle.
Related errors
- cursor id is None
- No active remote transaction
- BEGIN must start a transaction
- Cannot change isolation level while transaction is active.
- Cannot commit in autocommit mode.
AI-assisted analysis of tursodatabase/turso@c1e5928725 (2026-08-20).
Data as JSON: /api/errors/298052d05e9f9747.
Report an issue: GitHub.
Appendix: source
Thrown at bindings/javascript/packages/common/promise.ts:977
constructor(db: NativeDatabase, ioStep: () => Promise<void>) {
this.db = db;
this.ioStep = ioStep;
const lock = new AsyncLock();
this.gate = {
acquire: async () => { this.assertActive(); await lock.acquire(); },
release: () => { lock.release(); },
};
}
/** Whether the transaction is still open (COMMIT/ROLLBACK not executed yet). */
get open(): boolean {
return this.active;
}
private assertActive() {
if (!this.active) {
throw new TypeError("The transaction has already completed");
}
}
/** @internal Marks the transaction completed; called by the wrapper. */
finish() {
this.active = false;
}
/**
* Executes the given SQL string inside the transaction.
* Unlike prepared statements, this can execute strings that contain multiple SQL statements.
*
* @param {string} sql - The string containing SQL statements to execute
*/
async exec(sql: string, queryOptions?: QueryOptions) {
await this.gate.acquire();
try {
const exec = this.db.executor(sql, queryOptions);View on GitHub (pinned to c1e5928725)