@locel/client
@locel/client — createReplica, stores, and every method on a replica.
The local replica: durable storage, the outbox, rebase, and the broker that owns them. Runs in a browser, a worker, or Node.
Status: Built, with one seam. Everything on this page works, including the
broker, leadership and subsets. What locel does not do is construct the worker
for you: pass connect, because how a worker is built is a question about your
bundler and a guessed path is a file that is missing in production.
createReplica(options)
function createReplica<S extends Schema>(options: ReplicaOptions<S>): Replica<S>| Option | Type | Default | Description |
|---|---|---|---|
schema | Schema | — | From defineSchema. Required. |
store | Store | — | stores.opfs() or stores.memory(). Required. |
server | string | — | Base URL for the sync endpoints. Mutually exclusive with transport. |
transport | Transport | null | — | A custom transport, or null for a replica with no server. |
mode | 'partition' | 'subset' | 'partition' | See Partial sync. In the fingerprint key, so a different mode is a different store. |
scope | Scope | last resolved, else from the server | Partition values. Usually left unset — the server derives it from the session and returns it from hello, alongside the principal. Both are cached per app so an offline start opens the same store. Pass it when the application already knows it and must work on a first-ever offline start. |
app | string | 'locel' | Distinguishes two apps on one origin. In the fingerprint key. |
subsetTtl | number | 30_000 | Milliseconds a released subset stays live. subset mode only. |
rebuildOnMismatch | boolean | false | Rebuild instead of throwing on E_FINGERPRINT_MISMATCH. Development only. |
broker | 'shared' | 'dedicated' | 'none' | 'shared' | 'none' runs the store in the calling context — for Node and tests. |
connect | () => MessagePort | — | How to reach the broker. Required unless broker: 'none'. |
export const replica = createReplica({
schema,
server: '/sync',
store: stores.opfs(),
connect: () =>
new SharedWorker(new URL('./locel-broker.js', import.meta.url), { type: 'module' }).port,
})import { serveBroker } from '@locel/client/broker'
import { stores } from '@locel/client'
import schema from './schema.js'
serveBroker({ schema, store: stores.opfs() })There is deliberately no built-in worker URL and no fallback to opening the
store in the tab. A bundler has to be able to see the worker entry to emit it,
and a per-tab store would be two tabs racing for one exclusive handle — the
failure ADR 0009
exists to prevent, and one that would not look like a failure until a user
opened a second tab. Without connect, createReplica raises
E_BROKER_UNAVAILABLE where you can see it.
What a tab holds
The store is opened once, in the broker. A tab keeps its own copy of the
state in memory, folded from the same commits the broker wrote, with the same
function — which is what keeps get synchronous. A write applies in the tab
that made it in the same frame, and reaches the broker as the same outbox entry,
which is merged by id: the broadcast that comes back changes nothing.
Stores
import { stores } from '@locel/client'
stores.opfs(options?) // origin private filesystem
stores.memory() // in-memory; the handle can be reused across instances
// Both are real implementations here. On the server `stores.memory()` is a
// preset over SQLite — same name, different nature, because a browser has no
// SQL engine to preset over.opfs option | Type | Default | Description |
|---|---|---|---|
directory | string | 'locel' | /-separated path, created a segment at a time. |
A driver must commit rows, checkpoint, outbox and conflicts in one transaction. That is the contract, and there is no option that relaxes it.
stores.opfs() keeps them in an append-only log. A commit is one appended
record; a commit interrupted halfway is a record whose checksum does not match,
discarded when the log is read, leaving the state exactly as it was. That is why
E_STORE_TORN is unreachable rather than merely unlikely. Compaction writes a
fresh log whose first record is a whole snapshot and then removes the older
ones — OPFS has no atomic rename, so the newest log that parses wins.
That design rests on one assumption about the browser: that a positioned write
through createWritable appends rather than truncating, and that a closed
stream is what the next reader sees. If it were wrong, every commit after the
first would silently destroy the ones before it. So the store, a whole replica on
top of it, and a broker in a real SharedWorker all run in Chromium, Firefox and
WebKit — pnpm test:browser — as well as against an in-memory filesystem that
proves the framing, the checksums and the compaction.
Safari is not among them. The WebKit build those tests run against is not
Safari: it has no origin private filesystem where Safari does, and it has
SharedWorker where Safari does not. Where either is missing, locel refuses and
says which — unavailable from the store, E_BROKER_UNAVAILABLE from the
broker. It never falls back to a per-tab store or to memory, because a user
whose writes are not being saved should be told rather than shown a saved row.
Lifecycle
await replica.attach()
await replica.detach()
await replica.rebuild()
replica.statusinterface ReplicaStatus {
state: 'detached' | 'attached' | 'syncing' | 'idle' | 'offline' | 'refused'
pending: number // transactions the server has not answered for
oldest: Date | null // when the oldest of them was made
checkpoint: number
}| Method | Returns | Description |
|---|---|---|
attach() | Promise<void> | Verify the fingerprint, verify the store is not torn, replay the outbox, start syncing. Called implicitly on first use; call it explicitly to handle failure where you want it. |
detach() | Promise<void> | Release this tab. The broker survives while other tabs hold it. |
rebuild() | Promise<void> | Drop the store and re-sync, in one transaction, writing the new fingerprint in the same commit. Surviving outbox entries are replayed against the new schema and anything that no longer applies is reported. |
Throws E_FINGERPRINT_MISMATCH or E_STORE_TORN from attach(). Both are
recovered with rebuild().
Reading
A replica hands out rows and tells you which ones moved. It does not answer queries — no filtering, no ordering, no joins. That is TanStack DB's job, and this is the surface it is built on.
const todos = replica.collection(todosDefinition) // CollectionHandle<C>| Member | Returns | Description |
|---|---|---|
get(key) | Row<C> | undefined | Optimistic state — confirmed rows with the outbox replayed on top. What a user should see. |
confirmed(key) | Row<C> | undefined | Exactly what the server last said, ignoring pending local writes. What a "discard my changes" affordance needs. |
all() | Iterable<Row<C>> | Every row the replica holds for this collection, optimistic. |
count() | number | How many. Cheap; does not materialise rows. |
subscribe(fn) | () => void | Notifies on change. Returns its own unsubscribe. |
const off = todos.subscribe(({ keys }) => {
if (keys.size === 0) recomputeEverything()
else for (const key of keys) recompute(key)
})An empty keys set means "recompute", not "nothing changed". The replica
emits it when it cannot attribute a change to particular rows — a rebuild, a
restore from disk, a truncate. Treating it as a no-op is the subtlest way to get
a stale view, so it is a set rather than an optional field: you have to look at
it.
Rows are immutable snapshots. get(key) returns the same object identity until
that row actually changes, so an identity check is a valid "did this change?"
test and re-renders can be skipped on it.
Writing
A collection handle writes as well as reads. Each call is its own transaction;
inside replica.transaction, they join the open one.
todos.insert({ workspaceId, title: 'Ship it' })
todos.update('t_91', { done: true })
todos.delete('t_91')
const tx = replica.transaction(() => { /* collection writes */ }).default() runs here, on the client, so the row is complete and visible before
the server has heard about it. The base version a mutation carries is the
version of the row as it was last confirmed — what the user was looking at.
interface TransactionHandle {
readonly id: string
readonly durable: Promise<void>
readonly confirmed: Promise<void>
}durable resolves when the write reached storage. confirmed resolves when the
server accepted the transaction — including when it merged it — and rejects with
a LocelError when it refused. See Writing data.
A write applies to optimistic state in the same step that it is handed to
storage, so the UI never renders an intermediate ordering. If storage refuses
it, the row is taken back off the screen rather than left there looking saved,
and the replica stops accepting writes until resume().
An acknowledged transaction stays in the outbox until the checkpoint reaches the sequence number it was given. The server has agreed, but its version of the row has not arrived yet, and dropping the entry any earlier would flash the old value back onto the screen.
Scopes
await replica.switchScope(scope) // attach the store for a different partition scope
await replica.forget(scope) // delete that store, including unsent writes
replica.scope // ScopeSwitching detaches rather than destroys; unsent writes in the old scope resume
when you switch back. forget() is the only thing that deletes a scope's store
and is never automatic.
Syncing
await replica.sync() // flush the outbox and drain incoming changes now
await replica.whenIdle() // resolve when nothing is in flight and the outbox is empty or offlineSubsets
subset mode only. You will normally not call these — @locel/tanstack opens
and closes subsets from live queries.
await replica.openSubset(subset)
await replica.closeSubset(id)
replica.subsets // ReadonlyArray<OpenSubset>Subsets are owned by the broker and reference-counted across tabs. Closing
releases a reference; the subset is torn down after subsetTtl with no holders,
and only then are the rows it alone was holding dropped — as a forget, never
as a delete.
Membership is evaluated client-side with matchesSubset, the same function the
server runs. Two implementations of "does this row belong" would disagree
eventually, and the disagreement would be a row stranded in a view or missing
from one.
openSubset raises E_SUBSET_MODE_REQUIRED in partition mode, where the
client holds everything anyway.
Conflicts and rejections
replica.conflicts // ReadonlyArray<ConflictReport>
replica.acknowledge(conflictOrRejection) // clears it locally; sends nothing
replica.rejections // ReadonlyArray<Rejection>
const off = replica.onRejection((rejection) => { /* … */ })acknowledge() takes either a ConflictReport or a Rejection — both carry
what they need to identify themselves, so one method serves both and there is no
positional (collection, key) pair to get the wrong way round.
onRejection fires once, when the server answers. replica.rejections is the
durable record of the same thing: it is written in the transaction that rolls the
write back, survives a reload, and stays until acknowledged. Both exist because a
callback is the wrong place to put something a user has not read yet — the same
reason $conflict persists.
interface Rejection {
transaction: string
code: string
message: string
collection: string
key: string
mutations: Mutation[]
}Do not retry automatically. Every rejection code is a bug or a race and will fail again the same way.
Store failures
replica.onStoreFailure(({ reason, bytesHeld }) => { /* … */ })
await replica.resume()reason is 'quota' | 'unavailable' | 'corrupt'. On failure the replica stops
accepting writes and reports it. It does not drop outbox entries, prune
confirmed rows, or fall back to memory — see
Offline and durability.
Diagnostics
replica.writer // stable device installation identity
replica.fingerprint // { key: {…}, stamp: {…} } — see The replica
await replica.inspect() // full diagnostic snapshot
replica.onStatusChange((status) => { /* … */ })
replica.onLeadershipChange(({ isLeader, leadershipId }) => { /* … */ })inspect() returns fingerprint, attached tab count, leadership id, status,
checkpoint, row counts per collection, outbox depth and age, conflict count,
open subsets with reference counts, store driver and bytes held, and the torn
flag. Ship a way for users to send it to you.
Transports
import { transports } from '@locel/client'
transports.http({ url, headers?, fetch? }) // pull + push over HTTP, SSE for changesserver: '/sync' is shorthand for transports.http({ url: '/sync' }).
transport: null gives a replica with no server: writes are visible, durable and
permanently pending — the right harness for testing long offline sessions.
A custom transport implements hello, pull, push, subscribe and close,
and may implement identify(writer) — the replica calls it once, with the
writer it read out of the store, before anything else. See
the wire protocol.