Files
AgentCompany/src/main/database/backup.ts
T
2026-04-17 14:03:56 -04:00

155 lines
6.3 KiB
TypeScript

import { chmodSync, existsSync, mkdirSync } from "node:fs";
import { copyFile, cp, rm } from "node:fs/promises";
import { join } from "node:path";
import { DatabaseSync, backup as backupDatabase } from "node:sqlite";
async function copyIfExists(source: string, destination: string) {
if (!existsSync(source)) {
return;
}
await copyFile(source, destination);
}
export function buildBackupMethods(
deps: {
getDb: () => DatabaseSync;
setDb: (db: DatabaseSync) => void;
openConnection: () => DatabaseSync;
init: () => void;
close: () => void;
profileDir: string;
dbPath: string;
logsDir: string;
artifactsDir: string;
backupsDir: string;
},
) {
return {
exportBackup(targetDir: string) {
mkdirSync(targetDir, { recursive: true });
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
const copyJobs = [
backupDatabase(deps.getDb(), join(targetDir, "profile.sqlite")),
cp(deps.logsDir, join(targetDir, "logs"), { recursive: true, force: true }),
cp(deps.artifactsDir, join(targetDir, "artifacts"), { recursive: true, force: true }),
copyIfExists(`${deps.dbPath}-wal`, join(targetDir, "profile.sqlite-wal")),
copyIfExists(`${deps.dbPath}-shm`, join(targetDir, "profile.sqlite-shm")),
];
return Promise.all(copyJobs);
},
async restoreFromBackup(sourceDir: string) {
const sourceDbPath = join(sourceDir, "profile.sqlite");
if (!existsSync(sourceDbPath)) {
throw new Error("Backup directory is missing profile.sqlite.");
}
// Validate
const currentSchemaVersion =
(deps.getDb()
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
.get() as { v: number } | undefined)?.v ?? 0;
const validationDb = new DatabaseSync(sourceDbPath);
try {
const requiredTables = ["settings", "companies", "connectors"] as const;
for (const table of requiredTables) {
const row = validationDb
.prepare("select name from sqlite_master where type = 'table' and name = ?")
.get(table) as { name: string } | undefined;
if (!row) {
throw new Error(`Backup is missing the required table "${table}".`);
}
}
const hasMigrationsTable = validationDb
.prepare("select name from sqlite_master where type = 'table' and name = 'schema_migrations'")
.get() as { name: string } | undefined;
if (!hasMigrationsTable) {
throw new Error(
`Backup is missing schema_migrations. Expected schema version ${currentSchemaVersion}.`,
);
}
const backupVersion =
(validationDb
.prepare("select coalesce(max(version), 0) as v from schema_migrations")
.get() as { v: number } | undefined)?.v ?? 0;
if (backupVersion < currentSchemaVersion) {
throw new Error(
`Backup schema version ${backupVersion} is older than current ${currentSchemaVersion}. Restore would lose migrations.`,
);
}
} finally {
validationDb.close();
}
const recoveryDir = join(deps.backupsDir, `pre-restore-${Date.now()}`);
mkdirSync(recoveryDir, { recursive: true });
deps.getDb().exec("PRAGMA wal_checkpoint(TRUNCATE);");
await Promise.all([
backupDatabase(deps.getDb(), join(recoveryDir, "profile.sqlite")),
cp(deps.logsDir, join(recoveryDir, "logs"), { recursive: true, force: true }),
cp(deps.artifactsDir, join(recoveryDir, "artifacts"), { recursive: true, force: true }),
copyIfExists(join(deps.profileDir, "vault.key"), join(recoveryDir, "vault.key")),
copyIfExists(join(deps.profileDir, "agent-api-signing.key"), join(recoveryDir, "agent-api-signing.key")),
copyIfExists(`${deps.dbPath}-wal`, join(recoveryDir, "profile.sqlite-wal")),
copyIfExists(`${deps.dbPath}-shm`, join(recoveryDir, "profile.sqlite-shm")),
]);
deps.close();
try {
await applyBackup(sourceDir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
try {
await applyBackup(recoveryDir);
} catch (recoveryError) {
const recoveryMessage =
recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
try {
deps.setDb(deps.openConnection());
deps.init();
} catch {
// Reopen failed. Caller sees original + recovery messages.
}
throw new Error(
`Restore failed: ${message}. Recovery also failed: ${recoveryMessage}. Previous profile is at ${recoveryDir}.`,
);
}
throw new Error(`Restore failed. Previous profile was recovered from ${recoveryDir}. ${message}`);
}
},
};
async function applyBackup(sourceDir: string) {
await rm(deps.dbPath, { force: true });
await rm(`${deps.dbPath}-wal`, { force: true });
await rm(`${deps.dbPath}-shm`, { force: true });
await copyFile(join(sourceDir, "profile.sqlite"), deps.dbPath);
await copyIfExists(join(sourceDir, "profile.sqlite-wal"), `${deps.dbPath}-wal`);
await copyIfExists(join(sourceDir, "profile.sqlite-shm"), `${deps.dbPath}-shm`);
await rm(deps.logsDir, { recursive: true, force: true });
await rm(deps.artifactsDir, { recursive: true, force: true });
mkdirSync(deps.logsDir, { recursive: true });
mkdirSync(deps.artifactsDir, { recursive: true });
if (existsSync(join(sourceDir, "logs"))) {
await cp(join(sourceDir, "logs"), deps.logsDir, { recursive: true, force: true });
}
if (existsSync(join(sourceDir, "artifacts"))) {
await cp(join(sourceDir, "artifacts"), deps.artifactsDir, { recursive: true, force: true });
}
await copyIfExists(join(sourceDir, "vault.key"), join(deps.profileDir, "vault.key"));
await copyIfExists(join(sourceDir, "agent-api-signing.key"), join(deps.profileDir, "agent-api-signing.key"));
if (existsSync(join(deps.profileDir, "vault.key"))) {
chmodSync(join(deps.profileDir, "vault.key"), 0o600);
}
if (existsSync(join(deps.profileDir, "agent-api-signing.key"))) {
chmodSync(join(deps.profileDir, "agent-api-signing.key"), 0o600);
}
deps.setDb(deps.openConnection());
deps.init();
}
}