Proving the migration before I could run it
How I made a one-time, irreversible data migration safe by building the test that proves it correct before I built the migration itself.
Today's work was a migration I get to run exactly once. It walks every existing journal entry and encrypts it, and once that runs there is no clean way back to the version that came before. So before I wrote a line of the migration, I wrote the thing that proves the migration is correct.
Build the proof before the operation
Most features I build in the other order. Write the thing, then write a test that checks the thing. For a change you can't take back, that order is backwards. If the test comes second, the first time the operation runs for real is also the first time anyone learns whether it was right, and by then the entries are already changed.
So I inverted it. The migration's adversarial gate got built first, as a wall the engine had to clear, and the engine got written to satisfy a proof that already existed.
There's a discipline in that I didn't fully feel until I was inside it. When the test exists first, you can't quietly soften it to match what the code happened to do. The test is the spec. The code clears it or it doesn't ship. I've talked myself into "that edge case probably won't happen" more times than I'd like, usually right after writing the code that fails on it. Writing the gate first takes that move off the table.
The failure I couldn't see after the fact
Each entry holds more than one piece of writing. There's the conversation itself, and there's a separate field with the organized version of those thoughts. Both have to be encrypted. The obvious way to migrate a row is to encrypt one, then the other, then mark the row done.
Here's the trap. If something interrupts that row halfway, after the first field and before the second, you can land in a state where one piece is encrypted and the other is still sitting there in plain text. And nothing on the surface tells you. The row looks processed. The entry opens fine. The plaintext you missed is just quietly stranded behind a door you can't reopen.
So I designed for that specific failure instead of the ones that announce themselves. Each row gets encrypted in a single atomic write that flips both fields together or neither, so there is no halfway. And the check for whether a row is already done looks at both fields, not one, so a row that somehow ended up half-converted reads as not-done and gets handled, rather than waved through because the first field looked fine.
The bug that would have hurt most was the one I'd never have caught in a spot check. That's the one the test is built around.
Test through the door, not around it
Duskglow leans on a database rule called row-level security. In plain terms, the database itself enforces that each person can only ever touch their own rows, no matter what the app code does. It's the backstop under the whole data model.
When you write a migration test, there's a tempting shortcut. Run it as an admin, with access that sees everything, because that's faster to set up and the migration logic is the same either way. Except it isn't the same. The real migration runs inside real user sessions, under that per-user rule, and the failures I care about are the ones that only surface there: a row a user's own session can't actually reach, a permission that quietly blocks the write, a bug where one person's migration brushes another person's data.
So the test creates real accounts and signs them in the same way a person would, then runs the migration through that boundary. No god-mode shortcut. It is more work to stand up, and it is the only version of the test that exercises what ships. A proof that runs around your security model is proving a system you don't actually deploy.
The code I proved is the code that ships
One more decision tied the rest together. The migration engine doesn't reach out and grab the encryption key on its own. The key gets handed to it from the outside, passed in as an argument.
That sounds like small plumbing. It's the thing that lets the test be honest. Because the key is injected, the test can hand the engine a test key and exercise the exact same code the real run will use, with nothing stubbed out or swapped for a test-only stand-in. The engine that clears the gate and the engine that encrypts real entries are the same code. There's no "tested version" and "shipped version" drifting apart in the gap between them. I've been burned by that gap before, where the mock behaved and the real dependency didn't, and the only difference was the seam. Remove the seam, remove the gap.
The migration still goes out behind a backup, and I'll run it on my own entries last, after the synthetic accounts and a throwaway test account have been through it. But the part that lets me sleep is that the proof came first. The engine was written to clear a bar I'd already set, through the same boundary it'll run under, in the same code that ships.
Mental models
Build the proof before the irreversible operation. When you can't take a change back, the test can't come second. Write the gate first and let the code earn its way past it, so you can't lower the bar to match what you already built.
Design the test around the invisible failure. The dangerous bug is the one that leaves a broken state looking fine, not the one that throws an error you'll notice. Find the failure a spot check would miss, and build the test on that.
Test through your security boundary, not around it. A migration that runs under real per-user rules can fail in ways an admin shortcut never will. If the proof skips the boundary the real run uses, it's checking a different system than the one you ship.
Prove the exact code you ship. Inject the dependencies so the tested path and the deployed path are the same code, instead of a mock and a real counterpart that can quietly diverge.