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, 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-backed 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), so a later swap to Loro 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 — survives 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/attempt counting, optimistic local merge, durable persistence — is in the client and tested against an in-memory dispatcher that mirrors the server's merge.

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), the encoded state, outstanding / synced, and setOnline.

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