Architecture
Journal-based CRDT with edit buffering, snapshot compaction, and synced undo/redo.
State Model
graph
Current computed state. Derived from snapshot + journal + edits. Updated on every change.
journal
Committed messages. Each entry tracks acknowledgement status and deletion (undo) state.
edits
Uncommitted operations. Accumulated during a gesture (e.g., drag), then committed as one message.
snapshot
Periodic checkpoint. Bakes in acknowledged journal entries for fast replay.
Actions
1. Edit (Uncommitted)
Add operation to edit buffer and update graph immediately:
2. Commit Edits
Compact edit buffer into one message, add to journal, send to server:
3. Server Acknowledgement
When server confirms receipt:
4. Remote Message
When receiving edits from other clients:
5. Compaction
Bake acknowledged entries into snapshot:
Rebuild Graph
Always derived from snapshot + journal + edits:
Undo / Redo
Undo and redo are synced messages using meta.undo and meta.redo operations.
They set/clear deletedAt on target messages and sync across all clients.
Meta Operations
Undo
Redo
Synced Undo Flow
Key Insight: Undo/redo are regular messages that sync across all clients. Everyone sees the same undo state. No separate undo stack needed.
Idempotency
Deduplication by message ID prevents double-application:
| Operation | Idempotent? | Reason |
|---|---|---|
*.set | Yes | Compares lamportTime, same result on replay |
*.add | With dedup | Requires message ID check to prevent double-add |
meta.undo | Yes | Sets deletedAt, idempotent |
meta.redo | Yes | Clears deletedAt, idempotent |
Design Decisions
Why edit buffer?
Gestures like dragging generate many operations per second. The edit buffer merges them into one message on commit, reducing journal size and network traffic.
Why synced undo?
Local-only undo creates divergent state. By making undo a message, all clients see the same undo history and converge to the same state.
Why deletedAt instead of removal?
Soft delete allows redo. The message stays in journal until compaction, when deleted entries are garbage collected.
Why rebuild on remote message?
Remote messages may arrive out of order. Rebuilding from snapshot ensures consistent state regardless of arrival order.
References and Hydration
Nodes can reference other nodes using the $ref pattern. This is a data convention, not a CRDT operation—references are resolved during hydration in user space.
The $ref Pattern
Hydration
During hydration, resolve $ref objects to actual node references:
Children vs References
| Aspect | children: string[] | { $ref: 'key' } |
|---|---|---|
| Purpose | Structural hierarchy | Pointer/association |
| Ownership | Parent owns children | No ownership |
| Deletion | Cascade/tombstone children | Just removes pointer |
| Storage | Array of keys | Object with $ref |
| Usage | Scene graph traversal | Shared resources, links |