17 changed files with 1115 additions and 0 deletions
			
			
		@ -0,0 +1,3 @@
					 | 
				
			||||
# synchronize | 
				
			||||
 | 
				
			||||
A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html | 
				
			||||
@ -0,0 +1 @@
					 | 
				
			||||
include: package:neon_lints/dart.yaml | 
				
			||||
@ -0,0 +1,60 @@
					 | 
				
			||||
import 'package:meta/meta.dart'; | 
				
			||||
import 'package:synchronize/src/object.dart'; | 
				
			||||
 | 
				
			||||
/// Action to be executed in the sync process. | 
				
			||||
@internal | 
				
			||||
@immutable | 
				
			||||
sealed class SyncAction<T> { | 
				
			||||
  /// Creates a new action. | 
				
			||||
  const SyncAction(this.object); | 
				
			||||
 | 
				
			||||
  /// The object that is part of the action. | 
				
			||||
  final SyncObject<T> object; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncAction<$T>(object: $object)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Action to delete on object from A. | 
				
			||||
@internal | 
				
			||||
@immutable | 
				
			||||
interface class SyncActionDeleteFromA<T1, T2> extends SyncAction<T1> { | 
				
			||||
  /// Creates a new action to delete an object from A. | 
				
			||||
  const SyncActionDeleteFromA(super.object); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncActionDeleteFromA<$T1, $T2>(object: $object)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Action to delete an object from B. | 
				
			||||
@internal | 
				
			||||
@immutable | 
				
			||||
interface class SyncActionDeleteFromB<T1, T2> extends SyncAction<T2> { | 
				
			||||
  /// Creates a new action to delete an object from B. | 
				
			||||
  const SyncActionDeleteFromB(super.object); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncActionDeleteFromB<$T1, $T2>(object: $object)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Action to write an object to A. | 
				
			||||
@internal | 
				
			||||
@immutable | 
				
			||||
interface class SyncActionWriteToA<T1, T2> extends SyncAction<T2> { | 
				
			||||
  /// Creates a new action to write an object to A. | 
				
			||||
  const SyncActionWriteToA(super.object); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncActionWriteToA<$T1, $T2>(object: $object)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Action to write an object to B. | 
				
			||||
@internal | 
				
			||||
@immutable | 
				
			||||
interface class SyncActionWriteToB<T1, T2> extends SyncAction<T1> { | 
				
			||||
  /// Creates a new action to write an object to B. | 
				
			||||
  const SyncActionWriteToB(super.object); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncActionWriteToB<$T1, $T2>(object: $object)'; | 
				
			||||
} | 
				
			||||
@ -0,0 +1,61 @@
					 | 
				
			||||
import 'package:meta/meta.dart'; | 
				
			||||
import 'package:synchronize/src/object.dart'; | 
				
			||||
 | 
				
			||||
/// Contains information about a conflict that appeared during sync. | 
				
			||||
@immutable | 
				
			||||
class SyncConflict<T1, T2> { | 
				
			||||
  /// Creates a new conflict. | 
				
			||||
  const SyncConflict({ | 
				
			||||
    required this.id, | 
				
			||||
    required this.type, | 
				
			||||
    required this.objectA, | 
				
			||||
    required this.objectB, | 
				
			||||
    this.skipped = false, | 
				
			||||
  }); | 
				
			||||
 | 
				
			||||
  /// Id of the objects involved in the conflict. | 
				
			||||
  final String id; | 
				
			||||
 | 
				
			||||
  /// Type of the conflict that appeared. See [SyncConflictType] for more info. | 
				
			||||
  final SyncConflictType type; | 
				
			||||
 | 
				
			||||
  /// Object A involved in the conflict. | 
				
			||||
  final SyncObject<T1> objectA; | 
				
			||||
 | 
				
			||||
  /// Object B involved in the conflict. | 
				
			||||
  final SyncObject<T2> objectB; | 
				
			||||
 | 
				
			||||
  /// Whether the conflict was skipped by the user, useful for ignoring it later on. | 
				
			||||
  final bool skipped; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  bool operator ==(final dynamic other) => other is SyncConflict && other.id == id; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  int get hashCode => id.hashCode; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => | 
				
			||||
      'SyncConflict<$T1, $T2>(id: $id, type: $type, objectA: $objectA, objectB: $objectB, skipped: $skipped)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Types of conflicts that can appear during sync. | 
				
			||||
enum SyncConflictType { | 
				
			||||
  /// New objects with the same id exist on both sides. | 
				
			||||
  bothNew, | 
				
			||||
 | 
				
			||||
  /// Both objects with the same id have changed. | 
				
			||||
  bothChanged, | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Ways to resolve [SyncConflict]s. | 
				
			||||
enum SyncConflictSolution { | 
				
			||||
  /// Overwrite the content of object A with the content of object B. | 
				
			||||
  overwriteA, | 
				
			||||
 | 
				
			||||
  /// Overwrite the content of object B with the content of object A. | 
				
			||||
  overwriteB, | 
				
			||||
 | 
				
			||||
  /// Skip the conflict and just do nothing. | 
				
			||||
  skip, | 
				
