feat: implement sync conflict resolver with timestamp-based merging
Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
75
__tests__/lib/sync-conflict-resolver.test.ts
Normal file
75
__tests__/lib/sync-conflict-resolver.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
58
lib/sync-conflict-resolver.ts
Normal file
58
lib/sync-conflict-resolver.ts
Normal file
@@ -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<string, BibleHighlight>()
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user