fix: General tests improvements and misc small fixes (#104)
This commit is contained in:
parent
a24faf39af
commit
8e7e6551e0
20 changed files with 2902 additions and 2155 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,', () {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,', () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, '
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue