skills/local-first/references/conflict-resolution.md

502 lines
14 KiB
Markdown

---
title: Conflict Resolution
description: Conflict resolution strategies for concurrent edits in local-first apps including LWW, CRDTs, server-wins, field-level merge, and event sourcing
tags:
[
conflict,
lww,
crdt,
yjs,
automerge,
server-wins,
field-merge,
event-sourcing,
convergence,
]
---
## Overview Table
| Strategy | Complexity | Data Loss Risk | Best Use Case | Implementation Effort |
| ----------------- | ---------- | -------------- | -------------------------------- | --------------------- |
| Last-Write-Wins | Low | Medium | Independent fields, settings | Minimal |
| CRDTs | High | None | Collaborative text, shared lists | Significant |
| Server-Wins | Low | Low | Business-critical data | Low |
| Field-Level Merge | Medium | Low | Form data, record editing | Medium |
| Event Sourcing | High | None | Audit trails, undo/redo | Significant |
## Last-Write-Wins (LWW)
Simplest conflict resolution. When two clients edit the same record, the last write (by timestamp) wins. Earlier writes are silently discarded.
### Wall-Clock Implementation
```ts
type LWWRecord<T> = {
value: T;
updatedAt: number;
};
function lwwMerge<T>(local: LWWRecord<T>, remote: LWWRecord<T>): LWWRecord<T> {
if (remote.updatedAt > local.updatedAt) {
return remote;
}
return local;
}
// Usage
const localTodo: LWWRecord<Todo> = {
value: { id: '1', title: 'Buy milk', completed: true },
updatedAt: Date.now(),
};
const remoteTodo: LWWRecord<Todo> = {
value: { id: '1', title: 'Buy oat milk', completed: false },
updatedAt: Date.now() + 100,
};
const resolved = lwwMerge(localTodo, remoteTodo);
// Remote wins because it has a later timestamp
```
**Wall-clock problem:** Client clocks can drift. A client with a clock set 5 minutes ahead will always win.
### Logical Clock (Lamport Timestamp)
Use a logical clock to avoid wall-clock drift issues. Each operation increments a counter.
```ts
type LamportClock = {
counter: number;
nodeId: string;
};
function compareClock(a: LamportClock, b: LamportClock): number {
if (a.counter !== b.counter) {
return a.counter - b.counter;
}
return a.nodeId.localeCompare(b.nodeId);
}
function incrementClock(clock: LamportClock): LamportClock {
return { ...clock, counter: clock.counter + 1 };
}
function mergeClock(local: LamportClock, remote: LamportClock): LamportClock {
return {
counter: Math.max(local.counter, remote.counter) + 1,
nodeId: local.nodeId,
};
}
type LWWValue<T> = {
value: T;
clock: LamportClock;
};
function lwwMergeLogical<T>(
local: LWWValue<T>,
remote: LWWValue<T>,
): LWWValue<T> {
if (compareClock(remote.clock, local.clock) > 0) {
return remote;
}
return local;
}
```
**When to use LWW:** Settings, preferences, status fields, any field where the latest value is always correct.
**When to avoid LWW:** Collaborative text editing, counters, lists where concurrent additions should merge.
## CRDTs (Conflict-Free Replicated Data Types)
Data structures that mathematically guarantee convergence. Any two replicas that have seen the same set of operations will have the same state, regardless of order.
### Counter CRDTs
**G-Counter (grow-only):** Each node tracks its own count. Total is the sum of all nodes.
```ts
type GCounter = Record<string, number>;
function increment(counter: GCounter, nodeId: string): GCounter {
return {
...counter,
[nodeId]: (counter[nodeId] ?? 0) + 1,
};
}
function value(counter: GCounter): number {
return Object.values(counter).reduce((sum, n) => sum + n, 0);
}
function merge(a: GCounter, b: GCounter): GCounter {
const result: GCounter = { ...a };
for (const [node, count] of Object.entries(b)) {
result[node] = Math.max(result[node] ?? 0, count);
}
return result;
}
```
**PN-Counter (add and subtract):** Two G-Counters — one for increments, one for decrements.
```ts
type PNCounter = {
positive: GCounter;
negative: GCounter;
};
function pnIncrement(counter: PNCounter, nodeId: string): PNCounter {
return { ...counter, positive: increment(counter.positive, nodeId) };
}
function pnDecrement(counter: PNCounter, nodeId: string): PNCounter {
return { ...counter, negative: increment(counter.negative, nodeId) };
}
function pnValue(counter: PNCounter): number {
return value(counter.positive) - value(counter.negative);
}
function pnMerge(a: PNCounter, b: PNCounter): PNCounter {
return {
positive: merge(a.positive, b.positive),
negative: merge(a.negative, b.negative),
};
}
```
### Yjs (Collaborative Text and Data)
Yjs is a high-performance CRDT implementation for collaborative editing. Supports text, arrays, maps, and XML.
```ts
import * as Y from 'yjs';
const doc = new Y.Doc();
// Shared text
const ytext = doc.getText('document');
ytext.insert(0, 'Hello, world!');
// Observe changes
ytext.observe((event) => {
console.log('Text changed:', ytext.toString());
});
// Shared map (like a record)
const ymap = doc.getMap<string>('todo');
ymap.set('title', 'Buy groceries');
ymap.set('completed', 'false');
// Shared array (like a list)
const yarray = doc.getArray<string>('tags');
yarray.push(['urgent']);
yarray.insert(0, ['important']);
```
Sync between peers:
```ts
import * as Y from 'yjs';
function syncDocuments(local: Y.Doc, remote: Y.Doc): void {
const localState = Y.encodeStateVector(local);
const remoteState = Y.encodeStateVector(remote);
const localUpdate = Y.encodeStateAsUpdate(local, remoteState);
const remoteUpdate = Y.encodeStateAsUpdate(remote, localState);
Y.applyUpdate(local, remoteUpdate);
Y.applyUpdate(remote, localUpdate);
}
```
With a WebSocket provider:
```ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://your-server.com',
'document-room-id',
doc,
);
provider.on('status', ({ status }: { status: string }) => {
console.log('Connection status:', status);
});
const ytext = doc.getText('content');
```
### Automerge
Document-based CRDT library. Tracks changes as a history of operations, enabling merge, undo, and time travel.
```ts
import * as Automerge from '@automerge/automerge';
type TodoDoc = {
todos: Array<{ id: string; title: string; completed: boolean }>;
};
let doc = Automerge.init<TodoDoc>();
doc = Automerge.change(doc, (d) => {
d.todos = [];
});
doc = Automerge.change(doc, (d) => {
d.todos.push({ id: '1', title: 'Buy milk', completed: false });
});
// Fork for offline editing
let fork = Automerge.clone(doc);
doc = Automerge.change(doc, (d) => {
d.todos[0].title = 'Buy oat milk';
});
fork = Automerge.change(fork, (d) => {
d.todos[0].completed = true;
});
// Merge: both changes are preserved
doc = Automerge.merge(doc, fork);
// Result: { id: '1', title: 'Buy oat milk', completed: true }
```
**When to use CRDTs:** Collaborative text editing, shared lists where concurrent additions should merge, counters, any data where losing concurrent edits is unacceptable.
## Server-Wins
Client sends writes to the server. The server resolves any conflicts authoritatively. The resolved state syncs back to the client, overwriting local state.
This is the model used by ElectricSQL: reads sync from the server via Shapes, writes go through your API, and the server's state is the final truth.
```ts
// Client: write through API, let sync update local state
async function updateTodo(id: string, updates: Partial<Todo>): Promise<void> {
db.mutate.todos.update({
where: { id },
set: updates,
});
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
console.error('Server rejected update, sync will correct local state');
}
}
```
Server-side conflict resolution:
```ts
// Server: resolve conflicts with business logic
async function handleTodoUpdate(
id: string,
clientUpdates: Partial<Todo>,
_clientTimestamp: number,
): Promise<Todo> {
const current = await db.query('SELECT * FROM todos WHERE id = $1', [id]);
const resolved: Partial<Todo> = {};
if (clientUpdates.title !== undefined) {
resolved.title = clientUpdates.title;
}
// Server-authoritative: completed status follows business rules
if (clientUpdates.completed !== undefined) {
const canComplete = await validateCompletion(id);
resolved.completed = canComplete
? clientUpdates.completed
: current.completed;
}
const result = await db.query(
'UPDATE todos SET title = COALESCE($1, title), completed = COALESCE($2, completed) WHERE id = $3 RETURNING *',
[resolved.title, resolved.completed, id],
);
return result.rows[0];
}
```
**When to use server-wins:** Payment processing, inventory management, approval workflows, any data where business rules must be enforced authoritatively.
## Field-Level Merge
Instead of resolving conflicts at the record level, merge at individual field granularity. If two clients edit different fields of the same record, both edits are preserved.
```ts
type FieldTimestamp = Record<string, number>;
type MergeableRecord<T> = {
value: T;
fieldTimestamps: FieldTimestamp;
};
function fieldMerge<T extends Record<string, unknown>>(
local: MergeableRecord<T>,
remote: MergeableRecord<T>,
): MergeableRecord<T> {
const merged = { ...local.value };
const timestamps = { ...local.fieldTimestamps };
for (const key of Object.keys(remote.value)) {
const remoteTs = remote.fieldTimestamps[key] ?? 0;
const localTs = local.fieldTimestamps[key] ?? 0;
if (remoteTs > localTs) {
(merged as Record<string, unknown>)[key] = remote.value[key];
timestamps[key] = remoteTs;
}
}
return { value: merged as T, fieldTimestamps: timestamps };
}
// Client A edits title at t=100
const clientA: MergeableRecord<Todo> = {
value: { id: '1', title: 'Updated title', completed: false },
fieldTimestamps: { title: 100, completed: 50 },
};
// Client B edits completed at t=110
const clientB: MergeableRecord<Todo> = {
value: { id: '1', title: 'Original title', completed: true },
fieldTimestamps: { title: 50, completed: 110 },
};
const result = fieldMerge(clientA, clientB);
// Result: { title: 'Updated title', completed: true }
// Both edits preserved because they touched different fields
```
**When to use field-level merge:** Form data where different users edit different fields, profile updates, settings where fields are independent.
## Event Sourcing
Store events (facts about what happened) rather than current state. Derive current state by replaying events. Enables audit trails, undo/redo, and time-travel debugging.
### Event Store Pattern
```ts
type TodoEvent =
| { type: 'TodoCreated'; id: string; title: string; timestamp: number }
| { type: 'TodoCompleted'; id: string; timestamp: number }
| { type: 'TodoRenamed'; id: string; title: string; timestamp: number }
| { type: 'TodoDeleted'; id: string; timestamp: number };
const eventLog: TodoEvent[] = [];
function appendEvent(event: TodoEvent): void {
eventLog.push(event);
}
function getEventsSince(timestamp: number): TodoEvent[] {
return eventLog.filter((e) => e.timestamp > timestamp);
}
```
### Projection Function
Derive current state from events:
```ts
type TodoState = Map<string, Todo>;
function projectTodos(events: TodoEvent[]): TodoState {
const state: TodoState = new Map();
for (const event of events) {
const existing = state.get(event.id);
switch (event.type) {
case 'TodoCreated':
state.set(event.id, {
id: event.id,
title: event.title,
completed: false,
createdAt: event.timestamp,
});
break;
case 'TodoCompleted':
if (existing) state.set(event.id, { ...existing, completed: true });
break;
case 'TodoRenamed':
if (existing) state.set(event.id, { ...existing, title: event.title });
break;
case 'TodoDeleted':
state.delete(event.id);
break;
}
}
return state;
}
```
### Sync and Undo via Event Exchange
```ts
function mergeEventLogs(local: TodoEvent[], remote: TodoEvent[]): TodoEvent[] {
const seen = new Set(local.map((e) => `${e.type}-${e.timestamp}-${e.id}`));
const newEvents = remote.filter(
(e) => !seen.has(`${e.type}-${e.timestamp}-${e.id}`),
);
return [...local, ...newEvents].sort((a, b) => a.timestamp - b.timestamp);
}
function undo(events: TodoEvent[]): TodoState {
return projectTodos(events.slice(0, -1));
}
```
**When to use event sourcing:** Audit trails (compliance, finance), undo/redo, time-travel debugging, or when change history matters as much as current state.
## Choosing a Strategy
| Data Type | Recommended Strategy | Rationale |
| ----------------------- | ---------------------- | -------------------------------------------------- |
| User preferences | LWW | Last setting is always correct |
| Status fields | LWW or server-wins | Simple, low conflict surface |
| Form fields | Field-level merge | Different users edit different fields |
| Collaborative text | CRDTs (Yjs, Automerge) | Concurrent edits must merge character-by-character |
| Shared lists | CRDTs (OR-Set) | Concurrent additions should all be preserved |
| Counters (likes, votes) | CRDTs (PN-Counter) | Concurrent increments must not be lost |
| Payment amounts | Server-wins | Business rules must be enforced |
| Inventory quantities | Server-wins | Consistency is more important than availability |
| Document history | Event sourcing | Need audit trail and undo |
## Hybrid Approach
Use different strategies for different fields in the same record:
```ts
const todoFieldStrategies: Record<string, string> = {
title: 'lww',
description: 'crdt',
completed: 'server-wins',
tags: 'crdt',
priority: 'lww',
assigneeId: 'server-wins',
};
function resolveField<T>(field: string, local: T, remote: T): T {
const strategy = todoFieldStrategies[field] ?? 'lww';
if (strategy === 'server-wins') return remote;
return local;
}
```
Simple fields use LWW. Business-critical fields use server-wins. Collaborative content uses CRDTs.