loceldocumented
Reference

@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>
OptionTypeDefaultDescription
schemaSchemaFrom defineSchema. Required.
storeStorestores.opfs() or stores.memory(). Required.
serverstringBase URL for the sync endpoints. Mutually exclusive with transport.
transportTransport | nullA 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.
scopeScopelast resolved, else from the serverPartition 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.
appstring'locel'Distinguishes two apps on one origin. In the fingerprint key.
subsetTtlnumber30_000Milliseconds a released subset stays live. subset mode only.
rebuildOnMismatchbooleanfalseRebuild 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() => MessagePortHow to reach the broker. Required unless broker: 'none'.
src/sync/replica.ts
export const replica = createReplica({
  schema,
  server: '/sync',
  store: stores.opfs(),
  connect: () =>
    new SharedWorker(new URL('./locel-broker.js', import.meta.url), { type: 'module' }).port,
})
src/sync/locel-broker.ts
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 optionTypeDefaultDescription
directorystring'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.status
interface 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
}
MethodReturnsDescription
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>
MemberReturnsDescription
get(key)Row<C> | undefinedOptimistic state — confirmed rows with the outbox replayed on top. What a user should see.
confirmed(key)Row<C> | undefinedExactly 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()numberHow many. Cheap; does not materialise rows.
subscribe(fn)() => voidNotifies 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          // Scope

Switching 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 offline

Subsets

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 changes

server: '/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.

On this page