UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

307 lines 11.4 kB
import { FileUtils } from './file-utils.js'; /** * Manages atomic file operations with full rollback capability. * * Provides transactional semantics for file system operations, ensuring that either all operations * complete successfully or all changes are rolled back. Supports automatic backups and retry * logic. * * @category Utilities * * @example * Transactional file operations * ```typescript * const transaction = new TransactionManager({ * createBackups: true, * continueOnError: false * }); * * // Add operations to the transaction * transaction.addFileMove('old.md', 'new.md'); * transaction.addContentUpdate('target.md', newContent); * * try { * const result = await transaction.execute(); * if (result.success) { * console.log('All operations completed successfully'); * } else { * console.log('Transaction failed, all changes rolled back'); * } * } catch (error) { * console.error('Transaction error:', error); * } * ``` */ export class TransactionManager { steps = []; executedSteps = []; backups = new Map(); options; constructor(options = {}) { this.options = { createBackups: options.createBackups ?? true, continueOnError: options.continueOnError ?? false, maxRetries: options.maxRetries ?? 3, }; } /** Add a file move operation to the transaction */ addFileMove(sourcePath, destinationPath, description) { const stepId = `move-${this.steps.length}`; this.steps.push({ id: stepId, type: 'file-move', description: description || `Move ${sourcePath} to ${destinationPath}`, completed: false, execute: async () => { // Create backup if enabled if (this.options.createBackups && (await FileUtils.exists(sourcePath))) { const backupPath = await FileUtils.createBackup(sourcePath); this.backups.set(stepId, backupPath); } await FileUtils.moveFile(sourcePath, destinationPath, { createDirectories: true, overwrite: false, }); }, rollback: async () => { try { // If destination exists, remove it if (await FileUtils.exists(destinationPath)) { await FileUtils.deleteFile(destinationPath); } // Restore from backup if available const backupPath = this.backups.get(stepId); if (backupPath && (await FileUtils.exists(backupPath))) { await FileUtils.moveFile(backupPath, sourcePath); this.backups.delete(stepId); } } catch (error) { console.warn(`Failed to rollback file move: ${error}`); } }, }); } /** Add a content update operation to the transaction */ addContentUpdate(filePath, newContent, description) { const stepId = `update-${this.steps.length}`; let originalContent = null; this.steps.push({ id: stepId, type: 'content-update', description: description || `Update content of ${filePath}`, completed: false, execute: async () => { // Save original content for rollback if (await FileUtils.exists(filePath)) { originalContent = await FileUtils.readTextFile(filePath); } await FileUtils.writeTextFile(filePath, newContent, { createDirectories: true, }); }, rollback: async () => { try { if (originalContent !== null) { await FileUtils.writeTextFile(filePath, originalContent); } else { // File didn't exist originally, so delete it await FileUtils.deleteFile(filePath); } } catch (error) { console.warn(`Failed to rollback content update: ${error}`); } }, }); } /** Add a file creation operation to the transaction */ addFileCreate(filePath, content, description) { const stepId = `create-${this.steps.length}`; this.steps.push({ id: stepId, type: 'file-create', description: description || `Create file ${filePath}`, completed: false, execute: async () => { if (await FileUtils.exists(filePath)) { throw new Error(`File already exists: ${filePath}`); } await FileUtils.writeTextFile(filePath, content, { createDirectories: true, }); }, rollback: async () => { try { await FileUtils.deleteFile(filePath); } catch (error) { console.warn(`Failed to rollback file creation: ${error}`); } }, }); } /** Add a file deletion operation to the transaction */ addFileDelete(filePath, description) { const stepId = `delete-${this.steps.length}`; let originalContent = null; this.steps.push({ id: stepId, type: 'file-delete', description: description || `Delete file ${filePath}`, completed: false, execute: async () => { if (await FileUtils.exists(filePath)) { // Save content for potential rollback originalContent = await FileUtils.readTextFile(filePath); await FileUtils.deleteFile(filePath); } }, rollback: async () => { try { if (originalContent !== null) { await FileUtils.writeTextFile(filePath, originalContent, { createDirectories: true, }); } } catch (error) { console.warn(`Failed to rollback file deletion: ${error}`); } }, }); } /** Execute all steps in the transaction */ async execute() { const errors = []; const changes = []; let completedSteps = 0; try { for (const step of this.steps) { let retries = 0; let stepSuccess = false; while (retries <= this.options.maxRetries && !stepSuccess) { try { await step.execute(); step.completed = true; this.executedSteps.push(step); stepSuccess = true; completedSteps++; // Record the change changes.push({ type: this.mapStepTypeToChangeType(step.type), filePath: this.extractFilePathFromDescription(step.description), }); } catch (error) { retries++; const errorMessage = `Step "${step.description}" failed (attempt ${retries}): ${error}`; if (retries > this.options.maxRetries) { errors.push(errorMessage); if (!this.options.continueOnError) { // Rollback all executed steps await this.rollback(); return { success: false, completedSteps, errors, changes: [], }; } } else { // Wait before retry (exponential backoff) await new Promise((resolve) => setTimeout(resolve, 2 ** (retries - 1) * 1000)); } } } } // Clean up backups on success await this.cleanupBackups(); return { success: errors.length === 0, completedSteps, errors, changes, }; } catch (error) { errors.push(`Transaction execution failed: ${error}`); await this.rollback(); return { success: false, completedSteps, errors, changes: [], }; } } /** Rollback all executed steps */ async rollback() { const rollbackErrors = []; // Rollback in reverse order for (let i = this.executedSteps.length - 1; i >= 0; i--) { const step = this.executedSteps[i]; try { await step.rollback(); step.completed = false; } catch (error) { rollbackErrors.push(`Failed to rollback step "${step.description}": ${error}`); } } this.executedSteps = []; if (rollbackErrors.length > 0) { console.warn('Rollback completed with warnings:', rollbackErrors); } } /** Get a preview of all planned operations */ getPreview() { return this.steps.map((step) => ({ description: step.description, type: step.type, })); } /** Clear all planned operations */ clear() { this.steps = []; this.executedSteps = []; this.backups.clear(); } /** Get the number of planned operations */ getStepCount() { return this.steps.length; } async cleanupBackups() { for (const backupPath of this.backups.values()) { try { await FileUtils.deleteFile(backupPath); } catch (error) { console.warn(`Failed to cleanup backup ${backupPath}: ${error}`); } } this.backups.clear(); } mapStepTypeToChangeType(stepType) { switch (stepType) { case 'file-move': return 'file-moved'; case 'file-create': return 'file-created'; case 'file-delete': return 'file-deleted'; case 'content-update': return 'content-modified'; default: return 'content-modified'; } } extractFilePathFromDescription(description) { // Simple extraction - could be enhanced with more sophisticated parsing const match = description.match(/(?:Move|Update|Create|Delete)\s+(?:content of\s+)?([^\s]+)/); return match?.[1] || ''; } } //# sourceMappingURL=transaction-manager.js.map