chore: Move the drift_offline_first to legacy in favor of Serverpod implementation (#32)

This commit is contained in:
Marcelo Mendonça Soares 2026-04-05 23:20:21 -03:00 • committed by Marcelo Soares
commit cbc472523b
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
10 changed files with 13575 additions and 284 deletions

View file

@ -0,0 +1,124 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
import 'crdt_test_utils.dart';
void main() {
group('Given a data object with every column type', () {
late CrdtDatabase crdtDb;
late TableWithEveryColumnTypeData createdData;
late Hlc testHlc;
setUp(() async {
createdData = _createTestData();
(_, crdtDb, _) = database.crdtContext;
testHlc = Hlc.now(crdtDb.nodeId);
});
group('when converting the data to CRDT data entries', () {
late Iterable<CrdtDataEntry> entries;
setUp(() async {
entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
});
test('then it creates an entry for each column.', () async {
expect(
entries.map((e) => e.columnName).toSet(),
equals(
database.tableWithEveryColumnType.$columns.map((c) => c.$name).toSet(),
),
);
});
test('then each entry has the correct user ID.', () async {
expect(entries.map((e) => e.userId), everyElement(equals(crdtDb.userId)));
});
test('then each entry has the correct table name.', () async {
expect(
entries.map((e) => e.tblName),
everyElement(equals('table_with_every_column_type')),
);
});
test('then each entry has the correct HLC timestamp.', () async {
expect(entries.map((e) => e.hlcTimestamp), everyElement(equals(testHlc)));
});
test('then all entries have expected column names and values.', () async {
final mappedData = await database.tableWithEveryColumnType.fromCrdtDataEntries(
entries,
);
expect(mappedData.single, equals(createdData));
});
});
});
group('Given a changeset converted from a data object with every column type', () {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late TableWithEveryColumnTypeData createdData;
late List<CrdtDataEntry> expectedChangeset;
late Hlc testHlc;
setUp(() async {
createdData = _createTestData();
(crdt, crdtDb, _) = database.crdtContext;
// Perform an insert to the database to get the exact changeset that would
// be generated by the CRDT triggers.
await database.tableWithEveryColumnType.insertOne(createdData);
expectedChangeset = await crdtDb.managers.crdtDataTable
.filter(
(f) => f.columnName.equals(crdtDb.sqlBuilder.isDeletedColumnName).not(),
)
.get();
testHlc = expectedChangeset.first.hlcTimestamp;
await crdtDb.crdtDataTable.deleteAll();
});
group('when saving the changeset to the CRDT database', () {
late Iterable<CrdtDataEntry> entries;
setUp(() async {
entries = crdtDb.convertToCrdtDataEntry(createdData, testHlc);
await crdt.saveChangeset(entries, [testHlc]);
});
test('then the saved data is equal to the generated by CRDT triggers.', () async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
// The list object is not equal to the other objects because it is a
// different instance. So we remove it from the list and compare the
// it separately.
final savedListObject = savedChangeset.removeEntry('a_blob');
final expectedListObject = expectedChangeset.removeEntry('a_blob');
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
expect(
savedListObject.unwrappedValue,
equals(expectedListObject.unwrappedValue),
);
});
});
});
}
TableWithEveryColumnTypeData _createTestData() => TableWithEveryColumnTypeData(
id: const RowId(1),
aBool: true,
aDateTime: DateTime.now(),
aText: 'test',
anInt: 1,
anInt64: BigInt.one,
aReal: 1.0,
aBlob: Uint8List.fromList([1, 2, 3]),
anIntEnum: TodoStatus.open,
aTextWithConverter: MyCustomObject('test'),
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
);

View file

