mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Reviewed and verified at 69792ccbcf.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
118 lines
8.2 KiB
TypeScript
118 lines
8.2 KiB
TypeScript
import { randomBytes, createHash } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { createDb, runDatabaseBackup, runDatabaseRestore, type RunDatabaseBackupOptions } from "@paperclipai/db";
|
|
import { resolveExternalTestDatabaseUrl } from "../../src/lib/dedicated-test-database-url.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`CREATE TABLE backup_qualification.binary_arrays (id integer PRIMARY KEY, payloads 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)`;
|
|
await tx`INSERT INTO backup_qualification.binary_arrays VALUES
|
|
(1, ARRAY[decode('00ff10275c', 'hex'), convert_to('雪 ☃', 'UTF8'), decode('', 'hex'), NULL]::bytea[]),
|
|
(2, ARRAY[]::bytea[]), (3, NULL),
|
|
(4, ARRAY[[decode('00ff', 'hex'), NULL], [decode('', 'hex'), convert_to('雪', 'UTF8')]]::bytea[]),
|
|
(5, array_fill(decode('00ff', 'hex'), ARRAY[2,2], ARRAY[0,3]))`;
|
|
});
|
|
} 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 arrays = await target`SELECT id, pg_typeof(payloads)::text AS type, array_dims(payloads) AS dimensions,
|
|
CASE id
|
|
WHEN 1 THEN payloads IS NOT DISTINCT FROM ARRAY[decode('00ff10275c', 'hex'), convert_to('雪 ☃', 'UTF8'), decode('', 'hex'), NULL]::bytea[]
|
|
WHEN 2 THEN payloads IS NOT DISTINCT FROM ARRAY[]::bytea[]
|
|
WHEN 3 THEN payloads IS NULL
|
|
WHEN 4 THEN payloads IS NOT DISTINCT FROM ARRAY[[decode('00ff', 'hex'), NULL], [decode('', 'hex'), convert_to('雪', 'UTF8')]]::bytea[]
|
|
WHEN 5 THEN payloads IS NOT DISTINCT FROM array_fill(decode('00ff', 'hex'), ARRAY[2,2], ARRAY[0,3])
|
|
END AS matches FROM backup_qualification.binary_arrays ORDER BY id`;
|
|
const expected = [
|
|
{ id: 1, type: "bytea[]", dimensions: "[1:4]", matches: true },
|
|
{ id: 2, type: "bytea[]", dimensions: null, matches: true },
|
|
{ id: 3, type: "bytea[]", dimensions: null, matches: true },
|
|
{ id: 4, type: "bytea[]", dimensions: "[1:2][1:2]", matches: true },
|
|
{ id: 5, type: "bytea[]", dimensions: "[0:1][3:4]", matches: true },
|
|
];
|
|
if (JSON.stringify(arrays) !== JSON.stringify(expected)) throw new Error("Restored binary array values, dimensions or types differ");
|
|
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, binaryArrays: true, nestedBinaryArrays: true, binaryArrayBounds: 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 });
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of createReadStream(archive.backupFile)) hash.update(chunk);
|
|
return { engine, archive: archive.backupFile, sizeBytes: archive.sizeBytes,
|
|
sha256: hash.digest("hex"),
|
|
restored: await inspectRestoredFixture(pair.restoreUrl) };
|
|
}
|