			||||
} | 
				
			||||
@ -0,0 +1,33 @@
					 | 
				
			||||
import 'package:json_annotation/json_annotation.dart'; | 
				
			||||
import 'package:synchronize/src/journal_entry.dart'; | 
				
			||||
 | 
				
			||||
part 'journal.g.dart'; | 
				
			||||
 | 
				
			||||
/// Contains the journal. | 
				
			||||
/// | 
				
			||||
/// Used for detecting changes and new or deleted files. | 
				
			||||
@JsonSerializable() | 
				
			||||
class SyncJournal { | 
				
			||||
  /// Creates a new journal. | 
				
			||||
  // Note: This must not be const as otherwise the entries are not modifiable when a const set is used! | 
				
			||||
  SyncJournal([final Set<SyncJournalEntry>? entries]) : entries = entries ?? {}; | 
				
			||||
 | 
				
			||||
  /// Deserializes a journal from [json]. | 
				
			||||
  factory SyncJournal.fromJson(final Map<String, dynamic> json) => _$SyncJournalFromJson(json); | 
				
			||||
 | 
				
			||||
  /// Serializes a journal to JSON. | 
				
			||||
  Map<String, dynamic> toJson() => _$SyncJournalToJson(this); | 
				
			||||
 | 
				
			||||
  /// All entries contained in the journal. | 
				
			||||
  final Set<SyncJournalEntry> entries; | 
				
			||||
 | 
				
			||||
  /// Updates an [entry]. | 
				
			||||
  void updateEntry(final SyncJournalEntry entry) { | 
				
			||||
    entries | 
				
			||||
      ..remove(entry) | 
				
			||||
      ..add(entry); | 
				
			||||
  } | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncJournal(entries: $entries)'; | 
				
			||||
} | 
				
			||||
@ -0,0 +1,15 @@
					 | 
				
			||||
// GENERATED CODE - DO NOT MODIFY BY HAND | 
				
			||||
 | 
				
			||||
part of 'journal.dart'; | 
				
			||||
 | 
				
			||||
// ************************************************************************** | 
				
			||||
// JsonSerializableGenerator | 
				
			||||
// ************************************************************************** | 
				
			||||
 | 
				
			||||
SyncJournal _$SyncJournalFromJson(Map<String, dynamic> json) => SyncJournal( | 
				
			||||
      (json['entries'] as List<dynamic>).map((e) => SyncJournalEntry.fromJson(e as Map<String, dynamic>)).toSet(), | 
				
			||||
    ); | 
				
			||||
 | 
				
			||||
Map<String, dynamic> _$SyncJournalToJson(SyncJournal instance) => <String, dynamic>{ | 
				
			||||
      'entries': instance.entries.toList(), | 
				
			||||
    }; | 
				
			||||
@ -0,0 +1,52 @@
					 | 
				
			||||
import 'package:collection/collection.dart'; | 
				
			||||
import 'package:json_annotation/json_annotation.dart'; | 
				
			||||
import 'package:meta/meta.dart'; | 
				
			||||
import 'package:synchronize/src/journal.dart'; | 
				
			||||
 | 
				
			||||
part 'journal_entry.g.dart'; | 
				
			||||
 | 
				
			||||
/// Stores a single entry in the [SyncJournal]. | 
				
			||||
/// | 
				
			||||
/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively. | 
				
			||||
@immutable | 
				
			||||
@JsonSerializable() | 
				
			||||
class SyncJournalEntry { | 
				
			||||
  /// Creates a new journal entry. | 
				
			||||
  const SyncJournalEntry( | 
				
			||||
    this.id, | 
				
			||||
    this.etagA, | 
				
			||||
    this.etagB, | 
				
			||||
  ); | 
				
			||||
 | 
				
			||||
  /// Deserializes a journal entry from [json]. | 
				
			||||
  factory SyncJournalEntry.fromJson(final Map<String, dynamic> json) => _$SyncJournalEntryFromJson(json); | 
				
			||||
 | 
				
			||||
  /// Serializes a journal entry to JSON. | 
				
			||||
  Map<String, dynamic> toJson() => _$SyncJournalEntryToJson(this); | 
				
			||||
 | 
				
			||||
  /// Unique ID of the journal entry. | 
				
			||||
  final String id; | 
				
			||||
 | 
				
			||||
  /// ETag of the object A. | 
				
			||||
  final String etagA; | 
				
			||||
 | 
				
			||||
  /// ETag of the object B. | 
				
			||||
  final String etagB; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  bool operator ==(final Object other) => other is SyncJournalEntry && other.id == id; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  int get hashCode => id.hashCode; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncJournalEntry(id: $id, etagA: $etagA, etagB: $etagB)'; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Extension to find a [SyncJournalEntry]. | 
				
			||||
extension SyncJournalEntriesFind on Iterable<SyncJournalEntry> { | 
				
			||||
  /// Finds the first [SyncJournalEntry] that has the [SyncJournalEntry.id] set to [id]. | 
				
			||||
  /// | 
				
			||||
  /// Returns `null` if no matching [SyncJournalEntry] was found. | 
				
