Migrations
Changing rows that already exist, one tenant at a time.
Migrations are declared in order and handed to the tenant object:
export const TenantObject = createTenantObject<Env>({
schema: databaseSchema,
functions: serverFunctions,
subscribableQueries,
migrations, // [{ version: 1, name: "add_updated_at", apply }, …]
});They are applied per tenant, when that tenant next wakes — not fleet-wide at deploy. A deploy never has to touch every tenant at once, and a tenant nobody uses costs nothing until it is used.
The trade is that deployed and migrated become different questions.
migrationStatus answers the second for one tenant; there is currently no
fleet-wide aggregate.
Writing one
{
version: 2,
name: "add_updated_at",
apply: (ctx) => {
for (const row of ctx.table("notes").list()) {
ctx.table("notes").update(String(row["id"]), { updatedAt: row["createdAt"] });
}
},
}Each migration commits in one transaction with the record that it ran, so
the data and the history cannot disagree. apply is synchronous for that reason
— and because an await inside a storage transaction is the mistake an async
signature would invite. A migration that throws is rolled back, left unrecorded,
and retried on the next start.
A released migration is immutable. Renaming or editing one is refused at startup, because tenants that already ran it cannot be told about the change. Add a new version instead.
There is no down. Reversing data on a live multi-tenant system is a restore,
not a code path.
When a tenant refuses to serve
A tenant whose history disagrees with the build is stopped rather than served —
RPC calls reject and WebSocket upgrades return 503. Three causes, all stated
in the error:
| Cause | What happened |
|---|---|
| Storage ahead of the build | A rolled-back deploy. The data is newer than the code |
| A migration was renamed | A released migration was edited in place |
| A hole in the sequence | Storage recorded version n without n−1 |
The fix for the first is to roll the code forward, never the data back. Old handlers running against new data is how the damage compounds.
Because migrations are lazy, a bad one reaches tenants gradually — the first tenants to wake fail first. Roll the deploy back before more wake, then fix forward.
Checking before you deploy
bunx highx doctor[warn] migrations:pending: 1 migration(s) pending: storage is at 1, build defines 2A build older than the deployed schema is a failure, not a warning.