Skip to content

Planned: Transaction Support

Status: Planned (Not Yet Implemented) Priority: Future Enhancement

Goal

Implement Loopback 4-style explicit transaction objects, allowing transactions to be passed through multiple services/repositories instead of using Drizzle's callback-based approach.

Target API

typescript
// Default isolation level (READ COMMITTED)
const tx = await userRepo.beginTransaction();

// Or with specific isolation level
const tx = await userRepo.beginTransaction({
  isolationLevel: 'SERIALIZABLE'
});

try {
  await userRepo.create({ data, options: { transaction: tx } });
  await profileRepo.create({ data, options: { transaction: tx } });
  await tx.commit();
} catch (err) {
  await tx.rollback();
  throw err;
}

Isolation Levels

LevelDescriptionUse Case
READ COMMITTEDDefault. Sees only committed data at query startGeneral use, most common
REPEATABLE READSees snapshot from transaction startReports, consistent reads
SERIALIZABLEStrictest. Full isolation, may throw serialization errorsFinancial transactions, critical data

Implementation Steps

Step 1: Define Transaction Types

File: packages/core/src/base/datasources/types.ts

typescript
/** PostgreSQL transaction isolation levels */
export type TIsolationLevel = 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE';

/** Options for starting a transaction */
export interface ITransactionOptions {
  isolationLevel?: TIsolationLevel;
}

/** Transaction object returned by beginTransaction() */
export interface ITransaction<Connector = TNodePostgresConnector> {
  /** Isolated Drizzle instance bound to this transaction */
  connector: Connector;

  /** Commit the transaction */
  commit(): Promise<void>;

  /** Rollback the transaction */
  rollback(): Promise<void>;

  /** Check if transaction is still active */
  isActive: boolean;

  /** The isolation level used for this transaction */
  isolationLevel: TIsolationLevel;
}

Step 2: Add beginTransaction() to DataSource

File: packages/core/src/base/datasources/base.ts

typescript
async beginTransaction(
  opts?: ITransactionOptions
): Promise<ITransaction<Connector>> {
  // 1. Get raw client from pool
  const pool = this.connector.client as Pool;
  const client = await pool.connect();

  // 2. Determine isolation level (default: READ COMMITTED)
  const isolationLevel: TIsolationLevel = opts?.isolationLevel ?? 'READ COMMITTED';

  // 3. Execute BEGIN with isolation level
  await client.query(`BEGIN TRANSACTION ISOLATION LEVEL ${isolationLevel}`);

  // 4. Create isolated Drizzle instance with this client
  const txConnector = drizzle({ client, schema: this.schema });

  // 5. Return transaction object
  let isActive = true;

  return {
    connector: txConnector as Connector,
    isActive,
    isolationLevel,

    async commit() {
      if (!isActive) throw new Error('Transaction already ended');
      try {
        await client.query('COMMIT');
      } finally {
        isActive = false;
        client.release();
      }
    },

    async rollback() {
      if (!isActive) throw new Error('Transaction already ended');
      try {
        await client.query('ROLLBACK');
      } finally {
        isActive = false;
        client.release();
      }
    },
  };
}

Step 3: Update Repository Base

File: packages/core/src/base/repositories/core/base.ts

typescript
// Add method to start transaction (delegates to DataSource)
async beginTransaction(opts?: ITransactionOptions): Promise<ITransaction> {
  return this.dataSource.beginTransaction(opts);
}

// Replace this.connector with getConnector(opts)
protected getConnector(opts?: { transaction?: ITransaction }) {
  if (opts?.transaction) {
    if (!opts.transaction.isActive) {
      throw getError({ message: 'Transaction is no longer active' });
    }
    return opts.transaction.connector;
  }
  return this.dataSource.connector;
}

Step 4: Update CRUD Options Types

File: packages/core/src/base/repositories/common/types.ts

typescript
export type TTransactionOption = {
  transaction?: ITransaction;
};

// Add to existing option types
export type TCreateOptions = TTransactionOption & {
  shouldReturn?: boolean;
  log?: TRepositoryLogOptions;
};

Step 5: Update CRUD Methods

Files: readable.ts, persistable.ts

Change all methods from:

typescript
this.connector.insert(...)

To:

typescript
this.getConnector(opts.options).insert(...)

Files to Modify

FileChanges
packages/core/src/base/datasources/types.tsAdd TIsolationLevel, ITransactionOptions, ITransaction
packages/core/src/base/datasources/base.tsAdd beginTransaction(opts?) method
packages/core/src/base/repositories/common/types.tsAdd TTransactionOption
packages/core/src/base/repositories/core/base.tsAdd beginTransaction(opts?), getConnector(opts)
packages/core/src/base/repositories/core/readable.tsUse getConnector(opts) in all methods
packages/core/src/base/repositories/core/persistable.tsUse getConnector(opts) in all methods

Breaking Changes

  1. this.connectorthis.getConnector(opts)

    • Backward compatible when called without args
  2. Options parameter - Now includes optional transaction field

    • Non-breaking: transaction is optional

Benefits

AspectCurrent (Drizzle Callback)After (Pass-through)
Service compositionHard - all in one callbackEasy - pass tx anywhere
Separation of concernsServices must know each otherServices stay independent
TestingComplex mockingEasy to mock tx object
Code organizationNested callbacksFlat, sequential flow