feat: Implement the sync protocol (#54)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
83fbef8d2c
commit
f85239c3a4
13 changed files with 3031 additions and 306 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -24,5 +24,5 @@ database:
|
|||
filePath: test.db
|
||||
|
||||
sessionLogs:
|
||||
persistentEnabled: true
|
||||
persistentEnabled: false
|
||||
consoleEnabled: true
|
||||
|
|
|
|||
8
test/serverpod_offline_sync_test_server/lib/src/generated/analysis_options.yaml
generated
Normal file
8
test/serverpod_offline_sync_test_server/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
|
||||
|
|
@ -14,3 +14,8 @@ fields:
|
|||
anEnum: TypesEnum?
|
||||
optionalText: String?
|
||||
optionalUuid: UuidValue?
|
||||
|
||||
indexes:
|
||||
types_a_text_idx:
|
||||
fields: aText
|
||||
unique: false
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue