The backend came up clean on the first pass. The two things that actually cost me time weren't in the schema — they were a Postgres permission that refused to be revoked, and a seed file too big for the tool meant to load it. Neither problem cared what app I was building.

This is the field note from standing up a dedicated Supabase project — moving off the instance that came bundled with Lovable and onto a project I control, sitting alongside Cloudflare and OpenRouter for the rest of the stack.

---

What I Was Trying to Do

Lovable is great for getting to a working app fast. But the database it gives you is shared territory — fine for a prototype, not where you want your production data, your cron jobs, and your row-level security policies living long term.

The goal for this phase was narrow and deliberate: stand up a clean, dedicated Supabase project, with the full schema, security, and reference data, so the front end could be rebuilt on top of it later.

text
Old: Lovable app   →  Lovable-bundled Supabase
New: Own front end →  Own Supabase  +  Cloudflare (R2/storage)  +  OpenRouter (AI)

This note covers the backend (Supabase) phase. The Cloudflare and OpenRouter pieces are the surrounding stack — storage and AI generation — wired in separately.

---

Starting State

text
specs:          written (schema SQL, module docs, build runbook)
seed data:      exported from old Lovable project as CSV
supabase MCP:   connected — but pointed at an unrelated existing project
new project:    does not exist yet

The first useful check happened before any building. I asked the agent to list Supabase projects, and it found one — an existing, unrelated project. It correctly flagged that this was *not* the target, and that a new project was needed. That's the behaviour you want: confirm what's there before creating anything new.

---

What Actually Happened

Turning Point 1: Plan first, gate the irreversible

Before touching anything, the work was to read the schema SQL, the module docs, and the build runbook, then produce a numbered plan and stop. Creating a Supabase project is a production mutation — it doesn't get to happen on a whim.

So the first real action was a cost check, not a build:

text
get_cost → confirm_cost  →  $0/month confirmed

Then a hard stop at the human gate. Project name, org, region, cost — all on the table for approval before create_project ran. Nothing irreversible happened until I said go.

The boring middle is where the real setup happens. The gate is the cheapest insurance you'll ever buy.

Turning Point 2: Migration day is data-hygiene day

While preparing the seed SQL, one of the content tables turned out to have inconsistent naming. The same conceptual field appeared under three different legacy labels that didn't match the agreed spec:

text
"Destruction"  → should be "Break"
"Initiate"     → should be "Control"
"Receive"      → should be "Collect"

This is the kind of thing that silently rots an app: the front end filters on "Break", the data says "Destruction", and nothing renders. The fix went into the seed-generation script, so the corrected values landed in the new database on first insert.

Verification was a simple count:

text
74 occurrences of "Break" in the seeded table
0  residual "Destruction" / "Initiate" / "Receive" as classifier values

(One residual hit for "Destruction" survived — inside a longer description field, where the word was meant to be there. Worth checking, not worth panicking over.)

The lesson: a migration is the natural checkpoint to fix data hygiene. Don't carry the old mess into the new house.

Turning Point 3: When the tool chokes on size, change tools

The schema and security migrations all applied cleanly. Then came the calendar seed — 458 rows, ~36KB per batch — and the loading tool simply couldn't take it. The file was too large for the agent's file reader, too large to push through the MCP call in one shot.

I tried splitting it into batches. Still awkward. The actual fix was to stop fighting the high-level tool and drop down to the Supabase CLI, which has a flag built for exactly this:

bash
# link once, then execute SQL files directly
supabase link
supabase db query --linked -f 03_seed_reference_data.sql

That loaded the full 458 rows in one call. The other seed files followed the same path.

The real problem was not the data. It was the transport. When a tool chokes on size, find the tool built for size.

Turning Point 4: PUBLIC is the default you forget about

This was the one that actually mattered.

After everything was loaded, I ran the security audit — get_advisors(security) — and got back 12 warnings. Most were acceptable and intentional (public signup forms allow open INSERT; has_role() *has* to be callable for the RLS policies to work). But three were real: trigger-only and cron-only functions were callable over the public API as RPCs. Things like expire_promo_subscriptions() and handle_new_user() should never be reachable by an anonymous user.

