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:
- State Write (
db.saveNode()) - Updates the current state for immediate UI reads - 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
// 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
| Store | Purpose | Consumer |
|---|---|---|
nodes (State) | What the data IS right now | UI components for rendering |
syncQueue (Deltas) | What CHANGED and when | Sync manager for backend updates |
2. Optimized Network Payloads
The delta only contains the minimal changeset, not the entire entity:
// ❌ 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:
// 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 sentRelated Patterns
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
persistence.ts- Dual-write implementationdb.ts- IndexedDB operationssync-manager.ts- Delta processing & sync
Summary
The dual-write pattern (saveNode + saveDelta) is intentional, not redundant. It separates:
- What the UI needs NOW → Full state for immediate reads
- 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.