refactor(settings): sequential settings migration

This commit is contained in:
mingholy.lmh 2026-02-28 18:13:25 +08:00
parent ac5a0c68e5
commit ae8c0d3d4e
18 changed files with 3527 additions and 944 deletions

View file

@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { writeWithBackup, writeWithBackupSync } from './writeWithBackup.js';
describe('writeWithBackup', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'writeWithBackup-test-'));
});
afterEach(() => {
// Clean up temp directory
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (_e) {
// Ignore cleanup errors
}
});
describe('writeWithBackupSync', () => {
it('should write content to a new file', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const content = 'Hello, World!';
writeWithBackupSync(targetPath, content);
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(content);
});
it('should backup existing file before writing', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const originalContent = 'Original content';
const newContent = 'New content';
fs.writeFileSync(targetPath, originalContent);
writeWithBackupSync(targetPath, newContent);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(newContent);
expect(fs.existsSync(`${targetPath}.orig`)).toBe(true);
expect(fs.readFileSync(`${targetPath}.orig`, 'utf-8')).toBe(
originalContent,
);
});
it('should use custom backup suffix', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const originalContent = 'Original';
fs.writeFileSync(targetPath, originalContent);
writeWithBackupSync(targetPath, 'New', { backupSuffix: '.bak' });
expect(fs.existsSync(`${targetPath}.bak`)).toBe(true);
expect(fs.existsSync(`${targetPath}.orig`)).toBe(false);
});
it('should clean up temp file on failure', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const tempPath = `${targetPath}.tmp`;
// Create a situation where rename will fail (e.g., by creating a directory at target)
fs.mkdirSync(targetPath);
expect(() => writeWithBackupSync(targetPath, 'content')).toThrow();
expect(fs.existsSync(tempPath)).toBe(false);
});
it('should preserve original file content when write fails after backup', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const originalContent = 'Original content that must be preserved';
// Create original file
fs.writeFileSync(targetPath, originalContent);
// Create a situation where rename will fail (by creating a directory at temp path)
const tempPath = `${targetPath}.tmp`;
fs.mkdirSync(tempPath);
// The write should fail
expect(() => writeWithBackupSync(targetPath, 'New content')).toThrow();
// Original file should still exist with original content
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.statSync(targetPath).isFile()).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(originalContent);
// Cleanup
fs.rmdirSync(tempPath);
});
it('should restore original file from backup when rename fails', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const backupPath = `${targetPath}.orig`;
const originalContent = 'Original content';
const newContent = 'New content';
// Create original file
fs.writeFileSync(targetPath, originalContent);
// Write new content successfully first
writeWithBackupSync(targetPath, newContent);
// Verify backup exists with original content
expect(fs.existsSync(backupPath)).toBe(true);
expect(fs.readFileSync(backupPath, 'utf-8')).toBe(originalContent);
// Verify target has new content
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(newContent);
// Now simulate a failure scenario: delete target and try to restore from backup
fs.unlinkSync(targetPath);
// Restore from backup manually to verify backup integrity
fs.copyFileSync(backupPath, targetPath);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(originalContent);
});
it('should include recovery information in error message', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
// Create a situation where rename will fail (directory at target)
fs.mkdirSync(targetPath);
let errorMessage = '';
try {
writeWithBackupSync(targetPath, 'content');
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
// Error message should be descriptive
expect(errorMessage).toContain('directory');
expect(errorMessage.length).toBeGreaterThan(10);
});
it('should handle backup failure with descriptive error', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const backupPath = `${targetPath}.orig`;
const originalContent = 'Original content';
// Create original file
fs.writeFileSync(targetPath, originalContent);
// Create a directory at backup path to cause backup to fail
fs.mkdirSync(backupPath);
let errorMessage = '';
try {
writeWithBackupSync(targetPath, 'New content');
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
// Error message should mention backup failure
expect(errorMessage).toContain('backup');
// Original file should still exist
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(originalContent);
// Cleanup
fs.rmdirSync(backupPath);
});
it('should clean up temp file when backup creation fails', () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const tempPath = `${targetPath}.tmp`;
const backupPath = `${targetPath}.orig`;
const originalContent = 'Original content';
// Create original file
fs.writeFileSync(targetPath, originalContent);
// Create a directory at backup path to cause backup to fail
fs.mkdirSync(backupPath);
// The write should fail
expect(() => writeWithBackupSync(targetPath, 'New content')).toThrow();
// Temp file should be cleaned up
expect(fs.existsSync(tempPath)).toBe(false);
// Cleanup
fs.rmdirSync(backupPath);
});
});
describe('writeWithBackup (async)', () => {
it('should write content to a new file', async () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const content = 'Hello, World!';
await writeWithBackup(targetPath, content);
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(content);
});
it('should backup existing file before writing', async () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const originalContent = 'Original content';
const newContent = 'New content';
fs.writeFileSync(targetPath, originalContent);
await writeWithBackup(targetPath, newContent);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(newContent);
expect(fs.existsSync(`${targetPath}.orig`)).toBe(true);
expect(fs.readFileSync(`${targetPath}.orig`, 'utf-8')).toBe(
originalContent,
);
});
it('should use custom encoding', async () => {
const targetPath = path.join(tempDir, 'test-file.txt');
const content = 'Hello, World!';
await writeWithBackup(targetPath, content, { encoding: 'utf8' });
expect(fs.readFileSync(targetPath, 'utf-8')).toBe(content);
});
});
});

