HighX
Platform

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:

CauseWhat happened
Storage ahead of the buildA rolled-back deploy. The data is newer than the code
A migration was renamedA released migration was edited in place
A hole in the sequenceStorage 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 2

A build older than the deployed schema is a failure, not a warning.

Next

On this page