dorfteich/docs/architecture/realtime-collaboration.md
Claude Fable 5 b16d23297e Scaffold pnpm monorepo with lint, format, and test tooling
pnpm workspace with apps/web, apps/api, apps/collab, and
packages/shared; strict TypeScript base config, repo-wide ESLint (flat)
+ Prettier, Vitest per package, and root scripts lint/typecheck/test/
build. @dorfteich/shared ships a first health-response helper consumed
by apps/api to prove workspace linking. Existing markdown docs are
reformatted once by the new Prettier setup.

Closes #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:06:27 +02:00

4.8 KiB

Real-time collaboration

How live editing, cursors, offline work, and persistence fit together. Foundational decisions: ADR 0003 (Yjs + Hocuspocus), ADR 0004 (TipTap), ADR 0013 (versions).

Components

sequenceDiagram
    participant E as Editor (TipTap + Yjs)
    participant I as IndexedDB (y-indexeddb)
    participant A as api (REST)
    participant C as collab (Hocuspocus)
    participant P as PostgreSQL

    E->>A: GET /pages/:id/collab-token
    A->>A: resolve permissions (shared lib)
    A-->>E: JWT {userId, pageId, mode: rw|ro, ttl 60s}
    E->>C: WebSocket connect (token)
    C->>C: onAuthenticate: verify JWT
    C->>P: onLoadDocument: state + updates
    C-->>E: initial sync (Yjs protocol)
    E->>I: persist locally (continuous)
    E->>C: updates + awareness (cursors)
    C-->>E: other participants' updates/awareness
    C->>P: onStoreDocument (debounced)
    C->>P: refresh page_content_cache, page_links

Document lifecycle

  1. Open: client fetches a collab token from the API (permission check happens here), connects to /collab with it. Read-only users connect in ro mode: they receive updates and awareness but the server drops any update they send.
  2. Edit: Yjs syncs deltas both ways; TipTap renders remote changes; the collaboration-cursor extension renders remote cursors/selections with each participant's display name and a stable per-user color.
  3. Persist: Hocuspocus stores the merged state to PostgreSQL, debounced (default 2 s after last change, hard interval 30 s). The same hook refreshes page_content_cache (plain text, Markdown, HTML, outline) and the page_links wikilink index — search and backlinks are therefore near-real-time.
  4. Close: when the last participant disconnects, the server persists finally and triggers an automatic version snapshot if content changed (ADR 0013).

Offline behavior

  • y-indexeddb keeps every opened page's document local; the PWA service worker keeps the app shell loadable. A user can open previously-visited pages and edit without a connection.
  • On reconnect, Yjs's sync protocol exchanges state vectors; local and remote changes merge conflict-free. There is deliberately no conflict UI — CRDT semantics decide; version history is the safety net for surprising merges.
  • Permission changes vs. offline edits: tokens are re-acquired on every reconnect. If write permission was revoked while offline, the reconnect yields an ro token; the client keeps the local changes visible, informs the user ("your edit permission was removed — export your changes"), and offers copy/Markdown export of the local version. Local state for a page is discarded when the user leaves it after a successful sync.
  • Creating new pages offline is out of scope for v1 (requires the REST API); editing existing ones is the offline use case.

Awareness (cursors & presence)

  • Awareness state per participant: user id, display name, color, cursor anchor/head. The editor UI shows remote carets inline and an avatar strip of current participants at the top of the page.
  • Awareness is ephemeral (never persisted). Read-only participants appear in presence but without a caret.

Server operations

  • The collab server holds open documents in memory; maxDocuments and per-connection message size limits guard resources. It is safe to restart at any time — clients resync from IndexedDB + server state.
  • Update-log compaction (merge page_updates into ydoc_state) runs as a maintenance job when a page has > N log entries (default 500) and no open session. Version snapshots are self-contained, so compaction is invisible to history (ADR 0013).
  • Scaling note: one collab instance serves the target scale (ADR 0002). The sharding/pub-sub upgrade path (multiple instances + Redis adapter) is documented here as the known escape hatch and requires no data-model change.

Failure modes

Failure Behavior
collab container down editing degrades to offline mode (local persistence); banner "reconnecting…"; REST reads unaffected
WebSocket blocked (proxy) same as above; deployment docs require WebSocket pass-through for /collab
stale collab token client transparently re-fetches and reconnects
document too large server rejects updates beyond a size ceiling (instance setting) with a user-visible error; prevents runaway documents