Wire protocol
The protocol between client and server is a small set of typed events. Application code never touches them directly.
All events travel over an event bus — WebSocket frames in production, function calls in-process. The vocabulary is identical either way.
Client → server
Section titled “Client → server”| Event | Meaning |
|---|---|
doc:subscribe |
Start receiving changes for a document — carrying the last seen sequence (if any) and per-Y.Doc state vectors, so each lane answers with only what’s missing. Server replies with doc:init or doc:resume (or doc:notfound). |
doc:unsubscribe |
Stop receiving changes. |
doc:create |
Create a document — id, type, initial data, an optional name, optionally with embedded Yjs documents. |
doc:update |
Change a document — a JSON Patch and/or Yjs updates, an optional guard (sequence or patch), and the client’s event id for optimistic confirmation. |
doc:delete |
Soft-delete a document — it leaves the index and the read path, but its history is retained for restore. Takes an optional sequence guard (“delete only if unchanged since I looked”). |
doc:restore |
Undo a soft delete — the document re-enters the index at the exact sequence it left, and subscribers receive a fresh doc:init. |
doc:purge |
PERMANENTLY destroy a batch of soft-deleted documents (up to 100) and release their ids — a purged id afterwards answers doc:notfound like one that never existed, and may be created again. All-or-nothing — one bad or unauthorized target rejects the whole batch and destroys nothing. Requires the docType’s explicit access.purge rule (the default is nobody, admins included); success is one sys:trash removal patch at one sequence. |
doc:rename |
Set a document’s name (the name lives in sys:index, not the document body). Patches the document’s sys:index entry; never touches its data, sequence or event log. Last-writer-wins. |
doc:get-events |
Fetch a document’s event logs — both lanes: the JSON patch events and the Yjs update events. |
Server → client
Section titled “Server → client”| Event | Meaning |
|---|---|
doc:init |
Full JSON snapshot at subscribe time — sequence, type, data. The Yjs lane rides along as exact per-Y.Doc diffs against the subscribe’s state vectors (full states when none were sent), plus tombstone notices and the server’s own vectors. A create/restore commit’s init also carries the document’s listing entry, so it is never readable-but-unlisted while its sys:index patch is in flight. |
doc:patch |
An incremental change — patch and/or Yjs updates, the new sequence, and the originating client event id (so the originator can retire its optimistic entry). May also carry yjsDeleted — ids of embedded Y.Docs the orphan GC reclaimed on this write, so subscribers drop those sub-docs immediately. |
doc:error |
A rejected event — categorized (validation failure, failed guard, limit), tied back to the client event id. Guard failures are expected signals, not faults. |
doc:notfound |
The subscribed document doesn’t exist. |
doc:deleted |
The document was soft-deleted — pushed to live subscribers when a delete commits, and the answer to subscribing to an already-deleted document (deliberately distinct from doc:notfound). |
doc:resume |
The client’s JSON state is current — no data re-transferred. The Yjs lane rides along independently: diffs and tombstones for whatever the client’s vectors were missing, plus the server’s own vectors. |
doc:resync |
Presence-only cold-start nudge — carries just the docId, asking a presence document’s subscribers to republish their cells. See below. |
Presence rides the same events
Section titled “Presence rides the same events”Presence adds just one event of its own — the
doc:resync nudge. Otherwise a sys:presence:<presenceType>:<docId> document is
subscribed, initialized, patched, and updated through exactly the doc:*
vocabulary above — a cell write is a doc:update, peers learn of it via
doc:patch, and the per-session sys:presence-view:<presenceType>:<docId> is a
client-side projection the wire never sees. What differs is server-side
lifetime, not the protocol: presence state lives only in memory, so it never
appends to an event log (doc:get-events answers an explicit error — no history
exists, ever) and is dropped when the publishing connection goes.
That ephemerality surfaces on the wire in three ways:
- Liveness is a patch, not an event. When a connection drops, the server
doesn’t invent a disconnect event — it marks the orphaned cells stale (a
generation bump that peers receive as an ordinary
doc:patch) and removes them after the grace window, and a reconnecting client simply republishes its cells. Thestaleflag in the view is derived from that, with no vocabulary of its own. - Ephemeral Y.Docs re-send in full. A cell’s
streaming Y.Doc
rides the normal Yjs lane inside
doc:update/doc:patch, but because it is never logged it is the one place catch-up isn’t a state-vector diff: on the owner’s reconnect the server can’t reconstruct it, so the owner re-sends the Y.Doc’s full state. - Cold starts nudge, they don’t reset. When a transport host wakes from
hibernation or restarts, its in-memory cell registry is gone, so it sends each
still-subscribed presence document a
doc:resync, asking its subscribers to republish. It is deliberately not an emptydoc:init, which would clear the peers a client is still showing; instead the client arms its staleness ledger and republishes its own cell, so the roster refreshes without flickering empty.
Design notes
Section titled “Design notes”- Deltas dominate. After
doc:init, a subscriber receives patches. The exceptions are existence transitions, not content: adoc:restorepushes a freshdoc:initto live subscribers (as does the presence re-init that rides along with it), because the document’s re-entry can’t be expressed as a patch against a tombstone. - One update, two payloads — one sequence. A single
doc:updatecarries structured patches and Yjs binary updates together, so a mixed edit (retitle + type in the body) is one event. But the lanes version independently: only the JSON half advancessequence; a Yjs-only update repeats it unchanged on itsdoc:patchbroadcast. - Errors are addressed, not broadcast. Rejections return to the sender with its event id; other subscribers never see them.
- Orphaned Y.Docs are reclaimed live. When a write orphans an embedded
Y.Doc — a removed
yjsRef, or a discarded session copy — the reclaimed ids ride the samedoc:patchasyjsDeleted, so live subscribers tear those sub-docs down at once rather than waiting for their next reconnect’s init/resume. - Deletion is lifecycle, not content. A delete never appends to the
document’s own event log — it’s recorded in the folder’s membership log, so
a delete → restore round-trip returns the document at the exact sequence it
left, history intact. Deleted documents move from
sys:indexinto the synthesizedsys:trashdocument; subscribe to it and a trash UI updates live, with no dedicated listing request. The move is broadcast add-before-remove, so a row switches lists without ever vanishing in between. - Sequences make hydration cheap. Document state can be fetched outside
the socket — say, server-rendered over plain HTTP — and handed to the
client as preloaded state. When the client later subscribes, it sends the
sequence it already holds: the server answers
doc:resume(no data) if nothing changed, or a freshdoc:initif the client is behind. JSON catch-up is deliberately a full snapshot, not incremental patches — simple over clever, at the cost of re-sending a document that moved one event. The same mechanism makes reconnects cheap — resubscribing with the cached sequence transfers nothing when nothing moved. - Yjs catch-up is exact, either way. The CRDT lane never falls back to
snapshot-resending: whether the JSON lane earns an init or a resume, the
Yjs lane answers the subscribe’s state vectors with precisely the missing
diffs. So a reconnect after pure typing is a
doc:resumeplus a small diff — not a re-send of the document. A subscribe without vectors gets full Yjs states ondoc:init(and, when the document holds Y.Docs, a sequence-matched subscribe without vectors falls back to a full init, since Yjs currency is unknowable without them). - Catch-up is bidirectional.
doc:initanddoc:resumecarry the server’s own state vectors; a client holding Yjs content the server lacks — offline edits, or a write-behind crash window — pushes exactly the missing diffs back as an ordinarydoc:update. See Two sync lanes.