From dade5eb333d7b449028aca18822a43e7be298636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 12:16:39 -0400 Subject: [PATCH] Wire pre-migration auto-backup with named API and manifest (B2/backup precursor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before running any pending migration, take a synchronous snapshot of the profile to backups/pre-migration-v-/ and write a backup-manifest.json recording reason, schema version, app version, and git SHA. A failed migration now has a well-labelled recovery point instead of a half-upgraded profile. - createAutomaticBackup(reason): synchronous snapshot via VACUUM INTO + recursive cpSync of logs/artifacts, plus JSON manifest. No fallback — failure throws so the caller refuses to touch the schema. - runMigrations accepts createAutomaticBackup and invokes it when any migration is pending. No optional chaining; callers must supply it. - AppDatabase constructor takes appVersion so main-runtime stamps the manifest with app.getVersion(). --- src/main/database-base.ts | 10 +++- src/main/database.ts | 1 + src/main/database/backup.ts | 94 ++++++++++++++++++++++++++++++++++++- src/main/database/schema.ts | 24 +++++++++- src/main/main-runtime.ts | 2 +- 5 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/main/database-base.ts b/src/main/database-base.ts index fdfed48d..681e29f3 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -43,6 +43,7 @@ export class AppDatabaseBase { readonly logsDir: string; readonly artifactsDir: string; readonly backupsDir: string; + readonly appVersion: string; readonly vault: SecretVault; protected db: DatabaseSync; @@ -76,12 +77,16 @@ export class AppDatabaseBase { public readonly credentialsTable: ReturnType; public readonly modelCapabilities: ReturnType; - constructor(rootDir: string, options: { isPackaged?: boolean } = {}) { + constructor(rootDir: string, options: { isPackaged?: boolean; appVersion?: string } = {}) { this.profileDir = rootDir; this.dbPath = join(rootDir, "profile.sqlite"); this.logsDir = join(rootDir, "logs"); this.artifactsDir = join(rootDir, "artifacts"); this.backupsDir = join(rootDir, "backups"); + // Stamped into backup manifests so an operator looking at an old + // snapshot can tell which build produced it. Callers (bootstrap) + // pass `electron.app.getVersion()`; tests omit it. + this.appVersion = options.appVersion ?? "unknown"; mkdirSync(rootDir, { recursive: true }); mkdirSync(this.logsDir, { recursive: true }); mkdirSync(this.artifactsDir, { recursive: true }); @@ -159,6 +164,7 @@ export class AppDatabaseBase { profileDir: this.profileDir, dbPath: this.dbPath, logsDir: this.logsDir, artifactsDir: this.artifactsDir, backupsDir: this.backupsDir, + appVersion: this.appVersion, }); } @@ -167,7 +173,7 @@ export class AppDatabaseBase { init() { const ra = (sql: string, ...params: SQLInputValue[]) => this.readAll(sql, ...params); const tx = (op: () => T) => this.transaction(op); - initializeDatabase(this.db, ra, tx); + initializeDatabase(this.db, ra, tx, (reason) => this.backupMod.createAutomaticBackup(reason)); this.connectorsCrud.seedConnectors(); this.modelCapabilities.seedModelCapabilities(MODEL_CAPABILITY_SEED); // Crash-recovery: a run still at `running` on boot means the worker diff --git a/src/main/database.ts b/src/main/database.ts index e8f884dd..7a43018c 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -159,6 +159,7 @@ export class AppDatabase extends AppDatabaseBase { // ─── Backup ──────────────────────────────────────────────────────────────── exportBackup(targetDir: string) { return this.backupMod.exportBackup(targetDir); } + createAutomaticBackup(reason: string) { return this.backupMod.createAutomaticBackup(reason); } async restoreFromBackup(sourceDir: string) { return this.backupMod.restoreFromBackup(sourceDir); } // ─── Thread Runtime ──────────────────────────────────────────────────────── diff --git a/src/main/database/backup.ts b/src/main/database/backup.ts index d626b2a5..78b491a0 100644 --- a/src/main/database/backup.ts +++ b/src/main/database/backup.ts @@ -1,9 +1,42 @@ -import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { copyFile, cp, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { DatabaseSync, backup as backupDatabase } from "node:sqlite"; import log from "electron-log/main.js"; +function readSchemaVersion(db: DatabaseSync): number { + const row = db + .prepare("select coalesce(max(version), 0) as v from schema_migrations") + .get() as { v: number } | undefined; + return row?.v ?? 0; +} + +function readGitSha(): string | null { + try { + const out = execFileSync("git", ["rev-parse", "HEAD"], { + stdio: ["ignore", "pipe", "ignore"], + cwd: process.cwd(), + timeout: 1000, + }); + const sha = out.toString("utf8").trim(); + return sha.length === 40 ? sha : null; + } catch { + return null; + } +} + +function timestampSegment(): string { + // Filesystem-safe variant of ISO 8601 (YYYYMMDD-HHmmss-SSS). + const now = new Date(); + const pad = (n: number, w = 2) => n.toString().padStart(w, "0"); + return ( + `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-` + + `${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}-` + + `${pad(now.getUTCMilliseconds(), 3)}` + ); +} + async function copyIfExists(source: string, destination: string) { if (!existsSync(source)) { return; @@ -23,9 +56,68 @@ export function buildBackupMethods( logsDir: string; artifactsDir: string; backupsDir: string; + appVersion: string; }, ) { return { + /** + * Take a synchronous snapshot of the profile before a destructive or + * risky operation (currently: pre-migration). Named so the operator + * can tell snapshots apart by reason and target schema version. + * + * Uses `VACUUM INTO` + `cpSync` rather than the async `exportBackup` + * path so the caller — `runMigrations`, which is synchronous — can + * block on the backup without any async plumbing. The returned path + * is absolute so a failed migration can surface the recovery point + * in logs verbatim. + * + * Failure modes are reported by throwing. There is no fallback: if + * we cannot take a snapshot we must not touch the schema. + */ + createAutomaticBackup(reason: string): string { + if (!reason || reason.includes("/") || reason.includes("\\")) { + throw new Error(`createAutomaticBackup: invalid reason "${reason}"`); + } + const db = deps.getDb(); + const schemaVersion = readSchemaVersion(db); + const segment = `${reason}-v${schemaVersion}-${timestampSegment()}`; + const targetDir = join(deps.backupsDir, segment); + mkdirSync(deps.backupsDir, { recursive: true }); + mkdirSync(targetDir, { recursive: true }); + + // VACUUM INTO takes a single consistent snapshot of the live DB + // while the connection is held open — no WAL checkpoint dance, no + // torn writes, works on sqlite3 ≥ 3.27. The destination must not + // exist; `mkdirSync` above only created the parent directory. + const dbTarget = join(targetDir, "profile.sqlite").replace(/'/g, "''"); + db.exec(`VACUUM INTO '${dbTarget}'`); + + // Side-car files (logs + artifacts) ride along so operators + // restoring from this snapshot see the same run history the + // migration saw. `cpSync` is recursive and force-overwrites. + if (existsSync(deps.logsDir)) { + cpSync(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }); + } + if (existsSync(deps.artifactsDir)) { + cpSync(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }); + } + + const manifest = { + reason, + schemaVersion, + appVersion: deps.appVersion, + createdAt: new Date().toISOString(), + gitSha: readGitSha(), + }; + writeFileSync( + join(targetDir, "backup-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + { encoding: "utf8" }, + ); + log.info(`[database/backup] automatic snapshot "${reason}" written to ${targetDir}`); + return targetDir; + }, + exportBackup(targetDir: string) { mkdirSync(targetDir, { recursive: true }); deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);"); diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 4f352a55..a41f7b68 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import log from "electron-log/main.js"; import { createMigrationsV1V15 } from "./migrations/v1-v15"; import { createMigrationsV16V25 } from "./migrations/v16-v25"; import { createMigrationsV26V34 } from "./migrations/v26-v34"; @@ -99,7 +100,12 @@ export function migrateSecretsTable(db: DatabaseSync, readAll: ReadAll) { db.exec("drop table secrets_legacy;"); } -export function initializeDatabase(db: DatabaseSync, ra: ReadAll, tx: Transaction): void { +export function initializeDatabase( + db: DatabaseSync, + ra: ReadAll, + tx: Transaction, + createAutomaticBackup: (reason: string) => string, +): void { db.exec(` create table if not exists settings (key text primary key, value text not null); create table if not exists companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null); @@ -150,7 +156,7 @@ export function initializeDatabase(db: DatabaseSync, ra: ReadAll, tx: Transactio migrateSecretsTable(db, ra); db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`); - runMigrations(db, ra, tx); + runMigrations(db, ra, tx, createAutomaticBackup); ensureOptionalColumns(db); db.exec(` @@ -181,6 +187,7 @@ export function runMigrations( db: DatabaseSync, readAll: ReadAll, transaction: Transaction, + createAutomaticBackup: (reason: string) => string, ) { db.exec(`create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null);`); const applied = new Set(readAll<{ version: number }>("select version from schema_migrations").map((row) => row.version)); @@ -203,6 +210,19 @@ export function runMigrations( ...createMigrationsV48V48(deps), ]; + // Pre-migration snapshot. If any migration is pending we take a full + // profile snapshot before touching a single row. A migration crash + // then yields a well-labelled recovery point instead of a half- + // upgraded profile. The snapshot itself must succeed — failure here + // aborts the run with the raw error so the caller can surface it to + // the operator rather than silently upgrade in-place. + const pending = migrations.filter((m) => !applied.has(m.version)); + if (pending.length > 0) { + const highestTargetVersion = pending.reduce((max, m) => Math.max(max, m.version), 0); + const path = createAutomaticBackup(`pre-migration-v${highestTargetVersion}`); + log.info(`[schema] pre-migration snapshot for v${highestTargetVersion} at ${path}`); + } + for (const migration of migrations) { if (applied.has(migration.version)) continue; try { diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts index 02baf005..ba327130 100644 --- a/src/main/main-runtime.ts +++ b/src/main/main-runtime.ts @@ -60,7 +60,7 @@ export function createMainRuntime({ isDev, devServerOrigin, profileDir, - appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir, { isPackaged: app.isPackaged }), + appDatabaseFactory: (targetProfileDir) => new AppDatabase(targetProfileDir, { isPackaged: app.isPackaged, appVersion: app.getVersion() }), initSigningKey: initializeAgentTokenSigningKey, windowStatePath, preloadPath,