mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Replace single retentionDays with a three-tier BackupRetentionPolicy: - Daily: keep all backups (presets: 3, 7, 14 days; default 7) - Weekly: keep one per calendar week (presets: 1, 2, 4 weeks; default 4) - Monthly: keep one per calendar month (presets: 1, 3, 6 months; default 1) Pruning sorts backups newest-first and applies each tier's cutoff, keeping only the newest entry per ISO week/month bucket. The Instance Settings General page now shows three preset selectors (no icon, matches existing page design). Remove Database icon import.
224 lines
7.4 KiB
TypeScript
224 lines
7.4 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import postgres from "postgres";
|
|
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
|
|
import { ensurePostgresDatabase } from "./client.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./test-embedded-postgres.js";
|
|
|
|
const cleanups: Array<() => Promise<void> | void> = [];
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function createTempDir(prefix: string): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
cleanups.push(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
return dir;
|
|
}
|
|
|
|
async function createTempDatabase(): Promise<string> {
|
|
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-backup-");
|
|
cleanups.push(db.cleanup);
|
|
return db.connectionString;
|
|
}
|
|
|
|
async function createSiblingDatabase(connectionString: string, databaseName: string): Promise<string> {
|
|
const adminUrl = new URL(connectionString);
|
|
adminUrl.pathname = "/postgres";
|
|
await ensurePostgresDatabase(adminUrl.toString(), databaseName);
|
|
const targetUrl = new URL(connectionString);
|
|
targetUrl.pathname = `/${databaseName}`;
|
|
return targetUrl.toString();
|
|
}
|
|
|
|
afterEach(async () => {
|
|
while (cleanups.length > 0) {
|
|
const cleanup = cleanups.pop();
|
|
await cleanup?.();
|
|
}
|
|
});
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres backup tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describe("createBufferedTextFileWriter", () => {
|
|
it("preserves line boundaries across buffered flushes", async () => {
|
|
const tempDir = createTempDir("paperclip-buffered-writer-");
|
|
const outputPath = path.join(tempDir, "backup.sql");
|
|
const writer = createBufferedTextFileWriter(outputPath, 16);
|
|
const lines = [
|
|
"-- header",
|
|
"BEGIN;",
|
|
"",
|
|
"INSERT INTO test VALUES (1);",
|
|
"-- footer",
|
|
];
|
|
|
|
for (const line of lines) {
|
|
writer.emit(line);
|
|
}
|
|
|
|
await writer.close();
|
|
|
|
expect(fs.readFileSync(outputPath, "utf8")).toBe(lines.join("\n"));
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|
it(
|
|
"backs up and restores large table payloads without materializing one giant string",
|
|
async () => {
|
|
const sourceConnectionString = await createTempDatabase();
|
|
const restoreConnectionString = await createSiblingDatabase(
|
|
sourceConnectionString,
|
|
"paperclip_restore_target",
|
|
);
|
|
const backupDir = createTempDir("paperclip-db-backup-output-");
|
|
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
|
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
|
|
|
try {
|
|
await sourceSql.unsafe(`
|
|
CREATE TYPE "public"."backup_test_state" AS ENUM ('pending', 'done');
|
|
`);
|
|
await sourceSql.unsafe(`
|
|
CREATE TABLE "public"."backup_test_records" (
|
|
"id" serial PRIMARY KEY,
|
|
"title" text NOT NULL,
|
|
"payload" text NOT NULL,
|
|
"state" "public"."backup_test_state" NOT NULL,
|
|
"metadata" jsonb,
|
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
const payload = "x".repeat(8192);
|
|
for (let index = 0; index < 160; index += 1) {
|
|
const createdAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index));
|
|
await sourceSql`
|
|
INSERT INTO "public"."backup_test_records" (
|
|
"title",
|
|
"payload",
|
|
"state",
|
|
"metadata",
|
|
"created_at"
|
|
)
|
|
VALUES (
|
|
${`row-${index}`},
|
|
${payload},
|
|
${index % 2 === 0 ? "pending" : "done"}::"public"."backup_test_state",
|
|
${JSON.stringify({ index, even: index % 2 === 0 })}::jsonb,
|
|
${createdAt}
|
|
)
|
|
`;
|
|
}
|
|
|
|
const result = await runDatabaseBackup({
|
|
connectionString: sourceConnectionString,
|
|
backupDir,
|
|
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
|
filenamePrefix: "paperclip-test",
|
|
});
|
|
|
|
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/);
|
|
expect(result.sizeBytes).toBeGreaterThan(0);
|
|
expect(fs.existsSync(result.backupFile)).toBe(true);
|
|
|
|
await runDatabaseRestore({
|
|
connectionString: restoreConnectionString,
|
|
backupFile: result.backupFile,
|
|
});
|
|
|
|
const counts = await restoreSql.unsafe<{ count: number }[]>(`
|
|
SELECT count(*)::int AS count
|
|
FROM "public"."backup_test_records"
|
|
`);
|
|
expect(counts[0]?.count).toBe(160);
|
|
|
|
const sampleRows = await restoreSql.unsafe<{
|
|
title: string;
|
|
payload: string;
|
|
state: string;
|
|
metadata: { index: number; even: boolean };
|
|
}[]>(`
|
|
SELECT "title", "payload", "state"::text AS "state", "metadata"
|
|
FROM "public"."backup_test_records"
|
|
WHERE "title" IN ('row-0', 'row-159')
|
|
ORDER BY "title"
|
|
`);
|
|
expect(sampleRows).toEqual([
|
|
{
|
|
title: "row-0",
|
|
payload,
|
|
state: "pending",
|
|
metadata: { index: 0, even: true },
|
|
},
|
|
{
|
|
title: "row-159",
|
|
payload,
|
|
state: "done",
|
|
metadata: { index: 159, even: false },
|
|
},
|
|
]);
|
|
} finally {
|
|
await sourceSql.end();
|
|
await restoreSql.end();
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it(
|
|
"restores statements incrementally when backup comments precede the first breakpoint",
|
|
async () => {
|
|
const restoreConnectionString = await createTempDatabase();
|
|
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
|
const backupDir = createTempDir("paperclip-db-restore-manual-");
|
|
const backupFile = path.join(backupDir, "manual.sql");
|
|
|
|
try {
|
|
await fs.promises.writeFile(
|
|
backupFile,
|
|
[
|
|
"-- Paperclip database backup",
|
|
"-- Created: 2026-04-06T00:00:00.000Z",
|
|
"",
|
|
"BEGIN;",
|
|
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
|
"CREATE TABLE public.restore_stream_test (id integer primary key, payload text not null);",
|
|
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
|
"INSERT INTO public.restore_stream_test (id, payload)",
|
|
"VALUES (1, 'hello');",
|
|
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
|
"COMMIT;",
|
|
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
await runDatabaseRestore({
|
|
connectionString: restoreConnectionString,
|
|
backupFile,
|
|
});
|
|
|
|
const rows = await restoreSql.unsafe<{ payload: string }[]>(`
|
|
SELECT payload
|
|
FROM public.restore_stream_test
|
|
`);
|
|
expect(rows).toEqual([{ payload: "hello" }]);
|
|
} finally {
|
|
await restoreSql.end();
|
|
}
|
|
},
|
|
20_000,
|
|
);
|
|
});
|