db: replace the migration history with a baseline of the live schema

The migrations did not describe this database, and could not be made to.

Three findings, in the order they surfaced:

  * 25 functions declared across six migrations — all recorded as applied — did
    not exist. Their tables did. Two were reached by the app, so merchant tier
    upgrades and post-trade reputation updates had been quietly dead. This only
    came to light because a user hit "Could not find the function
    public.upsert_user_profile(...)" while toggling a notification setting.

  * admin_roles has three conflicting definitions across the set and production
    matches none of them. 001 says (id, user_id, role, granted_by, granted_at),
    COMBINED says (user_id, role, created_at), production has
    (id, user_id, role, permissions, created_at, updated_at).

  * Applied to an empty database, five migrations fail. The legacy 0NN filenames
    sort before the 14-digit timestamps they depend on — "013" < "20241117054600"
    — so 013 runs before the migration creating the table it alters. The set
    could never have been replayed from scratch.

So it could not be tested, could not rebuild the database, and did not match what
was running. Widening or patching it would have been dressing up a history that
was already fiction.

The baseline is a pg_dump of the live public schema, which matches production by
construction. Privileges are included deliberately: the REVOKEs on
lock_escrow_internal, release_escrow_internal, refund_escrow_internal and
request_withdraw are the 20260725030000 hardening, and dropping them would hand
fund movement back to anon.

Verified step by step against production before committing:
  - applies to an empty database cleanly, after three real obstacles were fixed
    (extensions live in the `extensions` schema, supabase_admin membership,
    platform-level ALTER DEFAULT PRIVILEGES that cannot apply outside Supabase)
  - produces 83 tables / 39 functions / 222 indexes / 360 policies — identical
    counts to production
  - recorded as applied in supabase_migrations without touching the 37 existing
    rows, then dry-run confirmed "up to date — no pending migrations", so the
    next deploy will not try to replay it over live tables
  - drift check now reports "every declared function is present"; the known-gaps
    list drops from 22 entries to zero

The old files move to migrations/archive/ rather than being deleted — they are
the only record of why parts of this look the way they do. Their README says
plainly not to run them, and why.

CI now applies migrations to an empty Postgres on every PR. That check was
impossible while the old set was the starting point; it is the thing that stops
this class of drift from being discovered by a user again.
This commit is contained in:
2026-07-31 18:49:59 -07:00
parent b20cfc84ef
commit e18ba679be
43 changed files with 10298 additions and 37 deletions
+87 -1
View File
@@ -1082,10 +1082,96 @@ jobs:
# All required checks must succeed (or be skipped, e.g. for rollback path).
# Branch protection on main should require this job's success.
# ========================================
# Applies every migration to an empty Postgres, so a migration that cannot run
# from scratch fails here rather than on the production host.
#
# This was not possible before 2026-08-01: the old set could not be replayed at
# all. Five migrations failed on an empty database, partly because the legacy
# 0NN filenames sort before the 14-digit timestamps they depend on
# ("013" < "20241117054600"), and the schema they produced did not match
# production anyway. The baseline replaced them with a dump of the live schema,
# which is what makes this check meaningful.
migration-test:
name: Migration test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: migration_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install psql
run: sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client
# Stand-ins for auth.uid(), auth.users, storage.objects and the Supabase
# roles. Without them the run would fail on the environment rather than on
# anything a migration got wrong.
- name: Apply Supabase stubs
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -d migration_test -v ON_ERROR_STOP=1 -q \
-f web/supabase/deploy/test-bootstrap.sql
echo "stubs applied"
- name: Apply migrations in order
env:
PGPASSWORD: postgres
run: |
set -euo pipefail
failed=0
for f in $(ls web/supabase/migrations/*.sql | sort); do
base="$(basename "$f")"
case "$base" in COMBINED*) echo " skip $base"; continue;; esac
# --single-transaction so a failure leaves nothing half-applied,
# matching how apply-migrations.sh runs them in production
if psql -h localhost -U postgres -d migration_test \
-v ON_ERROR_STOP=1 --single-transaction -q -f "$f" 2>/tmp/err.log; then
echo " ✔ $base"
else
failed=$((failed+1))
echo " ✗ $base"
grep -E "ERROR" /tmp/err.log | head -3 | sed 's/^/ /'
fi
done
if [ $failed -gt 0 ]; then
echo "::error::$failed migration(s) cannot be applied to an empty database"
exit 1
fi
echo "all migrations applied from scratch"
# A migration set that applies cleanly but produces nothing is still broken.
- name: Verify the schema was actually built
env:
PGPASSWORD: postgres
run: |
set -euo pipefail
read -r tables functions <<<"$(psql -h localhost -U postgres -d migration_test -tAq -c \
"SELECT (SELECT count(*) FROM pg_tables WHERE schemaname='public'),
(SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
WHERE n.nspname='public')" | tr '|' ' ')"
echo "built: $tables tables, $functions functions"
if [ "$tables" -lt 50 ] || [ "$functions" -lt 20 ]; then
echo "::error::schema looks incomplete — expected the full public schema"
exit 1
fi
ci-gate:
name: CI Gate ✅
runs-on: pwap-runner
needs: [web, backend, security-audit]
needs: [web, backend, security-audit, migration-test]
if: always()
steps: