bun-sqlite-orm
Version:
A lightweight TypeScript ORM for Bun runtime with Bun SQLite, featuring Active Record pattern and decorator-based entities
283 lines (237 loc) • 9.22 kB
text/typescript
import type { Database } from 'bun:sqlite';
import type { DbLogger } from '../types';
export interface TransactionOptions {
/**
* Transaction isolation level
* @default 'DEFERRED'
*/
isolation?: 'DEFERRED' | 'IMMEDIATE' | 'EXCLUSIVE';
}
/**
* Represents a database transaction that provides atomic operations
* and proper error handling with automatic rollback capabilities.
*/
export class Transaction {
private isActive = false;
private isCommitted = false;
private isRolledBack = false;
private savepointCounter = 0;
private savepointStack: string[] = [];
constructor(
private readonly database: Database,
private readonly logger: DbLogger,
private readonly options: TransactionOptions = {}
) {}
/**
* Start the transaction
*/
async begin(): Promise<void> {
if (this.isActive) {
throw new Error('Transaction is already active');
}
const isolation = this.options.isolation || 'DEFERRED';
const sql = `BEGIN ${isolation} TRANSACTION`;
this.logger.debug('Starting transaction', { sql, isolation });
try {
this.database.exec(sql);
this.isActive = true;
this.logger.debug('Transaction started successfully');
} catch (error) {
this.logger.error('Failed to start transaction', error);
throw new Error(`Failed to start transaction: ${error}`);
}
}
/**
* Commit the transaction
*/
async commit(): Promise<void> {
if (!this.isActive) {
throw new Error('No active transaction to commit');
}
if (this.isCommitted) {
throw new Error('Transaction has already been committed');
}
if (this.isRolledBack) {
throw new Error('Transaction has been rolled back and cannot be committed');
}
// SQLite automatically releases all savepoints during COMMIT
// No need to manually release them
this.logger.debug('Committing transaction');
try {
this.database.exec('COMMIT TRANSACTION');
this.isActive = false;
this.isCommitted = true;
this.savepointStack.length = 0; // Clear savepoint stack since SQLite released them all
this.logger.debug('Transaction committed successfully');
} catch (error) {
this.logger.error('Failed to commit transaction', error);
// Try to rollback on commit failure
try {
this.database.exec('ROLLBACK TRANSACTION');
this.isActive = false;
this.isRolledBack = true;
} catch (rollbackError) {
this.logger.error('Failed to rollback after commit failure', rollbackError);
}
throw new Error(`Failed to commit transaction: ${error}`);
}
}
/**
* Rollback the transaction
*/
async rollback(): Promise<void> {
if (!this.isActive) {
throw new Error('No active transaction to rollback');
}
if (this.isCommitted) {
throw new Error('Transaction has already been committed');
}
if (this.isRolledBack) {
throw new Error('Transaction has already been rolled back');
}
this.logger.debug('Rolling back transaction');
try {
this.database.exec('ROLLBACK TRANSACTION');
this.isActive = false;
this.isRolledBack = true;
this.savepointStack.length = 0; // Clear savepoint stack
this.logger.debug('Transaction rolled back successfully');
} catch (error) {
this.logger.error('Failed to rollback transaction', error);
throw new Error(`Failed to rollback transaction: ${error}`);
}
}
/**
* Create a savepoint for nested transactions
*/
async savepoint(name?: string): Promise<string> {
if (!this.isActive) {
throw new Error('Cannot create savepoint without active transaction');
}
const savepointName = name || `sp_${++this.savepointCounter}`;
const sql = `SAVEPOINT ${savepointName}`;
this.logger.debug('Creating savepoint', { savepoint: savepointName, sql });
try {
this.database.exec(sql);
this.savepointStack.push(savepointName);
this.logger.debug('Savepoint created successfully', { savepoint: savepointName });
return savepointName;
} catch (error) {
this.logger.error('Failed to create savepoint', { savepoint: savepointName, error });
throw new Error(`Failed to create savepoint ${savepointName}: ${error}`);
}
}
/**
* Release a savepoint (commit nested transaction)
*/
async releaseSavepoint(name?: string): Promise<void> {
if (!this.isActive) {
throw new Error('Cannot release savepoint without active transaction');
}
const savepointName = name || this.savepointStack[this.savepointStack.length - 1];
if (!savepointName) {
throw new Error('No savepoint to release');
}
// Check if the savepoint exists in our stack
const index = this.savepointStack.indexOf(savepointName);
if (index === -1) {
throw new Error(`Savepoint ${savepointName} not found`);
}
const sql = `RELEASE SAVEPOINT ${savepointName}`;
this.logger.debug('Releasing savepoint', { savepoint: savepointName, sql });
try {
this.database.exec(sql);
// SQLite RELEASE removes the savepoint and all savepoints created after it
this.savepointStack.splice(index);
this.logger.debug('Savepoint released successfully', {
savepoint: savepointName,
remainingStack: this.savepointStack,
});
} catch (error) {
this.logger.error('Failed to release savepoint', { savepoint: savepointName, error });
throw new Error(`Failed to release savepoint ${savepointName}: ${error}`);
}
}
/**
* Rollback to a savepoint (rollback nested transaction)
*/
async rollbackToSavepoint(name?: string): Promise<void> {
if (!this.isActive) {
throw new Error('Cannot rollback to savepoint without active transaction');
}
const savepointName = name || this.savepointStack[this.savepointStack.length - 1];
if (!savepointName) {
throw new Error('No savepoint to rollback to');
}
// Check if the savepoint exists in our stack
const index = this.savepointStack.indexOf(savepointName);
if (index === -1) {
throw new Error(`Savepoint ${savepointName} not found`);
}
const sql = `ROLLBACK TO SAVEPOINT ${savepointName}`;
this.logger.debug('Rolling back to savepoint', { savepoint: savepointName, sql });
try {
this.database.exec(sql);
// ROLLBACK TO does NOT destroy the savepoint itself, only nested ones
// Remove all savepoints created after this one (but keep the target savepoint)
this.savepointStack.splice(index + 1);
this.logger.debug('Rolled back to savepoint successfully', {
savepoint: savepointName,
remainingStack: this.savepointStack,
});
} catch (error) {
this.logger.error('Failed to rollback to savepoint', { savepoint: savepointName, error });
throw new Error(`Failed to rollback to savepoint ${savepointName}: ${error}`);
}
}
/**
* Check if transaction is active
*/
isTransactionActive(): boolean {
return this.isActive;
}
/**
* Check if transaction has been committed
*/
isTransactionCommitted(): boolean {
return this.isCommitted;
}
/**
* Check if transaction has been rolled back
*/
isTransactionRolledBack(): boolean {
return this.isRolledBack;
}
/**
* Get the underlying database connection
* This allows transaction-aware entities to use the same connection
*/
getDatabase(): Database {
return this.database;
}
/**
* Execute a query within the transaction context
*/
exec(sql: string): void {
if (!this.isActive) {
throw new Error('Cannot execute query without active transaction');
}
this.logger.debug('Executing SQL in transaction', { sql });
try {
this.database.exec(sql);
} catch (error) {
this.logger.error('Failed to execute SQL in transaction', { sql, error });
throw error;
}
}
/**
* Prepare a statement within the transaction context
*/
prepare(sql: string) {
if (!this.isActive) {
throw new Error('Cannot prepare statement without active transaction');
}
this.logger.debug('Preparing statement in transaction', { sql });
return this.database.prepare(sql);
}
}