/** * Transaction - Atomic Unit of Work * * Executes operations atomically: all succeed or all rollback. * Prevents partial failures that leave system in inconsistent state. * * Usage: * ```typescript * const tx = new Transaction() * tx.addOperation(operation1) * tx.addOperation(operation2) * await tx.execute() // Both succeed or both rollback * ``` */ import { Operation, RollbackAction, TransactionState, TransactionContext, TransactionOptions } from './types.js' import { InvalidTransactionStateError, TransactionExecutionError, TransactionRollbackError, TransactionTimeoutError } from './errors.js' import { prodLog } from '../utils/logger.js' /** * Default transaction options */ const DEFAULT_OPTIONS: Required = { timeout: 30000, // 30 seconds logging: false, maxRollbackRetries: 3 } /** * Transaction class */ export class Transaction implements TransactionContext { private operations: Operation[] = [] private rollbackActions: RollbackAction[] = [] private state: TransactionState = 'pending' private readonly options: Required private startTime?: number private endTime?: number constructor(options: TransactionOptions = {}) { this.options = { ...DEFAULT_OPTIONS, ...options } } /** * Add an operation to the transaction */ addOperation(operation: Operation): void { if (this.state !== 'pending') { throw new InvalidTransactionStateError( this.state, 'add operation' ) } this.operations.push(operation) } /** * Get current transaction state */ getState(): TransactionState { return this.state } /** * Get number of operations in transaction */ getOperationCount(): number { return this.operations.length } /** * Execute all operations atomically */ async execute(): Promise { if (this.state !== 'pending') { throw new InvalidTransactionStateError( this.state, 'execute' ) } this.state = 'executing' this.startTime = Date.now() if (this.options.logging) { prodLog.info(`[Transaction] Executing ${this.operations.length} operations`) } try { // Execute each operation in order. This loop is the sole rollback-guarded // region: ANY error that escapes it — an operation failure OR a // mid-flight timeout — is caught below and rolls back every operation // applied so far, in reverse order. That single guarantee is the // transaction's atomicity contract, and the generation-store commit path // depends on it: its abort cleanup assumes a throw from here already // restored the applied operations byte-identically (it only discards the // uncommitted staging directory). A rollback per exit path — the previous // design — let the timeout throw slip past rollback and strand the // already-applied writes as torn, generation-less state in canonical // storage. for (let i = 0; i < this.operations.length; i++) { // Budget check BEFORE starting the next operation. A trip here throws // into the catch below and rolls back like any other failure — it must // never bypass rollback. if (Date.now() - this.startTime > this.options.timeout) { throw new TransactionTimeoutError(this.options.timeout, i) } const operation = this.operations[i] if (this.options.logging) { prodLog.info(`[Transaction] Executing operation ${i}: ${operation.name || 'unnamed'}`) } let rollbackAction: RollbackAction | undefined try { rollbackAction = await operation.execute() } catch (error) { // Normalize an operation failure to a TransactionExecutionError; the // outer catch performs the (single) rollback and surfaces it. throw new TransactionExecutionError( `Operation ${i} failed: ${(error as Error).message}`, i, operation.name, error as Error ) } // Record rollback action (if the operation provided one) if (rollbackAction) { this.rollbackActions.push(rollbackAction) } } } catch (error) { // Single rollback point: undo everything applied so far, in reverse // order, then surface the original error. A rollback failure supersedes // it (rollback() throws TransactionRollbackError wrapping this error). await this.rollback(error as Error) throw error } // All operations succeeded — commit. this.commit() } /** * Commit the transaction */ private commit(): void { this.state = 'committed' this.endTime = Date.now() if (this.options.logging) { const duration = this.endTime - (this.startTime || this.endTime) prodLog.info(`[Transaction] Committed successfully in ${duration}ms`) } // Clear rollback actions - no longer needed this.rollbackActions = [] } /** * Rollback all executed operations in reverse order */ private async rollback(originalError: Error): Promise { this.state = 'rolling_back' if (this.options.logging) { prodLog.info(`[Transaction] Rolling back ${this.rollbackActions.length} operations`) } const rollbackErrors: Error[] = [] // Execute rollback actions in REVERSE order for (let i = this.rollbackActions.length - 1; i >= 0; i--) { const action = this.rollbackActions[i] // Retry rollback with exponential backoff let attempts = 0 let success = false while (attempts < this.options.maxRollbackRetries && !success) { try { await action() success = true if (this.options.logging) { prodLog.info(`[Transaction] Rolled back operation ${i}`) } } catch (error) { attempts++ if (attempts >= this.options.maxRollbackRetries) { // Max retries exceeded - log error and continue const rollbackError = error as Error rollbackErrors.push(rollbackError) prodLog.error( `[Transaction] Rollback failed for operation ${i} after ${attempts} attempts: ${rollbackError.message}` ) } else { // Retry with exponential backoff const delayMs = Math.pow(2, attempts) * 100 await new Promise(resolve => setTimeout(resolve, delayMs)) } } } } // Honest terminal state: only claim 'rolled_back' when EVERY undo applied. // If any undo failed, the store is not cleanly rolled back — mark // 'inconsistent' so the commit orchestration reconciles rather than trusts // a false 'rolled_back'. (Fixes the state half of the post-commit response // lie: the record whose undo failed is still durable.) this.state = rollbackErrors.length > 0 ? 'inconsistent' : 'rolled_back' this.endTime = Date.now() if (this.options.logging) { const duration = this.endTime - (this.startTime || this.endTime) prodLog.info(`[Transaction] ${this.state === 'inconsistent' ? 'Rollback INCOMPLETE' : 'Rolled back'} in ${duration}ms`) } // If rollback encountered errors, wrap them with original error. The // orchestration keys on `instanceof TransactionRollbackError` to know the // undo did not fully apply and reconciliation is required. if (rollbackErrors.length > 0) { throw new TransactionRollbackError( `Transaction rollback encountered ${rollbackErrors.length} errors during cleanup`, originalError, rollbackErrors ) } } /** * Get transaction execution time in milliseconds */ getExecutionTimeMs(): number | undefined { if (this.startTime && this.endTime) { return this.endTime - this.startTime } return undefined } }