loceldocumented

Getting started

Build a todo app whose writes survive a reload, a flaky connection, and a second device editing the same row.

This guide builds a small workspace todo app with an AdonisJS backend and a React frontend. You will learn how to:

  • Install and configure @locel/adonisjs
  • Declare a collection once and read it from both the server and the browser
  • Create a local replica in the browser and bind it to TanStack DB
  • Read and write rows, and see a write before the server has answered
  • Tell the difference between a write that is saved and one that is confirmed

Status: Built. Everything below exists and is under test. The one thing locel does not do for you is construct the broker's worker — see the replica.

Overview

A user opens your app on a train, ticks off three todos, and the tab reloads when the tunnel drops the connection. Where did the three writes go?

There are three honest answers, and most local-first stacks give you only the first two. The write was visible — it rendered. The write was durable — it reached local storage, so the reload found it. The write was confirmed — the server accepted it and told you what it became.

locel collapses the first two rather than tracking them separately: the row on screen is read from the local store, so if it rendered, it is saved. That leaves one question to ask per row — has the server answered? — and "it looked like it saved" stops being a state the system can be in.

Underneath, the model is small, and it rests on two words this guide will use throughout.

A sequence number is a counter on the server that only ever goes up. Every write that lands takes the next one — like the ticket dispenser at a deli counter. Ticket 7 definitely happened before ticket 8, for everyone, with no clocks involved and nothing to compare between devices. Every write stamps the rows and fields it touched with its ticket and with the identity of the device that wrote them. A client sends the version it was looking at; the server applies the change unless somebody else moved that same field since. There are no CRDTs, no vector clocks, and no wall-clock comparisons anywhere in the protocol.

A replica is the client's local database. Everything locel knows in the browser lives there — confirmed rows, their versions, the sync cursor, and the writes not yet sent — and all of it is written in one transaction or not at all, so there is no state where your rows were cleared but the cursor says you are up to date. One worker owns it and every tab shares it. You will create one in a moment.

Almost every guarantee below is one of these two comparing sequence numbers. "Has somebody moved this field since I looked?" is a comparison. "What have I not been sent yet?" is a comparison. "Is this store consistent?" is a comparison.

Installation

Install and configure the package in your AdonisJS application.

node ace add @locel/adonisjs

In the browser application, install the client and the TanStack DB binding.

pnpm add @locel/client @locel/tanstack @tanstack/db @tanstack/react-db

Declare a collection

A collection is declared once, in a file that imports nothing from a framework, because the server and the browser both read this exact file. If the two ever disagreed about what a todo is, every other guarantee in this guide would be worthless.

app/sync/schema.ts
import { defineCollection, defineSchema, field } from '@locel/core'

export const todos = defineCollection({
  name: 'todos',
  primaryKey: 'id',
  fields: {
    id: field.string().default(() => crypto.randomUUID()),
    workspaceId: field.string(),
    title: field.string(),
    note: field.string().nullable(),
    done: field.boolean().default(() => false),
    updatedAt: field.timestamp().serverOwned(),
  },
  writable: ['title', 'note', 'done'],
})

export default defineSchema({ todos })

Two of those lines are doing security work rather than schema work.

writable is an allowlist. A client may change title, note and done, and nothing else — a mutation touching any other field is refused whole, not silently trimmed. serverOwned() goes further: updatedAt is readable by clients and writable by none. The server stamps it on every accepted mutation.

Field names beginning with $ are rejected here. That prefix is reserved for magic columns, and a schema that could shadow one would make $pending mean two things.

Configure the server

config/locel.ts
import env from '#start/env'
import { defineConfig, stores, type InferLocelStores } from '@locel/adonisjs'
import schema from '#sync/schema'

const locelConfig = defineConfig({
  schema,

  default: env.get('LOCEL_STORE'),
  stores: {
    /**
     * Your application's database, through the connection Lucid already
     * has. Synced rows sit beside your own tables, in one pool and one
     * migration history.
     */
    lucid: stores.lucid({ connectionName: 'postgres' }),

    /**
     * A database locel owns. One file, one writer — which is what an
     * authority that assigns a monotonic sequence number wants anyway.
     */
    sqlite: stores.sqlite({ path: env.get('LOCEL_DB_PATH') }),

    /**
     * The same store in memory, for tests. Real SQL and real
     * transactions, so a test cannot pass something production fails.
     */
    memory: stores.memory(),
  },

  /**
   * The partition bounds what a session may read and write. It is derived
   * server-side, from the session — never sent by the client.
   */
  partition: (ctx) => ({ workspaceId: ctx.auth.getUserOrFail().currentWorkspaceId }),
})

export default locelConfig

declare module '@locel/adonisjs/types' {
  export interface LocelStoresList extends InferLocelStores<typeof locelConfig> {}
}

stores.sqlite() and stores.memory() need kysely installed — they are presets over the Kysely adapter rather than drivers of their own. stores.lucid() needs nothing beyond Lucid.

