jld3103
2 years ago
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