Skip to main content

Schema Versioning

When your sync document format evolves across app versions, you need a migration strategy so older documents are upgraded on pull.

Prerequisites: SyncManager, Integration Patterns

The Problem

App v1 syncs documents shaped like { tasks: [...], version: 1 }. App v2 renames tasks to items and adds a tags field. A user with v2 pulls a v1 document from the server — the app must migrate it.

Version Field

Include a _schemaVersion field in every sync document:

interface SyncDocument {
_schemaVersion: number
timestamp: string
items: Item[]
tags: Tag[]
}

const CURRENT_VERSION = 2

function createSyncDocument(): SyncDocument {
return {
_schemaVersion: CURRENT_VERSION,
timestamp: new Date().toISOString(),
items: getItems(),
tags: getTags(),
}
}

Migration on Pull

After pulling, check the version and apply migrations before using the data:

await sync.pull()
const data = sync.getData()

const migrated = migrateIfNeeded(data)
restoreFromSync(migrated)

Migration Function

const CURRENT_VERSION = 2

function migrateIfNeeded(data: Record<string, unknown>): Record<string, unknown> {
const version = (data._schemaVersion as number) ?? 1

if (version > CURRENT_VERSION) {
throw new Error(
`Document version ${version} is newer than app version ${CURRENT_VERSION}. Please update the app.`
)
}

let migrated = { ...data }

if (version < 2) {
migrated = migrateV1toV2(migrated)
}
// Add future migrations here:
// if (version < 3) migrated = migrateV2toV3(migrated)

return migrated
}

Migration Example: v1 to v2

function migrateV1toV2(data: Record<string, unknown>): Record<string, unknown> {
const migrated = { ...data }

// Rename field
if ("tasks" in migrated) {
migrated.items = migrated.tasks
delete migrated.tasks
}

// Add new field with default
if (!("tags" in migrated)) {
migrated.tags = []
}

// Rename values within arrays
const items = migrated.items as Array<Record<string, unknown>> ?? []
migrated.items = items.map((item) => ({
...item,
status: item.status === "todo" ? "pending" : item.status,
}))

migrated._schemaVersion = 2
return migrated
}

Migration Chain

Migrations run sequentially from the document's version to the current version:

v1 document → migrateV1toV2() → migrateV2toV3() → v3 document

Each migration is responsible for exactly one version step. This keeps migrations small and testable.

Forward Compatibility

When a newer app pushes a document that an older app doesn't understand:

  • Check version: if _schemaVersion > CURRENT_VERSION, show an "update required" message
  • Ignore unknown fields: don't crash on unrecognized keys — just pass them through
  • Use optional fields: new fields should have defaults so older versions can omit them

Interaction with Encryption

Migrations run after decryption. The SyncManager handles decryption transparently — by the time you call sync.getData(), you have the plaintext document ready for migration.

Server → encrypted blob → SyncManager.pull() → decrypted data → migrateIfNeeded() → app

Interaction with Conflict Resolution

If your conflict resolver references specific fields, ensure it handles both old and new schemas. Two approaches:

Migrate before merge

Run migrations on both local and remote data before conflict resolution:

const sync = new SyncManager({
client, pullPath, pushPath,
onConflict: (local, remote) => {
const migratedLocal = migrateIfNeeded(local)
const migratedRemote = migrateIfNeeded(remote)
return mergeDocuments(migratedLocal, migratedRemote)
},
})

Version-aware resolver

Check the version and adapt the merge strategy:

onConflict: (local, remote) => {
const localV = (local._schemaVersion as number) ?? 1
const remoteV = (remote._schemaVersion as number) ?? 1

// If versions differ, prefer the newer one
if (localV !== remoteV) {
return localV > remoteV ? local : remote
}

return mergeDocuments(local, remote)
}

Testing Migrations

Write migration tests with fixture data for each version:

test("v1 to v2: renames tasks to items", () => {
const v1 = {
_schemaVersion: 1,
tasks: [{ id: "1", title: "Test", status: "todo" }],
}
const v2 = migrateV1toV2(v1)

expect(v2._schemaVersion).toBe(2)
expect(v2.items).toHaveLength(1)
expect(v2.tasks).toBeUndefined()
expect((v2.items as any[])[0].status).toBe("pending")
})

Next Steps