Wire pre-migration auto-backup with named API and manifest (B2/backup precursor)

Before running any pending migration, take a synchronous snapshot of the
profile to backups/pre-migration-v<target>-<ts>/ 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().
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 12:16:39 -04:00
1 parent 08427931b6
commit dade5eb333
5 files changed
+125 -6

No files matched your search

+8 -2
View File
@@ -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<typeof buildCredentialMethods>;
public readonly modelCapabilities: ReturnType<typeof buildModelCapabilityMethods>;
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 = <T>(sql: string, ...params: SQLInputValue[]) => this.readAll<T>(sql, ...params);
const tx = <T>(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
+1
View File
@@ -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 ────────────────────────────────────────────────────────
+93 -1
View File
@@ -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);");
+22 -2
View File
@@ -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 {
+1 -1
View File
@@ -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,