From f6d5086c7d397707d760a5c0e42182a8573469a2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:42:52 -0400 Subject: [PATCH] feat(persistence): one-time profile backup before startup rewrites saved data (#29016) * feat(persistence): one-time profile backup before boot-time saved-data migrations Adds ensureProfileStatePreMigrationBackup: a validated copy of the profile database, written next to it as profile-state.db.pre-migration.db, taken once before the first boot-time rewrite of saved layouts and never replaced afterwards. It returns 'failed' instead of throwing so the caller skips the migration when the copy cannot be written (e.g. disk full). Nothing calls it yet; the startup layout migration will. Co-Authored-By: Claude Opus 5.5 * docs(persistence): name the lock that keeps one owner per profile Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...profile-state-pre-migration-backup.test.ts | 143 ++++++++++++++++++ .../profile-state-pre-migration-backup.ts | 43 ++++++ 2 files changed, 186 insertions(+) create mode 100644 src/main/persistence/profile-state/profile-state-pre-migration-backup.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-pre-migration-backup.ts 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' + } +}