diff --git a/__tests__/lib/sync-conflict-resolver.test.ts b/__tests__/lib/sync-conflict-resolver.test.ts new file mode 100644 index 0000000..84eef43 --- /dev/null +++ b/__tests__/lib/sync-conflict-resolver.test.ts @@ -0,0 +1,75 @@ +import { resolveConflict } from '@/lib/sync-conflict-resolver' +import { BibleHighlight } from '@/types' + +describe('SyncConflictResolver', () => { + it('should prefer server version if newer', () => { + const clientVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'yellow', + createdAt: 1000, + updatedAt: 1000, + syncStatus: 'pending' + } + + const serverVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'blue', + createdAt: 1000, + updatedAt: 2000, // newer + syncStatus: 'synced' + } + + const result = resolveConflict(clientVersion, serverVersion) + expect(result.color).toBe('blue') + expect(result.updatedAt).toBe(2000) + }) + + it('should prefer client version if newer', () => { + const clientVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'blue', + createdAt: 1000, + updatedAt: 3000, // newer + syncStatus: 'pending' + } + + const serverVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'yellow', + createdAt: 1000, + updatedAt: 2000, + syncStatus: 'synced' + } + + const result = resolveConflict(clientVersion, serverVersion) + expect(result.color).toBe('blue') + expect(result.updatedAt).toBe(3000) + }) + + it('should mark as synced after resolution', () => { + const clientVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'yellow', + createdAt: 1000, + updatedAt: 2000, + syncStatus: 'pending' + } + + const serverVersion: BibleHighlight = { + id: 'h-1', + verseId: 'v-1', + color: 'yellow', + createdAt: 1000, + updatedAt: 2000, + syncStatus: 'synced' + } + + const result = resolveConflict(clientVersion, serverVersion) + expect(result.syncStatus).toBe('synced') + }) +}) diff --git a/lib/sync-conflict-resolver.ts b/lib/sync-conflict-resolver.ts new file mode 100644 index 0000000..a995db9 --- /dev/null +++ b/lib/sync-conflict-resolver.ts @@ -0,0 +1,58 @@ +import { BibleHighlight } from '@/types' + +/** + * Resolves conflicts between client and server versions of a highlight. + * Uses timestamp-based "last write wins" strategy. + */ +export function resolveConflict( + clientVersion: BibleHighlight, + serverVersion: BibleHighlight +): BibleHighlight { + // Use timestamp to determine which version is newer + const isServerNewer = serverVersion.updatedAt > clientVersion.updatedAt + + // Take the newer version and mark as synced + const resolvedVersion = isServerNewer ? serverVersion : clientVersion + + return { + ...resolvedVersion, + syncStatus: 'synced' as const + } +} + +/** + * Merges server highlights with client highlights. + * - Adds new highlights from server + * - Updates existing highlights if server version is newer + * - Keeps client highlights if client version is newer + */ +export function mergeHighlights( + clientHighlights: BibleHighlight[], + serverHighlights: BibleHighlight[] +): BibleHighlight[] { + const clientMap = new Map(clientHighlights.map(h => [h.id, h])) + const serverMap = new Map(serverHighlights.map(h => [h.id, h])) + + const merged = new Map() + + // Add all client highlights, resolving conflicts with server + for (const [id, clientH] of clientMap) { + const serverH = serverMap.get(id) + if (serverH) { + // Conflict: both have this highlight + merged.set(id, resolveConflict(clientH, serverH)) + } else { + // No conflict: only client has it + merged.set(id, clientH) + } + } + + // Add any server highlights not in client + for (const [id, serverH] of serverMap) { + if (!clientMap.has(id)) { + merged.set(id, { ...serverH, syncStatus: 'synced' as const }) + } + } + + return Array.from(merged.values()) +}