kira-crud
Version:
Intelligent CRUD Generator for Laravel and Angular
419 lines (362 loc) • 12.7 kB
JavaScript
/**
* 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;