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), theSyncClientthat drives the queue over a transport,useCrdtText— the React binding for a collaborative text field — presence/awareness viausePresence, durable IndexedDB persistence, and thelocalFirsttable mixin. What remains is ONE thing — the two app-specific tagsuseCrdtTextis 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,useOutboxqueues 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 survivemergeCrdtStates 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 + persistenceDiscovery 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 auseMutationthat writes thecrdtText()column (the server folds it authoritatively).onRemoteState— receive merged state. Bound to the reactiveuseSubscriptionthat 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 viewEverything 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.
Two things stay yours to name, because nothing can derive them: which
mutation writes the column and which query streams the row. Voltro
generates no per-table CRUD surface, so the hook takes those two as push and
remote — the same shape as usePresence's injected channel. Everything under
them (client lifecycle, optimistic merge, offline queue, bounded retry, durable
persistence, the edit encoding) is framework code.
The descriptors on the other end declare the column as bytes-over-base64 —
Uint8Array in the handler, a base64 string on the wire:
import { Schema } from 'effect'
// documents.byId output (and documents.setBody input)
body: Schema.NullOr(Schema.Uint8ArrayFromBase64)What a crdtText() keystroke costs
A CRDT column on a hot editing path has two amplification effects to reason about, and the framework handles both — neither is left to your query shape:
- Downstream the wire carries the EDIT, not the document. A changed CRDT
cell diffs into an incremental
mergeCellssubscription op rather than a full-blob replace, so a one-character edit ships bytes proportional to the edit regardless of document size — a query that projectsbodyinto a list view does not stream the whole state per keystroke. - Server-side capture skips CRDT columns. The undo log (default-on outside
production) and
plugin-row-historystrip CRDT columns from the captured row, and an update touching ONLY CRDT columns is not captured at all, so per-keystroke mutations write no full-state blob into those tables.
What stays a real cost is the stored value itself: the blob in the row grows
with the document's edit history and soft-compacts past
crdt.compactMaxBytes — see the operational
rules below.
Presence & awareness
usePresence(roomId, self, { channel }) publishes this peer's ephemeral state
(cursor, name, selection) and returns everyone else's — the multiplayer cursors
of a collaborative editor. Presence is ephemeral and high-frequency, so it
rides a pub/sub channel, never Postgres CDC or a table.
import { usePresence } from '@voltro/local-first/react'
function Editor({ documentId, channel }) {
const { presence, others, setPresence } = usePresence(
documentId,
{ cursor: 0, name: 'Ada' },
{ channel },
)
// render `others` as remote cursors; update on selection change:
const onSelect = (cursor: number) => setPresence({ cursor, name: 'Ada' })
return <Cursors others={others} />
}The PresenceChannel is the same dumb string-payload pub/sub shape as the
framework broker (@voltro/plugin-broadcast), so a runtime binding forwards
straight onto the app's provisioned broker — in-memory locally,
Redis/NATS at scale (both already shipped). createInMemoryPresenceChannel() is
the local/test transport. Join/leave, announce-back discovery, cursor
propagation, and TTL expiry live in the pure createPresenceRoom the hook wraps.
Two different hooks share the name
usePresence, and they are not interchangeable. THIS one (@voltro/local-first/react) is peer-to-peer awareness over a pub/sub channel —usePresence(roomId, self, { channel })→{ presence, others, setPresence }— for high-frequency cursor/selection state that must never touch the database.@voltro/plugin-presence's is a server-backed roster —usePresence(channel, options)→ the list of members whose heartbeat is fresh, plus auseTypingindicator, through the app's own rpc. Reach for the plugin for "who is here"; reach for this one for "where is their cursor".
Binding it to the app's own presence lane — usePresenceChannel
createInMemoryPresenceChannel() is the local transport; in a running app the
binding is @voltro/plugin-presence's
usePresenceChannel(roomId, { selfKey }), which returns a PresenceChannel
backed by the plugin's existing heartbeat roster and rpc. Pass it straight in as
the channel above:
import { usePresence } from '@voltro/local-first/react'
import { usePresenceChannel } from '@voltro/plugin-presence/web'
function Editor({ documentId, userId }) {
const channel = usePresenceChannel(documentId, { selfKey: userId })
const { others, setPresence } = usePresence(documentId, { cursor: 0 }, { channel })
return <Cursors others={others} />
}That is deliberately ONE wire: awareness payloads ride the presence lane the app already runs rather than a second socket with its own lifecycle. The channel is room-scoped, and publishing into a room it was not created for THROWS — a silent cross-room delivery is the failure worth being loud about.
The offline sync queue
useSyncQueue() is a reactive view over a pure, tested reducer: writes made
offline are queued, and a transport drains them when connectivity returns. Use it
directly for fine-grained UI, or let the SyncClient
drive it for you.
import { useSyncQueue, useConnectionStatus } from '@voltro/local-first/react'
function SaveIndicator() {
const queue = useSyncQueue()
const { status } = useConnectionStatus(queue.outstanding)
// `outstanding` counts pending + in-flight (0 means synced).
return status === 'synced' ? null : <span>Saving… ({status})</span>
}Connection status
useConnectionStatus(outstanding) observes the connection lifecycle for the
local-first layer — network up/down plus reconnect confirmation — and folds the
number of unsynced writes into a display status of offline | syncing | synced, so synced means online and drained. It feeds the machine the two
signals a browser can observe (navigator.onLine + the online/offline
events); confirmed round-trips (confirm()/confirmFailed()) are left to the
caller, so the hook never invents a server ping.
This is the local-first connection machine, distinct from
@voltro/client's RPC-error-deriveduseConnectionStatus— a different package with a different signal source.
Durable persistence
Local CRDT state and the offline queue should survive a reload. Everything above
storage speaks the PersistenceAdapter contract, so the backing swaps freely:
createInMemoryPersistence()— ephemeral (lost on reload); the test/default.createIndexedDbPersistence()— durable, over the browser's own IndexedDB. No WASM, no added dependency; the IDB implementation is injectable, so it is tested against a fake backend that survives a reopen.
import { createIndexedDbPersistence } from '@voltro/local-first'
const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
const sync = createSyncClient({ transport, adapter }) // state now survives reloadRich text: crdtDoc() + useCrdtDoc + useCrdtEditor
crdtDoc() stores a WHOLE collaborative document (rich text, maps, arrays)
as a column — same storage and authoritative server merge as crdtText(),
which stays as the plain-text specialisation. The column definitions are
identical; what differs is which client binding you reach for.
Two hooks, and they are a pair. useCrdtDoc
(@voltro/local-first/react) owns the SYNC half — one SyncClient and one
live CRDT document per cell, the same wire useCrdtText rides.
useCrdtEditor (@voltro/local-first/editor, Tiptap, optional peers)
owns the EDITOR half and takes that document:
import { useCrdtDoc } from '@voltro/local-first/react'
import { useCrdtEditor } from '@voltro/local-first/editor'
import { EditorContent } from '@tiptap/react'
const Page = ({ id }: { id: string }) => {
const row = useSubscription<{ body: Uint8Array | null }>('app', 'documents.byId', { id })
const save = useMutation<{ id: string; update: Uint8Array }>('app', 'documents.setBody')
const shared = useCrdtDoc({
cell: { table: 'documents', id, column: 'body' },
remote: row.data?.body ?? null, // null = NOT LOADED
push: (w) => save.mutate({ id: w.id, update: w.update }),
})
// `doc` is null until the mount effect has run — render the editor in a
// CHILD so `useCrdtEditor` is never a conditional hook call.
return shared.doc === null ? null : <Surface doc={shared.doc} />
}
const Surface = ({ doc }: { doc: CrdtDocHandle }) => (
<EditorContent editor={useCrdtEditor({ doc })} />
)useCrdtDoc returns { doc, loaded, outstanding, synced, setOnline }. The
two names it asks for are the same two useCrdtText asks for and the same
two nothing can derive: the mutation that writes the column, and the
reactive query that streams the row.
Everything else is the hook's: local edits ride the app's mutation as
INCREMENTAL updates (folded server-side under a per-row mutex), remote edits
arrive as mergeCells subscription deltas — a one-character edit ships
under 1 KB in BOTH directions regardless of document size — plus the offline
queue, bounded retry, per-cell coalescence and durable persistence.
Two disciplines it enforces, because both fail silently when hand-rolled:
remote: nullmeans NOT LOADED, not empty. Folding an empty document over a loading row lets the first keystroke push a state that erases what was stored.loadedtells a UI which it is.- The echo guard. A
crdtDoc()document is mutated by the EDITOR, so local edits surface asdoc.onUpdatecallbacks — and folding a peer's state throughapplyStatefires the same callback. The hook pushes only whenlocalistrue. Without that every client re-broadcasts what it just received: one keystroke, one server write per open tab.
A page mounting an editor needs renderMode = 'spa'. The default is
'static', which pre-renders at build time, and the editor finds no
window there. 'spa' is skipped by the prerender and mounts on the
client; if the route has a layout, that layout still renders server-side
as an SSR shell.
This page said 'client', which is not one of the four render modes and
fails the build — see Render modes, which
names that exact value as invalid. The frontend-collab template copied
the sentence and was unbuildable for as long as it existed.
Carets ride a delivery: 'latest' EVENT, deliberately not presence
metadata: the roster's value-compare push would make every caret move a
"real" change. Pass an awareness transport plus user to
useCrdtEditor to mount them; attachAwarenessBridge publishes one
member's state per envelope (never the aggregated room). Stable positions
for inline comments come from encodeAnchor/resolveAnchor on the doc
handle.
The frontend-collab + api-collab template pair is this whole loop,
scaffoldable: voltro create-project collab --api=api-collab --web=frontend-collab.
Operational rules: the stored blob soft-compacts past crdt.compactMaxBytes
in app.config.ts (default 512 KiB, 0 disables; env override
VOLTRO_CRDT_COMPACT_MAX_BYTES) without breaking the merge
lineage; rebaseText is the explicit hard reset (a NEW EPOCH — subscribers
receive it as a fresh snapshot). CRDT columns are excluded from undo capture
and row history (document history = named snapshots taken BEFORE
compaction); .serverOnly() on a CRDT column is a declaration error and
.encrypted() makes it online-only.
The sync engine: query mirror + durable outbox
The engine's two halves ride the primitives you already use — there is no second data API:
- Reads. The subscription cache accepts a
mirror; every base movement of every subscribed query persists (rows + revision) into a subject+tenant partitioned store over IndexedDB, and a cold start seeds from it — the UI renders the last materialised rows offline through the SAMEuseSubscriptioncall, and the next connect presents the mirrored revision asvoltro-resume-from, so a reload inside the resume window continues with deltas instead of a snapshot. - Writes.
useOutboxwithpersistence: outboxPersistence(adapter)is the durable offline queue: writes survive a reload, replay in order on reconnect, stop at the first conflict, and a conflict resolves throughresolveConflict(id, resolveWithPolicy(policy, local, remote, { crdtColumns }))—crdtText()columns merge, scalars follow the declaredconflictPolicy(). One drain per device even with many tabs (withDrainLock, a per-partition Web Lock).
import {
createDurableKv, createQueryMirror, createSubscriptionMirrorBinding,
} from '@voltro/local-first'
import { localFirstTables } from './.framework/localFirst.generated'
const { kv, durability } = await createDurableKv() // 'memory' = visible degradation
const mirror = createQueryMirror(kv, { subjectId, tenantId }) // ONE partition per subject
const binding = createSubscriptionMirrorBinding(mirror, {
tags: { 'docs.list': 'docs' }, // the app's sync set
metadata: localFirstTables, // codegen: encrypted columns stripped
schemaFingerprint: BUILD_ID, // local-DB migration gate
})The deliberate design decision, recorded here because the obvious alternative
keeps being suggested: the engine is not a browser SQL database. The
client's whole query surface is (tag, input) — predicates are built and
evaluated on the server — so a wa-sqlite instance would evaluate a language
the client never sees. What offline needs is the last materialised answer per
query the user visited, kept current by deltas; that is what the mirror
stores. (The KvStore seam still admits a SQLite backing without touching a
consumer.)
Soundness rules, all enforced structurally and tested:
- Partition by key. Every stored key carries subject AND tenant; a
logout/login as somebody else can never read the predecessor's rows, and
purge()empties exactly one partition on revocation. Build ONE binding per resolved subject and rebuild it on an auth change — the same blank-on-auth doctrine the subscription cache itself follows. .encrypted()never lands. The server decrypts on read, so a naive mirror would persist plaintext on the device; the codegen-emittedlocalFirst.generated.tsnames those columns and the binding strips them before every save..serverOnly()columns never reach the wire at all.- The snapshot is the visible state. A save replaces the mirrored row set, so a row the server stopped sending (revoked share, RLS change, soft delete) is evicted by construction.
- Schema migration is a visible cold start. Entries persist under the
build's
schemaFingerprint; a new build's load misses them and the query falls back to loading → fresh snapshot — never a mixed-shape render. The offline queue deliberately does NOT gate on it: a queued old-shape write replays against the new server, whose input schema is the authority, and a rejection surfaces as a visible conflict instead of silently dropped work. - Shapes are your queries. There is no separate replication-shape
language: what is mirrored is exactly what the app subscribes to, so tenant
scoping, guards and
setRowFilterapply server-side, fail-closed, exactly as online — including parent-relative predicates ("tasks where projectId is one of my projects"), which are just queries. Mirroring is per QUERY, so a subgraph is N queries, not one nested shape. A storage budget is enforced withenforceBudget(maxBytes)— oldest-saved entries evict first.
Conflict policy for non-CRDT fields
CRDT fields resolve themselves — the merge is the resolver. A plain scalar
like title needs a policy. conflictPolicy() declares one per field; the
default everywhere is last-write-wins, and any field you do not name falls back
to it, so a policy never has to enumerate every column.
import { conflictPolicy } from '@voltro/local-first'
const policy = conflictPolicy({
title: 'lastWriteWins',
// A custom resolver MUST converge: both peers pick the same winner.
tags: (local, remote) => (remote.updatedAt >= local.updatedAt ? remote.value : local.value),
})
policy.resolveRecord(
{ title: { value: 'Draft', updatedAt: 1 } },
{ title: { value: 'Final', updatedAt: 2 } },
) // → { title: 'Final' }The one property that matters is convergence: lastWriteWins breaks an
exact updatedAt tie on a stable, symmetric key (writer id, then the value's
string form), so two peers agree regardless of which side each calls "local".
What's shipped vs. a runtime seam
The framework code for local-first is built and tested end to end. One thing remains, and it is not un-built framework — it is a pair of names only your app knows:
| Runtime seam | What it binds | Why it's a binding, not code |
|---|---|---|
| Two app-specific tags | Which mutation writes the crdtText() column, and which reactive query streams the row, in useCrdtText. |
Voltro generates no per-table CRUD surface, so there is nothing to derive them from. The client lifecycle, optimistic merge, offline queue, retry, persistence and edit encoding all ship. |
Two entries that used to sit in that table are gone, in opposite directions — worth stating, because "we have not built it" and "we decided against it" are different answers:
- The presence broker binding ships.
usePresenceChannel(@voltro/plugin-presence/web) is aPresenceChannelover the framework's own presence lane, so cross-replica fan-out is the broadcast plugin's and there is ONE presence wire rather than two. Nothing to bind by hand; see Presence & awareness. - A wa-sqlite / Turso adapter was rejected, not deferred. The client's whole
query surface is
(tag, input)— predicates are built and evaluated on the server — so a browser SQL engine would evaluate a language the client never sees. What offline needs is the last materialised answer per query, which is exactly what the query mirror stores. A SQLite backing beneathKvStoreremains possible without any consumer changing (React Native's adapter is exactly that) — that is a storage choice, not a missing engine.