Real-time collaboration (Loro)
Offline-first collaborative state: Loro CRDT replicas synced over vox channels — local writes are instant, everything merges, reconnects are deltas.
architect has two state models, and they answer different questions:
| Optimistic store (optimistic) | CRDT replica (this page) | |
|---|---|---|
| Source of truth | the server | the document (every replica holds it) |
| Local write | optimistic patch → reconcile or rollback | applied to the local doc, final — concurrent edits merge |
| Offline | reads stale cache, writes fail | fully functional; syncs when back |
| Conflicts | server wins | Loro merge semantics (CRDT) |
| Right for | server-owned rows, thin clients | collaborative, offline-first features (Task projects, DAW sessions) |
Both share the same wire (vox), the same fan-out (PubSub), the same
phase-match rendering. Pick per feature — the example app runs both
side by side (Example is store-backed, Note is a CRDT replica).
One attribute end to end#
#[derive(architect::Entity, ::facet::Facet, Clone, Debug, PartialEq)]
#[architect(table_name = "notes", repo, crdt, form)]
pub struct Note {
#[architect(primary_key, auto_increment = false, on_create = Uuid::new_v4())]
pub id: Uuid,
#[architect(sortable, fulltext)]
pub text: String,
pub author: String,
#[architect(exclude(create, update), on_create = Utc::now())]
pub created_at: DateTime<Utc>,
#[architect(exclude(create, update), on_create = Utc::now(), on_update = Utc::now())]
pub updated_at: DateTime<Utc>,
}
#[architect(crdt)] (user-crate feature crdt = ["dep:crdt"]) emits:
- the
EntityCrdtcodec — field ↔LoroMapmapping inferred from the field types (Uuid,String,bool, ints,DateTime<Utc>,Vec<String>,Optionof each), with the sameon_create/on_update/excludepolicy the SeaORM path uses; NoteRepoLoro— a Loro-backed implementation of the sameNoteRepotrait the SQL storage implements, over anyCrdtDoc;- with the
atomfeature too (convention:atom = ["architect/atom", "crdt?/dioxus"]):use_note_crdt_list()/use_note_crdt(id)returning the sameAtomResultphases pages alreadymatch, anduse_note_crdt_actions()— create/update/delete that write the local replica directly. No optimistic machinery, no rollback arm: writes are instant and final, concurrent edits merge.
The stack#
features/crdt/crdt is the local-first layer: one [CrdtDoc] per collaboration
boundary (a project, a workspace, a DAW session) wrapping a LoroDoc +
pluggable Persistence, and typed LoroRepo<E> CRUD views per entity.
crdt::sync (feature vox) is the transport:
client A server client B
┌────────────┐ sync(vv,up,down) ┌──────────────┐ sync ┌────────────┐
│ CrdtDoc │ ──────────────────▶ │ canonical │ ◀────── │ CrdtDoc │
│ (replica) │ ◀── backlog ────── │ CrdtDoc │ ──────▶ │ (replica) │
│ local ops ─┼──── up channel ───▶ │ + Persistence│ ─down─▶ │ │
│ │ ◀─── down channel ──│ + PubSub │ │ │
└────────────┘ └──────────────┘ └────────────┘
DocSync— one#[vox::service]method. The client sends its Loro version vector plus an up-channel and a down-sink; the server answers with exactly the missing history (ExportMode::Updates { from }— an offline week is a delta, not a re-download) and returns its own version vector, so the client pushes back anything the server is missing. Catch-up is bidirectional: a replica that edited offline, restarted (in-memory outbox gone), and reconnected still delivers its history.DocSyncHost— the server: canonical doc + unbounded fan-out (update bytes are never dropped). Relayed updates are persisted explicitly (imports don’t fire the local-update subscription)..with_compaction(n)folds the update log into a snapshot everynupdates so storage stays bounded;.with_shallow_bootstrap()serves fresh joiners a shallow snapshot instead of full history.SyncedDoc— the client driver: outbox-buffers local updates while offline, runs onesyncsession per connection, merges everything that comes down.run()is a plain future — spawn it with tokio,dioxus::spawn, or wasm-bindgen. By default what comes down is imported withapply_remoteand not persisted (the replica keeps its own edits and re-learns the rest on the next attach, enough for a browser tab); a device replica usesSyncedDoc::new(..).durable()so received edits survive an offline restart.- Opening —
CrdtDoc::open(doc_id, persistence)for one store per doc;CrdtDoc::open_shared(doc_id, Arc<dyn Persistence>)when one device database sits behind many docs. On a SeaORM server,crdt-seaorm(features/crdt/crdt-seaorm) providesSeaOrmPersistenceover its owncrdt_doc/crdt_updatetables; compose itsMigratorinto the feature’s migrator (or run it alone when CRDT bytes are all the database holds). DocPresence/PresenceHost/PresencePeer— who’s here, over Loro’sEphemeralStore(timestamp-LWW, auto-expiring) and a sliding PubSub: presence is state-shaped and droppable, unlike doc updates. Late joiners get the current picture from the host’s mirror on attach; peers re-announce their keys on reconnect.
Dioxus hooks#
// app root — one synced replica + presence per collaboration boundary:
crdt::use_synced_doc(COLLAB_DOC_ID);
crdt::use_presence_channel(COLLAB_DOC_ID, 30_000);
// a page (or use the derive's entity-bound wrappers):
let handle = crdt::use_doc_handle(); // .status(): Connecting | Live | Offline
let notes = use_note_crdt_list(); // AtomResult — same match as everywhere
let actions = use_note_crdt_actions(); // .create/.update/.delete → local replica
let presence = crdt::use_presence(); // .set(key, value), .states()
use_synced_doc owns the replica, bridges the doc’s change
subscription to a revision Signal (every committed change — local or
a peer’s — re-renders the readers), and keeps one sync session alive
against the app’s shared Connection<Caller>, retrying with the
version vector so every reconnect is a delta. The doc is usable
immediately, online or not. use_synced_doc_with(doc_id, || async { CrdtDoc::open(doc_id, persistence) }) makes the replica itself survive
restarts:
- desktop / server:
FilePersistence(snapshot + update log under a directory, write-then-rename, compaction prunes the log); - browser:
IndexedDbPersistence(featureindexeddb) — same layout in IndexedDB object stores.
Many documents (DocRegistry)#
DocSyncHost / PresenceHost each serve exactly one doc_id — but a
product like Task wants one doc per project, with projects coming
and going at runtime. crdt::registry::DocRegistry implements the same
DocSync + DocPresence service traits, so one mounted dispatcher
pair serves every doc: on sync(doc_id, …) / presence(doc_id, …) it
looks up — or lazily opens — the per-doc host and delegates. All the
single-doc invariants (snapshot-then-changes attach, durable relay,
weak-handle subscriptions, unbounded doc fan-out) are inherited by
composition.
let registry = DocRegistry::new(move |doc_id| {
let root = data_dir.clone();
// the factory owns per-doc persistence policy — FilePersistence
// already namespaces by doc id under its root; a DB backend keys
// its rows by doc_id the same way
Box::pin(async move { CrdtDoc::open(doc_id, FilePersistence::new(root)).await })
})
.with_compaction(64) // per created host
.with_shallow_bootstrap() // per created host
.with_presence_timeout(30_000) // per created PresenceHost
.with_admission(|doc_id| known.contains(&doc_id)) // gate before open/serve
.with_permissions(DocPermissions::new(org_engine).in_org(&org.id)) // who may
.with_idle_eviction(Duration::from_secs(900)); // compact + drop idle docs
router = router
.with(doc_sync_service_descriptor(), DocSyncDispatcher::new(registry.clone()))
.with(doc_presence_service_descriptor(), DocPresenceDispatcher::new(registry));
- Factory —
Fn(Uuid) -> Pin<Box<dyn Future<Output = Result<CrdtDoc, PersistError>> + Send>>: the caller decides persistence per doc. Onetokio::Mutexguards the doc map across the factory call, so two concurrent first-attaches can’t double-open the same persistence. - Admission —
Fn(Uuid) -> bool, checked before a doc is opened or served; rejected ids fail withUnknownDoc(indistinguishable from a doc that doesn’t exist). This is coarse server-side scoping, not authorization — the hook sees only the doc id, not who’s asking. - Who may — every attach knows its caller: the principal the
router’s
PermissionsGateresolved (architect::permissions_gate::caller).with_permissions(DocPermissions)asks the org’sPermissionEngineabout it on every call:writeonorg/<org>/doc/<id>to sync,readto watch presence (doc/<id>, org-relative, withoutin_org). A person who is not a member of the org, or whose role does not reach the doc, is refused with the sameUnknownDoc, before anything is opened.DocsHost::with_permissionsmakes the same check on the catalog (readto see a row,writeto register, rename or delete), so what a person may not open is not listed to them either.with_authorizer(|who, doc_id| …)is the closure form, for a rule no engine holds (a peer’s endpoint id). Proven byfeatures/crdt/crdt/tests/org_sync.rs(a_person_who_is_not_a_member_is_refused_on_the_server). - Idle eviction — a sweeper compacts and drops docs with zero live sessions and no activity for the configured window; the next attach reopens from persistence (one snapshot import, thanks to the pre-evict compaction). The eviction-vs-in-flight-attach race is closed by re-checking session counts under the same creation lock every attach holds from lookup through completion.
Client side, the keyed hooks pair with the registry: where
use_synced_doc / use_presence_channel provide a single unkeyed
context (two simultaneous docs would collide), use_synced_doc_keyed(doc_id)
and use_presence_channel_keyed(doc_id, timeout_ms) return their
handles instead — a component holds one per doc. The unkeyed hooks are
now thin wrappers that delegate to the keyed ones and provide context.
Server wiring (the example app’s shape)#
// once per process, like the evented repo wrapper — a DocRegistry over
// file persistence (one subdir per doc), notes doc opened eagerly:
let collab = Collab::open("./collab-data").await?;
// per connection (clones are cheap over the same doc map):
router = collab.mount(router); // DocSync + DocPresence
Proven end-to-end by features/crdt/crdt/tests/sync_convergence.rs (in-process:
bidirectional convergence, late joiner, offline-restart push-back,
shutdown teardown), features/crdt/crdt/tests/registry.rs (two docs through one
dispatcher pair, presence isolation, admission gate, evict-then-reopen)
and app-tests-e2e (notes_replicas_converge_over_websocket,
presence_propagates_between_peers, two_docs_sync_through_one_registry
— real WebSocket, real axum server, all through the registry).
The example app’s Collab page is the live showcase: open it in two
windows, type, kill the server, keep typing, restart it.
Loro gotchas the framework already encodes#
- Subscription closures never own a strong
CrdtDoc. Loro fires callbacks synchronously under internal locks; if a closure is the doc’s last owner, the final drop happens inside unsubscribe and deadlocks. HoldCrdtDoc::downgrade()(WeakCrdtDoc) and send work through a channel — seeDocSyncHost’s compaction worker. - Imports don’t fire
subscribe_local_update— a host applying relayed updates persists them explicitly (CrdtDoc::apply_remote_durable). - Events fire synchronously during
commit()/import()— hook callbacks only push into channels; signal writes happen in Dioxus tasks.
Text fields: LWW by default, crdt(text) to merge#
A String field on a #[architect(crdt)] entity is stored as a plain
LoroValue::String in the row’s LoroMap, which is last-writer-wins
on the whole string. If two peers edit the same field concurrently,
the merge keeps one peer’s entire value and the other’s edit is
lost. The row still converges, just not character by character.
That’s fine for short, rarely-contended fields (a title, an author
name), but it’s the wrong tool for collaborative prose.
Mark those fields #[architect(crdt(text))]:
#[architect::entity(table_name = "notes", repo, crdt)]
pub struct Note {
#[architect(primary_key, auto_increment = false, on_create = Uuid::new_v4())]
pub id: Uuid,
pub title: String, // LWW
#[architect(crdt(text))]
pub body: String, // LoroText: concurrent edits merge
}
The wire type stays String. The derive writes the field through
crdt::codec::write_text (a minimal prefix/suffix diff into a child
LoroText, so only the changed span becomes ops) and reads it through
read_text_or_legacy. If peer A appends while peer B prepends, both
edits survive the merge. features/crdt/tests/derive proves this
through the emitted NoteRepoLoro.
Stringonly.Option<String>and other types are a derive-time error, as iscrdt(text)without the container’scrdt.- Rows written before the field became
crdt(text)still decode: the legacy string is read as-is, and the next write upgrades it to a container. - The first write into a key that isn’t text yet creates a plain
(non-mergeable)
LoroText(codec::upgrade_to_text). Concurrent first writes therefore resolve LWW, like the string they replace, instead of concatenating both seeds. Every write after that merges.
Schema versions and migrations (crdt::schema)#
A replica carries its layout version in a root map. By default that’s
__architect_meta.schema_version. crdt::schema::migrate(doc, TARGET, MIGRATIONS) runs each Migration { to, name, apply } step in order
from the stamped version (0 if unstamped), bumping and committing the
stamp after each step. A gap in the list is an error. A doc stamped
newer than the app is refused with RepoError::Conflict. A doc that
already keeps its stamp elsewhere names that location, e.g. Session’s
meta.schema:
const SCHEMA: u32 = 3;
const MIGRATIONS: &[Migration] = &[
Migration { to: 1, name: "seed roots", apply: v1 },
Migration { to: 2, name: "chart → text", apply: v2 },
Migration { to: 3, name: "drop legacy keys", apply: v3 },
];
SchemaStamp::new("meta", "schema").migrate(doc.loro(), SCHEMA, MIGRATIONS)?;
Two offline peers can migrate the same old doc, so steps must be
idempotent. Steps also must not seed content into mergeable
containers: both peers’ seeds would survive the merge, and the content
would appear twice. Use codec::upgrade_to_text (or insert_container)
for those steps. Run migrate after the initial load/sync, because
stamping an empty joiner doc looks like “a session has arrived”.
What this means for Task and DAW#
- Task (local-first, multi-device): one doc per project — a
DocRegistryon the server, keyed hooks on the client; tasks/ cycles/milestones as#[architect(crdt)]entities in it. Devices work offline and merge on reconnect; file/IndexedDB persistence makes restarts free. - DAW: a session doc for collaborative arrangement state (markers,
regions, routing), with the REAPER extension holding a replica
in-process — host edits broadcast to web UIs and vice versa. Live
signal data (peaks, playhead) stays on
PubSubevent streams; CRDT is for document state.
Linked from
- Documents, sync and filesAn app's work as CRDT documents: kinds and named undo, the document file, sync to the org through the org driver, peer to peer by link and on the LAN, presence and collaborative text, blobs by hash, and Files.
- DocumentsCo-edited state as Loro documents: CrdtDoc, kinds of document with schema stamps and migrations, named undo, DocRegistry, crdt entities and the sync hooks.
- Peer to peerDevices sync documents with each other directly over iroh, admitted per document; a share link opens one live document to a guest.
- Presence and collaborative textWho is here and where: pointers as places in a view's coordinates, selections, carets and playheads; text that merges, with remote carets.
- ArchitectureThe patterns the architect template is showcasing.
- Documents: one layer for files, undo, sync and peersWhat an app's work is saved as, how it is undone, versioned, synced to a server and to peers, and how its media travels — architect's, so an app only declares its schema.
- Peers: documents between nodescrdt::peer — pairwise document exchange with no hub, and per-document access the library checks.
- Extensions and scriptsAdding to an app at runtime, without rebuilding it: data-only extensions, WASM components over a versioned WIT world drawn from the Action List, Luau scripts for macros, out-of-process plugins on a desk — with capabilities, and views as frame documents.