chore: Publish version 0.0.1 of the packages (#103)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-10 13:16:43 -03:00 • committed by GitHub
commit a24faf39af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 112 additions and 156 deletions

View file

@ -0,0 +1,13 @@
# Files and directories created by pub
.dart_tool/
.packages
# Omit committing pubspec.lock for library packages:
# https://dart.dev/guides/libraries/private-files#pubspeclock
pubspec.lock
# Conventional directory for build outputs
build/
# Directory created by dartdoc
doc/api/

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -0,0 +1,27 @@
Copyright 2025 The Serverpod authors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
4. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1 @@
../../README.md

View file

@ -0,0 +1,8 @@
include: ../../analysis_options.yaml
analyzer:
exclude:
# - lib/src/generated/**
formatter:
trailing_commas: preserve

View file

@ -0,0 +1,3 @@
export 'src/generated/protocol.dart';
export 'src/hlc/exceptions.dart';
export 'src/hlc/hlc.dart';

View file

@ -0,0 +1,8 @@
analyzer:
errors:
dead_code: ignore
duplicate_ignore: ignore
unnecessary_type_check: ignore
formatter:
trailing_commas: preserve

View file

@ -0,0 +1,66 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_serialization/serverpod_serialization.dart' as _i1;
/// Base class for HLC timestamps.
///
/// This class is used to represent the base HLC timestamp, which will be
/// extended with the node ID (in its canonical or normalized form).
class BaseHlc implements _i1.SerializableModel {
BaseHlc({
required this.hlcDatetime,
required this.hlcCounter,
});
factory BaseHlc.fromJson(Map<String, dynamic> jsonSerialization) {
return BaseHlc(
hlcDatetime: _i1.DateTimeJsonExtension.fromJson(
jsonSerialization['hlcDatetime'],
),
hlcCounter: jsonSerialization['hlcCounter'] as int,
);
}
/// The datetime component of the HLC timestamp.
DateTime hlcDatetime;
/// The counter component of the HLC timestamp.
int hlcCounter;
/// Returns a shallow copy of this [BaseHlc]
/// with some or all fields replaced by the given arguments.
@_i1.useResult
BaseHlc copyWith({
DateTime? hlcDatetime,
int? hlcCounter,
}) {
return BaseHlc(
hlcDatetime: hlcDatetime ?? this.hlcDatetime,
hlcCounter: hlcCounter ?? this.hlcCounter,
);
}
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'serverpod_offline_sync.BaseHlc',
'hlcDatetime': hlcDatetime.toJson(),
'hlcCounter': hlcCounter,
};
}
@override
String toString() {
return _i1.SerializationManager.encode(this);
}
}

View file

