import { promises as fs } from 'fs';
import { join, dirname } from 'path';
import { randomUUID } from 'crypto';
export class FileManager {
constructor() {
this.writeQueue = new Map(); // factId -> Promise to prevent concurrent writes
this.tempSuffix = '.tmp';
this.backupSuffix = '.bak';
}
async safeWriteJSON(filePath, data, options = {}) {
const {
createBackup = true,
validateJSON = true,
retries = 3,
atomicWrite = true
} = options;
const fileId = filePath;
// Check if a write is already in progress for this file
if (this.writeQueue.has(fileId)) {
await this.writeQueue.get(fileId);
}
// Create a promise for this write operation
const writePromise = this._performSafeWrite(filePath, data, {
createBackup,
validateJSON,
retries,
atomicWrite
});
this.writeQueue.set(fileId, writePromise);
try {
const result = await writePromise;
return result;
} finally {
this.writeQueue.delete(fileId);
}
}
async _performSafeWrite(filePath, data, options) {
const { createBackup, validateJSON, retries, atomicWrite } = options;
// Ensure directory exists
await fs.mkdir(dirname(filePath), { recursive: true });
// Serialize data to JSON
let jsonString;
try {
jsonString = JSON.stringify(data, null, 2);
} catch (error) {
throw new Error(`Failed to serialize data to JSON: ${error.message}`);
}
// Validate JSON if requested
if (validateJSON) {
try {
JSON.parse(jsonString);
} catch (error) {
throw new Error(`Generated JSON is invalid: ${error.message}`);
}
}
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
if (atomicWrite) {
await this._atomicWrite(filePath, jsonString, createBackup);
} else {
await this._directWrite(filePath, jsonString, createBackup);
}
// Verify the written file
await this._verifyWrittenFile(filePath, jsonString);
return { success: true, path: filePath, size: jsonString.length };
} catch (error) {
lastError = error;
console.warn(`Write attempt ${attempt}/${retries} failed for ${filePath}:`, error.message);
if (attempt < retries) {
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
}
}
}
throw new Error(`Failed to write file after ${retries} attempts: ${lastError.message}`);
}
async _atomicWrite(filePath, jsonString, createBackup) {
const tempPath = filePath + this.tempSuffix;
const backupPath = filePath + this.backupSuffix;
try {
// Create backup if file exists and backup requested
if (createBackup && await this._fileExists(filePath)) {
await fs.copyFile(filePath, backupPath);
}
// Write to temporary file first
await fs.writeFile(tempPath, jsonString, 'utf8');
// Verify temp file
await this._verifyWrittenFile(tempPath, jsonString);
// Atomic move from temp to final location
await fs.rename(tempPath, filePath);
// Clean up backup if write was successful
if (createBackup && await this._fileExists(backupPath)) {
// Keep backup for a short time, then clean up
setTimeout(async () => {
try {
await fs.unlink(backupPath);
} catch (error) {
// Ignore cleanup errors
}
}, 5000);
}
} catch (error) {
// Clean up temp file if it exists
try {
if (await this._fileExists(tempPath)) {
await fs.unlink(tempPath);
}
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error;
}
}
async _directWrite(filePath, jsonString, createBackup) {
const backupPath = filePath + this.backupSuffix;
// Create backup if file exists and backup requested
if (createBackup && await this._fileExists(filePath)) {
await fs.copyFile(filePath, backupPath);
}
try {
await fs.writeFile(filePath, jsonString, 'utf8');
} catch (error) {
// Restore from backup if available
if (createBackup && await this._fileExists(backupPath)) {
try {
await fs.copyFile(backupPath, filePath);
} catch (restoreError) {
// Log but don't throw restore error
console.error('Failed to restore from backup:', restoreError);
}
}
throw error;
}
}
async _verifyWrittenFile(filePath, expectedContent) {
try {
const actualContent = await fs.readFile(filePath, 'utf8');
// Check if content matches
if (actualContent !== expectedContent) {
throw new Error('File content verification failed: content mismatch');
}
// Validate JSON structure
JSON.parse(actualContent);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`File verification failed: invalid JSON - ${error.message}`);
}
throw error;
}
}
async _fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async safeReadJSON(filePath, options = {}) {
const { allowCorrupted = false, tryBackup = true } = options;
try {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error instanceof SyntaxError && !allowCorrupted) {
// Try to read from backup
const backupPath = filePath + this.backupSuffix;
if (tryBackup && await this._fileExists(backupPath)) {
try {
const backupContent = await fs.readFile(backupPath, 'utf8');
const data = JSON.parse(backupContent);
// Restore the main file from backup
await this.safeWriteJSON(filePath, data, { createBackup: false });
console.log(`Restored ${filePath} from backup due to corruption`);
return data;
} catch (backupError) {
console.error(`Backup restoration failed for ${filePath}:`, backupError);
}
}
// If we can't restore, clean up the corrupted file
await this._quarantineCorruptedFile(filePath);
}
throw error;
}
}
async _quarantineCorruptedFile(filePath) {
try {
const quarantinePath = filePath + '.corrupted.' + Date.now();
await fs.rename(filePath, quarantinePath);
console.log(`Quarantined corrupted file: ${filePath} -> ${quarantinePath}`);
} catch (error) {
console.error(`Failed to quarantine corrupted file ${filePath}:`, error);
}
}
async cleanupBackups(directory, maxAge = 24 * 60 * 60 * 1000) { // 24 hours default
try {
const files = await fs.readdir(directory);
const now = Date.now();
for (const file of files) {
if (file.endsWith(this.backupSuffix)) {
const filePath = join(directory, file);
const stats = await fs.stat(filePath);
if (now - stats.mtime.getTime() > maxAge) {
await fs.unlink(filePath);
console.log(`Cleaned up old backup: ${file}`);
}
}
}
} catch (error) {
console.error('Backup cleanup failed:', error);
}
}
async repairCorruptedFiles(directory) {
const results = {
scanned: 0,
corrupted: 0,
repaired: 0,
quarantined: 0
};
try {
const files = await fs.readdir(directory);
for (const file of files) {
if (file.endsWith('.json') && !file.endsWith(this.backupSuffix)) {
results.scanned++;
const filePath = join(directory, file);
try {
await this.safeReadJSON(filePath, { allowCorrupted: false, tryBackup: true });
} catch (error) {
results.corrupted++;
if (error instanceof SyntaxError) {
// Try to repair
try {
const repairedData = await this._attemptRepair(filePath);
if (repairedData) {
await this.safeWriteJSON(filePath, repairedData);
results.repaired++;
console.log(`Repaired corrupted file: ${file}`);
} else {
await this._quarantineCorruptedFile(filePath);
results.quarantined++;
}
} catch (repairError) {
await this._quarantineCorruptedFile(filePath);
results.quarantined++;
}
}
}
}
}
} catch (error) {
console.error('File repair scan failed:', error);
}
return results;
}
async _attemptRepair(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
// Try to find the end of the first valid JSON object
let braceCount = 0;
let jsonEnd = -1;
for (let i = 0; i < content.length; i++) {
if (content[i] === '{') {
braceCount++;
} else if (content[i] === '}') {
braceCount--;
if (braceCount === 0) {
jsonEnd = i + 1;
break;
}
}
}
if (jsonEnd > 0) {
const cleanedContent = content.substring(0, jsonEnd);
const parsed = JSON.parse(cleanedContent);
return parsed;
}
return null;
} catch (error) {
return null;
}
}
}