Skip to content

Changelog - 2026-09-26 ​

runInTransaction runs commit and rollback for you ​

Feature

Hand-written begin / try / commit / catch / rollback blocks repeat wherever a repository method needs a transaction. runInTransaction writes that block once:

typescript
const order = await orderRepository.runInTransaction({
  execute: async ({ transaction }) => {
    const { data: created } = await orderRepository.create({
      data: orderData,
      options: { transaction },
    });

    await orderItemRepository.create({
      data: { orderId: created.id, ...itemData },
      options: { transaction },
    });

    return created;
  },
});

Join or own, never both ​

Pass a transaction and runInTransaction joins it: it never commits or rolls back, and transactionOptions is ignored. Pass none and it owns one: begins, commits on success, and rolls back on failure - rethrowing the original error even when the rollback itself fails.

Joined (transaction passed)Owned (none passed)
BeginNeverbeginTransaction(transactionOptions)
CommitNeverAfter execute resolves
RollbackNeverOn failure, then rethrows the original error

That turns a service method that must work stand-alone and nested inside a caller's transaction into a one-line forward, not two code paths:

typescript
async function createOrder(opts: { data: TOrderCreate; transaction?: IDatabaseTransaction }) {
  return orderRepository.runInTransaction({
    transaction: opts.transaction,
    execute: async ({ transaction }) =>
      orderRepository.create({ data: opts.data, options: { transaction } }),
  });
}

See Transactions for the full rules, including what happens to an inactive handle and a failed rollback.

TransactionDouble stubs a transaction with no database ​

Feature

Unit-testing runInTransaction used to mean standing up Postgres or SQLite. TransactionDouble, from the new @venizia/ignis/testing entry (@venizia/ignis-connectors/testing in the connectors package), is a transaction handle with no database behind it:

typescript
import { spyOn } from 'bun:test';
import { PostgresTransactionDouble } from '@venizia/ignis/testing';

const double = new PostgresTransactionDouble();
spyOn(orderRepository, 'beginTransaction').mockResolvedValue(double);

await orderRepository.runInTransaction({ execute: async () => 'ok' });

expect(double.commitCount).toBe(1);

Reading double.connector throws - a repository method that reaches the database must be stubbed too. Inject commitError or rollbackError to test a failed end. See Transaction Doubles for the full guide, including the limit: a double proves control flow, not SQL atomicity.

If execute ends the owned transaction itself ​

runInTransaction expects execute to leave an owned transaction open for it to commit. Call commit() or rollback() from inside execute instead, and runInTransaction never ends it a second time: committed by execute - the result is returned, with no second commit attempt. Rolled back or left failed by execute - a dedicated error, naming that execute ended the transaction before it could be committed, with no rollback attempt. execute rejects after ending the transaction itself - the ORIGINAL rejection, with no further rollback attempt. This reads the transaction's own internal state through a registry symbol, so it recognises the real handle or TransactionDouble even across a CommonJS/ESM mix (a @venizia/ignis/testing double with an @venizia/ignis-connectors repository, or the reverse) - only a transaction from an entirely different ITransaction implementation is unreadable, and reports the "ended before it could be committed" error even when execute committed it successfully.

Always await commit()/rollback() when calling them from inside execute. A commit call that execute does not await is read as COMMITTED before the COMMIT statement itself has settled, so runInTransaction returns execute's result while that commit can still go on to fail.

Who is affected ​

Additive. Nothing existing changes behavior, and adopting either API is optional.

One shape changed under the hood, not in behavior: the handle beginTransaction() returns is now a class, ConnectionTransaction, instead of a closure building a fresh object literal each time. Its datasource, connection, and internal state are ES private fields - invisible to JSON.stringify, util.inspect, Object.keys, and a spread copy, exactly as before. commit(), rollback(), and connector behave exactly as before, and reading isActive on the handle itself still works.

The one real difference: isActive now lives on the shared prototype, not as an own property. A spread copy - { ...transaction } - no longer carries it, so passing a spread copy to a repository now throws "Transaction is no longer active" instead of working. Reading isActive through a Proxy or an Object.create(transaction) wrapper now throws a TypeError too - ES private fields are not proxyable and are not inherited. Nothing in IGNIS does either.

Files: