Web adapter
Installation
Section titled “Installation”npm install @livestore/adapter-web @livestore/wa-sqliteExample
Section titled “Example”import { const makePersistedAdapter: (options: WebAdapterOptions) => Adapter
Creates a web adapter with persistent storage (currently only supports OPFS).
Requires both a web worker and a shared worker.
On browsers without SharedWorker support (e.g. Android Chrome), this adapter
automatically falls back to single-tab mode. In single-tab mode:
- Each tab runs independently with its own leader worker
- Multi-tab synchronization is not available
- Devtools are not supported
makePersistedAdapter } from '@livestore/adapter-web'
import const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker from './livestore.worker.ts?worker'
const const adapter: Adapter
adapter = function makePersistedAdapter(options: WebAdapterOptions): Adapter
Creates a web adapter with persistent storage (currently only supports OPFS).
Requires both a web worker and a shared worker.
On browsers without SharedWorker support (e.g. Android Chrome), this adapter
automatically falls back to single-tab mode. In single-tab mode:
- Each tab runs independently with its own leader worker
- Multi-tab synchronization is not available
- Devtools are not supported
makePersistedAdapter({ storage: { readonly type: "opfs"; readonly directory?: string | undefined;}
Specifies where to persist data for this adapter
storage: { type: "opfs"
type: 'opfs' }, worker: ((options: { name: string;}) => globalThis.Worker) | (new (options: { name: string;}) => globalThis.Worker)
worker: const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker, sharedWorker: ((options: { name: string;}) => globalThis.SharedWorker) | (new (options: { name: string;}) => globalThis.SharedWorker)
This is mostly an implementation detail and needed to be exposed into app code
due to a current Vite limitation (https://github.com/vitejs/vite/issues/8427).
In most cases this should look like:
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({ sharedWorker: LiveStoreSharedWorker, // ...})
sharedWorker: const LiveStoreSharedWorker: new (options?: { name?: string;}) => SharedWorker
LiveStoreSharedWorker,})
import { const makeWorker: (options: WorkerOptions) => void
makeWorker } from '@livestore/adapter-web/worker'
import { import schema
schema } from './schema/index.ts'
function makeWorker(options: WorkerOptions): void
makeWorker({ schema: LiveStoreSchema<DbSchema, EventDefRecord>
schema })import { const defineMaterializer: <TEventDef extends State.SQLite.EventDef.AnyWithoutFn>(_eventDef: TEventDef, materializer: State.SQLite.Materializer<TEventDef>) => State.SQLite.Materializer<TEventDef>
Type-safe wrapper for defining a single materializer.
Useful when defining materializers separately from the materializers() builder.
The first argument provides type inference for the second.
defineMaterializer, import Events
Events, const makeSchema: <TInputSchema extends InputSchema>(inputSchema: TInputSchema) => FromInputSchema.DeriveSchema<TInputSchema>
makeSchema, import Schema
Schema, import State
State } from '@livestore/livestore'
const const tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables = { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos: import State
State.import SQLite
SQLite.function table<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: 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: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}
columns: { id: { columnType: "text"; schema: 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?: Schema.Codec<string, string, never, never>; default?: typeof NoDefault; nullable?: false; primaryKey?: true; autoIncrement?: false;}) => { columnType: "text"; schema: 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: 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: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
text(), completed: { columnType: "integer"; schema: 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: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
boolean({ default?: false
default: false }), }, }),} as type const = { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
const
const const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events = { todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated: import Events
Events.synced<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>(args: { name: "v1.TodoCreated"; schema: Schema.Codec<Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">, never, never>;} & Omit<...>): State.SQLite.EventDef<...>export synced
Creates a synced event definition.
Synced events are sent to the sync backend and distributed to all connected
clients. Use this for collaborative data that should be shared across users
and devices.
Event names should be versioned (e.g., v1.TodoCreated) to support
schema evolution over time.
synced({ name: "v1.TodoCreated"
name: 'v1.TodoCreated', schema: Schema.Codec<Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">, never, never>
schema: import Schema
Schema.function Struct<{ readonly id: Schema.String; readonly text: Schema.String;}>(fields: { readonly id: Schema.String; readonly text: Schema.String;}): Schema.Struct<{ readonly id: Schema.String; readonly text: Schema.String;}>
Defines a struct schema from a map of field schemas.
Details
Each field value is a schema. Use
optionalKey
or
optional
to
mark fields as optional, and
mutableKey
to mark them as mutable.
The resulting schema's Type is a readonly object type with the fields'
decoded types. The Encoded form mirrors the field schemas' encoded types.
Example (Defining a basic struct)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, email: Schema.optionalKey(Schema.String)})
// { readonly name: string; readonly age: number; readonly email?: string }type Person = typeof Person.Type
const alice = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 })console.log(alice)// { name: 'Alice', age: 30 }
Struct({ id: Schema.String
id: import Schema
Schema.const String: Schema.String
Type-level representation of
String
.
Schema for string values. Validates that the input is typeof "string".
String, text: Schema.String
text: import Schema
Schema.const String: Schema.String
Type-level representation of
String
.
Schema for string values. Validates that the input is typeof "string".
String }), }),} as type const = { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
const
const const materializers: { "v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>>;}
materializers = import State
State.import SQLite
SQLite.const materializers: <{ readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}>(_eventDefRecord: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}, handlers: { ...;}) => { ...;}
Builder function for creating a type-safe materializer map.
This is the primary way to define materializers in LiveStore. It ensures:
- Every non-derived event has a corresponding materializer
- Materializer argument types match their event schemas
- Derived events are excluded from the required handlers
materializers(const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events, { [const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events.todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated.name: "v1.TodoCreated"
Unique identifier for this event type. Conventionally versioned (e.g., v1.TodoCreated).
name]: defineMaterializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>>(_eventDef: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>, materializer: State.SQLite.Materializer<...>): State.SQLite.Materializer<...>
Type-safe wrapper for defining a single materializer.
Useful when defining materializers separately from the materializers() builder.
The first argument provides type inference for the second.
defineMaterializer(const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events.todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated, ({ id: string
id, text: string
text }) => const tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables.todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos.insert: (values: { readonly text: string; readonly id: string; readonly completed?: boolean;}) => QueryBuilder<readonly Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.Codec<string, string, never, never>; readonly text: Schema.Codec<string, string, never, never>; readonly completed: Schema.Codec<boolean, number, never, never>;}, "Type">[], State.SQLite.TableDefBase<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { ...; }; readonly completed: { ...; };}>, State.SQLite.WithDefaults<...>>, "select" | ... 6 more ... | "row">
Insert a new row into the table.
insert({ id: string
id, text: string
text, completed?: boolean
completed: false }), ),})
const const state: InternalState
state = import State
State.import SQLite
SQLite.const makeState: <{ tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>; }; materializers: { ...; };}>(inputSchema: { tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>; }; materializers: { ...; };}) => InternalState
makeState({ tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables, materializers: { "v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>>;}
materializers })
export const const schema: FromInputSchema.DeriveSchema<{ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}>
schema = makeSchema<{ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}>(inputSchema: { events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}): FromInputSchema.DeriveSchema<...>
makeSchema({ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events, state: InternalState
state })Adding a sync backend
Section titled “Adding a sync backend”import { const makeWorker: (options: WorkerOptions) => void
makeWorker } from '@livestore/adapter-web/worker'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 } from './schema/index.ts'
function makeWorker(options: WorkerOptions): void
makeWorker({ schema: LiveStoreSchema<DbSchema, EventDefRecord>
schema, sync?: SyncOptions
sync: { backend?: SyncBackendConstructor<any, Json>
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' }) } })import { const defineMaterializer: <TEventDef extends State.SQLite.EventDef.AnyWithoutFn>(_eventDef: TEventDef, materializer: State.SQLite.Materializer<TEventDef>) => State.SQLite.Materializer<TEventDef>
Type-safe wrapper for defining a single materializer.
Useful when defining materializers separately from the materializers() builder.
The first argument provides type inference for the second.
defineMaterializer, import Events
Events, const makeSchema: <TInputSchema extends InputSchema>(inputSchema: TInputSchema) => FromInputSchema.DeriveSchema<TInputSchema>
makeSchema, import Schema
Schema, import State
State } from '@livestore/livestore'
const const tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables = { todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos: import State
State.import SQLite
SQLite.function table<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: 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: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}
columns: { id: { columnType: "text"; schema: 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?: Schema.Codec<string, string, never, never>; default?: typeof NoDefault; nullable?: false; primaryKey?: true; autoIncrement?: false;}) => { columnType: "text"; schema: 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: 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: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
text(), completed: { columnType: "integer"; schema: 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: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false;} (+1 overload)
boolean({ default?: false
default: false }), }, }),} as type const = { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
const
const const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events = { todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated: import Events
Events.synced<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>(args: { name: "v1.TodoCreated"; schema: Schema.Codec<Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">, never, never>;} & Omit<...>): State.SQLite.EventDef<...>export synced
Creates a synced event definition.
Synced events are sent to the sync backend and distributed to all connected
clients. Use this for collaborative data that should be shared across users
and devices.
Event names should be versioned (e.g., v1.TodoCreated) to support
schema evolution over time.
synced({ name: "v1.TodoCreated"
name: 'v1.TodoCreated', schema: Schema.Codec<Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">, never, never>
schema: import Schema
Schema.function Struct<{ readonly id: Schema.String; readonly text: Schema.String;}>(fields: { readonly id: Schema.String; readonly text: Schema.String;}): Schema.Struct<{ readonly id: Schema.String; readonly text: Schema.String;}>
Defines a struct schema from a map of field schemas.
Details
Each field value is a schema. Use
optionalKey
or
optional
to
mark fields as optional, and
mutableKey
to mark them as mutable.
The resulting schema's Type is a readonly object type with the fields'
decoded types. The Encoded form mirrors the field schemas' encoded types.
Example (Defining a basic struct)
import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Number, email: Schema.optionalKey(Schema.String)})
// { readonly name: string; readonly age: number; readonly email?: string }type Person = typeof Person.Type
const alice = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 })console.log(alice)// { name: 'Alice', age: 30 }
Struct({ id: Schema.String
id: import Schema
Schema.const String: Schema.String
Type-level representation of
String
.
Schema for string values. Validates that the input is typeof "string".
String, text: Schema.String
text: import Schema
Schema.const String: Schema.String
Type-level representation of
String
.
Schema for string values. Validates that the input is typeof "string".
String }), }),} as type const = { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
const
const const materializers: { "v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>>;}
materializers = import State
State.import SQLite
SQLite.const materializers: <{ readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}>(_eventDefRecord: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}, handlers: { ...;}) => { ...;}
Builder function for creating a type-safe materializer map.
This is the primary way to define materializers in LiveStore. It ensures:
- Every non-derived event has a corresponding materializer
- Materializer argument types match their event schemas
- Derived events are excluded from the required handlers
materializers(const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events, { [const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events.todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated.name: "v1.TodoCreated"
Unique identifier for this event type. Conventionally versioned (e.g., v1.TodoCreated).
name]: defineMaterializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>>(_eventDef: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>, materializer: State.SQLite.Materializer<...>): State.SQLite.Materializer<...>
Type-safe wrapper for defining a single materializer.
Useful when defining materializers separately from the materializers() builder.
The first argument provides type inference for the second.
defineMaterializer(const events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events.todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String;}, "Encoded">>
todoCreated, ({ id: string
id, text: string
text }) => const tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables.todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; };}>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>
todos.insert: (values: { readonly text: string; readonly id: string; readonly completed?: boolean;}) => QueryBuilder<readonly Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.Codec<string, string, never, never>; readonly text: Schema.Codec<string, string, never, never>; readonly completed: Schema.Codec<boolean, number, never, never>;}, "Type">[], State.SQLite.TableDefBase<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { ...; }; readonly completed: { ...; };}>, State.SQLite.WithDefaults<...>>, "select" | ... 6 more ... | "row">
Insert a new row into the table.
insert({ id: string
id, text: string
text, completed?: boolean
completed: false }), ),})
const const state: InternalState
state = import State
State.import SQLite
SQLite.const makeState: <{ tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>; }; materializers: { ...; };}>(inputSchema: { tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>; }; materializers: { ...; };}) => InternalState
makeState({ tables: { readonly todos: State.SQLite.TableDef<State.SQLite.SqliteTableDefForInput<"todos", { readonly id: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: true; autoIncrement: false; }; readonly text: { columnType: "text"; schema: Schema.Codec<string, string, never, never>; default: None<never>; nullable: false; primaryKey: false; autoIncrement: false; }; readonly completed: { columnType: "integer"; schema: Schema.Codec<boolean, number, never, never>; default: Some<false>; nullable: false; primaryKey: false; autoIncrement: false; }; }>, State.SQLite.WithDefaults<...>, Schema.Struct<...>>;}
tables, materializers: { "v1.TodoCreated": State.SQLite.Materializer<State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>>;}
materializers })
export const const schema: FromInputSchema.DeriveSchema<{ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}>
schema = makeSchema<{ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}>(inputSchema: { events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct<Fields extends Schema.Struct.Fields>.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>; }; state: InternalState;}): FromInputSchema.DeriveSchema<...>
makeSchema({ events: { readonly todoCreated: State.SQLite.EventDef<"v1.TodoCreated", Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Type">, Schema.Struct.ReadonlySide<{ readonly id: Schema.String; readonly text: Schema.String; }, "Encoded">>;}
events, state: InternalState
state })In-memory adapter
Section titled “In-memory adapter”You can also use the in-memory adapter which can be useful in certain scenarios (e.g. testing).
import { const makeInMemoryAdapter: (options?: InMemoryAdapterOptions) => Adapter
Creates a web-only in-memory LiveStore adapter.
This adapter runs entirely in memory with no persistence. Ideal for:
- Unit tests and integration tests
- Sandboxes and demos
- Ephemeral sessions where persistence isn't needed
Characteristics:
- Fast, zero I/O overhead
- Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
- Supports optional sync backends for real-time collaboration
- No data persists after page reload
For persistent storage, use makePersistedAdapter instead.
makeInMemoryAdapter } from '@livestore/adapter-web'const const adapter: Adapter
adapter = function makeInMemoryAdapter(options?: InMemoryAdapterOptions): Adapter
Creates a web-only in-memory LiveStore adapter.
This adapter runs entirely in memory with no persistence. Ideal for:
- Unit tests and integration tests
- Sandboxes and demos
- Ephemeral sessions where persistence isn't needed
Characteristics:
- Fast, zero I/O overhead
- Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
- Supports optional sync backends for real-time collaboration
- No data persists after page reload
For persistent storage, use makePersistedAdapter instead.
makeInMemoryAdapter()
Web worker
Section titled “Web worker”- Make sure your schema doesn’t depend on any code which needs to run in the main thread (e.g. avoid importing from files using React)
- Unfortunately this constraints you from co-locating your table definitions in component files.
- You might be able to still work around this by using the following import in your worker:
import '@livestore/adapter-web/worker-vite-dev-polyfill'
Why is there a dedicated web worker and a shared worker?
Section titled “Why is there a dedicated web worker and a shared worker?”- Shared worker:
- Needed to allow tabs to communicate with each other using a binary message channel.
- The shared worker mostly acts as a proxy to the dedicated web worker.
- Dedicated web worker (also called “leader worker” via leader election mechanism using web locks):
- Acts as the leader/single writer for the storage.
- Also handles connection to sync backend.
- Currently needed for synchronous OPFS API which isn’t supported in a shared worker. (Hopefully won’t be needed in the future anymore.)
Why not use a service worker?
Section titled “Why not use a service worker?”- While service workers seem similar to shared workers (i.e. only a single instance across all tabs), they serve different purposes and have different trade-offs.
- Service workers are meant to be used to intercept network requests and tend to “shut down” when there are no requests for some period of time making them unsuitable for our use case.
- Also note that service workers don’t support some needed APIs such as OPFS.
Storage
Section titled “Storage”LiveStore currently only support OPFS to locally persist its data. In the future we might add support for other storage types (e.g. IndexedDB).
During development (NODE_ENV !== 'production'), LiveStore automatically copies older state database files into archive/ inside the OPFS directory for the store (e.g. livestore-<storeId>@<version>/archive/). The three most recent copies are retained so you can inspect pre-migration data; older archives are pruned. In production, we delete outdated state databases immediately.
LiveStore also uses window.sessionStorage to retain the identity of a client session (e.g. tab/window) across reloads.
Private browsing mode
Section titled “Private browsing mode”In Safari and Firefox private browsing mode, OPFS is not available due to browser restrictions. When this happens, LiveStore automatically falls back to in-memory storage. This means:
- The app will continue to work normally during the session
- Data will not persist across page reloads or tab closures
- Sync functionality (if configured) will still work
You can detect when the store is running in-memory mode using store.storageMode:
if (store.storageMode === 'in-memory') { // Show a warning to the user showToast('Data will not be saved in private browsing mode')}The storageMode property returns:
'persisted': Data is being persisted to disk (e.g., via OPFS)'in-memory': Data is only stored in memory and will be lost on page refresh
You can also listen for boot status events including warnings using the onBootStatus callback in your store options:
const useAppStore = () => useStore({ storeId: 'app', schema, adapter, batchUpdates: ReactDOM.unstable_batchedUpdates, onBootStatus: (status) => { if (status.stage === 'warning') { console.warn(`Storage warning (${status.reason}): ${status.message}`) } },})Resetting local persistence
Section titled “Resetting local persistence”Resetting local persistence only clears data stored in the browser and does not affect any connected sync backend.
In case you want to reset the local persistence of a client, you can provide the resetPersistence option to the adapter.
import { const makePersistedAdapter: (options: WebAdapterOptions) => Adapter
Creates a web adapter with persistent storage (currently only supports OPFS).
Requires both a web worker and a shared worker.
On browsers without SharedWorker support (e.g. Android Chrome), this adapter
automatically falls back to single-tab mode. In single-tab mode:
- Each tab runs independently with its own leader worker
- Multi-tab synchronization is not available
- Devtools are not supported
makePersistedAdapter } from '@livestore/adapter-web'
import const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker from './livestore.worker.ts?worker'
const const resetPersistence: boolean
resetPersistence = import.
The type of import.meta.
If you need to declare that a given property exists on import.meta,
this type may be augmented via interface merging.
meta.ImportMeta.env: Bun.Env & NodeJS.ProcessEnv & ImportMetaEnv
The environment variables of the process
import.meta.env === process.env
env.ImportMetaEnv.DEV: boolean
DEV && new var URLSearchParams: new (init?: string[][] | Record<string, string> | string | URLSearchParams) => URLSearchParams
The URLSearchParams interface defines utility methods to work with the query string of a URL.
URLSearchParams(var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.location: Location
The read-only location property of the Window interface returns a Location object with information about the current location of the document.
The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.
location.Location.search: string
The search property of the Location interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the location's URL. If the URL does not have a search query, this property contains an empty string, "".
search).URLSearchParams.get(name: string): string | null (+1 overload)
The get() method of the URLSearchParams interface returns the first value associated to the given search parameter.
get('reset') !== null
if (const resetPersistence: boolean
resetPersistence === true) { const const searchParams: URLSearchParams
searchParams = new var URLSearchParams: new (init?: string[][] | Record<string, string> | string | URLSearchParams) => URLSearchParams
The URLSearchParams interface defines utility methods to work with the query string of a URL.
URLSearchParams(var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.location: Location
The read-only location property of the Window interface returns a Location object with information about the current location of the document.
The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.
location.Location.search: string
The search property of the Location interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the location's URL. If the URL does not have a search query, this property contains an empty string, "".
search) const searchParams: URLSearchParams
searchParams.URLSearchParams.delete(name: string, value?: string): void (+1 overload)
The delete() method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters.
delete('reset') var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.history: History
The Window.history read-only property returns a reference to the History object, which provides an interface for manipulating the browser session history (pages visited in the tab or frame that the current page is loaded in).
history.History.replaceState(data: any, unused: string, url?: string | URL | null): void
The replaceState() method of the History interface modifies the current history entry, replacing it with the state object and URL passed in the method parameters. This method is particularly useful when you want to update the state object or URL of the current history entry in response to some user action.
replaceState(null, '', `${var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.location: Location
The read-only location property of the Window interface returns a Location object with information about the current location of the document.
The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.
location.Location.pathname: string
The pathname property of the Location interface is a string containing the path of the URL for the location. If there is no path, pathname will be empty: otherwise, pathname contains an initial '/' followed by the path of the URL, not including the query string or fragment.
pathname}?${const searchParams: URLSearchParams
searchParams.URLSearchParams.toString(): string (+1 overload)
toString()}`)}
const const adapter: Adapter
adapter = function makePersistedAdapter(options: WebAdapterOptions): Adapter
Creates a web adapter with persistent storage (currently only supports OPFS).
Requires both a web worker and a shared worker.
On browsers without SharedWorker support (e.g. Android Chrome), this adapter
automatically falls back to single-tab mode. In single-tab mode:
- Each tab runs independently with its own leader worker
- Multi-tab synchronization is not available
- Devtools are not supported
makePersistedAdapter({ storage: { readonly type: "opfs"; readonly directory?: string | undefined;}
Specifies where to persist data for this adapter
storage: { type: "opfs"
type: 'opfs' }, worker: ((options: { name: string;}) => globalThis.Worker) | (new (options: { name: string;}) => globalThis.Worker)
worker: const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker, sharedWorker: ((options: { name: string;}) => globalThis.SharedWorker) | (new (options: { name: string;}) => globalThis.SharedWorker)
This is mostly an implementation detail and needed to be exposed into app code
due to a current Vite limitation (https://github.com/vitejs/vite/issues/8427).
In most cases this should look like:
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({ sharedWorker: LiveStoreSharedWorker, // ...})
sharedWorker: const LiveStoreSharedWorker: new (options?: { name?: string;}) => SharedWorker
LiveStoreSharedWorker, resetPersistence?: boolean
Warning: This will reset both the app and eventlog database.
This should only be used during development.
resetPersistence,})
If you want to reset persistence manually, you can:
- Clear site data in Chrome DevTools (Application tab > Storage > Clear site data)
- Use console command if the above doesn’t work due to a Chrome OPFS bug:
const opfsRoot = await navigator.storage.getDirectory();await opfsRoot.remove();Note: Only use this during development while the app is running.
Architecture diagram
Section titled “Architecture diagram”Assuming the web adapter in a multi-client, multi-tab browser application, a diagram looks like this:

Other notes
Section titled “Other notes”- The web adapter is using some browser APIs that might require a HTTPS connection (e.g.
navigator.locks). It’s recommended to even use HTTPS during local development (e.g. via Caddy).
Browser support
Section titled “Browser support”- Notable required browser APIs: OPFS,
navigator.locks, WASM - SharedWorker is used for multi-tab synchronization but is not strictly required
Android Chrome (Single-tab mode)
Section titled “Android Chrome (Single-tab mode)”Android Chrome does not support the SharedWorker API (Chromium bug #40290702). When running on Android Chrome, LiveStore automatically falls back to single-tab mode:
- Each browser tab runs independently with its own leader worker
- Data is still persisted to OPFS (same as full mode)
- Multi-tab synchronization is not available
- Devtools are not supported in single-tab mode
- A warning is logged to the console when this fallback occurs
You can also explicitly use single-tab mode if you don’t need multi-tab support:
import { const makeSingleTabAdapter: (options: SingleTabAdapterOptions) => Adapter
Creates a single-tab web adapter with OPFS persistence.
This adapter is a fallback for browsers without SharedWorker support (notably Android Chrome).
It provides the same persistence capabilities as makePersistedAdapter, but without multi-tab
synchronization. Each browser tab runs its own independent leader worker.
In most cases, you should use makePersistedAdapter instead, which automatically falls back
to this adapter when SharedWorker is unavailable.
Limitations:
- No multi-tab synchronization (each tab operates independently)
- No devtools support (requires SharedWorker)
- Opening multiple tabs with the same storeId may cause data conflicts
makeSingleTabAdapter } from '@livestore/adapter-web'import const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker from './livestore.worker.ts?worker'
// Use this only if you specifically need single-tab mode.// Prefer makePersistedAdapter which auto-detects SharedWorker support.const const adapter: Adapter
adapter = function makeSingleTabAdapter(options: SingleTabAdapterOptions): Adapter
Creates a single-tab web adapter with OPFS persistence.
This adapter is a fallback for browsers without SharedWorker support (notably Android Chrome).
It provides the same persistence capabilities as makePersistedAdapter, but without multi-tab
synchronization. Each browser tab runs its own independent leader worker.
In most cases, you should use makePersistedAdapter instead, which automatically falls back
to this adapter when SharedWorker is unavailable.
Limitations:
- No multi-tab synchronization (each tab operates independently)
- No devtools support (requires SharedWorker)
- Opening multiple tabs with the same storeId may cause data conflicts
makeSingleTabAdapter({ worker: ((options: { name: string;}) => globalThis.Worker) | (new (options: { name: string;}) => globalThis.Worker)
The dedicated web worker that runs the LiveStore leader thread.
worker: const LiveStoreWorker: new (options?: { name?: string;}) => Worker
LiveStoreWorker, storage: { readonly type: "opfs"; readonly directory?: string | undefined;}
Storage configuration. Currently only OPFS is supported.
storage: { type: "opfs"
type: 'opfs' },})
Best practices
Section titled “Best practices”- It’s recommended to develop in an incognito window to avoid issues with persistent storage (e.g. OPFS).
What’s the bundle size of the web adapter?
Section titled “What’s the bundle size of the web adapter?”LiveStore with the web adapter adds two parts to your application bundle:
- The LiveStore JavaScript bundle (~180KB gzipped)
- SQLite WASM (~300KB gzipped)