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