Alternatively, define tables using Effect Schema with annotations. This approach is useful when you already have Effect Schema definitions to reuse, or when you prefer composable schema types.
import { Schema } from 'effect'import { State } from '@livestore/livestore'const TodoSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey()), text: Schema.String, completed: Schema.Boolean.pipe(Schema.withConstructorDefault(() => false)),})export const todosTable = State.SQLite.tableFromSchema(TodoSchema)
Effect Schema types are mapped to SQLite automatically:
Schema type
SQLite type
TypeScript type
Schema.String
text
string
Schema.Number
real
number
Schema.Int
integer
number
Schema.Boolean
integer
boolean
Schema.Date
text
Date
Complex types (Struct, Array)
text (JSON)
decoded type
Schema.optional(T)
nullable column
T | undefined
Schema.NullOr(T)
nullable column
T | null
The Effect Schema-based approach will become the default once Effect Schema v4 is released.
Use ctx.query to read current state while handling an event:
import { defineMaterializer, Events, Schema, State } from '@livestore/livestore'import { todos } from './example.ts'const events = { todoCreated: Events.synced({ name: 'todoCreated', schema: Schema.Struct({ id: Schema.String, text: Schema.String }), }),} as constexport const materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }, ctx) => { const previousIds = ctx.query(todos.select('id')) // ctx.query also supports raw SQL const existingTodos = ctx.query({ query: 'SELECT id FROM todos', bindValues: {} }) return todos.insert({ id: `${existingTodos.length}-${id}`, text, completed: false, previousIds }) }),})
Every materializer runs inside a transaction. Both ctx.query reads and returned write operations are part of that transaction, giving you a consistent view.
Materializers must be side-effect free and deterministic. Since they can be re-run during replays or rebases, any non-determinism leads to diverging state across clients.
import { randomUUID } from 'node:crypto'import { defineMaterializer, Events, Schema, State } from '@livestore/livestore'const events = { todoCreated: Events.synced({ name: 'v1.TodoCreated', schema: Schema.Struct({ text: Schema.String }), }),} as const// BAD: randomUUID() produces different values on each replayconst materializers = State.SQLite.materializers(events, { [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ text }) => todos.insert({ id: randomUUID(), text }), ),})
When you change your table definitions, LiveStore can migrate the read model automatically.
Strategy
Description
auto
Automatically migrates the database to the new schema and rematerializes state from the event log
manual
You handle the migration yourself
Because state is derived from the event log, migrations that use auto are safe — the full history is replayed through the updated materializers to reconstruct consistent state.
Client documents are a convenience wrapper for local-only state with an API similar to React.useState(). They automatically create a table with id and value columns, along with a matching Set event and materializer.
When a client document’s schema changes in a non-backward-compatible way, previous events are dropped and state resets. Do not store data in client documents that must not be lost.