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

  1. Move every use of tx (prepare/run/batch) inside the db.transaction callback so it cannot outlive COMMIT/ROLLBACK.
  2. If you hold the handle, check the public tx.open getter before each use and skip/reopen if it is false.
  3. Do not cache transaction-scoped statements; prepare them from db (not tx) if you need them after the transaction.
  4. Audit callbacks for missing await: any floating promise that touches tx after the callback resolves will hit this.
  5. 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

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


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)