mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
94 lines
6.4 KiB
TypeScript
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) };
|
|
}
|