# Schema migrations when you do not hold the database Source: https://selfstore.dev/blog/schema-migrations-without-a-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](https://selfstore.dev/docs/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](https://selfstore.dev/docs/errors), 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: ```ts 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](https://selfstore.dev/docs/api-store) has the exact signatures; [concepts](https://selfstore.dev/docs/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](https://selfstore.dev/docs/testing) 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](https://selfstore.dev/blog/indexeddb-as-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. Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt