docs/archives/114-desktop-file-storage/README.md
实现桌面版从内存存储到文件存储的完整切换,为桌面应用提供可靠的数据持久化解决方案。
IStorageProvider 接口,一行代码完成切换根据用户偏好,采用可执行文件同级目录存储:
// 路径设置逻辑
if (app.isPackaged) {
// 生产环境:可执行文件目录/prompt-optimizer-data/
const execDir = path.dirname(process.execPath);
userDataPath = path.join(execDir, 'prompt-optimizer-data');
} else {
// 开发环境:项目根目录/prompt-optimizer-data/
userDataPath = path.join(__dirname, '..', '..', 'prompt-optimizer-data');
}
优势:
// 简单的一行切换
// const storage = StorageFactory.create('memory') // 旧方式
const storage = new FileStorageProvider(userDataPath) // 新方式
prompt-optimizer-data/prompt-optimizer-data.json 文件在实现过程中顺便修复了16个测试失败问题:
修复后测试结果:291个测试通过,9个跳过 ✅
问题发现: 在使用FileStorageProvider后,发现应用退出时出现无限循环保存数据的问题。
问题表现:
[DESKTOP] Saving data before quit...
[DESKTOP] Data saved successfully
[DESKTOP] Saving data before quit...
[DESKTOP] Data saved successfully
根本原因:
isDirty标志未重置window.close → before-quit → app.quit() → before-quit解决方案:
async flush(): Promise<void> {
// 检查重试次数限制
if (this.flushAttempts >= this.MAX_FLUSH_ATTEMPTS) {
console.error('Max flush attempts reached, forcing isDirty to false');
this.isDirty = false;
this.flushAttempts = 0;
throw new Error('Max flush attempts exceeded');
}
try {
await Promise.race([
this.saveToFile(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Flush timeout')), this.MAX_FLUSH_TIME)
)
]);
this.isDirty = false;
this.flushAttempts = 0;
} catch (error) {
// 强制重置状态避免无限重试
if (this.flushAttempts >= this.MAX_FLUSH_ATTEMPTS) {
this.isDirty = false;
this.flushAttempts = 0;
}
throw error;
}
}
let isQuitting = false;
const MAX_SAVE_TIME = 5000;
// 应急退出:10秒后强制终止
function setupEmergencyExit() {
const emergencyExitTimer = setTimeout(() => {
console.error('[DESKTOP] EMERGENCY EXIT: Force terminating process');
process.exit(1);
}, 10000);
return emergencyExitTimer;
}
app.on('before-quit', async (event) => {
if (!isQuitting && storageProvider) {
event.preventDefault();
isQuitting = true;
const emergencyTimer = setupEmergencyExit();
try {
await Promise.race([
storageProvider.flush(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Save timeout')), MAX_SAVE_TIME - 1000)
)
]);
} catch (error) {
console.error('Save failed:', error);
} finally {
clearTimeout(emergencyTimer);
setImmediate(() => {
isQuitting = false;
app.quit();
});
}
}
});
isQuitting标志防止重复执行这些补充修复确保了FileStorageProvider在各种异常情况下都能正常工作,并且应用能够可靠地退出。
在审查恢复逻辑时发现了一个严重的数据安全问题:
问题场景:
storage.json 损坏storage.json.backup 完好危险流程:
从备份恢复 → saveToFile() → createBackup() → 将损坏的主文件覆盖完好的备份!
如果后续的原子写入也失败,将导致数据永久丢失。
/**
* 专门用于恢复的保存方法,避免覆盖完好的备份
*/
private async saveToFileWithoutBackup(): Promise<void> {
const data = Object.fromEntries(this.data);
const jsonString = JSON.stringify(data, null, 2);
// 验证数据完整性
if (!this.validateJSON(jsonString)) {
throw new StorageError('Generated JSON is invalid', 'write');
}
// 直接原子写入,不创建备份
await this.atomicWrite(jsonString);
}
private async loadFromFileWithRecovery(): Promise<void> {
// 1. 尝试从主文件加载
const mainResult = await this.tryLoadFromFile(this.filePath, 'main');
if (mainResult.success) {
this.data = mainResult.data!;
await this.createBackup();
return;
}
// 2. 尝试从备份文件加载
const backupResult = await this.tryLoadFromFile(this.backupPath, 'backup');
if (backupResult.success) {
this.data = backupResult.data!;
// 关键:使用专门的方法避免覆盖备份
await this.saveToFileWithoutBackup();
// 主文件恢复成功后,重新创建备份
await this.createBackup();
return;
}
// 3. 区分首次运行和数据损坏
if (!await this.fileExists(this.filePath) && !await this.fileExists(this.backupPath)) {
// 首次运行
this.data = new Map();
await this.saveToFile();
} else {
// 严重错误:文件存在但都损坏
throw new StorageError('Storage corruption detected', 'read');
}
}
为防止并发操作导致的数据不一致,增强了updateData的原子性:
/**
* 原子性数据更新 - 增强版
*/
async updateData<T>(key: string, modifier: (currentValue: T | null) => T): Promise<void> {
await this.ensureInitialized();
// 使用更新锁确保原子性
const currentLock = this.updateLock;
let resolveLock: () => void;
this.updateLock = new Promise<void>((resolve) => {
resolveLock = resolve;
});
try {
await currentLock;
await this.performAtomicUpdate(key, modifier);
} finally {
resolveLock!();
}
}
/**
* 执行原子更新操作
*/
private async performAtomicUpdate<T>(key: string, modifier: (currentValue: T | null) => T): Promise<void> {
// 重新从存储读取最新数据,确保数据一致性
const latestData = await this.getLatestData<T>(key);
// 应用修改
const newValue = modifier(latestData);
// 验证新值
this.validateValue(newValue);
// 写入新值
this.data.set(key, JSON.stringify(newValue));
this.scheduleWrite();
}
it('should not overwrite good backup during recovery', async () => {
// 模拟损坏的主文件和完好的备份
mockFs.readFile
.mockResolvedValueOnce('{ invalid json') // 损坏的主文件
.mockResolvedValueOnce(JSON.stringify(goodData)); // 完好的备份
await provider.getItem('test');
// 验证没有覆盖备份
const dangerousCopyCall = mockFs.copyFile.mock.calls.find(call =>
call[0] === mainPath && call[1] === backupPath
);
expect(dangerousCopyCall).toBeUndefined();
});
it('should handle concurrent updates safely', async () => {
const promises = [
provider.updateData('key1', () => 'value1'),
provider.updateData('key2', () => 'value2'),
provider.updateData('key3', () => 'value3')
];
await Promise.all(promises);
// 验证所有更新都成功
expect(await provider.getItem('key1')).toBe('value1');
expect(await provider.getItem('key2')).toBe('value2');
expect(await provider.getItem('key3')).toBe('value3');
});
这些增强确保了FileStorageProvider在各种复杂场景下的数据安全性和操作原子性。