Local-first & CRDTs

@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath.

@voltro/local-first is the framework's foundation for offline-capable, multiplayer, convergent apps: edit while disconnected, see other peers' cursors, and reconcile without losing work when the network returns. The . entry is pure and browser-safe — no effect, no node:* — so a route or component imports it directly. The React wrappers live behind @voltro/local-first/react (React is an optional peer, so the pure path never pulls it in).

What ships today: the pure CRDT text merge, the offline sync-queue reducer, the connection-lifecycle state machine, the conflict policy, the crdtText() database column (with its authoritative server-side merge on the write path), the SyncClient that drives the queue over a transport, useCrdtText — the React binding for a collaborative text field — presence/awareness via usePresence, durable IndexedDB persistence, and the localFirst table mixin. What remains is ONE thing — the two app-specific tags useCrdtText is pointed at (runtime seam); the presence broker binding ships and a browser SQL engine was deliberately rejected. And the sync engine is now BUILT: the query mirror persists every subscribed query's rows per subject+tenant partition once you hand the api mount a mirror (apis.<name>.mirror in app.config.ts), useOutbox queues offline writes durably, and a reload renders mirrored rows offline and delta-resumes online.

CRDT text: crdtText + mergeCrdtStates

A CRDT (Conflict-free Replicated Data Type) text field can be edited by many peers offline and always converges to the same result. crdtText() builds one (Yjs by default, behind the CrdtBackend abstraction), and mergeCrdtStates() is the heart of the package — it converges two encoded states into one, deterministically and order-independently.

import { crdtText, mergeCrdtStates, decodeCrdtText } from '@voltro/local-first'

// A field, edited offline. `insert`/`delete` mutate and return the same handle.
const doc = crdtText('Hello').insert(5, ', world')
const state = doc.encode() // the wire/storage form: a CrdtState

// Converge two peers' encoded states — order-independent, no lost edits.
const remote = crdtText('Hello').insert(5, ' there').encode()
const merged = mergeCrdtStates(state, remote)
decodeCrdtText(merged) // the plain-string view; both edits survive

mergeCrdtStates is deterministic (decodeCrdtText(mergeCrdtStates(a, b)) equals the same for (b, a)), idempotent (re-merging a contained state is a no-op), and treats emptyCrdtState() as identity. The backend is a parameter on every function, defaulting to Yjs; Loro ships as a second backend (see Choosing a backend), and switching touches no call site.

The crdtText() database column

@voltro/database ships a crdtText() column type for CRDT-managed fields. It needs no special DDL — to the declarative differ it is an ordinary nullable bytes column (BYTEA / BLOB / LONGBLOB / VARBINARY), so it plans and round-trips on every dialect like any other:

import { table, id, text, crdtText } from '@voltro/database'

export const documents = table('documents', {
  id: id(),
  title: text(),
  body: crdtText(), // CRDT-managed field — stored as the encoded state (bytes)
})

The row type is Uint8Array | null (the encoded CRDT state); decode it to a string with decodeCrdtText(), and produce writes with a crdtText() handle's .encode(). The merge is authoritative and server-side: the runtime folds an incoming update into the stored state with mergeCrdtStates on the write path before writing, then the reactive engine broadcasts the merged result — which is what makes concurrent edits converge without a last-write-wins loser.

The localFirst table mixin

localFirst() marks a table as local-first — mirrored to the client, synced bi-directionally, and (for its crdtText() fields) converged via CRDT merge. It adds no column; it is a property the framework reflects on.

import { table, id, text, crdtText, localFirst } from '@voltro/database'

export const documents = table('documents', {
  id: id(),
  title: text(),
  body: crdtText(),
}).with(localFirst()) // opt this table into local-first sync + persistence

Discovery needs no codegen change — a marker mixin rides .with() like any column type. isLocalFirst(table) and localFirstTables(schema) are pure helpers, and the runtime's schema registry reflects it as hasLocalFirst(table) (beside crdtColumns(table)), which is the signal a client-sync-set builder reads. A local-first table may also carry plain columns — those sync last-write-wins via the conflict policy.

