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