Files
PaperClipAI/packages/db/src/backup-lib.test.ts
T
Nicky LeachandPaperclip 1cebdd4c4f Add plugin lifecycle delivery and an initial resource baseline (#15343)
## Thinking Path

> - Paperclip manages agents and projects for work.
> - Their lifecycle changes already commit to a durable journal.
> - Plugins need to read these records and track completed work.
> - Existing resources need a one-time baseline before delivery is
enabled.
> - Worker crashes must leave unfinished records available for retry.
> - Each plugin needs its own progress and company access checks.
> - This PR adds a pull inbox through the existing plugin SDK and job
system.

## Linked Issues or Issue Description

**Problem or motivation**

Plugins cannot consume the durable resource lifecycle journal.
In-process notifications can disappear during a restart and cannot
record successful completion. Resource plugins need retries, company
boundaries, and ordered transitions.

**Proposed solution**

Add `ctx.events.listLifecycle(companyId, limit?, afterId?)` and
`ctx.events.acknowledgeLifecycle(companyId, eventId)` under
`events.subscribe`. Seed a one-time baseline from current resource
state. Deliver creation first, then the remaining transitions in ID
order. Store acknowledgments for each plugin. Use existing plugin jobs
to poll configured companies.

**Alternatives considered**

A global sequence cursor can skip lower IDs that commit later.
Fire-and-forget subscriptions cannot record completion. A new dispatcher
is unnecessary because plugin jobs support polling. Consumers serialize
polling and use provider idempotency keys.

**Roadmap alignment**

This extends the existing plugin system and builds on #15280 and #15306.
Searches found no duplicate resource inbox work. Related #13306 exposes
decision events on the in-process bus. This PR includes the initial
journal baseline. Provider provisioning remains separate work.

## What Changed

- Add company-scoped lifecycle reads and acknowledgments to the SDK and
worker RPC host.
- Gate both methods by capability, invocation or proactive company
scope, plugin readiness, and company enablement.
- Seed hired agents and all projects once. Preserve paused/terminated
agent state and archived project state. Keep pending hires behind
approval.
- Preserve existing history and deliver backfilled creation before
partial transition histories.
- Deliver project archive events from #15371 through the SDK. Seed
archive intents for archived projects and update intents for active
projects with a partial archive history.
- Store acknowledgments per plugin and reject acknowledgments that skip
earlier resource events.
- Page past failed resources while retaining their pending records.
Reset the page cursor each sweep to include late commits.
- Add acknowledgment storage, an index for resource ordering, tests, and
authoring guidance.
- Preserve native identity definitions and sequence progress in
JavaScript backups. Repair journal ID generators lost by older backups
before seeding the baseline, without changing existing IDs.

## Verification

- `pnpm -r typecheck` passed after the final origin/master rebase.
- `pnpm build` passed before the final metadata rebase. The final CI
build also passed.
- All 46 focused lifecycle, SDK harness/RPC, migration snapshot, and
legacy restore checks passed again with migration 0309.
- Checks cover retries, per-plugin progress, resource order,
capability/company boundaries, baseline idempotency, approval gates,
archive delivery, restored projects with partial histories, and atomic
migration rollback.
- Full CI passed on final commit `0e674b87fc`. One unrelated Cursor
fixture hit a 10-second timeout; it passed locally in under one second,
and the failed server shard passed on retry.
- Greptile scored the final commit 5/5 with no unresolved review
threads.
- The branch is rebased onto origin/master and is conflict-free.
- Earlier full local runs were stopped as scope changed. CI ran the
complete repository suite.
- `git diff --check` and a local secret/PII scan passed.

## Risks


- Apply migration `0309_loving_the_hood.sql` before starting the new
server. It creates acknowledgment storage and an index, then seeds the
baseline in the same transaction. Agent/project writes wait for the
migration to commit. Keep these writes quiesced through the migration
and activation of the new capture-capable server; do not resume an older
runtime that lacks capture after the baseline.
- The baseline records current desired state, not historical
transitions. It includes archived projects and terminated-agent cleanup
intents. It runs once with the delivery migration; no later or runtime
journal backfill is planned.
- Delivery is at least once. Concurrent reads can repeat an event.
Consumers must serialize polling, use stable company/event idempotency
keys, and acknowledge successful operations only.
- Reset `afterId` at the start of every polling sweep. It is a page
cursor, not a persisted high-water mark.
- Deleting a plugin removes its acknowledgment records. A new
installation may replay existing journal events.
- Records contain identity and action. Consumers must load current
authorized data before acting. Cleanup and retention policy belong to
the provider plugin.
- Provider calls, VM/volume provisioning, and journal retention are
outside this PR.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
code execution, and tool use. The exact deployment model ID and context
window are not exposed in this session.

## Checklist


- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-06 12:27:09 -07:00

634 lines
24 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { gunzipSync } from "node:zlib";
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?.();
}
}, 60_000);
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("preserves identity generation, sequence options and progress in JavaScript backups", async () => {
const source = await createTempDatabase();
const target = await createSiblingDatabase(source, "identity_restore_target");
const sourceSql = postgres(source, { max: 1, onnotice: () => {} });
const targetSql = postgres(target, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE SCHEMA identity_fixture;
CREATE TABLE identity_fixture.records (
id bigint GENERATED ALWAYS AS IDENTITY (START WITH 7 INCREMENT BY 2) PRIMARY KEY,
default_id integer GENERATED BY DEFAULT AS IDENTITY,
value text NOT NULL
);
INSERT INTO identity_fixture.records (value) VALUES ('one'), ('two');
`);
const backup = await runDatabaseBackup({ connectionString: source, backupDir: createTempDir("identity-backup-"), backupEngine: "javascript", retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 } });
await runDatabaseRestore({ connectionString: target, backupFile: backup.backupFile });
expect(await targetSql`SELECT id::text, default_id, value FROM identity_fixture.records ORDER BY id`)
.toEqual([{ id: "7", default_id: 1, value: "one" }, { id: "9", default_id: 2, value: "two" }]);
expect(await targetSql`INSERT INTO identity_fixture.records (value) VALUES ('three') RETURNING id::text, default_id`)
.toEqual([{ id: "11", default_id: 3 }]);
await expect(targetSql`INSERT INTO identity_fixture.records (id, value) VALUES (99, 'explicit')`).rejects.toThrow("non-DEFAULT");
} finally {
await sourceSql.end();
await targetSql.end();
}
}, 30_000);
it(
"keeps the newest backup for each retained calendar month",
async () => {
const sourceConnectionString = await createTempDatabase();
const backupDir = createTempDir("paperclip-db-backup-retention-");
const realDateNow = Date.now;
Date.now = () => Date.UTC(2026, 2, 31, 12, 0, 0);
const janNewest = path.join(backupDir, "paperclip-test-2026-01-28T12-00-00.sql.gz");
const janOlder = path.join(backupDir, "paperclip-test-2026-01-10T12-00-00.sql.gz");
const decOld = path.join(backupDir, "paperclip-test-2025-12-15T12-00-00.sql.gz");
try {
fs.writeFileSync(janNewest, "jan-newest");
fs.writeFileSync(janOlder, "jan-older");
fs.writeFileSync(decOld, "dec-old");
fs.utimesSync(janNewest, new Date("2026-01-28T12:00:00Z"), new Date("2026-01-28T12:00:00Z"));
fs.utimesSync(janOlder, new Date("2026-01-10T12:00:00Z"), new Date("2026-01-10T12:00:00Z"));
fs.utimesSync(decOld, new Date("2025-12-15T12:00:00Z"), new Date("2025-12-15T12:00:00Z"));
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 2 },
filenamePrefix: "paperclip-test",
});
expect(result.prunedCount).toBe(2);
expect(fs.existsSync(janNewest)).toBe(true);
expect(fs.existsSync(janOlder)).toBe(false);
expect(fs.existsSync(decOld)).toBe(false);
} finally {
Date.now = realDateNow;
}
},
30_000,
);
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()
);
`);
await sourceSql.unsafe(`
CREATE FUNCTION "public"."backup_test_mark_done"()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW."state" := 'done';
RETURN NEW;
END;
$$;
CREATE TRIGGER "backup_test_mark_done_trigger"
BEFORE UPDATE OF "title" ON "public"."backup_test_records"
FOR EACH ROW
EXECUTE FUNCTION "public"."backup_test_mark_done"();
`);
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",
backupEngine: "javascript",
});
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 } | string;
}[]>(`
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.map((row) => ({
...row,
metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
}))).toEqual([
{
title: "row-0",
payload,
state: "pending",
metadata: { index: 0, even: true },
},
{
title: "row-159",
payload,
state: "done",
metadata: { index: 159, even: false },
},
]);
await restoreSql.unsafe(`
UPDATE "public"."backup_test_records"
SET "title" = 'triggered'
WHERE "title" = 'row-0'
`);
const triggeredRows = await restoreSql.unsafe<{ state: string }[]>(`
SELECT "state"::text AS "state"
FROM "public"."backup_test_records"
WHERE "title" = 'triggered'
`);
expect(triggeredRows).toEqual([{ state: "done" }]);
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"backs up and restores non-public database schemas and migration history",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_full_logical_restore_target",
);
const backupDir = createTempDir("paperclip-db-full-logical-backup-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE SCHEMA IF NOT EXISTS "drizzle";
CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (
"id" serial PRIMARY KEY,
"hash" text NOT NULL,
"created_at" bigint
);
INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at")
VALUES ('paperclip-migration-history', 1770000000000);
`);
await sourceSql.unsafe(`
CREATE TABLE "public"."backup_parent_records" (
"id" uuid PRIMARY KEY,
"name" text NOT NULL
);
INSERT INTO "public"."backup_parent_records" ("id", "name")
VALUES ('11111111-1111-4111-8111-111111111111', 'parent');
`);
await sourceSql.unsafe(`
CREATE TABLE "public"."plugin_rows" (
"id" serial PRIMARY KEY,
"note" text NOT NULL
);
CREATE TABLE "public"."audit_rows" (
"id" serial PRIMARY KEY,
"secret_note" text
);
INSERT INTO "public"."plugin_rows" ("note")
VALUES ('public-collision');
INSERT INTO "public"."audit_rows" ("secret_note")
VALUES ('public-secret');
`);
await sourceSql.unsafe(`
CREATE SCHEMA "plugin_backup_scope";
CREATE TYPE "plugin_backup_scope"."plugin_status" AS ENUM ('ready', 'done');
CREATE TABLE "plugin_backup_scope"."plugin_rows" (
"id" serial PRIMARY KEY,
"parent_id" uuid NOT NULL REFERENCES "public"."backup_parent_records"("id") ON DELETE CASCADE,
"status" "plugin_backup_scope"."plugin_status" NOT NULL,
"note" text NOT NULL
);
CREATE TABLE "plugin_backup_scope"."audit_rows" (
"id" serial PRIMARY KEY,
"secret_note" text
);
CREATE UNIQUE INDEX "plugin_rows_note_uq" ON "plugin_backup_scope"."plugin_rows" ("note");
INSERT INTO "plugin_backup_scope"."plugin_rows" ("parent_id", "status", "note")
VALUES ('11111111-1111-4111-8111-111111111111', 'ready', 'first');
INSERT INTO "plugin_backup_scope"."audit_rows" ("secret_note")
VALUES ('plugin-secret');
`);
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-full-logical-test",
backupEngine: "javascript",
excludeTables: ["plugin_rows"],
nullifyColumns: {
audit_rows: ["secret_note"],
},
});
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const migrationRows = await restoreSql.unsafe<{ hash: string }[]>(`
SELECT "hash"
FROM "drizzle"."__drizzle_migrations"
WHERE "hash" = 'paperclip-migration-history'
`);
expect(migrationRows).toEqual([{ hash: "paperclip-migration-history" }]);
const pluginRows = await restoreSql.unsafe<{ note: string; status: string; parent_name: string }[]>(`
SELECT r."note", r."status"::text AS "status", p."name" AS "parent_name"
FROM "plugin_backup_scope"."plugin_rows" r
JOIN "public"."backup_parent_records" p ON p."id" = r."parent_id"
`);
expect(pluginRows).toEqual([{ note: "first", status: "ready", parent_name: "parent" }]);
const publicCollisionRows = await restoreSql.unsafe<{ count: number }[]>(`
SELECT count(*)::int AS count
FROM "public"."plugin_rows"
`);
expect(publicCollisionRows[0]?.count).toBe(0);
const publicAuditRows = await restoreSql.unsafe<{ secret_note: string | null }[]>(`
SELECT "secret_note"
FROM "public"."audit_rows"
`);
expect(publicAuditRows).toEqual([{ secret_note: null }]);
const pluginAuditRows = await restoreSql.unsafe<{ secret_note: string | null }[]>(`
SELECT "secret_note"
FROM "plugin_backup_scope"."audit_rows"
`);
expect(pluginAuditRows).toEqual([{ secret_note: "plugin-secret" }]);
await expect(
restoreSql.unsafe(`
INSERT INTO "plugin_backup_scope"."plugin_rows" ("parent_id", "status", "note")
VALUES ('11111111-1111-4111-8111-111111111111', 'done', 'first')
`),
).rejects.toThrow();
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"preserves composite foreign key column order without duplicate referenced columns",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_composite_fk_restore_target",
);
const backupDir = createTempDir("paperclip-db-composite-fk-backup-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE SCHEMA "plugin_composite_fk";
CREATE TABLE "plugin_composite_fk"."content_cases" (
"id" uuid PRIMARY KEY,
"company_id" uuid NOT NULL,
"title" text NOT NULL,
CONSTRAINT "content_cases_company_case_unique" UNIQUE ("company_id", "id")
);
CREATE TABLE "plugin_composite_fk"."content_case_signals" (
"company_id" uuid NOT NULL,
"case_id" uuid NOT NULL,
"signal" text NOT NULL,
"scopes" text[] NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
CONSTRAINT "content_case_signals_company_case"
FOREIGN KEY ("company_id", "case_id")
REFERENCES "plugin_composite_fk"."content_cases" ("company_id", "id")
ON DELETE CASCADE
);
INSERT INTO "plugin_composite_fk"."content_cases" ("company_id", "id", "title")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'case'
);
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes", "warnings")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'signal',
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'],
jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"'))
);
`);
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-composite-fk-test",
backupEngine: "javascript",
});
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const rows = await restoreSql.unsafe<{
signal: string;
title: string;
scopes: string[];
warnings: Array<string | { code: string }>;
}[]>(`
SELECT s."signal", c."title", s."scopes", s."warnings"
FROM "plugin_composite_fk"."content_case_signals" s
JOIN "plugin_composite_fk"."content_cases" c
ON c."company_id" = s."company_id"
AND c."id" = s."case_id"
`);
expect(rows).toEqual([
{
signal: "signal",
title: "case",
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"],
warnings: ["json warning", { code: 'quoted "value"' }],
},
]);
await expect(
restoreSql.unsafe(`
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes")
VALUES (
'11111111-1111-4111-8111-111111111111',
'33333333-3333-4333-8333-333333333333',
'orphan',
ARRAY[]::text[]
)
`),
).rejects.toThrow();
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"restores fallback COPY data when child tables are dumped before parent tables",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_copy_fk_restore_target",
);
const backupDir = createTempDir("paperclip-db-copy-fk-backup-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
const originalPgDumpPath = process.env.PAPERCLIP_PG_DUMP_PATH;
const originalPsqlPath = process.env.PAPERCLIP_PSQL_PATH;
process.env.PAPERCLIP_PG_DUMP_PATH = "/bin/false";
process.env.PAPERCLIP_PSQL_PATH = "/bin/false";
try {
await sourceSql.unsafe(`
CREATE TABLE "public"."zzz_parent_records" (
"id" uuid PRIMARY KEY,
"name" text NOT NULL
);
CREATE TABLE "public"."aaa_child_records" (
"id" uuid PRIMARY KEY,
"parent_id" uuid NOT NULL REFERENCES "public"."zzz_parent_records"("id") ON DELETE CASCADE,
"note" text NOT NULL
);
INSERT INTO "public"."zzz_parent_records" ("id", "name")
VALUES ('11111111-1111-4111-8111-111111111111', 'parent');
INSERT INTO "public"."aaa_child_records" ("id", "parent_id", "note")
VALUES (
'22222222-2222-4222-8222-222222222222',
'11111111-1111-4111-8111-111111111111',
'child emitted before parent'
);
`);
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-copy-fk-test",
backupEngine: "auto",
});
const backupSql = gunzipSync(await fs.promises.readFile(result.backupFile)).toString("utf8");
expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeGreaterThan(-1);
expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeLessThan(
backupSql.indexOf("-- Data for: public.zzz_parent_records"),
);
expect(backupSql).not.toContain(" FROM stdin;");
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const rows = await restoreSql.unsafe<{ note: string; name: string }[]>(`
SELECT child."note", parent."name"
FROM "public"."aaa_child_records" child
JOIN "public"."zzz_parent_records" parent ON parent."id" = child."parent_id"
`);
expect(rows).toEqual([{ note: "child emitted before parent", name: "parent" }]);
} finally {
if (originalPgDumpPath === undefined) {
delete process.env.PAPERCLIP_PG_DUMP_PATH;
} else {
process.env.PAPERCLIP_PG_DUMP_PATH = originalPgDumpPath;
}
if (originalPsqlPath === undefined) {
delete process.env.PAPERCLIP_PSQL_PATH;
} else {
process.env.PAPERCLIP_PSQL_PATH = originalPsqlPath;
}
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"restores legacy public-only backups without migration history",
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,
);
});