So I issued the REVOKEs. Re-checked. They hadn't taken effect.

The clue: the privileges weren't granted directly to the anon and authenticated roles — they were inherited from the PUBLIC role. In Postgres, every function is granted EXECUTE to PUBLIC by default. Revoking from anon does nothing if the access is flowing through PUBLIC.

The fix was to revoke from PUBLIC first, then re-grant selectively only to the roles that genuinely need it:

sql
REVOKE EXECUTE ON FUNCTION expire_promo_subscriptions() FROM PUBLIC;
-- then grant back ONLY where needed, e.g. has_role to anon + authenticated

After that, the function privileges lined up exactly as intended:

text
function                    anon    authenticated   purpose
expire_promo_subscriptions  no        no            cron-only
handle_new_user             no        no            trigger-only
has_role                    yes       yes           needed by RLS
increment_page_view         no        yes           authed users only
protect_subscription_tier   no        no            trigger-only
set_updated_at              no        no            trigger-only

Last check, the one that protects the whole content model: confirm anonymous users can only read *published* articles, not drafts.

text
articles anon SELECT  →  scoped to status = 'published'  ✓

---

Commands That Mattered

bash
# Load large SQL seed files the MCP tool can't handle
supabase link
supabase db query --linked -f path/to/seed.sql
sql
-- Revoke from PUBLIC, not just the named role — inheritance is the trap
REVOKE EXECUTE ON FUNCTION my_function() FROM PUBLIC;
text
-- Always end a Supabase build with the security audit
get_advisors(security)

---

The Working Chain

text
Specs + CSV exports
   → cost check → human gate → create project
   → extensions → 23 tables → indexes → grants
   → 6 functions + 12 triggers → ~50 RLS policies
   → vault secret → cron jobs
   → seed data (523 rows via CLI)
   → security audit → hardening → verify

Final tally for the phase:

text
tables:          23
functions:       6   triggers: 12
RLS policies:    ~50
cron jobs:       4
seed rows:       523 across 5 tables
forward coverage: ~15 months of reference data

---

What I Left Honest

A clean migration still leaves a punch list. On this one it was three small things — one content table whose forward coverage was shorter than the spec wanted, a legacy text blob still using the old vocabulary in places, and a few in-content links pointing at routes I'd since renamed. None of them blocked launch. All of them were named, dated, and scheduled.

"Done, with these three known gaps" builds more trust than claiming a spotless finish. The gaps are scheduled, not forgotten.

---

What I Learned

1. Gate the irreversible, automate the rest

Creating a project, inserting a secret, applying a migration — these get a human checkpoint. Reading data, running counts, generating SQL — these run freely. Knowing which is which is the whole discipline.

2. Migration day is data-hygiene day

You will never have a cleaner moment to fix inconsistent values than the instant before they enter a fresh database. Run a SELECT DISTINCT on every column the front end filters or groups by. Fix it in the seed script, verify with a count, move on.

3. In Postgres, `PUBLIC` is the default you forget about

If a REVOKE looks like it did nothing, the access is almost certainly inherited from PUBLIC. Revoke there first, then grant back narrowly. This is the single most common RLS-hardening miss, and it doesn't only happen on Supabase — it's a Postgres trap that travels with every Postgres-backed product.

4. When a tool chokes on size, change tools, not strategy

I lost time trying to batch a seed file through a tool that was never going to take it. The CLI had a -f flag the whole time. Match the tool to the job's shape.

5. End every backend build with the advisor

get_advisors(security) is the difference between "I think it's secure" and "I checked." Twelve warnings sounds alarming until you triage them — most were intentional, three were real. You only know which by reading each one.

---

Try This Next

If you're on Lovable and thinking about your own dedicated backend: before you migrate anything, export your reference tables to CSV and run a single SELECT DISTINCT on every column the front end filters or groups by. You'll find the dirty values — the three spellings of the same thing — while it's still cheap to fix. That one query is the best hour you'll spend on the whole migration.

---

*All views are my own. This field note is shared for learning and experimentation.*