Identical in Every Way I Checked A developer running a 750-page travel directory migrated its database out of Lovable Cloud into a self-owned Supabase project using Claude Code, after replaying the repo's 89 migrations failed at files 29 and 30 due to a missing row and a duplicated favorites table. The developer instead generated a 438-statement schema script from the live Postgres catalog and verified the copy with an eleven-category structural fingerprint, then caught a data-fidelity bug where JSON-returned numeric columns dropped trailing zeros (5.0 became 5) and a build-time page diff that exposed 71 pages with nondeterministic 'nearby places' ordering caused by ties in rating sorts. I run a travel directory: about 750 pages, a few hundred listings, a couple of dozen user accounts, and a log of every click on an affiliate booking link. The front end has always lived on Vercel. The database didn't. It lived inside Lovable Cloud, the managed backend that comes with Lovable's app builder: a real Supabase project, but one I could only reach through Lovable. No dashboard, no connection string, no pg dump . Lovable gives you a query tool, and on the free plan it locks you out for twenty minutes if you use it too much. This week I moved that database to a Supabase project I own. Claude Code did the hands-on work on my home server, I approved each step, and the plan had one rule: never touch the live database. Build a copy, prove the copy is the same, rehearse on it, then switch the site over once, with a way back. The copy passed every check I wrote. It was still wrong, and the check that caught it was me clicking around an admin page. The obvious way to rebuild a Supabase database is to replay its migrations: the folder of SQL files that, in theory, records every change ever made. The repo had 89 of them. The replay died at file 29 on a missing row, and again at file 30, because a table called favorites was created twice, by two different files, with different columns. Eighteen of the 89 files had been written by hand rather than generated by Lovable. Some had been run on the live database by pasting them into a SQL box. Some, it turned out, had never been run at all. The migrations folder wasn't the database's history. It was a story about the database, written by several authors, and nobody had checked it against the original. So I stopped trusting the story and read the database itself. Postgres will describe every object it holds: tables, columns, defaults, constraints, indexes, triggers, functions, security policies, permissions. One query against the live catalog produced a 438-statement script that rebuilt the structure exactly as it actually was, and it ran in a single transaction. Then I wrote a fingerprint: the same query run on both databases, hashing eleven categories of structure. 439 columns, 108 constraints, 109 indexes, 101 row-level security policies, 19 triggers, 22 functions and their permissions, table permissions, which tables have security switched on, enum types and storage buckets. All eleven hashes matched. Content came next: the listings, the place pages, the law pages, around 800 images. For each table I hashed every row's text on both sides. Seven tables matched. Three didn't. The values were all equal. The text wasn't. A few columns were declared as plain numeric , which keeps exactly the digits you store: a rating saved as 5.0 stays 5.0 , a latitude saved as 40.056930 keeps its last zero. Lovable's query tool returns results as JSON, and JSON numbers don't keep trailing zeros. Somewhere between the live database and my copy, 5.0 became 5 . The page would have shown a rating of "5" where it used to show "5.0". No one reading the data would have spotted that. The checksum did. From then on, every row left the live database as text, never as a number, and the three tables matched to the digit. The real test of a copy is whether the site built from it is the same site. This one prerenders its pages at build time, reading straight from the database, so I built it twice: once against live, once against the copy. Then I diffed all 745 generated pages. 71 differed. Every difference was the same kind: a "nearby places" box showing a different listing. The query sorted listings by rating, and many listings share a rating. When rows tie, Postgres returns them in whatever order they happen to sit on disk, and a freshly loaded copy sits differently from a database that's been edited for a year. Same data, different picks. That wasn't a migration bug. The live site had it too: those boxes could reshuffle between any two deploys. Adding "then by name" to the sort fixed it on both, and the next comparison came back with zero differences across 1,309 files. You can't diff two outputs of a process that isn't deterministic. Making it deterministic was worth doing anyway. Running the site's server code against a fresh database means running paths nobody had exercised in months. Two of them were broken on the live site. The contact form had been failing since May. A security update had changed four "notify me" triggers to call a function that doesn't exist in that database, so every contact message and every review errored on insert and was never saved. Nobody noticed because almost nobody used the form. Behind that sat a second bug: the notification itself had never been sent. The function that sends it reads a shared secret out of Supabase's vault through the REST API, and the vault isn't exposed over the REST API. Every call got a 401. I fixed the first on the live site that day, and the second in the new project. Then came the rehearsal. A private preview deploy of the full site, pointed at the copy, for me to use like an owner. I signed in with my real password, which proved the password hashes had come across. Pages loaded and booking links worked. The admin dashboard said Users: 0 . The copy had twenty. The count comes from the profiles table, and the profiles security policy contains a small subquery. It lets anyone see the profile of a person who has written a public review, so it reads from the reviews table. On the copy, signed-in users weren't allowed to read the reviews table at all, so Postgres refused the whole query, and the dashboard showed zero. On the live site the same policy worked, because in July someone had locked the reviews table down column by column. They revoked read access to the whole table, then granted it back for the safe columns only, keeping receipt images and booking confirmations private. That's 155 grants, attached to individual columns. My fingerprint compared permissions on tables. It never looked at permissions on columns. Postgres stores those in a different place, and I hadn't thought to ask for it. The fingerprint said "identical" and it was telling the truth about everything it had been asked. "Identical" isn't a property of two databases. It's a property of the comparison you ran. Eleven matching hashes meant the copy matched in eleven ways I'd thought of. The twelfth was a column-level permission I didn't know existed, and the only thing that touched it was a person using the admin panel the way they always do. The fix took a minute: read the 155 grants off the live database, apply them, compare. Then I widened the sweep to every other permission type I hadn't compared schemas, defaults, types, storage, role settings . The only differences left were Supabase's own platform internals. After that the switch itself was short. A final export of every table as text, loaded into the copy and compared row by row. All 34 tables matched. Then one commit, pointing the site's database settings and its same-origin database proxy at the new project, plus two production environment variables on Vercel. The old database stayed untouched, so reverting one commit and two variables would put everything back in minutes. The checks after the switch were the boring kind I wanted: every sitemap page returned 200 with the same title, canonical, links and structured data as before. Visitors can't write a single row. A contact message saved and its notification arrived, for the first time since May. Zero rows had landed on the old database between the final export and the switch.