/** * Atomic file writing utilities for JSON data * * Provides atomic write operations using temp-file + rename pattern, * ensuring data integrity even during crashes or power failures. */ import { secureFs } from '@automaker/platform'; import path from 'path'; import { createLogger } from './logger.js'; import { mkdirSafe } from './fs-utils.js'; const logger = createLogger('AtomicWriter'); /** Default maximum number of backup files to keep for crash recovery */ export const DEFAULT_BACKUP_COUNT = 3; /** * Options for atomic write operations */ export interface AtomicWriteOptions { /** Number of spaces for JSON indentation (default: 2) */ indent?: number; /** Create parent directories if they don't exist (default: false) */ createDirs?: boolean; /** Number of backup files to keep (0 = no backups, default: 0). When > 0, rotates .bak1, .bak2, etc. */ backupCount?: number; } /** * Rotate backup files (.bak1 -> .bak2 -> .bak3, oldest is deleted) * and create a new backup from the current file. * * @param filePath - Absolute path to the file being backed up * @param maxBackups - Maximum number of backup files to keep */ export async function rotateBackups( filePath: string, maxBackups: number = DEFAULT_BACKUP_COUNT ): Promise { // Check if the source file exists before attempting backup try { await secureFs.access(filePath); } catch { // No existing file to backup return; } // Rotate existing backups: .bak3 is deleted, .bak2 -> .bak3, .bak1 -> .bak2 for (let i = maxBackups; i >= 1; i--) { const currentBackup = `${filePath}.bak${i}`; const nextBackup = `${filePath}.bak${i + 1}`; try { if (i === maxBackups) { // Delete the oldest backup await secureFs.unlink(currentBackup); } else { // Rename current backup to next slot await secureFs.rename(currentBackup, nextBackup); } } catch { // Ignore errors - backup file may not exist } } // Copy current file to .bak1 try { await secureFs.copyFile(filePath, `${filePath}.bak1`); } catch (error) { logger.warn(`Failed to create backup of ${filePath}:`, error); // Continue with write even if backup fails } } /** * Atomically write JSON data to a file. * * Uses the temp-file + rename pattern for atomicity: * 1. Writes data to a temporary file * 2. Atomically renames temp file to target path * 3. Cleans up temp file on error * * @param filePath - Absolute path to the target file * @param data - Data to serialize as JSON * @param options - Optional write options * @throws Error if write fails (temp file is cleaned up) * * @example * ```typescript * await atomicWriteJson('/path/to/config.json', { key: 'value' }); * await atomicWriteJson('/path/to/data.json', data, { indent: 4, createDirs: true }); * ``` */ export async function atomicWriteJson( filePath: string, data: T, options: AtomicWriteOptions = {} ): Promise { const { indent = 2, createDirs = false, backupCount = 0 } = options; const resolvedPath = path.resolve(filePath); const tempPath = `${resolvedPath}.tmp.${Date.now()}`; // Create parent directories if requested if (createDirs) { const dirPath = path.dirname(resolvedPath); await mkdirSafe(dirPath); } const content = JSON.stringify(data, null, indent); try { // Rotate backups before writing (if backups are enabled) if (backupCount > 0) { await rotateBackups(resolvedPath, backupCount); } await secureFs.writeFile(tempPath, content, 'utf-8'); await secureFs.rename(tempPath, resolvedPath); } catch (error) { // Clean up temp file if it exists try { await secureFs.unlink(tempPath); } catch { // Ignore cleanup errors - best effort } logger.error(`Failed to atomically write to ${resolvedPath}:`, error); throw error; } } /** * Safely read JSON from a file with fallback to default value. * * Returns the default value if: * - File doesn't exist (ENOENT) * - File content is invalid JSON * * @param filePath - Absolute path to the file * @param defaultValue - Value to return if file doesn't exist or is invalid * @returns Parsed JSON data or default value * * @example * ```typescript * const config = await readJsonFile('/path/to/config.json', { version: 1 }); * ``` */ export async function readJsonFile(filePath: string, defaultValue: T): Promise { const resolvedPath = path.resolve(filePath); try { const content = (await secureFs.readFile(resolvedPath, 'utf-8')) as string; return JSON.parse(content) as T; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { return defaultValue; } logger.error(`Error reading JSON from ${resolvedPath}:`, error); return defaultValue; } } /** * Atomically update a JSON file by reading, transforming, and writing. * * Provides a safe read-modify-write pattern: * 1. Reads existing file (or uses default) * 2. Applies updater function * 3. Atomically writes result * * @param filePath - Absolute path to the file * @param defaultValue - Default value if file doesn't exist * @param updater - Function that transforms the data * @param options - Optional write options * * @example * ```typescript * await updateJsonAtomically( * '/path/to/counter.json', * { count: 0 }, * (data) => ({ ...data, count: data.count + 1 }) * ); * ``` */ export async function updateJsonAtomically( filePath: string, defaultValue: T, updater: (current: T) => T | Promise, options: AtomicWriteOptions = {} ): Promise { const current = await readJsonFile(filePath, defaultValue); const updated = await updater(current); await atomicWriteJson(filePath, updated, options); } /** * Result of a JSON read operation with recovery information */ export interface ReadJsonRecoveryResult { /** The data that was successfully read */ data: T; /** Whether recovery was needed (main file was corrupted or missing) */ recovered: boolean; /** Source of the data: 'main', 'backup', 'temp', or 'default' */ source: 'main' | 'backup' | 'temp' | 'default'; /** Error message if the main file had an issue */ error?: string; } /** * Options for readJsonWithRecovery */ export interface ReadJsonRecoveryOptions { /** Maximum number of backup files to check (.bak1, .bak2, etc.) Default: 3 */ maxBackups?: number; /** Whether to automatically restore main file from backup when corrupted. Default: true */ autoRestore?: boolean; } /** * Log a warning if recovery was needed (from backup or temp file). * * Use this helper to reduce duplicate logging code when using readJsonWithRecovery. * * @param result - The result from readJsonWithRecovery * @param identifier - A human-readable identifier for the file being recovered (e.g., "Feature abc123") * @param loggerInstance - Optional logger instance to use (defaults to AtomicWriter logger) * * @example * ```typescript * const result = await readJsonWithRecovery(featurePath, null); * logRecoveryWarning(result, `Feature ${featureId}`); * ``` */ export function logRecoveryWarning( result: ReadJsonRecoveryResult, identifier: string, loggerInstance: { warn: (msg: string, ...args: unknown[]) => void } = logger ): void { if (result.recovered && result.source !== 'default') { loggerInstance.warn(`${identifier} was recovered from ${result.source}: ${result.error}`); } } /** * Read JSON file with automatic recovery from backups. * * This function attempts to read a JSON file with fallback to backups: * 1. Try to read the main file * 2. If corrupted, check for temp files (.tmp.*) that might have valid data * 3. If no valid temp file, try backup files (.bak1, .bak2, .bak3) * 4. If all fail, return the default value * * Optionally restores the main file from a valid backup (autoRestore: true). * * @param filePath - Absolute path to the file * @param defaultValue - Value to return if no valid data found * @param options - Recovery options * @returns Result containing the data and recovery information * * @example * ```typescript * const result = await readJsonWithRecovery('/path/to/config.json', { version: 1 }); * if (result.recovered) { * console.log(`Recovered from ${result.source}: ${result.error}`); * } * const config = result.data; * ``` */ export async function readJsonWithRecovery( filePath: string, defaultValue: T, options: ReadJsonRecoveryOptions = {} ): Promise> { const { maxBackups = 3, autoRestore = true } = options; const resolvedPath = path.resolve(filePath); const dirPath = path.dirname(resolvedPath); const fileName = path.basename(resolvedPath); // Try to read the main file first try { const content = (await secureFs.readFile(resolvedPath, 'utf-8')) as string; const data = JSON.parse(content) as T; return { data, recovered: false, source: 'main' }; } catch (mainError) { const nodeError = mainError as NodeJS.ErrnoException; const errorMessage = nodeError.code === 'ENOENT' ? 'File does not exist' : `Failed to parse: ${mainError instanceof Error ? mainError.message : String(mainError)}`; // If file doesn't exist, check for temp files or backups logger.warn(`Main file ${resolvedPath} unavailable: ${errorMessage}`); // Try to find and recover from temp files first (in case of interrupted write) try { const files = (await secureFs.readdir(dirPath)) as string[]; const tempFiles = files .filter((f: string) => f.startsWith(`${fileName}.tmp.`)) .sort() .reverse(); // Most recent first for (const tempFile of tempFiles) { const tempPath = path.join(dirPath, tempFile); try { const content = (await secureFs.readFile(tempPath, 'utf-8')) as string; const data = JSON.parse(content) as T; logger.info(`Recovered data from temp file: ${tempPath}`); // Optionally restore main file from temp if (autoRestore) { try { await secureFs.rename(tempPath, resolvedPath); logger.info(`Restored main file from temp: ${tempPath}`); } catch (restoreError) { logger.warn(`Failed to restore main file from temp: ${restoreError}`); } } return { data, recovered: true, source: 'temp', error: errorMessage }; } catch { // This temp file is also corrupted, try next continue; } } } catch { // Could not read directory, skip temp file check } // Try backup files (.bak1, .bak2, .bak3) for (let i = 1; i <= maxBackups; i++) { const backupPath = `${resolvedPath}.bak${i}`; try { const content = (await secureFs.readFile(backupPath, 'utf-8')) as string; const data = JSON.parse(content) as T; logger.info(`Recovered data from backup: ${backupPath}`); // Optionally restore main file from backup if (autoRestore) { try { await secureFs.copyFile(backupPath, resolvedPath); logger.info(`Restored main file from backup: ${backupPath}`); } catch (restoreError) { logger.warn(`Failed to restore main file from backup: ${restoreError}`); } } return { data, recovered: true, source: 'backup', error: errorMessage }; } catch { // This backup doesn't exist or is corrupted, try next continue; } } // All recovery attempts failed, return default logger.warn(`All recovery attempts failed for ${resolvedPath}, using default value`); return { data: defaultValue, recovered: true, source: 'default', error: errorMessage }; } }