feat: Replace the external Hlc class by a proprietary (#19)
This commit is contained in:
parent
1e13f1ba76
commit
b747c2e785
16 changed files with 659 additions and 10 deletions
|
|
@ -13,4 +13,6 @@ export 'src/database/extensions/sync.dart';
|
||||||
export 'src/database/extensions/triggers.dart';
|
export 'src/database/extensions/triggers.dart';
|
||||||
export 'src/database/setup.dart';
|
export 'src/database/setup.dart';
|
||||||
export 'src/hlc/converter.dart';
|
export 'src/hlc/converter.dart';
|
||||||
|
export 'src/hlc/exceptions.dart';
|
||||||
|
export 'src/hlc/hlc.dart';
|
||||||
export 'src/migrator.dart';
|
export 'src/migrator.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:crdt/crdt.dart';
|
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
|
import '../hlc/hlc.dart';
|
||||||
import '../hlc/stateful.dart';
|
import '../hlc/stateful.dart';
|
||||||
|
|
||||||
/// Base class for CRDT implementations.
|
/// Base class for CRDT implementations.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:crdt/crdt.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
|
import '../hlc/hlc.dart';
|
||||||
import 'base.dart';
|
import 'base.dart';
|
||||||
|
|
||||||
/// A CRDT implementation for offline-first synchronization.
|
/// A CRDT implementation for offline-first synchronization.
|
||||||
|
|
@ -50,7 +50,7 @@ class OfflineSyncCrdt extends CrdtBase {
|
||||||
assert(onlyNodeId == null || exceptNodeId == null);
|
assert(onlyNodeId == null || exceptNodeId == null);
|
||||||
|
|
||||||
// Modified times use the local node id
|
// Modified times use the local node id
|
||||||
modifiedAfter = modifiedAfter?.apply(nodeId: nodeId);
|
modifiedAfter = modifiedAfter?.copyWith(nodeId: nodeId);
|
||||||
|
|
||||||
final query = _db.managers.crdtDataTable.filter((o) {
|
final query = _db.managers.crdtDataTable.filter((o) {
|
||||||
Expression<bool> condition = const Constant(true);
|
Expression<bool> condition = const Constant(true);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:crdt/crdt.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../hlc/hlc.dart';
|
||||||
import '../../utils/sql_builder.dart';
|
import '../../utils/sql_builder.dart';
|
||||||
import '../database.dart';
|
import '../database.dart';
|
||||||
import 'transactions.dart';
|
import 'transactions.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:crdt/crdt.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
export 'package:crdt/crdt.dart' show Hlc;
|
import 'hlc.dart';
|
||||||
|
|
||||||
|
export 'hlc.dart';
|
||||||
|
|
||||||
class _HlcConverter extends TypeConverter<Hlc, String> {
|
class _HlcConverter extends TypeConverter<Hlc, String> {
|
||||||
const _HlcConverter();
|
const _HlcConverter();
|
||||||
|
|
|
||||||
39
lib/src/hlc/exceptions.dart
Normal file
39
lib/src/hlc/exceptions.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/// Exception thrown when the clock drift exceeds the maximum allowed.
|
||||||
|
class ClockDriftException implements Exception {
|
||||||
|
/// Creates a new instance of [ClockDriftException].
|
||||||
|
ClockDriftException(DateTime dateTime, DateTime wallTime, this.maxDrift)
|
||||||
|
: drift = dateTime.difference(wallTime);
|
||||||
|
|
||||||
|
/// The duration of the clock drift.
|
||||||
|
final Duration drift;
|
||||||
|
|
||||||
|
/// The maximum allowed clock drift.
|
||||||
|
final Duration maxDrift;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Clock drift of $drift ms exceeds maximum ($maxDrift)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when the timestamp counter overflows.
|
||||||
|
class OverflowException implements Exception {
|
||||||
|
/// Creates a new instance of [OverflowException].
|
||||||
|
OverflowException(this.counter);
|
||||||
|
|
||||||
|
/// The counter that overflowed.
|
||||||
|
final int counter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Timestamp counter overflow: $counter';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when a duplicate node is detected.
|
||||||
|
class DuplicateNodeException implements Exception {
|
||||||
|
/// Creates a new instance of [DuplicateNodeException].
|
||||||
|
DuplicateNodeException(this.nodeId);
|
||||||
|
|
||||||
|
/// The node ID that is duplicated.
|
||||||
|
final String nodeId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'Duplicate node: $nodeId';
|
||||||
|
}
|
||||||
160
lib/src/hlc/hlc.dart
Normal file
160
lib/src/hlc/hlc.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// This file includes code from the project: https://github.com/cachapa/crdt
|
||||||
|
// Licensed under Apache License 2.0: https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
// Copyright Daniel Cachapa.
|
||||||
|
//
|
||||||
|
// Modifications copyright 2026 Marcelo Soares
|
||||||
|
// Licensed under MIT License: https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
|
||||||
|
import 'exceptions.dart';
|
||||||
|
|
||||||
|
/// A Hybrid Logical Clock implementation that supports both ISO 8601 and Unix
|
||||||
|
/// timestamp formats. The datetime component has millisecond precision and is
|
||||||
|
/// always stored in UTC.
|
||||||
|
///
|
||||||
|
/// Although the original HLC paper uses microsecond precision, since it was
|
||||||
|
/// proved that the counter typically stays in single digits, millisecond
|
||||||
|
/// precision can be considered enough for the vast majority of applications.
|
||||||
|
///
|
||||||
|
/// The advantage of using milliseconds is that the timestamp can be generated
|
||||||
|
/// on any database (inside SQLite3 for example) and will occupy less space in
|
||||||
|
/// the database.
|
||||||
|
///
|
||||||
|
/// Inspiration: https://cse.buffalo.edu/tech-reports/2014-04.pdf
|
||||||
|
class Hlc implements Comparable<Hlc> {
|
||||||
|
/// Creates a new instance of [Hlc].
|
||||||
|
Hlc(DateTime dateTime, this.counter, this.nodeId)
|
||||||
|
: dateTime = dateTime.toUtcMillisecond(),
|
||||||
|
assert(counter <= _maxCounter);
|
||||||
|
|
||||||
|
/// Instantiates an Hlc at the beginning of time and space: January 1, 1970.
|
||||||
|
Hlc.zero(String nodeId) : this(DateTime.utc(1970), 0, nodeId);
|
||||||
|
|
||||||
|
/// Instantiates an Hlc using the wall clock.
|
||||||
|
Hlc.now(String nodeId) : this(DateTime.now(), 0, nodeId);
|
||||||
|
|
||||||
|
/// Parse an HLC string in the format `<ISO8601_date>-<counter>-<node_id>` or
|
||||||
|
/// `<unix_timestamp>-<counter>-<node_id>`.
|
||||||
|
factory Hlc.parse(String timestamp) {
|
||||||
|
final indexOfColon = timestamp.lastIndexOf(':');
|
||||||
|
final indexOfStart = indexOfColon > 0 && indexOfColon < 20 ? indexOfColon : 0;
|
||||||
|
|
||||||
|
final counterDash = timestamp.indexOf('-', indexOfStart);
|
||||||
|
final timestampPart = timestamp.substring(0, counterDash);
|
||||||
|
|
||||||
|
final nodeIdDash = timestamp.indexOf('-', counterDash + 1);
|
||||||
|
final counterPart = timestamp.substring(counterDash + 1, nodeIdDash);
|
||||||
|
|
||||||
|
final unixTimestamp = int.tryParse(timestampPart);
|
||||||
|
final datetime = unixTimestamp != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(unixTimestamp, isUtc: true)
|
||||||
|
: DateTime.tryParse(timestampPart);
|
||||||
|
|
||||||
|
if (datetime == null) {
|
||||||
|
throw FormatException('Invalid HLC string: $timestamp');
|
||||||
|
}
|
||||||
|
|
||||||
|
final counter = int.parse(counterPart, radix: 16);
|
||||||
|
final nodeId = timestamp.substring(nodeIdDash + 1);
|
||||||
|
return Hlc(datetime, counter, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The date and time of the HLC.
|
||||||
|
final DateTime dateTime;
|
||||||
|
|
||||||
|
/// The counter of the HLC.
|
||||||
|
final int counter;
|
||||||
|
|
||||||
|
/// The node ID of the HLC.
|
||||||
|
final String nodeId;
|
||||||
|
|
||||||
|
/// The Unix timestamp of the HLC.
|
||||||
|
int get unixTimestamp => dateTime.millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
static const _maxDrift = Duration(minutes: 1);
|
||||||
|
static const _maxCounter = 0xFFFF;
|
||||||
|
|
||||||
|
/// Increments the current timestamp for transmission to another system.
|
||||||
|
Hlc increment() {
|
||||||
|
final localWallTime = clock.now().toUtc();
|
||||||
|
final dateTimeNew = localWallTime.isAfter(dateTime) ? localWallTime : dateTime;
|
||||||
|
final counterNew = dateTimeNew == dateTime ? counter + 1 : 0;
|
||||||
|
|
||||||
|
if (dateTimeNew.difference(localWallTime) > const Duration(minutes: 1)) {
|
||||||
|
throw ClockDriftException(dateTimeNew, localWallTime, _maxDrift);
|
||||||
|
}
|
||||||
|
if (counterNew > _maxCounter) {
|
||||||
|
throw OverflowException(counterNew);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Hlc(dateTimeNew, counterNew, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares and validates a timestamp from a remote system with the local
|
||||||
|
/// timestamp to preserve monotonicity.
|
||||||
|
Hlc merge(Hlc remote) {
|
||||||
|
if (remote.dateTime.isBefore(dateTime) ||
|
||||||
|
(remote.dateTime.isAtSameMomentAs(dateTime) && remote.counter <= counter)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeId == remote.nodeId) {
|
||||||
|
throw DuplicateNodeException(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localWallTime = clock.now().toUtc();
|
||||||
|
if (remote.dateTime.difference(localWallTime) > _maxDrift) {
|
||||||
|
throw ClockDriftException(remote.dateTime, localWallTime, _maxDrift);
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote.copyWith(nodeId: nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of this object replacing the optional properties.
|
||||||
|
Hlc copyWith({DateTime? dateTime, int? counter, String? nodeId}) =>
|
||||||
|
Hlc(dateTime ?? this.dateTime, counter ?? this.counter, nodeId ?? this.nodeId);
|
||||||
|
|
||||||
|
/// Convenience method for easy json encoding.
|
||||||
|
String toJson({bool unixTimestamp = false}) => toString(unixTimestamp: unixTimestamp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString({bool unixTimestamp = false}) => [
|
||||||
|
if (unixTimestamp)
|
||||||
|
this.unixTimestamp.toString().padLeft(15, '0')
|
||||||
|
else
|
||||||
|
dateTime.toIso8601String(),
|
||||||
|
counter.toRadixString(16).toUpperCase().padLeft(4, '0'),
|
||||||
|
nodeId,
|
||||||
|
].join('-');
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => toString().hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is Hlc && compareTo(other) == 0;
|
||||||
|
|
||||||
|
/// Whether this [Hlc] is causally before the [other].
|
||||||
|
bool operator <(Object other) => other is Hlc && compareTo(other) < 0;
|
||||||
|
|
||||||
|
/// Whether this [Hlc] is causally before or equal to the [other].
|
||||||
|
bool operator <=(Object other) => this < other || this == other;
|
||||||
|
|
||||||
|
/// Whether this [Hlc] is causally after the [other].
|
||||||
|
bool operator >(Object other) => other is Hlc && compareTo(other) > 0;
|
||||||
|
|
||||||
|
/// Whether this [Hlc] is causally after or equal to the [other].
|
||||||
|
bool operator >=(Object other) => this > other || this == other;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(Hlc other) => dateTime.isAtSameMomentAs(other.dateTime)
|
||||||
|
? counter == other.counter
|
||||||
|
? nodeId.compareTo(other.nodeId)
|
||||||
|
: counter - other.counter
|
||||||
|
: dateTime.compareTo(other.dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DateTime {
|
||||||
|
DateTime toUtcMillisecond() =>
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch, isUtc: true);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import 'package:crdt/crdt.dart';
|
import 'hlc.dart';
|
||||||
|
|
||||||
/// A [Hlc] instance that is used to generate unique timestamps for CRDT operations.
|
/// A [Hlc] instance that is used to generate unique timestamps for CRDT operations.
|
||||||
/// Will keep track of the HLC during the lifetime of the application.
|
/// Will keep track of the HLC during the lifetime of the application.
|
||||||
class StatefulHlc {
|
class StatefulHlc {
|
||||||
StatefulHlc._(this.userId, this.nodeId) {
|
StatefulHlc._(this.userId, this.nodeId) {
|
||||||
lastHlc = _lastUserIdHlc[userId]?.apply(nodeId: nodeId) ?? Hlc.zero(nodeId);
|
lastHlc = _lastUserIdHlc[userId]?.copyWith(nodeId: nodeId) ?? Hlc.zero(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final Map<String, Hlc> _lastUserIdHlc = {};
|
static final Map<String, Hlc> _lastUserIdHlc = {};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ environment:
|
||||||
|
|
||||||
# TODO: Relax the package constraints to expand compatibility.
|
# TODO: Relax the package constraints to expand compatibility.
|
||||||
dependencies:
|
dependencies:
|
||||||
crdt: ^5.1.3
|
clock: ^1.1.2
|
||||||
drift: ^2.28.0
|
drift: ^2.28.0
|
||||||
meta: ^1.12.0
|
meta: ^1.12.0
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
|
|
|
||||||
133
test/hlc/hlc_comparison_test.dart
Normal file
133
test/hlc/hlc_comparison_test.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Given two Hlcs with same dateTime, counter and nodeId when comparing ', () {
|
||||||
|
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final hlc2 = Hlc(_dateTime, 17, 'abc');
|
||||||
|
|
||||||
|
test('then they are equal.', () {
|
||||||
|
expect(hlc1, hlc2);
|
||||||
|
expect(hlc1 == hlc2, isTrue);
|
||||||
|
expect(hlc1, equals(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then compareTo returns 0.', () {
|
||||||
|
expect(hlc1.compareTo(hlc2), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then <= is true.', () {
|
||||||
|
expect(hlc1 <= hlc2, isTrue);
|
||||||
|
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then >= is true.', () {
|
||||||
|
expect(hlc1 >= hlc2, isTrue);
|
||||||
|
expect(hlc1, greaterThanOrEqualTo(hlc2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given two Hlcs with same dateTime and counter and different nodeId when comparing ',
|
||||||
|
() {
|
||||||
|
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final hlc2 = Hlc(_dateTime, 17, 'abb');
|
||||||
|
|
||||||
|
test('then they are not equal.', () {
|
||||||
|
expect(hlc1, isNot(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then compareTo returns 1.', () {
|
||||||
|
expect(hlc1.compareTo(hlc2), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then lexicographically greater nodeId is greater.', () {
|
||||||
|
expect(hlc1 > hlc2, isTrue);
|
||||||
|
expect(hlc1, greaterThan(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then >= is true.', () {
|
||||||
|
expect(hlc1 >= hlc2, isTrue);
|
||||||
|
expect(hlc1, greaterThanOrEqualTo(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then < is false.', () {
|
||||||
|
expect(hlc1 < hlc2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then <= is false.', () {
|
||||||
|
expect(hlc1 <= hlc2, isFalse);
|
||||||
|
expect(hlc1, isNot(lessThanOrEqualTo(hlc2)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group('Given two Hlcs with same dateTime and different counter when comparing ', () {
|
||||||
|
final hlc1 = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final hlc2 = Hlc(_dateTime, 18, 'abc');
|
||||||
|
|
||||||
|
test('then they are not equal.', () {
|
||||||
|
expect(hlc1, isNot(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then compareTo returns -1.', () {
|
||||||
|
expect(hlc1.compareTo(hlc2), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then < is true.', () {
|
||||||
|
expect(hlc1 < hlc2, isTrue);
|
||||||
|
expect(hlc1, lessThan(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then <= is true.', () {
|
||||||
|
expect(hlc1 <= hlc2, isTrue);
|
||||||
|
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then > is false.', () {
|
||||||
|
expect(hlc1 > hlc2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then >= is false.', () {
|
||||||
|
expect(hlc1 >= hlc2, isFalse);
|
||||||
|
expect(hlc1, isNot(greaterThanOrEqualTo(hlc2)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given two Hlcs with different dateTime when comparing ',
|
||||||
|
() {
|
||||||
|
final hlc1 = Hlc(_dateTime, 0, 'abc');
|
||||||
|
final hlc2 = Hlc(_dateTime.add(const Duration(milliseconds: 1)), 17, 'abc');
|
||||||
|
|
||||||
|
test('then they are not equal.', () {
|
||||||
|
expect(hlc1, isNot(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then compareTo returns -1.', () {
|
||||||
|
expect(hlc1.compareTo(hlc2), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then < is true.', () {
|
||||||
|
expect(hlc1 < hlc2, isTrue);
|
||||||
|
expect(hlc1, lessThan(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then <= is true.', () {
|
||||||
|
expect(hlc1 <= hlc2, isTrue);
|
||||||
|
expect(hlc1, lessThanOrEqualTo(hlc2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then > is false.', () {
|
||||||
|
expect(hlc1 > hlc2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then >= is false.', () {
|
||||||
|
expect(hlc1 >= hlc2, isFalse);
|
||||||
|
expect(hlc1, isNot(greaterThanOrEqualTo(hlc2)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
24
test/hlc/hlc_converter_test.dart
Normal file
24
test/hlc/hlc_converter_test.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _isoTime = '2001-09-09T01:46:40.000Z';
|
||||||
|
final _dateTime = DateTime.parse(_isoTime);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Given an HLC and the HlcConverter '
|
||||||
|
'when converting to the database format '
|
||||||
|
'then it returns string with HLC JSON format.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(hlcConverter.toSql(hlc), '${_dateTime.toIso8601String()}-0017-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given an HLC and the HlcConverter '
|
||||||
|
'when converting from the database format '
|
||||||
|
'then it returns the equivalent HLC.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
final databaseHlc = hlcConverter.toSql(hlc);
|
||||||
|
|
||||||
|
expect(hlcConverter.fromSql(databaseHlc), hlc);
|
||||||
|
});
|
||||||
|
}
|
||||||
29
test/hlc/hlc_copy_with_test.dart
Normal file
29
test/hlc/hlc_copy_with_test.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Given an HLC', () {
|
||||||
|
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||||
|
|
||||||
|
test('when calling copyWith with new dateTime then result has new dateTime.', () {
|
||||||
|
final newDateTime = _dateTime.add(const Duration(minutes: 1));
|
||||||
|
final newHlc = hlc.copyWith(dateTime: newDateTime);
|
||||||
|
|
||||||
|
expect(newHlc.dateTime, newDateTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when calling copyWith with new counter then result has new counter.', () {
|
||||||
|
final newHlc = hlc.copyWith(counter: 18);
|
||||||
|
|
||||||
|
expect(newHlc.counter, 18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when calling copyWith with new nodeId then result has new nodeId.', () {
|
||||||
|
final newHlc = hlc.copyWith(nodeId: 'abcd');
|
||||||
|
|
||||||
|
expect(newHlc.nodeId, 'abcd');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
85
test/hlc/hlc_increment_test.dart
Normal file
85
test/hlc/hlc_increment_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Given an HLC with lower canonical time than wall time', () {
|
||||||
|
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final wallTime = _dateTime.advance();
|
||||||
|
|
||||||
|
test('when incrementing then dateTime becomes wall time and counter resets.', () {
|
||||||
|
withClock(Clock.fixed(wallTime), () {
|
||||||
|
final sendHlc = hlc.increment();
|
||||||
|
|
||||||
|
expect(sendHlc, isNot(hlc));
|
||||||
|
expect(sendHlc.dateTime, wallTime);
|
||||||
|
expect(sendHlc.counter, 0);
|
||||||
|
expect(sendHlc.nodeId, hlc.nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given an HLC with equal canonical time and wall time', () {
|
||||||
|
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final wallTime = _dateTime;
|
||||||
|
|
||||||
|
test('when incrementing then counter increments and dateTime is unchanged.', () {
|
||||||
|
withClock(Clock.fixed(wallTime), () {
|
||||||
|
final sendHlc = hlc.increment();
|
||||||
|
|
||||||
|
expect(sendHlc, isNot(hlc));
|
||||||
|
expect(sendHlc.dateTime, hlc.dateTime);
|
||||||
|
expect(sendHlc.counter, 18);
|
||||||
|
expect(sendHlc.nodeId, hlc.nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given an HLC with higher canonical time than wall time', () {
|
||||||
|
final hlc = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final wallTime = _dateTime.retreat();
|
||||||
|
|
||||||
|
test('when incrementing then counter increments and dateTime is unchanged.', () {
|
||||||
|
withClock(Clock.fixed(wallTime), () {
|
||||||
|
final sendHlc = hlc.increment();
|
||||||
|
|
||||||
|
expect(sendHlc, isNot(hlc));
|
||||||
|
expect(sendHlc.dateTime, hlc.dateTime);
|
||||||
|
expect(sendHlc.counter, 18);
|
||||||
|
expect(sendHlc.nodeId, hlc.nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given an HLC with canonical time more than one minute ahead of wall time', () {
|
||||||
|
final hlc = Hlc(_dateTime.advance(const Duration(minutes: 2)), 0, 'abc');
|
||||||
|
final wallTime = _dateTime;
|
||||||
|
|
||||||
|
test('when incrementing then ClockDriftException is thrown.', () {
|
||||||
|
withClock(Clock.fixed(wallTime), () {
|
||||||
|
expect(hlc.increment, throwsA(isA<ClockDriftException>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given an HLC with counter at maximum and canonical time at wall time', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0xFFFF, 'abc');
|
||||||
|
final wallTime = _dateTime;
|
||||||
|
|
||||||
|
test('when incrementing at same time then OverflowException is thrown.', () {
|
||||||
|
withClock(Clock.fixed(wallTime), () {
|
||||||
|
expect(hlc.increment, throwsA(isA<OverflowException>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DateTime {
|
||||||
|
DateTime advance([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||||
|
add(duration);
|
||||||
|
|
||||||
|
DateTime retreat([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||||
|
subtract(duration);
|
||||||
|
}
|
||||||
106
test/hlc/hlc_merge_test.dart
Normal file
106
test/hlc/hlc_merge_test.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
final _dateTime = DateTime.now().toUtc();
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Given local canonical Hlc and remote with higher time', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime.advance(), 2, 'abd');
|
||||||
|
|
||||||
|
test('when merging then result has remote time and counter with local nodeId.', () {
|
||||||
|
final hlc = canonical.merge(remote);
|
||||||
|
|
||||||
|
expect(hlc, Hlc(remote.dateTime, remote.counter, canonical.nodeId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with same time but higher counter', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime, 18, 'abd');
|
||||||
|
|
||||||
|
test('when merging then result has remote counter with local nodeId.', () {
|
||||||
|
final hlc = canonical.merge(remote);
|
||||||
|
|
||||||
|
expect(hlc, Hlc(canonical.dateTime, remote.counter, canonical.nodeId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with same time and counter', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime, 17, 'abd');
|
||||||
|
|
||||||
|
test('when merging then local is returned.', () {
|
||||||
|
final hlc = canonical.merge(remote);
|
||||||
|
|
||||||
|
expect(hlc, equals(canonical));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with lower time', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime.retreat(), 17, 'abcd');
|
||||||
|
|
||||||
|
test('when merging then local is returned.', () {
|
||||||
|
final hlc = canonical.merge(remote);
|
||||||
|
|
||||||
|
expect(hlc, equals(canonical));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with same nodeId and same time', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime, 17, 'abc');
|
||||||
|
|
||||||
|
test('when merging then node id is not checked and local is returned.', () {
|
||||||
|
expect(canonical.merge(remote), canonical);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with same nodeId and lower time', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime.retreat(), 17, 'abc');
|
||||||
|
|
||||||
|
test('when merging then node id is not checked and local is returned.', () {
|
||||||
|
expect(canonical.merge(remote), canonical);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given local canonical Hlc and remote with same nodeId and higher time', () {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime.advance(), 0, 'abc');
|
||||||
|
|
||||||
|
test('when merging then DuplicateNodeException is thrown.', () {
|
||||||
|
expect(
|
||||||
|
() => canonical.merge(remote),
|
||||||
|
throwsA(isA<DuplicateNodeException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given local canonical Hlc and remote with time more than one minute ahead of wall',
|
||||||
|
() {
|
||||||
|
final canonical = Hlc(_dateTime, 17, 'abc');
|
||||||
|
final remote = Hlc(_dateTime.advance(const Duration(minutes: 2)), 17, 'abd');
|
||||||
|
|
||||||
|
test('when merging then ClockDriftException is thrown.', () {
|
||||||
|
withClock(Clock.fixed(_dateTime.advance()), () {
|
||||||
|
expect(
|
||||||
|
() => canonical.merge(remote),
|
||||||
|
throwsA(isA<ClockDriftException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DateTime {
|
||||||
|
DateTime advance([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||||
|
add(duration);
|
||||||
|
|
||||||
|
DateTime retreat([Duration duration = const Duration(milliseconds: 1)]) =>
|
||||||
|
subtract(duration);
|
||||||
|
}
|
||||||
70
test/hlc/hlc_serialization_test.dart
Normal file
70
test/hlc/hlc_serialization_test.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import 'package:drift_offline_sync/drift_offline_sync.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _isoTime = '2001-09-09T01:46:40.000Z';
|
||||||
|
final _dateTime = DateTime.parse(_isoTime);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Given an HLC built with dateTime, counter and nodeId '
|
||||||
|
'when reading properties '
|
||||||
|
'then dateTime, counter and nodeId match.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(hlc.dateTime, _dateTime);
|
||||||
|
expect(hlc.counter, 0x17);
|
||||||
|
expect(hlc.nodeId, 'abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given Hlc.zero '
|
||||||
|
'when building for a nodeId '
|
||||||
|
'then it equals epoch with zero counter.', () {
|
||||||
|
final zero = Hlc.zero('abc');
|
||||||
|
|
||||||
|
expect(zero.unixTimestamp, 0);
|
||||||
|
expect(zero.counter, 0);
|
||||||
|
expect(zero.nodeId, 'abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given an ISO HLC string '
|
||||||
|
'when parsing '
|
||||||
|
'then it returns the equivalent Hlc.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(Hlc.parse('${_dateTime.toIso8601String()}-0017-abc'), hlc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given a Unix timestamp HLC string '
|
||||||
|
'when parsing '
|
||||||
|
'then it returns the equivalent Hlc.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(Hlc.parse('${_dateTime.millisecondsSinceEpoch}-0017-abc'), hlc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given a parsed Hlc '
|
||||||
|
'when calling toString '
|
||||||
|
'then it returns the ISO HLC string.', () {
|
||||||
|
final hlc = Hlc.parse('$_isoTime-0017-abc');
|
||||||
|
|
||||||
|
expect(hlc.toString(), '$_isoTime-0017-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given an HLC '
|
||||||
|
'when converting to JSON '
|
||||||
|
'then it returns the equivalent string.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(hlc.toJson(), '${_dateTime.toIso8601String()}-0017-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Given an HLC '
|
||||||
|
'when converting to JSON with unixTimestamp '
|
||||||
|
'then it returns the equivalent string with padded unix timestamp.', () {
|
||||||
|
final hlc = Hlc(_dateTime, 0x17, 'abc');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
hlc.toJson(unixTimestamp: true),
|
||||||
|
'${_dateTime.millisecondsSinceEpoch.toString().padLeft(15, '0')}-0017-abc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ void main() {
|
||||||
'when retrieving the cached HLC '
|
'when retrieving the cached HLC '
|
||||||
'then the HLC for the node is the initial HLC for the requested node.',
|
'then the HLC for the node is the initial HLC for the requested node.',
|
||||||
() async {
|
() async {
|
||||||
final expectedHlc = initialHlc.apply(nodeId: testNodeId);
|
final expectedHlc = initialHlc.copyWith(nodeId: testNodeId);
|
||||||
expect(
|
expect(
|
||||||
StatefulHlc.cached(testUserId, testNodeId).lastHlc,
|
StatefulHlc.cached(testUserId, testNodeId).lastHlc,
|
||||||
equals(expectedHlc),
|
equals(expectedHlc),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue