Files
PaperClipAI/server/scripts/lib/backup-qualification.ts
T
2026-11-02 13:02:25 -06:00

94 lines
6.4 KiB
TypeScript

import { randomBytes, createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createDb, runDatabaseBackup, runDatabaseRestore, type RunDatabaseBackupOptions } from "@paperclipai/db";
import { resolveExternalTestDatabaseUrl } from "../../src/__tests__/helpers/external-test-database.js";
import { verifyIdleDatabaseBackup } from "../../src/services/idle-database-backup.js";
export const SOURCE_ENV = "PAPERCLIP_BACKUP_CHECKPOINT_SOURCE_TEST_DATABASE_URL";
export const RESTORE_ENV = "PAPERCLIP_BACKUP_CHECKPOINT_RESTORE_TEST_DATABASE_URL";
export const retention = { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 6 };
export function resolveBackupQualificationPair(source = process.env[SOURCE_ENV], restore = process.env[RESTORE_ENV]) {
const sourceUrl = resolveExternalTestDatabaseUrl(SOURCE_ENV, source);
const restoreUrl = resolveExternalTestDatabaseUrl(RESTORE_ENV, restore);
if (!sourceUrl && !restoreUrl) return undefined;
if (!sourceUrl || !restoreUrl) throw new Error("Backup qualification requires both source and restore test database URLs");
const a = new URL(sourceUrl), b = new URL(restoreUrl);
if (a.hostname.toLowerCase() === b.hostname.toLowerCase() && (a.port || "5432") === (b.port || "5432")
&& decodeURIComponent(a.pathname) === decodeURIComponent(b.pathname)) throw new Error("Backup source and restore databases must differ");
return { sourceUrl, restoreUrl };
}
export type QualificationPair = NonNullable<ReturnType<typeof resolveBackupQualificationPair>>;
export async function assertDistinctEmptyRestore(pair: QualificationPair) {
const source = createDb(pair.sourceUrl, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0, connectTimeoutSeconds: 5 }).$client;
const target = createDb(pair.restoreUrl, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0, connectTimeoutSeconds: 5 }).$client;
const key = randomBytes(8); const a = key.readInt32BE(0), b = key.readInt32BE(4);
let sourceLocked = false, targetLocked = false;
try {
await source`SELECT pg_advisory_lock(${a}, ${b})`; sourceLocked = true;
const [result] = await target`SELECT pg_try_advisory_lock(${a}, ${b}) AS acquired`;
targetLocked = result!.acquired === true;
if (!targetLocked) throw new Error("Backup source and restore resolve to the same actual database");
const rows = await target`SELECT 1 FROM pg_namespace WHERE nspname NOT IN ('public', 'information_schema') AND nspname !~ '^pg_'
UNION ALL SELECT 1 FROM pg_depend
WHERE refclassid = 'pg_namespace'::regclass AND refobjid = 'public'::regnamespace LIMIT 1`;
if (rows.length) throw new Error("Backup restore target must be empty before restore");
} finally {
try {
await Promise.all([
...(targetLocked ? [target`SELECT pg_advisory_unlock(${a}, ${b})`] : []),
...(sourceLocked ? [source`SELECT pg_advisory_unlock(${a}, ${b})`] : []),
]);
} finally { await Promise.all([source.end({ timeout: 5 }), target.end({ timeout: 5 })]); }
}
}
export async function seedBackupQualificationFixture(sourceUrl: string) {
const source = createDb(sourceUrl, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0, connectTimeoutSeconds: 5 }).$client;
try {
await source`CREATE SCHEMA backup_qualification`;
await source`CREATE TABLE backup_qualification.parents (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value text NOT NULL)`;
await source`CREATE TABLE backup_qualification.children (id integer PRIMARY KEY, parent_id bigint REFERENCES backup_qualification.parents(id),
value text, bytes bytea)`;
await source.begin(async tx => {
await tx`INSERT INTO backup_qualification.parents (value) VALUES ('last committed state — 雪 ☃')`;
await tx`INSERT INTO backup_qualification.children VALUES (1, 1, NULL, decode('00ff10275c', 'hex')), (2, 1, 'empty bytes', decode('', 'hex')), (3, 1, 'null bytes', NULL)`;
});
} finally { await source.end({ timeout: 5 }); }
}
export async function inspectRestoredFixture(restoreUrl: string) {
const target = createDb(restoreUrl, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0, connectTimeoutSeconds: 5 }).$client;
try {
const [row] = await target`SELECT p.id::text AS id, p.value, c.value AS nullable, encode(c.bytes, 'hex') AS binary
FROM backup_qualification.parents p JOIN backup_qualification.children c ON c.parent_id = p.id WHERE c.id = 1`;
if (!row || row.id !== "1" || row.value !== "last committed state — 雪 ☃" || row.nullable !== null || row.binary !== "00ff10275c") {
throw new Error("Restored relational, Unicode, binary or null fixture differs");
}
const [empty] = await target`SELECT octet_length(bytes) AS size FROM backup_qualification.children WHERE id = 2`;
if (empty?.size !== 0) throw new Error("Restored empty binary value differs");
const [nullBytes] = await target`SELECT bytes IS NULL AS is_null FROM backup_qualification.children WHERE id = 3`;
if (nullBytes?.is_null !== true) throw new Error("Restored null binary value differs");
const [next] = await target`INSERT INTO backup_qualification.parents (value) VALUES ('sequence after restore') RETURNING id::text`;
if (next?.id !== "2") throw new Error("Restored identity sequence did not resume at 2");
try {
await target`INSERT INTO backup_qualification.children VALUES (999, 999, NULL, decode('', 'hex'))`;
throw new Error("Restored foreign key was not enforced");
} catch (error) { if ((error as { code?: string }).code !== "23503") throw error; }
return { relational: true, unicode: true, binary: true, emptyBinary: true, nullBinary: true, nullable: true, nextIdentity: next.id, foreignKeyEnforced: true };
} finally { await target.end({ timeout: 5 }); }
}
export async function qualifyBackupRestore(pair: QualificationPair, backupDir: string, engine: RunDatabaseBackupOptions["backupEngine"]) {
await assertDistinctEmptyRestore(pair);
const archive = await runDatabaseBackup({ connectionString: pair.sourceUrl, backupDir, retention,
backupEngine: engine, verifyBeforePrune: verifyIdleDatabaseBackup });
await assertDistinctEmptyRestore(pair);
await runDatabaseRestore({ connectionString: pair.restoreUrl, backupFile: archive.backupFile });
return { engine, archive: archive.backupFile, sizeBytes: archive.sizeBytes,
sha256: createHash("sha256").update(await readFile(archive.backupFile)).digest("hex"),
restored: await inspectRestoredFixture(pair.restoreUrl) };
}