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

97 lines
4.8 KiB
Markdown

# 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
```mermaid
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 |