Browse Source

feat(synchronize): Init

Signed-off-by: jld3103 <jld3103yt@gmail.com>
pull/600/head
jld3103 2 years ago
parent
commit
30deb2e654
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 1
      commitlint.yaml
  2. 1
      packages/synchronize/LICENSE
  3. 3
      packages/synchronize/README.md
  4. 1
      packages/synchronize/analysis_options.yaml
  5. 60
      packages/synchronize/lib/src/action.dart
  6. 61
      packages/synchronize/lib/src/conflict.dart
  7. 33
      packages/synchronize/lib/src/journal.dart
  8. 15
      packages/synchronize/lib/src/journal.g.dart
  9. 52
      packages/synchronize/lib/src/journal_entry.dart
  10. 19
      packages/synchronize/lib/src/journal_entry.g.dart
  11. 12
      packages/synchronize/lib/src/object.dart
  12. 39
      packages/synchronize/lib/src/sources.dart
  13. 246
      packages/synchronize/lib/src/sync.dart
  14. 6
      packages/synchronize/lib/synchronize.dart
  15. 20
      packages/synchronize/pubspec.yaml
  16. 4
      packages/synchronize/pubspec_overrides.yaml
  17. 542
      packages/synchronize/test/sync_test.dart

1
commitlint.yaml

@ -26,3 +26,4 @@ rules:
- neon_lints
- nextcloud
- sort_box
- synchronize

1
packages/synchronize/LICENSE

@ -0,0 +1 @@
../../LICENSE

3
packages/synchronize/README.md

@ -0,0 +1,3 @@
# synchronize
A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html

1
packages/synchronize/analysis_options.yaml

@ -0,0 +1 @@
include: package:neon_lints/dart.yaml

60
packages/synchronize/lib/src/action.dart

@ -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)';
}

61
packages/synchronize/lib/src/conflict.dart

@ -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,
}

33
packages/synchronize/lib/src/journal.dart

@ -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)';
}

15
packages/synchronize/lib/src/journal.g.dart

@ -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(),
};

52
packages/synchronize/lib/src/journal_entry.dart

@ -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);
}

19
packages/synchronize/lib/src/journal_entry.g.dart

@ -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,
};

12
packages/synchronize/lib/src/object.dart

@ -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);
}

39
packages/synchronize/lib/src/sources.dart

@ -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)';
}

246
packages/synchronize/lib/src/sync.dart

@ -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));

6
packages/synchronize/lib/synchronize.dart

@ -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';

20
packages/synchronize/pubspec.yaml

@ -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

4
packages/synchronize/pubspec_overrides.yaml

@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: neon_lints
dependency_overrides:
neon_lints:
path: ../neon_lints

542
packages/synchronize/test/sync_test.dart

@ -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…
Cancel
Save