loceldocumented
GuidesConcepts

The replica

The local store, the worker that owns it, how tabs share it, and what happens when its identity does not match.

This guide covers the machinery under every other page. You will learn:

  • Why one worker owns the store and tabs attach to it
  • What a fingerprint is and why a mismatch refuses rather than clears
  • How leadership is decided and what happens when it moves
  • What a torn store is, and why locel can detect one exactly
  • How to inspect and recover a replica in the field

Status: Built. The API below exists and is under test.

You can build a whole application without reading this. Read it when something has gone wrong, or before you ship to a user base that opens six tabs.

Overview

A local-first store in a browser has three problems that have nothing to do with sync, and every one of them has been solved wrongly at least once by a serious library.

Several tabs want the same file. Storage from a previous version of your app is still there. Something crashed halfway through a write.

locel's answers are, in order: one worker owns the store; identity is checked before anything is opened; and there is nothing to be halfway through, because every write is one transaction.

One owner

The replica lives in a worker. Tabs attach to it and talk to it; they do not open storage themselves.

This is not an optimisation. Two tabs each opening an exclusive file handle on the same origin private filesystem is a race with no correct outcome — the second one fails, and no amount of lock retrying changes that, because the lock and the file handle are different mechanisms that know nothing about each other. Systems that take per-tab handles and coordinate with Web Locks end up with a second tab that error-loops forever.

Since the worker owns the store, it owns everything that follows from the store:

  • One outbox, so a write made in one tab is durable for all of them.
  • One connection, so ten tabs are one subscriber rather than ten.
  • One subset registration per distinct query, shared and reference-counted across tabs.

None of this is configurable, and none of it appears in your code.

Leadership

One attached tab drives the connection at a time. The others read and write through the same replica; they simply are not the one holding the socket.

Leadership prefers a tab the user is actually looking at. A background tab that has been hidden for an hour is a poor choice to own a connection whose failures nobody will notice, so visibility and the time since last visible are both inputs.

When a leader goes away — closed, crashed, or backgrounded long enough — another is promoted and resumes from the first unanswered transaction. The resend is safe because every transaction carries a client-generated id the server applies at most once.

A promotion is identified separately from the tab that received it, because the same tab can be promoted repeatedly across failovers and a log that conflates the two is unreadable exactly when you need it.

replica.onLeadershipChange(({ isLeader, leadershipId }) => {
  console.log(isLeader ? `leading as ${leadershipId}` : 'following')
})

You should not need this outside diagnostics. Writes work identically from a follower.

Fingerprint

Before a replica opens a store, it checks that the store is its store.

replica.fingerprint
// {
//   key: {                                    // which store
//     app: 'todo-workspaces',
//     server: 'https://app.example.com/sync',
//     principal: 'p_4c1a…',
//     scope: { workspaceId: 'w1' },
//     mode: 'partition',
//   },
//   stamp: {                                  // what is inside it
//     schemaHash: 'sha256-9f3c…',
//     protocolVersion: 'locel-v1',
//     storageFormatVersion: 'locel-store-v1',
//   },
// }

The two halves behave differently, and the difference is the whole design.

The key selects a store. A value it has never seen is not an error — it is a different store, which starts empty and syncs. Signing in as somebody else, or switching workspace, lands on a different key and the previous store is left exactly as it was, unsent writes included.

The stamp describes what is inside the store it found, and a mismatch there refuses: E_FINGERPRINT_MISMATCH, at attach, before anything is read or written. A different schema hash means the columns are not the columns. A different protocol version means the framing changed. There is no version of those where the bytes still mean what they meant.

The writer is in neither half. It is minted when the store is created and read back out of it, so there is nothing to compare it against — derived state, not identity.

principal is an opaque identity for the signed-in subject, returned by hello and derived server-side the same way the scope is. It is in the key because two people using one browser must not share a store — and because if they did, they would share a writer, and "a writer's own earlier write is never a conflict" would quietly stop being true between two different humans.

This is the decision most worth defending, because the obvious alternative — clear what does not match and re-sync — is what almost everyone does, and it has a failure mode that is very hard to see. Clearing is never atomic across every piece of a store unless the store was designed for it, so you get a store whose rows were dropped and whose sync cursor was not. On the next start it resumes from a cursor that is past all the data it just deleted, receives nothing, because nothing has changed since, and reports itself ready. The app is blank, confident, and silent, and there is no error anywhere.

A refusal cannot half-happen. Recovery is then explicit:

try {
  await replica.attach()
} catch (error) {
  if (error.code === 'E_FINGERPRINT_MISMATCH') {
    await replica.rebuild()   // drops the store and re-syncs from the server
  }
}

rebuild() is one transaction: the rows, the checkpoint, the outbox and the conflicts go together, and the new fingerprint is written in the same commit. It replays any surviving outbox entries against the new schema afterwards and reports the ones that no longer apply, so a user who worked offline through a deploy is told what happened rather than quietly losing it.

In development, where the schema changes every few minutes, opt into doing that automatically:

