Migrating local databases

9/20/2026
DatabasesLocal-firstPrismaDXOffline-first

An interesting problem in local-first app development is database migrations. IndexedDB doesn't enforce a row schema, but your application still does. If a new version of your app expects fields, indexes, or relationships that old local databases don't have, those databases still need to migrate.

This was the problem I faced when I was building Prisma-IDB, a local-first ORM for IndexedDB that uses your Prisma schema as the source of truth for your database structure. So here's the crux of the problem, and how I solved it with some guidance from the Prisma team.

Start with a simple schema. A user who installs this version stores records that match it:

// v1

model User {
  id    String @id @default(cuid())
  name  String
  email String @unique
}

Local record (v1)

id
"clx9k2f0a0001"
name
"Ada"
email
"ada@example.com"

Then you release a new version of your app with a new schema. Users who haven't updated yet still have v1-shaped records:

// v2

model User {
  id        String   @id @default(cuid())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())    // new field
}

Existing local record (still v1)

id
"clx9k2f0a0001"
name
"Ada"
email
"ada@example.com"
createdAt missing
not present

Then you release another version. Now some users have v1-shaped records and others have v2-shaped records:

// v3

model User {
  id          String   @id @default(cuid())
  name        String
  email       String   @unique
  createdAt   DateTime @default(now())
  displayName String?                       // new field
}

Existing local record (still v2)

id
"clx9k2f0a0001"
name
"Ada"
email
"ada@example.com"
createdAt
2026-09-20T10:00:00Z
displayName missing
not present

In every case, the app expects the new schema, but the local database still holds records written under an older one.

How do you migrate all users, who may each be on a different schema version, to the latest version without losing data or breaking the app? Local-first apps have no central database, so you can't run a single migration script on a server. Each user must migrate their own database independently.

Instead of one script that runs on a central database, think of migrations as a series of versioned transformations. Each transformation takes a local database from one schema version to the next, so every migration needs two pieces of information: the schema it starts from and the schema it ends at. Applying the transformations in order brings any database to the latest version.

Luckily, Prisma 8 moved to a contract-based approach, where the contract is the source of truth for the database structure. Each contract state has a stable hash. That hash is more useful than an arbitrary version number: it identifies the exact database structure a migration expects. Storing the current contract hash alongside the local database tells us which state the database is in, and therefore which migrations it needs to reach the latest contract.

I've described migrations as a sequence, but migration history doesn't have to be linear. Once every migration declares a from contract hash and a to contract hash, contract states become nodes and migrations become edges. The result is a migration graph:

A ──→ B ──→ D
│           ↑
└───→ C ────┘

To migrate a database at A to the latest contract D, the client finds any valid path through the graph and applies each transformation in order. Here, both A → B → D and A → C → D work.

Prisma-IDB bundles this graph with your app as a contract-space: a single file that contains:

  • The latest contract for the app schema
  • The migration packages the app supports. Each package contains:
    • Metadata
    • The from and to contract hashes
    • The ops that transform the database from the from state to the to state
  • A head ref: the to hash of the latest migration

The createAutoMigratingIdbClient helper uses the contract-space to run migrations automatically. From the docs:

createAutoMigratingIdbClient opens the database, applies any pending migrations, and resolves to a client.

"So what does this achieve?", I hear you asking. In local-first apps, users might connect to the internet a few days a week and use the app offline the rest of the time. Some users connect only after months. Each user's database is on the schema version they last updated to, so when they finally update, the app must handle all of these scenarios:

  • v9 → v10
  • v8 → v10
  • v5 → v10
  • Even v1 → v10

Users don't need to have installed every intermediate version of your app. Their local database only needs to identify the contract state it's in, and the bundled migration graph shows how to get from that state to the current head.

A traditional application migrates one centralized database. A local-first application has thousands of independently evolving databases. Treating schema states as nodes and migrations as edges makes that manageable.