Documents, sync and files
An 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.
An app’s work (a song, a note, a chart, a rig library) is a document: a Loro CRDT that every device edits offline and that merges when they meet. architect owns how a document is shaped, saved, undone, synced and shared; the app declares its kinds and edits. In this chapter you will:
- declare a document kind and edit it through named, undoable steps;
- keep it in a document file, with checkpoints, branches and crash recovery;
- sync it to the org’s server with one driver per org;
- share it live peer to peer: by link, with the person’s own devices, and with devices found on the LAN;
- show who is here and bind a collaborative text field;
- keep its audio and images as blobs by hash, fetched on demand;
- store files in an org’s roots with
architect-files.
All of it is the crdt crate (features/crdt/crdt) unless said otherwise.
Each step names the test that proves it; read the test beside the step.
Rows a server owns and validates (a task’s status, a member list) are not
documents: they are entities and services with optimistic updates (the
architect-atom store). Use a document where concurrent edits must both
survive.
1. A kind, and named undo#
A DocKind is an id, a name, a file extension, a schema stamp with its
migrations, and which blobs it points at (features/crdt/crdt/src/kinds.rs):
use crdt::{DocKind, Migration};
fn song_audio(doc: &LoroDoc) -> Vec<String> { /* the 64-hex hashes the song names */ }
let kind = DocKind::new("studio.song", "Song")
.extension("session")
.schema(2, vec![
Migration { from: 0, name: "seed roots", run: seed_roots },
Migration { from: 1, name: "notes to text", run: notes_to_text },
])
.blobs(song_audio);
Register every kind in a DocKinds (register, get, for_extension) so
an opened file finds its kind by extension. Migrations run one stamp at a
time and must be idempotent: two offline devices may both run one, and
both runs merge.
Every change is a step with a name, undone as one (features/crdt/crdt/src/history.rs
History):
let mut history = History::new(&doc, 200); // keep 200 steps
history.transact(&doc, "Arm track", |doc| { /* edit */ });
history.undo(); // Some("Arm track")
Undo is local: Loro’s undo manager undoes only this peer’s changes, so
undoing my step never undoes a collaborator’s. A step can span many calls:
begin(name) opens one, every transact until commit(&doc) folds into it,
and abort(&doc) takes it all back. That is how a script that runs ten
actions undoes as one step (the extending chapter).
Share one Arc<Mutex<History>> between everything that edits the document.
2. The document file#
One file per document (features/crdt/crdt/src/file/mod.rs DocFile): an
append-only record file with the kind, schema and id in its meta, branches,
named checkpoints, and a blob table. Nothing in it is native, so a browser
reads a file it was handed (DocFile::from_bytes) as a desktop reads one
from disk (DiskStorage).
use crdt::file::{DiskStorage, MAIN};
use crdt::DocFile;
let file = DocFile::create(DiskStorage::open(&path)?, &kind).await?; // or DocFile::open
let mut main = file.open_branch(MAIN).await?; // migrated to the kind's schema
main.transact("Split item", |doc| { /* edit */ }).await?; // on disk when it returns
main.save("Mix sent to Dan").await?; // a named checkpoint, folded
let mut alt = main.fork("acoustic").await?; // a branch, open side by side
main.merge(&alt).await?;
let back = file.restore(&file.versions(MAIN).await?[3], "from v3").await?;
- No unsaved state.
transact,undoandredoreturn once the step is on disk; “Save” names a version. - Crash. A write cut short is a torn last record;
opendrops it (DocFile::recoveredsays so) and replays the steps since the last snapshot.kill_9_mid_edit_reopens_with_every_finished_stepinfeatures/crdt/crdt/tests/file.rskills a writer mid-edit to prove it. - Kind. Opening refuses another kind or a newer schema.
- Sync.
file.persistence(MAIN)is the branch as aPersistence, so aCrdtDocand its sync keep their replica in the file.
Not yet: undo that survives reopening, an IndexedDB Storage, one writer
per file across processes.
3. Sync to the org: the org driver#
A signed-in device keeps one driver per org it has documents in
(features/crdt/crdt/src/docs/org.rs). OrgSyncs makes each org’s
OrgDocs on first ask and keeps it connected; Placement puts each
document in its file. Shortened from features/crdt/crdt/tests/org_sync.rs:
let syncs = OrgSyncs::new(
move |org: Org| { // once per org
let placement = Placement::new(dir.join(&org.slug), kinds.clone());
async move { Ok(OrgDocs::new(org, TursoDocsIndex::open_path(&index).await?, placement)) }
},
move |org: Org| async move { // every dial: the token as it is now
match signed_in() {
None => Dialed::Stop,
Some(token) => match lane(&org_url(&org.slug), &token).await {
Ok(lane) => Dialed::Lane(lane),
Err(e) => Dialed::Unreachable(e),
},
}
},
);
let docs = syncs.current(&orgs).await?; // the window's org; None in All
- Staying connected. Each org dials, runs a session until the
connection closes (
Caller::closed), waits under architect’sBackoff(full jitter, 250 ms to 10 s), and dials again;Dialed::Stop(signed out) ends it.link()isConnecting,Live,OfflineorStopped, for a status item. An app whose connection is already supervised (use_app_supervised) callsrun_on(&lane)once perConnection::generationinstead. - Placement.
docs.placement().place(id, path)before the driver opens a document puts it in the file the person chose; otherwise it is<dir>/<id>.<extension>. A document made on this device gets a stamped file; one made elsewhere an empty one (DocFile::adopt) that fills when it syncs, so it is never another lineage. - Following the person.
syncs.follow(&orgs)stops the orgs the person left; their files stay. - Offline. Every document edits locally; the next session is a delta
both ways (
a_members_doc_syncs_through_a_dropped_connection_and_merges_offline_edits).
On the server: who may read and write#
The org’s server hosts the catalogue and the content: DocsHost over a
DocRegistry, both asking the org’s permission engine on every call
through DocPermissions (features/crdt/crdt/src/registry.rs), which
checks read or write on org/<org>/doc/<id>:
let p = DocPermissions::new(org_engine).in_org(&org.id);
let host = DocsHost::open(store, registry.with_permissions(p.clone())).await?.with_permissions(p);
One host per org, mounted on the org’s lane behind the permissions gate (the identity chapter). Every refusal answers “unknown document”, so a caller cannot tell forbidden from absent.
Lineage#
A document is created once, on one device, and adopted by the others. Two
devices that each create “the same” document offline under one id have
made two documents: the host refuses the second as foreign lineage
(Lineage::Strict, the default). A document every device makes offline
under one derived id, merged per key, is Lineage::Merge (the person’s
synced settings are one). A host never writes into a Strict document.
4. Peer to peer#
Three ways a document reaches another device without the org’s server,
all over vox on iroh (features/crdt/crdt/src/p2p.rs). Each node keeps
one NodeAccess (its Admission and its Shares) and serves every
connection through node_router, which resolves the caller from the
endpoint id the handshake proved:
let access = NodeAccess::new(Admission::new(), Shares::new());
architect_devices::serve(&endpoint, access.devices.clone(), move |remote| {
node_router(reg.clone(), OpenDocs(reg.clone()), access.clone(), &remote.to_string())
});
- Share links.
shares.share(&endpoint.id().to_string(), doc_id)returns aDocLink; its text isfts-doc:<endpoint>/<doc>?key=<key>. Whoever holds it edits that one document untilshares.revoke(&link). The other side parses it, dials the endpoint, presents the key withLinkKey::new(&link)as middleware on aDocSyncClient, and runsSyncedDoc::new(link.doc_id, doc).run(&client). A link is a credential; there are no read-only links yet. - The person’s devices. Run the account pass,
architect_devices::account::pass(&devices_client, &token, &this_device, &node, &access.devices)(ordrive, on every reconnect): it registers this device and lets the account’s others into theAdmission. An account device may sync any document; a device taken off the account is refused on its next call and its connection closes. Registering is in the identity chapter. - A paired device. A device on nobody’s account redeems a pairing offer
(a six-digit code or a
fts-pair:ticket) made withNodeAccess::pairing(Pairings::new(...)), and may sync the documents the offer named. See Devices. - On the LAN. Bind the endpoint with
PeerLookup::LanorPeerLookup::N0AndLan, advertise withadvertise_name(&endpoint, "Cody's laptop"), and browse withLanBrowser::new(endpoint.id())?.events()(LanEvent::Found/Lost); dial a found id bare (crates/architect/src/iroh_link/lan.rs). An iOS app lists_fts._udpinNSBonjourServices.
features/crdt/crdt/tests/p2p.rs is all three over real iroh endpoints.
5. Presence and collaborative text#
Who is here#
Presence rides beside each document and is never in it
(features/crdt/crdt/src/presence.rs). Publish yours with Here (your
state on change, your pointer up to 30 times a second, your playhead) and
read everyone’s with Roster. In Dioxus, use_here, use_roster and
use_presence_frame (features/crdt/crdt/src/hooks.rs).
A pointer is a place, not pixels: a space and named coordinates, so it lands on the same bar of the same track however differently two people have scrolled:
here.pointer(Place::new("session.arrange").with("time", t).with("track", id), clock_ms());
// `arrange_pixels` is the view's: a place in its pixels, or None when it does not show it.
let marks = roster.pointers(clock_ms(), |place| arrange_pixels(place));
Draw them with the workspace’s PresenceLayer as the view’s last child
(the UI chapter). Never call sweep() in a render.
Collaborative text#
A LoroText bound to an editor (features/crdt/crdt/src/text.rs
TextBinding): the editor reports its edits as operations, others’ edits
come back echo-guarded and transformed against what the person typed, and
carets are stable cursors.
// `editor` is your editor, implementing `EditorBinding`.
let binding = TextBinding::new(&doc, doc.get_text("body"));
editor.set_text(&binding.shown()); // seed it
binding.on_local(&ops)?; // each edit, in the editor's coordinates
binding.sync_into(&mut editor); // after every document revision
let caret = binding.caret(anchor, head); // for PeerState::caret
let others = binding.remote_carets(&roster);
set_offset hides a prefix the editor does not show (a note’s
frontmatter); set_composing brackets IME composition. Positions are
Unicode scalars, so an editor in UTF-16 translates at its edge.
6. Blobs by hash#
Audio and images never go in the Loro document: the document holds their
sha256, and the kind’s blobs function reads the hashes out. The bytes
live in a LocalFs store and travel through architect::blob’s Fetcher
(crates/architect/src/blob/fetch.rs):
use architect::blob::{Fetcher, HttpRemote, LocalFs, VoxRemote};
let fetcher = Fetcher::new(LocalFs::new(data_dir.join("blobs")))
.with_remote(Arc::new(VoxRemote::new(peer_client))) // the peer the song came from
.with_remote(Arc::new(HttpRemote::new(server_blobs))); // the org server's copy
let reader = fetcher.open(hash).await?; // streams as it arrives
- One transfer runs per hash however many readers open it; a dropped link
keeps the partial file and the next fetch resumes (
set_remotesafter a reconnect). The whole blob is verified before it is kept. - A node serves its store with
BlobTransferHoston the same router asDocSync;.allow(|hash| …)narrows what it sends. A server’s HTTP route answers ranged requests withserve_http. - Pinning.
crdt::blobs::keep_offline(kind, doc, &fetcher)pins and fetches every blob a document points at;releaseunpins them. - Collection.
store.collect(&crdt::blobs::live(&kinds, docs), grace)removes every blob no document on the device points at and nothing pins.
features/crdt/crdt/tests/blobs.rs records a take on one device and plays
it on another.
7. Files: architect-files#
Files that are not a document’s (an org’s shared folders, impulse
responses, sample packs) go in architect-files (features/files): roots
of files and folders, a version per save, labels, resumable and
deduplicated uploads over vox channels, a quota, and access by role plus
per-folder grants. Its bytes are the same architect::blob store, so a
song’s audio can be both a file in a root and a blob its document points
at.
The app’s side:
let files = FilesClient::new(caller); // the org lane's vox Caller
let irs = files.ensure_root("signal/impulse-responses", "Impulse responses").await?;
let saved = files.put(irs.id, "Cab/4x12.wav", &wav, Save::create_only()).await?;
// next time: Save::replacing(saved.content), refused as Stale if it moved on
The server mounts FilesServer on each org’s lane behind the gate, with
ConfinedEngine so only members reach it; the org comes from the lane,
never from an argument. skills/architect/references/files.md has the
mount, and features/files/tests/files.rs the whole loop.
Where Task and Studio are#
Architect-side today: kinds, named undo, the file, the org driver,
presence, the text binding, blob transfer and the Files core. Still the
apps’ to adopt: Studio’s session-format moving onto DocFile; Task’s and
Studio’s org drivers moving onto OrgSyncs; editor-crdt implementing
EditorBinding for Task’s notes and Studio’s chart editor; Studio’s
arrangement publishing presence places. Task and Studio still run Task’s
own Files; client sync, the browser and desktop integrations and the HTTP
byte face of architect-files are not built yet.
Where to read more#
- Documents: the layer, the file format, the org driver, Files, text, presence.
- Collaboration:
CrdtDoc, typed repositories, hooks,DocRegistry, schema migrations. - Peers: pairwise exchange, devices and links.
- Blobs:
Fetcher, serving, pins and collection. skills/architect/references/crdt-peer-sync.mdandskills/architect/references/files.md.
Linked from
- Build an app with architectA chapter-by-chapter walk through building a real app on architect: the contract, the backend, the server, the clients, the tests and the release.
- The app shell and the workspaceMount architect-shell at the root, give it tabs, sides and a status line, then hand architect-workspace your views and get tab groups, splits, docks, floats, pop-out windows and screensets.
- SettingsDeclare settings once as a typed struct; architect stores each in its scope (this device, the person's, the project's) under an org's defaults and locks, and draws the settings page with search to the row.
- Sign-in, permissions and organizationsSign a person in against the one identity server, keep the session and register the device; gate every service call with PermissionsGate; and make the organization the scope of everything, on the client and confined on the server.
- Scripts, extensions and canvasesLet people and models add to the app without rebuilding it: Luau scripts over the Action List as one undo step, extensions with manifests, registries and hot reload, process extensions with remote views, and ImGui-shaped canvases over a view.