@ -0,0 +1,182 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_serialization/serverpod_serialization.dart' as _i1;
import 'hlc/base.dart' as _i2;
export 'hlc/base.dart';
class Protocol extends _i1.SerializationManager {
Protocol._();
factory Protocol() => _instance;
static final Protocol _instance = Protocol._();
final Set<_i1.SerializationManager> _hostProtocols = {};
void registerHostProtocol(
String projectName,
_i1.SerializationManager protocol,
) {
_hostProtocols.add(protocol);
}
static String? getClassNameFromObjectJson(dynamic data) {
if (data is! Map) return null;
final className = data['__className__'] as String?;
if (className == null) return null;
if (!className.startsWith('serverpod_offline_sync.')) return className;
return className.substring(23);
}
@override
T deserialize<T>(
dynamic data, [
Type? t,
]) {
t ??= T;
final dataClassName = getClassNameFromObjectJson(data);
if (dataClassName != null && dataClassName != getClassNameForType(t)) {
try {
return deserializeByClassName({
'className': dataClassName,
'data': data,
});
} on FormatException catch (_) {
// If the className is not recognized (e.g., older client receiving
// data with a new subtype), fall back to deserializing without the
// className, using the expected type T.
}
}
if (t == _i2.BaseHlc) {
return _i2.BaseHlc.fromJson(data) as T;
}
if (t == _i1.getType<_i2.BaseHlc?>()) {
return (data != null ? _i2.BaseHlc.fromJson(data) : null) as T;
}
return super.deserialize<T>(data, t);
}
static String? getClassNameForType(Type type) {
return switch (type) {
_i2.BaseHlc => 'BaseHlc',
_ => null,
};
}
@override
String? getClassNameForObject(Object? data) {
String? className = super.getClassNameForObject(data);
if (className != null) return className;
if (data is Map<String, dynamic> && data['__className__'] is String) {
return (data['__className__'] as String).replaceFirst(
'serverpod_offline_sync.',
'',
);
}
switch (data) {
case _i2.BaseHlc():
return 'BaseHlc';
}
return null;
}
@override
dynamic deserializeByClassName(Map<String, dynamic> data) {
var dataClassName = data['className'];
if (dataClassName is! String) {
return super.deserializeByClassName(data);
}
if (dataClassName == 'BaseHlc') {
return deserialize<_i2.BaseHlc>(data['data']);
}
return super.deserializeByClassName(data);
}
@override
Object? dynamicFieldToJson(
Object? object, {
bool forProtocol = false,
}) {
if ((object is List || object is Set || object is Map) ||
getClassNameForObject(object) != null) {
return super.dynamicFieldToJson(object, forProtocol: forProtocol);
}
for (final protocol in _hostProtocols) {
final className = protocol.getClassNameForObject(object);
if (className == null) continue;
final host = protocol.getModuleName();
final wrapped = {
'className': className.contains('.') ? className : '$host.$className',
'data': object,
};
return forProtocol
? _i1.SerializationManager.toEncodableForProtocol(wrapped)
: _i1.SerializationManager.toEncodable(wrapped);
}
return super.dynamicFieldToJson(object, forProtocol: forProtocol);
}
@override
dynamic deserializeDynamicFieldValue(Object? value) {
if (value == null) return null;
if (value is! Map<String, dynamic> || value['className'] is! String) {
throw FormatException(
'Dynamic fields are encoded as a Map with className and data, but got '
'${value.runtimeType} instead.',
);
}
final className = value['className'] as String;
for (final protocol in _hostProtocols) {
final host = protocol.getModuleName();
final hostPrefix = '$host.';
if (className.startsWith(hostPrefix)) {
final strippedClassName = className.substring(hostPrefix.length);
if (strippedClassName.contains('.')) {
throw FormatException(
'Dynamic field className must not use multiple prefixes: $className',
);
}
final hostData = Map<String, dynamic>.from(value);
hostData['className'] = strippedClassName;
return protocol.deserializeByClassName(hostData);
}
}
if (className.contains('.')) {
for (final protocol in _hostProtocols) {
try {
return protocol.deserializeByClassName(value);
} on FormatException catch (_) {}
}
}
return deserializeByClassName(value);
}
@override
String getModuleName() => 'serverpod_offline_sync';
/// Maps any `Record`s known to this [Protocol] to their JSON representation
///
/// Throws in case the record type is not known.
///
/// This method will return `null` (only) for `null` inputs.
Map<String, dynamic>? mapRecordToJson(Record? record) {
if (record == null) {
return null;
}
throw Exception('Unsupported record type ${record.runtimeType}');
}
}

View file

@ -0,0 +1,11 @@
### Base class for HLC timestamps.
###
### This class is used to represent the base HLC timestamp, which will be
### extended with the node ID (in its canonical or normalized form).
class: BaseHlc
fields:
### The datetime component of the HLC timestamp.
hlcDatetime: DateTime
### The counter component of the HLC timestamp.
hlcCounter: int

View file

@ -0,0 +1,41 @@
import 'package:serverpod_serialization/serverpod_serialization.dart';
/// 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 UuidValue nodeId;
@override
String toString() => 'Duplicate node: $nodeId';
}

View file

