Skip to content

Event Sourcing & Dual-Write Pattern ​

Overview ​

The mindmap.build persistence layer uses an Event Sourcing pattern combined with a Dual-Write strategy. Every mutation operation performs two distinct writes:

  1. State Write (db.saveNode()) - Updates the current state for immediate UI reads
  2. Event Write (db.saveDelta()) - Appends a change event for eventual backend sync

This separation is a deliberate architectural decision that enables offline-first sync, conflict resolution, and network efficiency.

The Pattern in Code ​

typescript
// From persistence.ts - persistNodeUpdate()
export async function persistNodeUpdate(node: NodeData, updates: Partial<NodeData>): Promise<void> {
  // 1. Save the FULL node state (for UI reads)
  await db.saveNode(node)

  // 2. Save the DELTA/event (for backend sync)
  await db.saveDelta({
    type: 'NODE_COALESCED_UPDATE',
    nodeId: node.id,
    updates: { x: updates.x, y: updates.y, ... },
    timestamp: Date.now(),
  })
}

Architecture Diagram ​

Why Two Separate Writes? ​

1. Separation of Concerns ​

StorePurposeConsumer
nodes (State)What the data IS right nowUI components for rendering
syncQueue (Deltas)What CHANGED and whenSync manager for backend updates

2. Optimized Network Payloads ​

The delta only contains the minimal changeset, not the entire entity:

typescript
// ❌ Without deltas: Send entire node (wasteful)
POST /api/nodes/123
{ id: "123", text: "Hello", x: 100, y: 200, color: "blue", parentId: "root", ... }

// ✅ With deltas: Send only what changed (efficient)
POST /api/sync
{ type: "NODE_COALESCED_UPDATE", nodeId: "123", updates: { x: 100, y: 200 } }

3. Offline-First Capability ​

  • Immediate UI Response: saveNode() updates local state instantly
  • Queue for Later: saveDelta() queues changes when offline
  • Batch Sync: When online, deltas are batched and sent to the backend

4. Conflict Resolution & Audit Trail ​

Deltas with timestamps enable:

  • Last-Write-Wins: Compare timestamps during merge
  • Three-Way Merge: Compare original, local changes, and remote changes
  • Audit Trail: Debug sync issues by inspecting the delta history

5. Change Coalescing ​

Multiple rapid updates (e.g., dragging a node) can be coalesced:

typescript
// 50 drag events become 1 sync operation
Delta 1: { updates: { x: 101, y: 200 } }
Delta 2: { updates: { x: 105, y: 202 } }  // Coalesce with Delta 1
...
Final:   { updates: { x: 450, y: 380 } }  // Only this is sent

Outbox Pattern ​

The syncQueue (deltas) store acts as an Outbox—a queue of pending operations to be sent to the backend. This is a common pattern in distributed systems for reliable message delivery.

CQRS (Command Query Responsibility Segregation) ​

Though not a full CQRS implementation, this pattern shares the philosophy:

  • Commands (deltas) represent user intentions
  • Queries (reading nodes) return current state
  • The two are stored and optimized separately

Event Sourcing (Lite) ​

Traditional event sourcing derives state by replaying all events. Our implementation is a "lite" version:

  • We store both current state and events
  • State is updated directly (not derived from events)
  • Events are primarily for sync, not state reconstitution

File References ​

Summary ​

The dual-write pattern (saveNode + saveDelta) is intentional, not redundant. It separates:

  1. What the UI needs NOW → Full state for immediate reads
  2. What the backend needs LATER → Minimal deltas for efficient sync

This enables a robust offline-first experience with optimized network usage and conflict resolution capabilities.