Skip to content

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.

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.
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 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. The stale flag 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 empty doc: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.
  • Deltas dominate. After doc:init, a subscriber receives patches. The exceptions are existence transitions, not content: a doc:restore pushes a fresh doc:init to 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:update carries 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 advances sequence; a Yjs-only update repeats it unchanged on its doc:patch broadcast.
  • 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 same doc:patch as yjsDeleted, 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:index into the synthesized sys:trash document; 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 fresh doc:init if 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:resume plus a small diff — not a re-send of the document. A subscribe without vectors gets full Yjs states on doc: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:init and doc:resume carry 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 ordinary doc:update. See Two sync lanes.