Better Rich Text Buy licence

0004 Collaboration

On this page
  1. Decision
  2. Why not a Yjs CRDT as the canonical model
  3. Adding a Yjs interop adapter later
  4. Undo
  5. Presence
  6. Comments
  7. Packages
  8. Engine changes
  9. Limits

Real-time collaboration is opt-in. Several people edit one document, see each other's selections and carets, and discuss text in anchored comment threads. The server is the only writer: clients send semantic commands, the server rebases and executes them, and every replica applies the resulting operations in one order.

Decision

A server-authoritative operation log with command replay, not a CRDT, is the canonical model.

  • Commands, not state. A client submits { id, command, input, selection, follows?, storedMarks,
    baseRevision }. id is the idempotency key, namespaced by the authenticated actor (<actorTag>.<session>.<counter>). The actor comes from the connection's authentication, never from the message.

  • Rebase on the server. The authority maps the selection and every TextPoint inside the input from baseRevision to its head through the MapSteps recorded for each transaction committed since, then runs the command in a headless editor with the same plugins as the clients. When follows names the actor's previous command, the selection is the one that command produced (exact for continuous typing).

  • Durable before visible. The resulting { revision, actorId, commandId, operations, steps,
    selectionAfter } is appended to the log; only then is it acknowledged and broadcast. A retry with the same id returns the recorded result. Snapshots are taken periodically and the log is compacted behind the retained rebase window.

  • Typed refusals. stale-target (the target was removed), conflict (exact-base commands after someone else's change, undo over a peer's edit), permission-denied, rebase-window-expired (base older than the retained log), invalid-input, unknown-command. A delete whose whole target is already gone is an acknowledged no-op. Text another actor inserted inside a range being deleted or replaced survives: only the observed segments are removed.

  • Command replay on the client. The client shows replay(A, P): the acknowledged state A plus its pending commands P, applied optimistically. When entries arrive it inverts its pending operations, applies the authority's operations (origin remote, never in local history), and replays the remaining commands. Bookmarks move through the same steps, so selections, carets and the commands themselves land where the user intended. When the authority confirms exactly what was predicted, nothing is re-applied.

  • Same ids everywhere. Node ids allocated while executing a command derive from its id (<commandId>.<n>) on every replica, so optimistic content keeps its identity after confirmation, later commands can refer to it, and a client cannot claim ids outside its namespace.

  • Outbox. Pending commands, the visible document and the acknowledged revision persist (memory or IndexedDB). On reconnect the client catches up from its last revision and resends unsent work with the original ids; after a reload the outbox restores what the user saw.

  • Handshake. Protocol version and schema hash are checked. A client whose schema differs is read-only. Missing revisions are detected and requested; duplicates are ignored.

Why not a Yjs CRDT as the canonical model

The engine already expresses every change as invertible, validated operations against a schema-normalised tree with stable ids (ADR 0001). A CRDT would add a second document model with its own structural rules, and convergence alone is not the goal:

  • Schema invariants, normalisation and plugin rules (tables, lists, limits) must hold after every merge. A CRDT merges structure without running them, so merged states can be invalid and each replica would have to repair them identically. The authority runs the real commands and their normalisation, so every accepted state is one the editor itself produced.

  • Permissions are enforced on intent. A server can check "comment-only actors may not change text" on a command; deciding the same from arbitrary CRDT updates means re-deriving intent from bytes.

  • Intent-preserving rebasing (insertions ordered at a gap, observed-segment deletion, undo that refuses to overwrite peers) is expressed directly in command terms.

  • Snapshots stay the native JSON envelope. There is no tombstone growth or second persistence format, and the log doubles as an audit trail.

The cost is that edits need the authority to become final; offline work is pending until the connection returns and may be refused (it is then kept for recovery, never silently dropped).

Adding a Yjs interop adapter later

Interop belongs at the edge, translating in both directions without making Yjs the source of truth:

  • Outbound: subscribe to committed log entries and apply each entry's operations to a Y.Doc that mirrors the native tree (one Y.Map per node keyed by NodeId, one Y.Text per text leaf, child lists as Y.Arrays of ids). Operations map one to one: insertText/deleteText to Y.Text edits, splitText/joinText to moving a text suffix between leaves, node operations to Y.Array edits, setAnnotation to a Y.Map of annotations.

  • Inbound: observe the Y.Doc for changes made by Yjs-native peers, convert each transaction into collab.applyOperations (or a semantic command where one is recognisable) and submit it as an actor through the authority like any other client. The authority validates and normalises it; the resulting entry flows back out through the outbound path.

  • Awareness maps to presence (selections become TextPoint bookmarks through the leaf-id mapping).

Undo

Undo is per actor. history.undo submits collab.undo naming the actor's own last transaction (typing runs are grouped). Its inverse is mapped through everything committed later, and each step checks a precondition first: text to remove must still read as typed, attributes and marks must still hold the values the actor set, inserted nodes must be unchanged. If a peer has changed the same content the undo stops with conflict and the interface says why, instead of overwriting the peer. Positions collapsed by later deletions are recovered exactly when the same undo restores that text, so restored characters keep their order.

Presence

Presence (name, colour, selection, focus) is a separate, rate-limited channel. Name and colour are assigned by the server from the authenticated identity; presence never grants anything. Remote selections are painted through the view.decorations filter with the CSS Custom Highlight API (one highlight name per peer) and carets are positioned overlay elements outside the content DOM, placed from DOM ranges, with name labels. Peers' selections are mapped through every transaction between updates. Following a peer keeps their caret in view.

Comments

Threads are document annotations (comment-thread) with a range anchor, the quoted excerpt, workflow state (open/resolved), anchor state (attached/orphaned), authors and replies. They change through commands (comment.create, reply, resolve, reopen, reattach, editMessage, deleteMessage, delete) that go through the authority like any edit; authorship and times come from the authenticated actor. Anchors are mapped through every non-verbatim transaction in transaction.beforeCommit; when all anchored text disappears the thread is orphaned, keeps its excerpt and can be re-attached to a new selection without changing whether it is resolved. Because the anchor updates are operations of the same transaction, the authority's log carries them and every replica agrees.

Packages

  • collab-protocol: versioned messages with runtime validators, rebasing helpers, the collab plugin (collab.undo, collab.applyOperations, collab.replaceDocument), the environment-neutral DocumentAuthority and AuthorityRoom (handshake, authentication, serial processing, persistence before broadcast, presence, backpressure).

  • collab-server: Node WebSocket server (ws), pluggable authentication and storage (memory and file system: atomic snapshots plus a synced NDJSON log), lazy room loading, idle unload, heartbeat.

  • collab-client: CollabClient, WebSocket, in-memory and BroadcastChannel transports, an embedded LocalAuthority, outbox stores, PresenceLayer and CollaboratorsBar.

  • plugin-comments: comment model, commands, anchor mapping, decorations, review panel and the comment toolbar item.

Engine changes

Kept to what collaboration and annotations need:

  • TransactionEvent.steps exposes the mapping steps of each transaction.

  • The transaction.ids filter lets a plugin supply the node id allocator for a transaction.

  • setAnnotation is a new invertible operation; drafts carry annotations and commits store them; setDocument with an envelope replaces annotations too.

  • VERBATIM_META marks drafts that replay exact recorded operations (undo/redo, remote entries) so derived effects are not applied twice.

  • A history.delegate service can take over history.undo and history.redo.

  • replaceNode accepts a structurally equal copy of the node (operations received over the network).

  • The view's DOM reconciliation and in-editor drag-move run as named commands (text.replace, content.move) so they can be replayed and rebased.

Limits

  • Formatting a range while someone types inside it formats the concurrent text too.

  • Undo of structural changes whose surroundings were restructured by peers (unwrap, container joins, deletions in a reordered parent) is best-effort and may report a conflict.

  • Commands whose effect depends on client-local state (search and replace) are sent as exact operations and need an unchanged base.

  • Rejections are remembered in memory only; committed command ids survive restarts through the log and snapshot.

  • One process owns a document; multi-instance deployments need a routing layer and a fenced lease in front of the storage adapter.