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.
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. 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 schemaValidation 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.
Liveness and the grace window
Section titled “Liveness and the grace window”A participant who drops shouldn’t blink out instantly on every transient
reconnect. Each peer in the view carries a stale flag rather than simply
vanishing: when a connection drops, its cells are marked stale and held through
the configurable presence_grace window (10 seconds by default) before removal,
and a reconnecting client republishes its cells to clear the flag. The UI can dim a stale collaborator and
restore them on reconnect instead of flickering.
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.