			||||
  SyncJournalEntry? tryFind(final String id) => firstWhereOrNull((final entry) => entry.id == id); | 
				
			||||
} | 
				
			||||
@ -0,0 +1,19 @@
					 | 
				
			||||
// GENERATED CODE - DO NOT MODIFY BY HAND | 
				
			||||
 | 
				
			||||
part of 'journal_entry.dart'; | 
				
			||||
 | 
				
			||||
// ************************************************************************** | 
				
			||||
// JsonSerializableGenerator | 
				
			||||
// ************************************************************************** | 
				
			||||
 | 
				
			||||
SyncJournalEntry _$SyncJournalEntryFromJson(Map<String, dynamic> json) => SyncJournalEntry( | 
				
			||||
      json['id'] as String, | 
				
			||||
      json['etagA'] as String, | 
				
			||||
      json['etagB'] as String, | 
				
			||||
    ); | 
				
			||||
 | 
				
			||||
Map<String, dynamic> _$SyncJournalEntryToJson(SyncJournalEntry instance) => <String, dynamic>{ | 
				
			||||
      'id': instance.id, | 
				
			||||
      'etagA': instance.etagA, | 
				
			||||
      'etagB': instance.etagB, | 
				
			||||
    }; | 
				
			||||
@ -0,0 +1,12 @@
					 | 
				
			||||
import 'package:collection/collection.dart'; | 
				
			||||
 | 
				
			||||
/// Wraps the actual data contained on each side. | 
				
			||||
typedef SyncObject<T> = ({String id, T data}); | 
				
			||||
 | 
				
			||||
/// Extension to find a [SyncObject]. | 
				
			||||
extension SyncObjectsFind<T> on Iterable<SyncObject<T>> { | 
				
			||||
  /// Finds the first [SyncObject] that has the `id` set to [id]. | 
				
			||||
  /// | 
				
			||||
  /// Returns `null` if no matching [SyncObject] was found. | 
				
			||||
  SyncObject<T>? tryFind(final String id) => firstWhereOrNull((final object) => object.id == id); | 
				
			||||
} | 
				
			||||
@ -0,0 +1,39 @@
					 | 
				
			||||
import 'dart:async'; | 
				
			||||
 | 
				
			||||
import 'package:meta/meta.dart'; | 
				
			||||
import 'package:synchronize/src/conflict.dart'; | 
				
			||||
import 'package:synchronize/src/object.dart'; | 
				
			||||
 | 
				
			||||
/// The source the sync uses to sync from and to. | 
				
			||||
@immutable | 
				
			||||
abstract interface class SyncSource<T1, T2> { | 
				
			||||
  /// List all the objects. | 
				
			||||
  FutureOr<List<SyncObject<T1>>> listObjects(); | 
				
			||||
 | 
				
			||||
  /// Calculates the ETag of a given [object]. | 
				
			||||
  /// | 
				
			||||
  /// Must be something easy to compute like the mtime of a file and preferably not the hash of the whole content in order to be fast. | 
				
			||||
  FutureOr<String> getObjectETag(final SyncObject<T1> object); | 
				
			||||
 | 
				
			||||
  /// Writes the given [object]. | 
				
			||||
  FutureOr<SyncObject<T1>> writeObject(final SyncObject<T2> object); | 
				
			||||
 | 
				
			||||
  /// Deletes the given [object]. | 
				
			||||
  FutureOr<void> deleteObject(final SyncObject<T1> object); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// The sources the sync uses to sync from and to. | 
				
			||||
@immutable | 
				
			||||
abstract interface class SyncSources<T1, T2> { | 
				
			||||
  /// Source A. | 
				
			||||
  SyncSource<T1, T2> get sourceA; | 
				
			||||
 | 
				
			||||
  /// Source B. | 
				
			||||
  SyncSource<T2, T1> get sourceB; | 
				
			||||
 | 
				
			||||
  /// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories. | 
				
			||||
  SyncConflictSolution? findSolution(final SyncObject<T1> objectA, final SyncObject<T2> objectB); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)'; | 
				
			||||
} | 
				
			||||
@ -0,0 +1,246 @@
					 | 
				
