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:
Satoshi Qazi Muhammed committed 2026-07-31 18:49:59 -07:00
1 parent b20cfc84ef
commit e18ba679be
43 files changed
+10298 -37

No files matched your search

+9 -36
View File
@@ -1,41 +1,14 @@
# Functions declared in migrations that are knowingly absent from the database.
#
# Six migrations were recorded as applied but only ran partway: their tables
# exist, their function bodies never ran. Those files carry "Run this in Supabase
# SQL Editor" headers — they were pasted in by hand before apply-migrations.sh
# existed, and a run that stopped midway still got recorded.
# Empty, and it should stay that way.
#
# Nothing in the app calls any function listed here, and several are trigger
# bodies whose triggers were never created either. Creating them now would switch
# on behaviour that has never been live (P2P notification triggers, fraud
# counters, stats updaters) rather than restore something that broke — a product
# decision, not a repair. They stay listed until someone deliberately enables them.
# This file used to list 22 functions. They came from six migrations recorded as
# applied that had only run partway — their tables existed, their function bodies
# never ran. That gap is gone: the baseline (00000000000000_baseline.sql) is a
# dump of the live schema, so everything it declares exists by construction.
#
# The three the app does reach were repaired in 20260730150000 and are not here:
# apply_for_tier_upgrade, check_tier_eligibility, update_p2p_reputation
# The file is kept because the drift check points here when something is missing.
# If that happens, the first question is whether the function should exist — a
# name listed here is a decision not to have it, not a way to quiet the check.
#
# Remove a line when you actually create the function. Anything missing that is
# NOT listed here is new drift and the deploy will say so.
approve_tier_application
calculate_merchant_stats
calculate_user_risk_score
cancel_expired_offers
check_trade_allowed
decrement_escrow_balance
generate_referral_code
get_payment_method_details
increment_escrow_balance
log_suspicious_activity
notify_on_dispute_opened
notify_on_new_message
notify_on_new_trade
notify_on_payment_sent
notify_on_trade_completed
reset_daily_fraud_counters
reset_weekly_fraud_counters
update_discussion_activity
update_fraud_indicators_on_trade
update_merchant_stats_on_trade
update_rating_stats_trigger
update_user_rating_stats
# One name per line, no trailing comments.
+88
View File
@@ -0,0 +1,88 @@
-- Minimal stand-ins for what Supabase provides, so migrations can be applied to
-- a plain Postgres in CI.
--
-- The migrations reference auth.uid() 127 times, auth.users 95 times,
-- auth.role() 11 times and storage.objects 3 times. None of that exists in a
-- stock Postgres image, so without these stubs every run would fail on the
-- environment rather than on anything the migrations got wrong.
--
-- These are deliberately the thinnest thing that lets DDL resolve. They are not
-- a Supabase emulation and must never be applied to a real database: RLS
-- policies compiled against these stubs would behave differently, because
-- auth.uid() here returns NULL rather than the caller's id.
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS storage;
CREATE SCHEMA IF NOT EXISTS extensions;
-- Supabase installs these into the `extensions` schema, and the dumped schema
-- calls them fully qualified as extensions.uuid_generate_v4(). Installing them
-- into public instead makes the baseline fail on a name that does resolve in
-- production.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA extensions;
-- Enough of auth.users for foreign keys to resolve. Real Supabase has far more
-- columns; migrations only reference id and email.
CREATE TABLE IF NOT EXISTS auth.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text
);
-- In production these read the request's JWT claims. Here they return NULL,
-- which is fine for CREATE POLICY (only the expression has to type-check) and
-- is why this must not touch a real database.
CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid
LANGUAGE sql STABLE AS $$ SELECT NULL::uuid $$;
CREATE OR REPLACE FUNCTION auth.role() RETURNS text
LANGUAGE sql STABLE AS $$ SELECT NULL::text $$;
CREATE OR REPLACE FUNCTION auth.email() RETURNS text
LANGUAGE sql STABLE AS $$ SELECT NULL::text $$;
-- storage.objects: three migrations attach policies to it.
CREATE TABLE IF NOT EXISTS storage.objects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id text,
name text,
owner uuid
);
CREATE TABLE IF NOT EXISTS storage.buckets (
id text PRIMARY KEY,
name text
);
-- Roles the migrations GRANT to. CREATE ROLE is not idempotent, hence the guard.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
CREATE ROLE anon NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
CREATE ROLE authenticated NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
CREATE ROLE service_role NOLOGIN BYPASSRLS;
END IF;
-- The dump carries ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin, which
-- requires membership in that role rather than just its existence.
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_admin') THEN
CREATE ROLE supabase_admin NOLOGIN;
END IF;
END $$;
GRANT supabase_admin TO CURRENT_USER;
GRANT USAGE ON SCHEMA public, auth, storage TO anon, authenticated, service_role;
-- Supabase ships a realtime publication that migrations add tables to.
-- Without it, ALTER PUBLICATION fails on the environment rather than on
-- anything the migration got wrong.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
CREATE PUBLICATION supabase_realtime;
END IF;
END $$;