From 0502f2c7a4e2c3a8b1540f76d0b8c3ec8e618bf8 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Feb 2026 18:12:06 -0600 Subject: [PATCH] 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. --- src/__tests__/main/stats/stats-db.test.ts | 54 +++++++++++++++++++++++ src/main/stats/stats-db.ts | 22 +++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/__tests__/main/stats/stats-db.test.ts b/src/__tests__/main/stats/stats-db.test.ts index 0f2d20b7..047078f9 100644 --- a/src/__tests__/main/stats/stats-db.test.ts +++ b/src/__tests__/main/stats/stats-db.test.ts @@ -851,6 +851,60 @@ describe('Daily backup system', () => { 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']); + }); + }); }); /** diff --git a/src/main/stats/stats-db.ts b/src/main/stats/stats-db.ts index 957413b5..b6e6d139 100644 --- a/src/main/stats/stats-db.ts +++ b/src/main/stats/stats-db.ts @@ -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. */ @@ -333,7 +349,7 @@ export class StatsDB { const timestamp = Date.now(); const backupPath = `${this.dbPath}.backup.${timestamp}`; - fs.copyFileSync(this.dbPath, backupPath); + this.safeBackupCopy(backupPath); logger.info(`Created database backup at ${backupPath}`, LOG_CONTEXT); return { success: true, backupPath }; @@ -367,8 +383,8 @@ export class StatsDB { return; } - // Create today's backup - fs.copyFileSync(this.dbPath, dailyBackupPath); + // Create today's backup (checkpoint WAL first so the copy is self-contained) + this.safeBackupCopy(dailyBackupPath); logger.info(`Created daily backup: ${dailyBackupPath}`, LOG_CONTEXT); // Rotate old backups (keep last 7 days)