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 };
  }
}