View file

@ -0,0 +1,169 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
/**
* Options for writeWithBackup function.
*/
export interface WriteWithBackupOptions {
/** Suffix for backup file (default: '.orig') */
backupSuffix?: string;
/** File encoding (default: 'utf-8') */
encoding?: BufferEncoding;
}
/**
* Safely writes content to a file with backup protection.
*
* This function ensures data safety by:
* 1. Writing content to a temporary file first
* 2. Backing up the existing target file (if any)
* 3. Renaming the temporary file to the target path
*
* If any step fails, an error is thrown and no partial changes are left on disk.
* The backup file (if created) can be used for manual recovery.
*
* Note: This is not 100% atomic but provides good protection. In the worst case,
* a .orig backup file remains that can be manually restored.
*
* @param targetPath - The path to write to
* @param content - The content to write
* @param options - Optional configuration
* @throws Error if any step of the write process fails
*
* @example
* ```typescript
* await writeWithBackup('/path/to/settings.json', JSON.stringify(settings, null, 2));
* // If /path/to/settings.json existed, it's now backed up to /path/to/settings.json.orig
* ```
*/
export async function writeWithBackup(
targetPath: string,
content: string,
options: WriteWithBackupOptions = {},
): Promise<void> {
// Async version delegates to sync version since file operations are synchronous
writeWithBackupSync(targetPath, content, options);
}
/**
* Synchronous version of writeWithBackup.
*
* @param targetPath - The path to write to
* @param content - The content to write
* @param options - Optional configuration
* @throws Error if any step of the write process fails
*/
export function writeWithBackupSync(
targetPath: string,
content: string,
options: WriteWithBackupOptions = {},
): void {
const { backupSuffix = '.orig', encoding = 'utf-8' } = options;
const tempPath = `${targetPath}.tmp`;
const backupPath = `${targetPath}${backupSuffix}`;
// Clean up any existing temp file from previous failed attempts
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (_e) {
// Ignore cleanup errors
}
try {
// Step 1: Write to temporary file
fs.writeFileSync(tempPath, content, { encoding });
// Step 2: If target exists, back it up
if (fs.existsSync(targetPath)) {
// Check if target is a directory - we can't write to a directory
const targetStat = fs.statSync(targetPath);
if (targetStat.isDirectory()) {
// Clean up temp file before throwing
try {
fs.unlinkSync(tempPath);
} catch (_e) {
// Ignore cleanup error
}
throw new Error(
`Cannot write to '${targetPath}' because it is a directory`,
);
}
try {
fs.renameSync(targetPath, backupPath);
} catch (backupError) {
// Clean up temp file before throwing
try {
fs.unlinkSync(tempPath);
} catch (_e) {
// Ignore cleanup error
}
throw new Error(
`Failed to backup existing file: ${backupError instanceof Error ? backupError.message : String(backupError)}`,
);
}
}
// Step 3: Rename temp file to target
try {
fs.renameSync(tempPath, targetPath);
} catch (renameError) {
let restoreFailedMessage: string | undefined;
let backupExisted = false;
// Attempt to restore backup if rename failed
if (fs.existsSync(backupPath)) {
backupExisted = true;
try {
fs.renameSync(backupPath, targetPath);
} catch (restoreError) {
restoreFailedMessage =
restoreError instanceof Error
? restoreError.message
: String(restoreError);
}
}
const writeFailureMessage =
renameError instanceof Error
? renameError.message
: String(renameError);
if (restoreFailedMessage) {
throw new Error(
`Failed to write file: ${writeFailureMessage}. ` +
`Automatic restore failed: ${restoreFailedMessage}. ` +
`Manual recovery may be required using backup file '${backupPath}'.`,
);
}
if (backupExisted) {
throw new Error(
`Failed to write file: ${writeFailureMessage}. ` +
`Target was automatically restored from backup '${backupPath}'.`,
);
}
throw new Error(
`Failed to write file: ${writeFailureMessage}. No backup file was available for restoration.`,
);
}
} catch (error) {
// Ensure temp file is cleaned up on any error
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (_e) {
// Ignore cleanup error
}
throw error;
}
}