import 'package:synchronize/src/action.dart'; import 'package:synchronize/src/conflict.dart'; import 'package:synchronize/src/journal.dart'; import 'package:synchronize/src/journal_entry.dart'; import 'package:synchronize/src/object.dart'; import 'package:synchronize/src/sources.dart'; /// Sync between two [SyncSources]s. /// /// This implementation follows https://unterwaditzer.net/2016/sync-algorithm.html in a generic and abstract way /// and should work for any two kinds of sources and objects. Future>> sync( final SyncSources sources, final SyncJournal journal, { final Map? conflictSolutions, final bool keepSkipsAsConflicts = false, }) async { final diff = await computeSyncDiff( sources, journal, conflictSolutions: conflictSolutions, keepSkipsAsConflicts: keepSkipsAsConflicts, ); await executeSyncDiff( sources, journal, diff, ); return diff.conflicts; } /// Differences between the two sources. class SyncDiff { /// Creates a new diff. SyncDiff( this.actions, this.conflicts, ); /// Actions required to solve the difference. final List> actions; /// Conflicts without solutions that need to be solved. final List> conflicts; } /// Executes the actions required to solve the difference. Future executeSyncDiff( final SyncSources sources, final SyncJournal journal, final SyncDiff diff, ) async { for (final action in diff.actions) { switch (action) { case SyncActionDeleteFromA(): await sources.sourceA.deleteObject(action.object as SyncObject); journal.entries.removeWhere((final entry) => entry.id == action.object.id); case SyncActionDeleteFromB(): await sources.sourceB.deleteObject(action.object as SyncObject); journal.entries.removeWhere((final entry) => entry.id == action.object.id); case SyncActionWriteToA(): final objectA = await sources.sourceA.writeObject(action.object as SyncObject); journal.updateEntry( SyncJournalEntry( action.object.id, await sources.sourceA.getObjectETag(objectA), await sources.sourceB.getObjectETag(action.object as SyncObject), ), ); case SyncActionWriteToB(): final objectB = await sources.sourceB.writeObject(action.object as SyncObject); journal.updateEntry( SyncJournalEntry( action.object.id, await sources.sourceA.getObjectETag(action.object as SyncObject), await sources.sourceB.getObjectETag(objectB), ), ); } } } /// Computes the difference, useful for displaying if a sync is up to date. Future> computeSyncDiff( final SyncSources sources, final SyncJournal journal, { final Map? conflictSolutions, final bool keepSkipsAsConflicts = false, }) async { final actions = >[]; final conflicts = >{}; var objectsA = await sources.sourceA.listObjects(); var objectsB = await sources.sourceB.listObjects(); for (final objectA in objectsA) { final objectB = objectsB.tryFind(objectA.id); final journalEntry = journal.entries.tryFind(objectA.id); // If the ID exists on side A and the journal, but not on B, it has been deleted on B. Delete it from A and the journal. if (journalEntry != null && objectB == null) { actions.add(SyncActionDeleteFromA(objectA)); continue; } // If the ID exists on side A and side B, but not in journal, we can not just create it in journal, since the two items might contain different content each. if (objectB != null && journalEntry == null) { conflicts.add( SyncConflict( id: objectA.id, type: SyncConflictType.bothNew, objectA: objectA, objectB: objectB, ), ); continue; } // If the ID exists on side A, but not on B or the journal, it must have been created on A. Copy the item from A to B and also insert it into journal. if (objectB == null || journalEntry == null) { actions.add(SyncActionWriteToB(objectA)); continue; } } for (final objectB in objectsB) { final objectA = objectsA.tryFind(objectB.id); final journalEntry = journal.entries.tryFind(objectB.id); // If the ID exists on side B and the journal, but not on A, it has been deleted on A. Delete it from B and the journal. if (journalEntry != null && objectA == null) { actions.add(SyncActionDeleteFromB(objectB)); continue; } // If the ID exists on side B and side A, but not in journal, we can not just create it in journal, since the two items might contain different content each. if (objectA != null && journalEntry == null) { conflicts.add( SyncConflict( id: objectA.id, type: SyncConflictType.bothNew, objectA: objectA, objectB: objectB, ), ); continue; } // If the ID exists on side B, but not on A or the journal, it must have been created on B. Copy the item from B to A and also insert it into journal. if (objectA == null || journalEntry == null) { actions.add(SyncActionWriteToA(objectB)); continue; } } objectsA = await sources.sourceA.listObjects(); objectsB = await sources.sourceB.listObjects(); final entries = journal.entries.toList(); for (final entry in entries) { final objectA = objectsA.tryFind(entry.id); final objectB = objectsB.tryFind(entry.id); // Remove all entries from journal that don't exist anymore if (objectA == null && objectB == null) { journal.entries.removeWhere((final e) => e.id == entry.id); continue; } if (objectA != null && objectB != null) { final changedA = entry.etagA != await sources.sourceA.getObjectETag(objectA); final changedB = entry.etagB != await sources.sourceB.getObjectETag(objectB); if (changedA && changedB) { conflicts.add( SyncConflict( id: objectA.id, type: SyncConflictType.bothChanged, objectA: objectA, objectB: objectB, ), ); continue; } if (changedA && !changedB) { actions.add(SyncActionWriteToB(objectA)); continue; } if (changedB && !changedA) { actions.add(SyncActionWriteToA(objectB)); continue; } } } final unsolvedConflicts = >[]; for (final conflict in conflicts) { final solution = conflictSolutions?[conflict.id] ?? sources.findSolution(conflict.objectA, conflict.objectB); switch (solution) { case SyncConflictSolution.overwriteA: actions.add(SyncActionWriteToA(conflict.objectB)); case SyncConflictSolution.overwriteB: actions.add(SyncActionWriteToB(conflict.objectA)); case SyncConflictSolution.skip: if (keepSkipsAsConflicts) { unsolvedConflicts.add( SyncConflict( id: conflict.id, type: conflict.type, objectA: conflict.objectA, objectB: conflict.objectB, skipped: true, ), ); } case null: unsolvedConflicts.add(conflict); } } return SyncDiff( _sortActions(actions), unsolvedConflicts, ); } List> _sortActions(final List> actions) { final addActions = >[]; final removeActions = >[]; for (final action in actions) { switch (action) { case SyncActionWriteToA(): addActions.add(action); case SyncActionWriteToB(): addActions.add(action); case SyncActionDeleteFromA(): removeActions.add(action); case SyncActionDeleteFromB(): removeActions.add(action); } } return _innerSortActions(addActions)..addAll(_innerSortActions(removeActions).reversed); } List> _innerSortActions(final List> actions) => actions..sort((final a, final b) => a.object.id.compareTo(b.object.id));