mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: Add safeWriteJson utility for atomic file operations
Implements a robust JSON file writing utility that: - Prevents concurrent writes to the same file using in-memory locks - Ensures atomic operations with temporary file and backup strategies - Handles error cases with proper rollback mechanisms - Cleans up temporary files even when operations fail - Provides comprehensive test coverage for success and failure scenarios Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>
This commit is contained in:
parent
6a9503da1d
commit
ac619f106b
2 changed files with 557 additions and 0 deletions
415
src/utils/__tests__/safeWriteJson.test.ts
Normal file
415
src/utils/__tests__/safeWriteJson.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
const actualFsPromises = jest.requireActual("fs/promises")
|
||||
const originalFsPromisesRename = actualFsPromises.rename
|
||||
const originalFsPromisesUnlink = actualFsPromises.unlink
|
||||
const originalFsPromisesWriteFile = actualFsPromises.writeFile
|
||||
const _originalFsPromisesAccess = actualFsPromises.access
|
||||
|
||||
jest.mock("fs/promises", () => {
|
||||
const actual = jest.requireActual("fs/promises")
|
||||
return {
|
||||
// Explicitly mock functions used by the SUT and tests, defaulting to actual implementations
|
||||
writeFile: jest.fn(actual.writeFile),
|
||||
readFile: jest.fn(actual.readFile),
|
||||
rename: jest.fn(actual.rename),
|
||||
unlink: jest.fn(actual.unlink),
|
||||
access: jest.fn(actual.access),
|
||||
mkdtemp: jest.fn(actual.mkdtemp),
|
||||
rm: jest.fn(actual.rm),
|
||||
readdir: jest.fn(actual.readdir),
|
||||
// Ensure all functions from 'fs/promises' that might be called are explicitly mocked
|
||||
// or ensure that the SUT and tests only call functions defined here.
|
||||
// For any function not listed, calls like fs.someOtherFunc would be undefined.
|
||||
}
|
||||
})
|
||||
|
||||
import * as fs from "fs/promises" // This will now be the mocked version
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import { safeWriteJson, activeLocks } from "../safeWriteJson"
|
||||
|
||||
describe("safeWriteJson", () => {
|
||||
let tempTestDir: string = ""
|
||||
let currentTestFilePath = ""
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temporary directory for each test
|
||||
const tempDirPrefix = path.join(os.tmpdir(), "safeWriteJson-test-")
|
||||
tempTestDir = await fs.mkdtemp(tempDirPrefix)
|
||||
currentTestFilePath = path.join(tempTestDir, "test-data.json")
|
||||
activeLocks.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempTestDir) {
|
||||
await fs.rm(tempTestDir, { recursive: true, force: true })
|
||||
tempTestDir = ""
|
||||
}
|
||||
activeLocks.clear()
|
||||
|
||||
// Explicitly reset mock implementations to default (actual) behavior
|
||||
// This helps prevent state leakage between tests if spy.mockRestore() isn't fully effective
|
||||
// for functions on the module mock created by the factory.
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(actualFsPromises.writeFile)
|
||||
;(fs.rename as jest.Mock).mockImplementation(actualFsPromises.rename)
|
||||
;(fs.unlink as jest.Mock).mockImplementation(actualFsPromises.unlink)
|
||||
;(fs.access as jest.Mock).mockImplementation(actualFsPromises.access)
|
||||
;(fs.readFile as jest.Mock).mockImplementation(actualFsPromises.readFile)
|
||||
;(fs.mkdtemp as jest.Mock).mockImplementation(actualFsPromises.mkdtemp)
|
||||
;(fs.rm as jest.Mock).mockImplementation(actualFsPromises.rm)
|
||||
;(fs.readdir as jest.Mock).mockImplementation(actualFsPromises.readdir)
|
||||
})
|
||||
|
||||
const readJsonFile = async (filePath: string): Promise<any | null> => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8") // Now uses the mocked fs
|
||||
return JSON.parse(content)
|
||||
} catch (error: any) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return null // File not found
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const listTempFiles = async (dir: string, baseName: string): Promise<string[]> => {
|
||||
const files = await fs.readdir(dir) // Now uses the mocked fs
|
||||
return files.filter((f: string) => f.startsWith(`.${baseName}.new_`) || f.startsWith(`.${baseName}.bak_`))
|
||||
}
|
||||
|
||||
// Success Scenarios
|
||||
test("should successfully write a new file when filePath does not exist", async () => {
|
||||
const data = { message: "Hello, new world!" }
|
||||
await safeWriteJson(currentTestFilePath, data)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(data)
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should successfully overwrite an existing file", async () => {
|
||||
const initialData = { message: "Initial content" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Now uses the mocked fs for setup
|
||||
|
||||
const newData = { message: "Updated content" }
|
||||
await safeWriteJson(currentTestFilePath, newData)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(newData)
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
})
|
||||
|
||||
// Failure Scenarios
|
||||
test("should handle failure when writing to tempNewFilePath", async () => {
|
||||
const data = { message: "This should not be written" }
|
||||
const writeFileSpy = jest.spyOn(fs, "writeFile")
|
||||
// Make the first call to writeFile (for tempNewFilePath) fail
|
||||
writeFileSpy.mockImplementationOnce(async (filePath: any, fileData: any, options?: any) => {
|
||||
if (typeof filePath === "string" && filePath.includes(".new_")) {
|
||||
throw new Error("Simulated FS Error: writeFile tempNewFilePath")
|
||||
}
|
||||
// For any other writeFile call (e.g. if tests write initial files), use original
|
||||
return actualFsPromises.writeFile(filePath, fileData, options) // Call actual for passthrough
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
"Simulated FS Error: writeFile tempNewFilePath",
|
||||
)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toBeNull() // File should not exist or be created
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp files should be cleaned up
|
||||
|
||||
writeFileSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => {
|
||||
const initialData = { message: "Initial content, should remain" }
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData)) // Use original for setup
|
||||
|
||||
const newData = { message: "This should not be written" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
// First rename is target to backup
|
||||
renameSpy.mockImplementationOnce(async (oldPath: any, newPath: any) => {
|
||||
if (typeof newPath === "string" && newPath.includes(".bak_")) {
|
||||
throw new Error("Simulated FS Error: rename to tempBackupFilePath")
|
||||
}
|
||||
return originalFsPromisesRename(oldPath, newPath) // Use constant
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow(
|
||||
"Simulated FS Error: rename to tempBackupFilePath",
|
||||
)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(initialData) // Original file should be intact
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// tempNewFile was created, but should be cleaned up. Backup was not created.
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(0)
|
||||
|
||||
renameSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => {
|
||||
const initialData = { message: "Initial content, should be restored" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
|
||||
const newData = { message: "This is in tempNewFilePath" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
let renameCallCountTest1 = 0
|
||||
renameSpy.mockImplementation(async (oldPath: any, newPath: any) => {
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
renameCallCountTest1++
|
||||
console.log(`[TEST 1] fs.rename spy call #${renameCallCountTest1}: ${oldPathStr} -> ${newPathStr}`)
|
||||
|
||||
// First rename call by safeWriteJson (if target exists) is target -> .bak
|
||||
if (renameCallCountTest1 === 1 && !oldPathStr.includes(".new_") && newPathStr.includes(".bak_")) {
|
||||
console.log("[TEST 1] Spy: Call #1 (target->backup), executing original rename.")
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
}
|
||||
// Second rename call by safeWriteJson is .new -> target
|
||||
else if (
|
||||
renameCallCountTest1 === 2 &&
|
||||
oldPathStr.includes(".new_") &&
|
||||
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
|
||||
) {
|
||||
console.log("[TEST 1] Spy: Call #2 (.new->target), THROWING SIMULATED ERROR.")
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
|
||||
}
|
||||
// Fallback for unexpected calls or if the target file didn't exist (only one rename: .new -> target)
|
||||
else if (
|
||||
renameCallCountTest1 === 1 &&
|
||||
oldPathStr.includes(".new_") &&
|
||||
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
|
||||
) {
|
||||
// This case handles if the initial file didn't exist, so only one rename happens.
|
||||
// For this specific test, we expect two renames.
|
||||
console.warn(
|
||||
"[TEST 1] Spy: Call #1 was .new->target, (unexpected for this test scenario, but handling)",
|
||||
)
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
|
||||
}
|
||||
console.warn(
|
||||
`[TEST 1] Spy: Unexpected call #${renameCallCountTest1} or paths. Defaulting to original rename. ${oldPathStr} -> ${newPathStr}`,
|
||||
)
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
})
|
||||
|
||||
// This scenario should reject because the new data couldn't be written to the final path,
|
||||
// even if rollback succeeds.
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow(
|
||||
"Simulated FS Error: rename tempNewFilePath to filePath",
|
||||
)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(initialData) // Original file should be restored from backup
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp/backup files should be cleaned up
|
||||
|
||||
renameSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should handle failure when deleting tempBackupFilePath (filePath exists, all renames succeed)", async () => {
|
||||
const initialData = { message: "Initial content" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
|
||||
const newData = { message: "This should be the final content" }
|
||||
const unlinkSpy = jest.spyOn(fs, "unlink")
|
||||
// The unlink that targets the backup file fails
|
||||
unlinkSpy.mockImplementationOnce(async (filePath: any) => {
|
||||
const filePathStr = filePath.toString()
|
||||
if (filePathStr.includes(".bak_")) {
|
||||
console.log("[TEST unlink bak] Mock: Simulating failure for unlink backup.")
|
||||
throw new Error("Simulated FS Error: delete tempBackupFilePath")
|
||||
}
|
||||
console.log("[TEST unlink bak] Mock: Condition NOT MET. Using originalFsPromisesUnlink.")
|
||||
return originalFsPromisesUnlink(filePath)
|
||||
})
|
||||
|
||||
// The function itself should still succeed from the user's perspective,
|
||||
// as the primary operation (writing the new data) was successful.
|
||||
// The error during backup cleanup is logged but not re-thrown to the caller.
|
||||
// However, the current implementation *does* re-throw. Let's test that behavior.
|
||||
// If the desired behavior is to not re-throw on backup cleanup failure, the main function needs adjustment.
|
||||
// The current safeWriteJson logic is to log the error and NOT reject.
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).resolves.toBeUndefined()
|
||||
|
||||
// The main file should be the new data
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(newData)
|
||||
|
||||
// Check that the cleanup failure was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Successfully wrote ${currentTestFilePath}, but failed to clean up backup`),
|
||||
expect.objectContaining({ message: "Simulated FS Error: delete tempBackupFilePath" }),
|
||||
)
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// The .new file is gone (renamed to target), the .bak file failed to delete
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(1) // Backup file remains
|
||||
|
||||
unlinkSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should handle failure when renaming tempNewFilePath to filePath (filePath does not exist)", async () => {
|
||||
const data = { message: "This should not be written" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
// The rename from tempNew to target fails
|
||||
renameSpy.mockImplementationOnce(async (oldPath: any, newPath: any) => {
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
if (oldPathStr.includes(".new_") && path.resolve(newPathStr) === path.resolve(currentTestFilePath)) {
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath (no prior file)")
|
||||
}
|
||||
return originalFsPromisesRename(oldPath, newPath) // Use constant
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
"Simulated FS Error: rename tempNewFilePath to filePath (no prior file)",
|
||||
)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toBeNull() // File should not exist
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp files should be cleaned up
|
||||
|
||||
renameSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should throw an error if a lock is already held for the filePath", async () => {
|
||||
const data = { message: "test lock" }
|
||||
// Manually acquire lock for testing purposes
|
||||
activeLocks.add(path.resolve(currentTestFilePath))
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
`File operation already in progress for this path: ${path.resolve(currentTestFilePath)}`,
|
||||
)
|
||||
|
||||
// Ensure lock is still there (safeWriteJson shouldn't release if it didn't acquire)
|
||||
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(true)
|
||||
activeLocks.delete(path.resolve(currentTestFilePath)) // Manual cleanup for this test
|
||||
})
|
||||
test("should release lock even if an error occurs mid-operation", async () => {
|
||||
const data = { message: "test lock release on error" }
|
||||
const writeFileSpy = jest.spyOn(fs, "writeFile").mockImplementationOnce(async () => {
|
||||
throw new Error("Simulated FS Error during writeFile")
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Simulated FS Error during writeFile")
|
||||
|
||||
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(false) // Lock should be released
|
||||
|
||||
writeFileSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("should handle fs.access error that is not ENOENT", async () => {
|
||||
const data = { message: "access error test" }
|
||||
const accessSpy = jest.spyOn(fs, "access").mockImplementationOnce(async () => {
|
||||
const err = new Error("Simulated EACCES Error") as NodeJS.ErrnoException
|
||||
err.code = "EACCES" // Simulate a permissions error, for example
|
||||
throw err
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Simulated EACCES Error")
|
||||
|
||||
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(false) // Lock should be released
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// .new file might have been created before access check, should be cleaned up
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
|
||||
accessSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Test for rollback failure scenario
|
||||
test("should log error and re-throw original if rollback fails", async () => {
|
||||
const initialData = { message: "Initial, should be lost if rollback fails" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
const newData = { message: "New data" }
|
||||
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
let renameCallCountTest2 = 0
|
||||
|
||||
renameSpy.mockImplementation(async (oldPath: any, newPath: any) => {
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
renameCallCountTest2++
|
||||
const resolvedOldPath = path.resolve(oldPathStr)
|
||||
const resolvedNewPath = path.resolve(newPathStr)
|
||||
const resolvedCurrentTFP = path.resolve(currentTestFilePath)
|
||||
console.log(
|
||||
`[TEST 2] fs.promises.rename call #${renameCallCountTest2}: oldPath=${oldPathStr} (resolved: ${resolvedOldPath}), newPath=${newPathStr} (resolved: ${resolvedNewPath}), currentTFP (resolved: ${resolvedCurrentTFP})`,
|
||||
)
|
||||
|
||||
if (renameCallCountTest2 === 1) {
|
||||
// Call 1: Original -> Backup (Succeeds)
|
||||
if (resolvedOldPath === resolvedCurrentTFP && newPathStr.includes(".bak_")) {
|
||||
console.log("[TEST 2] Call #1 (Original->Backup): Condition MET. originalFsPromisesRename.")
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
}
|
||||
console.error("[TEST 2] Call #1: UNEXPECTED args.")
|
||||
throw new Error("Unexpected args for rename call #1 in test")
|
||||
} else if (renameCallCountTest2 === 2) {
|
||||
// Call 2: New -> Original (Fails - this is the "original error")
|
||||
if (oldPathStr.includes(".new_") && resolvedNewPath === resolvedCurrentTFP) {
|
||||
console.log(
|
||||
'[TEST 2] Call #2 (New->Original): Condition MET. Throwing "Simulated FS Error: new to original".',
|
||||
)
|
||||
throw new Error("Simulated FS Error: new to original")
|
||||
}
|
||||
console.error("[TEST 2] Call #2: UNEXPECTED args.")
|
||||
throw new Error("Unexpected args for rename call #2 in test")
|
||||
} else if (renameCallCountTest2 === 3) {
|
||||
// Call 3: Backup -> Original (Rollback attempt - Fails)
|
||||
if (oldPathStr.includes(".bak_") && resolvedNewPath === resolvedCurrentTFP) {
|
||||
console.log(
|
||||
'[TEST 2] Call #3 (Backup->Original Rollback): Condition MET. Throwing "Simulated FS Error: backup to original (rollback)".',
|
||||
)
|
||||
throw new Error("Simulated FS Error: backup to original (rollback)")
|
||||
}
|
||||
console.error("[TEST 2] Call #3: UNEXPECTED args.")
|
||||
throw new Error("Unexpected args for rename call #3 in test")
|
||||
}
|
||||
console.error(`[TEST 2] Unexpected fs.promises.rename call count: ${renameCallCountTest2}`)
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Simulated FS Error: new to original")
|
||||
|
||||
// Check that the rollback failure was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Operation failed for ${path.resolve(currentTestFilePath)}: [Original Error Caught]`,
|
||||
),
|
||||
expect.objectContaining({ message: "Simulated FS Error: new to original" }), // The original error
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[Catch\] Failed to restore backup .*?\.bak_.*?\s+to .*?:/), // Matches the backup filename pattern
|
||||
expect.objectContaining({ message: "Simulated FS Error: backup to original (rollback)" }), // The rollback error
|
||||
)
|
||||
// The original error is logged first in safeWriteJson's catch block, then the rollback failure.
|
||||
|
||||
// File system state: original file is lost (backup couldn't be restored and was then unlinked),
|
||||
// new file was cleaned up. The target path `currentTestFilePath` should not exist.
|
||||
const finalState = await readJsonFile(currentTestFilePath)
|
||||
expect(finalState).toBeNull()
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// Backup file should also be cleaned up by the final unlink attempt in safeWriteJson's catch block,
|
||||
// as that unlink is not mocked to fail.
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
|
||||
renameSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
142
src/utils/safeWriteJson.ts
Normal file
142
src/utils/safeWriteJson.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
const activeLocks = new Set<string>()
|
||||
|
||||
/**
|
||||
* Safely writes JSON data to a file.
|
||||
* - Uses an in-memory advisory lock 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 replaced.
|
||||
* - Attempts to roll back and clean up in case of errors.
|
||||
*
|
||||
* @param {string} filePath - The absolute path to the target file.
|
||||
* @param {any} data - The data to serialize to JSON and write.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function safeWriteJson(filePath: string, data: any): Promise<void> {
|
||||
const absoluteFilePath = path.resolve(filePath)
|
||||
|
||||
if (activeLocks.has(absoluteFilePath)) {
|
||||
throw new Error(`File operation already in progress for this path: ${absoluteFilePath}`)
|
||||
}
|
||||
|
||||
activeLocks.add(absoluteFilePath)
|
||||
|
||||
// Variables to hold the actual paths of temp files if they are created.
|
||||
let actualTempNewFilePath: string | null = null
|
||||
let actualTempBackupFilePath: string | null = null
|
||||
|
||||
try {
|
||||
// Step 1: Write data to a new temporary file.
|
||||
actualTempNewFilePath = path.join(
|
||||
path.dirname(absoluteFilePath),
|
||||
`.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`,
|
||||
)
|
||||
const jsonData = JSON.stringify(data, null, 2)
|
||||
await fs.writeFile(actualTempNewFilePath, jsonData, "utf8")
|
||||
|
||||
// Step 2: Check if the target file exists. If so, rename it to a backup path.
|
||||
try {
|
||||
await fs.access(absoluteFilePath) // Check for target file existence
|
||||
// Target exists, create a backup path and rename.
|
||||
actualTempBackupFilePath = path.join(
|
||||
path.dirname(absoluteFilePath),
|
||||
`.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`,
|
||||
)
|
||||
await fs.rename(absoluteFilePath, actualTempBackupFilePath)
|
||||
} catch (accessError: any) {
|
||||
// Explicitly type accessError
|
||||
if (accessError.code !== "ENOENT") {
|
||||
// An error other than "file not found" occurred during access check.
|
||||
throw accessError
|
||||
}
|
||||
// Target file does not exist, so no backup is made. actualTempBackupFilePath remains null.
|
||||
}
|
||||
|
||||
// Step 3: Rename the new temporary file to the target file path.
|
||||
// This is the main "commit" step.
|
||||
await fs.rename(actualTempNewFilePath, absoluteFilePath)
|
||||
|
||||
// If we reach here, the new file is successfully in place.
|
||||
// The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp".
|
||||
// const _successfullyMovedNewFile = actualTempNewFilePath; // This variable is unused
|
||||
actualTempNewFilePath = null // Mark as "used" or "committed"
|
||||
|
||||
// Step 4: If a backup was created, attempt to delete it.
|
||||
if (actualTempBackupFilePath) {
|
||||
try {
|
||||
await fs.unlink(actualTempBackupFilePath)
|
||||
// console.log(`Successfully deleted backup file: ${actualTempBackupFilePath}`);
|
||||
actualTempBackupFilePath = null // Mark backup as handled
|
||||
} catch (unlinkBackupError) {
|
||||
// Log this error, but do not re-throw. The main operation was successful.
|
||||
console.error(
|
||||
`Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`,
|
||||
unlinkBackupError,
|
||||
)
|
||||
// actualTempBackupFilePath remains set, indicating an orphaned backup.
|
||||
}
|
||||
}
|
||||
} catch (originalError) {
|
||||
console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError)
|
||||
|
||||
const newFileToCleanupWithinCatch = actualTempNewFilePath
|
||||
const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath
|
||||
|
||||
// Attempt rollback if a backup was made
|
||||
if (backupFileToRollbackOrCleanupWithinCatch) {
|
||||
try {
|
||||
// Inner try for rollback
|
||||
console.log(
|
||||
`[Catch] Attempting to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}`,
|
||||
)
|
||||
await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath)
|
||||
console.log(
|
||||
`[Catch] Successfully restored backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}.`,
|
||||
)
|
||||
actualTempBackupFilePath = null // Mark as handled, prevent later unlink of this path
|
||||
} catch (rollbackError) {
|
||||
console.error(
|
||||
`[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`,
|
||||
rollbackError,
|
||||
)
|
||||
// actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup the .new file if it exists
|
||||
if (newFileToCleanupWithinCatch) {
|
||||
try {
|
||||
// Inner try for new file cleanup
|
||||
await fs.unlink(newFileToCleanupWithinCatch)
|
||||
console.log(`[Catch] Cleaned up temporary new file: ${newFileToCleanupWithinCatch}`)
|
||||
} catch (cleanupError) {
|
||||
console.error(
|
||||
`[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`,
|
||||
cleanupError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored)
|
||||
if (actualTempBackupFilePath) {
|
||||
// Checks outer scope var, which is null if rollback succeeded
|
||||
try {
|
||||
// Inner try for backup file cleanup
|
||||
await fs.unlink(actualTempBackupFilePath)
|
||||
console.log(`[Catch] Cleaned up temporary backup file: ${actualTempBackupFilePath}`)
|
||||
} catch (cleanupError) {
|
||||
console.error(
|
||||
`[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`,
|
||||
cleanupError,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw originalError // This MUST be the error that rejects the promise.
|
||||
} finally {
|
||||
activeLocks.delete(absoluteFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
export { safeWriteJson, activeLocks } // Export activeLocks for testing lock contention
|
||||
Loading…
Add table
Reference in a new issue