Presence
Some state belongs to a moment, not to history: who has a document open, where their cursor sits, the display name to show beside it, a half-generated LLM response still arriving. None of it should be versioned, replayed, or kept once the participant leaves. datadata models all of it as presence — and, true to the rest of the system, presence is just another document.
State a client could compute from an agreed time — an enemy on a fixed route, a countdown — needs no presence at all: see the shared clock.
An ephemeral document of cells
Section titled “An ephemeral document of cells”Presence lives in channels: a presence document
sys:presence:<presenceType>:<docId> is a flat record set of cells keyed
by presenceId, one per participant, each { subject, state }. The
<presenceType> names the channel’s schema, and <docId> is just a document
id — the app gives it meaning (typically “the document this channel is about”);
the server never resolves it, only caps it at 256 characters. That independence is deliberate: a channel works on a document that doesn’t exist (yet) server-side
— say, a staged create two sessions are collaborating on — and one channel type
(a generic editor’s cursors, say) serves any document. Which channels an app
opens on which documents is convention in app code, exactly like deterministic
docIds; a common convention is one channel per docType.
The state is an application-defined JSON object — cursor, selection, display
name, whatever the app puts there —
schema-validated against the channel’s
required schema at sys:schema:presence:<presenceType>. A channel whose
schema isn’t declared doesn’t exist: subscribing or publishing to it answers an
explicit unknownType error — a loud configuration signal, never a silent
“not found”. The presence schema is an ordinary schema document with one twist:
because presence is never stored, it
carries no migration log —
its shape changes freely and each cell just re-validates on its next write. The
server stamps subject from the authenticated connection, so a cell can’t lie
about who owns it.
Presence needs almost no protocol of its own. The presence document rides the
same wire as any other — the ordinary
subscribe/init/patch/update path, plus a single presence-only event
(doc:resync, below) — so presence reuses the engine’s existing sync, fan-out,
and optimistic-update machinery rather than bolting on a parallel channel.
What makes it presence rather than a document is its lifetime: presence is never stored durably. The server keeps only an in-memory aggregate — each client is the source of truth for its own cells, bounded by the connection that published them and cleared on disconnect or explicit removal. Nothing is logged or replayable.
So when a host restarts or wakes from hibernation it loses that aggregate
outright, and rebuilds it by sending each still-subscribed presence document a
doc:resync — the one event unique to presence — asking every subscriber to
republish its cells. The nudge deliberately isn’t an empty doc:init, which
would clear the peers a client is already showing; instead each client marks its
peers stale, republishes its own cell, and the
roster refreshes as everyone’s republishes arrive, without flickering empty.
Read and written per-session
Section titled “Read and written per-session”A client never touches the raw aggregate. It works through a synthetic
view, sys:presence-view:<presenceType>:<docId>, that projects the cells into
{ self, peers } and is read and written per-session with the same
getDocument / updateDocument / onDocumentChange a session uses for any
document. A session writes only its own self cell; the other participants
arrive as peers. The view is computed on read — it is never itself persisted.
Because presence is per-session, two sessions on the same client (the
ambient client.live and a scoped one, say) each
own a distinct cell, and a staging session
publishes its presence live even while its document edits stay staged.
Each session mints its own presenceId. Pass presenceId to createLiveSession
to choose it instead — for deterministic ids in tests, or to correlate with
another system. The id is the cell’s key in the channel document, so it follows
the same rules as a document id.
Creating a session with any other id throws, and the server rejects a cell
written under one.
Liveness and the grace window
Section titled “Liveness and the grace window”When a peer’s connection closes, the server removes that peer’s cells at once:
the departure reaches everyone else as an ordinary doc:patch removing the
cell, and the peer leaves the view. There is no disconnect event and no timeout
on the server.
The stale flag covers the other case: when your own view of the
channel can no longer be trusted. That happens when your client reconnects, or
when the server wakes from hibernation and sends doc:resync. Your client can’t
tell yet which peers are still there, so it keeps showing them, marked stale,
instead of blanking the roster. Each peer’s republish (or the fresh doc:init
after a reconnect) confirms that peer and clears its flag. A peer nobody
confirms within the grace window, presenceGraceMs on the client (10 seconds
by default), is hidden from the view. The UI can dim stale collaborators while
the roster settles instead of flickering empty.
A client that changes its own cells while offline sends nothing: the reconnect republishes each cell’s latest state, so peers see only where it ended up.
stateDiagram-v2
accTitle: A peer's cell as one client sees it
accDescr {
A peer's cell is live once published. If the peer's connection closes, the
server removes the cell and it leaves the view. If this client reconnects,
or the server wakes and sends doc:resync, the cell is marked stale. The
peer's republish, or a fresh snapshot, makes it live again. If the grace
window elapses first, the cell is hidden.
}
[*] --> Live: peer publishes its cell
Live --> [*]: peer's connection closes<br/><small>server sends a cell remove</small>
Live --> Stale: this client reconnects,<br/>or the server sends doc:resync
Stale --> Live: peer republishes,<br/>or a fresh doc:init lists it
Stale --> [*]: grace window elapses<br/><small>presenceGraceMs, 10 seconds by default</small>
Bounded by construction
Section titled “Bounded by construction”Presence is a hot, fan-out-heavy channel, so it is capped: each cell’s JSON
state is bounded (max_presence_state_bytes, 4 KiB), keeping the per-keystroke
traffic small. Larger transient payloads don’t go in the cell at all — they use
the ephemeral Yjs lane below.
Two reserved lanes
Section titled “Two reserved lanes”Most of a cell’s state is opaque app data, but two reserved fields turn presence into transport for richer collaboration.
Awareness — collaborative cursors
Section titled “Awareness — collaborative cursors”The sys:awareness field carries y-protocols
awareness states, keyed by yjsId. A bridge publishes each Y.Doc’s local
awareness there (merged with the app’s own fields) and injects peers’ entries
into a real Awareness instance, so editor bindings like y-prosemirror’s cursor
plugin plug in directly — no second wire protocol. The numeric awareness client
ids editors see are allocated client-locally per presenceId and never cross
the wire. This is how rich-text editors get live
carets, including staged rich text
whose cursors ride each session’s own cell.
You never declare sys:awareness in your presence schema — datadata composes it
in (an optional map of opaque cursor state) wherever your schemas are registered,
so the whole cell validates against one schema and the field shows up in schema
introspection exactly as the server enforces it.
Ephemeral Y.Docs — streaming transient text
Section titled “Ephemeral Y.Docs — streaming transient text”A presence schema may declare a Yjs reference field, letting a cell own an
ephemeral Y.Doc that lives only in server memory — never stored, never
logged, reaped when the cell goes. The motivating case is an LLM response
streamed token by token: peers watch it arrive live, but the half-generated text
is worthless once the turn ends. Folding each token as a Yjs delta is a few
bytes, where re-sending the whole growing string as a JSON patch every token is
O(n²) on the wire and would blow the cell’s size cap. The lane is single-writer
(a cell may only stream into Y.Docs it references), bounded by
max_presence_yjs_update_bytes (1 MiB) per delta, and survives the author’s
reconnect — on a hibernation wake the owner re-sends the Y.Doc’s full state.
When the stream finishes, the author writes the final text to a durable
document and clears the cell. See
Rich text with Yjs for the durable counterpart.