			||||
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<List<SyncConflict<T1, T2>>> sync<T1, T2>( | 
				
			||||
  final SyncSources<T1, T2> sources, | 
				
			||||
  final SyncJournal journal, { | 
				
			||||
  final Map<String, SyncConflictSolution>? conflictSolutions, | 
				
			||||
  final bool keepSkipsAsConflicts = false, | 
				
			||||
}) async { | 
				
			||||
  final diff = await computeSyncDiff<T1, T2>( | 
				
			||||
    sources, | 
				
			||||
    journal, | 
				
			||||
    conflictSolutions: conflictSolutions, | 
				
			||||
    keepSkipsAsConflicts: keepSkipsAsConflicts, | 
				
			||||
  ); | 
				
			||||
  await executeSyncDiff<T1, T2>( | 
				
			||||
    sources, | 
				
			||||
    journal, | 
				
			||||
    diff, | 
				
			||||
  ); | 
				
			||||
  return diff.conflicts; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Differences between the two sources. | 
				
			||||
class SyncDiff<T1, T2> { | 
				
			||||
  /// Creates a new diff. | 
				
			||||
  SyncDiff( | 
				
			||||
    this.actions, | 
				
			||||
    this.conflicts, | 
				
			||||
  ); | 
				
			||||
 | 
				
			||||
  /// Actions required to solve the difference. | 
				
			||||
  final List<SyncAction<dynamic>> actions; | 
				
			||||
 | 
				
			||||
  /// Conflicts without solutions that need to be solved. | 
				
			||||
  final List<SyncConflict<T1, T2>> conflicts; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Executes the actions required to solve the difference. | 
				
			||||
Future<void> executeSyncDiff<T1, T2>( | 
				
			||||
  final SyncSources<T1, T2> sources, | 
				
			||||
  final SyncJournal journal, | 
				
			||||
  final SyncDiff<T1, T2> diff, | 
				
			||||
) async { | 
				
			||||
  for (final action in diff.actions) { | 
				
			||||
    switch (action) { | 
				
			||||
      case SyncActionDeleteFromA(): | 
				
			||||
        await sources.sourceA.deleteObject(action.object as SyncObject<T1>); | 
				
			||||
        journal.entries.removeWhere((final entry) => entry.id == action.object.id); | 
				
			||||
      case SyncActionDeleteFromB(): | 
				
			||||
        await sources.sourceB.deleteObject(action.object as SyncObject<T2>); | 
				
			||||
        journal.entries.removeWhere((final entry) => entry.id == action.object.id); | 
				
			||||
      case SyncActionWriteToA(): | 
				
			||||
        final objectA = await sources.sourceA.writeObject(action.object as SyncObject<T2>); | 
				
			||||
        journal.updateEntry( | 
				
			||||
          SyncJournalEntry( | 
				
			||||
            action.object.id, | 
				
			||||
            await sources.sourceA.getObjectETag(objectA), | 
				
			||||
            await sources.sourceB.getObjectETag(action.object as SyncObject<T2>), | 
				
			||||
          ), | 
				
			||||
        ); | 
				
			||||
      case SyncActionWriteToB(): | 
				
			||||
        final objectB = await sources.sourceB.writeObject(action.object as SyncObject<T1>); | 
				
			||||
        journal.updateEntry( | 
				
			||||
          SyncJournalEntry( | 
				
			||||
            action.object.id, | 
				
			||||
            await sources.sourceA.getObjectETag(action.object as SyncObject<T1>), | 
				
			||||
            await sources.sourceB.getObjectETag(objectB), | 
				
			||||
          ), | 
				
			||||
        ); | 
				
			||||
    } | 
				
			||||
  } | 
				
			||||
} | 
				
			||||
 | 
				
			||||
/// Computes the difference, useful for displaying if a sync is up to date. | 
				
			||||
Future<SyncDiff<T1, T2>> computeSyncDiff<T1, T2>( | 
				
			||||
  final SyncSources<T1, T2> sources, | 
				
			||||
  final SyncJournal journal, { | 
				
			||||
  final Map<String, SyncConflictSolution>? conflictSolutions, | 
				
			||||
  final bool keepSkipsAsConflicts = false, | 
				
			||||
}) async { | 
				
			||||
  final actions = <SyncAction<dynamic>>[]; | 
				
			||||
  final conflicts = <SyncConflict<T1, T2>>{}; | 
				
			||||
  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<T1, T2>(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<T1, T2>( | 
				
			||||
          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<T1, T2>(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<T1, T2>(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<T1, T2>( | 
				
			||||
          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<T1, T2>(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<T1, T2>( | 
				
			||||
            id: objectA.id, | 
				
			||||
            type: SyncConflictType.bothChanged, | 
				
			||||
            objectA: objectA, | 
				
			||||
            objectB: objectB, | 
				
			||||
          ), | 
				
			||||
        ); | 
				
			||||
        continue; | 
				
			||||
      } | 
				
			||||
 | 
				
			||||
      if (changedA && !changedB) { | 
				
			||||
        actions.add(SyncActionWriteToB<T1, T2>(objectA)); | 
				
			||||
        continue; | 
				
			||||
      } | 
				
			||||
 | 
				
			||||
      if (changedB && !changedA) { | 
				
			||||
        actions.add(SyncActionWriteToA<T1, T2>(objectB)); | 
				
			||||
        continue; | 
				
			||||
      } | 
				
			||||
    } | 
				
			||||
  } | 
				
			||||
 | 
				
			||||
  final unsolvedConflicts = <SyncConflict<T1, T2>>[]; | 
				
			||||
  for (final conflict in conflicts) { | 
				
			||||
    final solution = conflictSolutions?[conflict.id] ?? sources.findSolution(conflict.objectA, conflict.objectB); | 
				
			||||
    switch (solution) { | 
				
			||||
      case SyncConflictSolution.overwriteA: | 
				
			||||
        actions.add(SyncActionWriteToA<T1, T2>(conflict.objectB)); | 
				
			||||
      case SyncConflictSolution.overwriteB: | 
				
			||||
        actions.add(SyncActionWriteToB<T1, T2>(conflict.objectA)); | 
				
			||||
      case SyncConflictSolution.skip: | 
				
			||||
        if (keepSkipsAsConflicts) { | 
				
			||||
          unsolvedConflicts.add( | 
				
			||||
            SyncConflict<T1, T2>( | 
				
			||||
              id: conflict.id, | 
				
			||||
              type: conflict.type, | 
				
			||||
              objectA: conflict.objectA, | 
				
			||||
              objectB: conflict.objectB, | 
				
			||||
              skipped: true, | 
				
			||||
            ), | 
				
			||||
          ); | 
				
			||||
        } | 
				
			||||
      case null: | 
				
			||||
        unsolvedConflicts.add(conflict); | 
				
			||||
    } | 
				
			||||
  } | 
				
			||||
 | 
				
			||||
  return SyncDiff<T1, T2>( | 
				
			||||
    _sortActions(actions), | 
				
			||||
    unsolvedConflicts, | 
				
			||||
  ); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
List<SyncAction<dynamic>> _sortActions(final List<SyncAction<dynamic>> actions) { | 
				
			||||
  final addActions = <SyncAction<dynamic>>[]; | 
				
			||||
  final removeActions = <SyncAction<dynamic>>[]; | 
				
			||||
  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<SyncAction<dynamic>> _innerSortActions(final List<SyncAction<dynamic>> actions) => | 
				
			||||
    actions..sort((final a, final b) => a.object.id.compareTo(b.object.id)); | 
				
			||||
@ -0,0 +1,6 @@
					 | 
				
			||||
export 'package:synchronize/src/conflict.dart'; | 
				
			||||
export 'package:synchronize/src/journal.dart'; | 
				
			||||
export 'package:synchronize/src/journal_entry.dart'; | 
				
			||||
export 'package:synchronize/src/object.dart'; | 
				
			||||
export 'package:synchronize/src/sources.dart'; | 
				
			||||
export 'package:synchronize/src/sync.dart'; | 
				
			||||
@ -0,0 +1,20 @@
					 | 
				
			||||
name: synchronize | 
				
			||||
version: 1.0.0 | 
				
			||||
 | 
				
			||||
environment: | 
				
			||||
  sdk: '>=3.0.0 <4.0.0' | 
				
			||||
 | 
				
			||||
dependencies: | 
				
			||||
  collection: ^1.0.0 | 
				
			||||
  json_annotation: ^4.8.1 | 
				
			||||
  meta: ^1.0.0 | 
				
			||||
 | 
				
			||||
dev_dependencies: | 
				
			||||
  build_runner: ^2.4.6 | 
				
			||||
  crypto: ^3.0.0 | 
				
			||||
  json_serializable: ^6.7.1 | 
				
			||||
  neon_lints: | 
				
			||||
    git: | 
				
			||||
      url: https://github.com/nextcloud/neon | 
				
			||||
      path: packages/neon_lints | 
				
			||||
  test: ^1.24.9 | 
				
			||||
@ -0,0 +1,4 @@
					 | 
				
			||||
# melos_managed_dependency_overrides: neon_lints | 
				
			||||
dependency_overrides: | 
				
			||||
  neon_lints: | 
				
			||||
    path: ../neon_lints | 
				
			||||
@ -0,0 +1,542 @@
					 | 
				
			||||
import 'dart:convert'; | 
				
			||||
import 'dart:math'; | 
				
			||||
 | 
				
			||||
import 'package:crypto/crypto.dart'; | 
				
			||||
import 'package:synchronize/synchronize.dart'; | 
				
			||||
import 'package:test/test.dart'; | 
				
			||||
 | 
				
			||||
abstract class Wrap { | 
				
			||||
  Wrap(this.content); | 
				
			||||
 | 
				
			||||
  final String content; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class WrapA extends Wrap { | 
				
			||||
  WrapA(super.content); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class WrapB extends Wrap { | 
				
			||||
  WrapB(super.content); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class TestSyncState { | 
				
			||||
  TestSyncState( | 
				
			||||
    this.stateA, | 
				
			||||
    this.stateB, | 
				
			||||
  ); | 
				
			||||
 | 
				
			||||
  final Map<String, WrapA> stateA; | 
				
			||||
  final Map<String, WrapB> stateB; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class TestSyncSourceA implements SyncSource<WrapA, WrapB> { | 
				
			||||
  TestSyncSourceA(this.state); | 
				
			||||
 | 
				
			||||
  final Map<String, WrapA> state; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<List<SyncObject<WrapA>>> listObjects() async => | 
				
			||||
      state.keys.map((final key) => (id: key, data: state[key]!)).toList(); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<String> getObjectETag(final SyncObject<WrapA> object) async => etagA(object.data.content); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<SyncObject<WrapA>> writeObject(final SyncObject<WrapB> object) async { | 
				
			||||
    final wrap = WrapA(object.data.content); | 
				
			||||
    state[object.id] = wrap; | 
				
			||||
    return (id: object.id, data: wrap); | 
				
			||||
  } | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<void> deleteObject(final SyncObject<WrapA> object) async => state.remove(object.id); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class TestSyncSourceB implements SyncSource<WrapB, WrapA> { | 
				
			||||
  TestSyncSourceB(this.state); | 
				
			||||
 | 
				
			||||
  final Map<String, WrapB> state; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<List<SyncObject<WrapB>>> listObjects() async => | 
				
			||||
      state.keys.map((final key) => (id: key, data: state[key]!)).toList(); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<String> getObjectETag(final SyncObject<WrapB> object) async => etagB(object.data.content); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<SyncObject<WrapB>> writeObject(final SyncObject<WrapA> object) async { | 
				
			||||
    final wrap = WrapB(object.data.content); | 
				
			||||
    state[object.id] = wrap; | 
				
			||||
    return (id: object.id, data: wrap); | 
				
			||||
  } | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  Future<void> deleteObject(final SyncObject<WrapB> object) async => state.remove(object.id); | 
				
			||||
} | 
				
			||||
 | 
				
			||||
class TestSyncSources implements SyncSources<WrapA, WrapB> { | 
				
			||||
  TestSyncSources( | 
				
			||||
    this.sourceA, | 
				
			||||
    this.sourceB, | 
				
			||||
  ); | 
				
			||||
 | 
				
			||||
  factory TestSyncSources.fromState(final TestSyncState state) => TestSyncSources( | 
				
			||||
        TestSyncSourceA(state.stateA), | 
				
			||||
        TestSyncSourceB(state.stateB), | 
				
			||||
      ); | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  final SyncSource<WrapA, WrapB> sourceA; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  final SyncSource<WrapB, WrapA> sourceB; | 
				
			||||
 | 
				
			||||
  @override | 
				
			||||
  SyncConflictSolution? findSolution(final SyncObject<WrapA> objectA, final SyncObject<WrapB> objectB) => null; | 
				
			||||
} | 
				
			||||
 | 
				
			||||
String etagA(final String content) => sha1.convert(utf8.encode('A$content')).toString(); | 
				
			||||
 | 
				
			||||
String etagB(final String content) => sha1.convert(utf8.encode('B$content')).toString(); | 
				
			||||
 | 
				
			||||
String randomEtag() => sha1.convert(utf8.encode(Random().nextDouble().toString())).toString(); | 
				
			||||
 | 
				
			||||
Future<void> main() async { | 
				
			||||
  group('sync', () { | 
				
			||||
    group('stub', () { | 
				
			||||
      test('all empty', () async { | 
				
			||||
        final state = TestSyncState({}, {}); | 
				
			||||
        final sources = TestSyncSources.fromState(state); | 
				
			||||
        final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
        final conflicts = await sync(sources, journal); | 
				
			||||
        expect(conflicts, isEmpty); | 
				
			||||
        expect(state.stateA, isEmpty); | 
				
			||||
        expect(state.stateB, isEmpty); | 
				
			||||
        expect(journal.entries, isEmpty); | 
				
			||||
      }); | 
				
			||||
 | 
				
			||||
      group('copy', () { | 
				
			||||
        group('missing', () { | 
				
			||||
          test('to A', () async { | 
				
			||||
            const id = '123'; | 
				
			||||
            const content = '456'; | 
				
			||||
            final state = TestSyncState( | 
				
			||||
              {}, | 
				
			||||
              { | 
				
			||||
                id: WrapB(content), | 
				
			||||
              }, | 
				
			||||
            ); | 
				
			||||
            final sources = TestSyncSources.fromState(state); | 
				
			||||
            final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
            final conflicts = await sync(sources, journal); | 
				
			||||
            expect(conflicts, isEmpty); | 
				
			||||
            expect(state.stateA, hasLength(1)); | 
				
			||||
            expect(state.stateA[id]!.content, content); | 
				
			||||
            expect(state.stateB, hasLength(1)); | 
				
			||||
            expect(state.stateB[id]!.content, content); | 
				
			||||
            expect(journal.entries, hasLength(1)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagA, etagA(content)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagB, etagB(content)); | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          test('to B', () async { | 
				
			||||
            const id = '123'; | 
				
			||||
            const content = '456'; | 
				
			||||
            final state = TestSyncState( | 
				
			||||
              { | 
				
			||||
                id: WrapA(content), | 
				
			||||
              }, | 
				
			||||
              {}, | 
				
			||||
            ); | 
				
			||||
            final sources = TestSyncSources.fromState(state); | 
				
			||||
            final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
            final conflicts = await sync(sources, journal); | 
				
			||||
            expect(conflicts, isEmpty); | 
				
			||||
            expect(state.stateA, hasLength(1)); | 
				
			||||
            expect(state.stateA[id]!.content, content); | 
				
			||||
            expect(state.stateB, hasLength(1)); | 
				
			||||
            expect(state.stateB[id]!.content, content); | 
				
			||||
            expect(journal.entries, hasLength(1)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagA, etagA(content)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagB, etagB(content)); | 
				
			||||
          }); | 
				
			||||
        }); | 
				
			||||
 | 
				
			||||
        group('changed', () { | 
				
			||||
          test('to A', () async { | 
				
			||||
            const id = '123'; | 
				
			||||
            const contentA = '456'; | 
				
			||||
            const contentB = '789'; | 
				
			||||
            final state = TestSyncState( | 
				
			||||
              { | 
				
			||||
                id: WrapA(contentA), | 
				
			||||
              }, | 
				
			||||
              { | 
				
			||||
                id: WrapB(contentB), | 
				
			||||
              }, | 
				
			||||
            ); | 
				
			||||
            final sources = TestSyncSources.fromState(state); | 
				
			||||
            final journal = SyncJournal({ | 
				
			||||
              SyncJournalEntry(id, etagA(contentA), randomEtag()), | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            final conflicts = await sync(sources, journal); | 
				
			||||
            expect(conflicts, isEmpty); | 
				
			||||
            expect(state.stateA, hasLength(1)); | 
				
			||||
            expect(state.stateA[id]!.content, contentB); | 
				
			||||
            expect(state.stateB, hasLength(1)); | 
				
			||||
            expect(state.stateB[id]!.content, contentB); | 
				
			||||
            expect(journal.entries, hasLength(1)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          test('to B', () async { | 
				
			||||
            const id = '123'; | 
				
			||||
            const contentA = '456'; | 
				
			||||
            const contentB = '789'; | 
				
			||||
            final state = TestSyncState( | 
				
			||||
              { | 
				
			||||
                id: WrapA(contentA), | 
				
			||||
              }, | 
				
			||||
              { | 
				
			||||
                id: WrapB(contentB), | 
				
			||||
              }, | 
				
			||||
            ); | 
				
			||||
            final sources = TestSyncSources.fromState(state); | 
				
			||||
            final journal = SyncJournal({ | 
				
			||||
              SyncJournalEntry(id, randomEtag(), etagB(contentB)), | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            final conflicts = await sync(sources, journal); | 
				
			||||
            expect(conflicts, isEmpty); | 
				
			||||
            expect(state.stateA, hasLength(1)); | 
				
			||||
            expect(state.stateA[id]!.content, contentA); | 
				
			||||
            expect(state.stateB, hasLength(1)); | 
				
			||||
            expect(state.stateB[id]!.content, contentA); | 
				
			||||
            expect(journal.entries, hasLength(1)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); | 
				
			||||
            expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); | 
				
			||||
          }); | 
				
			||||
        }); | 
				
			||||
      }); | 
				
			||||
 | 
				
			||||
      group('delete', () { | 
				
			||||
        test('from A', () async { | 
				
			||||
          const id = '123'; | 
				
			||||
          const content = '456'; | 
				
			||||
          final state = TestSyncState( | 
				
			||||
            { | 
				
			||||
              id: WrapA(content), | 
				
			||||
            }, | 
				
			||||
            {}, | 
				
			||||
          ); | 
				
			||||
          final sources = TestSyncSources.fromState(state); | 
				
			||||
          final journal = SyncJournal({ | 
				
			||||
            SyncJournalEntry(id, etagA(content), etagB(content)), | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          final conflicts = await sync(sources, journal); | 
				
			||||
          expect(conflicts, isEmpty); | 
				
			||||
          expect(state.stateA, isEmpty); | 
				
			||||
          expect(state.stateB, isEmpty); | 
				
			||||
          expect(journal.entries, isEmpty); | 
				
			||||
        }); | 
				
			||||
 | 
				
			||||
        test('from B', () async { | 
				
			||||
          const id = '123'; | 
				
			||||
          const content = '456'; | 
				
			||||
          final state = TestSyncState( | 
				
			||||
            {}, | 
				
			||||
            { | 
				
			||||
              id: WrapB(content), | 
				
			||||
            }, | 
				
			||||
          ); | 
				
			||||
          final sources = TestSyncSources.fromState(state); | 
				
			||||
          final journal = SyncJournal({ | 
				
			||||
            SyncJournalEntry(id, etagA(content), etagB(content)), | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          final conflicts = await sync(sources, journal); | 
				
			||||
          expect(conflicts, isEmpty); | 
				
			||||
          expect(state.stateA, isEmpty); | 
				
			||||
          expect(state.stateB, isEmpty); | 
				
			||||
          expect(journal.entries, isEmpty); | 
				
			||||
        }); | 
				
			||||
 | 
				
			||||
        test('from journal', () async { | 
				
			||||
          const id = '123'; | 
				
			||||
          const content = '456'; | 
				
			||||
          final state = TestSyncState({}, {}); | 
				
			||||
          final sources = TestSyncSources.fromState(state); | 
				
			||||
          final journal = SyncJournal({ | 
				
			||||
            SyncJournalEntry(id, etagA(content), etagB(content)), | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          final conflicts = await sync(sources, journal); | 
				
			||||
          expect(conflicts, isEmpty); | 
				
			||||
          expect(state.stateA, isEmpty); | 
				
			||||
          expect(state.stateB, isEmpty); | 
				
			||||
          expect(journal.entries, isEmpty); | 
				
			||||
        }); | 
				
			||||
      }); | 
				
			||||
 | 
				
			||||
      group('conflict', () { | 
				
			||||
        test('journal missing', () async { | 
				
			||||
          const id = '123'; | 
				
			||||
          const content = '456'; | 
				
			||||
          final state = TestSyncState( | 
				
			||||
            { | 
				
			||||
              id: WrapA(content), | 
				
			||||
            }, | 
				
			||||
            { | 
				
			||||
              id: WrapB(content), | 
				
			||||
            }, | 
				
			||||
          ); | 
				
			||||
          final sources = TestSyncSources.fromState(state); | 
				
			||||
          final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
          final conflicts = await sync(sources, journal); | 
				
			||||
          expect(conflicts, hasLength(1)); | 
				
			||||
          expect(conflicts[0].type, SyncConflictType.bothNew); | 
				
			||||
          expect(state.stateA, hasLength(1)); | 
				
			||||
          expect(state.stateA[id]!.content, content); | 
				
			||||
          expect(state.stateB, hasLength(1)); | 
				
			||||
          expect(state.stateB[id]!.content, content); | 
				
			||||
          expect(journal.entries, isEmpty); | 
				
			||||
        }); | 
				
			||||
 | 
				
			||||
        test('both changed', () async { | 
				
			||||
          const id = '123'; | 
				
			||||
          const contentA = '456'; | 
				
			||||
          const contentB = '789'; | 
				
			||||
          final state = TestSyncState( | 
				
			||||
            { | 
				
			||||
              id: WrapA(contentA), | 
				
			||||
            }, | 
				
			||||
            { | 
				
			||||
              id: WrapB(contentB), | 
				
			||||
            }, | 
				
			||||
          ); | 
				
			||||
          final sources = TestSyncSources.fromState(state); | 
				
			||||
          final journal = SyncJournal({ | 
				
			||||
            SyncJournalEntry(id, randomEtag(), randomEtag()), | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          final conflicts = await sync(sources, journal); | 
				
			||||
          expect(conflicts, hasLength(1)); | 
				
			||||
          expect(conflicts[0].type, SyncConflictType.bothChanged); | 
				
			||||
          expect(state.stateA, hasLength(1)); | 
				
			||||
          expect(state.stateA[id]!.content, contentA); | 
				
			||||
          expect(state.stateB, hasLength(1)); | 
				
			||||
          expect(state.stateB[id]!.content, contentB); | 
				
			||||
          expect(journal.entries, hasLength(1)); | 
				
			||||
        }); | 
				
			||||
 | 
				
			||||
        group('solution', () { | 
				
			||||
          group('journal missing', () { | 
				
			||||
            test('skip', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.skip, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentA); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentB); | 
				
			||||
              expect(journal.entries, isEmpty); | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            test('overwrite A', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.overwriteA, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentB); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentB); | 
				
			||||
              expect(journal.entries, hasLength(1)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            test('overwrite B', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal(); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.overwriteB, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentA); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentA); | 
				
			||||
              expect(journal.entries, hasLength(1)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); | 
				
			||||
            }); | 
				
			||||
          }); | 
				
			||||
 | 
				
			||||
          group('both changed', () { | 
				
			||||
            test('skip', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal({ | 
				
			||||
                SyncJournalEntry(id, randomEtag(), randomEtag()), | 
				
			||||
              }); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.skip, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentA); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentB); | 
				
			||||
              expect(journal.entries, hasLength(1)); | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            test('overwrite A', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal({ | 
				
			||||
                SyncJournalEntry(id, randomEtag(), randomEtag()), | 
				
			||||
              }); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.overwriteA, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentB); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentB); | 
				
			||||
              expect(journal.entries, hasLength(1)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); | 
				
			||||
            }); | 
				
			||||
 | 
				
			||||
            test('overwrite B', () async { | 
				
			||||
              const id = '123'; | 
				
			||||
              const contentA = '456'; | 
				
			||||
              const contentB = '789'; | 
				
			||||
              final state = TestSyncState( | 
				
			||||
                { | 
				
			||||
                  id: WrapA(contentA), | 
				
			||||
                }, | 
				
			||||
                { | 
				
			||||
                  id: WrapB(contentB), | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              final sources = TestSyncSources.fromState(state); | 
				
			||||
              final journal = SyncJournal({ | 
				
			||||
                SyncJournalEntry(id, randomEtag(), randomEtag()), | 
				
			||||
              }); | 
				
			||||
 | 
				
			||||
              final conflicts = await sync( | 
				
			||||
                sources, | 
				
			||||
                journal, | 
				
			||||
                conflictSolutions: { | 
				
			||||
                  id: SyncConflictSolution.overwriteB, | 
				
			||||
                }, | 
				
			||||
              ); | 
				
			||||
              expect(conflicts, isEmpty); | 
				
			||||
              expect(state.stateA, hasLength(1)); | 
				
			||||
              expect(state.stateA[id]!.content, contentA); | 
				
			||||
              expect(state.stateB, hasLength(1)); | 
				
			||||
              expect(state.stateB[id]!.content, contentA); | 
				
			||||
              expect(journal.entries, hasLength(1)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); | 
				
			||||
              expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); | 
				
			||||
            }); | 
				
			||||
          }); | 
				
			||||
        }); | 
				
			||||
      }); | 
				
			||||
    }); | 
				
			||||
  }); | 
				
			||||
} | 
				
			||||
					Loading…
					
					
				
		Reference in new issue