UNPKG

kira-crud

Version:

Intelligent CRUD Generator for Laravel and Angular

419 lines (362 loc) 12.7 kB
/** * Transaction Manager for CRUD Generator * Provides a robust transaction system for safe file operations with rollback capabilities */ const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const chalk = require('chalk'); const ora = require('ora'); /** * Transaction manager class */ class TransactionManager { /** * Create a new transaction manager * @param {Object} options - Configuration options */ constructor(options = {}) { this.operations = []; this.backupDir = options.backupDir || path.join(process.cwd(), '.kira-backups'); this.backupHash = crypto.randomBytes(8).toString('hex'); this.backupPath = path.join(this.backupDir, this.backupHash); this.completed = false; this.rolledBack = false; this.verbose = options.verbose || false; this.createBackups = options.createBackups !== false; // Default to true this.spinner = null; this.operationIndex = 0; } /** * Initialize the transaction and create backup directory */ async init() { if (this.createBackups) { try { await fs.mkdir(this.backupDir, { recursive: true }); await fs.mkdir(this.backupPath, { recursive: true }); if (this.verbose) { console.log(chalk.gray(`Transaction initialized with backup path: ${this.backupPath}`)); } } catch (error) { throw new Error(`Failed to initialize transaction: ${error.message}`); } } } /** * Add a write operation to the transaction * @param {string} filePath - Path to the file to write * @param {string} content - Content to write to the file */ async addWrite(filePath, content) { // Create a normalized absolute path const normalizedPath = path.resolve(filePath); // Check if file exists and create backup if it does try { const fileExists = await fileExistsAsync(normalizedPath); if (fileExists && this.createBackups) { // Create backup await this.createBackup(normalizedPath); } // Add operation to the list this.operations.push({ type: 'write', path: normalizedPath, content, exists: fileExists }); if (this.verbose) { console.log(chalk.gray(`Added write operation for: ${normalizedPath}`)); } } catch (error) { throw new Error(`Failed to add write operation for ${normalizedPath}: ${error.message}`); } } /** * Add a delete operation to the transaction * @param {string} filePath - Path to the file to delete */ async addDelete(filePath) { // Create a normalized absolute path const normalizedPath = path.resolve(filePath); // Check if file exists and create backup if it does try { const fileExists = await fileExistsAsync(normalizedPath); if (!fileExists) { if (this.verbose) { console.log(chalk.yellow(`Warning: Attempted to delete non-existent file: ${normalizedPath}`)); } return; } if (this.createBackups) { // Create backup await this.createBackup(normalizedPath); } // Add operation to the list this.operations.push({ type: 'delete', path: normalizedPath }); if (this.verbose) { console.log(chalk.gray(`Added delete operation for: ${normalizedPath}`)); } } catch (error) { throw new Error(`Failed to add delete operation for ${normalizedPath}: ${error.message}`); } } /** * Add a directory creation operation to the transaction * @param {string} dirPath - Path to the directory to create */ async addMkdir(dirPath) { // Create a normalized absolute path const normalizedPath = path.resolve(dirPath); // Check if directory exists try { const dirExists = await directoryExistsAsync(normalizedPath); // Add operation to the list this.operations.push({ type: 'mkdir', path: normalizedPath, exists: dirExists }); if (this.verbose) { console.log(chalk.gray(`Added mkdir operation for: ${normalizedPath}`)); } } catch (error) { throw new Error(`Failed to add mkdir operation for ${normalizedPath}: ${error.message}`); } } /** * Create a backup of a file * @param {string} filePath - Path to the file to backup */ async createBackup(filePath) { try { const fileContent = await fs.readFile(filePath, 'utf8'); const backupFilePath = path.join(this.backupPath, path.basename(filePath)); // Create subdirectories if needed const relativeDir = path.relative(process.cwd(), path.dirname(filePath)); const backupDirPath = path.join(this.backupPath, relativeDir); await fs.mkdir(backupDirPath, { recursive: true }); // Save backup with full path structure const fullBackupPath = path.join(backupDirPath, path.basename(filePath)); await fs.writeFile(fullBackupPath, fileContent); if (this.verbose) { console.log(chalk.gray(`Created backup of ${filePath} at ${fullBackupPath}`)); } } catch (error) { throw new Error(`Failed to create backup for ${filePath}: ${error.message}`); } } /** * Execute all operations in the transaction * @returns {Promise<boolean>} Whether the transaction succeeded */ async execute() { if (this.completed) { throw new Error('Transaction already completed'); } if (this.rolledBack) { throw new Error('Transaction already rolled back'); } if (this.operations.length === 0) { if (this.verbose) { console.log(chalk.yellow('No operations to execute')); } this.completed = true; return true; } this.spinner = ora('Executing transaction...').start(); try { this.operationIndex = 0; for (const operation of this.operations) { this.operationIndex++; this.spinner.text = `Executing operation ${this.operationIndex}/${this.operations.length}...`; await this.executeOperation(operation); } this.spinner.succeed(`Completed ${this.operations.length} operations successfully`); this.completed = true; return true; } catch (error) { this.spinner.fail(`Transaction failed at operation ${this.operationIndex}/${this.operations.length}`); console.error(chalk.red(`Error: ${error.message}`)); await this.rollback(); return false; } } /** * Execute a single operation * @param {Object} operation - The operation to execute */ async executeOperation(operation) { try { switch (operation.type) { case 'write': // Create parent directory if it doesn't exist const parentDir = path.dirname(operation.path); await fs.mkdir(parentDir, { recursive: true }); // Write the file await fs.writeFile(operation.path, operation.content); break; case 'delete': await fs.unlink(operation.path); break; case 'mkdir': if (!operation.exists) { await fs.mkdir(operation.path, { recursive: true }); } break; default: throw new Error(`Unknown operation type: ${operation.type}`); } } catch (error) { throw new Error(`Failed to execute ${operation.type} operation on ${operation.path}: ${error.message}`); } } /** * Roll back all operations in the transaction */ async rollback() { if (this.rolledBack) { if (this.verbose) { console.log(chalk.yellow('Transaction already rolled back')); } return; } if (!this.createBackups) { console.log(chalk.yellow('Rollback not possible: backups were disabled')); return; } const rollbackSpinner = ora('Rolling back transaction...').start(); try { // Roll back operations in reverse order for (let i = this.operationIndex - 1; i >= 0; i--) { const operation = this.operations[i]; rollbackSpinner.text = `Rolling back operation ${this.operationIndex - i}/${this.operationIndex}...`; await this.rollbackOperation(operation); } rollbackSpinner.succeed(`Rolled back ${this.operationIndex} operations successfully`); this.rolledBack = true; } catch (error) { rollbackSpinner.fail('Rollback failed'); console.error(chalk.red(`Rollback error: ${error.message}`)); console.error(chalk.red('System may be in an inconsistent state.')); // Try to provide recovery instructions console.log(chalk.yellow('\nRecovery instructions:')); console.log(chalk.yellow(`1. Check backup files in: ${this.backupPath}`)); console.log(chalk.yellow('2. Manually restore any critical files')); } } /** * Roll back a single operation * @param {Object} operation - The operation to roll back */ async rollbackOperation(operation) { try { switch (operation.type) { case 'write': if (operation.exists) { // Restore from backup const relativeDir = path.relative(process.cwd(), path.dirname(operation.path)); const backupPath = path.join(this.backupPath, relativeDir, path.basename(operation.path)); if (await fileExistsAsync(backupPath)) { const backupContent = await fs.readFile(backupPath, 'utf8'); await fs.writeFile(operation.path, backupContent); } else { throw new Error(`Backup file not found: ${backupPath}`); } } else { // Delete file that was created await fs.unlink(operation.path); } break; case 'delete': // Restore from backup const relativeDir = path.relative(process.cwd(), path.dirname(operation.path)); const backupPath = path.join(this.backupPath, relativeDir, path.basename(operation.path)); if (await fileExistsAsync(backupPath)) { const backupContent = await fs.readFile(backupPath, 'utf8'); await fs.writeFile(operation.path, backupContent); } else { throw new Error(`Backup file not found: ${backupPath}`); } break; case 'mkdir': // We don't roll back directory creation because it might contain other files now break; default: throw new Error(`Unknown operation type: ${operation.type}`); } } catch (error) { throw new Error(`Failed to roll back ${operation.type} operation on ${operation.path}: ${error.message}`); } } /** * Clean up backup files */ async cleanup() { if (!this.createBackups) { return; } try { if (await directoryExistsAsync(this.backupPath)) { await fs.rm(this.backupPath, { recursive: true, force: true }); if (this.verbose) { console.log(chalk.gray(`Cleaned up backup directory: ${this.backupPath}`)); } } } catch (error) { console.error(chalk.yellow(`Warning: Failed to clean up backup directory: ${error.message}`)); } } /** * Get a summary of the transaction * @returns {Object} Transaction summary */ getSummary() { return { operations: this.operations.length, completed: this.completed, rolledBack: this.rolledBack, backupPath: this.backupPath, operationsByType: this.operations.reduce((acc, op) => { acc[op.type] = (acc[op.type] || 0) + 1; return acc; }, {}) }; } } /** * Check if a file exists asynchronously * @param {string} filePath - Path to the file * @returns {Promise<boolean>} Whether the file exists */ async function fileExistsAsync(filePath) { try { const stats = await fs.stat(filePath); return stats.isFile(); } catch (error) { if (error.code === 'ENOENT') { return false; } throw error; } } /** * Check if a directory exists asynchronously * @param {string} dirPath - Path to the directory * @returns {Promise<boolean>} Whether the directory exists */ async function directoryExistsAsync(dirPath) { try { const stats = await fs.stat(dirPath); return stats.isDirectory(); } catch (error) { if (error.code === 'ENOENT') { return false; } throw error; } } module.exports = TransactionManager;