Supabase-migraties
RLS, grants en de schema-reload
Self-hosted Supabase faalt anders dan de gehoste variant, en het faalt stil. De migratie slaagt, de API geeft een lege array terug en nergens staat een foutmelding. Deze skill is de volgorde die dat voorkomt: idempotente DDL, eerst grants dan policies, en die ene notify-regel onderaan zonder welke je tabel wel in Postgres bestaat en niet over de API.
Zet hem in je eigen agent
Kies je client. Het commando staat er klaar voor.
/plugin marketplace add ASNNetworks/floh-skillseenmalig/plugin install supabase-migrations@floh-skillsTyp deze twee in een Claude Code-sessie. De skill is meteen daarna beschikbaar; herstarten is niet nodig.
Wat je nodig hebt
- psql, of docker exec naar je database-container
- Optioneel curl voor de PostgREST-check
Zo ziet het eruit
Een echte sessie, stap voor stap afgespeeld.
De kern
De notify-regel
PostgREST cachet het schema bij het opstarten. Zonder notify pgrst, reload schema bestaat je tabel niet over de API en wijst elk symptoom naar je clientcode.
Grants en policies in de goede volgorde
RLS zonder grant geeft permission denied. Een grant zonder RLS zet elke rij open. Allebei nodig, en in die volgorde.
Idempotent, altijd
if not exists, en drop policy voor create policy. Een migratie die niet twee keer kan draaien, draait ooit twee keer.
Verifieert tegen de database
verify.sh controleert of de tabel bestaat, RLS aanstaat, er policies zijn, de grants kloppen en PostgREST hem daadwerkelijk serveert. Niet tegen het bestand, tegen de database.
Wanneer je hem pakt
Een tabel toevoegen
Het sjabloon geeft je tabel, index, RLS, grants, twee policies en de notify-regel in één keer goed.
Een RPC schrijven
security definer met een vastgezet search_path, en revoke van public voor je aan een rol grant. Een functie is standaard voor iedereen uitvoerbaar, en dat wil je bijna nooit.
Een migratie reviewen
Vaste controle op destructieve DDL, ontbrekende where en een alter column zonder using, voordat het tegen productie draait.
Debuggen waarom de API niets teruggeeft
De tabel met symptomen en oorzaken wijst de vier klassieke gevallen meteen aan. Meestal is het geen querybug.
Lees hem voordat je hem draait
Dit is precies wat er in de map komt te staan. Geen verrassingen, geen verborgen stappen.
2 bestanden
---
name: supabase-migrations
description: Write and review Postgres migrations for a self-hosted Supabase project — numbering, RLS policies, grants, idempotency and the PostgREST schema reload. Use when adding a table, changing a column, writing an RPC, or reviewing a migration before it runs against production.
---
# Supabase migrations
Self-hosted Supabase fails differently from managed Supabase. The failures are quiet: the
migration succeeds, the API returns an empty array, and nothing anywhere reports an error.
This skill is the checklist that catches those before they ship.
## When to use this
Writing a migration, reviewing one, or debugging "the table exists but the API says it
does not".
## The order that matters
1. **Take the next free number.** Never reuse one, never renumber an applied migration.
```bash
ls migrations/ | sort -V | tail -1
```
2. **Write the DDL idempotently.** `create table if not exists`, `add column if not
exists`, `drop policy if exists` before `create policy`. A migration that cannot be
run twice will eventually be run twice.
3. **Grant, then policy, then enable.** Row Level Security without a grant returns
permission-denied; a grant without RLS exposes every row. Both, in that order.
4. **End with the schema reload.**
```sql
notify pgrst, 'reload schema';
```
This is the one that gets forgotten. PostgREST caches the schema at boot. Without this
line the table exists in Postgres and does not exist over the API, and every symptom
points at your client code.
5. **Verify against the database, not against the file.**
```bash
bash scripts/verify.sh <table_name>
```
## Template
```sql
-- 00NN_<what_it_does>.sql
-- <one line: why this exists>
create table if not exists public.thing (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
status text not null default 'draft'
check (status in ('draft', 'published', 'archived')),
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists thing_status_idx on public.thing (status)
where status = 'published';
alter table public.thing enable row level security;
grant select on public.thing to anon, authenticated;
grant all on public.thing to service_role;
drop policy if exists "thing: public reads published" on public.thing;
create policy "thing: public reads published"
on public.thing for select
to anon, authenticated
using (status = 'published');
drop policy if exists "thing: service_role full access" on public.thing;
create policy "thing: service_role full access"
on public.thing for all
to service_role
using (true) with check (true);
notify pgrst, 'reload schema';
```
## RPCs
A function the API can call needs `security definer` only when it must bypass RLS, and
then it needs a pinned `search_path` or it is an escalation path:
```sql
create or replace function public.increment_counter(p_slug text, p_kind text)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
update public.thing
set payload = jsonb_set(
payload, array['counts', p_kind],
to_jsonb(coalesce((payload->'counts'->>p_kind)::int, 0) + 1)),
updated_at = now()
where slug = p_slug;
end;
$$;
revoke all on function public.increment_counter(text, text) from public;
grant execute on function public.increment_counter(text, text) to anon, authenticated;
```
`revoke ... from public` first, then grant to the roles that need it. Creating a function
grants execute to `public` by default, which is almost never what you want for a
`security definer`.
## Failure modes, and what they actually mean
| Symptom | Cause |
|---------|-------|
| API returns `[]`, table has rows | RLS on, no policy for that role. Not a query bug |
| API returns "relation does not exist" | Missing `notify pgrst, 'reload schema'` |
| Works as service_role, not as anon | Grant present, policy missing (or the reverse) |
| Insert silently drops rows | Policy has `using` but no `with check` |
| Migration passes locally, fails in prod | Different extension set. Check `gen_random_uuid` |
## Before it runs against production
- Read the whole file top to bottom, out of the diff.
- Confirm nothing is destructive: no bare `drop table`, no `alter column ... type` without
a `using`, no `delete from` without a `where`.
- Confirm it ends with the notify line.
- Run it against a scratch database first if one exists.
- Never run it as part of the same step that writes it.
## Files
- `scripts/verify.sh` — post-migration verification: table exists, RLS on, policies
present, and the anon role can actually read what it should.
Wat er veranderd is
- v1.0.0huidig
- Eerste versie: migratiesjabloon, RPC-patroon, faaltabel en de checklist voor productie.
- verify.sh controleert bestaan, RLS, policies, grants en de PostgREST-cache.
Werkt hij voor je?
Over Supabase-migraties
Wat mensen vragen voordat ze hem installeren.
Combineert goed met
Zo een, maar dan voor jouw werk?
Elke skill hier komt uit werk dat ik echt deed. Zit er iets in jouw proces dat je telkens opnieuw uitlegt aan een agent, dan is dat precies een skill.