chore: Publish version 0.0.1 of the packages (#103)
This commit is contained in:
parent
20b23a1a9c
commit
a24faf39af
77 changed files with 112 additions and 156 deletions
34
README.md
34
README.md
|
|
@ -96,46 +96,22 @@ develop the app with normal repository calls against the wrapped `session`.
|
||||||
|
|
||||||
### 1. Add the package to the project
|
### 1. Add the package to the project
|
||||||
|
|
||||||
Since this package is still in development and not yet released to pub.dev,
|
Add the packages to the server and client `pubspec.yaml` files.
|
||||||
you need to add it as a git reference to the project. First, add it normally
|
|
||||||
on the server and client `pubspec.yaml` files.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# your_project_client/pubspec.yaml
|
# your_project_client/pubspec.yaml
|
||||||
dependencies:
|
dependencies:
|
||||||
serverpod_offline_sync_client: 4.0.0-beta.0
|
serverpod_offline_sync_client: 0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# your_project_server/pubspec.yaml
|
# your_project_server/pubspec.yaml
|
||||||
dependencies:
|
dependencies:
|
||||||
serverpod_offline_sync_server: 4.0.0-beta.0
|
serverpod_offline_sync_server: 0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, on the workspace root `pubspec.yaml` file, add the following overrides:
|
> [!NOTE]
|
||||||
|
> Version `0.0.1` requires Serverpod `4.0.0-beta.0`.
|
||||||
```yaml
|
|
||||||
dependency_overrides:
|
|
||||||
serverpod_offline_sync_client:
|
|
||||||
git:
|
|
||||||
url: https://github.com/marcelomendoncasoares/serverpod_offline_sync.git
|
|
||||||
path: packages/serverpod_offline_sync_client
|
|
||||||
ref: main
|
|
||||||
serverpod_offline_sync_server:
|
|
||||||
git:
|
|
||||||
url: https://github.com/marcelomendoncasoares/serverpod_offline_sync.git
|
|
||||||
path: packages/serverpod_offline_sync_server
|
|
||||||
ref: main
|
|
||||||
serverpod_offline_sync_shared:
|
|
||||||
git:
|
|
||||||
url: https://github.com/marcelomendoncasoares/serverpod_offline_sync.git
|
|
||||||
path: packages/serverpod_offline_sync_shared
|
|
||||||
ref: main
|
|
||||||
```
|
|
||||||
|
|
||||||
Be mindful that installing the package as a git reference to `main` is subject
|
|
||||||
to sudden changes when resolving dependencies. If you want to use a specific
|
|
||||||
version, pin the commit hash on the `ref` field instead.
|
|
||||||
|
|
||||||
After adding the dependencies, list the `serverpod_offline_sync` module on the
|
After adding the dependencies, list the `serverpod_offline_sync` module on the
|
||||||
`generator.yaml` file:
|
`generator.yaml` file:
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ dependencies:
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
serverpod_database: 4.0.0-beta.0
|
serverpod_database: 4.0.0-beta.0
|
||||||
|
serverpod_offline_sync:
|
||||||
|
path: ../packages/serverpod_offline_sync
|
||||||
serverpod_offline_sync_server:
|
serverpod_offline_sync_server:
|
||||||
path: ../packages/serverpod_offline_sync_server
|
path: ../packages/serverpod_offline_sync_server
|
||||||
serverpod_offline_sync_shared:
|
|
||||||
path: ../packages/serverpod_offline_sync_shared
|
|
||||||
serverpod_offline_sync_test_client:
|
serverpod_offline_sync_test_client:
|
||||||
path: ../test/serverpod_offline_sync_test_client
|
path: ../test/serverpod_offline_sync_test_client
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import 'dart:typed_data';
|
||||||
import 'package:benchmark_harness/benchmark_harness.dart';
|
import 'package:benchmark_harness/benchmark_harness.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:serverpod_database/serverpod_database.dart';
|
import 'package:serverpod_database/serverpod_database.dart';
|
||||||
|
import 'package:serverpod_offline_sync/serverpod_offline_sync.dart';
|
||||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
|
|
||||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||||
|
|
||||||
import 'benchmark.dart';
|
import 'benchmark.dart';
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ dependency_overrides:
|
||||||
url: https://github.com/marcelomendoncasoares/serverpod.git
|
url: https://github.com/marcelomendoncasoares/serverpod.git
|
||||||
path: packages/serverpod_database
|
path: packages/serverpod_database
|
||||||
ref: fix-double-generator-bug
|
ref: fix-double-generator-bug
|
||||||
serverpod_offline_sync_shared:
|
serverpod_offline_sync:
|
||||||
path: ../../packages/serverpod_offline_sync_shared
|
path: ../../packages/serverpod_offline_sync
|
||||||
serverpod_serialization:
|
serverpod_serialization:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/marcelomendoncasoares/serverpod.git
|
url: https://github.com/marcelomendoncasoares/serverpod.git
|
||||||
|
|
|
||||||
13
packages/serverpod_offline_sync/.gitignore
vendored
Normal file
13
packages/serverpod_offline_sync/.gitignore
vendored
Normal 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/
|
||||||
1
packages/serverpod_offline_sync/CHANGELOG.md
Symbolic link
1
packages/serverpod_offline_sync/CHANGELOG.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../CHANGELOG.md
|
||||||
27
packages/serverpod_offline_sync/LICENSE
Normal file
27
packages/serverpod_offline_sync/LICENSE
Normal 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.
|
||||||
1
packages/serverpod_offline_sync/README.md
Symbolic link
1
packages/serverpod_offline_sync/README.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../README.md
|
||||||
8
packages/serverpod_offline_sync/analysis_options.yaml
Normal file
8
packages/serverpod_offline_sync/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
include: ../../analysis_options.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
# - lib/src/generated/**
|
||||||
|
|
||||||
|
formatter:
|
||||||
|
trailing_commas: preserve
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export 'src/generated/protocol.dart';
|
||||||
|
export 'src/hlc/exceptions.dart';
|
||||||
|
export 'src/hlc/hlc.dart';
|
||||||
8
packages/serverpod_offline_sync/lib/src/generated/analysis_options.yaml
generated
Normal file
8
packages/serverpod_offline_sync/lib/src/generated/analysis_options.yaml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
analyzer:
|
||||||
|
errors:
|
||||||
|
dead_code: ignore
|
||||||
|
duplicate_ignore: ignore
|
||||||
|
unnecessary_type_check: ignore
|
||||||
|
|
||||||
|
formatter:
|
||||||
|
trailing_commas: preserve
|
||||||
66
packages/serverpod_offline_sync/lib/src/generated/hlc/base.dart
generated
Normal file
66
packages/serverpod_offline_sync/lib/src/generated/hlc/base.dart
generated
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
packages/serverpod_offline_sync/lib/src/generated/protocol.dart
generated
Normal file
182
packages/serverpod_offline_sync/lib/src/generated/protocol.dart
generated
Normal 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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/serverpod_offline_sync/lib/src/hlc/base.spy.yaml
Normal file
11
packages/serverpod_offline_sync/lib/src/hlc/base.spy.yaml
Normal 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
|
||||||
41
packages/serverpod_offline_sync/lib/src/hlc/exceptions.dart
Normal file
41
packages/serverpod_offline_sync/lib/src/hlc/exceptions.dart
Normal 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';
|
||||||
|
}
|
||||||
204
packages/serverpod_offline_sync/lib/src/hlc/hlc.dart
Normal file
204
packages/serverpod_offline_sync/lib/src/hlc/hlc.dart
Normal 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);
|
||||||
|
}
|
||||||
19
packages/serverpod_offline_sync/pubspec.yaml
Normal file
19
packages/serverpod_offline_sync/pubspec.yaml
Normal 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
|
||||||
|
|
@ -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)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue