Skip to content

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.

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.

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.

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.

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.

Most of a cell’s state is opaque app data, but two reserved fields turn presence into transport for richer collaboration.

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.