@ -0,0 +1,204 @@
// 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 'package:serverpod_serialization/serverpod_serialization.dart';
import '../../src/generated/hlc/base.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
@immutable
class Hlc implements Comparable<Hlc> {
/// Creates a new instance of [Hlc].
///
/// Precision of the [datetime] is milliseconds.
factory Hlc(DateTime datetime, int counter, UuidValue nodeId) =>
Hlc._(datetime.toUtcMillisecond(), counter, nodeId);
/// Parse an HLC string in the format `<ISO8601_date>-<counter>-<node_id>`
/// or `<unix_timestamp>-<counter>-<node_id>`.
///
/// For backward compatibility, the node segment may use a legacy
/// `uuid:worker_id` form; the worker suffix is ignored.
factory Hlc.parse(String timestamp) {
final indexOfColon = timestamp.indexOf(':');
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 nodeIdPart = timestamp.substring(nodeIdDash + 1);
return Hlc(datetime, counter, parseNodeSegment(nodeIdPart));
}
/// Convenience method to decode the HLC from a JSON map.
factory Hlc.fromJson(Map<String, dynamic> json) => Hlc(
DateTime.parse(json['datetime'] as String),
int.parse(json['counter'] as String, radix: 16),
UuidValue.withValidation(json['node'] as String),
);
const Hlc._(this.datetime, this.counter, this.nodeId)
: assert(counter <= _maxCounter);
/// Instantiates an Hlc at the beginning of time and space: January 1, 1970.
factory Hlc.zero(UuidValue nodeId) => Hlc(DateTime.utc(1970), 0, nodeId);
/// Instantiates an Hlc using the wall clock.
factory Hlc.now(UuidValue nodeId) => Hlc(DateTime.now(), 0, nodeId);
/// Parses the node segment of an HLC string: a UUID, or legacy `uuid:workerId`.
static UuidValue parseNodeSegment(String segment) {
final parts = segment.split(':');
if (parts.length == 2 && int.tryParse(parts[1]) != null) {
return UuidValue.withValidation(parts[0]);
}
return UuidValue.withValidation(segment);
}
/// The date and time of the HLC.
final DateTime datetime;
/// The counter of the HLC.
final int counter;
/// The logical node ID for this HLC.
final UuidValue 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, UuidValue? nodeId}) => Hlc(
datetime ?? this.datetime,
counter ?? this.counter,
nodeId ?? this.nodeId,
);
/// Returns the maximum HLC between the two given HLCs.
Hlc maxBetween(Hlc? other) => other == null || this > other ? this : other;
@override
String toString() =>
'${datetime.toIso8601String()}-'
'${counter.toRadixString(16).toUpperCase().padLeft(4, '0')}-'
'$nodeId';
/// Convenience method for easy json encoding.
Map<String, dynamic> toJson() => {
'datetime': datetime.toIso8601String(),
'counter': counter.toRadixString(16).toUpperCase().padLeft(4, '0'),
'node': nodeId.toString(),
};
@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.uuid.compareTo(other.nodeId.uuid)
: counter - other.counter
: datetime.compareTo(other.datetime);
}
extension on DateTime {
DateTime toUtcMillisecond() =>
DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch, isUtc: true);
}
/// Extensions for [BaseHlc] to convert it to an [Hlc].
extension BaseHlcToHlc on BaseHlc {
/// Converts the [BaseHlc] to an [Hlc] with the given [nodeId].
Hlc toHlcForNode(UuidValue nodeId) => Hlc(hlcDatetime, hlcCounter, nodeId);
}
/// Extensions for an [Iterable] of [Hlc].
extension HlcMax on Iterable<Hlc> {
/// The greatest HLC represented by the HLCs in this iterable.
Hlc get max => reduce((a, b) => a > b ? a : b);
}

View file

@ -0,0 +1,19 @@
name: serverpod_offline_sync
version: 0.0.1
description: Shared package for the Serverpod Offline Sync project.
homepage: https://github.com/marcelomendoncasoares/serverpod_offline_sync
environment:
sdk: '^3.10.3'
resolution: workspace
dependencies:
clock: ^1.1.2
serverpod_database: 4.0.0-beta.0
serverpod_serialization: 4.0.0-beta.0
uuid: ^4.5.3
dev_dependencies:
lints: '>=3.0.0 <7.0.0'
test: ^1.25.5

View file

@ -0,0 +1,129 @@
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
final _nodeId = const Uuid().v4obj();
void main() {
group('Given two Hlcs with same dateTime, counter and nodeId when comparing ', () {
final hlc1 = Hlc(_dateTime, 17, _nodeId);
final hlc2 = Hlc(_dateTime, 17, _nodeId);
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 node2 = const Uuid().v4obj();
final hlc1 = Hlc(_dateTime, 17, _nodeId);
final hlc2 = Hlc(_dateTime, 17, node2);
test('then they are not equal.', () {
expect(hlc1, isNot(hlc2));
});
test('then compareTo follows lexicographic node uuid order.', () {
expect(
hlc1.compareTo(hlc2),
hlc1.nodeId.uuid.compareTo(hlc2.nodeId.uuid),
);
});
test('then one is greater or less by uuid order.', () {
if (_nodeId.uuid.compareTo(node2.uuid) > 0) {
expect(hlc1 > hlc2, isTrue);
} else {
expect(hlc1 < hlc2, isTrue);
}
});
},
);
group('Given two Hlcs with same dateTime and different counter when comparing ', () {
final hlc1 = Hlc(_dateTime, 17, _nodeId);
final hlc2 = Hlc(_dateTime, 18, _nodeId);
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, _nodeId);
final hlc2 = Hlc(_dateTime.add(const Duration(milliseconds: 1)), 17, _nodeId);
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)));
});
},
);
}

View file

