Collaborative editing
without a second backend.
Google-Docs-style editing normally means adopting a whole separate sync service. Here it is a column type. Declare crdtText(), bind a field, and concurrent edits converge — merged authoritatively on the server, queued while offline, and reconciled on reconnect. The same wire your app already uses.
// documents.schema.ts — a CRDT field is just a column
export const documents = table('documents', {
id: id({ prefix: 'doc' }),
title: text(),
body: crdtText(), // ← conflict-free, merged on write
})
// In a component — two people type, both edits survive
const { text, insert } = useCrdtText({
table: 'documents', id: docId, column: 'body',
})
const { members } = usePresence('doc:' + docId) // live cursorsWhat you get out of the box.
A CRDT is a column, not a service
crdtText() declares a conflict-free text field in your schema like any other column. The server folds every incoming update into the stored state on the write path — so two clients that edited the same base converge instead of one overwriting the other. No last-write-wins loser, no separate sync server to operate.
The merge is server-authoritative
Convergence is not left to the clients to negotiate. The runtime merges on write, order-independently and idempotently, then broadcasts the merged row through the normal reactive path. A malformed update fails at the write that caused it, naming the column — not silently poisoning a later read.
Offline is a durable outbox, not an error
Writes made while disconnected land in an outbox that survives a reload, replays in order on reconnect and stops at the first conflict rather than plowing past it. The conflict resolves through the policy you declared: crdtText() columns merge, plain scalars follow conflictPolicy(). A per-partition Web Lock keeps that to one drain per device no matter how many tabs are open.
Your last answer is still there offline
Every base movement of every subscribed query persists into a query mirror over IndexedDB, partitioned by subject AND tenant — so a cold start renders the last materialised rows through the same useSubscription call, and the next connect presents the mirrored revision and continues with deltas instead of a fresh snapshot. Encrypted columns are stripped before every save, and a row the server stopped sending is evicted by construction rather than by a sweep.
Presence and awareness
usePresence gives you the roster of who else is in a channel, with per-member metadata your app fills — a status, an avatar, a current view. High-frequency editor carets ride a delivery: latest event instead (a lagging caret skips to the newest position). @voltro/plugin-presence keeps presence ephemeral and in memory — no table written — and with plugin-broadcast every replica sees the whole room instead of only its own clients.
Pure and browser-safe by construction
The main entry has no effect and no node:* imports, so a route or component imports it directly and the React bindings live behind a subpath with React as an optional peer. The same pure merge runs in the browser, on the server, and in a unit test with no database.
The part most teams underestimate.
Convergence is a write-path property
Most bolt-on collaboration puts the merge in the client and hopes every peer agrees. Voltro folds the update into the stored state on the server, on the same write path as every other mutation — so the merged row rides the existing reactive fan-out, respects the same tenant scope and guards, and is what any late-joining client reads. Collaboration inherits your access rules instead of routing around them.
Two people, one paragraph, no lock.
// database/schema.ts
export const documents = table('documents', {
title: text(),
body: crdtText(), // ← conflict-free, merged on the server
}).with(tenant())Concurrent edits converge rather than overwrite, and the merge is authoritative on the server rather than whichever client saved last. Offline is the same path with a longer gap: the queue drains on reconnect and the same merge runs.
Local-first and CRDTs, in depth.
What is a CRDT, and why does it belong in the schema?
A conflict-free replicated data type is a value that can be edited independently in several places and merged without a loser. For text that means two people typing in the same paragraph produce a document containing both edits, rather than one overwriting the other on save.
Declaring it as a column type rather than adopting a sync service is the design decision. `crdtText()` sits in the same schema as everything else, migrates with everything else, and is read through the same query — so collaboration is a property of a field rather than a parallel system with its own storage, auth and operational story.
The stored value is an encoded CRDT state. That is why a client sends an update rather than a new string: the server folds the update into what it has, which is what makes two concurrent edits converge instead of racing.
Where does the merge happen, and why does that matter?
On the server, on the write path, before the row is stored. The runtime merges the incoming update into the stored state — order-independently and idempotently — and then broadcasts the merged row through the normal reactive path.
Most bolt-on collaboration puts the merge in the client and hopes every peer agrees. Folding it server-side means the merged row is what any late-joining client reads, it rides the fan-out that already exists, and it respects the same tenant scope and access decisions as every other write. Collaboration inherits your rules instead of routing around them.
It also fails usefully. A malformed update is rejected at the write that caused it, with the column named, rather than being stored raw and throwing on some later read — which is the failure that is nearly impossible to trace back.
How does offline work?
Writes made while disconnected go into a durable outbox. The queue underneath is a pure reducer, so its behaviour is testable rather than emergent, and its entries persist — closing the tab mid-edit does not discard them. When the connection returns they replay in order, and a per-partition Web Lock keeps that to one drain per device no matter how many tabs are open.
A replay stops at the first conflict instead of plowing past it, and the conflict resolves through the policy you declared. A crdtText() column merges — the CRDT property makes that lossless — and a plain scalar follows conflictPolicy(), whose default is last-write-wins with a symmetric tie-break, so two peers pick the same winner regardless of which side each calls "local".
Reads are the other half, and they are a query mirror rather than a browser SQL database. Every subscribed query's rows persist per subject-and-tenant partition over IndexedDB, so the app renders offline through the same subscription call and delta-resumes on reconnect. The deliberate limit: the client's whole query surface is a tag plus an input — predicates are built and evaluated on the server — so a SQL engine in the tab would evaluate a language the client never sees.
What about presence and cursors?
`usePresence` gives the roster of who else is in a channel, with per-member metadata your app fills — a status, an avatar, a current view. High-frequency editor carets deliberately do NOT ride this lane: they go over a delivery: latest event, so a caret that falls behind skips to the current position instead of replaying every move. Presence stays ephemeral and in memory — no table is written.
Cross-instance presence needs a broker. Without one, each replica sees only the clients connected to it, and the boot says so: a correct roster of a fraction of the room looks exactly like success, which is the kind of wrong that survives testing.
The pure entry point has no Effect and no Node imports, so a route or component imports it directly, and the React bindings live behind a subpath with React as an optional peer. The same merge runs in the browser, on the server, and in a unit test with no database.
What ships, and what it is for.
| Piece | What it provides |
|---|---|
| crdtText() | A conflict-free text column in your schema, merged server-authoritatively on the write path. |
| Durable outbox | Offline writes that survive a reload, replay in order, stop at the first conflict, and drain once per device. |
| SyncClient | Drives the queue over a transport you bind to your app's existing mutation and subscription wire. |
| Query mirror | Subscribed rows persisted per subject+tenant over IndexedDB — offline reads, then a delta resume online. |
| usePresence | Ephemeral in-memory roster per channel with per-member metadata; cross-instance with a broker. |
| localFirst() | A marker mixin declaring a table offline-capable — the schema-level opt-in the sync machinery discovers. |
Frequently asked questions
Do I need a separate sync service?
No. The merge happens on your server on the write path, and the merged row travels over the reactive fan-out you already have. There is no second datastore, no separate auth model and no extra service to operate.
What happens if two people edit the same sentence?
Both edits survive and every client converges on the same text, regardless of the order the server processed them in. That is the CRDT property — the merge is order-independent and idempotent, so replaying an update that was already folded in changes nothing.
Can I use it without going fully local-first?
Yes, and that is the common case: one collaborative field on an otherwise ordinary table. Because it is a column type, adopting it is a schema change rather than an architecture change.
Is the client bundle heavy?
The pure entry point carries no Effect and no Node imports, and React is an optional peer behind a subpath — so a route that only needs the merge does not pull in the hooks, and a server-side test needs no browser environment.
Does presence write to the database?
No. The roster is in memory and owner-partitioned so high-frequency presence does not become write traffic. Cross-instance presence needs a broker, and without one the boot warns rather than letting a partial roster look correct.
Local-first builds on the rest of the stack.
Open the framework. See it for yourself.
Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.