Server-side clients
You can also use LiveStore on the server side e.g. via the @livestore/adapter-node adapter. This allows you to:
- have an up-to-date server-side SQLite database (read model)
- react to events / state changes on the server side (e.g. to send emails/push notifications)
- commit events on the server side (e.g. for sensitive/trusted operations)
Note about the schema: While the events schema needs to be shared across all clients, the state schema can be different for each client (e.g. to allow for a different SQLite table design on the server side).
Example
Section titled “Example”import { const makeAdapter: ({ sync, ...options }: NodeAdapterOptions & { sync?: SyncOptions;}) => Adapter
Creates a single-threaded LiveStore adapter for Node.js applications.
This adapter runs the leader thread (persistence and sync) in the same thread as
your application. Suitable for CLI tools, scripts, and applications where simplicity
is preferred over maximum performance.
For production servers or performance-critical applications, consider makeWorkerAdapter
which runs persistence/sync in a separate worker thread.
makeAdapter } from '@livestore/adapter-node'import { const makeWsSync: (options: WsSyncOptions) => SyncBackendConstructor<SyncMetadata>
Creates a sync backend that uses WebSocket to communicate with the sync backend.
makeWsSync } from '@livestore/sync-cf/client'
import { import schema
schema, import tables
tables } from './schema.ts'
const const adapter: Adapter
adapter = function makeAdapter({ sync, ...options }: NodeAdapterOptions & { sync?: SyncOptions;}): Adapter
Creates a single-threaded LiveStore adapter for Node.js applications.
This adapter runs the leader thread (persistence and sync) in the same thread as
your application. Suitable for CLI tools, scripts, and applications where simplicity
is preferred over maximum performance.
For production servers or performance-critical applications, consider makeWorkerAdapter
which runs persistence/sync in a separate worker thread.
makeAdapter({ NodeAdapterOptions.storage: { readonly type: ["in-memory"]; readonly importSnapshot?: any;} | { readonly type: ["fs"]; readonly baseDirectory?: string | undefined;}
storage: { type: string
type: 'fs', baseDirectory?: string | undefined
Where to store the database files
baseDirectory: 'tmp' }, sync?: SyncOptions
sync: { backend?: SyncBackendConstructor<any, JsonValue>
backend: function makeWsSync(options: WsSyncOptions): SyncBackendConstructor<SyncMetadata>
Creates a sync backend that uses WebSocket to communicate with the sync backend.
makeWsSync({ WsSyncOptions.url: string
URL of the sync backend
The protocol can either http/https or ws/wss
url: 'ws://localhost:8787' }), onSyncError?: "shutdown" | "ignore"
What to do if there is an error during sync.
Options:
shutdown will stop the sync processor and cause the app to crash.
ignore will log the error and let the app continue running acting as if it was offline.
onSyncError: 'shutdown' },})
const const store: Store<any, {}>
store = await createStorePromise<any, {}, Codec<Json, Json, never, never>>({ signal, otelOptions, ...options }: CreateStoreOptionsPromise<any, {}, Codec<Json, Json, never, never>>): Promise<Store<any, {}>>
Create a new LiveStore Store
createStorePromise({ CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.adapter: Adapter
Adapter used for data storage and synchronization.
adapter, CreateStoreOptions<any, {}, Codec<Json, Json, never, never>>.schema: any
The LiveStore schema defining tables, events, and materializers.
schema, CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.storeId: string
Unique identifier for the Store instance, stable for its lifetime.
- Valid characters: Only alphanumeric characters, underscores (
_), and hyphens (-)
are allowed. Must match /^[a-zA-Z0-9_-]+$/.
- Globally unique: Use globally unique IDs (e.g., nanoid) to prevent collisions across stores.
- Use namespaces: Prefix to avoid collisions and for easier identification when debugging
(e.g.,
app-root, workspace-abc123, issue-456)
storeId: 'test', CreateStoreOptions<any, {}, Codec<Json, Json, never, never>>.syncPayload?: Json
Payload that is sent to the sync backend when connecting
- Its TypeScript type is inferred from
syncPayloadSchema (i.e. typeof SyncPayload.Type).
- At runtime this value is encoded with
syncPayloadSchema and carried through the adapter
to the backend where it can be decoded with the same schema.
syncPayload: { authToken: string
authToken: 'insecure-token-change-me' },})
const const todos: unknown
todos = const store: Store<any, {}>
store.Store<any, {}>.query: <unknown>(query: Queryable<unknown> | { query: string; bindValues: Bindable; schema?: Decoder<unknown, never>;}, options?: { otelContext?: Context; debugRefreshReason?: RefreshReason;}) => unknown
Synchronously queries the database without creating a LiveQuery.
This is useful for queries that don't need to be reactive.
Example: Query builder
const completedTodos = store.query(tables.todo.where({ complete: true }))
Example: Raw SQL query
const completedTodos = store.query({ query: 'SELECT * FROM todo WHERE complete = 1', bindValues: {} })
query(import tables
tables.any
todos.any
where({ completed: boolean
completed: false }))
import { const makeAdapter: ({ sync, ...options }: NodeAdapterOptions & { sync?: SyncOptions;}) => Adapter
Creates a single-threaded LiveStore adapter for Node.js applications.
This adapter runs the leader thread (persistence and sync) in the same thread as
your application. Suitable for CLI tools, scripts, and applications where simplicity
is preferred over maximum performance.
For production servers or performance-critical applications, consider makeWorkerAdapter
which runs persistence/sync in a separate worker thread.
makeAdapter } from '@livestore/adapter-node'import { const createStorePromise: <TSchema extends LiveStoreSchema = LiveStoreSchema.Any, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>({ signal, otelOptions, ...options }: CreateStoreOptionsPromise<TSchema, TContext, TSyncPayloadSchema>) => Promise<Store<TSchema, TContext>>
Create a new LiveStore Store
createStorePromise } from '@livestore/livestore'import { const makeWsSync: (options: WsSyncOptions) => SyncBackendConstructor<SyncMetadata>
Creates a sync backend that uses WebSocket to communicate with the sync backend.
makeWsSync } from '@livestore/sync-cf/client'
import { const schema: FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>
schema, const tables: { todos: TableDef<SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, WithDefaults<...>, Struct<...>>;}
tables } from './schema.ts'
const const adapter: Adapter
adapter = function makeAdapter({ sync, ...options }: NodeAdapterOptions & { sync?: SyncOptions;}): Adapter
Creates a single-threaded LiveStore adapter for Node.js applications.
This adapter runs the leader thread (persistence and sync) in the same thread as
your application. Suitable for CLI tools, scripts, and applications where simplicity
is preferred over maximum performance.
For production servers or performance-critical applications, consider makeWorkerAdapter
which runs persistence/sync in a separate worker thread.
makeAdapter({ NodeAdapterOptions.storage: { readonly type: ["in-memory"]; readonly importSnapshot?: any;} | { readonly type: ["fs"]; readonly baseDirectory?: string | undefined;}
storage: { type: string
type: 'fs', baseDirectory?: string | undefined
Where to store the database files
baseDirectory: 'tmp' }, sync?: SyncOptions
sync: { backend?: SyncBackendConstructor<any, JsonValue>
backend: function makeWsSync(options: WsSyncOptions): SyncBackendConstructor<SyncMetadata>
Creates a sync backend that uses WebSocket to communicate with the sync backend.
makeWsSync({ WsSyncOptions.url: string
URL of the sync backend
The protocol can either http/https or ws/wss
url: 'ws://localhost:8787' }), onSyncError?: "shutdown" | "ignore"
What to do if there is an error during sync.
Options:
shutdown will stop the sync processor and cause the app to crash.
ignore will log the error and let the app continue running acting as if it was offline.
onSyncError: 'shutdown' },})
const const store: Store<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>, {}>
store = await createStorePromise<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>, {}, Codec<Json, Json, never, never>>({ signal, otelOptions, ...options }: CreateStoreOptionsPromise<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>, {}, Codec<Json, Json, never, never>>): Promise<Store<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>, {}>>
Create a new LiveStore Store
createStorePromise({ CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.adapter: Adapter
Adapter used for data storage and synchronization.
adapter, CreateStoreOptions<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState; }>, {}, Codec<Json, Json, never, never>>.schema: FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>
The LiveStore schema defining tables, events, and materializers.
schema, CreateStoreOptions<TSchema extends LiveStoreSchema, TContext = {}, TSyncPayloadSchema extends Codec<Json, Json> = Codec<Json, Json, never, never>>.storeId: string
Unique identifier for the Store instance, stable for its lifetime.
- Valid characters: Only alphanumeric characters, underscores (
_), and hyphens (-)
are allowed. Must match /^[a-zA-Z0-9_-]+$/.
- Globally unique: Use globally unique IDs (e.g., nanoid) to prevent collisions across stores.
- Use namespaces: Prefix to avoid collisions and for easier identification when debugging
(e.g.,
app-root, workspace-abc123, issue-456)
storeId: 'test', CreateStoreOptions<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState; }>, {}, Codec<Json, Json, never, never>>.syncPayload?: Json
Payload that is sent to the sync backend when connecting
- Its TypeScript type is inferred from
syncPayloadSchema (i.e. typeof SyncPayload.Type).
- At runtime this value is encoded with
syncPayloadSchema and carried through the adapter
to the backend where it can be decoded with the same schema.
syncPayload: { authToken: string
authToken: 'insecure-token-change-me' },})
const const todos: readonly Struct.ReadonlySide<{ readonly id: Codec<string, string, never, never>; readonly text: Codec<string, string, never, never>; readonly completed: Codec<boolean, number, never, never>;}, "Type">[]
todos = const store: Store<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>, {}>
store.Store<FromInputSchema.DeriveSchema<{ events: {}; state: InternalState; }>, {}>.query: <readonly Struct<Fields extends Struct.Fields>.ReadonlySide<{ readonly id: Codec<string, string, never, never>; readonly text: Codec<string, string, never, never>; readonly completed: Codec<boolean, number, never, never>;}, "Type">[]>(query: Queryable<readonly Struct.ReadonlySide<{ readonly id: Codec<string, string, never, never>; readonly text: Codec<string, string, never, never>; readonly completed: Codec<boolean, number, never, never>;}, "Type">[]> | { query: string; bindValues: Bindable; schema?: Decoder<...>;}, options?: { otelContext?: Context; debugRefreshReason?: RefreshReason;}) => readonly Struct.ReadonlySide<...>[]
Synchronously queries the database without creating a LiveQuery.
This is useful for queries that don't need to be reactive.
Example: Query builder
const completedTodos = store.query(tables.todo.where({ complete: true }))
Example: Raw SQL query
const completedTodos = store.query({ query: 'SELECT * FROM todo WHERE complete = 1', bindValues: {} })
query(const tables: { todos: TableDef<SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, WithDefaults<...>, Struct<...>>;}
tables.todos: TableDef<SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, WithDefaults<...>, Struct<...>>
todos.where: (params: Partial<{ readonly id: string | { op: Exclude<QueryBuilder<TResult, TTableDef extends TableDefBase<any, any>, TWithout extends QueryBuilder.ApiFeature = never>.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>; value: string; } | { op: QueryBuilder.WhereOps.MultiValue; value: readonly string[]; } | undefined; readonly text: string | { op: Exclude<QueryBuilder.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>; value: string; } | { ...; } | undefined; readonly completed: boolean | ... 2 more ... | undefined;}>) => QueryBuilder<...> (+3 overloads)
where({ completed?: boolean | { op: Exclude<QueryBuilder.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>; value: boolean;} | { op: QueryBuilder.WhereOps.MultiValue; value: readonly boolean[];} | undefined
completed: false }))
import { const makeSchema: <TInputSchema extends InputSchema>(inputSchema: TInputSchema) => FromInputSchema.DeriveSchema<TInputSchema>
makeSchema, import State
State } from '@livestore/livestore'
const const events: {}
events = {}
const const tables: { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Struct<...>>;}
tables = { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, State.SQLite.WithDefaults<...>, Struct<...>>
todos: import State
State.import SQLite
SQLite.function table<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}, Partial<...>>(args: { ...;} & Partial<...>): State.SQLite.TableDef<...> (+2 overloads)
Creates a SQLite table definition from columns or an Effect Schema.
This function supports two main ways to define a table:
- Using explicit column definitions
- Using an Effect Schema (either the
name property needs to be provided or the schema needs to have a title/identifier)
// Using explicit columnsconst usersTable = State.SQLite.table({ name: 'users', columns: { id: State.SQLite.text({ primaryKey: true }), name: State.SQLite.text({ nullable: false }), email: State.SQLite.text({ nullable: false }), age: State.SQLite.integer({ nullable: true }), },})
// Using Effect Schema with annotationsimport { Schema } from '@livestore/utils/effect'
const UserSchema = Schema.Struct({ id: Schema.Int.pipe(State.SQLite.withPrimaryKey).pipe(State.SQLite.withAutoIncrement), email: Schema.String.pipe(State.SQLite.withUnique), name: Schema.String, active: Schema.Boolean.pipe(State.SQLite.withDefault(true)), createdAt: Schema.optional(Schema.Date),})
// Option 1: With explicit nameconst usersTable = State.SQLite.table({ name: 'users', schema: UserSchema,})
// Option 2: With name from schema annotation (title or identifier)const AnnotatedUserSchema = UserSchema.annotate({ title: 'users' })const usersTable2 = State.SQLite.table({ schema: AnnotatedUserSchema,})
// Adding indexesconst PostSchema = Schema.Struct({ id: Schema.String.pipe(State.SQLite.withPrimaryKey), title: Schema.String, authorId: Schema.String, createdAt: Schema.Date,}).annotate({ identifier: 'posts' })
const postsTable = State.SQLite.table({ schema: PostSchema, indexes: [ { name: 'idx_posts_author', columns: ['authorId'] }, { name: 'idx_posts_created', columns: ['createdAt'], isUnique: false }, ],})
table({ name: "todos"
name: 'todos', columns: { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}
columns: { id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false;}
id: import State
State.import SQLite
SQLite.const text: <string, string, false, typeof NoDefault, true, false>(args: { schema?: Codec<string, string, never, never>; default?: typeof NoDefault; nullable?: false; primaryKey?: true; autoIncrement?: false;}) => { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false;} (+1 overload)
text({ primaryKey?: true
primaryKey: true }), text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false;}
text: import State
State.import SQLite
SQLite.const text: () => { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
text(), completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false;}
completed: import State
State.import SQLite
SQLite.const boolean: <boolean, false, false, false, false>(args: { default?: false; nullable?: false; primaryKey?: false; autoIncrement?: false;}) => { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
boolean({ default?: false
default: false }), }, }),}
const const state: InternalState
state = import State
State.import SQLite
SQLite.const makeState: <{ tables: { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Struct<...>>; }; materializers: {};}>(inputSchema: { tables: { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Struct<...>>; }; materializers: {};}) => InternalState
makeState({ tables: { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Struct<...>>;}
tables, materializers: {}
materializers: {} })
export const const schema: FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>
schema = makeSchema<{ events: {}; state: InternalState;}>(inputSchema: { events: {}; state: InternalState;}): FromInputSchema.DeriveSchema<{ events: {}; state: InternalState;}>
makeSchema({ events: {}
events, state: InternalState
state })
export { const tables: { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Struct<...>>;}export tables
tables }Further notes
Section titled “Further notes”Cloudflare Workers
Section titled “Cloudflare Workers”- The
@livestore/adapter-nodeadapter doesn’t yet work with Cloudflare Workers but you can follow this issue for a Cloudflare adapter to enable this use case. - Having a
@livestore/adapter-cf-workeradapter could enable serverless server-side client scenarios.