The SyncClient: bi-directional wire

createSyncClient({ transport }) maps the offline sync queue onto a transport: a local edit merges optimistically and queues; reconnect drains it to the server with retry; incoming merged state folds back via the CRDT — and concurrent edits converge. It invents no transport of its own — the SyncTransport is two functions an app binds to its existing wire:

  • push — deliver a queued CRDT write. Bound to a useMutation that writes the crdtText() column (the server folds it authoritatively).
  • onRemoteState — receive merged state. Bound to the reactive useSubscription that already streams the row.
import { createSyncClient } from '@voltro/local-first'

const sync = createSyncClient({
  transport: {
    kind: 'sync-transport',
    push: (write) => runMutation('documents.setBody', write.payload),
    onRemoteState: (handler) =>
      subscribeRow('documents', (row) =>
        handler({ table: 'documents', id: row.id, column: 'body', state: row.body }),
      ),
  },
  adapter: durablePersistence, // optional — unsent writes survive a reload
})

// A local edit: merges locally at once, queues, drains when online.
sync.enqueue({ table: 'documents', id: 'd1', column: 'body', update: doc.encode() })
sync.getText({ table: 'documents', id: 'd1', column: 'body' }) // the merged view

Everything below the two transport functions — the drain loop, retry with backoff, optimistic local merge, durable persistence — is in the client and tested against an in-memory dispatcher that mirrors the server's merge.

A failed write goes back to the head of the queue and is retried on the client's own clock: retryDelay(attempts) milliseconds later — by default defaultRetryDelay, exponential from 1 s and capped at 30 s, with jitter. A server that is away while the network is up (a deploy, an outage) fires no online event, so the retry cannot wait for one. setOnline(true) cuts a pending wait short and retries at once; a local edit made during the wait coalesces into the queued write without cutting it short, so typing through an outage does not turn every keystroke into a push attempt. maxAttempts (default: unbounded) stops scheduling retries for a write; it then stays queued for the next setOnline(true) or edit. flush() drains now, skipping a pending wait.

A collaborative text field: useCrdtText

useCrdtText is the React binding over that wire — one crdtText() cell, bound to the mutation that writes it and the reactive query that streams it:

import { useMutation, useSubscription } from '@voltro/client'
import { useCrdtText } from '@voltro/local-first/react'

function BodyEditor({ id }: { id: string }) {
  const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
  const save = useMutation<{ id: string; body: Uint8Array }>('app', 'documents.setBody')

  const body = useCrdtText({
    cell: { table: 'documents', id, column: 'body' },
    remote: row.data?.body ?? null,             // what the server currently holds
    push: (w) => save.mutate({ id: w.id, body: w.update }),  // deliver a local edit
  })

  return (
    <>
      <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
      {body.synced ? null : <em>saving… ({body.outstanding})</em>}
    </>
  )
}

The hook owns one SyncClient per (table, id, column) cell — created and closed with the component — re-renders on a local edit, an ack or incoming merged state, and folds the streamed row back in. It returns text, insert(index, text), delete(index, length), setText(next, base?), the encoded state, outstanding / synced, and setOnline.

The hook follows connectivity by itself: the browser's signal (navigator.onLine and the online/offline events) AND the api's own transport, so an edit typed offline — or while the server is down and the network is up, as during a deploy — waits and goes out when a write can land. No window listeners to wire. api: 'app' names the api push writes to; omitted, the hook follows any mounted api. An online option overrides both. A change of online is applied to the live client; it never rebuilds it, so nothing queued is lost. setOnline overrides by hand until the followed signal next changes.

Undo and refusals. undo() / redo() (with canUndo / canRedo) revert THIS user's own recent edits — typing within undoCaptureMs (default 500 ms) is one step — and never a peer's: the reverting change is an ordinary edit that syncs like typing. When the server refuses a write for good (see server rules), the hook does not retry it: the field falls back to the server's text and refused carries the reason (refused.error.message is a key such as crdt.updateTooLarge). useCrdtDoc exposes the same refused and rebuilds its document from the server's state.

**`setText` is a span diff, and that is the whole point.** A `