/** * Conflict Resolution Module * * Provides strategies for detecting and resolving conflicts during sync. */ import type { BaseRecord, Conflict, ConflictType, ConflictStrategy, ConflictResolver, } from '../types' /** * Input for conflict detection */ export interface ConflictDetectionInput { collectionId: string recordId: string | number localValue?: Partial remoteValue?: Partial baseValue?: T localTimestamp: number remoteTimestamp: number } /** * Detect if a conflict exists between local and remote changes */ export function detectConflict( input: ConflictDetectionInput ): Conflict | null { const { localValue, remoteValue, baseValue, localTimestamp, remoteTimestamp } = input // Determine conflict type let type: ConflictType | null = null if (!baseValue) { // Both are inserts if (localValue && remoteValue) { type = 'insert-insert' } } else if (!localValue && remoteValue) { // Local deleted, remote updated type = 'delete-update' } else if (localValue && !remoteValue) { // Local updated, remote deleted type = 'update-delete' } else if (localValue && remoteValue) { // Both updated - check if same fields modified const localKeys = Object.keys(localValue) const remoteKeys = Object.keys(remoteValue) const conflictingKeys = localKeys.filter(k => remoteKeys.includes(k)) // Check if any conflicting fields have different values const hasConflict = conflictingKeys.some(k => { const localVal = (localValue as Record)[k] const remoteVal = (remoteValue as Record)[k] const baseVal = (baseValue as Record)?.[k] // Conflict if both changed from base and to different values return localVal !== baseVal && remoteVal !== baseVal && localVal !== remoteVal }) if (hasConflict) { type = 'update-update' } } if (!type) return null const conflict: Conflict = { type, collectionId: input.collectionId, recordId: input.recordId, localTimestamp, remoteTimestamp, } if (localValue !== undefined) { conflict.localValue = localValue } if (remoteValue !== undefined) { conflict.remoteValue = remoteValue } if (baseValue !== undefined) { conflict.baseValue = baseValue } return conflict } /** * Built-in conflict resolution strategies */ export const strategies = { /** * Local changes always win */ async localWins(conflict: Conflict): Promise { if (!conflict.localValue) return null const base = conflict.baseValue || {} as T return { ...base, ...conflict.localValue } as T }, /** * Remote changes always win */ async remoteWins(conflict: Conflict): Promise { if (!conflict.remoteValue) return null const base = conflict.baseValue || {} as T return { ...base, ...conflict.remoteValue } as T }, /** * Most recent timestamp wins */ async latestWins(conflict: Conflict): Promise { if (conflict.localTimestamp > conflict.remoteTimestamp) { return strategies.localWins(conflict) } return strategies.remoteWins(conflict) }, /** * Merge non-conflicting fields, use latest for conflicts */ async merge(conflict: Conflict): Promise { const base = conflict.baseValue || {} as T const local = conflict.localValue || {} const remote = conflict.remoteValue || {} // Start with base const result = { ...base } // Get all modified keys const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]) for (const key of allKeys) { const localVal = (local as Record)[key] const remoteVal = (remote as Record)[key] const baseVal = (base as Record)[key] const localChanged = localVal !== undefined && localVal !== baseVal const remoteChanged = remoteVal !== undefined && remoteVal !== baseVal if (localChanged && remoteChanged) { // Both changed - use latest if (conflict.localTimestamp > conflict.remoteTimestamp) { (result as Record)[key] = localVal } else { (result as Record)[key] = remoteVal } } else if (localChanged) { (result as Record)[key] = localVal } else if (remoteChanged) { (result as Record)[key] = remoteVal } } return result as T }, } /** * Resolve a conflict using a strategy or custom resolver */ export async function resolveConflict( conflict: Conflict, strategyOrResolver: ConflictStrategy | ConflictResolver ): Promise { if (typeof strategyOrResolver === 'function') { return strategyOrResolver(conflict) } switch (strategyOrResolver) { case 'local-wins': return strategies.localWins(conflict) case 'remote-wins': return strategies.remoteWins(conflict) case 'latest-wins': return strategies.latestWins(conflict) case 'merge': return strategies.merge(conflict) case 'manual': return null } } /** * Create a conflict resolver function from a strategy */ export function createConflictResolver( strategy: ConflictStrategy ): ConflictResolver { return (conflict: Conflict) => resolveConflict(conflict, strategy) } /** * Builder for creating custom conflict resolvers with multiple rules */ export class ConflictResolverBuilder { private _defaultStrategy: ConflictStrategy = 'latest-wins' private conflictTypeStrategies: Map> = new Map() private recordHandlers: Map> = new Map() /** * Set the default strategy for unmatched conflicts */ defaultStrategy(strategy: ConflictStrategy): this { this._defaultStrategy = strategy return this } /** * Set strategy for a specific conflict type */ forConflictType( type: ConflictType, strategyOrResolver: ConflictStrategy | ConflictResolver ): this { this.conflictTypeStrategies.set(type, strategyOrResolver) return this } /** * Set custom handler for a specific record ID */ forRecord(recordId: string | number, handler: ConflictResolver): this { this.recordHandlers.set(recordId, handler) return this } /** * Build the resolver function */ build(): ConflictResolver { return async (conflict: Conflict): Promise => { // Check for record-specific handler first const recordHandler = this.recordHandlers.get(conflict.recordId) if (recordHandler) { return recordHandler(conflict) } // Check for conflict type specific strategy const typeStrategy = this.conflictTypeStrategies.get(conflict.type) if (typeStrategy) { return resolveConflict(conflict, typeStrategy) } // Fall back to default strategy return resolveConflict(conflict, this._defaultStrategy) } } }