From 82ee2adff4eff372c4328b16d8a9e1911c929aea Mon Sep 17 00:00:00 2001 From: Vitor Alves Date: Wed, 8 Jul 2026 14:10:51 +0000 Subject: [PATCH] fix(cutover): make pg17 stageA idempotent (clean v17 db + pg16 guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stageA created the v17 db only when absent, then pg_restore'd into it. A leftover copy (an earlier stageA, or a hand-staged dump) made the restore fail on duplicate objects — hit live validating the logcheck cutover, where db_logcheck already existed on v17 from a July-2 hand run and had to be dropped manually first. stageA now drops+recreates the v17 db from template0 every run, so it is cleanly re-runnable (and works for the pre-staged logcheck/zyramed dbs). A guard refuses stageA when the project is already bound to pg17, so a re-run can never overwrite a live v17 db with a stale pg16 snapshot — roll back first. Safe because during stageA the app still serves from pg16; WITH (FORCE) just clears any idle leftover connection. Co-Authored-By: Claude Opus 4.8 --- control-plane/src/cutover.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/control-plane/src/cutover.ts b/control-plane/src/cutover.ts index dd4658f..40086ba 100644 --- a/control-plane/src/cutover.ts +++ b/control-plane/src/cutover.ts @@ -153,6 +153,15 @@ export async function stageA(name: string): Promise { if (!row.db_password) { throw new Error(`project '${name}' has no stored db password — re-provision it`); } + // stageA assumes the app is serving from pg16: it (re)builds a fresh v17 copy + // from the current pg16 data. Refuse if the project is ALREADY on pg17 — its + // v17 db is live, and re-running would drop it and overwrite with a stale pg16 + // snapshot. Roll back to pg16 first if a re-run is really intended. + if (row.cluster === "pg17") { + throw new Error( + `project '${name}' is already bound to pg17 — stageA would overwrite its live v17 db with a stale pg16 copy. Roll back first (cutover --rollback) if you mean to re-run.`, + ); + } const password = row.db_password; const src = connOf(config.adminUrl); @@ -186,13 +195,15 @@ export async function stageA(name: string): Promise { `alter role ${ident(role)} set idle_in_transaction_session_timeout = '60s'`, ); - // 3. The database on v17 (from template0 — no supabase seed conflicts with - // the restore) + connect privileges scoped to this project's role only. - console.log(`[cutover ${name}] stageA 3/7 database ${database}`); - const d = await v17admin.query("select 1 from pg_database where datname = $1", [database]); - if (!d.rowCount) { - await v17admin.query(`create database ${ident(database)} template template0`); - } + // 3. The database on v17, ALWAYS from a clean slate (from template0 — no + // supabase seed conflicts with the restore). Dropping first makes stageA + // idempotent: a leftover copy (an earlier stageA, or a hand-staged dump) + // would otherwise make the pg_restore below fail on duplicate objects. The + // project is on pg16 here (guarded above), so nothing live depends on the + // v17 copy — WITH (FORCE) just clears any idle leftover connection. + console.log(`[cutover ${name}] stageA 3/7 (re)create database ${database} on pg17`); + await v17admin.query(`drop database if exists ${ident(database)} with (force)`); + await v17admin.query(`create database ${ident(database)} template template0`); await v17admin.query(`revoke connect on database ${ident(database)} from public`); await v17admin.query(`grant connect on database ${ident(database)} to ${ident(role)}`); } finally {