createReplica({ schema, server: '/sync', store: stores.opfs(), rebuildOnMismatch: import.meta.env.DEV })

Leave it off in production. In production a mismatch means something you did not expect, and you want to see it.

Scopes

A partition scope is part of the fingerprint, so each scope is its own store.

await replica.switchScope({ workspaceId: 'w2' })   // attach a different store
await replica.forget({ workspaceId: 'w1' })  // drop it, including unsent writes

Switching detaches rather than destroys. Unsent writes in the old scope stay durable and resume when you switch back — the laptop that went offline mid-edit in one workspace and came back in another is a real Tuesday, and re-pulling on every switch would throw that work away.

forget() is the only thing that deletes a scope's store, and it is never automatic. Deleting a user's unsent work is not a decision an eviction policy should be making.

Signing out, and switching users

Because principal is part of the key, signing in as somebody else attaches a different store. You do not have to clear anything for correctness — one user cannot read another's rows, because they are not in the same store.

What you do have to decide is what happens to the store they left behind, and it is a product decision rather than a technical one:

// Personal device: keep it. Signing back in resumes exactly where they were,
// including writes that never reached the server.
await replica.detach()

// Shared device: flush first, then delete. Order matters.
await replica.sync()
await replica.whenIdle()
await replica.forget(replica.scope)

forget() before a flush throws away work the user believed was saved. If the device is offline at that moment there is nothing to flush to, and the honest options are to keep the store until it can be flushed or to tell the user what they are about to lose. locel will not choose for you, and it will never delete unsent writes on its own.

The writer is not a user identity. It is minted once per store and persisted in it — a client that forgot who it was would conflict with its own replayed outbox. So a shared device has one writer per person per workspace, which is what the conflict rules assume.

Knowing the scope before you can ask for it

There is an ordering problem here worth being explicit about, because the obvious design does not survive being offline.

The scope is derived server-side and returned by hello. But the scope is part of the fingerprint, and the fingerprint decides which store to open — so a replica that waited for hello before opening anything would be unable to read its own local data without a network. That is the failure this whole library exists to avoid.

So the replica keeps a small scope index per app, outside the scoped stores and holding nothing but the last scope each writer resolved. On start it opens that store immediately and syncs afterwards. If the next hello returns a different scope — the user's default workspace changed while they were away — that is a scope switch, not a mismatch: the old store is detached with its outbox intact and the new one is attached.

The one case with no answer is a first-ever start with no network and no explicit scope: there is nothing cached and nobody to ask. That raises E_UNKNOWN_IDENTITY, and the app should render its signed-out or first-run state. Pass scope explicitly at construction if your application already knows it — from the URL, say — and this case disappears.

Torn stores

A store is torn if its row versions and its checkpoint disagree: some $version exceeds the checkpoint, or some outbox base version does. Either means the store holds something it cannot possibly have received in order.

locel checks this on every attach, exactly:

max(row.$version) <= checkpoint  AND  every outbox baseVersion <= checkpoint

Exactly, rather than heuristically, because the row versions and the checkpoint come from the same value space — both are sequence numbers assigned by one server. Systems whose sync cursor is an opaque offset cannot do this; the best available check there is "zero rows but a non-initial cursor", which catches one case and misses the rest.

A torn store raises E_STORE_TORN at attach. It should never happen, and it will not happen from a crash — one transaction per write means there is nothing to be halfway through. If you see it, the store was modified by something other than locel, or the storage layer lied about a commit. Recovery is rebuild().

Inspecting one

await replica.inspect()
// {
//   fingerprint: { … },
//   attachedTabs: 3,
//   leadershipId: 'ld_7a1f…',
//   status: 'idle',
//   checkpoint: 4128,
//   rows: { todos: 812, categories: 14 },
//   outbox: { pending: 0, oldest: null },
//   conflicts: 2,
//   rejections: 1,
//   subsets: [{ id: 'issues:7', rows: 50, refs: 2, releasedAt: null }],
//   store: { driver: 'opfs', bytesHeld: 4_182_930 },
//   torn: false,
// }

Ship a way for users to send you this. Every field in it is a question you will otherwise be asking over email: how many rows do you have, is anything stuck in the outbox, how old is the oldest one, which subsets are open, is the store torn.

Stores

stores.opfs()      // origin private filesystem — the default for browsers
stores.memory()    // tests; accepts a handle you can carry across instances

A driver provides transactional key-range storage. The transaction boundary is the contract — a driver that cannot commit rows, checkpoint and outbox together cannot back a replica, and there is no configuration that relaxes this.

Lifecycle

const replica = createReplica({ schema, server: '/sync', store: stores.opfs() })

await replica.attach()   // fingerprint check, torn check, outbox replay
replica.status           // { state, pending, oldest, checkpoint }
await replica.sync()     // flush the outbox and drain changes now
await replica.whenIdle() // resolve when nothing is in flight
await replica.detach()   // release this tab; the broker lives on if others hold it

attach() is called for you on first use. Call it explicitly when you want the fingerprint failure at a point in your startup where you can handle it, rather than at the first render.

Where to go next

On this page