@ -0,0 +1,32 @@
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
final _nodeId = const Uuid().v4obj();
void main() {
group('Given an HLC', () {
final hlc = Hlc(_dateTime, 17, _nodeId);
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 newNodeId = const Uuid().v4obj();
final newHlc = hlc.copyWith(nodeId: newNodeId);
expect(newHlc.nodeId, newNodeId);
});
});
}

View file

@ -0,0 +1,84 @@
import 'package:clock/clock.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
final _dateTime = DateTime.parse('2001-09-09T01:46:40.000Z');
final _nodeId = const Uuid().v4obj();
void main() {
group('Given an HLC with lower canonical time than wall time', () {
final hlc = Hlc(_dateTime, 17, _nodeId);
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, _nodeId);
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, _nodeId);
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.add(const Duration(minutes: 1, seconds: 5)), 0, _nodeId);
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, _nodeId);
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() => add(const Duration(milliseconds: 1));
DateTime retreat() => subtract(const Duration(milliseconds: 1));
}

View file

@ -0,0 +1,113 @@
import 'package:clock/clock.dart';
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
final _dateTime = DateTime.now().toUtc();
final _nodeId = const Uuid().v4obj();
final _nodeIdAfter = const Uuid().v4obj();
void main() {
group('Given local canonical Hlc and remote with higher time', () {
final canonical = Hlc(_dateTime, 17, _nodeId);
final remote = Hlc(_dateTime.advance(), 2, _nodeIdAfter);
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, _nodeId);
final remote = Hlc(_dateTime, 18, _nodeIdAfter);
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, _nodeId);
final remote = Hlc(_dateTime, 17, _nodeIdAfter);
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, _nodeId);
final remote = Hlc(_dateTime.retreat(), 17, _nodeIdAfter);
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, _nodeId);
final remote = Hlc(_dateTime, 17, _nodeId);
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, _nodeId);
final remote = Hlc(_dateTime.retreat(), 17, _nodeId);
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, _nodeId);
final remote = Hlc(_dateTime.advance(), 0, _nodeId);
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, _nodeId);
final remote = Hlc(
_dateTime.advance(const Duration(minutes: 2)),
17,
_nodeIdAfter,
);
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);
}

View file

@ -0,0 +1,74 @@
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
const _isoTime = '2001-09-09T01:46:40.000Z';
final _dateTime = DateTime.parse(_isoTime);
final _nodeId = const Uuid().v4obj();
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, _nodeId);
expect(hlc.datetime, _dateTime);
expect(hlc.counter, 0x17);
expect(hlc.nodeId, _nodeId);
});
test('Given Hlc.zero '
'when building for a nodeId '
'then it equals epoch with zero counter.', () {
final zero = Hlc.zero(_nodeId);
expect(zero.unixTimestamp, 0);
expect(zero.counter, 0);
expect(zero.nodeId, _nodeId);
});
test('Given an ISO HLC string '
'when parsing '
'then it returns the equivalent Hlc.', () {
final hlc = Hlc(_dateTime, 0x17, _nodeId);
expect(Hlc.parse('${_dateTime.toIso8601String()}-0017-$_nodeId'), hlc);
});
test('Given a Unix timestamp HLC string '
'when parsing '
'then it returns the equivalent Hlc.', () {
final hlc = Hlc(_dateTime, 0x17, _nodeId);
expect(Hlc.parse('${_dateTime.millisecondsSinceEpoch}-0017-$_nodeId'), hlc);
});
test('Given legacy string with :worker suffix '
'when parsing '
'then node id is the uuid only.', () {
final hlc = Hlc.parse('$_isoTime-0017-$_nodeId:7');
expect(hlc.nodeId, _nodeId);
expect(hlc.counter, 0x17);
expect(hlc.datetime, _dateTime);
});
test('Given a parsed Hlc '
'when calling toString '
'then it returns the ISO HLC string.', () {
final hlc = Hlc.parse('$_isoTime-0017-$_nodeId');
expect(hlc.toString(), '$_isoTime-0017-$_nodeId');
});
test('Given an HLC '
'when converting to JSON '
'then it returns the equivalent string.', () {
final hlc = Hlc(_dateTime, 0x17, _nodeId);
expect(hlc.toJson(), {
'datetime': _dateTime.toIso8601String(),
'counter': '0017',
'node': _nodeId.toString(),
});
});
}

View file

@ -0,0 +1 @@
../../CHANGELOG.md

View file

@ -1,3 +0,0 @@
# serverpod_offline_sync_client
Client package for the Serverpod Offline Sync project.

View file

@ -0,0 +1 @@
../../README.md

Some files were not shown because too many files have changed in this diff Show more