From 68ed90f97fba1f5e28a2caf2d0b2729c57628f69 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Sun, 9 Aug 2026 23:50:10 -0700 Subject: [PATCH] fix: record the user_hackathons migration on main (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public.user_hackathons exists in the live Supabase project — table, RLS policies and grants — but nothing on main said so. The migration that creates it lived only on the unmerged draft branch for #236, and main's migrations stopped at 20260722192614. That left the repo drifting from the database in two directions at once: rebuild a database from main's migrations and you get a schema missing a table production has, and the migration is absent from supabase_migrations.schema_migrations, so a future `supabase db push` would try to replay a create table for a table that already exists. Bringing the file onto main independently of #236, because the drift is not contingent on that feature landing. The migration is idempotent against the live database — `create table if not exists`, every `create policy` preceded by `drop policy if exists`, and the grants, revokes and `enable row level security` are all no-ops on a re-run — so recording it here changes nothing about the running system. Also brings the README section documenting the divergence, which the migration header already points at ("See README for the divergence") but which only existed on the same draft branch. It records that this file was applied through the SQL Editor rather than `apply_migration`, why `ls` returns one more entry than the recorded ledger, and how to reconcile if a CLI is ever wired up. Deliberately does NOT bring 20260810064325_atomic_tracker_upsert.sql. That one defines a function only tracker-store.ts calls, which exists on #236's branch alone; landing it here would add a function nothing on main uses. This does not by itself apply anything to the database. The table is already there, so main and production now agree — but the ledger reconciliation described in the README is still outstanding if a CLI is adopted. Closes #254 Co-Authored-By: Claude Opus 5 (1M context) --- .../20260725154500_user_hackathons.sql | 92 +++++++++++++++++++ supabase/migrations/README.md | 27 ++++++ 2 files changed, 119 insertions(+) create mode 100644 supabase/migrations/20260725154500_user_hackathons.sql diff --git a/supabase/migrations/20260725154500_user_hackathons.sql b/supabase/migrations/20260725154500_user_hackathons.sql new file mode 100644 index 0000000..f5b73fc --- /dev/null +++ b/supabase/migrations/20260725154500_user_hackathons.sql @@ -0,0 +1,92 @@ +-- supabase/migrations/20260725154500_user_hackathons.sql +-- NOTE: applied by hand through the Supabase SQL Editor on 2026-07-26, because +-- this project has no Supabase CLI or MCP `apply_migration` configured. That +-- path records nothing in `supabase_migrations.schema_migrations`, so unlike +-- every other file here this timestamp is NOT a recorded version — it will not +-- appear in `list_migrations`, and `supabase db push` would try to replay it if +-- a CLI is ever wired up. See README for the divergence. +-- +-- The tracker's per-user pipeline, moved off localStorage. One row per +-- (user, hackathon): which stage it sits in, and whether the user won it (#226). +-- +-- `user_id` is the Clerk `sub`, matching the `submitted_by` convention on +-- public.hackathons. It defaults from the JWT rather than being sent by the +-- client, so a caller cannot write a row owned by someone else even before the +-- policies below are consulted. +-- +-- Deliberately NO foreign key to public.hackathons. That table is a mirror the +-- hourly sync can leave up to an hour behind .github/scripts/listings.json, +-- which is what the app actually renders from — an FK would reject a save for +-- any listing added since the last sync. The id is validated in the app layer +-- against the live listing set instead. + +create table if not exists public.user_hackathons ( + user_id text not null default auth.jwt() ->> 'sub', + hackathon_id uuid not null, + stage text not null default 'interested', + is_win boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (user_id, hackathon_id), + constraint user_hackathons_stage_check + check (stage in ('interested', 'applied', 'accepted', 'going')) +); + +-- `origin` on public.hackathons is text + check for the same reason: adding a +-- stage means dropping and recreating one constraint, not altering a pg enum. + +comment on table public.user_hackathons is + 'Per-user hackathon tracker: pipeline stage and win flag. user_id is the Clerk sub.'; + +alter table public.user_hackathons enable row level security; + +-- Reads and writes are the owner's only. `(select auth.jwt())` is wrapped so +-- Postgres evaluates the claim once per statement instead of once per row — +-- the unwrapped form is what Supabase's performance advisor flags. + +drop policy if exists "read own tracker" on public.user_hackathons; + +create policy "read own tracker" + on public.user_hackathons for select + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "insert own tracker" on public.user_hackathons; + +create policy "insert own tracker" + on public.user_hackathons for insert + to authenticated + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "update own tracker" on public.user_hackathons; + +create policy "update own tracker" + on public.user_hackathons for update + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id) + with check ((select auth.jwt() ->> 'sub') = user_id); + +drop policy if exists "delete own tracker" on public.user_hackathons; + +create policy "delete own tracker" + on public.user_hackathons for delete + to authenticated + using ((select auth.jwt() ->> 'sub') = user_id); + +-- Explicit grants, matching 20260722190741: Supabase's stock bootstrap is not +-- relied on, so a replay onto a fresh database produces a usable table. +-- `anon` gets nothing — a tracker has no public read. +grant select, insert, update, delete on public.user_hackathons to authenticated; +grant all on public.user_hackathons to service_role; + +-- Supabase's stock bootstrap grants every new public table to anon and +-- authenticated via ALTER DEFAULT PRIVILEGES. A per-user tracker must not +-- inherit that, so undo it: anon gets nothing at all, and authenticated keeps +-- only the row DML the policies above gate. TRUNCATE in particular is NOT +-- subject to RLS, so it must never linger on an API role. Mirrors the intent of +-- 20260722154244 for the hackathons table. +revoke all on public.user_hackathons from anon; +revoke truncate, references, trigger on public.user_hackathons from authenticated; + +-- Listing a user's tracker is the only read pattern; the primary key already +-- serves it (user_id leads), so no extra index is created here. diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md index 1a625f7..04a8c9e 100644 --- a/supabase/migrations/README.md +++ b/supabase/migrations/README.md @@ -28,6 +28,23 @@ top saying so and saying how: | `20260722145817_rls_policies.sql` | executable SQL — gained four `drop policy if exists` lines | | `20260722144205_add_deck_columns.sql` | comments only — a stale claim about enforcement, corrected | +One file was **applied by hand**, not through `apply_migration`, so it is absent +from `list_migrations` and `ls` here returns one more entry than the recorded +ledger does: + +| file | state | +| --- | --- | +| `20260725154500_user_hackathons.sql` | applied via the Supabase SQL Editor on 2026-07-26; never sent to `apply_migration` | + +This project has no Supabase CLI or MCP configured, so the migration was run +directly in the dashboard. That records nothing in +`supabase_migrations.schema_migrations`, so its timestamp stays a placeholder +rather than a recorded version, the two lists do not align, and `supabase db +push` would try to replay it if a CLI is ever wired up. If you later adopt the +CLI/MCP, reconcile by inserting the version into the ledger (or re-running it +through `apply_migration` against a fresh database) rather than trusting the +filename alone. + The three SQL divergences all exist so the chain replays cleanly onto a fresh database. That makes the files the runnable artefact and the recorded statements the historical record; they are not interchangeable, and where they disagree the @@ -54,6 +71,16 @@ names, so renaming them is a breaking change for no benefit. **`host` is `company_name` renamed**, done by `build_row` in that same script. +**`user_hackathons` has no foreign key to `hackathons`.** It is the per-user +tracker (stage + win flag) added for #226, keyed on the Clerk `sub` like +`submitted_by`. An FK would look obvious and be wrong: this table's `hackathon_id` +comes from `listings.json`, which the app renders from directly, while +`hackathons` trails it by up to an hour — so a user saving a listing added since +the last sync would hit a constraint violation for a listing that plainly exists +on screen. The id is checked against the live listing set in the app layer +instead, and `user_id` defaults from `auth.jwt()` rather than being sent by the +client, so the owner cannot be spoofed even before the policies are consulted. + ## The two write paths `listings.json` reaches this table through `.github/workflows/sync_supabase.yml`,