Schema migrations when you do not hold the database
Local-first inverts who owns the data, and with it who can run a migration. What replaces the deploy-time script - a read-time upgrade, a version that travels with the file, and a loud refusal in the other direction.
A migration in a server app is an operation. There is one database, you have credentials to it, and there is a moment - a deploy, a maintenance window, a long-running job - when every row that exists is in front of you. The script runs, it either finishes or it rolls back, and afterwards the shape of the data is a fact you can rely on in the code below.
Local-first takes that away, and it is worth being precise about what exactly it takes. It is not that migrations get harder to write. It is that the moment when all the data is in one place stops existing. Each user holds their own copy. One user holds several, on a laptop and a phone and a tablet that has not been opened since spring. Behind those sits an encrypted backup in a folder they control, written by a version of your app you have since forgotten. You cannot enumerate those copies, you cannot reach them, and you certainly cannot lock them for the duration of a job.
So the migration has to go somewhere else. It goes into the read path.
Three things that change
The upgrade happens on read, not on deploy. Old data does not arrive at a moment you choose - it arrives when a dormant device wakes up, or when someone restores a two-year-old backup onto a new machine. Your app therefore has to keep the ability to read every shape it has ever written, for as long as those copies plausibly exist. That is the real cost, and it is an ongoing one.
The version has to travel with the data. A snapshot that does not carry its
own schema number cannot be migrated, only guessed at, and guessing shape from
content is how people end up parsing a string field to work out whether it was
ever split in two. The number belongs in the file, next to the payload. In the
backup format, schemaVersion sits in the cleartext meta.json
entry of the archive, which is what makes an old file self-describing to a
reader that has never seen it before.
You do not control rollback. In a server app, old code and new data is a
transient state you engineer your way through. Here it is permanent: someone is
running last year’s bundle, offline, and it will meet data written by this
year’s. That direction has exactly one safe behaviour, and it is to stop. Data
written by a newer schema than the running app must refuse loudly rather than
half-parse - SCHEMA_TOO_NEW in the error contract, which the
app surfaces as “update, then sync” instead of quietly dropping the fields it
does not recognise.
What it looks like
Two options, bumped together, and the second is a pure function from an old snapshot to the current shape:
const store = await selfstore('todo-app', {
schema: 3,
migrate: (from, snap) => {
if (from < 2) snap = splitTitleAndNotes(snap);
if (from < 3) snap = defaultPriorities(snap);
return snap;
}
});
The chain matters more than either step. from is the version that wrote the
data, so a snapshot at 1 falls through both branches and one at 2 through only
the second. Each step is written once and then never touched again, which is
what keeps a five-year-old file readable without anyone having to reason about
five years of accumulated change at once. The store reference
has the exact signatures; concepts covers where the version
sits relative to your release version, which is a different number and should
not be conflated with this one.
Rules that hold up
Additive changes are nearly free
Adding an optional field costs nothing if the read path tolerates its absence. The version number earns its keep on the changes that are not additive: renames, splits, a field that changes type, a collection that becomes two. Bumping the schema for every release turns a meaningful signal into noise, and the noise version is the one nobody bothers to test.
The migration function must be pure
No network, no reads of live app state, no dependence on the current date. It may run on a device that has been offline for months, in the middle of a restore, against a file whose author is long gone. Everything it needs has to be in the snapshot it was handed - collections and files, nothing else.
Never delete the oldest step
The temptation, around version 6, is to drop the branch that upgrades from 1 on the grounds that surely nobody is still there. Somebody is; they are restoring the backup they made before a laptop died. If you genuinely must drop a step, that is a product decision with a user-visible consequence, and it deserves an explicit refusal with a comprehensible message - not a crash in a function that assumed a field would be there.
A lossy migration is lossy forever
Dropping a field is not a change you make once against a live database. It is a change that applies, on read, to every archived copy for the rest of the app’s life. Every restore of a pre-drop backup will discard that data again, silently, each time. That may well be the right call - but it is a deletion, and it should be decided as one.
The test that actually proves it
The migration path is the least-exercised code in a local-first app, because normal use never touches it: your own data is always current. The test that catches a broken upgrade is not a unit test on the function - it is a real backup file, written by a shipped version, checked into the repository, and opened by the suite against today’s code.
One fixture per schema version you have ever released. They are small, they never change, and they are the only evidence that version 1 data still loads. When a migration step gets refactored into something subtly wrong, that fixture is what fails. The testing guide covers running a store in a suite without a browser, which is what makes those fixtures cheap enough to keep adding.
The trade
You give up the ability to make a change true everywhere at once. In exchange, nothing you ship can corrupt data you do not hold, because you are never writing to it - you are only ever reading it forward, on a device, with a version number telling you where it came from. The failure mode of a bad server migration is a table that is now wrong for everyone. The failure mode here is an app that will not open a file until you fix the step, with the file still intact.
That is a better shape of failure, and it is the same trade the rest of this architecture makes: the working copy is the live state and the backup is the durable one, so evolving the shape is a question about what a reader does with an old file - not a question about whether anyone still has one.