Skip to content

Event Sourcing & Dual-Write Pattern

Overview

The Kemma 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.