Skip to Content
AdvancedSchema Migrations

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 fields

Removing 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 upgraded

Renaming 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 version

Versioning 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

Last updated on