GitHub

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 server
function 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:

OperationIdempotent?Reason
*.setYesCompares lamportTime, same result on replay
*.addWith dedupRequires message ID check to prevent double-add
meta.undoYesSets deletedAt, idempotent
meta.redoYesClears 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.