Schema Evolution

Versioned streams with declarative migration functions. When you change a data shape, old clients receive migrated data automatically.

Versioning a stream

Add version and migrate to your stream options:

export const todos = live.stream('todos', async (ctx) => {
  return db.todos.all();
}, {
  merge: 'crud',
  key: 'id',
  version: 3,
  migrate: {
    // v1 -> v2: add priority field
    1: (item) => ({ ...item, priority: item.priority ?? 'medium' }),
    // v2 -> v3: rename 'done' to 'completed'
    2: (item) => {
      const { done, ...rest } = item;
      return { ...rest, completed: done ?? false };
    }
  }
});

How it works

The Vite plugin includes the stream version in the client stub. On reconnect, the client sends its version. If the server is ahead, migration functions chain in order (v1 → v2 → v3). If versions match, no migration runs.

Each key in the migrate object is the version the data is migrating from. The function transforms a single item from that version to the next. Migrations compose sequentially - a client on v1 connecting to a v3 server runs both migration 1 and migration 2.

Options

OptionDefaultDescription
version-Current schema version number
migrate-Object mapping source version numbers to (item) => item migration functions

When to use schema evolution

Schema evolution is useful when you deploy server changes while clients may still be running older code. Instead of forcing a page reload, the server transparently migrates data to the shape the client expects.

Common scenarios:

  • Adding a new field with a default value
  • Renaming a field
  • Changing a field type (e.g. string → object)
  • Removing a deprecated field

Schema evolution works with all merge strategies. The migration function is applied per-item for array-based strategies (crud, latest, presence, cursor) and to the entire value for set.

Was this page helpful?