{"record":{"id":"298052d05e9f9747","repo":"tursodatabase/turso","slug":"the-transaction-has-already-completed","errorCode":null,"errorMessage":"The transaction has already completed","messagePattern":"The transaction has already completed","errorType":"exception","errorClass":"TypeError","httpStatus":null,"severity":"error","filePath":"bindings/javascript/packages/common/promise.ts","lineNumber":977,"sourceCode":"\n  constructor(db: NativeDatabase, ioStep: () => Promise<void>) {\n    this.db = db;\n    this.ioStep = ioStep;\n    const lock = new AsyncLock();\n    this.gate = {\n      acquire: async () => { this.assertActive(); await lock.acquire(); },\n      release: () => { lock.release(); },\n    };\n  }\n\n  /** Whether the transaction is still open (COMMIT/ROLLBACK not executed yet). */\n  get open(): boolean {\n    return this.active;\n  }\n\n  private assertActive() {\n    if (!this.active) {\n      throw new TypeError(\"The transaction has already completed\");\n    }\n  }\n\n  /** @internal Marks the transaction completed; called by the wrapper. */\n  finish() {\n    this.active = false;\n  }\n\n  /**\n   * Executes the given SQL string inside the transaction.\n   * Unlike prepared statements, this can execute strings that contain multiple SQL statements.\n   *\n   * @param {string} sql - The string containing SQL statements to execute\n   */\n  async exec(sql: string, queryOptions?: QueryOptions) {\n    await this.gate.acquire();\n    try {\n      const exec = this.db.executor(sql, queryOptions);","sourceCodeStart":959,"sourceCodeEnd":995,"githubUrl":"https://github.com/tursodatabase/turso/blob/c1e59287258d99b309e362a63f48822256e2f65f/bindings/javascript/packages/common/promise.ts#L959-L995","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nlet savedTx;\nawait db.transaction(async (tx) => {\n  savedTx = tx;\n  await tx.run(\"INSERT INTO t VALUES (1)\");\n});\nawait savedTx.run(\"INSERT INTO t VALUES (2)\"); // throws: already completed\n\n// after\nawait db.transaction(async (tx) => {\n  await tx.run(\"INSERT INTO t VALUES (1)\");\n  await tx.run(\"INSERT INTO t VALUES (2)\"); // all uses inside the callback\n});","handlingStrategy":"validation","validationCode":"if (!tx.open) { /* transaction finished; reopen or skip */ }\nawait tx.run(sql);","typeGuard":"const isTxOpen = (tx: { open: boolean }): boolean => tx.open;","tryCatchPattern":"try {\n  await tx.run(sql);\n} catch (e) {\n  if (e instanceof TypeError && e.message === \"The transaction has already completed\") {\n    // tx finished: reopen a new transaction instead of reusing the handle\n  } else throw e;\n}","preventionTips":["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."],"tags":["transaction","state","promise-api","lifetime"],"backgroundTag":"transaction-already-completed","analyzedSha":"c1e59287258d99b309e362a63f48822256e2f65f","analyzedAt":"2026-08-20T07:02:18.389Z","contentChangedAt":"2026-08-20T07:02:18.389Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}