* Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. * - Writes to a temporary file first. * - If the target file exists, it's backed up before being re
(filePath: string, data: any, options?: SafeWriteJsonOptions)
| 33 | */ |
| 34 | |
| 35 | async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise<void> { |
| 36 | const absoluteFilePath = path.resolve(filePath) |
| 37 | let releaseLock = async () => {} // Initialized to a no-op |
| 38 | |
| 39 | // For directory creation |
| 40 | const dirPath = path.dirname(absoluteFilePath) |
| 41 | |
| 42 | // Ensure directory structure exists with improved reliability |
| 43 | try { |
| 44 | // Create directory with recursive option |
| 45 | await fs.mkdir(dirPath, { recursive: true }) |
| 46 | |
| 47 | // Verify directory exists after creation attempt |
| 48 | await fs.access(dirPath) |
| 49 | } catch (dirError: any) { |
| 50 | console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) |
| 51 | throw dirError |
| 52 | } |
| 53 | |
| 54 | // Acquire the lock before any file operations |
| 55 | try { |
| 56 | releaseLock = await lockfile.lock(absoluteFilePath, { |
| 57 | stale: 31000, // Stale after 31 seconds |
| 58 | update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long |
| 59 | realpath: false, // the file may not exist yet, which is acceptable |
| 60 | retries: { |
| 61 | // Configuration for retrying lock acquisition |
| 62 | retries: 5, // Number of retries after the initial attempt |
| 63 | factor: 2, // Exponential backoff factor (e.g., 100ms, 200ms, 400ms, ...) |
| 64 | minTimeout: 100, // Minimum time to wait before the first retry (in ms) |
| 65 | maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) |
| 66 | }, |
| 67 | onCompromised: (err) => { |
| 68 | console.error(`Lock at ${absoluteFilePath} was compromised:`, err) |
| 69 | throw err |
| 70 | }, |
| 71 | }) |
| 72 | } catch (lockError) { |
| 73 | // If lock acquisition fails, we throw immediately. |
| 74 | // The releaseLock remains a no-op, so the finally block in the main file operations |
| 75 | // try-catch-finally won't try to release an unacquired lock if this path is taken. |
| 76 | console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) |
| 77 | // Propagate the lock acquisition error |
| 78 | throw lockError |
| 79 | } |
| 80 | |
| 81 | // Variables to hold the actual paths of temp files if they are created. |
| 82 | let actualTempNewFilePath: string | null = null |
| 83 | let actualTempBackupFilePath: string | null = null |
| 84 | |
| 85 | try { |
| 86 | // Step 1: Write data to a new temporary file. |
| 87 | actualTempNewFilePath = path.join( |
| 88 | path.dirname(absoluteFilePath), |
| 89 | `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, |
| 90 | ) |
| 91 | |
| 92 | await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) |
no test coverage detected