fix(db): repair functions their migrations recorded but never created

A user hit "Could not find the function public.upsert_user_profile(...) in the
schema cache" when toggling push notifications. The function was missing, and so
were the profiles columns it writes — while schema_migrations listed both 002 and
004 as applied.

That pattern turned out to be widespread. Comparing every function declared
across the migrations against the live database: 25 are missing, from six
migrations all recorded as applied. Their tables exist; only the function bodies
are absent. Those files carry "Run this in Supabase SQL Editor" headers, so they
were pasted in by hand before apply-migrations.sh existed and a run that stopped
partway was still recorded. The runner has skipped them ever since, which is why
this stayed invisible until it surfaced as a user-facing error.

apply-migrations.sh is not at fault: it wraps each migration and its tracking row
in one transaction with ON_ERROR_STOP, so it cannot half-record anything. It
inherited a dirty history.

Three of the 25 are actually reached by the app, so those are repaired here:

  apply_for_tier_upgrade   MerchantApplication.tsx:213 - tier upgrades were dead
  check_tier_eligibility   called by the above
  update_p2p_reputation    shared/lib/p2p-fiat.ts:803 - reputation never updated

This is forward-only repair, not a replay of the source files. Replaying them
would abort on their bare CREATE POLICY/INDEX/TRIGGER statements now that the
tables exist, and 20241117054602 in particular would overwrite 016's newer
cancel_expired_trades with its own older definition.

Verified before writing: every table the three functions touch is present, and
the whole migration was run inside BEGIN/ROLLBACK against production — three
functions created, signatures matching what the callers pass, then rolled back
leaving nothing behind.

The remaining 22 stay missing on purpose and are listed in known-schema-gaps.txt.
Nothing calls them, and several are trigger bodies whose triggers were never
created either, so creating them would switch on behaviour that has never run.

Also adds a drift check to apply-migrations.sh: after applying, it compares
declared functions against the database and flags anything missing that is not in
the known-gaps list. Being recorded as applied is not proof of having been
applied, and that gap should never again be discovered by a user. The check
already earned itself — it caught update_p2p_reputation, which my own first pass
had missed to a regex that dropped digits from function names.

upsert_user_profile and the profiles notification columns were applied directly
to the database from their existing migrations (002, 004), which are fully
idempotent; no new migration was needed for them.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-30 09:16:01 -07:00
1 parent e95907ec87
commit 658c99b9bb
3 files changed
+366

No files matched your search

+45
View File
@@ -76,3 +76,48 @@ if [[ $pending -eq 0 ]]; then
else
echo "✔ applied $done migration(s)"
fi
# ── Drift check ───────────────────────────────────────────────────────────────
# Being recorded as applied is not proof of having been applied. Six migrations
# carry "Run this in Supabase SQL Editor" headers and were pasted in by hand
# before this runner existed; at least one run stopped partway — the tables
# landed, the function bodies did not — and the version was recorded anyway.
# This runner then skipped them forever, so the gap stayed invisible until a
# user hit "Could not find the function ..." in production.
#
# So after applying, compare what the migrations declare against what the
# database actually has. Known, accepted gaps live in known-schema-gaps.txt;
# anything outside that list is new drift and gets surfaced loudly.
echo "▶ drift check: declared functions vs database"
gaps_file="$MIGRATIONS_DIR/../deploy/known-schema-gaps.txt"
declared="$(grep -rhoiE 'create (or replace )?function (public\.)?[a-z0-9_]+' "$MIGRATIONS_DIR"/*.sql 2>/dev/null \
| awk '{print tolower($NF)}' | sed 's/^public\.//' | sort -u)"
present="$("${PSQL_Q[@]}" -c \
"SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public';" \
| sort -u)"
missing="$(comm -23 <(echo "$declared") <(echo "$present") || true)"
if [[ -n "$missing" ]]; then
if [[ -f "$gaps_file" ]]; then
known="$(grep -vE '^\s*(#|$)' "$gaps_file" | tr -d ' \t' | sort -u)"
unexpected="$(comm -23 <(echo "$missing") <(echo "$known") || true)"
else
unexpected="$missing"
fi
known_count="$(echo "$missing" | grep -c . || true)"
new_count="$(echo "$unexpected" | grep -c . || true)"
if [[ -n "$unexpected" ]]; then
echo "::warning::schema drift — $new_count function(s) declared in migrations but absent from the database:"
echo "$unexpected" | sed 's/^/ ✗ /'
echo " A migration is recorded as applied but its functions are not there."
echo " Fix with a forward-only repair migration, or add to known-schema-gaps.txt if intentional."
else
echo " ✔ no new drift ($known_count known gap(s), see known-schema-gaps.txt)"
fi
else
echo " ✔ every declared function is present"
fi
+41
View File
@@ -0,0 +1,41 @@
# 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.
#
# 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.
#
# The three the app does reach were repaired in 20260730150000 and are not here:
# apply_for_tier_upgrade, check_tier_eligibility, update_p2p_reputation
#
# 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