@ -0,0 +1,83 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
void main() {
group('Given CRDT database for a database with data with every column type', () {
late CrdtDatabase crdtDb;
late String rowId;
late TableWithEveryColumnTypeData createdData;
setUp(() async {
final createdRowId = await database.managers.tableWithEveryColumnType.create(
(t) => t(
aBool: const Value(true),
aDateTime: Value(DateTime.now()),
aText: const Value('test'),
anInt: const Value(1),
anInt64: Value(BigInt.one),
aReal: const Value(1.0),
aBlob: Value(Uint8List.fromList([1, 2, 3])),
anIntEnum: const Value(TodoStatus.open),
aTextWithConverter: Value(MyCustomObject('test')),
aUuid: Value(UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000')),
),
);
(_, crdtDb, _) = database.crdtContext;
rowId = createdRowId.toString();
createdData = await database.managers.tableWithEveryColumnType.getSingle();
});
test('when getting the data from the CRDT data table of the CRDT database '
'then it rebuilds the data perfectly.', () async {
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
rowId,
);
expect(data, equals(createdData));
});
test(
'when trying to get the data from the CRDT data table with a non-existent row id '
'then it returns null.',
() async {
final data = await crdtDb.getSingleFromCrdtData<TableWithEveryColumnTypeData>(
'non-existent-row-id',
);
expect(data, isNull);
},
);
test('when trying to get the data from the CRDT data table with multiple row ids '
'then it returns the data for the existing row ids.', () async {
final data = await crdtDb.getFromCrdtData<TableWithEveryColumnTypeData>(
[rowId, 'non-existent-row-id'],
);
expect(data, equals([createdData]));
});
test(
'when trying to get the data from the CRDT data table for a non-synchronized table '
'then it throws an error.',
() async {
expect(
() => crdtDb.getSingleFromCrdtData<WithCustomTypeData>(rowId),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
'Table for type "WithCustomTypeData" not found in synchronized tables.',
),
),
);
},
);
});
}

View file

@ -0,0 +1,176 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
import 'crdt_test_utils.dart';
void main() {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late String otherNodeId;
group(
'Given CRDT for an empty database and a saved changeset during a merge operation',
() {
late TableWithEveryColumnTypeData testData;
late List<CrdtDataEntry> expectedChangeset;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
testData = TableWithEveryColumnTypeData(
id: const RowId(1),
aBool: true,
aDateTime: DateTime.now(),
aText: 'test',
anInt: 1,
anInt64: BigInt.one,
aReal: 1.0,
aBlob: Uint8List.fromList([1, 2, 3]),
anIntEnum: TodoStatus.open,
aTextWithConverter: MyCustomObject('test'),
aUuid: UuidValue.fromString('123e4567-e89b-12d3-a456-426614174000'),
);
await database.tableWithEveryColumnType.insertOne(testData);
await database.tableWithEveryColumnType.deleteAll();
expectedChangeset = await crdtDb.managers.crdtDataTable.get();
});
group('when syncing tables to the database', () {
setUp(() async {
await crdtDb.syncCrdtDataToTables();
});
test(
'then the data is inserted into the database according to the changeset.',
() async {
final savedData = await database.managers.tableWithEveryColumnType
.getSingle();
expect(savedData, equals(testData));
},
);
test(
'then no changes are made to the CRDT data table due to the sync.',
() async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
// The list object is not equal to the other objects because it is a
// different instance. So we remove it from the list and compare the
// it separately.
final savedListObject = savedChangeset.removeEntry('a_blob');
final expectedListObject = expectedChangeset.removeEntry('a_blob');
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
expect(
savedListObject.unwrappedValue,
equals(expectedListObject.unwrappedValue),
);
},
);
});
},
);
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> lateOldChangeset;
late Hlc lastHlc;
late Hlc lateOldHlc;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
lastHlc = Hlc.now(otherNodeId);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]);
lateOldHlc = Hlc.zero(otherNodeId);
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, lateOldHlc);
});
group('when saving a changeset with older HLC', () {
setUp(() async {
await crdt.saveChangeset(lateOldChangeset, [lateOldHlc]);
});
test('then no changes are saved to the database.', () async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
});
test('then the last received HLC is not updated in the database.', () async {
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
});
});
},
);
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> newerChangeset;
late Hlc lastHlc;
late Hlc newerHlc;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
lastHlc = Hlc.now(otherNodeId);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]);
newerHlc = Hlc.now(otherNodeId);
newerChangeset = crdtDb
.convertToCrdtDataEntry(todo, newerHlc)
.where((e) => e.columnName == 'content');
});
group('when saving a changeset with some entries with newer HLC', () {
setUp(() async {
await crdt.saveChangeset(newerChangeset, [newerHlc]);
});
test('then the entries with newer HLC are saved to the database.', () async {
final expectedChangeset = [
for (final e in firstChangeset)
if (e.columnName == 'content') newerChangeset.first else e,
];
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
});
test('then the last received HLC is updated in the database.', () async {
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, newerHlc);
});
});
},
);
}

