From 5ae73686567b6308d95793d0ba6b7de0902e34e7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 21:21:18 -0500 Subject: [PATCH] fix(db): tolerate missing constraints during clone upgrades Co-Authored-By: Paperclip --- packages/db/src/client.test.ts | 31 +++++++++++++++++++++++++++++++ packages/db/src/client.ts | 9 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 0d2ff2d98a8..5b941c12965 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -168,6 +168,37 @@ describeEmbeddedPostgres("resetPostgresDatabase", () => { }); describeEmbeddedPostgres("applyPendingMigrations", () => { + it("upgrades an older clone whose replaced connection constraint is already absent", async () => { + const clusterUrl = new URL(await createTempDatabase()); + const databaseName = "missing_connection_constraint"; + clusterUrl.pathname = "/postgres"; + await ensurePostgresDatabase(clusterUrl.toString(), databaseName); + clusterUrl.pathname = "/" + databaseName; + const connectionString = clusterUrl.toString(); + const prefixFolder = await fs.promises.mkdtemp(join(tmpdir(), "paperclip-migration-prefix-")); + cleanups.push(() => fs.promises.rm(prefixFolder, { recursive: true, force: true })); + await fs.promises.mkdir(join(prefixFolder, "meta")); + const journal = JSON.parse(await fs.promises.readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")); + journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx < 255); + await fs.promises.writeFile(join(prefixFolder, "meta/_journal.json"), JSON.stringify(journal)); + for (const entry of journal.entries) { + await fs.promises.copyFile(new URL(`./migrations/${entry.tag}.sql`, import.meta.url), join(prefixFolder, `${entry.tag}.sql`)); + } + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + await migrate(drizzle(sql), { migrationsFolder: prefixFolder }); + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_transport_check"`; + await applyPendingMigrations(connectionString); + expect((await inspectMigrations(connectionString)).status).toBe("upToDate"); + const [constraint] = await sql<{ definition: string }[]>` + SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint + WHERE conrelid = 'tool_connections'::regclass AND conname = 'tool_connections_transport_check' + `; + expect(constraint?.definition).toContain("chat_sdk"); + expect(constraint?.definition).toContain("runtime_auth"); + } finally { await sql.end(); } + }, 30_000); + it("upgrades renumbered recovery migrations and replays their schema idempotently", async () => { const connectionString = await createTempDatabase(); await applyPendingMigrations(connectionString); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index d71aeff6c9e..3df9e543082 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -494,7 +494,14 @@ async function applyPendingMigrationsManually( await runInTransaction(sql, async () => { for (const statement of splitMigrationStatements(migrationContent)) { - await sql.unsafe(statement); + // Older dev schemas can already lack a constraint that the next + // migration replaces. Keep the published SQL/hash intact and let + // that harmless drop proceed; the replacement still runs normally. + const executable = statement.replace( + /^(\s*ALTER TABLE "[^"]+" DROP CONSTRAINT) (?!IF EXISTS\b)/i, + "$1 IF EXISTS ", + ); + await sql.unsafe(executable); } await recordMigrationHistoryEntry(