From 90abb926f8d3d3aed98a9b2e5802a09af94178fd Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:39:37 +0200 Subject: [PATCH] fix(migrations): unbreak main, and stop two sessions sharing a version number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main is red and CD has stopped deploying for the whole repo. Migration Replay aborts with: ERROR: duplicate key value violates unique constraint "schema_migrations_pkey" Key (version)=(20260826180000) already exists. Supabase keys its ledger on the numeric PREFIX of the filename (supabase_migrations.schema_migrations.version, a PRIMARY KEY), not on the whole name. Two sessions working in parallel each timestamped a migration 20260826180000 — mention_queue_all_mentions and resolve_username_history_rpc — so to the ledger they are one migration, and replaying them dies. git cannot catch this. Neither file conflicts with the other: different names, different contents, no overlapping lines. Only the ledger notices, and by then it is on main, where it blocks the deploy carrying the fix each session was waiting on. Renamed mine to 20260826181500. Nothing else about it changes; the collision is entirely in the filename. Then closed the class, because "pick a unique timestamp" is exactly the kind of instruction that works until two people are typing at once: scripts/check-migration-versions.mjs fails when any two migrations share a version prefix, and it runs inside `verify`, so a collision costs one red PR check instead of a blocked pipeline. Verified by reintroducing a duplicate and watching it name both files, then passing on a clean tree. npm run verify green; 2385 tests pass. Co-Authored-By: Claude Opus 5 --- package.json | 5 +- scripts/check-migration-versions.mjs | 57 +++++++++++++++++++ ...26181500_resolve_username_history_rpc.sql} | 0 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 scripts/check-migration-versions.mjs rename supabase/migrations/{20260826180000_resolve_username_history_rpc.sql => 20260826181500_resolve_username_history_rpc.sql} (100%) diff --git a/package.json b/package.json index 3f1ab2987..d8b0d9a96 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:rpc-exists": "node scripts/check-rpc-exists.mjs", "check:ai-models": "node scripts/check-ai-models.mjs", "check:mdx": "node scripts/check-mdx.mjs", - "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", + "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:migration-versions && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", "audit:schema": "node scripts/db/audit-schema-drift.mjs", "audit:routes": "node scripts/audit-routes.mjs", "gen:types": "bash scripts/db/gen-types.sh", @@ -105,7 +105,8 @@ "test:e2e:matrix": "playwright test tests/e2e/workflow-matrix.spec.ts --project=chromium --reporter=line", "test:e2e:matrix:p0": "playwright test tests/e2e/workflow-matrix.spec.ts --project=chromium --grep @p0 --reporter=line", "db:audit": "node scripts/db-audit.mjs", - "eval:voice": "node scripts/eval-voice-routing.mjs" + "eval:voice": "node scripts/eval-voice-routing.mjs", + "check:migration-versions": "node scripts/check-migration-versions.mjs" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", diff --git a/scripts/check-migration-versions.mjs b/scripts/check-migration-versions.mjs new file mode 100644 index 000000000..14eed1ba3 --- /dev/null +++ b/scripts/check-migration-versions.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * One migration per version number. + * + * Supabase keys its ledger on the numeric prefix of the filename + * (supabase_migrations.schema_migrations.version, a PRIMARY KEY), not on the + * whole name. So two files that merely SHARE a prefix are the same migration as + * far as the ledger is concerned, and replaying them dies on: + * + * ERROR: duplicate key value violates unique constraint "schema_migrations_pkey" + * Key (version)=(20260826180000) already exists. + * + * That is not a theoretical clash. It happened on 2026-08-26: two sessions + * working in parallel each timestamped a migration 20260826180000 + * (mention_queue_all_mentions, resolve_username_history_rpc), main went red, + * and CD stopped deploying for the whole repo — including the deploy carrying + * the fix each of them was waiting on. + * + * git does not catch it, because neither file conflicts with the other: they + * have different names and different contents. Only the ledger notices, and by + * then it is on main. So this runs in CI, where a collision costs one red PR + * check instead of a blocked pipeline. + * + * Run: node scripts/check-migration-versions.mjs + */ + +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIR = join(process.cwd(), 'supabase', 'migrations'); + +const byVersion = new Map(); +for (const name of readdirSync(DIR)) { + if (!name.endsWith('.sql')) continue; + const version = name.match(/^(\d+)/)?.[1]; + if (!version) { + console.error(`✗ ${name} has no leading version number — supabase cannot record it`); + process.exit(1); + } + if (!byVersion.has(version)) byVersion.set(version, []); + byVersion.get(version).push(name); +} + +const collisions = [...byVersion.entries()].filter(([, files]) => files.length > 1); + +if (collisions.length > 0) { + console.error('✗ migration version collision — supabase_migrations.schema_migrations.version is a PRIMARY KEY,'); + console.error(' so these files are the same migration to the ledger and a replay will abort:\n'); + for (const [version, files] of collisions) { + console.error(` ${version}:`); + for (const f of files) console.error(` ${f}`); + } + console.error('\n Fix: bump one filename to an unused timestamp. Nothing else needs to change.'); + process.exit(1); +} + +console.log(`✓ migration versions unique (${byVersion.size} migrations)`); diff --git a/supabase/migrations/20260826180000_resolve_username_history_rpc.sql b/supabase/migrations/20260826181500_resolve_username_history_rpc.sql similarity index 100% rename from supabase/migrations/20260826180000_resolve_username_history_rpc.sql rename to supabase/migrations/20260826181500_resolve_username_history_rpc.sql