tursodatabase/turso · error · TypeError

The transaction has already completed

Error message

The transaction has already completed

What it means

Thrown by every method on the Transaction handle after the transaction has committed or rolled back: the transactionAsync wrapper calls txn.finish() in its finally block, which flips the active flag, and every handle method goes through assertActive()/the gate. The dedicated session is closed at the same moment, so statements prepared from the handle are equally unusable.

Source

Thrown at serverless/javascript/src/connection.ts:605

  }

  private async withGate<T>(fn: () => Promise<T>): Promise<T> {
    await this.gate.acquire();
    try {
      return await fn();
    } finally {
      this.gate.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;
  }

  /**
   * Prepares a SQL statement scoped to the transaction. The statement runs
   * on the transaction's session without re-acquiring the connection lock
   * and becomes unusable once the transaction completes.
   */
  async prepare(sql: string): Promise<Statement> {
    const description = await this.withGate(() => this.session.describe(sql));
    const stmt = Statement.fromSession(this.session, sql, description.cols, this.gate);
    if (this.defaultSafeIntegerMode) {
      stmt.safeIntegers(true);

View on GitHub (pinned to bad083fafb)

Solutions

  1. Move every statement that uses tx inside the transactionAsync callback, before it returns
  2. Await all promises created inside the callback so no work leaks past COMMIT/ROLLBACK
  3. Pass plain data (ids, row values) to post-transaction work — never the tx handle or statements made from it
  4. Check the tx.open property before use to fail fast with your own message

Example fix

// before
let txRef: any;
await db.transactionAsync(async (tx) => { txRef = tx; await tx.run("INSERT INTO t VALUES (1)"); })();
await txRef.run("UPDATE t SET x = 2"); // TypeError: transaction already completed

// after
await db.transactionAsync(async (tx) => {
  await tx.run("INSERT INTO t VALUES (1)");
  await tx.run("UPDATE t SET x = 2");
})(); // all SQL inside the callback
Defensive patterns

Strategy: type-guard

Validate before calling

if (!tx.open) {
  throw new Error("transaction finished — restart the transactionAsync call");
}
await tx.run("UPDATE t SET x = 1");

Type guard

const isTxUsable = (tx: { open: boolean }): boolean => tx.open;
// `open` is false once the wrapper ran COMMIT/ROLLBACK; check it before
// every late (closure, deferred, retried) use of the handle.

Try / catch

try {
  await tx.run(sql);
} catch (e) {
  if (e instanceof TypeError && e.message === "The transaction has already completed") {
    // Do not reuse the handle — rerun the whole transactionAsync(fn) call.
  } else throw e;
}

Prevention

When it happens

Trigger: Capturing tx in a closure or class field and calling tx.run()/tx.all()/tx.prepare()/tx.batch()/tx.exec() after the callback has returned; fire-and-forget promises started inside the callback that outlive the COMMIT and later touch tx; executing a Statement obtained from tx.prepare() after the transaction completes.

Common situations: Queueing background work inside a transaction that still references the handle; retry wrappers that accidentally reuse the finished handle; generators created inside the callback being consumed after it ends.

Related errors


AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16). Data as JSON: /api/errors/fd96a981148faa8f. Report an issue: GitHub.