Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions commitlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ rules:
- notifications_push_repository
- release
- sort_box
- synchronize
- talk_app
- tool
1 change: 1 addition & 0 deletions packages/neon_framework/packages/synchronize/LICENSE
3 changes: 3 additions & 0 deletions packages/neon_framework/packages/synchronize/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# synchronize

A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
include: package:neon_lints/dart.yaml

custom_lint:
rules:
- avoid_exports: false
60 changes: 60 additions & 0 deletions packages/neon_framework/packages/synchronize/lib/src/action.dart
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions packages/neon_framework/packages/synchronize/lib/src/conflict.dart
Original file line number Diff line number Diff line change
@@ -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 ==(Object 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 changes: 33 additions & 0 deletions packages/neon_framework/packages/synchronize/lib/src/journal.dart
Original file line number Diff line number Diff line change
@@ -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([Set<SyncJournalEntry>? entries]) : entries = entries ?? {};

/// Deserializes a journal from [json].
factory SyncJournal.fromJson(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(SyncJournalEntry entry) {
entries
..remove(entry)
..add(entry);
}

@override
String toString() => 'SyncJournal(entries: $entries)';
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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(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 ==(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(String id) => firstWhereOrNull((entry) => entry.id == id);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/neon_framework/packages/synchronize/lib/src/object.dart
Original file line number Diff line number Diff line change
@@ -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(String id) => firstWhereOrNull((object) => object.id == id);
}
39 changes: 39 additions & 0 deletions packages/neon_framework/packages/synchronize/lib/src/sources.dart
Original file line number Diff line number Diff line change
@@ -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(SyncObject<T1> object);

/// Writes the given [object].
FutureOr<SyncObject<T1>> writeObject(SyncObject<T2> object);

/// Deletes the given [object].
FutureOr<void> deleteObject(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(SyncObject<T1> objectA, SyncObject<T2> objectB);

@override
String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)';
}
Loading