View file

@ -0,0 +1,72 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
void main() {
group('Given CRDT for an empty database', () {
late OfflineSyncCrdt crdt;
late String nodeId;
setUp(() async {
(crdt, _, nodeId) = database.crdtContext;
});
test('when getting the canonical time '
'then it returns Hlc.zero for the nodeId.', () async {
expect(await crdt.getCanonicalTime(), equals(Hlc.zero(nodeId)));
});
});
group('Given CRDT for a database with data', () {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late Hlc createdHlc;
setUp(() async {
await database.managers.todosTable.create(
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
);
(crdt, crdtDb, _) = database.crdtContext;
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
});
test('when getting the canonical time '
'then it returns the HLC timestamp of the created data.', () async {
expect(await crdt.getCanonicalTime(), equals(createdHlc));
});
});
group('Given CRDT for a database with data and a new data entry', () {
late OfflineSyncCrdt crdt;
late Hlc firstCreatedHlc;
setUp(() async {
await database.managers.todosTable.create(
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
);
(crdt, _, _) = database.crdtContext;
firstCreatedHlc = await crdt.getCanonicalTime();
await database.managers.todosTable.create(
(t) => t(content: 'test2', targetDate: Value(DateTime.now())),
);
});
test(
'when getting the canonical time '
'then the canonical time is increased since the first data entry was created.',
() async {
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, greaterThan(firstCreatedHlc));
},
);
});
}

View file

@ -0,0 +1,140 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
void main() {
group('Given CRDT for an empty database', () {
late OfflineSyncCrdt crdt;
setUp(() async {
(crdt, _, _) = database.crdtContext;
});
test('when getting a changeset '
'then it returns an empty changeset.', () async {
final changeset = await crdt.getChangeset();
expect(changeset, isEmpty);
});
test('when adding data to the database '
'then the changeset contains the data.', () async {
await database.managers.todosTable.create(
(t) => t(content: 'test'),
);
final changeset = await crdt.getChangeset();
expect(changeset, isNotEmpty);
});
});
group('Given CRDT for a database with data', () {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late String nodeId;
late String rowId;
late Hlc createdHlc;
late TodoEntry createdData;
setUp(() async {
final createdRowId = await database.managers.todosTable.create(
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
);
(crdt, crdtDb, nodeId) = database.crdtContext;
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
rowId = createdRowId.toString();
createdData = await database.managers.todosTable.getSingle();
});
group('when getting a changeset', () {
group('without any filters', () {
test(
'then it returns a changeset with one entry per column of the created data.',
() async {
final changeset = await crdt.getChangeset();
final changesetColumns = changeset.map((e) => e.columnName).toSet();
final expectedColumns = {
...database.todosTable.$columns.map((c) => c.$name),
crdtDb.sqlBuilder.isDeletedColumnName,
};
expect(changeset.length, expectedColumns.length);
expect(changesetColumns, equals(expectedColumns));
},
);
test('then the raw values are set correctly.', () async {
final changeset = await crdt.getChangeset();
final rebuiltData = database.todosTable.map({
for (final c in database.todosTable.$columns)
c.$name: changeset
.firstWhere((e) => e.rowId == rowId && e.columnName == c.$name)
.rawValue
?.rawSqlValue,
});
expect(rebuiltData, equals(createdData));
});
});
test('with onlyNodeId set to the nodeId '
'then it returns a changeset with the data for the nodeId.', () async {
final changeset = await crdt.getChangeset(onlyNodeId: nodeId);
final nodeIds = changeset.map((e) => e.hlcTimestamp.nodeId).toSet();
expect(changeset, isNotEmpty);
expect(nodeIds, equals({nodeId}));
});
test('with onlyNodeId set to a different nodeId '
'then it returns an empty changeset.', () async {
final changeset = await crdt.getChangeset(onlyNodeId: 'wrong-node');
expect(changeset, isEmpty);
});
test('with exceptNodeId set to the nodeId '
'then it returns an empty changeset.', () async {
final changeset = await crdt.getChangeset(exceptNodeId: nodeId);
expect(changeset, isEmpty);
});
test('with exceptNodeId set to a different nodeId '
'then it returns a changeset with data from other nodes.', () async {
const otherNodeId = 'other-node';
final changeset = await crdt.getChangeset(exceptNodeId: otherNodeId);
final nodeIds = changeset.map((e) => e.hlcTimestamp.nodeId).toSet();
expect(changeset, isNotEmpty);
expect(nodeIds, isNot(contains(otherNodeId)));
});
test(
'with modifiedAfter set to a timestamp before the created data '
'then it returns a changeset with the entries from the created data.',
() async {
final earlierHlc = Hlc.zero(nodeId);
final changeset = await crdt.getChangeset(modifiedAfter: earlierHlc);
expect(changeset, isNotEmpty);
expect(changeset.map((e) => e.rowId).toSet(), equals({rowId}));
},
);
test('with modifiedAfter set to a timestamp after the created data '
'then it returns an empty changeset.', () async {
final futureHlc = createdHlc.increment();
final changeset = await crdt.getChangeset(modifiedAfter: futureHlc);
expect(changeset, isEmpty);
});
});
});
}

View file

@ -0,0 +1,72 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
void main() {
group('Given CRDT for an empty database', () {
late OfflineSyncCrdt crdt;
setUp(() async {
(crdt, _, _) = database.crdtContext;
});
test('when getting the last modified timestamp '
'then it returns null since the database is empty.', () async {
final lastModified = await crdt.getLastModified();
expect(lastModified, isNull);
});
});
group('Given CRDT for a database with data', () {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late String nodeId;
late Hlc createdHlc;
setUp(() async {
await database.managers.todosTable.create(
(t) => t(content: 'test', targetDate: Value(DateTime.now())),
);
(crdt, crdtDb, nodeId) = database.crdtContext;
final allCrdtDataEntries = await crdtDb.managers.crdtDataTable.get();
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
});
group('when getting the last modified timestamp', () {
test('without any filters '
'then it returns the HLC timestamp of the created data.', () async {
final lastModified = await crdt.getLastModified();
expect(lastModified, equals(createdHlc));
});
test('with onlyNodeId set to the nodeId '
'then it returns the HLC timestamp of the created data.', () async {
final lastModified = await crdt.getLastModified(onlyNodeId: nodeId);
expect(lastModified, equals(createdHlc));
});
test('with onlyNodeId set to a different nodeId '
'then it returns null.', () async {
final lastModified = await crdt.getLastModified(onlyNodeId: 'other-node');
expect(lastModified, isNull);
});
test('with exceptNodeId set to the nodeId '
'then it returns null.', () async {
final lastModified = await crdt.getLastModified(exceptNodeId: nodeId);
expect(lastModified, isNull);
});
test('with exceptNodeId set to a different nodeId '
'then it returns the HLC timestamp of the created data.', () async {
final lastModified = await crdt.getLastModified(exceptNodeId: 'other-node');
expect(lastModified, equals(createdHlc));
});
});
});
}

