feat: Implement the sync protocol (#54)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Marcelo Mendonça Soares 2026-05-29 01:54:23 -03:00 • committed by GitHub
commit f85239c3a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3031 additions and 306 deletions

View file

@ -2,7 +2,7 @@ include: ../../analysis_options.yaml
analyzer:
exclude:
- lib/src/protocol/**
# - lib/src/protocol/**
formatter:
trailing_commas: preserve

View file

@ -1,3 +1,4 @@
export 'package:serverpod_client/serverpod_client.dart';
export 'src/protocol/client.dart';
export 'src/protocol/protocol.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

@ -8,7 +8,7 @@ environment:
resolution: workspace
dependencies:
serverpod_client: 3.5.0-beta.6
serverpod_database: 3.5.0-beta.6
serverpod_client: 3.5.0-beta.9
serverpod_database: 3.5.0-beta.9
serverpod_offline_sync_client:
path: ../../packages/serverpod_offline_sync_client

View file

@ -2,7 +2,7 @@ include: ../../analysis_options.yaml
analyzer:
exclude:
- lib/src/generated/**
# - lib/src/generated/**
- test/integration/test_tools/serverpod_test_tools.dart
formatter:

View file

@ -24,5 +24,5 @@ database:
filePath: test.db
sessionLogs:
persistentEnabled: true
persistentEnabled: false
consoleEnabled: true

View file

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

View file

@ -14,3 +14,8 @@ fields:
anEnum: TypesEnum?
optionalText: String?
optionalUuid: UuidValue?
indexes:
types_a_text_idx:
fields: aText
unique: false

View file

@ -8,7 +8,7 @@ environment:
resolution: workspace
dependencies:
serverpod: 3.5.0-beta.6
serverpod: 3.5.0-beta.9
serverpod_offline_sync_server:
path: ../../packages/serverpod_offline_sync_server
serverpod_offline_sync_shared:
@ -19,5 +19,5 @@ dev_dependencies:
path: ^1.9.1
serverpod_offline_sync_test_client:
path: ../serverpod_offline_sync_test_client
serverpod_test: 3.5.0-beta.6
serverpod_test: 3.5.0-beta.9
test: ^1.25.5

View file

@ -8,7 +8,7 @@ import '../test_tools/client_session.dart';
void main() {
initTestClientSession();
late CrdtMergeSet mergeset;
late CrdtMergeSet mergeSet;
group('Given a table with an existing row and a newer remote delete, ', () {
late Person person;
@ -43,17 +43,13 @@ void main() {
isDeleted: true,
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [],
deletes: [remoteDelete],
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -115,17 +111,13 @@ void main() {
isDeleted: false,
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [],
deletes: [remoteRestore],
);
mergeSet = [remoteRestore];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -193,17 +185,13 @@ void main() {
isDeleted: true,
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [],
deletes: [remoteRestore],
);
mergeSet = [remoteRestore];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -247,17 +235,13 @@ void main() {
isDeleted: true,
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [],
deletes: [remoteDelete],
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});

View file

@ -8,7 +8,7 @@ import '../test_tools/client_session.dart';
void main() {
initTestClientSession();
late CrdtMergeSet mergeset;
late CrdtMergeSet mergeSet;
group('Given an empty table and a remote insert, ', () {
late UuidValue remoteNodeId;
@ -29,17 +29,13 @@ void main() {
data: remotePerson,
);
mergeset = CrdtMergeSet(
inserts: [remoteInsert],
updates: [],
deletes: [],
);
mergeSet = [remoteInsert];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -97,17 +93,13 @@ void main() {
value: 'updated remotely',
);
mergeset = CrdtMergeSet(
inserts: [remoteInsert],
updates: [remoteUpdate],
deletes: [],
);
mergeSet = [remoteInsert, remoteUpdate];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -195,17 +187,13 @@ void main() {
data: remotePerson,
);
mergeset = CrdtMergeSet(
inserts: [remoteInsert],
updates: [],
deletes: [],
);
mergeSet = [remoteInsert];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -331,11 +319,7 @@ void main() {
);
localUpdatedFieldHlc = localNameField!.hlc;
mergeset = CrdtMergeSet(
inserts: [remoteInsert],
updates: [],
deletes: [],
);
mergeSet = [remoteInsert];
});
group('when merging, ', () {
@ -343,7 +327,7 @@ void main() {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);

View file

@ -8,7 +8,7 @@ import '../test_tools/client_session.dart';
void main() {
initTestClientSession();
late CrdtMergeSet mergeset;
late CrdtMergeSet mergeSet;
group(
'Given a table with an existing unchanged row and a remote update for one of its columns, ',
@ -47,17 +47,13 @@ void main() {
value: 'updated remotely',
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [remoteUpdate],
deletes: [],
);
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -134,17 +130,13 @@ void main() {
value: 'older remote',
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [remoteUpdate],
deletes: [],
);
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});
@ -192,17 +184,13 @@ void main() {
value: 'updated remotely',
);
mergeset = CrdtMergeSet(
inserts: [],
updates: [remoteUpdate],
deletes: [],
);
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeset,
mergeSet,
userId: testCrdtUserId,
);
});

View file

@ -0,0 +1,713 @@
// Uses serverpod_database and serverpod_test types already available transitively from the test setup.
// ignore_for_file: depend_on_referenced_packages
import 'dart:async';
import 'package:serverpod/serverpod.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'
as client;
import 'package:serverpod_offline_sync_test_server/src/generated/protocol.dart'
as server;
import 'package:test/test.dart';
import '../test_tools/client_session.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
initTestClientSession(withPersistentUser: true);
final clientSyncTables = [
client.Address.t,
client.Person.t,
client.Types.t,
client.Unique.t,
];
final serverSyncTables = [
server.Address.t,
server.Person.t,
server.Types.t,
server.Unique.t,
];
late client.Client testClient;
late CrdtDatabaseSession serverSession;
late CrdtDatabaseSession clientSession;
withServerpod(
'Given a server and client CRDT session that are initialized with the same sync tables',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
rawServerSession.serverpod
..initializeCrdtSync(syncTables: serverSyncTables)
..authenticationHandler = (session, token) async => AuthenticationInfo(
testCrdtUserId.toString(),
<Scope>{},
authId: const Uuid().v4(),
);
setUp(() async {
testClient = client.Client(
'http://localhost:${rawServerSession.server.port}',
)..authKeyProvider = TestClientAuthKeyProvider();
clientSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: clientSyncTables,
persistentUserId: testCrdtUserId,
);
await clientSession.db.initialize();
serverSession = CrdtDatabaseSession.wraps(
rawServerSession,
syncTables: serverSyncTables,
);
await serverSession.db.initialize();
});
tearDown(() async {
await serverSession.clearUserTables();
});
group('and no local or remote pending changes exist', () {
test(
'when client syncOnce is called '
'then the sync completes.',
() async {
await expectLater(
testClient.crdt
.syncOnce(clientSession)
.timeout(const Duration(seconds: 3)),
completes,
);
},
);
test(
'when client syncContinuously is called '
'then neither side reports a successful merge.',
() async {
var mergeSuccessCount = 0;
final syncSession = testClient.crdt.syncContinuously(
clientSession,
onMergeSuccess: (_) => mergeSuccessCount++,
);
addTearDown(syncSession.cancel);
await Future<void>.delayed(const Duration(seconds: 2));
expect(mergeSuccessCount, 0);
},
);
test(
'when client syncContinuously runs '
'then neither side sends merge chunk or batch boundary events over the stream.',
() async {
final clientToServer = StreamController<CrdtSyncStreamEvent>();
final serverToClient = StreamController<CrdtSyncStreamEvent>();
final clientOutboundEvents = <CrdtSyncStreamEvent>[];
final serverOutboundEvents = <CrdtSyncStreamEvent>[];
addTearDown(() async {
await clientToServer.close();
await serverToClient.close();
});
final clientSync = CrdtSync(
syncTables: clientSyncTables,
serializationManager: clientSession.db.serializationManager,
);
final clientSubscription = clientSync
.sync(
clientSession,
userId: testCrdtUserId,
inbound: serverToClient.stream,
once: false,
)
.listen((event) {
clientOutboundEvents.add(event);
clientToServer.add(event);
});
final serverSubscription = CrdtSync.instance
.sync(
serverSession,
userId: testCrdtUserId,
inbound: clientToServer.stream,
once: false,
)
.listen((event) {
serverOutboundEvents.add(event);
serverToClient.add(event);
});
await Future<void>.delayed(const Duration(seconds: 2));
await clientToServer.close();
await serverToClient.close();
await clientSubscription.cancel();
await serverSubscription.cancel();
for (final events in [clientOutboundEvents, serverOutboundEvents]) {
expect(events.whereType<CrdtSyncMergeChunk>(), isEmpty);
expect(events.whereType<CrdtSyncEndOfBatch>(), isEmpty);
expect(events.whereType<CrdtSyncClose>(), isEmpty);
expect(events.whereType<CrdtSyncIdleTimeout>(), isEmpty);
}
},
);
});
group('and an inserted client person', () {
late UuidValue personId;
setUp(() async {
personId = const Uuid().v7obj();
await client.Person.db.insertRow(
clientSession,
client.Person(id: personId, name: 'client-person'),
);
});
test(
'when client syncOnce is called '
'then the server merges the client pending changes.',
() async {
await testClient.crdt.syncOnce(clientSession);
final serverPerson = await server.Person.db.findById(
serverSession,
personId,
);
expect(serverPerson, isNotNull);
expect(serverPerson!.id, personId);
expect(serverPerson.name, 'client-person');
},
);
test(
'when client syncContinuously is called '
'then the server merges the client pending changes.',
() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel);
await _waitUntil(() async {
final serverPerson = await server.Person.db.findById(
serverSession,
personId,
);
return serverPerson != null;
});
await syncSession.cancel();
final serverPerson = await server.Person.db.findById(
serverSession,
personId,
);
expect(serverPerson, isNotNull);
expect(serverPerson!.id, personId);
expect(serverPerson.name, 'client-person');
},
);
test(
'when a running syncContinuously session is cancelled '
'then done completes without hanging.',
() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
await syncSession.cancel();
await expectLater(syncSession.done, completes);
},
);
});
group('and an inserted server person', () {
late UuidValue personId;
setUp(() async {
personId = const Uuid().v7obj();
await serverSession.db.transactionForUser(testCrdtUserId, (tx) async {
await server.Person.db.insertRow(
serverSession,
server.Person(id: personId, name: 'server-person'),
transaction: tx,
);
});
});
test(
'when client syncContinuously is called '
'then the client merges the server pending changes.',
() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel);
await _waitUntil(() async {
final clientPerson = await client.Person.db.findById(
clientSession,
personId,
);
return clientPerson != null;
});
await syncSession.cancel();
final clientPerson = await client.Person.db.findById(
clientSession,
personId,
);
expect(clientPerson, isNotNull);
expect(clientPerson!.id, personId);
expect(clientPerson.name, 'server-person');
},
);
});
group('and an inserted client and server person', () {
late UuidValue clientPersonId;
late UuidValue serverPersonId;
setUp(() async {
clientPersonId = const Uuid().v7obj();
serverPersonId = const Uuid().v7obj();
await client.Person.db.insertRow(
clientSession,
client.Person(id: clientPersonId, name: 'client-person'),
);
await serverSession.db.transactionForUser(testCrdtUserId, (tx) async {
await server.Person.db.insertRow(
serverSession,
server.Person(id: serverPersonId, name: 'server-person'),
transaction: tx,
);
});
});
test(
'when client syncOnce is called '
'then both the client and server have the two persons.',
() async {
await testClient.crdt.syncOnce(clientSession);
final clientPerson = await client.Person.db.find(clientSession);
expect(clientPerson, hasLength(2));
expect(clientPerson.map((e) => e.id).toSet(), {
clientPersonId,
serverPersonId,
});
expect(clientPerson.map((e) => e.name).toSet(), {
'client-person',
'server-person',
});
final serverPerson = await server.Person.db.find(serverSession);
expect(serverPerson, hasLength(2));
expect(serverPerson.map((e) => e.id).toSet(), {
clientPersonId,
serverPersonId,
});
expect(serverPerson.map((e) => e.name).toSet(), {
'client-person',
'server-person',
});
},
);
test(
'when client syncContinuously is called '
'then the client merges the server pending changes.',
() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel);
await _waitUntil(() async {
final clientMergedPerson = await client.Person.db.findById(
clientSession,
serverPersonId,
);
final serverMergedPerson = await server.Person.db.findById(
serverSession,
clientPersonId,
);
return clientMergedPerson != null && serverMergedPerson != null;
});
await syncSession.cancel();
final clientPerson = await client.Person.db.find(clientSession);
expect(clientPerson, hasLength(2));
expect(clientPerson.map((e) => e.id).toSet(), {
clientPersonId,
serverPersonId,
});
expect(clientPerson.map((e) => e.name).toSet(), {
'client-person',
'server-person',
});
final serverPerson = await server.Person.db.find(serverSession);
expect(serverPerson, hasLength(2));
expect(serverPerson.map((e) => e.id).toSet(), {
clientPersonId,
serverPersonId,
});
expect(serverPerson.map((e) => e.name).toSet(), {
'client-person',
'server-person',
});
},
);
});
group('and a running client syncContinuously session', () {
late CrdtSyncSession syncSession;
setUp(() async {
syncSession = testClient.crdt.syncContinuously(clientSession);
});
tearDown(() async {
await syncSession.cancel();
});
test(
'when the session is cancelled '
'then done completes without hanging.',
() async {
await syncSession.cancel();
await expectLater(syncSession.done, completes);
},
);
test(
'when no local or remote changes exist '
'then the idle session can still be cancelled cleanly.',
() async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await syncSession.cancel();
await expectLater(syncSession.done, completes);
},
);
test(
'when a new person is inserted into the client '
'then the server merges the client pending changes.',
() async {
final clientPerson = await client.Person.db.insertRow(
clientSession,
client.Person(name: 'client-person'),
);
await expectLater(
_waitUntil(() async {
final serverPerson = await server.Person.db.findById(
serverSession,
clientPerson.id!,
);
return serverPerson != null && serverPerson.name == 'client-person';
}),
completes,
);
},
);
test(
'when a new person is inserted into the server for the same user '
'then the client merges the server pending changes.',
() async {
final serverPerson = await serverSession.db.transactionForUser(
testCrdtUserId,
(tx) async => server.Person.db.insertRow(
serverSession,
server.Person(name: 'server-person'),
transaction: tx,
),
);
await expectLater(
_waitUntil(() async {
final clientPerson = await client.Person.db.findById(
clientSession,
serverPerson.id!,
);
return clientPerson != null && clientPerson.name == 'server-person';
}),
completes,
);
},
);
test(
'when a new person is inserted into the server for a different user '
'then the client does not merge the server pending changes.',
() async {
final serverPerson = await serverSession.db.transactionForUser(
const Uuid().v7obj(),
(tx) async => server.Person.db.insertRow(
serverSession,
server.Person(name: 'server-person'),
transaction: tx,
),
);
await expectLater(
_waitUntil(() async {
final clientPerson = await client.Person.db.findById(
clientSession,
serverPerson.id!,
);
return clientPerson != null && clientPerson.name == 'server-person';
}),
throwsA(isA<TimeoutException>()),
);
},
);
});
group(
'and a running client syncContinuously session with a merge success callback',
() {
late CrdtSyncSession syncSession;
late Completer<Hlc> mergeSuccessCompleter;
setUp(() async {
mergeSuccessCompleter = Completer<Hlc>();
syncSession = testClient.crdt.syncContinuously(
clientSession,
onMergeSuccess: mergeSuccessCompleter.complete,
);
});
tearDown(() async {
await syncSession.cancel();
});
test(
'when a new person is inserted into the client '
'then the merge success callback is called.',
() async {
await client.Person.db.insertRow(
clientSession,
client.Person(name: 'client-person'),
);
await expectLater(mergeSuccessCompleter.future, completes);
},
);
test(
'when a new person is inserted into the server for the same user '
'then the merge success callback is called.',
() async {
await serverSession.db.transactionForUser(
testCrdtUserId,
(tx) async => server.Person.db.insertRow(
serverSession,
server.Person(name: 'server-person'),
transaction: tx,
),
);
await expectLater(mergeSuccessCompleter.future, completes);
},
);
},
);
},
);
withServerpod(
'Given a server and client CRDT session that are initialized with the same sync tables and a continuous sync interval',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
const syncInterval = Duration(milliseconds: 400);
rawServerSession.serverpod
..initializeCrdtSync(
syncTables: serverSyncTables,
continuousSyncInterval: syncInterval,
)
..authenticationHandler = (session, token) async => AuthenticationInfo(
testCrdtUserId.toString(),
<Scope>{},
authId: const Uuid().v4(),
);
setUp(() async {
testClient = client.Client(
'http://localhost:${rawServerSession.server.port}',
)..authKeyProvider = TestClientAuthKeyProvider();
clientSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: clientSyncTables,
persistentUserId: testCrdtUserId,
continuousSyncInterval: syncInterval,
);
await clientSession.db.initialize();
});
test(
'when a client change is inserted after a sync round '
'then the next merge chunk waits for the configured interval.',
() async {
var mergeSuccessCount = 0;
final firstMergeCompleter = Completer<void>();
final secondMergeCompleter = Completer<void>();
final syncSession = testClient.crdt.syncContinuously(
clientSession,
onMergeSuccess: (_) {
switch (++mergeSuccessCount) {
case 1:
firstMergeCompleter.complete();
case 2:
secondMergeCompleter.complete();
default:
throw Exception('Unexpected merge success count: $mergeSuccessCount');
}
},
);
addTearDown(syncSession.cancel);
await client.Person.db.insertRow(
clientSession,
client.Person(
id: const Uuid().v7obj(),
name: 'first-person',
),
);
await expectLater(
firstMergeCompleter.future.timeout(const Duration(seconds: 3)),
completes,
);
final stopwatch = Stopwatch()..start();
await client.Person.db.insertRow(
clientSession,
client.Person(
id: const Uuid().v7obj(),
name: 'second-person',
),
);
await Future<void>.delayed(syncInterval * 0.8);
expect(secondMergeCompleter.isCompleted, isFalse);
await expectLater(
secondMergeCompleter.future.timeout(const Duration(seconds: 3)),
completes,
);
expect(stopwatch.elapsed, greaterThanOrEqualTo(syncInterval));
},
);
},
);
withServerpod(
'Given a server and client CRDT session that are initialized with different sync tables',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
rawServerSession.serverpod
..initializeCrdtSync(syncTables: serverSyncTables)
..authenticationHandler = (session, token) async => AuthenticationInfo(
testCrdtUserId.toString(),
<Scope>{},
authId: const Uuid().v4(),
);
setUp(() async {
testClient = client.Client(
'http://localhost:${rawServerSession.server.port}',
)..authKeyProvider = TestClientAuthKeyProvider();
clientSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [client.Address.t, client.Person.t],
persistentUserId: testCrdtUserId,
);
await clientSession.db.initialize();
serverSession = CrdtDatabaseSession.wraps(
rawServerSession,
syncTables: serverSyncTables,
);
await serverSession.db.initialize();
});
tearDown(() async {
await serverSession.clearUserTables();
});
test(
'when client syncOnce is called '
'then SyncTablesHashMismatchException is thrown.',
() async {
await expectLater(
testClient.crdt.syncOnce(clientSession),
throwsA(isA<SyncTablesHashMismatchException>()),
);
},
);
test(
'when client syncContinuously is called '
'then SyncTablesHashMismatchException is thrown.',
() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
addTearDown(syncSession.cancel);
await expectLater(
syncSession.done,
throwsA(isA<SyncTablesHashMismatchException>()),
);
},
);
},
);
}
Future<void> _waitUntil(
Future<bool> Function() condition, {
Duration timeout = const Duration(seconds: 3),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await condition()) return;
await Future<void>.delayed(const Duration(milliseconds: 50));
}
throw TimeoutException('Condition was not met within $timeout.');
}
class TestClientAuthKeyProvider implements ClientAuthKeyProvider {
@override
Future<String?> get authHeaderValue async => 'Bearer token';
}

View file

@ -0,0 +1,202 @@
// Uses serverpod_database types already available transitively from the test setup.
// ignore_for_file: depend_on_referenced_packages
import 'package:serverpod_database/serverpod_database.dart' hide Protocol;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
hide Protocol;
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
void main() {
final protocol = Protocol();
final syncTables = [Address.t, Person.t, Unique.t];
final addressDefinition = protocol.getTableDefinition(Address.t);
final personDefinition = protocol.getTableDefinition(Person.t);
final uniqueDefinition = protocol.getTableDefinition(Unique.t);
final typesDefinition = protocol.getTableDefinition(Types.t);
group('Given table definitions with different table and schema elements order', () {
final baseDefinitions = [
addressDefinition,
personDefinition,
uniqueDefinition,
];
final changedDefinitions = [
for (final definition in baseDefinitions)
definition.copyWith(
columns: definition.columns.reversed.toList(),
foreignKeys: definition.foreignKeys.reversed.toList(),
indexes: definition.indexes.reversed.toList(),
),
];
test(
'when comparing the definition hashes '
'then their hash values are equal.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: baseDefinitions,
);
final changedHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: changedDefinitions,
);
expect(changedHash, baseHash);
},
);
});
group('Given table definitions with different columns', () {
final baseDefinitions = [
personDefinition,
];
final changedDefinitions = [
personDefinition.copyWith(
columns: [
...personDefinition.columns,
personDefinition.columns.last.copyWith(name: 'shadowColumn'),
],
),
];
test(
'when comparing the definition hashes '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: baseDefinitions,
);
final changedHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: changedDefinitions,
);
expect(changedHash, isNot(baseHash));
},
);
});
group('Given table definitions with different foreign keys', () {
final baseDefinitions = [
addressDefinition,
];
final changedDefinitions = [
addressDefinition.copyWith(
foreignKeys: [
addressDefinition.foreignKeys.single.copyWith(
onDelete: ForeignKeyAction.cascade,
),
],
),
];
test(
'when comparing the definition hashes '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: baseDefinitions,
);
final changedHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: changedDefinitions,
);
expect(changedHash, isNot(baseHash));
},
);
});
group('Given table definitions with different unique indexes', () {
final baseDefinitions = [
addressDefinition,
];
final changedDefinitions = [
addressDefinition.copyWith(
indexes: [
addressDefinition.indexes.single.copyWith(
elements: [
addressDefinition.indexes.single.elements.single.copyWith(
definition: 'street',
),
],
),
],
),
];
test(
'when comparing the definition hashes '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: baseDefinitions,
);
final changedHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: changedDefinitions,
);
expect(changedHash, isNot(baseHash));
},
);
});
group('Given table definitions with different non-unique indexes', () {
final baseDefinitions = [
typesDefinition,
];
final changedDefinitions = [
typesDefinition.copyWith(
indexes: [
typesDefinition.indexes.single.copyWith(
elements: [
typesDefinition.indexes.single.elements.single.copyWith(
definition: 'aText',
),
],
),
],
),
];
test(
'when comparing the definition hashes '
'then their hash values are equal.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: baseDefinitions,
);
final changedHash = CrdtSync.computeSyncTablesHash(
syncTables,
tableDefinitions: changedDefinitions,
);
expect(changedHash, baseHash);
},
);
});
}
extension on DatabaseSerializationManager {
TableDefinition getTableDefinition(Table table) {
return getTargetTableDefinitions()
.firstWhere((def) => def.name == table.tableName)
.copyWith();
}
}

View file

@ -0,0 +1,405 @@
import 'dart:async';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:test/test.dart';
void main() {
group('Given an HlcManager', () {
final userId = const Uuid().v7obj();
final nodeId = const Uuid().v7obj();
final lastHlc = Hlc(DateTime.utc(2026, 5, 10, 12), 7, nodeId);
final manager = HlcManager.forUser(
CrdtUser(
id: 42,
uuidUserId: userId,
currentNodeId: 9,
currentNode: CrdtNode(
id: 9,
userId: 42,
uuidNodeId: nodeId,
lastReceivedHlc: lastHlc,
),
),
);
test(
'when getting the node then the persisted node shape is preserved.',
() {
final node = manager.getNode();
expect(node.id, 9);
expect(node.userId, 42);
expect(node.uuidNodeId, nodeId);
expect(node.lastReceivedHlc, lastHlc);
},
);
});
group('Given a sync stream with complete framed sync batches', () {
final rowId = const Uuid().v7obj();
final requesterNodeId = const Uuid().v7obj();
final row = CrdtNode(userId: 1, uuidNodeId: rowId);
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeInsert(
hlcDatetime: DateTime.utc(2026, 5, 10, 12),
hlcCounter: 1,
tableName: 'person',
uuidRowId: rowId,
uuidNodeId: requesterNodeId,
data: row,
),
],
),
CrdtSyncEndOfBatch(),
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 13),
hlcCounter: 2,
tableName: 'person',
uuidRowId: rowId,
uuidNodeId: requesterNodeId,
isDeleted: true,
),
],
),
CrdtSyncEndOfBatch(),
]);
test(
'when collecting batches then each framed batch becomes a merge set.',
() async {
final iterator = StreamIterator(stream);
final firstBatch = await iterator.collectNextBatch();
final secondBatch = await iterator.collectNextBatch();
expect(firstBatch, isNotNull);
expect(secondBatch, isNotNull);
expect(firstBatch!.inserts, hasLength(1));
expect(firstBatch.deletes, isEmpty);
expect(secondBatch!.inserts, isEmpty);
expect(secondBatch.deletes, hasLength(1));
expect(
iterator.collectNextBatch,
throwsA(isA<CrdtSyncStreamClosedException>()),
);
},
);
test(
'when collecting the next batch then batched merge changes are preserved.',
() async {
final singleBatchStream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeInsert(
hlcDatetime: DateTime.utc(2026, 5, 10, 16),
hlcCounter: 1,
tableName: 'person',
uuidRowId: rowId,
uuidNodeId: requesterNodeId,
data: row,
),
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 17),
hlcCounter: 2,
tableName: 'person',
uuidRowId: rowId,
uuidNodeId: requesterNodeId,
isDeleted: true,
),
],
),
CrdtSyncEndOfBatch(),
]);
final batch = await StreamIterator(singleBatchStream).collectNextBatch();
expect(batch, isNotNull);
expect(batch, hasLength(2));
},
);
});
test(
'Given an empty stream when collecting the next batch then collection fails.',
() async {
const stream = Stream<CrdtSyncStreamEvent>.empty();
final iterator = StreamIterator(stream);
expect(
iterator.collectNextBatch,
throwsA(isA<CrdtSyncStreamClosedException>()),
);
},
);
test(
'Given an empty stream '
'when collecting the next batch allowing close before batch '
'then null is returned.',
() async {
const stream = Stream<CrdtSyncStreamEvent>.empty();
final iterator = StreamIterator(stream);
final batch = await iterator.collectNextBatch(allowCloseBeforeBatch: true);
expect(batch, isNull);
},
);
test(
'Given a stream that ends without CrdtSyncEndOfBatch '
'when collecting the next batch '
'then collection fails.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
isDeleted: true,
),
],
),
]);
final iterator = StreamIterator(stream);
expect(
iterator.collectNextBatch,
throwsA(isA<CrdtSyncStreamClosedException>()),
);
},
);
test(
'Given a stream that ends after a merge batch without CrdtSyncEndOfBatch '
'when collecting the next batch allowing close before batch '
'then collection fails.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
isDeleted: true,
),
],
),
]);
final iterator = StreamIterator(stream);
expect(
() => iterator.collectNextBatch(allowCloseBeforeBatch: true),
throwsA(isA<CrdtSyncStreamClosedException>()),
);
},
);
test(
'Given a stream that starts with CrdtSyncEndOfBatch '
'when collecting the next batch '
'then an empty merge set is returned.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncEndOfBatch(),
]);
final iterator = StreamIterator(stream);
final batch = await iterator.collectNextBatch();
expect(batch, isNotNull);
expect(batch, isEmpty);
},
);
test(
'Given a stream that is idle before a batch starts '
'when collecting the next batch '
'then an empty merge set is returned.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncIdleTimeout(),
]);
final iterator = StreamIterator(stream);
final batch = await iterator.collectNextBatch();
expect(batch, isNotNull);
expect(batch, isEmpty);
},
);
test(
'Given a stream that is idle after a merge batch '
'when collecting the next batch '
'then the idle event does not end the batch.',
() async {
final rowId = const Uuid().v7obj();
final requesterNodeId = const Uuid().v7obj();
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: rowId,
uuidNodeId: requesterNodeId,
isDeleted: true,
),
],
),
CrdtSyncIdleTimeout(),
CrdtSyncEndOfBatch(),
]);
final iterator = StreamIterator(stream);
final batch = await iterator.collectNextBatch();
expect(batch, isNotNull);
expect(batch!.deletes, hasLength(1));
},
);
test(
'Given a stream with CrdtSyncClose before CrdtSyncEndOfBatch '
'when collecting the next batch '
'then collection fails with CrdtSyncUnexpectedEventException.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
isDeleted: true,
),
],
),
CrdtSyncClose(),
]);
final iterator = StreamIterator(stream);
expect(
iterator.collectNextBatch,
throwsA(
isA<CrdtSyncUnexpectedEventException>()
.having(
(exception) => exception.received,
'received',
isA<CrdtSyncClose>(),
)
.having(
(exception) => exception.toString(),
'toString',
'CrdtSyncUnexpectedEventException: expected "CrdtSyncMergeChunk" '
'or "CrdtSyncEndOfBatch", but '
'received "CrdtSyncClose" instead.',
),
),
);
},
);
test(
'Given an empty stream '
'when expecting CrdtSyncConnect '
'then it throws a CrdtSyncStreamClosedException.',
() async {
const stream = Stream<CrdtSyncStreamEvent>.empty();
final iterator = StreamIterator(stream);
expect(
() => iterator.moveAndThrowIfNot<CrdtSyncConnect>(),
throwsA(
isA<CrdtSyncStreamClosedException>().having(
(exception) => exception.toString(),
'toString',
'CrdtSyncStreamClosedException: sync stream closed before '
'"CrdtSyncConnect" event.',
),
),
);
},
);
test(
'Given a stream starting with CrdtSyncEndOfBatch '
'when expecting CrdtSyncConnect '
'then it throws a CrdtSyncUnexpectedEventException.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncEndOfBatch(),
]);
final iterator = StreamIterator(stream);
expect(
() => iterator.moveAndThrowIfNot<CrdtSyncConnect>(),
throwsA(
isA<CrdtSyncUnexpectedEventException>()
.having(
(exception) => exception.received,
'received',
isA<CrdtSyncEndOfBatch>(),
)
.having(
(exception) => exception.toString(),
'toString',
'CrdtSyncUnexpectedEventException: expected "CrdtSyncConnect", but '
'received "CrdtSyncEndOfBatch" instead.',
),
),
);
},
);
test(
'Given a stream starting with CrdtSyncConnect '
'when expecting CrdtSyncClose '
'then it throws a CrdtSyncUnexpectedEventException.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncConnect(
localNodeId: const Uuid().v7obj(),
syncTablesHash: 'hash',
),
]);
final iterator = StreamIterator(stream);
expect(
() => iterator.moveAndThrowIfNot<CrdtSyncClose>(),
throwsA(
isA<CrdtSyncUnexpectedEventException>()
.having(
(exception) => exception.received,
'received',
isA<CrdtSyncConnect>(),
)
.having(
(exception) => exception.toString(),
'toString',
'CrdtSyncUnexpectedEventException: expected "CrdtSyncClose", but '
'received "CrdtSyncConnect" instead.',
),
),
);
},
);
}

View file

@ -0,0 +1,217 @@
import 'dart:typed_data';
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:test/test.dart';
import '../test_tools/client_session.dart';
void main() {
initTestClientSession();
final syncTables = [
Address.t,
Person.t,
Types.t,
Unique.t,
];
late CrdtDatabaseSession crdtSession;
late CrdtSync crdtSync;
setUp(() async {
crdtSession = CrdtDatabaseSession.wraps(testSession, syncTables: syncTables);
await crdtSession.db.initialize();
crdtSync = CrdtSync(
syncTables: syncTables,
serializationManager: testSession.db.serializationManager,
);
});
group('Given an inserted typed CRDT row', () {
late Types insertedRow;
setUp(() async {
insertedRow = await crdtSession.db.transactionForUser(
testCrdtUserId,
(tx) async {
return Types.db.insertRow(
crdtSession,
Types(
id: const Uuid().v7obj(),
aBool: true,
aDateTime: DateTime.utc(2026, 5, 8, 12, 34, 56),
aText: 'text',
anInt: 42,
anInt64: BigInt.parse('9007199254740993'),
aReal: 3.14,
aBlob: [1, 2, 3, 4].toBlob(),
anEnum: TypesEnum.gamma,
optionalText: 'optional',
optionalUuid: const Uuid().v7obj(),
),
transaction: tx,
);
},
);
});
test(
'when pending changes are collected '
'then the row payload roundtrips with its original Dart type.',
() async {
final mergeSet = await crdtSync
.collectPendingChanges(
testSession,
otherNodeId: const Uuid().v7obj(),
userId: testCrdtUserId,
)
.toList();
final insert = mergeSet.inserts.single;
expect(insert.uuidRowId, insertedRow.id);
expect(insert.data, isA<Types>());
final recoveredData = insert.data as Types;
expect(recoveredData.aDateTime, insertedRow.aDateTime);
expect(recoveredData.optionalUuid, insertedRow.optionalUuid);
expect(recoveredData.anInt64, insertedRow.anInt64);
expect(recoveredData.anEnum, TypesEnum.gamma);
expect(recoveredData.aBlob.toBytes(), insertedRow.aBlob.toBytes());
},
);
});
group('Given inserted CRDT rows and a sync batch size of two', () {
late CrdtSync batchingCrdtSync;
setUp(() async {
batchingCrdtSync = CrdtSync(
syncTables: syncTables,
serializationManager: testSession.db.serializationManager,
syncBatchSize: 2,
);
for (var i = 0; i < 3; i++) {
await crdtSession.db.transactionForUser(
testCrdtUserId,
(tx) async {
await Person.db.insertRow(
crdtSession,
Person(id: const Uuid().v7obj(), name: 'person-$i'),
transaction: tx,
);
},
);
}
});
test(
'when streaming an outbound sync batch '
'then pending changes are chunked into merge chunk events.',
() async {
final events = await batchingCrdtSync
.streamOutboundBatch(
testSession,
userId: testCrdtUserId,
peerNodeId: const Uuid().v7obj(),
sinceHlc: Hlc.zero(const Uuid().v7obj()),
)
.toList();
final mergeChunks = events.whereType<CrdtSyncMergeChunk>().toList();
expect(mergeChunks, hasLength(2));
expect(mergeChunks.map((chunk) => chunk.changes.length), [2, 1]);
expect(events.last, isA<CrdtSyncEndOfBatch>());
},
);
});
group('Given an updated typed CRDT row', () {
late Types row;
late Types updatedRow;
setUp(() async {
row = await crdtSession.db.transactionForUser(testCrdtUserId, (
tx,
) async {
return Types.db.insertRow(
crdtSession,
Types(
id: const Uuid().v7obj(),
aBool: true,
aDateTime: DateTime.utc(2026, 1, 1),
aText: 'text',
anInt: 42,
anInt64: BigInt.from(99),
aReal: 3.14,
aBlob: [0, 1, 2].toBlob(),
anEnum: TypesEnum.alpha,
optionalText: 'optional',
),
transaction: tx,
);
});
updatedRow = row.copyWith(
aDateTime: DateTime.utc(2027, 1, 2, 3, 4, 5),
optionalUuid: const Uuid().v7obj(),
anInt64: BigInt.parse('12345678901234567890'),
aBlob: [7, 8, 9].toBlob(),
anEnum: TypesEnum.beta,
);
await crdtSession.db.transactionForUser(
testCrdtUserId,
(tx) async {
await Types.db.updateRow(
crdtSession,
updatedRow,
transaction: tx,
);
},
);
});
test(
'when pending changes are collected '
'then the field values roundtrip with their original Dart types.',
() async {
final mergeSet = await crdtSync
.collectPendingChanges(
testSession,
otherNodeId: const Uuid().v7obj(),
userId: testCrdtUserId,
)
.toList();
final updates = {
for (final update in mergeSet.updates) update.columnName: update,
};
final aDateTime = updates[Types.t.aDateTime.columnName]!;
final optionalUuid = updates[Types.t.optionalUuid.columnName]!;
final anInt64 = updates[Types.t.anInt64.columnName]!;
final anEnum = updates[Types.t.anEnum.columnName]!;
final aBlob = updates[Types.t.aBlob.columnName]!;
expect(aDateTime.value, updatedRow.aDateTime);
expect(optionalUuid.value, updatedRow.optionalUuid);
expect(anInt64.value, updatedRow.anInt64);
expect(anEnum.value, TypesEnum.beta);
expect((aBlob.value as ByteData).toBytes(), updatedRow.aBlob.toBytes());
},
);
});
}
extension on List<int> {
ByteData toBlob() => ByteData.sublistView(Uint8List.fromList(this));
}
extension on ByteData {
List<int> toBytes() => buffer.asUint8List();
}

View file

@ -8,10 +8,12 @@ import 'package:test/test.dart';
const _clientUrl = 'http://localhost:8081/';
late Client _testClient;
late ClientDatabaseSession _testSession;
late CrdtDatabaseSession _crdtSession;
late Directory _tempDir;
late UuidValue _testCrdtUserId;
late bool _withPersistentUser;
/// A fresh [ClientDatabaseSession] for each test that is automatically closed
/// and removed once no longer needed.
@ -34,34 +36,22 @@ UuidValue get testCrdtUserId => _testCrdtUserId;
/// [Database.transaction] instead of [CrdtDatabase.transactionForUser] (client
/// mode).
void initTestClientSession({bool withPersistentUser = false}) {
_withPersistentUser = withPersistentUser;
setUpAll(() async {
_tempDir = await Directory.systemTemp.createTemp('offline_first_');
_testSession = await Client(_clientUrl).createSession(
_testClient = Client(_clientUrl);
_testSession = await _testClient.createSession(
p.join(_tempDir.path, 'test.db'),
isDebugMode: true,
);
_testCrdtUserId = const Uuid().v7obj();
_crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [
Address.t,
City.t,
Company.t,
Organization.t,
Person.t,
Town.t,
Unique.t,
UniqueUuid.t,
],
persistentUserId: withPersistentUser ? _testCrdtUserId : null,
);
await _initialize();
});
tearDown(() async {
await _clearUserTables(_testSession);
await _testSession.clearUserTables();
await _initialize();
});
@ -74,22 +64,39 @@ void initTestClientSession({bool withPersistentUser = false}) {
}
Future<void> _initialize() async {
CrdtUserManager.clearCache();
HlcManager.reset();
_crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [
Address.t,
City.t,
Company.t,
Organization.t,
Person.t,
Town.t,
Unique.t,
UniqueUuid.t,
],
persistentUserId: _withPersistentUser ? _testCrdtUserId : null,
);
await _crdtSession.db.initialize();
}
Future<void> _clearUserTables(DatabaseSession session) async {
await session.db.unsafeExecute('PRAGMA foreign_keys = OFF');
final result = await session.db.unsafeQuery('''
extension ClearDatabaseTables on DatabaseSession {
/// Clears all user tables from the database for tests teardown.
Future<void> clearUserTables() async {
await db.unsafeExecute('PRAGMA foreign_keys = OFF');
final result = await db.unsafeQuery('''
SELECT name
FROM sqlite_master
WHERE (type = 'table') AND (name NOT LIKE 'serverpod_%')
''');
for (final row in result) {
final tableName = row[0] as String;
await session.db.unsafeExecute('DELETE FROM "$tableName"');
for (final row in result) {
final tableName = row[0] as String;
await db.unsafeExecute('DELETE FROM "$tableName"');
}
await db.unsafeExecute('PRAGMA foreign_keys = ON');
}
await session.db.unsafeExecute('PRAGMA foreign_keys = ON');
}