fix: checkpoint WAL before database backup to prevent data loss

fs.copyFileSync on a WAL-mode SQLite database can produce incomplete
backups because committed data may still reside in the -wal file.
Added safeBackupCopy() that runs PRAGMA wal_checkpoint(TRUNCATE) before
copying, ensuring the .db file is self-contained.
This commit is contained in:
Pedram Amini
2026-02-05 18:12:06 -06:00
parent 33355253eb
commit 0502f2c7a4
2 changed files with 73 additions and 3 deletions

View File

@@ -851,6 +851,60 @@ describe('Daily backup system', () => {
expect(dailyBackupCalls).toHaveLength(0); expect(dailyBackupCalls).toHaveLength(0);
}); });
}); });
describe('WAL checkpoint before backup', () => {
it('should checkpoint WAL before creating daily backup', async () => {
const today = new Date().toISOString().split('T')[0];
mockFsExistsSync.mockImplementation((p: unknown) => {
if (typeof p === 'string' && p.includes(`daily.${today}`)) return false;
return true;
});
const { StatsDB } = await import('../../../main/stats');
const db = new StatsDB();
db.initialize();
// Should have called wal_checkpoint(TRUNCATE) before copyFileSync
expect(mockDb.pragma).toHaveBeenCalledWith('wal_checkpoint(TRUNCATE)');
});
it('should checkpoint WAL before creating manual backup', async () => {
const { StatsDB } = await import('../../../main/stats');
const db = new StatsDB();
db.initialize();
mockDb.pragma.mockClear();
db.backupDatabase();
expect(mockDb.pragma).toHaveBeenCalledWith('wal_checkpoint(TRUNCATE)');
});
it('should call checkpoint before copyFileSync (correct ordering)', async () => {
const callOrder: string[] = [];
mockDb.pragma.mockImplementation((pragmaStr: string) => {
if (pragmaStr === 'wal_checkpoint(TRUNCATE)') {
callOrder.push('checkpoint');
}
if (pragmaStr === 'integrity_check') return [{ integrity_check: 'ok' }];
return [{ user_version: 3 }];
});
mockFsCopyFileSync.mockImplementation(() => {
callOrder.push('copy');
});
const { StatsDB } = await import('../../../main/stats');
const db = new StatsDB();
db.initialize();
mockDb.pragma.mockClear();
mockFsCopyFileSync.mockClear();
callOrder.length = 0;
db.backupDatabase();
expect(callOrder).toEqual(['checkpoint', 'copy']);
});
});
}); });
/** /**

View File

@@ -321,6 +321,22 @@ export class StatsDB {
} }
} }
/**
* Checkpoint WAL to flush pending writes into the main database file,
* then copy the database file to the destination path.
*
* Plain fs.copyFileSync on a WAL-mode database can produce an incomplete
* copy because committed data may still reside in the -wal file.
* PRAGMA wal_checkpoint(TRUNCATE) forces all WAL content into the main
* file and resets the WAL, making the .db file self-contained.
*/
private safeBackupCopy(destPath: string): void {
if (this.db) {
this.db.pragma('wal_checkpoint(TRUNCATE)');
}
fs.copyFileSync(this.dbPath, destPath);
}
/** /**
* Create a backup of the current database file. * Create a backup of the current database file.
*/ */
@@ -333,7 +349,7 @@ export class StatsDB {
const timestamp = Date.now(); const timestamp = Date.now();
const backupPath = `${this.dbPath}.backup.${timestamp}`; const backupPath = `${this.dbPath}.backup.${timestamp}`;
fs.copyFileSync(this.dbPath, backupPath); this.safeBackupCopy(backupPath);
logger.info(`Created database backup at ${backupPath}`, LOG_CONTEXT); logger.info(`Created database backup at ${backupPath}`, LOG_CONTEXT);
return { success: true, backupPath }; return { success: true, backupPath };
@@ -367,8 +383,8 @@ export class StatsDB {
return; return;
} }
// Create today's backup // Create today's backup (checkpoint WAL first so the copy is self-contained)
fs.copyFileSync(this.dbPath, dailyBackupPath); this.safeBackupCopy(dailyBackupPath);
logger.info(`Created daily backup: ${dailyBackupPath}`, LOG_CONTEXT); logger.info(`Created daily backup: ${dailyBackupPath}`, LOG_CONTEXT);
// Rotate old backups (keep last 7 days) // Rotate old backups (keep last 7 days)