Schema evolution
Users don't upgrade at the same moment. A phone still on last month's build and a laptop on today's share one relay, and both keep writing. So the engine never runs migration scripts against a user's database: the schema only grows, and every app version has to cope with data from the versions before and after it.
The short version:
| Change | Safe? | Do this |
|---|---|---|
| Add a table | Yes | Just add it. |
| Add a column | Yes | Declare it nullOr(...) or withDefault(...) — see New columns are NULL on old rows. |
| Add an index | Yes | Add it to indexes. |
| Relax a constraint (longer text, wider range) | With care | Older builds hold values they still reject — see Changing a column type. |
| Rename a table or column | No | Add the new one and read both. |
| Remove a table or column | No | Keep it declared; stop writing it. |
| Change a column's type | No | Add a new column with the new type. |
| Change what an existing field means | With care | Bump schemaVersion — and read what that costs first. |
#What happens on upgrade
On every boot the engine reconciles the local database with the schema you pass
it: it creates missing tables, adds missing columns (ALTER TABLE … ADD COLUMN),
and creates missing indexes. It never renames or drops anything, so a
database only ever accumulates columns. There is no migration step to write and
none to run.
#When an older app meets newer data
Every event a device pulls passes one admission check before it may touch a row
(engine/admission.ts, shared by the TypeScript, Swift, and Kotlin engines). An
event that decrypts and authenticates is the owner's own data, so the engine
discards it only when it is structurally broken — not a payload at all, or
stamped with an HLC that can't be ordered. Anything a later moment could resolve
is held instead:
| The event… | What an older app does |
|---|---|
declares a higher schemaVersion than this build |
Holds it unapplied. It never reads a newer meaning through older code. |
| targets a table this build doesn't have | Holds it until an upgrade adds the table. |
| has a value this build's column type rejects | Holds it, and records applyFailed in the sync log. |
| carries a column this build doesn't have | Applies the row and drops just that column. |
| is stamped more than five minutes in the future | Holds it until the wall clock catches up — see Sync & the relay. |
Held events live in the local _sync_deferred table and are retried every time
the app starts, so the first launch after an upgrade that adds support applies
them. Nothing is lost while an app is behind; it just doesn't see what it can't
understand yet.
#An older app editing a row with a newer column
This is safe. An edit's payload is the device's whole row — every column that
device has — and applying a payload only sets the columns it contains. An older
app's row has no dueDate, so its edits never mention one, and a newer device
keeps the dueDate it already had.
#Caveat: relay compaction
Both reference relays compact their logs on a schedule (every six hours by default), keeping only each row's newest event. If that newest event came from an app version that predates a column, any device that pulls the row afterwards — a fresh install, a restore, a device that was offline — gets it without that column's value. Devices that already had the value keep it, and their next edit of the row sends it again.
So during a rollout, a new column's value is only as durable as the newest writer of each row is up to date. If a column has to survive a restore, get users onto the new version before relying on it.
#New columns are NULL on old rows
SQLite has no value for a column on rows written before it existed, or on rows
written by an app version that predates it, so those rows read NULL. Queries
don't validate values on read: a column you typed NonEmptyString still comes
back NULL for those rows, and the type checker won't warn you.
Declare new columns so that's explicit:
const schema = {
todo: {
id: id("Todo"),
title: NonEmptyString,
dueDate: nullOr(NonEmptyString), // NULL until someone sets it
priority: withDefault(0)(FiniteNumber), // backfilled to 0 on upgrade
},
};With withDefault, the engine adds the column as NOT NULL DEFAULT …, backfills
existing NULLs once, and rows inserted by older devices get the default too.
withDefault is TypeScript-only today: the Swift and Kotlin engines add every new
column as plain nullable, so handle NULL there.
#Renaming and removing
Two facts make removal unsafe: the engine never drops a column, and an edit's
payload carries every column the device has on disk. A build that stops
declaring notes still has the last value it saw in SQLite and still sends it
with every edit — so an older app that still uses notes gets its edits
reverted. Keep a retired column declared, stop writing it, and say so:
todo: {
id: id("Todo"),
title: NonEmptyString,
body: nullOr(NonEmptyString),
/** @deprecated Replaced by `body`. Still declared so older apps aren't reverted. */
notes: nullOr(NonEmptyString),
},Declared, the old column keeps receiving older apps' edits, so the value this build sends back is current rather than stale.
A rename is a removal plus an addition, so treat it as one: add the new column, write to it, and read both. Newer code prefers the new column and falls back to the old one, which older apps keep writing:
const body = row.body ?? row.notes;#Changing a column type
A value that fails the receiving build's column type is held, not applied, and
recorded as applyFailed in the sync log (getSyncLog()). Change amount from
FiniteNumber to NonEmptyString, and every edit from an older app is held on
newer devices for good. Add a new column with the new type instead, and read both
as above.
Relaxing a constraint — a longer maxLength, a wider range — works in one
direction only. Newer builds accept everything older ones wrote, but an older
build holds any newly allowed value it still rejects until it upgrades. Test
both directions before shipping it.
#When to bump schemaVersion
Additive changes — tables, columns, indexes — stay on schemaVersion: 1. Bump it
only when an existing field's meaning changes incompatibly: a price moves
from cents to dollars, a timestamp from local time to UTC. Older apps then hold
those events instead of reading dollars as cents, and apply them once they
upgrade to a build that declares the new version.
schemaVersion is engine-wide, though. After a bump, older apps hold every
change from upgraded devices, not just the field whose meaning moved — the whole
fleet stops converging until everyone upgrades. Adding a new column with the new
meaning almost always beats a bump.