mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators need first-party agent capabilities for repeatable company work, not just manually created one-off agents. > - Built-in agents need to behave like normal company-scoped agents while preserving approval gates, permissions, budgets, and audit trails. > - Reflection and coaching work also needs bundled instructions, skill content, and a routine so the feature can be installed and reset predictably. > - The API, database, UI, portability, and tests all need to agree on the built-in lifecycle from not provisioned through setup, approval, ready, paused, and reset. > - This pull request adds built-in agent provisioning and the Reflection Coach bundle end-to-end. > - The benefit is a safer first-party path for Paperclip-managed agents without bypassing the same governance model used for operator-created agents. ## Linked Issues or Issue Description No public GitHub issue was found for this exact built-in agent and Reflection Coach bundle work. Problem/motivation: - Paperclip did not have a first-party built-in agent lifecycle for product-owned agents. - Bundled agent resources such as default instructions, skills, and routines needed managed ownership and reset semantics. - Approval-gated companies needed built-in setup to preserve requested adapter, budget, manager, and permission state through board approval. - The board UI needed clear built-in badges, setup affordances, readiness state, and bundle status without exposing secrets. Proposed solution: - Add a company-scoped built-in agent registry, provisioning/reset/reconcile/status APIs, and Reflection Coach bundled resources. - Track bundled managed resources in the database with idempotent migration behavior. - Reuse existing agent approval, authorization, budget, and activity-log paths instead of creating a bypass. - Add UI setup, badges, gates, bundle panels, and route coverage for built-in agents. Duplicate search: - Searched GitHub PRs for `built-in agents Reflection Coach repo:paperclipai/paperclip`; only this PR was returned. - Searched GitHub issues for the same query; no public issues were returned. ## What Changed - Added built-in agent definitions, lifecycle state derivation, provisioning, reset, reconcile, status, and routine-control routes. - Added the `built_in_managed_resources` migration and schema exports for bundled instructions, skill, and routine ownership. - Added the Reflection Coach built-in bundle with default instructions, skill catalog content, routine template, default permissions, and managed-resource drift handling. - Added approval-aware provisioning behavior that preserves requested adapter config, budgets, manager assignment, and built-in permissions through hire approval. - Added authorization and mutation gates for built-in agent and skill changes, including consented Reflection Coach change paths. - Added UI surfaces for built-in agent setup, roster/detail badges, readiness gates, bundle status, routine controls, and route filtering. - Added company import/export and validator coverage for built-in managed resources and low-trust/red-team presets. - Addressed Greptile follow-ups for pending approval reconciliation, consent-gate error propagation, config-read authorization fallback, approval-path manager preservation, and non-model adapter provisioning. ## Verification Local verification: - `git diff --check public/master..HEAD` passed. - `pnpm check:token-gates` passed with all gates clean. - `pnpm exec vitest run ui/src/components/ConfigureBuiltInAgentModal.test.tsx` passed: 1 file, 4 tests. - `pnpm exec vitest run ui/src/components/EntityRow.test.tsx ui/src/pages/Agents.test.tsx ui/src/components/BuiltInAgentGate.test.tsx ui/src/components/ConfigureBuiltInAgentModal.test.tsx ui/src/components/BuiltInBundlePanel.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx ui/src/pages/Routines.test.tsx` passed: 7 files, 64 tests. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/authorization-service.test.ts src/__tests__/company-skills-routes.test.ts` passed: 3 files, 91 tests. - `pnpm --filter @paperclipai/db check:migrations` passed. - `pnpm -r typecheck` passed after the rebase; `pnpm --filter ui typecheck` passed after the final UI review fix. Remote verification on latest head `1c61f693a4ec881d739022b0e75a8ca8bf8c2cd8`: - Merge state: `CLEAN`. - Greptile: `5/5`, zero unresolved Greptile threads. - PR check rollup: all checks successful, neutral, or skipped as expected. - Passing gates include Build, Typecheck + Release Registry, all server shards, all workspace shards, all serialized server suites, e2e, Canary Dry Run, policy, review, verify, Socket, Superagent, and Snyk. ## Risks - This adds a new managed-resource table and migration; the migration uses idempotent create/add/index guards and passed migration safety checks. - Built-in agent provisioning touches approval and authorization paths; tests cover pending approval preservation, stale retry rejection, consent gates, and config-read fallback behavior. - Reflection Coach creates managed instructions, skill, and routine resources; drift/reset behavior is covered by service tests and redacted API responses. - Non-model adapter setup now provisions a `needs_setup` built-in row before command/endpoint fields are complete; this matches the server lifecycle and is covered by the setup modal regression test. ## Model Used OpenAI Codex coding agent based on GPT-5. Exact hosted model ID, context-window size, and reasoning-mode labels are not exposed in this runtime; tool use, shell execution, GitHub CLI/API access, and local code editing were enabled. ## 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>
458 lines
17 KiB
TypeScript
458 lines
17 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?.();
|
|
}
|
|
}, 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(
|
|
"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",
|
|
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 },
|
|
},
|
|
]);
|
|
} 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 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,
|
|
);
|
|
});
|