fix: General tests improvements and misc small fixes (#104)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-22 14:28:14 -03:00 • committed by GitHub
commit 8e7e6551e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2902 additions and 2155 deletions

View file

@ -421,139 +421,148 @@ void main() {
});
});
group('Given an ON DELETE CASCADE city -> organization -> person relationship, '
'when deleting the city,', () {
late City city;
late Organization organization;
late Person person;
group(
'Given an ON DELETE CASCADE city -> organization -> person relationship, '
'when deleting the city,',
() {
late City city;
late Organization organization;
late Person person;
setUp(() async {
city = await session.db.transactionForUser(
testCrdtUserId,
(tx) => City.db.insertRow(
session,
City(name: 'parent'),
transaction: tx,
),
);
organization = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Organization.db.insertRow(
session,
Organization(name: 'child', cityId: city.id),
transaction: tx,
),
);
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'grandchild', organizationId: organization.id),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => City.db.deleteRow(session, city, transaction: tx),
);
});
test('then a CRDT tombstone is created for the organization row.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(organization.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.userCascadeDelete);
});
test(
'then the organization CRDT row is hidden by the cascade-delete tombstone.',
() async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(organization.id),
setUp(() async {
city = await session.db.transactionForUser(
testCrdtUserId,
(tx) => City.db.insertRow(
session,
City(name: 'parent'),
transaction: tx,
),
);
expect(crdtRow, isNotNull);
expect(crdtRow!.isHidden, isTrue);
expect(crdtRow.visibility, CrdtDataRowVisibility.userCascadeDelete);
},
);
test('then the organization row still exist on the organization table.', () async {
final row = await Organization.db.findFirstRow(
session,
where: (t) => t.id.equals(organization.id) & t.includeHiddenRows,
);
expect(row, isNotNull);
expect(row!.name, organization.name);
});
test('then a CRDT tombstone is also created for the person row.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.userCascadeDelete);
});
test(
'then the person CRDT row is also hidden by the cascade-delete tombstone.',
() async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
organization = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Organization.db.insertRow(
session,
Organization(name: 'child', cityId: city.id),
transaction: tx,
),
);
expect(crdtRow, isNotNull);
expect(crdtRow!.isHidden, isTrue);
expect(crdtRow.visibility, CrdtDataRowVisibility.userCascadeDelete);
},
);
});
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'grandchild', organizationId: organization.id),
transaction: tx,
),
);
group('Given an address row with an ON DELETE RESTRICT related person, '
'when trying to delete the person,', () {
late Future<Person> personDelete;
await session.db.transactionForUser(
testCrdtUserId,
(tx) => City.db.deleteRow(session, city, transaction: tx),
);
});
setUp(() async {
final person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
test('then a CRDT tombstone is created for the organization row.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
Person(name: 'unique'),
transaction: tx,
),
where: (t) => t.row.uuidRowId.equals(organization.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.userCascadeDelete);
});
test(
'then the organization CRDT row is hidden by the cascade-delete tombstone.',
() async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(organization.id),
);
expect(crdtRow, isNotNull);
expect(crdtRow!.isHidden, isTrue);
expect(crdtRow.visibility, CrdtDataRowVisibility.userCascadeDelete);
},
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Address.db.insertRow(
test(
'then the organization row still exist on the organization table.',
() async {
final row = await Organization.db.findFirstRow(
session,
where: (t) => t.id.equals(organization.id) & t.includeHiddenRows,
);
expect(row, isNotNull);
expect(row!.name, organization.name);
},
);
test('then a CRDT tombstone is also created for the person row.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
Address(street: 'Oak', inhabitantId: person.id),
transaction: tx,
),
);
where: (t) => t.row.uuidRowId.equals(person.id),
);
personDelete = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
});
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.userCascadeDelete);
});
test('then the delete fails.', () async {
await expectLater(personDelete, throwsA(isA<Exception>()));
});
});
test(
'then the person CRDT row is also hidden by the cascade-delete tombstone.',
() async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
);
expect(crdtRow, isNotNull);
expect(crdtRow!.isHidden, isTrue);
expect(crdtRow.visibility, CrdtDataRowVisibility.userCascadeDelete);
},
);
},
);
group(
'Given an address row with an ON DELETE RESTRICT related person, '
'when trying to delete the person,',
() {
late Future<Person> personDelete;
setUp(() async {
final person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'unique'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Address.db.insertRow(
session,
Address(street: 'Oak', inhabitantId: person.id),
transaction: tx,
),
);
personDelete = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
});
test('then the delete fails.', () async {
await expectLater(personDelete, throwsA(isA<Exception>()));
});
},
);
group('Given a town row with an ON DELETE SET NULL related person,', () {
late Town town;
@ -680,4 +689,99 @@ void main() {
});
});
});
// INTERIM: non-synced -> synced relations are "always forbidden" by
// docs/sync-non-sync-relations.md, but that proposal's initialize()
// validation is not implemented yet. Until it lands, deletes must not crash
// and must not mutate untracked rows behind the application's back — which
// is what this group pins. Once initialize() rejects this relation, replace
// this group with the "rejecting non-sync -> sync" initialization test from
// the proposal's test list (the scenario below becomes un-representable).
group(
'Given a CRDT session tracking only the person table and untracked town and address rows referencing a person,',
() {
late CrdtDatabaseSession subsetSession;
late Person person;
late Town town;
late Address address;
setUp(() async {
subsetSession = CrdtDatabaseSession.wraps(
await createAdditionalTestSession(),
syncTables: [Person.t],
);
await subsetSession.db.initialize();
person = await subsetSession.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
subsetSession,
Person(name: 'referenced by untracked children'),
transaction: tx,
),
);
// Untracked tables bypass CRDT entirely, so these rows have no scope
// and no metadata; only their physical foreign keys point at the
// tracked person row.
town = await Town.db.insertRow(
subsetSession,
Town(name: 'untracked set-null child', mayorId: person.id),
);
address = await Address.db.insertRow(
subsetSession,
Address(street: 'untracked restrict child', inhabitantId: person.id),
);
});
group('when deleting the person,', () {
setUp(() async {
await subsetSession.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(subsetSession, person, transaction: tx),
);
});
test(
'then the untracked restrict child does not block the soft delete.',
() async {
expect(
await Person.db.findById(subsetSession, person.id!),
isNull,
);
expect(
await Person.db.findFirstRow(
subsetSession,
where: (t) => t.id.equals(person.id) & t.includeHiddenRows,
),
isNotNull,
);
},
);
test(
'then no set-null action is applied to the untracked town row.',
() async {
final untouchedTown = await Town.db.findById(
subsetSession,
town.id!,
);
expect(untouchedTown, isNotNull);
expect(untouchedTown!.mayorId, person.id);
},
);
test('then the untracked address row is untouched.', () async {
final untouchedAddress = await Address.db.findById(
subsetSession,
address.id!,
);
expect(untouchedAddress, isNotNull);
expect(untouchedAddress!.inhabitantId, person.id);
});
});
},
);
}

View file

