# Convex schema migrations (zero-downtime)

How to make a breaking change to the Convex schema — add a required field, change a type,
or **remove a column** — without downtime, given Glide's setup: a Convex backend plus a
**mobile client that ships as app builds that linger on devices**. Uses the
[`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component (installed;
runner in [`convex/migrations.ts`](../../convex/migrations.ts), registered in
[`convex/convex.config.ts`](../../convex/convex.config.ts)).

Related: [convex-sync.md](convex-sync.md) (the sync layer), [local-first.md](local-first.md).

## The one constraint that drives everything

**Convex won't deploy a schema that doesn't match the data at rest.** You can't add a
required field existing docs lack, change a type docs still use, or remove a field docs still
have. So every breaking change is the same shape:

> **widen** the schema → **migrate** the data → **narrow** the schema.

## The part that's specific to a mobile app: two schemas, not one

There's the **server** schema *and* the **client** — and the client ships as builds you
can't update atomically. Old builds keep talking to the server. That creates a **read/write
asymmetry** you must respect:

- **Making a field optional is backward-compatible for *writers*** (a client still sending it
  satisfies "optional") **but breaking for *readers*** that assume it's always present. A
  consumer written against "required" is not guaranteed to handle "absent."
- So: **widen = deploy the server first** (old clients tolerate a more-permissive schema, and
  the data hasn't changed yet). **Narrow = ship the client first, then deploy** (a restrictive
  change breaks any client still relying on the old shape — incl. an arg validator rejecting a
  field an old client still sends).

Concretely, two things move in lockstep with the server's widen/narrow:

1. The **table schema** (`convex/schema.ts`) **and** the **function arg validators**
   (e.g. `pushTasks` in `convex/sync.ts`). Both must be widened, and both narrowed.
2. The **generated `Doc<>` type flips to optional on widen** — so the client's read types
   (and ordering/logic) must already tolerate the field's absence *before* the widen lands.

## Canonical sequence (the by-the-book path)

For **removing** a field (the hardest case — it's read *and* written). `F` = the field.

| Step | What | Where | Why it's safe |
|---|---|---|---|
| **0. Forward-compat client** | Stop *depending* on `F` on read (tolerate it absent); **keep writing it** | client build | readers must tolerate absence *before* it can occur; still-writing keeps it valid against the un-widened server |
| **1. Widen** | `F` → `v.optional` in schema **and** every arg validator | `convex deploy` | more-permissive; old clients unaffected, data unchanged |
| **2. Stop-writing client** | Remove `F` from writes (and types) | client build | needs the widened arg validator to accept payloads without `F` |
| **3. Migrate** | Clear `F` from all docs (migrations component) | `convex run` | only "sticks" once no client rewrites `F` |
| **4. Narrow** | Remove `F` from schema **and** arg validators; delete the migration | `convex deploy` | no doc has `F`, no client sends `F` |

Steps 0 and 2 are **two separate client builds** in the general case — you can't collapse
them, because one build either writes `F` (so the data never clears) or doesn't (so it needs
the widened server and would strand a still-required reader).

For *adding* or *changing* a field, the shape is the same; see the patterns in
`.claude/skills/convex-migration-helper/references/migration-patterns.md` (dual-write /
dual-read strategies).

### Single-client / dogfood shortcut

When you control **all** clients and update them together (e.g. one sim via Metro reload, or a
single preview build), the "wait for old clients to drain" windows collapse: **Step 0 folds
into Step 2** (ship one forward-compatible, stop-writing client), so it's *widen → one client
→ migrate → narrow* (one build). The order still matters — deploy the widen before the
stop-writing client goes live. Only skip Step 0 when you're certain no old reader survives.

### Two deployments

Glide has **dev** (`superb-setter-633`, what the sim/Metro use) and **prod**
(`festive-hyena-328`, what preview/store builds use). Run every server step on **both**:
`bunx convex dev --once` for dev, `bunx convex deploy` for prod; migrations take `--prod`.

## Releasing the client to production-backed devices (OTA vs store)

On **dev**, "the client" is the sim and you update it instantly (Metro reload), so you can run
all phases freely. In **production the client is an installed app build you can't update
atomically** — which changes two things:

1. **A narrow (or any client-breaking server change) is gated on the prod client being updated
   AND adopted — not just on a server deploy.** With a live prod client you must release the
   forward-compat client, then the stop-writing client, and confirm adoption *before*
   narrowing. Narrow too early and live clients break — and because the app is **local-first,
   they break silently**: rejected `pushTasks` calls are swallowed, the UI keeps working off
   the local store, and sync just stops. (This bit us once: prod was narrowed while a
   sortAt-having preview client was live → its writes were silently failing. See Recovery.)

2. **Two release vehicles, very different speed:**
   - **EAS Update (OTA)** — ships the JS bundle + assets over-the-air, **no App Store review**.
     Covers *JS-only* changes, which is **most migration client work** (ordering, read/write
     shape, sync logic, Convex query/mutation calls — all JS). Fast adoption (downloads on
     launch). Gated by **runtime version**: an update only reaches builds with matching native
     code — the `fingerprint` runtime-version policy (configured here) ensures a bundle that
     needs native changes is never delivered OTA. **Configured**: `expo-updates` + per-profile
     channels (`development`/`preview`/`production`); publish JS with
     `eas update --branch <channel> --message "..."`. Takes effect from the next build onward
     (the binary must embed `expo-updates`; builds predating it can't receive OTA).
   - **Store / TestFlight build** — required for **native** changes (new native modules,
     `app.json`/config-plugin changes, SDK bumps) or when OTA isn't set up. Slow, gradual
     adoption; for a hard cutover gate on a minimum app version / forced update.

   The **server** side is always instant regardless — Convex functions deploy via
   `convex deploy` with no app-store involvement. Only *client* code has this question.

### Production sequence with a live client

widen prod → release forward-compat client → **confirm adoption** → release stop-writing
client → **confirm adoption** → migrate → narrow prod. Prefer OTA for the client releases
(the change is almost always JS-only); fall back to a store build only when a native change
rides along. Don't narrow until you're confident no un-updated client remains (check adoption)
— or you deliberately accept breaking stragglers.

### Recovery (narrowed too early)

A client built against the old shape has **silently failing writes** (local-first hides it),
and degraded read ordering. Ship a fixed (new-shape) client — **OTA if configured, otherwise a
new build installed *over* the existing app (do NOT delete it)** so the local store survives.
On launch the new client drains its still-dirty rows to the server: the failed writes flush
and **no data is lost** — sync was merely paused, not corrupted. Then prod can stay narrowed.

## The migrations component

Use it for the data step — it batches (Convex mutations have per-transaction limits),
paginates, tracks state, is **idempotent + resumable**, and supports **dry runs**.

```ts
// convex/convex.config.ts
import { defineApp } from 'convex/server';
import migrations from '@convex-dev/migrations/convex.config.js';
const app = defineApp();
app.use(migrations);
export default app;

// convex/migrations.ts
import { Migrations } from '@convex-dev/migrations';
import { components } from './_generated/api';
import type { DataModel } from './_generated/dataModel';
export const migrations = new Migrations<DataModel>(components.migrations);

export const myMigration = migrations.define({
  table: 'tasks',
  // return an object → applied as a patch; return undefined → no change.
  // patching a field to `undefined` removes it.
  migrateOne: (_ctx, doc) => (doc.someField !== undefined ? { someField: undefined } : undefined),
});
```

```bash
bunx convex run migrations:myMigration '{"dryRun": true}'   # one batch, rolled back
bunx convex run migrations:myMigration                       # run for real (dev)
bunx convex run migrations:myMigration --prod                # run on prod
bunx convex run --component migrations lib:getStatus --watch # monitor a long one
```

Delete the migration export once it's run on every deployment and confirmed (keep the
`migrations` runner instance + the component installed for the next one).

## Worked example: removing the `sortAt` column

`sortAt` was a per-task ordering field (with an over-engineered exposure-based re-sort);
ordering moved to `createdAt`/`completedAt`, so the column was removed via the full cycle.

```
Step 0  use-board.ts → order open/ready by createdAt, done by completedAt (still wrote sortAt).
        Metro reload; verified the new order against the UN-widened server.
Step 1  schema.ts + sync.ts: sortAt → v.optional(v.number()).
        bunx convex dev --once   (dev)   &&   bunx convex deploy   (prod)
Step 2  Removed sortAt from src/data (LocalTask, ServerTask, toWire, all writes).
        Metro reload; verified pushes (no sortAt) accepted against the widened server.
Step 3  bun add @convex-dev/migrations; added convex.config.ts + migrations.ts:clearSortAt.
        Snapshot backup: bunx convex export --prod --path <file>  (also lands in dashboard).
        bunx convex run migrations:clearSortAt '{"dryRun": true}'  → run → repeat with --prod.
        Verified: getTasks shows 0 rows carrying sortAt (dev + prod).
Step 4  Removed sortAt from schema.ts + sync.ts; deleted the clearSortAt migration.
        bunx convex dev --once   (dev)   &&   bunx convex deploy   (prod)
```

### Gotchas hit (so the next one is smoother)

- **Widening flips the generated `Doc.sortAt` to optional**, which breaks any client type that
  declared it required (`ServerTask`). That's the read-tolerance issue in type form — fix it
  in the forward-compat step (make it optional or drop it).
- **`pushTasks` uses `ctx.db.replace`** (full-document overwrite), so a stop-writing client
  drops the field organically on every task it touches — the migration just mops up the rest.
- **The migration references the field** (`doc.sortAt`); once you narrow the schema the field
  leaves `Doc<>`, so the migration stops type-checking — delete it as part of Step 4.
- **Back up before the migrate**: `convex export --prod` creates a downloadable zip *and* a
  durable dashboard snapshot (Settings → Snapshot Export).
- **Always dry-run first** (`'{"dryRun": true}'`) — one batch, rolled back.

## Checklist

- [ ] Step 0: forward-compatible client shipped + adopted (reads tolerate the change)
- [ ] Step 1: widen schema **and** arg validators → deploy dev + prod
- [ ] Step 2: stop-writing client shipped + adopted
- [ ] Step 3: define migration → **dry-run** → run on dev → run on prod → verify 0 remaining
- [ ] **Prod-backed clients:** new client released **and adopted** (OTA or store) before the narrow — a narrow breaks un-updated clients, and under local-first it fails *silently*
- [ ] Step 4: narrow schema + arg validators, delete the migration → deploy dev + prod
- [ ] Remove the migration export; keep the component installed for next time
