File size: 12,067 Bytes
1dbc34b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | /**
* 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 crypto from 'crypto';
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<void> {
// 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<T>(
filePath: string,
data: T,
options: AtomicWriteOptions = {}
): Promise<void> {
const { indent = 2, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath);
// Use timestamp + random suffix to ensure uniqueness even for concurrent writes
const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`;
// Always ensure parent directories exist before writing the temp file
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<T>(filePath: string, defaultValue: T): Promise<T> {
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<T>(
filePath: string,
defaultValue: T,
updater: (current: T) => T | Promise<T>,
options: AtomicWriteOptions = {}
): Promise<void> {
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<T> {
/** 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<T>(
result: ReadJsonRecoveryResult<T>,
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<T>(
filePath: string,
defaultValue: T,
options: ReadJsonRecoveryOptions = {}
): Promise<ReadJsonRecoveryResult<T>> {
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 };
}
}
|