diff --git a/src/main/persistence/profile-state/profile-state-pre-migration-backup.test.ts b/src/main/persistence/profile-state/profile-state-pre-migration-backup.test.ts new file mode 100644 index 00000000000..29f306efb8d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-pre-migration-backup.test.ts @@ -0,0 +1,143 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { writeProfileStateBackup } from './profile-state-backup-job' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { + ensureProfileStatePreMigrationBackup, + profileStatePreMigrationBackupPath +} from './profile-state-pre-migration-backup' + +const PROFILE = 'backup-profile' +const directories: string[] = [] +const databases: ReturnType[] = [] + +afterEach(() => { + for (const opened of databases.splice(0)) { + opened.db.close() + } + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-pre-migration-backup-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, PROFILE) + databases.push(opened) + const write = (generation: number) => { + importProfileStateJson(opened.db, JSON.stringify({ settings: { generation } })) + } + write(1) + return { + directory, + databasePath, + write, + backupPath: profileStatePreMigrationBackupPath(databasePath) + } +} + +function readGeneration(path: string): unknown { + const opened = openProfileStateDatabaseReadOnly(path, PROFILE) + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json).settings.generation + } finally { + opened.db.close() + } +} + +describe('the backup taken before the first boot-time rewrite of saved data', () => { + it('copies the profile next to its database', async () => { + const { directory, databasePath, backupPath } = fixture() + + await expect( + ensureProfileStatePreMigrationBackup(databasePath, PROFILE, writeProfileStateBackup) + ).resolves.toBe('ready') + + expect(readGeneration(backupPath)).toBe(1) + // Self-contained: no temporary files or SQLite sidecars are left beside it. + expect(readdirSync(directory).filter((name) => name.includes('pre-migration'))).toEqual([ + basename(backupPath) + ]) + }) + + it('keeps the first copy on later boots, so it stays the pre-migration state', async () => { + const { databasePath, backupPath, write } = fixture() + await ensureProfileStatePreMigrationBackup(databasePath, PROFILE, writeProfileStateBackup) + write(2) + const runBackup = vi.fn(writeProfileStateBackup) + + await expect( + ensureProfileStatePreMigrationBackup(databasePath, PROFILE, runBackup) + ).resolves.toBe('ready') + + expect(runBackup).not.toHaveBeenCalled() + expect(readGeneration(backupPath)).toBe(1) + }) + + it('reports a failed copy so the caller does not migrate, and leaves nothing behind', async () => { + const { directory, databasePath, backupPath } = fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const diskFull = Object.assign(new Error('ENOSPC: no space left on device'), { + code: 'ENOSPC' + }) + + await expect( + ensureProfileStatePreMigrationBackup(databasePath, PROFILE, () => Promise.reject(diskFull)) + ).resolves.toBe('failed') + expect(existsSync(backupPath)).toBe(false) + expect(readdirSync(directory).some((name) => name.includes('pre-migration'))).toBe(false) + + // A later boot with room takes the copy then. + await expect( + ensureProfileStatePreMigrationBackup(databasePath, PROFILE, writeProfileStateBackup) + ).resolves.toBe('ready') + expect(readGeneration(backupPath)).toBe(1) + }) + + it('refuses rather than replaces something else at the backup path', async () => { + const { databasePath, backupPath } = fixture() + mkdirSync(backupPath) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const runBackup = vi.fn(writeProfileStateBackup) + + await expect( + ensureProfileStatePreMigrationBackup(databasePath, PROFILE, runBackup) + ).resolves.toBe('failed') + expect(runBackup).not.toHaveBeenCalled() + }) + + it('is not one of the hourly backups, so rotation never prunes it', async () => { + const { databasePath, backupPath, write } = fixture() + await ensureProfileStatePreMigrationBackup(databasePath, PROFILE, writeProfileStateBackup) + let now = Date.now() + const rotation = new ProfileStateBackupRotation( + databasePath, + PROFILE, + () => now, + writeProfileStateBackup + ) + for (let generation = 2; generation <= 8; generation += 1) { + write(generation) + now += 2 * 60 * 60 * 1000 + rotation.schedule() + await rotation.drain() + } + rotation.stop() + + expect(profileStateDatabaseBackups(databasePath).map(({ path }) => path)).not.toContain( + backupPath + ) + expect(readGeneration(backupPath)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-pre-migration-backup.ts b/src/main/persistence/profile-state/profile-state-pre-migration-backup.ts new file mode 100644 index 00000000000..fb08d86ebd5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-pre-migration-backup.ts @@ -0,0 +1,43 @@ +import { lstat } from 'node:fs/promises' +import { runProfileStateBackup } from './profile-state-backup-worker' + +/** `ready`: the copy exists, so a migration may rewrite saved data. `failed`: do not migrate. */ +export type ProfileStatePreMigrationBackup = 'ready' | 'failed' + +/** + * Outside the `.backup.` namespace, so the hourly rotation never prunes it. Delete it, and any + * `.tmp` a crash mid-copy left, when the boot-time layout migration is retired. + */ +export function profileStatePreMigrationBackupPath(databaseFile: string): string { + return `${databaseFile}.pre-migration.db` +} + +/** + * Copy the profile once, before the first boot-time rewrite of saved data; later boots find the + * copy and keep it, so it stays the pre-migration state. Call from the profile's single live owner + * (the instance lock keeps desktop and orcad apart), before any rewrite of this boot reaches disk. + */ +export async function ensureProfileStatePreMigrationBackup( + databaseFile: string, + profileId: string, + runBackup = runProfileStateBackup +): Promise { + const targetPath = profileStatePreMigrationBackupPath(databaseFile) + try { + const existing = await lstat(targetPath).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + if (existing !== undefined) { + // Only a validated, fsynced copy is ever renamed into place; never replace it. + return existing.isFile() ? 'ready' : 'failed' + } + await runBackup({ databasePath: databaseFile, profileId, targetPath }) + return 'ready' + } catch (error) { + console.error('[persistence] Could not back up the profile before migrating saved data:', error) + return 'failed' + } +}