Supabase Integration and the Rada v2 Cutover
Yaya Engine used to treat Supabase as the system of record for weather geography, forecasts, and alert subscriptions, reached over PostgREST through a Saloon HTTP connector. The Rada v2 project is collapsing that cross-database dependency into yaya-engine's own Postgres tables, and the two halves of this page describe where the codebase still stands mid-cutover: a live, flag-gated sync path to Supabase that should be shrinking toward zero, instrumented with a "tripwire" that catches any call site the cutover missed, and a set of yaya-engine-native tables (towns, states, countries, seasons, events, scheduled_alerts, town_forecasts, town_observations, forecast_configs, dispatch_modes) that migrated cleanly onto Postgres but were caught, once, going live in code while sitting empty in production.
The Supabase connector and sync service¶
App\Http\Integrations\Supabase\SupabaseConnector is a Saloon Connector that resolves its base URL to {baseUrl}/rest/v1 and authenticates with a bearer token plus an apikey header, matching Supabase's PostgREST convention [@connector]. App\Services\SupabaseService wraps it with the actual sync logic: syncUser() upserts a yaya-engine User into Supabase's users table, and syncAlertSubscription() upserts the user's confirmed primary location into Supabase's alert_subscriptions table as a PostGIS point [@service]. Both are gated by isEnabled(), which requires services.supabase.sync_enabled to be true and both the URL and API key to be present — so the integration can be fully wired in code while doing nothing in an environment where the flag is off [@service].
Resolving which Supabase row corresponds to a yaya-engine user is not a straight foreign key lookup, because Supabase predates yaya-engine's user model. resolveSupabaseUserId() looks up by yaya_user_id first and falls back to phone number, specifically because Telegram-onboarded users have no phone number until they reach the airtime-payout flow, so a phone-first lookup would silently fail to dedupe them [@service]. resolveCountryName() is stricter than it once was: it throws rather than defaulting when a country code is missing or unresolvable, because an earlier silent fallback to "Kenya" corrupted alert-subscription data for every non-KE/CO country (issue #169) [@service].
The tripwire¶
Every outbound Saloon request through SupabaseConnector logs a structured event from boot(): rada.supabase_call on the rada log channel, carrying the resolved endpoint, HTTP method, and a caller field it recovers by walking the backtrace past Saloon and connector frames to the first real call site [@connector]. The doc comment on boot() states the intent plainly: "Post-cutover this should fire ~zero times; any residual call site is a regression we want to surface fast" [@connector]. In production the rada channel stacks onto Nightwatch, so these events become searchable, alertable log entries rather than lines in a file nobody tails [@tripwire-doc].
The tripwire has explicit alert thresholds: zero events per five minutes is the healthy steady state, more than five a minute means a code path slipped through cutover and should be investigated within a day, and more than fifty a minute means a high-traffic path is still hammering Supabase and should page on-call [@tripwire-doc]. The doc names the likely offenders as of this writing: SupabaseService's own sync paths (verify sync_enabled is false in prod), App\Services\Messaging\BroadcastService's bulk fan-out, and App\Http\Controllers\Api\V1\UsersWithForecastsController's read path, which should be migrated to a yaya-engine-local query [@tripwire-doc]. This instrumentation traces back to ye#333, the tripwire ticket, and is a companion piece to Rada v2 Collapse and Supabase/Rada v2 Postgres Cutover.
The identity-source toggle¶
config/rada.php exposes RADA_USER_IDENTITY_SOURCE, controlling where yaya-engine reads user-to-town mappings from: supabase (the historical PostgREST cross-DB read) or yaya_engine (the local user_nearest_towns table, populated by a backfill and going forward written by rada-weather) [@rada-config]. It defaults to supabase, so shipping this config is a no-op until an operator flips the environment variable per-environment — the same "code merged, behavior unchanged until an explicit switch" pattern as SupabaseService.isEnabled() [@rada-config].
A gap the tripwire doesn't cover yet¶
Supabase's users table has no fsp_id column today. Downstream consumers — alert-manager and rada-message-system — infer which FSP a user belongs to from country plus whether yaya_user_id is set, a heuristic BUSINESS.md calls brittle [@business-md]. The planned fix is to add an fsp_id mirror column to Supabase ahead of the full collapse so those consumers stop depending on the inference, rather than requiring a fresh Supabase schema change for every new dimension the team wants to expose [@business-md].
The Postgres side of Rada v2¶
The Rada v2 side of the cutover — moving geography, forecast, and alert-scheduling tables natively into yaya-engine — went through a dedicated verification pass on 2026-07-08. Because the yaya-engine test suite runs on SQLite in-memory while production runs Postgres (Neon), this pass stood up a real standalone Postgres 18 cluster and ran migrate:fresh --force against all 113 migrations, which applied with zero errors and rolled back cleanly with migrate:rollback --force, including FK teardown ordering [@verification-doc]. A leading-column FK-index audit across pg_constraint/pg_index confirmed all ten Rada v2 foreign-key constraints are covered by a proper index — events.season_id, events.country, event_fsp_assignments.event_id/fsp_id, scheduled_alerts.event_id, seasons.country, forecast_configs.country, forecast_config_audits.country, dispatch_modes.country, and dispatch_mode_audits.country — while flagging, out of scope, roughly 21 pre-existing yaya-engine FK columns (on users, user_action_steps, requested_payouts, generated_links, and others) that lack a covering index [@verification-doc].
The pass also exercised PruneForecastStoreCommand's append-only retention logic: rows are deleted only when they are both superseded (not the MAX(id) within their (town_id, model, forecast_date) group) and old (created_at past the retention window), so the newest row in a group is always kept even if it is old — a seeded "data-loss sentinel" row proved this by being simultaneously the oldest and the latest-by-id row in its group and surviving the prune [@verification-doc]. The verdict: no critical or major bugs in the Rada v2 migration set [@verification-doc].
When "done" meant "merged," not "ran"¶
Passing migrations do not mean the cutover actually happened in production, and that gap bit the team directly. Three geography-cutover sub-issues (#317, #352, #355) were closed as done once the migration, the rada:backfill-geography command, and the read API had merged and deployed — but the one-time operational step of actually running the backfill against production never happened, and prod's towns and states tables sat at zero rows for roughly two weeks (migration on 2026-06-25, discovery on 2026-07-08) [@p22-incident]. Nothing alerted on empty tables, and the legacy Supabase paths kept serving reads in the meantime, so the gap was invisible from the outside [@p22-incident]. The root cause was procedural: issue closure tracked code shipped, not effect achieved, and a runnable backfill command that exists but was never invoked is indistinguishable from one that ran, purely from the tracker's point of view [@p22-incident]. The fix applied going forward is to require a prod row-count check pasted into the issue before closing any data-migration cutover ticket, and to treat "command merged" and "command executed against prod" as two separate checkboxes [@p22-incident]. This incident is the direct forerunner of Rada v2 Postgres Cutover as a decision record, and it shapes how Rada v2 Collapse frames "cutover" as an operational event, not just a code state.
Together, the tripwire and the verification/incident history describe a system in transition: Rada v2's own tables are proven correct on Postgres, but the surrounding process of retiring Supabase reads and writes is still actively monitored rather than finished. See Domain Model for how these tables relate to the rest of yaya-engine's schema, and API v1 Surface for the endpoints that now serve what Supabase used to.