From 034733e444885074bcd6a7687623ca43e9e91546 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 10:34:01 -0500 Subject: [PATCH] Fix worktree seed restore for plugin schemas --- packages/db/src/backup-lib.test.ts | 101 +++++++++++++++++++++ packages/db/src/backup-lib.ts | 136 +++++++++++++++++------------ 2 files changed, 183 insertions(+), 54 deletions(-) diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 8a83a8f4db7..10d217e2bbc 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -309,6 +309,107 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { 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"'], + 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; + }[]>(` + 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"'], + 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 () => { diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 2ae92517d58..7a278b60935 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -249,12 +249,39 @@ function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean { Object.keys(opts.nullifyColumns ?? {}).length > 0; } -function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set): string { +function formatPostgresArrayElement(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (Array.isArray(value)) return formatPostgresArrayLiteral(value); + const raw = value instanceof Date + ? value.toISOString() + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + if (raw.length === 0 || /[{}\s,"\\]/.test(raw)) { + return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + } + return raw; +} + +function formatPostgresArrayLiteral(value: unknown[]): string { + return `{${value.map(formatPostgresArrayElement).join(",")}}`; +} + +function formatSqlValue( + rawValue: unknown, + columnName: string | undefined, + nullifiedColumns: Set, + dataType?: string, +): string { const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; if (val === null || val === undefined) return "NULL"; + if (dataType === "json" || dataType === "jsonb") { + return formatSqlLiteral(JSON.stringify(val)); + } if (typeof val === "boolean") return val ? "true" : "false"; if (typeof val === "number") return String(val); if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (Array.isArray(val)) return formatSqlLiteral(formatPostgresArrayLiteral(val)); if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); return formatSqlLiteral(String(val)); } @@ -745,58 +772,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } - // Foreign keys (after all tables created) - const allForeignKeys = await sql<{ - constraint_name: string; - source_schema: string; - source_table: string; - source_columns: string[]; - target_schema: string; - target_table: string; - target_columns: string[]; - update_rule: string; - delete_rule: string; - }[]>` - SELECT - c.conname AS constraint_name, - srcn.nspname AS source_schema, - src.relname AS source_table, - array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns, - tgtn.nspname AS target_schema, - tgt.relname AS target_table, - array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns, - CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, - CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule - FROM pg_constraint c - JOIN pg_class src ON src.oid = c.conrelid - JOIN pg_namespace srcn ON srcn.oid = src.relnamespace - JOIN pg_class tgt ON tgt.oid = c.confrelid - JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace - JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey) - JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey) - WHERE c.contype = 'f' - AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))} - GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype - ORDER BY srcn.nspname, src.relname, c.conname - `; - const fks = allForeignKeys.filter( - (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table)) - && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), - ); - - if (fks.length > 0) { - emit("-- Foreign keys"); - for (const fk of fks) { - const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); - const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); - emitStatement( - `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, - ); - } - emit(""); - } - - // Unique constraints + // Unique constraints must exist before foreign keys that reference them. const allUniqueConstraints = await sql<{ constraint_name: string; schema_name: string; @@ -827,6 +803,58 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } + // Foreign keys (after all tables and referenced unique constraints are created) + const allForeignKeys = await sql<{ + constraint_name: string; + source_schema: string; + source_table: string; + source_columns: string[]; + target_schema: string; + target_table: string; + target_columns: string[]; + update_rule: string; + delete_rule: string; + }[]>` + SELECT + c.conname AS constraint_name, + srcn.nspname AS source_schema, + src.relname AS source_table, + array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns, + tgtn.nspname AS target_schema, + tgt.relname AS target_table, + array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns, + CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, + CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule + FROM pg_constraint c + JOIN pg_class src ON src.oid = c.conrelid + JOIN pg_namespace srcn ON srcn.oid = src.relnamespace + JOIN pg_class tgt ON tgt.oid = c.confrelid + JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace + JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true + JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum + JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum + WHERE c.contype = 'f' + AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))} + GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype + ORDER BY srcn.nspname, src.relname, c.conname + `; + const fks = allForeignKeys.filter( + (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table)) + && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), + ); + + if (fks.length > 0) { + emit("-- Foreign keys"); + for (const fk of fks) { + const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); + const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); + emitStatement( + `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, + ); + } + emit(""); + } + // Indexes (non-primary, non-unique-constraint) const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>` SELECT schemaname AS schema_name, tablename, indexdef @@ -895,7 +923,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise for await (const rows of rowCursor) { for (const row of rows) { const values = row.map((rawValue, index) => - formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns), + formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type), ); emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); }