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 once you hand the api mount amirror(apis.<name>.mirrorinapp.config.ts),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 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 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; 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 + 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 — 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 viewEverything 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.
An uncontrolled field (defaultValue, the DOM owns the text) can hand you a
keystroke typed on text another editor's edit has already replaced. Pass the
text the field SHOWED when the key was pressed as base:
setText(e.target.value, shown). The keystroke is then diffed against that
text and moved over the edit that arrived in between (rebaseEdit(base, next, current)), instead of being diffed against the current text — which would read
the other editor's characters as deleted. A controlled value={body.text}
field needs no base.
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, retry with backoff,
connectivity, 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 three amplification effects to reason about, and the framework handles all three — none is left to your query shape:
- Upstream the wire carries the EDIT, not the document. The sync queue
holds whole local states (they coalesce by merge, offline included), and a
push sends only their increment over the last state the server delivered —
so the upload follows the edit, and a document larger than
crdt.maxUpdateBytesstays editable. An increment over a state the cell no longer holds (a database restored from an older backup) is refused ascrdt.updateNotDecodable, and the client then sends its whole state. - 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. An increment only means something folded into the cell held before, and the framework folds it at every stage: importing@voltro/local-firstregisterscrdtMergeCellas the client cache's merger (registerCellMergerin@voltro/client— the cache finds it without a dependency); areplacefor a scalar that changed beside the cell keeps the held cell; and a slow reader whose deltas the server collapses into one gets a collapsed INCREMENT, never the last increment as the whole cell. Before, any of these could leave a reader holding only the newest increment — another editor's text missing until the next snapshot. - 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.
For the plain question "can a write reach the server right now?",
useApiOnline(name?) from @voltro/client answers it: the browser's signal
AND the api's transport, the same answer the CRDT hooks and useOutbox({ api })
drain on. A navigator that does not report onLine (React Native, Node)
counts as online — only an explicit false is offline.
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 }) // the queue now survives a reloadWhat survives is what was not sent. The client saves its queue on every
change; the next client started on the same adapter enqueues that queue once
more, so an edit made offline reaches the server after a reload — and a write
that had landed after all is folded in again by the server and changes
nothing (a CRDT merge is idempotent). The queue is ONE slot per adapter: give
each createSyncClient its own adapter (or databaseName).
useCrdtText and useCrdtDoc do not use that slot. Each keeps its cell's
unsent edits under a key of the cell's own, hands them over once when the next
hook for the cell mounts, and clears them when every write has landed — so
every field of a form can share one adapter. useCrdtDoc folds the handed-over
edits into the document it renders, so an edit made offline is on screen again
the moment the editor reopens — not only once the server echoes it back.
Unsent edits belong to whoever made them. On a device several people sign in
to, open one adapter per subject (databaseName carrying the subject id) so
one person's unsent edits are never pushed under the next person's session;
the outbox does the same through outboxPersistence(adapter, { partition }).
Rich 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 — on Yjs by default, on Loro with
binding: loroEditorBinding (see
Choosing a backend):
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, retry with backoff, connectivity, per-cell coalescence and durable
persistence. online behaves as in useCrdtText: followed, never a rebuild —
which here also keeps the document the editor is bound to.
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; past crdt.rebaseMaxBytes it is hard-compacted into a new epoch (see
server rules). The fold is lossless across replicas: the
write only lands if the stored state is still the one it merged into
(compare-and-swap on the stored bytes, on every dialect), otherwise it re-reads
and re-merges, and a fold that keeps losing to concurrent writers is refused —
the client's sync queue retries it — rather than written over another
replica's edit. 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.
Server rules: limits, validation, history and epochs
What a client may write
A CRDT update is the client's own bytes, so the server bounds it. Three gates
run on the write path, each refusing with a typed ValidationError whose
field is the column — it rides every mutation's wire union, so the client's
sync queue knows the refusal is final and stops retrying:
// app.config.ts (the api)
import { decodeCrdtText } from '@voltro/local-first'
export default {
type: 'api' as const,
crdt: {
maxUpdateBytes: 256 * 1024, // one incoming update
maxDocumentBytes: 8 * 1024 * 1024, // the stored document, after compaction
rebaseMaxBytes: 2 * 1024 * 1024, // hard-compact into a new epoch above this
validate: ({ table, column, state }) =>
table === 'notes' && column === 'body' && decodeCrdtText(state).length > 50_000
? 'notes.bodyTooLong'
: undefined,
},
}maxUpdateBytes(default 1 MiB) — one incoming update; refused ascrdt.updateTooLarge. Bytes that are not a CRDT update at all arecrdt.updateNotDecodable.maxDocumentBytes(default 16 MiB) — the merged document, after compaction;crdt.documentTooLarge.validate— your check of the merged document: return a message key to refuse, nothing to accept. It runs on the server for every write.
An oversized update inside a transaction is refused BEFORE commit, so the rest
of the mutation rolls back with it. Env overrides: VOLTRO_CRDT_MAX_UPDATE_BYTES,
VOLTRO_CRDT_MAX_DOCUMENT_BYTES, VOLTRO_CRDT_REBASE_MAX_BYTES.
Version history
_voltro_crdt_versions keeps a cell's history: named snapshots, the state
before every restore, and the lineage every hard compaction archived. Expose it
as ordinary procedures:
// mutations/notes.snapshot.mutation.server.ts
import { snapshotCrdt, listCrdtVersions, restoreCrdtVersion, type AppContext } from '@voltro/runtime'
export const snapshot = (input: { id: string; label: string }, ctx: AppContext) =>
snapshotCrdt(ctx.store, { table: 'notes', id: input.id, column: 'body', label: input.label })
export const versions = (input: { id: string }, ctx: AppContext) =>
listCrdtVersions(ctx.store, { table: 'notes', id: input.id, column: 'body' })
export const restore = (input: { versionId: string }, ctx: AppContext) =>
restoreCrdtVersion(ctx.store, { versionId: input.versionId })Each function reads the cell's row through the store it is given first, so the
caller's tenant scope and row filters decide whose history is visible; listing
never ships the document bytes (the state column is serverOnly()). Restoring
writes the version's content as an ORDINARY edit (CrdtBackend.restoreTo) —
text, rich text, maps and arrays — so it merges with edits made meanwhile,
syncs to every client like typing does, and first archives the state it
replaces, so a restore is undone by restoring that. crdtCellStats(store, cell)
reports size, epoch, author count, history length and the limits in force —
the numbers to alert on before a document hits them.
Hard compaction into a new epoch
Soft compaction keeps every struct of a document edited by many sessions, so a
hot document still grows. Past rebaseMaxBytes (default 4 MiB, 0 = never)
the write path rewrites it as a NEW EPOCH — same content, no history, no old
authors — after archiving the old lineage as a version (nothing is lost, it can
be restored). Three things keep that safe:
- Clients adopt the epoch. The subscription replaces the cell instead of sending an increment, and the CRDT hooks replace their local state instead of merging two lineages (which would show everything twice). A document being edited is rebuilt once its own writes are out.
- Old-lineage writes are carried over. A client that was offline across the rebase pushes edits made on the archived lineage. For text the server translates them — the changed span, moved over what changed meanwhile — and applies them once.
- What cannot be placed is refused. A structured change (rich-text nodes,
map entries) made on an archived lineage has no faithful place in the new
one: it is refused as
crdt.staleLineage, and the client'srefusedsays so instead of the document silently doubling.
Content migrations
A CRDT column stores the document, not a schema the database knows — rename a
Tiptap node type or split a map field, and every stored document still has the
old shape. crdt.migrations declares, per table and column, how a document
moves forward. Each document records the version it was migrated to:
// app.config.ts (the api)
import { renameEditorNodes } from '@voltro/local-first'
export default {
type: 'api' as const,
crdt: {
migrations: {
notes: {
body: [
// v1: the editor's `title` node became `heading`
{ version: 1, migrate: (doc) => { renameEditorNodes(doc, { from: 'title', to: 'heading' }) } },
],
},
},
},
}- On the next write. The write path runs a document's due migrations in version order inside the same compare-and-swap as the merge, so a document is migrated exactly once — however many writes or replicas race — and the result syncs to every client like any other edit.
- Or all at once.
migrateCrdtColumn(ctx.store, { table: 'notes', column: 'body' })(from@voltro/runtime) pages through the table (batchSize, default 200), migrates every document still behind, and returns{ scanned, migrated }. Run it from a one-off mutation or a schedule after the deploy. - A migration is an edit.
migratechanges the document through the backend's own API (doc.rawis theY.DocorLoroDoc), so clients still on the old content merge with it instead of being overwritten. The version survives a hard compaction.
renameEditorNodes (from @voltro/local-first — server-safe, no Tiptap)
renames every node of one type, keeping attributes and children, on both
backends. field is the editor's root: the Yjs XML fragment (default
"default", Tiptap's) or the Loro root map (default "doc").
- On Loro the node type is a map entry, so a rename is one write and the node keeps its identity — what an offline client typed inside it stays in it.
- On Yjs an element cannot change its name, so each node is replaced by a
copy, and the rename records which original became which copy. An update a
client made against the original — typed text, a deletion, formatting, a
changed attribute, a new child node — lands in the deleted original and is
then carried into the copy by the write path (
CrdtBackend.carryReplaced), at the position it had and around what changed meanwhile. A re-delivered update is recorded as carried and adds nothing; a node renamed twice is followed to its latest copy. The record lives in the document, so it counts toward its size: pastcrdt.rebaseMaxBytesthe hard compaction drops it with the rest of the history, keeping the content and its schema version. A structured edit made offline on the lineage before that compaction is then refused ascrdt.staleLineage, like any such edit (see the epochs above).
Counters: crdtCounter() + useCrdtCounter
A plain integer read, incremented and written back by two clients at once
keeps one of the increments. crdtCounter() keeps both: each writer
accumulates its own steps under its own key and the value is the sum, so
concurrent steps — offline ones too — commute. The server merges it like any
CRDT column, under the same limits and history:
import { useMutation } from '@voltro/client'
import { crdtCounter, id, table } from '@voltro/database'
import { useCrdtCounter } from '@voltro/local-first/react'
export const posts = table('posts', { id: id(), likes: crdtCounter() })
function LikeButton({ post }: { post: { id: string; likes: Uint8Array | null } }) {
const save = useMutation<{ id: string; likes: Uint8Array }>('app', 'posts.like')
const likes = useCrdtCounter({
cell: { table: 'posts', id: post.id, column: 'likes' },
remote: post.likes,
push: (w) => save.mutate({ id: w.id, likes: w.update }),
})
return <button onClick={() => likes.add(1)}>♥ {likes.value}</button>
}useCrdtCounter has the CRDT hooks' queue, retry, connectivity, durable
adapter and refused; read a stored value with crdtCounterValue(state).
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. Hand the api mount a
mirrorand 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.
useOutbox({ api: 'app', persistence })is the durable offline write lane: writes survive a reload, replay in order, stop at the first conflict, and PREVIEW on every query of their table until the server has them — see offline writes below. One drain per device even with many tabs (withDrainLock, a per-partition Web Lock).
The mirror is declared on the api in the web app's app.config.ts, beside
authHeaders — like it, a function, so the file is imported into the client
bundle and must stay browser-safe:
// app.config.ts (the web app)
import { createMirrorSource } from '@voltro/local-first'
import { localFirstTables } from '../api/.framework/localFirst.generated'
export const docsMirror = createMirrorSource({
metadata: localFirstTables, // codegen: which tables are localFirst(), which columns are encrypted
warm: [['docs.list', {}]], // mirrored even before any screen opens it
schemaFingerprint: 'v1', // local-DB migration gate
maxBytes: 20_000_000, // storage budget per partition
})
export default {
type: 'web' as const,
apis: {
app: { package: '@app/api', mirror: docsMirror },
},
}What is mirrored is derived. The runtime hands the source the api's
descriptors: every query whose source tables are ALL localFirst() tables
is mirrored, and a query that also reads a table that did not opt in is not —
its rows would land on the device for a table nobody declared local. Name the
set yourself with tags: { 'docs.list': 'docs' } when you want fewer.
deriveMirrorTags(descriptors, localFirstTables) returns the derived set, for
a log line or a test.
Whose rows — remembered on the device. partition defaults to the
remembered subject. After a sign-in or tenant switch, store who is signed in,
then reconnect; on sign-out, forget them (which purges their rows) first:
import { useReconnect } from '@voltro/web'
const reconnect = useReconnect()
const onSignedIn = async (subjectId: string, tenantId: string): Promise<void> => {
await docsMirror.remember({ subjectId, tenantId })
reconnect()
}
const onSignedOut = async (): Promise<void> => {
await docsMirror.forget()
reconnect()
}A cold start WITHOUT network then still knows whose rows to render, and a
device that nobody is signed in on mirrors nothing. Pass partition: () => …
instead when the app already keeps that answer somewhere it can read offline.
Unvisited queries can be offline. Offline coverage is otherwise "what the
user happened to open". warm: [[tag, input]] makes it a declaration: those
queries stay subscribed for the whole connection generation and are mirrored
before any screen asks. Only queries in the sync set are kept warm; a warm tag
the api does not have fails that one subscription, never the connection. warm: 'all' keeps EVERY query of the sync set warm whose
declared input accepts {} — lists, feeds, settings — and leaves out the
ones that need parameters.
When it is resolved. The runtime calls the source ONCE per connection
generation, before that generation's subscription cache exists, and the cache
keeps that mirror for its whole life. That timing is the partition rule: the
sign-in's reconnect() builds a new generation, and that call reads the NEXT
subject. A partition read lazily per save would let the outgoing cache's late
delta land under the incoming subject. A source that throws mirrors nothing —
the connection still comes up — and records a runtime.mirror client
diagnostic. Without kv, storage is createDurableKv() (IndexedDB, in-memory
where the host has none); React Native passes mirror on its
MobileApiBinding with a KvStore over the device's storage.
Offline writes: useOutbox({ api })
Bound to an api, the outbox is the framework's offline write lane — the write half of the engine, on the same mutations the app already declares:
import { useApiOnline, useOutbox } from '@voltro/client'
import { outboxPersistence } from '@voltro/local-first'
const online = useApiOnline('app')
const persistence = useMemo(
() => outboxPersistence(adapter, { partition: docsMirror.partitionKey }),
[adapter],
)
const outbox = useOutbox({ api: 'app', persistence })
outbox.enqueue('docs.create', { title: 'Written on the train' })
// online === false → queued, previewed in every docs list, sent when back- The send is the api's own mutation, with the entry id as its idempotency key: a write the server committed whose answer was lost is not applied twice when it is resent — by this page, or by the service worker draining the same queue over HTTP.
- Every queued write previews. The mutation's declared
targetsstage a STANDING optimistic patch: it applies to every query of the table — those open now, those opened later, and rows rendered from the mirror after a reload — until the write is sent (the server's echo then replaces the preview, no flash) or conflicts (reverted). - Retries run on the outbox's own clock. A transient failure is retried
after
retryDelay(attempts)(defaultdefaultOutboxRetryDelay: 1 s doubling to 30 s, jittered); coming back online retries at once. A server that is down while the network is up fires noonlineevent, so waiting for one alone would leave the queue stuck. - Connectivity is the api's transport, not just the browser:
useApiOnline('app')is false when the network is down OR the socket is. - The queue belongs to who is signed in.
outboxPersistence(adapter, { partition })stores each write under the subject that made it; a sign-in, sign-out or tenant switch drops the previous subject's queue from memory and loads the next one's. Writes queued before a sign-out wait for their own subject — they are never replayed under somebody else's identity.
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. Each connection generation is bound to the partition current when it was built, and an auth change rebuilds the generation — 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.
Local queries over the replica: useLocalQuery
The mirror and the cache hold rows; useLocalQuery(api, table, options)
queries them. It is the union of every row of table the api's cache holds —
from every single-table query, from the durable mirror after an offline
reload, and every queued write's preview — filtered, sorted and limited on the
device:
import { useLocalQuery } from '@voltro/client'
type Todo = { id: string; title: string; done: boolean; due: number }
// Every row of `todos` this device holds, filtered and sorted locally —
// instant, offline, no declared query, previews of queued writes included.
const overdue = useLocalQuery<Todo>('app', 'todos', {
where: (t) => !t.done && t.due < Date.now(),
orderBy: ['due', 'asc'],
limit: 20,
})It answers instantly and offline for ANY filter, without a declared query or a
round trip. What it sees is what the device holds: with the mirror's
warm: 'all' that is each local-first table as the server let this subject
read it — tenant scope and row filters already applied server-side, so a local
query can never reveal a row the subject could not read. An entry over several
tables holds joined shapes, not rows of one, and is left out. This is the
answer to a client-side SQL engine without one: predicates run over rows the
server already authorised, in plain TypeScript.
End-to-end encryption: crdtSealed()
A server that merges CRDT updates must read them. crdtSealed() is the column
for content the operator must not be able to read: every update is encrypted
on the client (AES-GCM over WebCrypto) with a key the server never sees, and
the server keeps an append-only log of the sealed frames — it checks framing
and size limits and nothing else. Clients decrypt and merge locally, so the
CRDT convergence still holds:
import { crdtSealed, id, table } from '@voltro/database'
import { generateSealKey } from '@voltro/local-first'
import { useCrdtText } from '@voltro/local-first/react'
export const minutes = table('minutes', { id: id(), body: crdtSealed() })
// The key is the app's: generate it, wrap it per member, share it through an
// invite — the server only ever stores ciphertext.
const key = await generateSealKey()
const body = useCrdtText({
cell: { table: 'minutes', id, column: 'body' },
remote: row.data?.body ?? null,
push: (w) => save.mutate({ id: w.id, body: w.update }),
seal: { key },
})seal: { key } works on useCrdtText, useCrdtDoc and useCrdtCounter.
Every frame is authenticated: a wrong key or a flipped bit fails to open
(onSealError). Appends from several replicas use the same compare-and-swap
as open folds.
The clients compact the log, because only they can read it — the two kinds of compaction the server performs on an open column:
- Soft, once the log outgrows
compactAboveBytes(default 256 KiB): a client caught up with the log writes a sealed snapshot of the document that replaces the frames it covers. The server keeps every frame appended meanwhile, and refuses the snapshot when the bytes it covers are no longer the ones it was computed from (two clients compacting at once), so compaction never loses an edit. - Hard, once the document itself outgrows
rebaseAboveBytes(default 4 MiB,0= never): the client rebases it into a NEW EPOCH — same content, no history, tombstones or old authors — and the snapshot replaces the whole log. The server archives the replaced log as arebaseversion and refuses the compaction if anything was appended meanwhile (the client computes it again from the newer log).sealHardCompaction(key, log, state)does the same by hand.
useCrdtDoc({ cell, remote, push, seal: { key, compactAboveBytes: 256 * 1024, rebaseAboveBytes: 4 * 1024 * 1024 } })The lineage is the one thing about a sealed document the server reads: each
frame names its epoch in cleartext — a random id, not content — and the server
appends only frames of the log's current epoch. A client that typed offline on
the archived lineage is refused, adopts the rebased log when it arrives, and
carries its edit over ITSELF (it holds the plaintext of both lineages): text
lands once, at the position it had; a structured change that has no faithful
place on the new lineage is refused as crdt.staleLineage, as on an open
column.
What the server cannot do, by construction: run crdt.validate on the
content — it never reads a word. Snapshots, stats (size, epoch, history) and
the version history work on ciphertext. The subscription ships the log, not an
increment.
Restoring a sealed version is split in two. restoreCrdtVersion refuses a
sealed cell with crdt.sealedRestoreIsClientSide, because the server cannot
compute the restore. Instead prepareSealedCrdtRestore archives the current log
as a restore version (so the restore stays undoable) and returns the
version's ciphertext; the client opens it and writes the restore as an ordinary
sealed edit:
// mutations/vault.prepareRestore.mutation.server.ts
import { prepareSealedCrdtRestore, type AppContext } from '@voltro/runtime'
export const prepareRestore = (input: { versionId: string }, ctx: AppContext) =>
prepareSealedCrdtRestore(ctx.store, { versionId: input.versionId })// in the component that holds the key
import { openSealedLog } from '@voltro/local-first'
const prepared = await prepareRestore.mutate({ versionId })
if (prepared !== undefined) note.restore(await openSealedLog(key, prepared.versionLog))restore(target) is on useCrdtText and useCrdtDoc and takes a plaintext
state: it merges with what peers write meanwhile and syncs, sealed, like typing.
An open column's history bytes never leave the server —
prepareSealedCrdtRestore refuses one with crdt.openRestoreIsServerSide. Edits not yet
sent are kept in the device's storage SEALED too (with a durable adapter),
so neither the server nor the device's storage holds plaintext — keep the key
itself out of that storage.
Choosing a backend: Yjs or Loro
Everything above speaks the CrdtBackend contract, not Yjs. Two backends
ship: Yjs (the default) and Loro, behind
@voltro/local-first/loro (loro-crdt is an optional peer dependency —
install it to use it). Select Loro on BOTH sides:
// apps/api/app.config.ts — the server merge, history, epochs and inspection
import { loroBackend } from '@voltro/local-first/loro'
export default { type: 'api' as const, crdt: { backend: loroBackend } }// the web or mobile entry, before anything renders
import { setDefaultCrdtBackend } from '@voltro/local-first'
import { loroBackend } from '@voltro/local-first/loro'
setDefaultCrdtBackend(loroBackend)The two backends' bytes do not mix: a Yjs update sent to a Loro server is
refused as crdt.updateNotDecodable and never stored, so pick one before the
column holds documents. A Loro change depends on everything its writer had
seen; the increments a client sends build on the state the server delivered,
so the server holds their base, and one that lacks it is refused rather than
merged without it. On Loro, useCrdtText, useCrdtDoc with your own
document code, useCrdtCounter, sealed columns, history, epochs and content
migrations all work unchanged.
The rich-text editor on Loro. useCrdtEditor takes a binding: the Yjs
one (Tiptap's Collaboration extension) is the default, and the Loro one comes
from @voltro/local-first/loro-editor (install loro-prosemirror):
import { useCrdtEditor } from '@voltro/local-first/editor'
import { loroEditorBinding } from '@voltro/local-first/loro-editor'
const editor = useCrdtEditor({ doc, binding: loroEditorBinding, awareness, user })It syncs the editor through loro-prosemirror, carets ride the same
awareness transport as on Yjs, and undo is Loro's — on both backends
editor.commands.undo() (and Mod-z) reverts this editor's own change and
never a peer's. One thing is added on top of loro-prosemirror: an empty
document gets the schema's empty skeleton as a change every client writes
byte-identically, so two people typing into a new document offline both keep
their text (with the lazily created skeleton alone, one of them lost
everything). A binding refuses a document of the other backend with an error
that names the right one.
The conformance suite
Both backends pass ONE executable contract, and a third — Automerge, your own — registers the same suite and is then a drop-in for the server merge, the sync client, the hooks, history, epochs and counters:
// mybackend.conformance.test.ts
import { crdtBackendConformance } from '@voltro/testing/crdt'
import { myBackend } from './myBackend'
crdtBackendConformance('mine', { backend: myBackend })It pins merge commutativity, idempotence and identity, collaborative undo,
the remote/local fold signal, incremental updates, two queued local updates
merging with no base in hand and losing nothing, soft compaction keeping
mergeability, restoreTo, rebase with epochs and fresh authors, translate
across lineages, the content schema version surviving a rebase, and commuting
counters.
Measured under contention. Four separate processes, each with its own connection pool, folding 240 edits into ONE postgres row concurrently: every edit lands exactly once, about 55 folds per second on that single hot row, with 6 compare-and-swap refusals retried by the client — the worst case, since edits to different rows never contend.
Building on the engine: the lower-level API
The hooks cover the common case. What follows is the surface they are built from — for a custom editor, a server job, a binding for another framework, or a CRDT backend of your own.
Refusal codes
Every refusal below is a typed ValidationError whose field is the column.
The sync client treats it as FINAL — it drops the write and the same cell's
pending ones, falls back to the server's state, and reports it as refused —
except where the table says the client handles it itself.
message |
Raised when | The client |
|---|---|---|
crdt.updateTooLarge |
One update exceeds crdt.maxUpdateBytes. |
reports it |
crdt.documentTooLarge |
The merged document exceeds crdt.maxDocumentBytes. |
reports it |
crdt.updateNotDecodable |
The bytes are no CRDT update of this backend, or an increment builds on a state the cell does not hold. | resends its whole state once, then reports it |
crdt.staleLineage |
An edit was made on a lineage a hard compaction archived and cannot be placed on the new one (structured content). For a sealed column, every frame of an archived lineage. | sealed: carries the edit over itself once the rebased log arrives; open: reports it |
crdt.sealedFrameInvalid |
A sealed write is not exactly one well-formed frame. | reports it |
crdt.sealedSnapshotStale |
A sealed snapshot covers bytes that changed since it was computed, or a hard compaction misses a frame appended meanwhile. | drops it; the next delivery compacts again |
crdt.sealedRestoreIsClientSide |
restoreCrdtVersion on a sealed cell. |
— (use prepareSealedCrdtRestore) |
crdt.openRestoreIsServerSide |
prepareSealedCrdtRestore on an open cell. |
— (use restoreCrdtVersion) |
| your key | crdt.validate returned a message. |
reports it |
A custom editor binding
useCrdtEditor mounts whatever a CrdtEditorBinding opens for a document:
the collaboration, undo and caret extensions, and a dispose for what it
opened. yjsEditorBinding (the default) and loroEditorBinding are two of
them; this one wraps the Yjs binding to add an extension of its own:
import { Extension } from '@tiptap/react'
import { yjsEditorBinding, type CrdtEditorBinding } from '@voltro/local-first/editor'
const WordCount = Extension.create({ name: 'wordCount' })
export const countingBinding: CrdtEditorBinding = {
name: 'yjs-with-word-count',
accepts: yjsEditorBinding.accepts,
open: (input) => {
const session = yjsEditorBinding.open(input)
return { extensions: [...session.extensions, WordCount], dispose: session.dispose }
},
}A binding must turn off StarterKit's own history (useCrdtEditor does) and
own undo — a local history would revert peers' edits. The Loro binding's two
building blocks are exported for a binding of your own: seedLoroEditorDoc
gives an empty document the schema's skeleton as a change every client writes
identically (without it, two people typing into a new document offline lose
one person's text), and attachLoroCursorBridge relays a Loro cursor store
over the same delivery: 'latest' transport the Yjs awareness uses.
Content migrations outside the write path
The write path runs crdt.migrations itself. The same steps run anywhere a
document is in hand — an export job, a test, a one-off script:
import { applyCrdtMigrations, crdtSchemaVersionOf, latestCrdtSchemaVersion, renameEditorNodes, type CrdtMigration, type CrdtState } from '@voltro/local-first'
const steps: CrdtMigration[] = [
{ version: 1, migrate: (doc) => { renameEditorNodes(doc, { from: 'title', to: 'heading' }) } },
]
export const upgrade = (state: CrdtState): CrdtState => {
if (crdtSchemaVersionOf(state) >= latestCrdtSchemaVersion(steps)) return state
// `undefined` when nothing was due; otherwise the migrated state.
return applyCrdtMigrations(state, steps) ?? state
}The sealed log, for a binding of your own
A sealed column's value is a log of encrypted frames. The hooks write and
open it through these functions; a binding for another framework uses them
the same way, and applySealedFrame is the server's exact step — handy for a
test double:
import {
applySealedFrame, crdtText, decodeCrdtText, decodeSealedLog, generateSealKey,
openSealedLog, sealHardCompaction, sealSnapshot, sealUpdate, sealedLogEpoch,
} from '@voltro/local-first'
const key = await generateSealKey()
const fold = (log: Uint8Array | undefined, frame: Uint8Array): Uint8Array => {
const write = applySealedFrame(log, frame)
if ('refused' in write) throw new Error(write.refused)
return write.log
}
// Updates are sealed with the lineage they were made on ('' before any
// hard compaction).
let log = fold(undefined, await sealUpdate(key, crdtText('draft').encode()))
log = fold(log, await sealUpdate(key, crdtText('draft').insert(5, ' v2').encode()))
// A soft compaction names the bytes it replaces; frames after them stay.
log = fold(log, await sealSnapshot(key, await openSealedLog(key, log), log))
// A hard compaction rebases into a new epoch and replaces the whole log.
const { frame, epoch } = await sealHardCompaction(key, log, await openSealedLog(key, log))
log = fold(log, frame)
decodeSealedLog(log)?.length // 1 — the rebased snapshot
sealedLogEpoch(log) === epoch // the only thing about it the server can read
decodeCrdtText(await openSealedLog(key, log))What a custom backend may add
Beyond the methods the conformance suite requires, a CrdtBackend has two
optional ones. missingDependencies(state) reports whether a merged state
holds changes whose dependencies it lacks — implement it when your merge
keeps such changes instead of refusing them (Yjs parks them, Loro's merge
throws); the server then refuses an increment over a state the cell does not
hold rather than storing it invisibly. carryReplaced(current, update) carries
an edit made inside content a migration REPLACED into its replacement —
implement it when your renames replace nodes (Yjs) rather than keep them
(Loro). The suite checks both where present. A sync binding of your own reads
the last state the server delivered from SyncClient.getRemoteState(cell) —
the base the hooks compute upload increments from.
Inspecting CRDT storage in the dashboard
Open CRDT in the inspected API app's sidebar. The panel is read-only: it shows stored sizes, writer-session counts, epochs and version counts, never document contents. Each column scans at most 500 rows and lists the ten largest documents within that sample. A truncation notice means the figures are not a full-table inventory. Version counts are computed by the store without transferring history records to the dashboard.
Replica selection follows shared-store inspection: combined views do not add the same document once per replica. The observation notice identifies the responding process; the displayed limits are that process's configuration. Local Devtools refreshes the inspection automatically after each completed read. This diagnostic snapshot is not a document-editing subscription.
During a failed read, target change or malformed response, previous facts are hidden instead of presented as current. Successful subsequent reads restore the panel automatically. If the history table is absent, the panel explicitly states that displayed zero version counts do not establish an empty history.
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".
Order by a hybrid logical clock, not the wall clock
Wall clocks disagree. Ordered by them, the device whose clock runs two minutes fast wins every race — including against an edit made AFTER it. A hybrid logical clock stamp never runs backwards, and a write made after SEEING another one stamps after it, whatever the two clocks say:
import { createHybridClock, lastWriteWins } from '@voltro/local-first'
const clock = createHybridClock({ node: deviceId })
const local = { value: 'Final', updatedAt: clock.now(), writer: deviceId }
clock.receive(remote.updatedAt) // fold every stamp you receive
lastWriteWins(local, remote) // causal order, then the tiebreakupdatedAt accepts a stamp or a millisecond epoch (which compares as that
millisecond); use one consistently. Stamps are fixed-width strings, so they
sort and store as text. useOutbox({ stamp: clock.now }) stamps every queued
write (entry.stamp), which is the local side's updatedAt when a conflict
is resolved. maxDriftMs makes receive refuse a stamp from a clock too far
ahead (it throws) instead of adopting it — capping it would break the
ordering promise.
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, connectivity, persistence and edit encoding all ship — as does the rest of the offline story: the mirror with its derived sync set, warm queries and remembered partition, and the outbox's previewed, idempotent, partitioned write lane. |
Beyond the CRDT engine itself the offline story also ships: local queries over
the replica (useLocalQuery), end-to-end-sealed columns (crdtSealed()), a
CRDT panel in both dashboards (largest documents, pressure on the limits,
epochs, writer sessions, history), content migrations, a second CRDT backend
(Loro) and the backend conformance suite both pass.
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.