defineConfig returns its argument unchanged. Its only job is inference: the declare module block feeds the shape of your config back into locel's types, so locel.use('sqlite') autocompletes and locel.use('postgres') is a compile error.

The partition function is the single most important line in this file. It is the consistency boundary of the whole system — a client can only ever read, write, or even be told about rows inside its own partition, and a row's partition columns are immutable for the life of the row. Deriving it from ctx rather than accepting it from the request is what makes that a guarantee instead of a convention.

Create the replica in the browser

src/sync/replica.ts
import { createReplica, stores } from '@locel/client'
import schema from './schema.js'

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

That is the whole local database. stores.opfs() puts it in the origin private filesystem; stores.memory() is for tests.

One replica is shared by every tab on the origin. It runs in a worker, and tabs attach to it rather than each opening the file — two tabs racing for the same exclusive file handle is not a problem you can solve with retries, so locel does not create it. Which tab drives the connection is decided by leadership, and leadership prefers the tab the user is actually looking at. You do not configure any of this; it is described in The replica because you will eventually need to debug it.

The replica refuses to attach to a store whose contents it does not recognise, rather than clearing it. Store identity covers the signed-in subject, the workspace, the schema, the server origin and the storage format — so signing in as somebody else opens a different store, and a schema change during development ends in a legible error and a deliberate rebuild rather than an empty database that reports itself as up to date.

Bind it to TanStack DB

locel is the store and the sync engine. TanStack DB is the query engine — live queries, incremental recomputation, joins, framework bindings. Neither half reimplements the other.

src/sync/db.ts
import { createCollection } from '@tanstack/db'
import { locelCollectionOptions } from '@locel/tanstack'
import { replica } from './replica.js'
import { todos } from './schema.js'

export const todosCollection = createCollection(
  locelCollectionOptions({ replica, collection: todos })
)

Do not add TanStack DB's own persistence here. locel is already the durable store, and a second one would be a second opinion about what your user wrote. locelCollectionOptions configures the collection so this is the default.

Read

src/components/todo_list.tsx
import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'
import { todosCollection } from '../sync/db.js'

export function TodoList() {
  const { data: open } = useLiveQuery((q) =>
    q.from({ todo: todosCollection }).where(({ todo }) => eq(todo.done, false))
  )

  return (
    <ul>
      {open.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

This is an ordinary TanStack DB live query. The rows come from the local replica, so it answers before the network does — including on a cold start with no connection at all.

Write

todosCollection.insert({ workspaceId, title: 'Ship it' })

todosCollection.update(todo.id, (draft) => {
  draft.done = true
})

todosCollection.delete(todo.id)

A write is saved before the call resolves and sent to the server when there is a server to send it to. You do not check whether you are online first, and you do not await anything — that is the whole point. The returned handle is there for the cases where you want to wait for the server, and ignoring it is the normal case; see settle.

To batch several writes so they commit together on the server, wrap them:

replica.transaction(() => {
  todosCollection.update(a.id, (draft) => { draft.done = true })
  todosCollection.update(b.id, (draft) => { draft.done = true })
})

Either both land or neither does. The server assigns one sequence number to the whole batch.

Magic columns

Every row carries sync state on $-prefixed columns. They are ordinary columns as far as queries are concerned, which means every question you have about sync is a live query rather than an API you have to learn.

ColumnMeaning
$versionThe sequence number this row was last confirmed at
$writerWhich device installation last wrote it
$pendingThere are local writes the server has not answered for
$conflictThe server accepted the write but resolved a field against a concurrent one

So the "not yet saved" indicator every offline app needs is this:

const { data: unsent } = useLiveQuery((q) =>
  q.from({ todo: todosCollection }).where(({ todo }) => eq(todo.$pending, true))
)

And the account a reconnect owes the user after a long stretch offline is this:

const { data: resolved } = useLiveQuery((q) =>
  q.from({ todo: todosCollection }).where(({ todo }) => todo.$conflict != null)
)

$conflict deserves a note now and a whole guide later. A rejected transaction rolls back and disappears. A merged one is different: it landed, but somebody else had already moved one of the fields it touched, so that field kept their value and yours did not survive. The row is correct and the user was not told — unless you tell them. That is what $conflict is for.

Try it on two devices

Open the app in two browsers signed in to the same workspace.

  1. Edit a todo's title in one. It appears in the other, without a refresh.
  2. Turn off the network in one, tick three todos, and reload the tab. The three ticks are still there, and $pending is true on all three.
  3. Turn the network back on. They flush in order, $pending clears, and $version advances to whatever sequence number the server gave them.
  4. With one still offline, change the same todo's title in both. Bring the offline one back. The later writer's title loses, $conflict names the field, and the row is consistent everywhere.

Step 4 is the one worth sitting with. Nothing was merged character by character, nothing was decided by a clock, and the loser was told exactly what they lost.

Where to go next

  • Collections — field types, defaults, encryption, and why the schema is a plain file
  • Partitions — how the boundary is enforced, and what a partition escape is
  • Conflicts — resolution policies, and encoding real product decisions in them
  • Offline and durability — what survives a reload, a crash, and a full disk

On this page