Schema Migrations
Handle schema changes gracefully without forcing app updates.
The Problem
Traditional approach breaks old clients:
// v1 schema (deployed to users)
model Post {
id String
title String
content String
}
// v2 schema (you deploy)
model Post {
id String
title String
content String
subtitle String // NEW FIELD
category String // NEW FIELD
}
// Old clients receive new fields → crash! 💥
// Or new server expects fields old clients don't send → errors! 💥The Solution
Schema versioning with backward compatibility:
// Server declares version
const adapter = PrismaAdapter({
prisma,
schemaVersion: 2,
migrations: {
2: {
// What changed in v2
post: {
added: ['subtitle', 'category'],
removed: [],
renamed: {}
}
}
}
})
// Client declares version
const { store } = createSyncClient({
schemaVersion: 1 // Old client still on v1
})Result:
- ✅ Server auto-strips new fields for v1 clients
- ✅ Old clients continue working
- ✅ New clients (v2) receive all fields
- ✅ Gradual rollout possible
Migration Types
Adding Fields (Safe)
migrations: {
2: {
post: {
added: ['subtitle', 'category']
}
}
}
// Server behavior:
// - v1 clients: Strip subtitle, category from responses
// - v2 clients: Include all fields
// - Database: Has all fieldsRemoving Fields (Breaking)
migrations: {
2: {
post: {
removed: ['deprecated_field']
}
}
}
// Server behavior:
// - v1 clients: Include deprecated_field (from database or null)
// - v2 clients: Don't include deprecated_field
// - Database: Can drop column after all clients upgradedRenaming Fields
migrations: {
2: {
post: {
renamed: {
author_id: 'authorId' // old → new
}
}
}
}
// Server behavior:
// - v1 clients: Receive 'author_id'
// - v2 clients: Receive 'authorId'
// - Database: Uses 'authorId' (new name)Type Changes
migrations: {
2: {
post: {
transformed: {
tags: {
from: 'string', // CSV string
to: 'array', // String array
up: (val) => val.split(','),
down: (val) => val.join(',')
}
}
}
}
}
// Server transforms based on client versionVersioning Strategy
Server Schema Endpoint
// New endpoint (automatic)
GET /sync/v1/schema
{
"version": 2,
"models": {
"user": {
"fields": {
"id": "string",
"email": "string",
"name": "string"
},
"version": 2
},
"post": {
"fields": {
"id": "string",
"title": "string",
"subtitle": "string", // v2 only
"content": "string"
},
"version": 2
}
}
}Client Schema Validation
// On bootstrap, client checks compatibility
await store.init()
const serverSchema = await fetch('/sync/v1/schema').then(r => r.json())
if (serverSchema.version > store.schemaVersion) {
console.warn('Server has newer schema, some features may not work')
}
if (serverSchema.version < store.schemaVersion) {
console.error('Client schema too new! Update required.')
}Gradual Rollout
Phase 1: Deploy Server v2
// Server deployed with v2 schema
const adapter = PrismaAdapter({
schemaVersion: 2,
migrations: { 2: { ... } }
})
// Supports both v1 and v2 clients ✓Phase 2: Deploy Client v2
// Update client schema version
const { store } = createSyncClient({
schemaVersion: 2 // Now uses v2 fields
})
// Old clients (v1) still work ✓
// New clients (v2) get new features ✓Phase 3: Deprecate v1
// After 90% of users on v2
const adapter = PrismaAdapter({
schemaVersion: 2,
minClientVersion: 2 // Reject v1 clients
})
// v1 clients get error: "App update required"Breaking Changes
Handling Breaking Changes
migrations: {
3: {
post: {
// Completely new structure
breaking: true,
transform: {
up: (v2Record) => {
// Transform v2 → v3
return {
id: v2Record.id,
heading: v2Record.title, // Renamed
body: v2Record.content, // Renamed
meta: {
subtitle: v2Record.subtitle,
category: v2Record.category
}
}
},
down: (v3Record) => {
// Transform v3 → v2 (for old clients)
return {
id: v3Record.id,
title: v3Record.heading,
content: v3Record.body,
subtitle: v3Record.meta.subtitle,
category: v3Record.meta.category
}
}
}
}
}
}Migration Testing
// Test migrations
describe('Schema migrations', () => {
it('should handle v1 clients with v2 server', async () => {
const v1Client = createSyncClient({ schemaVersion: 1 })
const v2Server = createAdapter({ schemaVersion: 2 })
const rows = await v2Server.bootstrap('org-1')
await v1Client.applyRows(rows)
// v1 client shouldn't see v2-only fields
const post = v1Client.getModel('post', 'p1')
expect(post.subtitle).toBeUndefined() // v2 field
})
})Best Practices
DO ✅
- Version all schema changes
- Support N-1 versions (current + previous)
- Test with mixed client versions
- Provide update prompts for old clients
- Document breaking changes clearly
DON’T ❌
- Force immediate updates (gradual rollout)
- Break old clients without warning
- Skip version numbers (maintain sequence)
- Remove old migrations prematurely
Next Steps
- Code Generation - Generate migrations automatically
- Testing - Test schema compatibility
- Deployment - Rolling deployment strategies
Last updated on