@ -46,7 +46,7 @@ void main() {
otherScopeId = otherScope!.id!;
});
group('when finding inside the first scope, ', () {
group('when finding inside the first scope,', () {
late List<Person> rows;
setUp(() async {
@ -78,6 +78,19 @@ void main() {
},
);
test(
'when counting inside the first scope, '
'then only the first scope row is counted.',
() async {
final count = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.count(session, transaction: tx),
);
expect(count, 1);
},
);
test(
'when finding without a scope, '
'then both rows keep their stored scopeId.',
@ -207,6 +220,18 @@ void main() {
isEmpty,
);
});
test('when calling count, then the deleted row is not counted.', () async {
expect(await Person.db.count(session), 0);
});
test(
'when calling count with includeHiddenRows, '
'then the deleted row is counted.',
() async {
expect(await Person.db.count(session, where: (t) => t.includeHiddenRows), 1);
},
);
});
// This test is not expected to happen in production since the tombstone

View file

@ -28,6 +28,16 @@ void main() {
);
});
test(
'when using find without includeHiddenRows, '
'then only the live row is returned.',
() async {
final result = await Person.db.find(session);
expect(result.map((r) => r.id).toSet(), {liveRow.id});
},
);
test(
'when using find with includeHiddenRows, '
'then both the live and tombstoned rows are returned.',
@ -71,16 +81,6 @@ void main() {
expect(result.single.id, deletedRow.id);
},
);
test(
'when using find without includeHiddenRows, '
'then only the live row is returned.',
() async {
final result = await Person.db.find(session);
expect(result.map((r) => r.id).toSet(), {liveRow.id});
},
);
});
group(

View file

@ -115,35 +115,6 @@ void main() {
});
});
test(
'when inserting a Person with another scopeId, '
'then the insert throws.',
() async {
final insertFuture = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'wrong scope', scopeId: -1),
transaction: tx,
),
);
await expectLater(
insertFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
allOf([
contains('Cannot write person row'),
contains('with scopeId -1 while acting in scope 1'),
]),
),
),
);
},
);
group('when inserting two Person rows with insert,', () {
late List<Person> inserted;
@ -245,6 +216,35 @@ void main() {
expect(crdtRow, isNotNull);
});
});
test(
'when inserting a Person with another scopeId, '
'then the insert throws.',
() async {
final insertFuture = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'wrong scope', scopeId: -1),
transaction: tx,
),
);
await expectLater(
insertFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
allOf([
contains('Cannot write person row'),
contains('with scopeId -1 while acting in scope 1'),
]),
),
),
);
},
);
});
group('Given a person row with a fixed id,', () {
@ -359,6 +359,93 @@ void main() {
expect(crdt!.hlc, greaterThan(firstInsertedCrdtRow.hlc));
});
});
group('when inserting the same id and a new row in a batch,', () {
late Person newPerson;
late List<Person> inserted;
setUp(() async {
newPerson = Person(id: const Uuid().v7obj(), name: 'batch new');
inserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insert(
session,
[person.copyWith(name: 'batch reinserted'), newPerson],
transaction: tx,
),
);
});
test('then both rows are returned.', () async {
expect(inserted.map((e) => e.name).toSet(), {
'batch reinserted',
'batch new',
});
});
test('then the deleted row is visible again with the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, 'batch reinserted');
});
test('then the new row exists in the person table.', () async {
final row = await Person.db.findById(session, newPerson.id!);
expect(row, isNotNull);
expect(row!.name, 'batch new');
});
test('then the CRDT metadata row of the reinserted row is updated.', () async {
final crdt = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(crdt!.hlc, greaterThan(firstInsertedCrdtRow.hlc));
});
test('then the tombstone of the reinserted row is lifted.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse);
expect(tombstone.reason, CrdtDataDeletedReason.userReinsert);
});
});
group('when inserting the same id in a batch with ignoreConflicts,', () {
late List<Person> inserted;
setUp(() async {
inserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insert(
session,
[person.copyWith(name: 'batch reinserted')],
transaction: tx,
ignoreConflicts: true,
),
);
});
test('then the reinserted row is returned.', () async {
expect(inserted, hasLength(1));
expect(inserted.single.name, 'batch reinserted');
});
test('then the deleted row is visible again with the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, 'batch reinserted');
});
});
});
group('Given a person table with a deleted row that is reinserted,', () {

View file

@ -140,60 +140,43 @@ void main() {
});
});
test(
'when updating a Person with another scopeId, then it throws.',
() async {
final updateFuture = session.db.transactionForUser(
group('when updating the Person name column with updateById,', () {
late Person? updatedPerson;
setUp(() async {
updatedPerson = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateRow(
(tx) => Person.db.updateById(
session,
person.copyWith(name: 'wrong scope', scopeId: -1),
columns: (t) => [t.name],
person.id!,
columnValues: (t) => [t.name('updated by id')],
transaction: tx,
),
);
});
await expectLater(
updateFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
allOf([
contains('Cannot write person row'),
contains('with scopeId -1 while acting in scope 1'),
]),
),
),
);
},
);
test('then the updated row is returned with scopeId null.', () async {
expect(updatedPerson, isNotNull);
expect(updatedPerson!.name, 'updated by id');
expect(updatedPerson!.scopeId, isNull);
});
test(
'when updateWhere sets scopeId, then it throws.',
() async {
final updateFuture = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateWhere(
session,
columnValues: (t) => [t.scopeId(-1)],
where: (t) => t.id.equals(person.id),
transaction: tx,
),
test('then the person row reflects the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row?.name, 'updated by id');
});
test('then a CRDT field is created for the name column.', () async {
final field = await CrdtDataField.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
);
await expectLater(
updateFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('scopeId is immutable and owned by the CRDT sync layer'),
),
),
);
},
);
expect(field, isNotNull);
expect(field!.column!.name, 'name');
});
});
group('when updating the Person with update and noReturn,', () {
late List<Person> updated;
@ -255,6 +238,79 @@ void main() {
expect(row?.name, 'updated');
});
});
test(
'when updating a non-existing Person with updateById, '
'then null is returned.',
() async {
final updated = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateById(
session,
const Uuid().v7obj(),
columnValues: (t) => [t.name('missing')],
transaction: tx,
),
);
expect(updated, isNull);
},
);
test(
'when updating a Person with another scopeId, then it throws.',
() async {
final updateFuture = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateRow(
session,
person.copyWith(name: 'wrong scope', scopeId: -1),
columns: (t) => [t.name],
transaction: tx,
),
);
await expectLater(
updateFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
allOf([
contains('Cannot write person row'),
contains('with scopeId -1 while acting in scope 1'),
]),
),
),
);
},
);
test(
'when updateWhere sets scopeId, then it throws.',
() async {
final updateFuture = session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateWhere(
session,
columnValues: (t) => [t.scopeId(-1)],
where: (t) => t.id.equals(person.id),
transaction: tx,
),
);
await expectLater(
updateFuture,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('scopeId is immutable and owned by the CRDT sync layer'),
),
),
);
},
);
});
group('Given a person table with a deleted row,', () {
@ -289,6 +345,36 @@ void main() {
);
});
group('when updating the deleted person with updateById,', () {
late Person? updated;
setUp(() async {
updated = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.updateById(
session,
person.id!,
columnValues: (t) => [t.name('test2')],
transaction: tx,
),
);
});
test('then null is returned.', () async {
expect(updated, isNull);
});
test('then the deleted row keeps its original value.', () async {
final row = await Person.db.findFirstRow(
session,
where: (t) => t.id.equals(person.id) & t.includeHiddenRows,
);
expect(row, isNotNull);
expect(row!.name, person.name);
});
});
group('when updating the deleted person with updateWhere,', () {
late List<Person> updatedRows;

View file

@ -402,6 +402,83 @@ void main() {
);
expect(crdtRow!.hlc, greaterThan(insertedCrdtRow.hlc));
});
test('then the tombstone of the reinserted row is lifted.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse);
expect(tombstone.reason, CrdtDataDeletedReason.userReinsert);
});
});
group(
'when upserting the Person with a column outside updateColumns changed,',
() {
late Person? reinserted;
setUp(() async {
reinserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsertRow(
session,
person.copyWith(
name: 'reinserted',
surname: 'reinserted surname',
),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
),
);
});
test(
'then the full incoming row is persisted, '
'including the column outside updateColumns.',
() async {
expect(reinserted, isNotNull);
expect(reinserted!.surname, 'reinserted surname');
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, 'reinserted');
expect(row.surname, 'reinserted surname');
},
);
},
);
group('when upserting the Person with noReturn,', () {
late List<Person> upserted;
setUp(() async {
upserted = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.upsert(
session,
[person.copyWith(name: 'reinserted')],
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
transaction: tx,
noReturn: true,
),
);
});
test('then an empty list is returned.', () async {
expect(upserted, isEmpty);
});
test('then the row is visible again with the new values.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, 'reinserted');
});
});
group('when upserting the Person with a non-matching updateWhere,', () {

View file

@ -10,7 +10,7 @@ void main() {
late CrdtMergeSet mergeSet;
group('Given a table with an existing row and a newer remote delete, ', () {
group('Given a table with an existing row and a newer remote delete,', () {
late Person person;
late Hlc rowHlc;
late UuidValue remoteNodeId;
@ -48,7 +48,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -84,7 +84,7 @@ void main() {
});
group(
'Given a table with an implicitly visible row and an older remote delete, ',
'Given a table with an implicitly visible row and an older remote delete,',
() {
late Person person;
late Hlc rowHlc;
@ -122,7 +122,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -151,7 +151,7 @@ void main() {
},
);
group('Given a table with a deleted row and a same-generation newer event, ', () {
group('Given a table with a deleted row and a same-generation newer event,', () {
late Person person;
late Hlc tombstoneHlc;
late UuidValue remoteNodeId;
@ -193,7 +193,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -217,7 +217,7 @@ void main() {
});
});
group('Given a table with a deleted row and a newer remote restore, ', () {
group('Given a table with a deleted row and a newer remote restore,', () {
late Person person;
late Hlc tombstoneHlc;
late UuidValue remoteNodeId;
@ -260,7 +260,7 @@ void main() {
mergeSet = [remoteRestore];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -289,7 +289,7 @@ void main() {
});
});
group('Given a table with a restored row and an older remote delete, ', () {
group('Given a table with a restored row and an older remote delete,', () {
late Person person;
late Hlc tombstoneHlc;
late UuidValue remoteNodeId;
@ -338,7 +338,7 @@ void main() {
mergeSet = [remoteRestore];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -368,7 +368,7 @@ void main() {
});
group(
'Given a table with a restored row and an older remote delete from a later generation, ',
'Given a table with a restored row and an older remote delete from a later generation,',
() {
late Person person;
late Hlc tombstoneHlc;
@ -415,7 +415,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -446,7 +446,7 @@ void main() {
group(
'Given a table with an existing row and a newer remote delete with a '
'non-synced projection reason, ',
'non-synced projection reason,',
() {
late Person person;
late CrdtMergeDelete remoteDelete;
@ -481,7 +481,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -512,7 +512,7 @@ void main() {
);
group(
'Given an empty table and a remote delete for a non-existing row, ',
'Given an empty table and a remote delete for a non-existing row,',
() {
late UuidValue remoteNodeId;
late CrdtMergeDelete remoteDelete;
@ -534,7 +534,7 @@ void main() {
mergeSet = [remoteDelete];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,

View file

@ -10,7 +10,7 @@ void main() {
initTestClientSession();
group(
'Given a parent with a visible restrict child and a child update, ',
'Given a parent with a visible restrict child and a child update,',
() {
late Person parent;
late Address child;
@ -41,7 +41,7 @@ void main() {
});
});
group('when a concurrent remote parent delete is merged, ', () {
group('when a concurrent remote parent delete is merged,', () {
setUp(() async {
remoteParentDelete = _deleteChange(
tableName: Person.t.tableName,
@ -88,7 +88,7 @@ void main() {
);
group(
'Given a parent with a restrict child insert, ',
'Given a parent with a restrict child insert,',
() {
late Person parent;
late RestrictChild child;
@ -113,7 +113,7 @@ void main() {
});
});
group('when a concurrent remote parent delete is merged, ', () {
group('when a concurrent remote parent delete is merged,', () {
setUp(() async {
remoteParentDelete = _deleteChange(
tableName: Person.t.tableName,
@ -162,7 +162,7 @@ void main() {
);
group(
'Given a merged parent delete kept visible by a concurrent update to a restrict child, ',
'Given a merged parent delete kept visible by a concurrent update to a restrict child,',
() {
late Person parent;
late Address child;
@ -201,7 +201,7 @@ void main() {
expect(visibleParent!.name, 'blocked parent');
});
group('when the restrict child is detached, ', () {
group('when the restrict child is detached,', () {
setUp(() async {
final visibleChild = await Address.db.findById(session, child.id!);
await session.db.transactionForUser(testCrdtUserId, (tx) async {
@ -230,7 +230,7 @@ void main() {
);
group(
'Given a parent with two visible restrict children and a merged parent delete, ',
'Given a parent with two visible restrict children and a merged parent delete,',
() {
late Person parent;
late RestrictChild firstChild;
@ -275,7 +275,7 @@ void main() {
);
});
group('when one restrict child is detached by a merged update, ', () {
group('when one restrict child is detached by a merged update,', () {
setUp(() async {
final firstChildDetach = _updateChange(
tableName: RestrictChild.t.tableName,
@ -315,7 +315,7 @@ void main() {
);
});
group('when both restrict children are detached by merged updates, ', () {
group('when both restrict children are detached by merged updates,', () {
setUp(() async {
final firstChildDetach = _updateChange(
tableName: RestrictChild.t.tableName,
@ -361,7 +361,7 @@ void main() {
);
group(
'Given a parent with a visible no-action child, ',
'Given a parent with a visible no-action child,',
() {
late Town town;
late Company company;
@ -402,7 +402,7 @@ void main() {
});
});
group('when a concurrent remote parent delete is merged, ', () {
group('when a concurrent remote parent delete is merged,', () {
setUp(() async {
remoteCompanyDelete = _deleteChange(
tableName: Company.t.tableName,
@ -436,7 +436,7 @@ void main() {
);
group(
'Given a parent with a required (non-nullable) set-null child insert, ',
'Given a parent with a required (non-nullable) set-null child insert,',
() {
late Person parent;
late RequiredSetNullChild child;
@ -461,7 +461,7 @@ void main() {
});
});
group('when a concurrent remote parent delete is merged, ', () {
group('when a concurrent remote parent delete is merged,', () {
setUp(() async {
remoteParentDelete = _deleteChange(
tableName: Person.t.tableName,
@ -494,7 +494,7 @@ void main() {
);
group(
'Given a parent with a nullable set-null foreign key whose attempted value was stored on update, ',
'Given a parent with a nullable set-null foreign key whose attempted value was stored on update,',
() {
late Person attemptedParent;
late Town child;
@ -542,7 +542,7 @@ void main() {
);
});
group('when the attempted parent delete is merged, ', () {
group('when the attempted parent delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteParentDelete],
@ -596,7 +596,7 @@ void main() {
);
group(
'Given a unique nullable set-null foreign key, ',
'Given a unique nullable set-null foreign key,',
() {
late Person parent;
late UniqueSetNullChild child;
@ -628,7 +628,7 @@ void main() {
);
});
group('when the remote parent delete is merged, ', () {
group('when the remote parent delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteParentDelete],
@ -668,7 +668,7 @@ void main() {
);
group(
'Given a child with a visible parent foreign key and no active projection override, ',
'Given a child with a visible parent foreign key and no active projection override,',
() {
late Person parent;
late Town child;
@ -709,7 +709,7 @@ void main() {
});
group(
'when the foreign key is cleared by an update without narrowing columns, ',
'when the foreign key is cleared by an update without narrowing columns,',
() {
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
@ -756,7 +756,7 @@ void main() {
);
group(
'Given a set-null projection is active after a merged parent delete, ',
'Given a set-null projection is active after a merged parent delete,',
() {
late Person attemptedParent;
late Person newParent;
@ -808,7 +808,7 @@ void main() {
expect(projectedChild.mayorId, isNull);
});
group('when the user changes the foreign key to a visible parent, ', () {
group('when the user changes the foreign key to a visible parent,', () {
late CrdtDataForeignKey projection;
setUp(() async {
@ -844,7 +844,7 @@ void main() {
);
});
group('when a later merge restores the attempted parent, ', () {
group('when a later merge restores the attempted parent,', () {
setUp(() async {
final remoteParentRestore = _restoreChange(
tableName: Person.t.tableName,
@ -882,7 +882,7 @@ void main() {
);
});
group('when the row is updated without narrowing columns, ', () {
group('when the row is updated without narrowing columns,', () {
late Hlc mayorFieldHlcBeforeUpdate;
setUp(() async {
@ -934,7 +934,7 @@ void main() {
});
group(
'when the row is updated without narrowing columns and a changed foreign key, ',
'when the row is updated without narrowing columns and a changed foreign key,',
() {
late Hlc mayorFieldHlcBeforeUpdate;
@ -990,7 +990,7 @@ void main() {
);
group(
'when the foreign key is explicitly updated to the projected value with narrowed columns, ',
'when the foreign key is explicitly updated to the projected value with narrowed columns,',
() {
late Hlc mayorFieldHlcBeforeUpdate;
@ -1049,7 +1049,7 @@ void main() {
group(
'Given a set-null projection is active after a merged parent delete and a '
'remote update that touches only a non-foreign-key column, ',
'remote update that touches only a non-foreign-key column,',
() {
late Person attemptedParent;
late Town child;
@ -1094,7 +1094,7 @@ void main() {
);
});
group('when the remote update is merged, ', () {
group('when the remote update is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteNameUpdate],
@ -1128,7 +1128,7 @@ void main() {
);
group(
'Given a nullable foreign key with set-null whose attempted value only exists on insert, ',
'Given a nullable foreign key with set-null whose attempted value only exists on insert,',
() {
late Person attemptedParent;
late Town child;
@ -1160,7 +1160,7 @@ void main() {
);
});
group('when the attempted parent delete is merged, ', () {
group('when the attempted parent delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteParentDelete],
@ -1197,7 +1197,7 @@ void main() {
);
group(
'Given a remote insert with a set-null foreign key that points to a missing parent, ',
'Given a remote insert with a set-null foreign key that points to a missing parent,',
() {
late UuidValue missingParentId;
late Town child;
@ -1211,7 +1211,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
setUp(() async {
final remoteNodeId = const Uuid().v7obj();
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
@ -1261,7 +1261,7 @@ void main() {
);
group(
'Given a visible child and a remote update with a set-null foreign key that points to a missing parent, ',
'Given a visible child and a remote update with a set-null foreign key that points to a missing parent,',
() {
late UuidValue missingParentId;
late Town child;
@ -1291,7 +1291,7 @@ void main() {
);
});
group('when the remote update is merged, ', () {
group('when the remote update is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteMissingParentUpdate],
@ -1329,7 +1329,7 @@ void main() {
);
group(
'Given a set-default foreign key with a visible default target and a stored attempted parent value, ',
'Given a set-default foreign key with a visible default target and a stored attempted parent value,',
() {
late Town defaultTown;
late Town attemptedTown;
@ -1378,7 +1378,7 @@ void main() {
);
});
group('when the attempted parent delete is merged, ', () {
group('when the attempted parent delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteAttemptedTownDelete],
@ -1414,7 +1414,7 @@ void main() {
});
group(
'when the attempted parent delete is merged and the row is updated without narrowing columns, ',
'when the attempted parent delete is merged and the row is updated without narrowing columns,',
() {
late Hlc townIdFieldHlcBeforeUpdate;
late Company projectedChild;
@ -1477,7 +1477,7 @@ void main() {
);
group(
'when the attempted parent delete is merged and the row is updated without narrowing columns with a changed foreign key, ',
'when the attempted parent delete is merged and the row is updated without narrowing columns with a changed foreign key,',
() {
late Town otherTown;
late Hlc townIdFieldHlcBeforeUpdate;
@ -1553,7 +1553,7 @@ void main() {
);
group(
'Given a remote insert with a set-default foreign key that points to a missing parent and a visible default target, ',
'Given a remote insert with a set-default foreign key that points to a missing parent and a visible default target,',
() {
late Town defaultTown;
late UuidValue missingTownId;
@ -1574,7 +1574,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
setUp(() async {
final remoteNodeId = const Uuid().v7obj();
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
@ -1624,7 +1624,7 @@ void main() {
);
group(
'Given a set-default foreign key whose default target is missing, ',
'Given a set-default foreign key whose default target is missing,',
() {
late Town attemptedTown;
late Company child;
@ -1662,7 +1662,7 @@ void main() {
);
});
group('when the attempted parent delete is merged, ', () {
group('when the attempted parent delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteAttemptedTownDelete],
@ -1705,7 +1705,7 @@ void main() {
);
group(
'Given a remote insert with a restrict foreign key that points to a missing parent, ',
'Given a remote insert with a restrict foreign key that points to a missing parent,',
() {
late UuidValue missingParentId;
late Address child;
@ -1719,7 +1719,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
setUp(() async {
final remoteNodeId = const Uuid().v7obj();
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
@ -1783,7 +1783,7 @@ void main() {
);
group(
'Given a remote insert with a cascade foreign key that points to a missing parent, ',
'Given a remote insert with a cascade foreign key that points to a missing parent,',
() {
late UuidValue missingParentId;
late Organization child;
@ -1797,7 +1797,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
setUp(() async {
final remoteNodeId = const Uuid().v7obj();
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
@ -1864,7 +1864,7 @@ void main() {
);
group(
'Given a remote insert with a cascade foreign key that points to a hidden parent in scope, ',
'Given a remote insert with a cascade foreign key that points to a hidden parent in scope,',
() {
late Organization organization;
late Person person;
@ -1902,7 +1902,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remotePersonInsert],
@ -1952,7 +1952,7 @@ void main() {
// transaction atomically instead of applying partial state.
group(
'Given a causally incomplete remote insert with a set-default foreign key '
'whose attempted parent and default target are both missing, ',
'whose attempted parent and default target are both missing,',
() {
late UuidValue missingTownId;
late Company child;
@ -1979,7 +1979,7 @@ void main() {
);
});
group('when the remote insert is merged, ', () {
group('when the remote insert is merged,', () {
late Object? mergeError;
setUp(() async {
@ -2018,7 +2018,7 @@ void main() {
);
group(
'Given a set-default foreign key whose default target is cascade-attached to another parent and a concurrent delete that hides the default target first, ',
'Given a set-default foreign key whose default target is cascade-attached to another parent and a concurrent delete that hides the default target first,',
() {
late City city;
late Town defaultTown;
@ -2080,7 +2080,7 @@ void main() {
);
});
group('when both root deletes are merged in the same batch, ', () {
group('when both root deletes are merged in the same batch,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteCityDelete, remoteAttemptedTownDelete],
@ -2114,7 +2114,7 @@ void main() {
);
group(
'Given a valid cascade closure with three levels and a concurrent delete that hides the root before the children inserts, ',
'Given a valid cascade closure with three levels and a concurrent delete that hides the root before the children inserts,',
() {
// City
// │ CASCADE
@ -2162,7 +2162,7 @@ void main() {
);
});
group('when the root delete is merged, ', () {
group('when the root delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteCityDelete],
@ -2180,7 +2180,7 @@ void main() {
);
});
group('when the root delete replayed after merged, ', () {
group('when the root delete replayed after merged,', () {
late List<String> visibilityAfterMerge;
late List<String> visibilityAfterReplay;
late List<String> foreignKeyProjectionAfterMerge;
@ -2218,7 +2218,7 @@ void main() {
);
});
group('when a root restore is merged after the root delete, ', () {
group('when a root restore is merged after the root delete,', () {
late CrdtMergeDelete remoteCityRestore;
setUp(() async {
@ -2259,7 +2259,7 @@ void main() {
group(
'when a newer remote update for a cascade-hidden descendant is merged '
'after the root delete, ',
'after the root delete,',
() {
late CrdtMergeUpdate remoteOrganizationUpdate;
@ -2295,7 +2295,7 @@ void main() {
},
);
group('when the root restore is merged afterwards, ', () {
group('when the root restore is merged afterwards,', () {
setUp(() async {
final remoteCityRestore = _restoreChange(
tableName: City.t.tableName,
@ -2328,7 +2328,7 @@ void main() {
);
group(
'Given a cascade to restrict chain whose restrict row has set-null and cascade grandchildren and a concurrent delete that hides the root before the children inserts, ',
'Given a cascade to restrict chain whose restrict row has set-null and cascade grandchildren and a concurrent delete that hides the root before the children inserts,',
() {
// Root
// │ CASCADE
@ -2398,7 +2398,7 @@ void main() {
);
});
group('when the root delete is merged, ', () {
group('when the root delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteRootDelete],
@ -2458,7 +2458,7 @@ void main() {
});
group(
'when a restrict blocker delete is merged after the root delete, ',
'when a restrict blocker delete is merged after the root delete,',
() {
late CrdtMergeDelete remoteRestrictBlockerDelete;
@ -2525,7 +2525,7 @@ void main() {
group(
'when the root delete and the restrict blocker delete are merged in '
'the same batch, ',
'the same batch,',
() {
setUp(() async {
final restrictBlockerHlc = await _rowHlc(restrictBlocker.id!);
@ -2588,7 +2588,7 @@ void main() {
);
group(
'Given a cascade to set-null chain whose middle row has restrict, set-null, and cascade grandchildren and a concurrent delete that hides the root before the children inserts, ',
'Given a cascade to set-null chain whose middle row has restrict, set-null, and cascade grandchildren and a concurrent delete that hides the root before the children inserts,',
() {
// Root
// │ CASCADE
@ -2669,7 +2669,7 @@ void main() {
);
});
group('when the root delete is merged, ', () {
group('when the root delete is merged,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteRootDelete],
@ -2746,7 +2746,7 @@ void main() {
});
group(
'when a restrict grandchild delete is merged after the root delete, ',
'when a restrict grandchild delete is merged after the root delete,',
() {
late CrdtMergeDelete remoteRestrictGrandchildDelete;
@ -2885,7 +2885,7 @@ void main() {
});
group(
'when the person and company concurrent deletes are merged in the same batch, ',
'when the person and company concurrent deletes are merged in the same batch,',
() {
setUp(() async {
await session.db.mergeChanges(
@ -2922,7 +2922,7 @@ void main() {
);
group(
'Given two databases with the same set-null graph and the same remote operations, ',
'Given two databases with the same set-null graph and the same remote operations,',
() {
late CrdtDatabaseSession singleBatchSession;
late CrdtDatabaseSession splitBatchSession;
@ -3010,7 +3010,7 @@ void main() {
});
group(
'when one database merges one batch and the other merges split batches, ',
'when one database merges one batch and the other merges split batches,',
() {
setUp(() async {
await singleBatchSession.db.mergeChanges(
@ -3068,7 +3068,7 @@ void main() {
group(
'Given two databases with the same visible parent and a remote restrict '
'child insert concurrent with the parent delete, ',
'child insert concurrent with the parent delete,',
() {
late CrdtDatabaseSession singleBatchSession;
late CrdtDatabaseSession splitBatchSession;
@ -3132,7 +3132,7 @@ void main() {
group(
'when one database merges one batch and the other merges the delete '
'before the child insert, ',
'before the child insert,',
() {
setUp(() async {
await singleBatchSession.db.mergeChanges(

View file

@ -10,7 +10,7 @@ void main() {
late CrdtMergeSet mergeSet;
group('Given an empty table and a remote insert, ', () {
group('Given an empty table and a remote insert,', () {
late UuidValue remoteNodeId;
late Person remotePerson;
late CrdtMergeInsert remoteInsert;
@ -33,7 +33,7 @@ void main() {
mergeSet = [remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -63,7 +63,7 @@ void main() {
});
group(
'Given an empty table, a remote insert and a newer remote update for the same row, ',
'Given an empty table, a remote insert and a newer remote update for the same row,',
() {
late UuidValue remoteNodeId;
late Person remotePerson;
@ -99,7 +99,7 @@ void main() {
mergeSet = [remoteInsert, remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -136,7 +136,7 @@ void main() {
group(
'Given a table with an existing row updated locally and a newer insert '
'with the same row ID but different data, ',
'with the same row ID but different data,',
() {
late Person person;
late Hlc localRowHlc;
@ -194,7 +194,7 @@ void main() {
mergeSet = [remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -269,7 +269,7 @@ void main() {
group(
'Given a table with an existing row updated locally and a remote insert '
'that is after the insert and before the update for the same row ID but '
'different data, ',
'different data,',
() {
late Person person;
late Hlc localRowHlc;
@ -330,7 +330,7 @@ void main() {
mergeSet = [remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
late Person mergedPerson;
setUp(() async {
@ -374,7 +374,7 @@ void main() {
);
group(
'Given a table with an existing row and an older remote insert for the same row ID, ',
'Given a table with an existing row and an older remote insert for the same row ID,',
() {
late Person person;
late Hlc localRowHlc;
@ -409,7 +409,7 @@ void main() {
mergeSet = [remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -452,7 +452,7 @@ void main() {
group(
'Given an empty table and a merge set that lists a newer remote update '
'before its remote insert, ',
'before its remote insert,',
() {
late Person remotePerson;
late CrdtMergeInsert remoteInsert;
@ -486,7 +486,7 @@ void main() {
mergeSet = [remoteUpdate, remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -525,7 +525,7 @@ void main() {
);
group(
'Given a remote insert and a newer remote update that were already merged, ',
'Given a remote insert and a newer remote update that were already merged,',
() {
late Person remotePerson;
late CrdtMergeUpdate remoteUpdate;
@ -579,7 +579,7 @@ void main() {
fieldHlcsAfterFirstMerge = [for (final field in fields) field.hlc];
});
group('when the same merge set is replayed, ', () {
group('when the same merge set is replayed,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -624,7 +624,7 @@ void main() {
);
group(
'Given a table with a row deleted in generation four and a newer remote insert payload, ',
'Given a table with a row deleted in generation four and a newer remote insert payload,',
() {
late Person person;
late CrdtMergeInsert remoteInsert;
@ -669,7 +669,7 @@ void main() {
mergeSet = [remoteInsert];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,

View file

@ -10,7 +10,7 @@ void main() {
group(
'Given a visible row claiming a unique value and a newer remote insert '
'that claims the same value, ',
'that claims the same value,',
() {
late Unique winner;
late Unique loser;
@ -38,7 +38,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
@ -65,7 +65,7 @@ void main() {
);
});
group('when the same losing insert was merged and is replayed, ', () {
group('when the same losing insert was merged and is replayed,', () {
late Hlc loserRowHlcAfterFirstMerge;
setUp(() async {
@ -106,7 +106,7 @@ void main() {
group(
'Given a row owned by one scope and a remote insert for another scope '
'that claims the same unique value, ',
'that claims the same unique value,',
() {
late Unique owner;
late Unique incoming;
@ -155,7 +155,7 @@ void main() {
group(
'Given a visible row claiming a unique value and an older remote insert '
'that claims the same value, ',
'that claims the same value,',
() {
late Unique existingLoser;
late Unique incomingWinner;
@ -183,7 +183,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
@ -226,7 +226,7 @@ void main() {
group(
'Given two visible rows and an older remote update that claims the unique '
'value of the row with the newer claim, ',
'value of the row with the newer claim,',
() {
late Unique updatedWinner;
late Unique existingLoser;
@ -265,7 +265,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteUpdate],
@ -296,7 +296,7 @@ void main() {
group(
'Given a visible child claiming a nullable unique foreign key and a newer '
'remote insert that claims the same parent, ',
'remote insert that claims the same parent,',
() {
late Person parent;
late UniqueSetNullChild winner;
@ -341,7 +341,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
@ -372,7 +372,7 @@ void main() {
group(
'Given two databases and two remote inserts that claim the same unique '
'value with identical HLCs from different nodes, ',
'value with identical HLCs from different nodes,',
() {
late CrdtDatabaseSession singleBatchSession;
late CrdtDatabaseSession splitBatchSession;
@ -418,7 +418,7 @@ void main() {
group(
'when one database merges one batch and the other merges split '
'batches in reverse arrival order, ',
'batches in reverse arrival order,',
() {
setUp(() async {
await singleBatchSession.db.mergeChanges(
@ -464,7 +464,7 @@ void main() {
group(
'Given a visible UniqueUuid row claiming a UUID unique value and a newer '
'remote insert that claims the same value, ',
'remote insert that claims the same value,',
() {
final sharedValue = const Uuid().v7obj();
late UniqueUuid winner;
@ -493,7 +493,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
@ -529,7 +529,7 @@ void main() {
group(
'Given a unique value previously claimed by a locally deleted row and a '
'remote insert that claims it, ',
'remote insert that claims it,',
() {
late Unique deletedRow;
late Unique incomingRow;
@ -561,7 +561,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],

View file

@ -11,7 +11,7 @@ void main() {
late CrdtMergeSet mergeSet;
group(
'Given a table with an existing unchanged row and a remote update for one of its columns, ',
'Given a table with an existing unchanged row and a remote update for one of its columns,',
() {
late Person person;
late Hlc localFieldHlc;
@ -51,7 +51,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -84,7 +84,7 @@ void main() {
);
group(
'Given a table with an existing row updated locally and an older remote update for the same column, ',
'Given a table with an existing row updated locally and an older remote update for the same column,',
() {
late Person person;
late Hlc localFieldHlc;
@ -135,7 +135,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -168,7 +168,7 @@ void main() {
);
group(
'Given an empty table and a remote update for a non-existing row, ',
'Given an empty table and a remote update for a non-existing row,',
() {
late UuidValue remoteNodeId;
late CrdtMergeUpdate remoteUpdate;
@ -190,7 +190,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -220,7 +220,7 @@ void main() {
);
group(
'Given a table with a row hidden by a remote delete and a newer remote update, ',
'Given a table with a row hidden by a remote delete and a newer remote update,',
() {
late Person person;
late CrdtMergeDelete remoteDelete;
@ -269,7 +269,7 @@ void main() {
);
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
[remoteUpdate],
@ -301,7 +301,7 @@ void main() {
});
});
group('when merging together with a newer remote restore, ', () {
group('when merging together with a newer remote restore,', () {
setUp(() async {
final remoteRestore = CrdtMergeDelete(
uuidScopeId: testCrdtUserId,
@ -334,7 +334,7 @@ void main() {
);
group(
'Given a remote update that loses a composite unique conflict, ',
'Given a remote update that loses a composite unique conflict,',
() {
late UniqueComposite loser;
late CrdtMergeUpdate remoteUpdate;
@ -381,7 +381,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -422,7 +422,7 @@ void main() {
);
group(
'Given a remote update that loses a composite unique conflict with a stable discriminator, ',
'Given a remote update that loses a composite unique conflict with a stable discriminator,',
() {
late UniqueDiscriminator loser;
late CrdtMergeUpdate remoteUpdate;
@ -469,7 +469,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
@ -510,7 +510,7 @@ void main() {
);
group(
'Given a remote update that loses a UUID unique conflict, ',
'Given a remote update that loses a UUID unique conflict,',
() {
final sharedValue = const Uuid().v7obj();
final originalLoserValue = const Uuid().v7obj();
@ -550,7 +550,7 @@ void main() {
mergeSet = [remoteUpdate];
});
group('when merging, ', () {
group('when merging,', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,

View file

@ -66,6 +66,36 @@ void main() {
},
);
test(
'Given a CRDT schema registry with several tables with UuidValue primary key in the sync list, '
'when syncAndGetSchema is called, '
'then the tables are registered with the correct names and IDs.',
() async {
final tables = [
_UuidPkTable(name: 'table1'),
_UuidPkTable(name: 'table2'),
_UuidPkTable(name: 'table3'),
];
final (tableRows, columnRows) = await CrdtSchemaRegistry(
session,
syncTables: tables,
).syncAndGetSchema();
expect(tableRows, hasLength(tables.length));
expect(
tableRows.map((t) => t.name).toSet(),
tables.map((t) => t.tableName).toSet(),
);
expect(
columnRows,
hasLength(
tables.fold<int>(0, (sum, t) => sum + t.columns.syncColumnNames.length),
),
);
},
);
group(
'Given a CRDT schema registry with tables already registered,',
() {
@ -104,32 +134,89 @@ void main() {
);
test(
'Given a CRDT schema registry with several tables with UuidValue primary key in the sync list, '
'Given a CRDT schema registry with a synced nullable foreign-key-only unique index, '
'when syncAndGetSchema is called, '
'then the tables are registered with the correct names and IDs.',
'then the index is accepted.',
() async {
final tables = [
_UuidPkTable(name: 'table1'),
_UuidPkTable(name: 'table2'),
_UuidPkTable(name: 'table3'),
];
final (tableRows, columnRows) = await CrdtSchemaRegistry(
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: tables,
syncTables: [Address.t, Person.t],
).syncAndGetSchema();
expect(tableRows, hasLength(tables.length));
expect(
tableRows.map((t) => t.name).toSet(),
tables.map((t) => t.tableName).toSet(),
expect(tableRows.map((table) => table.name).toSet(), {
Address.t.tableName,
Person.t.tableName,
});
},
);
test(
'Given a CRDT schema registry with a global unique index containing only foreign keys to synced tables, '
'when syncAndGetSchema is called, '
'then the index is accepted.',
() async {
final tableDefinitions = testSession.db.serializationManager
.getTargetTableDefinitions();
final townDefinition = tableDefinitions.firstWhere(
(definition) => definition.name == Town.t.tableName,
);
expect(
columnRows,
hasLength(
tables.fold<int>(0, (sum, t) => sum + t.columns.syncColumnNames.length),
),
final addressDefinition = tableDefinitions.firstWhere(
(definition) => definition.name == Address.t.tableName,
);
final indexTemplate = addressDefinition.indexes.singleWhere(
(index) => index.indexName == 'address__inhabitantId__unique_idx',
);
final fkOnlyDefinition = townDefinition.copyWith(
indexes: [
indexTemplate.copyWith(
indexName: 'town_global_city_mayor_unique_idx',
elements: [
indexTemplate.elements.single.copyWith(definition: 'cityId'),
indexTemplate.elements.single.copyWith(definition: 'mayorId'),
],
),
],
);
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: [Town.t, City.t, Person.t],
tableDefinitions: [fkOnlyDefinition],
).syncAndGetSchema();
expect(tableRows.map((table) => table.name).toSet(), {
Town.t.tableName,
City.t.tableName,
Person.t.tableName,
});
},
);
test(
'Given a CRDT database with a scoped composite unique index that has a stable discriminator and a releasable column, '
'when the database is initialized, '
'then the unique index metadata is accepted.',
() async {
final crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [UniqueDiscriminator.t],
);
await expectLater(crdtSession.db.initialize(), completes);
},
);
test(
'Given a CRDT schema registry with a synced table that has the scopeId cascade relation to crdt_scopes, '
'when the registry is created, '
'then no error is thrown.',
() async {
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: [Person.t],
).syncAndGetSchema();
expect(tableRows.map((t) => t.name).toSet(), {Person.t.tableName});
},
);
@ -197,23 +284,6 @@ void main() {
},
);
test(
'Given a CRDT schema registry with a synced nullable foreign-key-only unique index, '
'when syncAndGetSchema is called, '
'then the index is accepted.',
() async {
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: [Address.t, Person.t],
).syncAndGetSchema();
expect(tableRows.map((table) => table.name).toSet(), {
Address.t.tableName,
Person.t.tableName,
});
},
);
test(
'Given a CRDT schema registry with a synced required foreign-key-only unique index, '
'when the registry is created, '
@ -250,48 +320,6 @@ void main() {
},
);
test(
'Given a CRDT schema registry with a global unique index containing only foreign keys to synced tables, '
'when syncAndGetSchema is called, '
'then the index is accepted.',
() async {
final tableDefinitions = testSession.db.serializationManager
.getTargetTableDefinitions();
final townDefinition = tableDefinitions.firstWhere(
(definition) => definition.name == Town.t.tableName,
);
final addressDefinition = tableDefinitions.firstWhere(
(definition) => definition.name == Address.t.tableName,
);
final indexTemplate = addressDefinition.indexes.singleWhere(
(index) => index.indexName == 'address__inhabitantId__unique_idx',
);
final fkOnlyDefinition = townDefinition.copyWith(
indexes: [
indexTemplate.copyWith(
indexName: 'town_global_city_mayor_unique_idx',
elements: [
indexTemplate.elements.single.copyWith(definition: 'cityId'),
indexTemplate.elements.single.copyWith(definition: 'mayorId'),
],
),
],
);
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: [Town.t, City.t, Person.t],
tableDefinitions: [fkOnlyDefinition],
).syncAndGetSchema();
expect(tableRows.map((table) => table.name).toSet(), {
Town.t.tableName,
City.t.tableName,
Person.t.tableName,
});
},
);
test(
'Given a CRDT schema registry with a global unique index containing a required foreign key to a synced table, '
'when the registry is created, '
@ -386,20 +414,6 @@ void main() {
},
);
test(
'Given a CRDT database with a scoped composite unique index that has a stable discriminator and a releasable column, '
'when the database is initialized, '
'then the unique index metadata is accepted.',
() async {
final crdtSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: [UniqueDiscriminator.t],
);
await expectLater(crdtSession.db.initialize(), completes);
},
);
test(
'Given a CRDT schema registry with a scoped unique index that has no releasable non-scope column, '
'when the registry is created, '
@ -450,20 +464,6 @@ void main() {
},
);
test(
'Given a CRDT schema registry with a synced table that has the scopeId cascade relation to crdt_scopes, '
'when the registry is created, '
'then no error is thrown.',
() async {
final (tableRows, _) = await CrdtSchemaRegistry(
session,
syncTables: [Person.t],
).syncAndGetSchema();
expect(tableRows.map((t) => t.name).toSet(), {Person.t.tableName});
},
);
test(
'Given a CRDT schema registry with a synced table missing the scopeId cascade relation to crdt_scopes, '
'when the registry is created, '

View file

@ -222,6 +222,48 @@ void main() {
},
);
test(
'when a sent change advances a completed handshake, '
'then the tracked checkpoint moves to the change HLC.',
() async {
final userUuid = _uuid(37);
final localNodeUuid = _uuid(47);
final remoteNodeUuid = _uuid(107);
final authoritative = CrdtScopeSyncSession(
session,
userId: userUuid,
mode: CrdtSyncPeerMode.authoritative,
peerNodeId: _uuid(98),
);
final scopeUuid = await session.crdt.scopes.createFor(userUuid);
await authoritative.reconcile();
authoritative.recordPeerHandshake(
scopeUuid,
_since(scopeUuid, _hlc(remoteNodeUuid, minute: 6)),
);
final change = CrdtMergeInsert(
uuidScopeId: scopeUuid,
hlcDatetime: DateTime.utc(2026, 7, 2, 12, 7),
hlcCounter: 1,
tableName: 'person',
uuidRowId: _uuid(57),
uuidNodeId: localNodeUuid,
data: CrdtNode(uuidNodeId: _uuid(67)),
);
authoritative.advanceCheckpoint(scopeUuid, change);
expect(
authoritative.checkpointMaxOf(scopeUuid),
Hlc(DateTime.utc(2026, 7, 2, 12, 7), 1, localNodeUuid),
);
},
);
test(
'when an active scope is revoked and later re-announced, '
'then its checkpoint and sent-handshake state are pruned.',
@ -303,48 +345,6 @@ void main() {
);
},
);
test(
'when a sent change advances a completed handshake, '
'then the tracked checkpoint moves to the change HLC.',
() async {
final userUuid = _uuid(37);
final localNodeUuid = _uuid(47);
final remoteNodeUuid = _uuid(107);
final authoritative = CrdtScopeSyncSession(
session,
userId: userUuid,
mode: CrdtSyncPeerMode.authoritative,
peerNodeId: _uuid(98),
);
final scopeUuid = await session.crdt.scopes.createFor(userUuid);
await authoritative.reconcile();
authoritative.recordPeerHandshake(
scopeUuid,
_since(scopeUuid, _hlc(remoteNodeUuid, minute: 6)),
);
final change = CrdtMergeInsert(
uuidScopeId: scopeUuid,
hlcDatetime: DateTime.utc(2026, 7, 2, 12, 7),
hlcCounter: 1,
tableName: 'person',
uuidRowId: _uuid(57),
uuidNodeId: localNodeUuid,
data: CrdtNode(uuidNodeId: _uuid(67)),
);
authoritative.advanceCheckpoint(scopeUuid, change);
expect(
authoritative.checkpointMaxOf(scopeUuid),
Hlc(DateTime.utc(2026, 7, 2, 12, 7), 1, localNodeUuid),
);
},
);
},
);
}

View file

@ -14,7 +14,7 @@ void main() {
final uniqueDefinition = protocol.getTableDefinition(Unique.t);
final typesDefinition = protocol.getTableDefinition(Types.t);
group('Given table definitions with different table and schema elements order', () {
group('Given table definitions with different table and schema elements order,', () {
final baseDefinitions = [
addressDefinition,
personDefinition,
@ -31,7 +31,7 @@ void main() {
];
test(
'when comparing the definition hashes '
'when comparing the definition hashes, '
'then their hash values are equal.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
@ -49,7 +49,44 @@ void main() {
);
});
group('Given table definitions with different columns', () {
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);
},
);
});
group('Given table definitions with different columns,', () {
final baseDefinitions = [
personDefinition,
];
@ -64,7 +101,7 @@ void main() {
];
test(
'when comparing the definition hashes '
'when comparing the definition hashes, '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
@ -82,7 +119,7 @@ void main() {
);
});
group('Given table definitions with different foreign keys', () {
group('Given table definitions with different foreign keys,', () {
final baseDefinitions = [
addressDefinition,
];
@ -101,7 +138,7 @@ void main() {
];
test(
'when comparing the definition hashes '
'when comparing the definition hashes, '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
@ -119,7 +156,7 @@ void main() {
);
});
group('Given table definitions with different unique indexes', () {
group('Given table definitions with different unique indexes,', () {
final baseDefinitions = [
addressDefinition,
];
@ -142,7 +179,7 @@ void main() {
];
test(
'when comparing the definition hashes '
'when comparing the definition hashes, '
'then their hash values are different.',
() {
final baseHash = CrdtSync.computeSyncTablesHash(
@ -159,43 +196,6 @@ void main() {
},
);
});
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 {

View file

@ -39,7 +39,7 @@ void main() {
late CrdtSync clientSync;
withServerpod(
'Given a server and client CRDT session that are initialized with the same sync tables',
'[CRDT Sync Inbound Authorization]',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
@ -70,9 +70,76 @@ void main() {
});
group(
'and a stale client session holds a readWrite projection for a shared '
'scope the server grants as readOnly, with pending personal and shared '
'writes, when the combined batch is spliced into a real sync session,',
'Given a stale client session with a readWrite projection for a scope the server never granted, with a pending write, '
'when the crafted batch is spliced into a real sync session,',
() {
late UuidValue ungrantedScopeId;
late UuidValue scopedPersonId;
late Object? sessionError;
setUp(() async {
ungrantedScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
clientSession,
userUuid: testCrdtUserId,
scopeUuid: ungrantedScopeId,
role: CrdtScopeRole.readWrite,
);
final scopedPerson = await clientSession.db.transactionForUser(
testCrdtUserId,
(tx) => client.Person.db.insertRow(
clientSession,
client.Person(name: 'never-granted-write'),
transaction: tx,
),
scopeId: ungrantedScopeId,
);
scopedPersonId = scopedPerson.id!;
final changes = await clientSync
.collectPendingChanges(
clientSession,
checkpointsByScopeUuid: {ungrantedScopeId: const []},
)
.toList();
expect(changes, isNotEmpty);
sessionError = await _syncOnceWithSplicedMergeChunk(
serverSync: rawServerSession.crdt,
clientSync: clientSync,
clientSession: clientSession,
userUuid: testCrdtUserId,
splicedChanges: changes,
);
});
test('then the session completes without an error.', () {
expect(sessionError, isNull);
});
test(
'then the non-member write is skipped without recording a violation.',
() async {
expect(
await server.Person.db.findById(serverSession, scopedPersonId),
isNull,
);
expect(
await CrdtSyncIntegrityViolation.db.find(
rawServerSession,
where: (t) => t.uuidRowId.equals(scopedPersonId),
),
isEmpty,
);
},
);
},
);
group(
'Given a stale client session with a readWrite projection for a shared scope the server grants as readOnly, with pending personal and shared writes, '
'when the combined batch is spliced into a real sync session,',
() {
late UuidValue sharedScopeId;
late UuidValue personalPersonId;
@ -155,8 +222,7 @@ void main() {
});
test(
'then the unauthorized shared-scope write is not applied and '
'records an unauthorizedWrite violation.',
'then the unauthorized shared-scope write is not applied and records an unauthorizedWrite violation.',
() async {
expect(
await server.Person.db.findById(serverSession, sharedPersonId),
@ -176,75 +242,6 @@ void main() {
);
},
);
group(
'and a stale client session holds a readWrite projection for a scope '
'the server never granted, with a pending write, '
'when the crafted batch is spliced into a real sync session,',
() {
late UuidValue ungrantedScopeId;
late UuidValue scopedPersonId;
late Object? sessionError;
setUp(() async {
ungrantedScopeId = const Uuid().v7obj();
await _upsertScopeMembership(
clientSession,
userUuid: testCrdtUserId,
scopeUuid: ungrantedScopeId,
role: CrdtScopeRole.readWrite,
);
final scopedPerson = await clientSession.db.transactionForUser(
testCrdtUserId,
(tx) => client.Person.db.insertRow(
clientSession,
client.Person(name: 'never-granted-write'),
transaction: tx,
),
scopeId: ungrantedScopeId,
);
scopedPersonId = scopedPerson.id!;
final changes = await clientSync
.collectPendingChanges(
clientSession,
checkpointsByScopeUuid: {ungrantedScopeId: const []},
)
.toList();
expect(changes, isNotEmpty);
sessionError = await _syncOnceWithSplicedMergeChunk(
serverSync: rawServerSession.crdt,
clientSync: clientSync,
clientSession: clientSession,
userUuid: testCrdtUserId,
splicedChanges: changes,
);
});
test('then the session completes without an error.', () {
expect(sessionError, isNull);
});
test(
'then the non-member write is skipped without recording a violation.',
() async {
expect(
await server.Person.db.findById(serverSession, scopedPersonId),
isNull,
);
expect(
await CrdtSyncIntegrityViolation.db.find(
rawServerSession,
where: (t) => t.uuidRowId.equals(scopedPersonId),
),
isEmpty,
);
},
);
},
);
},
);
}

View file

@ -5,7 +5,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
import 'package:test/test.dart';
void main() {
group('Given an HlcManager', () {
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);
@ -24,7 +24,8 @@ void main() {
);
test(
'when getting the node then the persisted node shape is preserved.',
'when getting the node, '
'then the persisted node shape is preserved.',
() {
final node = manager.getNode();
@ -35,7 +36,7 @@ void main() {
);
});
group('Given a sync stream with complete framed sync batches', () {
group('Given a sync stream with complete framed sync batches,', () {
final uuidScopeId = const Uuid().v7obj();
final rowId = const Uuid().v7obj();
final requesterNodeId = const Uuid().v7obj();
@ -74,7 +75,8 @@ void main() {
]);
test(
'when collecting batches then each framed batch becomes a merge set.',
'when collecting batches, '
'then each framed batch becomes a merge set.',
() async {
final iterator = StreamIterator(stream);
final firstBatch = await iterator.collectNextBatch();
@ -94,7 +96,8 @@ void main() {
);
test(
'when collecting the next batch then batched merge changes are preserved.',
'when collecting the next batch, '
'then batched merge changes are preserved.',
() async {
final singleBatchStream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
@ -132,97 +135,8 @@ void main() {
});
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 uuidScopeId = const Uuid().v7obj();
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
uuidScopeId: uuidScopeId,
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
],
),
]);
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 uuidScopeId = const Uuid().v7obj();
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
uuidScopeId: uuidScopeId,
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
],
),
]);
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 '
'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([
@ -239,8 +153,8 @@ void main() {
);
test(
'Given a stream that is idle before a batch starts '
'when collecting the next batch '
'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([
@ -257,8 +171,8 @@ void main() {
);
test(
'Given a stream that is idle after a merge batch '
'when collecting the next batch '
'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 uuidScopeId = const Uuid().v7obj();
@ -292,8 +206,8 @@ void main() {
);
test(
'Given a stream with CrdtSyncClose before CrdtSyncEndOfBatch '
'when collecting the next batch '
'Given a stream with CrdtSyncClose before CrdtSyncEndOfBatch, '
'when collecting the next batch, '
'then changes are discarded and null is returned.',
() async {
final uuidScopeId = const Uuid().v7obj();
@ -323,8 +237,99 @@ void main() {
);
test(
'Given an empty stream '
'when expecting CrdtSyncConnect '
'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 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 a stream that ends without CrdtSyncEndOfBatch, '
'when collecting the next batch, '
'then collection fails.',
() async {
final uuidScopeId = const Uuid().v7obj();
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
uuidScopeId: uuidScopeId,
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
],
),
]);
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 uuidScopeId = const Uuid().v7obj();
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
CrdtSyncMergeChunk(
changes: [
CrdtMergeDelete(
uuidScopeId: uuidScopeId,
hlcDatetime: DateTime.utc(2026, 5, 10, 14),
hlcCounter: 3,
tableName: 'person',
uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(),
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
],
),
]);
final iterator = StreamIterator(stream);
expect(
() => iterator.collectNextBatch(allowCloseBeforeBatch: true),
throwsA(isA<CrdtSyncStreamClosedException>()),
);
},
);
test(
'Given an empty stream, '
'when expecting CrdtSyncConnect, '
'then it throws a CrdtSyncStreamClosedException.',
() async {
const stream = Stream<CrdtSyncStreamEvent>.empty();
@ -345,8 +350,8 @@ void main() {
);
test(
'Given a stream starting with CrdtSyncEndOfBatch '
'when expecting CrdtSyncConnect '
'Given a stream starting with CrdtSyncEndOfBatch, '
'when expecting CrdtSyncConnect, '
'then it throws a CrdtSyncUnexpectedEventException.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([
@ -375,8 +380,8 @@ void main() {
);
test(
'Given a stream starting with CrdtSyncConnect '
'when expecting CrdtSyncClose '
'Given a stream starting with CrdtSyncConnect, '
'when expecting CrdtSyncClose, '
'then it throws a CrdtSyncUnexpectedEventException.',
() async {
final stream = Stream<CrdtSyncStreamEvent>.fromIterable([

View file

@ -31,7 +31,7 @@ void main() {
);
});
group('Given an inserted typed CRDT row', () {
group('Given an inserted typed CRDT row,', () {
late Types insertedRow;
setUp(() async {
@ -60,7 +60,7 @@ void main() {
});
test(
'when pending changes are collected '
'when pending changes are collected, '
'then the row payload roundtrips with its original Dart type.',
() async {
final mergeSet = await crdtSync
@ -84,197 +84,7 @@ void main() {
);
});
group('Given multiple inserted CRDT rows', () {
setUp(() async {
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 collected pending changes are chunked '
'then each chunk is no larger than the batch size.',
() async {
final chunks = await crdtSync
.collectPendingChanges(
testSession,
checkpointsByScopeUuid: {testCrdtUserId: const []},
)
.chunked(2)
.toList();
expect(chunks, hasLength(2));
expect(chunks.map((chunk) => chunk.length), [2, 1]);
final changes = chunks.expand((chunk) => chunk).toList();
expect(changes.whereType<CrdtMergeInsert>(), hasLength(3));
},
);
});
group('Given personal and shared scope rows authored by the same local node', () {
late UuidValue sharedScopeId;
late Person sharedPerson;
late Person personalPerson;
setUp(() async {
sharedScopeId = const Uuid().v7obj();
final sharedScope = await CrdtScopeManager(testSession).getOrCreate(
sharedScopeId,
);
await CrdtScopeMember.db.insertRow(
testSession,
CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: testCrdtUserId,
role: CrdtScopeRole.readWrite,
),
);
sharedPerson = await crdtSession.db.transactionForUser(
testCrdtUserId,
scopeId: sharedScopeId,
(tx) => Person.db.insertRow(
crdtSession,
Person(id: const Uuid().v7obj(), name: 'shared-person'),
transaction: tx,
),
);
personalPerson = await crdtSession.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
crdtSession,
Person(id: const Uuid().v7obj(), name: 'personal-person'),
transaction: tx,
),
);
});
test(
'when only the personal scope checkpoint has advanced '
'then only the shared scope row is collected.',
() async {
final personalScope = await CrdtScope.db.findFirstRow(
testSession,
where: (t) => t.uuidScopeId.equals(testCrdtUserId),
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final sharedScope = await CrdtScope.db.findFirstRow(
testSession,
where: (t) => t.uuidScopeId.equals(sharedScopeId),
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final personalTracker = await CrdtDataRow.db.findFirstRow(
testSession,
where: (t) => t.uuidRowId.equals(personalPerson.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(
personalScope!.currentNode!.uuidNodeId,
sharedScope!.currentNode!.uuidNodeId,
);
final changes = await crdtSync
.collectPendingChanges(
testSession,
checkpointsByScopeUuid: {
// Simulate the personal scope checkpoint advancing by passing
// the personal tracker's HLC and not the shared scope's HLC.
testCrdtUserId: [personalTracker!.hlc],
sharedScopeId: const [],
},
)
.toList();
final changedRowIds = changes.map((change) => change.uuidRowId);
expect(changedRowIds, contains(sharedPerson.id));
expect(changedRowIds, isNot(contains(personalPerson.id)));
},
);
});
group('Given existing scopes with different current CRDT nodes', () {
late UuidValue firstScopeId;
late UuidValue secondScopeId;
late CrdtNode firstNode;
late Hlc newerSecondNodeHlc;
late CrdtScope secondScope;
setUp(() async {
firstScopeId = const Uuid().v7obj();
secondScopeId = const Uuid().v7obj();
// First scope: already on this replica's stable current node (older clock).
final firstNodeId = const Uuid().v7obj();
firstNode = await CrdtNode.db.insertRow(
testSession,
CrdtNode(
uuidNodeId: firstNodeId,
lastHlc: Hlc(DateTime.utc(2026, 5, 8), 1, firstNodeId),
),
);
await CrdtScope.db.insertRow(
testSession,
CrdtScope(uuidScopeId: firstScopeId, currentNodeId: firstNode.id),
);
// Second scope: opened on a different node with a newer clock.
final secondNodeId = const Uuid().v7obj();
newerSecondNodeHlc = Hlc(DateTime.utc(2026, 5, 9), 1, secondNodeId);
final secondNode = await CrdtNode.db.insertRow(
testSession,
CrdtNode(uuidNodeId: secondNodeId, lastHlc: newerSecondNodeHlc),
);
secondScope = await CrdtScope.db.insertRow(
testSession,
CrdtScope(uuidScopeId: secondScopeId, currentNodeId: secondNode.id),
);
});
test(
'when getOrCreate reopens the second scope '
'then it reuses the first scope node with the second scope HLC instead of creating a new node.',
() async {
final adoptedScope = await CrdtScopeManager(
testSession,
).getOrCreate(secondScopeId);
final expectedHlc = newerSecondNodeHlc.copyWith(
nodeId: firstNode.uuidNodeId,
);
expect(adoptedScope.currentNodeId, firstNode.id);
expect(adoptedScope.currentNode!.lastHlc, expectedHlc);
final persistedSecondScope = await CrdtScope.db.findById(
testSession,
secondScope.id!,
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final scopeNode = await CrdtScopeNode.db.findFirstRow(
testSession,
where: (t) =>
t.scopeId.equals(secondScope.id) & t.nodeId.equals(firstNode.id),
);
expect(persistedSecondScope!.currentNodeId, firstNode.id);
expect(persistedSecondScope.currentNode!.lastHlc, expectedHlc);
expect(scopeNode, isNotNull);
},
);
});
group('Given an updated typed CRDT row', () {
group('Given an updated typed CRDT row,', () {
late Types row;
late Types updatedRow;
@ -321,7 +131,7 @@ void main() {
});
test(
'when pending changes are collected '
'when pending changes are collected, '
'then the field values roundtrip with their original Dart types.',
() async {
final mergeSet = await crdtSync
@ -350,7 +160,43 @@ void main() {
);
});
group('Given an inserted row with an active set-null projection', () {
group('Given multiple inserted CRDT rows,', () {
setUp(() async {
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 collected pending changes are chunked, '
'then each chunk is no larger than the batch size.',
() async {
final chunks = await crdtSync
.collectPendingChanges(
testSession,
checkpointsByScopeUuid: {testCrdtUserId: const []},
)
.chunked(2)
.toList();
expect(chunks, hasLength(2));
expect(chunks.map((chunk) => chunk.length), [2, 1]);
final changes = chunks.expand((chunk) => chunk).toList();
expect(changes.whereType<CrdtMergeInsert>(), hasLength(3));
},
);
});
group('Given an inserted row with an active set-null projection,', () {
late Person attemptedParent;
late Town child;
@ -394,7 +240,7 @@ void main() {
});
test(
'when pending changes are collected '
'when pending changes are collected, '
'then the insert payload carries the attempted foreign key value.',
() async {
final mergeSet = await crdtSync
@ -414,7 +260,7 @@ void main() {
);
});
group('Given an updated row with an active set-null projection', () {
group('Given an updated row with an active set-null projection,', () {
late Town child;
late UuidValue missingParentId;
@ -451,7 +297,7 @@ void main() {
});
test(
'when pending changes are collected '
'when pending changes are collected, '
'then the update payload carries the attempted foreign key value.',
() async {
final mergeSet = await crdtSync
@ -475,9 +321,163 @@ void main() {
);
});
group('Given personal and shared scope rows authored by the same local node,', () {
late UuidValue sharedScopeId;
late Person sharedPerson;
late Person personalPerson;
setUp(() async {
sharedScopeId = const Uuid().v7obj();
final sharedScope = await CrdtScopeManager(testSession).getOrCreate(
sharedScopeId,
);
await CrdtScopeMember.db.insertRow(
testSession,
CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: testCrdtUserId,
role: CrdtScopeRole.readWrite,
),
);
sharedPerson = await crdtSession.db.transactionForUser(
testCrdtUserId,
scopeId: sharedScopeId,
(tx) => Person.db.insertRow(
crdtSession,
Person(id: const Uuid().v7obj(), name: 'shared-person'),
transaction: tx,
),
);
personalPerson = await crdtSession.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
crdtSession,
Person(id: const Uuid().v7obj(), name: 'personal-person'),
transaction: tx,
),
);
});
test(
'when only the personal scope checkpoint has advanced, '
'then only the shared scope row is collected.',
() async {
final personalScope = await CrdtScope.db.findFirstRow(
testSession,
where: (t) => t.uuidScopeId.equals(testCrdtUserId),
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final sharedScope = await CrdtScope.db.findFirstRow(
testSession,
where: (t) => t.uuidScopeId.equals(sharedScopeId),
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final personalTracker = await CrdtDataRow.db.findFirstRow(
testSession,
where: (t) => t.uuidRowId.equals(personalPerson.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(
personalScope!.currentNode!.uuidNodeId,
sharedScope!.currentNode!.uuidNodeId,
);
final changes = await crdtSync
.collectPendingChanges(
testSession,
checkpointsByScopeUuid: {
// Simulate the personal scope checkpoint advancing by passing
// the personal tracker's HLC and not the shared scope's HLC.
testCrdtUserId: [personalTracker!.hlc],
sharedScopeId: const [],
},
)
.toList();
final changedRowIds = changes.map((change) => change.uuidRowId);
expect(changedRowIds, contains(sharedPerson.id));
expect(changedRowIds, isNot(contains(personalPerson.id)));
},
);
});
group('Given existing scopes with different current CRDT nodes,', () {
late UuidValue firstScopeId;
late UuidValue secondScopeId;
late CrdtNode firstNode;
late Hlc newerSecondNodeHlc;
late CrdtScope secondScope;
setUp(() async {
firstScopeId = const Uuid().v7obj();
secondScopeId = const Uuid().v7obj();
// First scope: already on this replica's stable current node (older clock).
final firstNodeId = const Uuid().v7obj();
firstNode = await CrdtNode.db.insertRow(
testSession,
CrdtNode(
uuidNodeId: firstNodeId,
lastHlc: Hlc(DateTime.utc(2026, 5, 8), 1, firstNodeId),
),
);
await CrdtScope.db.insertRow(
testSession,
CrdtScope(uuidScopeId: firstScopeId, currentNodeId: firstNode.id),
);
// Second scope: opened on a different node with a newer clock.
final secondNodeId = const Uuid().v7obj();
newerSecondNodeHlc = Hlc(DateTime.utc(2026, 5, 9), 1, secondNodeId);
final secondNode = await CrdtNode.db.insertRow(
testSession,
CrdtNode(uuidNodeId: secondNodeId, lastHlc: newerSecondNodeHlc),
);
secondScope = await CrdtScope.db.insertRow(
testSession,
CrdtScope(uuidScopeId: secondScopeId, currentNodeId: secondNode.id),
);
});
test(
'when getOrCreate reopens the second scope, '
'then it reuses the first scope node with the second scope HLC instead of creating a new node.',
() async {
final adoptedScope = await CrdtScopeManager(
testSession,
).getOrCreate(secondScopeId);
final expectedHlc = newerSecondNodeHlc.copyWith(
nodeId: firstNode.uuidNodeId,
);
expect(adoptedScope.currentNodeId, firstNode.id);
expect(adoptedScope.currentNode!.lastHlc, expectedHlc);
final persistedSecondScope = await CrdtScope.db.findById(
testSession,
secondScope.id!,
include: CrdtScope.include(currentNode: CrdtNode.include()),
);
final scopeNode = await CrdtScopeNode.db.findFirstRow(
testSession,
where: (t) =>
t.scopeId.equals(secondScope.id) & t.nodeId.equals(firstNode.id),
);
expect(persistedSecondScope!.currentNodeId, firstNode.id);
expect(persistedSecondScope.currentNode!.lastHlc, expectedHlc);
expect(scopeNode, isNotNull);
},
);
});
test(
'Given a CRDT node without local changes '
'when synchronization checkpoints are created '
'Given a CRDT node without local changes, '
'when synchronization checkpoints are created, '
'then one fresh checkpoint for the local node is included.',
() async {
final scope = await CrdtScopeManager(testSession).getOrCreate(testCrdtUserId);

View file

@ -0,0 +1,182 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.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);
withServerpod(
'Given initialized server and client CRDT sessions with matching sync tables,',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
late client.Client testClient;
late CrdtDatabaseSession clientSession;
late CrdtDatabaseSession serverSession;
final serverSyncTables = [
server.Address.t,
server.Person.t,
server.Types.t,
server.Unique.t,
];
final clientSyncTables = [
client.Address.t,
client.Person.t,
client.Types.t,
client.Unique.t,
];
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();
});
test(
'when client syncOnce is called, '
'then synchronization completes.',
() async {
await expectLater(
testClient.crdt.syncOnce(clientSession).timeout(const Duration(seconds: 3)),
completes,
);
},
);
},
);
withServerpod(
'Given initialized server and client CRDT sessions with different sync tables,',
rollbackDatabase: RollbackDatabase.disabled,
// Silent mode avoids printing the symmetric hash mismatch from the server.
// The client-side exception is asserted below.
testServerOutputMode: TestServerOutputMode.silent,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
late client.Client testClient;
late CrdtDatabaseSession clientSession;
late CrdtDatabaseSession serverSession;
final serverSyncTables = [
server.Address.t,
server.Person.t,
server.Types.t,
server.Unique.t,
];
final clientSyncTables = [
client.Address.t,
client.Person.t,
];
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();
});
final expectedHashMismatch = isA<SyncTablesHashMismatchException>()
.having(
(e) => e.toString(),
'message',
contains('SyncTablesHashMismatchException: schema hash mismatch.'),
)
.having(
(e) => e.toString(),
'message',
contains(
'Ensure both sides are on the same schema version before syncing.',
),
);
test(
'when client syncOnce is called, '
'then SyncTablesHashMismatchException is thrown.',
() async {
await expectLater(
testClient.crdt.syncOnce(clientSession),
throwsA(expectedHashMismatch),
);
},
);
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(expectedHashMismatch),
);
},
);
},
);
}
class TestClientAuthKeyProvider implements ClientAuthKeyProvider {
@override
Future<String?> get authHeaderValue async => 'Bearer token';
}

View file

@ -21,7 +21,7 @@ void main() {
late CrdtDatabaseSession secondClientSession;
withServerpod(
'Given a server and two client CRDT sessions for the same user that are initialized with the unique sync table',
'[CRDT Sync Unique Conflict]',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
@ -66,7 +66,7 @@ void main() {
await serverSession.clearUserTables();
});
group('and an older server row conflicts with a newer incoming row,', () {
group('Given an older server row conflicting with a newer incoming row,', () {
late client.Unique olderRow;
late client.Unique newerRow;
@ -106,7 +106,7 @@ void main() {
});
});
group('and a newer server row conflicts with an older incoming row,', () {
group('Given a newer server row conflicting with an older incoming row,', () {
late client.Unique olderRow;
late client.Unique newerRow;
@ -146,7 +146,7 @@ void main() {
});
});
group('and a newer server update conflicts with an older incoming update,', () {
group('Given a newer server update conflicting with an older incoming update,', () {
late client.Unique olderUpdatedRow;
late client.Unique newerUpdatedRow;
@ -204,7 +204,7 @@ void main() {
});
});
group('and an older incoming insert conflicts with a newer server update,', () {
group('Given an older incoming insert conflicting with a newer server update,', () {
late client.Unique insertedRow;
late client.Unique updatedRow;