View file

@ -0,0 +1,105 @@
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
void main() {
group('Given CRDT with an existing canonical time', () {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late Hlc initialHlc;
const otherNodeId = 'other';
final initialDateTime = DateTime.now().toUtc();
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
await crdtDb.managers.crdtHlcStateTable.create(
(t) => t(
userId: crdt.userId,
lastTimestamp: initialDateTime.millisecondsSinceEpoch,
counter: 17,
),
);
initialHlc = await crdt.getCanonicalTime();
});
test('when merging an HLC with higher time and counter '
'then the canonical time has the new HLC timestamp and counter.', () async {
final newHlc = Hlc(initialDateTime.advance(), 18, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
});
test('when merging an HLC with higher time and lower counter '
'then the canonical time has the new HLC timestamp and counter.', () async {
final newHlc = Hlc(initialDateTime.advance(), 0, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
});
test('when merging an HLC with lower time and counter '
'then the canonical time is the initial HLC.', () async {
final newHlc = Hlc(initialDateTime.retreat(), 0, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(initialHlc));
});
test('when merging an HLC with lower time and higher counter '
'then the canonical time is the initial HLC.', () async {
final newHlc = Hlc(initialDateTime.retreat(), 18, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(initialHlc));
});
test('when merging an HLC with same time and higher counter '
'then the canonical time has the higher counter.', () async {
final newHlc = Hlc(initialDateTime, 18, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(newHlc.copyWith(nodeId: crdt.nodeId)));
});
test('when merging an HLC with same time and lower counter '
'then the canonical time is the initial HLC.', () async {
final newHlc = Hlc(initialDateTime, 0, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(initialHlc));
});
test('when merging an HLC with same time and counter '
'then the canonical time is the initial HLC.', () async {
final newHlc = Hlc(initialDateTime, 17, otherNodeId);
await crdt.mergeCanonicalTime(newHlc);
final canonicalTime = await crdt.getCanonicalTime();
expect(canonicalTime, equals(initialHlc));
});
});
}
extension on DateTime {
DateTime advance() => add(const Duration(milliseconds: 1));
DateTime retreat() => subtract(const Duration(milliseconds: 1));
}

View file

@ -0,0 +1,106 @@
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
import 'crdt_test_utils.dart';
void main() {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late String nodeId;
group('Given CRDT for an empty database and a pending changeset', () {
late Iterable<CrdtDataEntry> changeset;
setUp(() async {
(crdt, crdtDb, nodeId) = database.crdtContext;
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
});
test('when merging a changeset '
'then the changeset is saved to the database.', () async {
await crdt.merge(changeset);
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, changeset.sortedEntries);
});
});
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> lateOldChangeset;
setUp(() async {
(crdt, crdtDb, nodeId) = database.crdtContext;
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
await crdt.merge(firstChangeset);
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.zero(nodeId));
});
test('when merging a changeset with older HLC '
'then no changes are saved to the database.', () async {
await crdt.merge(lateOldChangeset);
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
});
},
);
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> newerChangeset;
setUp(() async {
(crdt, crdtDb, nodeId) = database.crdtContext;
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(nodeId));
await crdt.merge(firstChangeset);
newerChangeset = crdtDb
.convertToCrdtDataEntry(todo, Hlc.now(nodeId))
.where((e) => e.columnName == 'content');
});
test('when merging a changeset with some entries with newer HLC '
'then the entries with newer HLC are saved to the database.', () async {
await crdt.merge(newerChangeset);
final expectedChangeset = [
for (final e in firstChangeset)
if (e.columnName == 'content') newerChangeset.first else e,
];
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
});
},
);
}

View file

