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:
1 parent
08427931b6
commit
dade5eb333
5 files changed
+125
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user