markmv
Version:
TypeScript CLI for markdown file operations with intelligent link refactoring
307 lines • 11.4 kB
JavaScript
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