@ -0,0 +1,144 @@
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
import 'crdt_test_utils.dart';
void main() {
late OfflineSyncCrdt crdt;
late CrdtDatabase crdtDb;
late String otherNodeId;
group('Given CRDT for an empty database and a pending changeset', () {
late Iterable<CrdtDataEntry> changeset;
late Hlc lastHlc;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
changeset = crdtDb.convertToCrdtDataEntry(todo, Hlc.now(otherNodeId));
lastHlc = Hlc.now(otherNodeId);
});
group('when saving a changeset', () {
setUp(() async {
await crdt.saveChangeset(changeset, [lastHlc]);
});
test('then the changeset is saved to the database.', () async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, changeset.sortedEntries);
});
test('then the merged HLCs are saved to the database.', () async {
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
});
});
});
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and older HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> lateOldChangeset;
late Hlc lastHlc;
late Hlc lateOldHlc;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
lastHlc = Hlc.now(otherNodeId);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]);
lateOldHlc = Hlc.zero(otherNodeId);
lateOldChangeset = crdtDb.convertToCrdtDataEntry(todo, lateOldHlc);
});
group('when saving a changeset with older HLC', () {
setUp(() async {
await crdt.saveChangeset(lateOldChangeset, [lateOldHlc]);
});
test('then no changes are saved to the database.', () async {
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, firstChangeset.sortedEntries);
});
test('then the last received HLC is not updated in the database.', () async {
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, lastHlc);
});
});
},
);
group(
'Given CRDT for a database with data and a pending changeset with conflicting data and some entries with newer HLC',
() {
late Iterable<CrdtDataEntry> firstChangeset;
late Iterable<CrdtDataEntry> newerChangeset;
late Hlc lastHlc;
late Hlc newerHlc;
setUp(() async {
(crdt, crdtDb, _) = database.crdtContext;
otherNodeId = const Uuid().v4();
final todo = TodoEntry(
id: const RowId(1),
content: 'test',
targetDate: DateTime.now(),
);
lastHlc = Hlc.now(otherNodeId);
firstChangeset = crdtDb.convertToCrdtDataEntry(todo, lastHlc);
await crdt.saveChangeset(firstChangeset, [lastHlc]);
newerHlc = Hlc.now(otherNodeId);
newerChangeset = crdtDb
.convertToCrdtDataEntry(todo, newerHlc)
.where((e) => e.columnName == 'content');
});
group('when saving a changeset with some entries with newer HLC', () {
setUp(() async {
await crdt.saveChangeset(newerChangeset, [newerHlc]);
});
test('then the entries with newer HLC are saved to the database.', () async {
final expectedChangeset = [
for (final e in firstChangeset)
if (e.columnName == 'content') newerChangeset.first else e,
];
final savedChangeset = await crdtDb.managers.crdtDataTable.get();
expect(savedChangeset.sortedEntries, expectedChangeset.sortedEntries);
});
test('then the last received HLC is updated in the database.', () async {
final savedMergedHlcs = await crdtDb.managers.crdtMergeHlcTable.get();
expect(savedMergedHlcs.map((e) => e.lastReceivedHlc).single, newerHlc);
});
});
},
);
}

View file

@ -0,0 +1,27 @@
import 'package:drift_offline_sync/drift_offline_sync.dart';
extension CrdtDataEntryListExtensions on List<CrdtDataEntry> {
CrdtDataEntry removeEntry(String columnName) {
final entry = firstWhere((e) => e.columnName == columnName);
remove(entry);
return entry;
}
}
/// Test helpers for CRDT tests.
///
/// Use [sortedEntries] when comparing [CrdtDataEntry] lists, since entry order
/// is not guaranteed by the API or database.
extension CrdtDataEntryTestExtensions on Iterable<CrdtDataEntry> {
/// Returns a list of entries sorted by (tblName, rowId, columnName) for
/// order-independent comparison in tests.
List<CrdtDataEntry> get sortedEntries {
return toList()..sort((a, b) {
final c = a.tblName.compareTo(b.tblName);
if (c != 0) return c;
final r = a.rowId.compareTo(b.rowId);
if (r != 0) return r;
return a.columnName.compareTo(b.columnName);
});
}
}