Architecture
Journal-based CRDT with edit buffering, snapshot compaction, and synced undo/redo.
State Model
interface ClientState {
// 1. Current computed value (derived from snapshot + journal + edits)
graph: SceneGraph;
// 2. Committed edits (sent or pending acknowledgement)
journal: JournalEntry[];
// 3. Uncommitted operations (in-progress edits)
edits: EditBuffer;
// 4. Checkpoint for fast replay
snapshot: Snapshot;
// 5. Clocks
lamportTime: number;
vectorClock: VectorClock;
sessionId: string;
}
interface JournalEntry {
msg: CRDTMessage;
ack: boolean; // Has server acknowledged?
deletedAt?: number; // If set, message is "undone"
}
interface EditBuffer {
ops: Operation[]; // Pending operations (merged)
startGraph: SceneGraph; // Graph state when edits started
}
interface Snapshot {
graph: SceneGraph;
vectorClock: VectorClock;
journalIndex: number; // How many entries are baked in
}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:
function onEdit(state: ClientState, op: Operation): ClientState {
return produce(state, draft => {
// Save start graph for undo (first edit only)
if (draft.edits.ops.length === 0) {
draft.edits.startGraph = draft.graph;
}
// Merge into edit buffer
mergeOp(draft.edits, op);
// Apply to graph immediately (optimistic)
draft.graph = applyOperation(draft.graph, op);
});
}
// Merge additive ops, replace LWW ops
function mergeOp(buffer: EditBuffer, op: Operation): void {
const key = `${op.key}:${op.path}`;
const existing = buffer.ops.find(o => `${o.key}:${o.path}` === key);
if (existing && isAdditive(op.otype)) {
// Merge: vector3.add [1,0,0] + [0,2,0] = [1,2,0]
existing.value = addValues(existing.value, op.value);
} else {
buffer.ops.push(op);
}
}2. Commit Edits
Compact edit buffer into one message, add to journal, send to server:
Edit buffer: [add [1,0,0], add [0,2,0], set color]
│
▼
Compact into 1 message
│
▼
┌─────────────────────────────────────────┐
│ msg: { ops: [add [1,2,0], set color] } │
│ ack: false │
│ deletedAt: undefined │
└─────────────────────────────────────────┘
│
▼
Send to serverfunction commitEdits(state: ClientState): { state: ClientState; msg: CRDTMessage | null } {
if (state.edits.ops.length === 0) {
return { state, msg: null };
}
const msg: CRDTMessage = {
id: generateUUID(),
sessionId: state.sessionId,
clock: incrementClock(state.vectorClock, state.sessionId),
lamportTime: state.lamportTime + 1,
timestamp: Date.now(),
ops: state.edits.ops,
};
const newState = produce(state, draft => {
// Add to journal (unacknowledged)
draft.journal.push({ msg, ack: false });
// Clear edit buffer
draft.edits = { ops: [], startGraph: draft.graph };
// Update clocks
draft.lamportTime = msg.lamportTime;
draft.vectorClock = msg.clock;
});
return { state: newState, msg };
}3. Server Acknowledgement
When server confirms receipt:
function onServerAck(state: ClientState, msgId: string): ClientState {
return produce(state, draft => {
const entry = draft.journal.find(e => e.msg.id === msgId);
if (entry) entry.ack = true;
});
}4. Remote Message
When receiving edits from other clients:
function onRemoteMessage(state: ClientState, msg: CRDTMessage): ClientState {
// Skip duplicates
if (state.journal.some(e => e.msg.id === msg.id)) {
return state;
}
return produce(state, draft => {
// Add to journal (already acked - came from server)
draft.journal.push({ msg, ack: true });
// Process meta ops (undo/redo)
for (const op of msg.ops) {
if (op.otype === 'meta.undo') {
const target = draft.journal.find(e => e.msg.id === op.targetMsgId);
if (target) target.deletedAt = msg.timestamp;
} else if (op.otype === 'meta.redo') {
const target = draft.journal.find(e => e.msg.id === op.targetMsgId);
if (target) delete target.deletedAt;
}
}
// Merge clocks
draft.vectorClock = mergeClock(draft.vectorClock, msg.clock);
draft.lamportTime = Math.max(draft.lamportTime, msg.lamportTime);
// Rebuild graph
draft.graph = rebuildGraph(draft.snapshot, draft.journal, draft.edits.ops);
});
}5. Compaction
Bake acknowledged entries into snapshot:
function compact(state: ClientState): ClientState {
const lastAckedIdx = state.journal.findLastIndex(e => e.ack);
if (lastAckedIdx < 0) return state;
return produce(state, draft => {
// Build new snapshot (skip deleted entries)
let snapshotGraph = draft.snapshot.graph;
for (let i = 0; i <= lastAckedIdx; i++) {
const entry = draft.journal[i];
if (entry.deletedAt) continue;
snapshotGraph = applyMessage(snapshotGraph, entry.msg);
}
draft.snapshot = {
graph: snapshotGraph,
vectorClock: draft.journal[lastAckedIdx].msg.clock,
journalIndex: draft.snapshot.journalIndex + lastAckedIdx + 1,
};
// Remove compacted entries
draft.journal = draft.journal.slice(lastAckedIdx + 1);
});
}Rebuild Graph
Always derived from snapshot + journal + edits:
function rebuildGraph(
snapshot: Snapshot,
journal: JournalEntry[],
pendingOps: Operation[]
): SceneGraph {
let graph = snapshot.graph;
// Apply journal (skip deleted entries and meta ops)
for (const entry of journal) {
if (entry.deletedAt) continue;
const realOps = entry.msg.ops.filter(op => !op.otype.startsWith('meta.'));
if (realOps.length > 0) {
graph = applyMessage(graph, { ...entry.msg, ops: realOps });
}
}
// Apply pending edits
for (const op of pendingOps) {
graph = applyOperation(graph, op);
}
return graph;
}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
interface UndoOp {
otype: 'meta.undo';
key: '_meta';
path: '_meta';
targetMsgId: string;
}
interface RedoOp {
otype: 'meta.redo';
key: '_meta';
path: '_meta';
targetMsgId: string;
}Undo
function undo(state: ClientState): { state: ClientState; msg: CRDTMessage | null } {
let currentState = state;
let targetMsgId: string;
// If edit buffer not empty, commit first then mark as deleted
if (state.edits.ops.length > 0) {
const { state: committed, msg } = commitEdits(state);
currentState = committed;
targetMsgId = msg!.id;
} else {
// Find last non-deleted message from this session
const lastActive = [...currentState.journal]
.reverse()
.find(e => !e.deletedAt && e.msg.sessionId === currentState.sessionId);
if (!lastActive) return { state: currentState, msg: null };
targetMsgId = lastActive.msg.id;
}
// Create undo message
const undoMsg: CRDTMessage = {
id: generateUUID(),
sessionId: currentState.sessionId,
clock: incrementClock(currentState.vectorClock, currentState.sessionId),
lamportTime: currentState.lamportTime + 1,
timestamp: Date.now(),
ops: [{ otype: 'meta.undo', key: '_meta', path: '_meta', targetMsgId }],
};
// Apply locally and return message to send
return {
state: applyMetaMessage(currentState, undoMsg),
msg: undoMsg
};
}Redo
function redo(state: ClientState): { state: ClientState; msg: CRDTMessage | null } {
// Find last deleted message from this session
const lastDeleted = [...state.journal]
.reverse()
.find(e => e.deletedAt && e.msg.sessionId === state.sessionId);
if (!lastDeleted) return { state, msg: null };
const redoMsg: CRDTMessage = {
id: generateUUID(),
sessionId: state.sessionId,
clock: incrementClock(state.vectorClock, state.sessionId),
lamportTime: state.lamportTime + 1,
timestamp: Date.now(),
ops: [{ otype: 'meta.redo', key: '_meta', path: '_meta', targetMsgId: lastDeleted.msg.id }],
};
return {
state: applyMetaMessage(state, redoMsg),
msg: redoMsg
};
}Synced Undo Flow
┌──────────────────────────────────────────────────────────────┐ │ SYNCED UNDO FLOW │ ├──────────────────────────────────────────────────────────────┤ │ │ │ Alice ──▶ edit cube position │ │ ──▶ commit (msg-1) │ │ ──▶ undo() │ │ │ │ │ ▼ │ │ Create meta.undo message (target: msg-1) │ │ │ │ │ ├──▶ Apply locally: msg-1.deletedAt = now │ │ │ Rebuild graph (skips msg-1) │ │ │ │ │ └──▶ Send to server │ │ │ │ │ ▼ │ │ Server receives meta.undo │ │ Marks msg-1.deletedAt │ │ Broadcasts to all clients │ │ │ │ │ ▼ │ │ Bob receives meta.undo ──▶ msg-1.deletedAt = now │ │ ──▶ Rebuild graph │ │ ──▶ Cube position reverts │ │ │ └──────────────────────────────────────────────────────────────┘
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.