feat: Implements foreign key handling (#68)

This commit is contained in:
Marcelo Mendonça Soares 2026-06-10 17:31:05 -03:00 • committed by GitHub
commit 4580326956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 6630 additions and 189 deletions

View file

@ -26,3 +26,4 @@ database:
sessionLogs:
persistentEnabled: false
consoleEnabled: true
consoleLogFormat: text

View file

@ -0,0 +1,8 @@
class: FkChainCascadeMiddle
table: fk_chain_cascade_middle
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
root: FkChainRoot?, relation(field=rootId, onDelete=Cascade)
rootId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainMiddleCascadeChild
table: fk_chain_middle_cascade_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=Cascade)
restrictBlockerId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainMiddleSetNullChild
table: fk_chain_middle_set_null_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
restrictBlocker: FkChainRestrictBlocker?, relation(field=restrictBlockerId, onDelete=SetNull)
restrictBlockerId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainRestrictBlocker
table: fk_chain_restrict_blocker
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=Restrict)
cascadeMiddleId: UuidValue?

View file

@ -0,0 +1,6 @@
class: FkChainRoot
table: fk_chain_root
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String

View file

@ -0,0 +1,8 @@
class: FkChainSetNullCascadeChild
table: fk_chain_set_null_cascade_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Cascade)
setNullMiddleId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainSetNullMiddle
table: fk_chain_set_null_middle
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
cascadeMiddle: FkChainCascadeMiddle?, relation(field=cascadeMiddleId, onDelete=SetNull)
cascadeMiddleId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainSetNullRestrictChild
table: fk_chain_set_null_restrict_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=Restrict)
setNullMiddleId: UuidValue?

View file

@ -0,0 +1,8 @@
class: FkChainSetNullSetNullChild
table: fk_chain_set_null_set_null_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
setNullMiddle: FkChainSetNullMiddle?, relation(field=setNullMiddleId, onDelete=SetNull)
setNullMiddleId: UuidValue?

View file

@ -0,0 +1,8 @@
class: RequiredSetNullChild
table: required_set_null_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
parent: Person?, relation(field=parentId, onDelete=SetNull)
parentId: UuidValue

View file

@ -0,0 +1,8 @@
class: RestrictChild
table: restrict_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
parent: Person?, relation(field=parentId, onDelete=Restrict)
parentId: UuidValue?

View file

@ -4,4 +4,5 @@ database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
city: City?, relation(optional, onDelete=Cascade)
mayor: Person?, relation(optional, onDelete=SetNull)

View file

@ -0,0 +1,8 @@
class: UniqueSetNullChild
table: unique_set_null_child
database: all
fields:
id: UuidValue?, defaultPersist=random_v7
name: String
parent: Person?, relation(field=parentId, onDelete=SetNull)
parentId: UuidValue?, unique

View file

@ -322,6 +322,12 @@ void main() {
);
expect(tombstones.single.isDeleted, isFalse);
await CrdtDataRow.db.updateWhere(
session,
columnValues: (t) => [t.visibility(CrdtDataRowVisibility.userReinsert)],
where: (t) => t.uuidRowId.equals(person.id),
);
});
group('when deleting the person again,', () {
@ -393,9 +399,23 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
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);
},
);
test('then the organization row still exist on the organization table.', () async {
final row = await Organization.db.findFirstRow(
// Use the test session to avoid the tombstone filter of the CRDT database.
@ -414,8 +434,22 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
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),
);
expect(crdtRow, isNotNull);
expect(crdtRow!.isHidden, isTrue);
expect(crdtRow.visibility, CrdtDataRowVisibility.userCascadeDelete);
},
);
});
group('Given an address row with an ON DELETE RESTRICT related person, '

View file

@ -97,6 +97,13 @@ void main() {
),
],
);
await CrdtDataRow.db.updateRow(
session,
personCrdtRow.copyWith(
visibility: CrdtDataRowVisibility.userDelete,
),
columns: (t) => [t.visibility],
);
});
test(

View file

@ -179,7 +179,7 @@ void main() {
hlcDatetime: tombstoneHlc.datetime.advance(),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.cascadeDelete,
reason: CrdtDataDeletedReason.userCascadeDelete,
);
mergeSet = [remoteDelete];
});
@ -201,7 +201,7 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
expect(tombstone.reason, CrdtDataDeletedReason.userCascadeDelete);
expect(tombstone.hlc, remoteDelete.hlc);
expect(tombstone.node!.uuidNodeId, remoteNodeId);
});
@ -432,6 +432,72 @@ void main() {
},
);
group(
'Given a table with an existing row and a newer remote delete with a '
'non-synced projection reason, ',
() {
late Person person;
late CrdtMergeDelete remoteDelete;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'projection reason delete'),
transaction: tx,
),
);
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
remoteDelete = CrdtMergeDelete(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: crdtRow!.hlc.datetime.advance(),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.uniqueLoser,
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test(
'then the non-synced delete is ignored and the row stays visible.',
() async {
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, person.name);
},
);
test('then no tombstone metadata is recorded.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(tombstone, isNull);
});
});
},
);
group(
'Given an empty table and a remote delete for a non-existing row, ',
() {

View file

@ -368,6 +368,251 @@ void main() {
},
);
group(
'Given a table with an existing row and an older remote insert for the same row ID, ',
() {
late Person person;
late Hlc localRowHlc;
late CrdtMergeInsert remoteInsert;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'local'),
transaction: tx,
),
);
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
localRowHlc = row!.hlc;
remoteInsert = CrdtMergeInsert(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: localRowHlc.datetime.retreat(),
hlcCounter: localRowHlc.counter,
data: person.copyWith(name: 'older remote'),
);
mergeSet = [remoteInsert];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the local row data is preserved.', () async {
final row = await Person.db.findById(session, person.id!);
expect(row, isNotNull);
expect(row!.name, 'local');
});
test('then the CRDT row metadata is not updated.', () async {
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
expect(row!.hlc, localRowHlc);
expect(row.node!.uuidNodeId, localRowHlc.nodeId);
});
test(
'then no CRDT field metadata is recorded for the ignored insert.',
() async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(fields, isEmpty);
},
);
});
},
);
group(
'Given an empty table and a merge set that lists a newer remote update '
'before its remote insert, ',
() {
late Person remotePerson;
late CrdtMergeInsert remoteInsert;
late CrdtMergeUpdate remoteUpdate;
setUp(() {
final remoteNodeId = const Uuid().v7obj();
remotePerson = Person(id: const Uuid().v7obj(), name: 'remote');
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
remoteInsert = CrdtMergeInsert(
tableName: Person.t.tableName,
uuidRowId: remotePerson.id!,
uuidNodeId: hlc.nodeId,
hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter,
data: remotePerson,
);
remoteUpdate = CrdtMergeUpdate(
tableName: Person.t.tableName,
uuidRowId: remotePerson.id!,
uuidNodeId: remoteNodeId,
hlcDatetime: remoteInsert.hlc.datetime.advance(),
hlcCounter: 0,
columnName: Person.t.name.columnName,
value: 'updated remotely',
);
mergeSet = [remoteUpdate, remoteInsert];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test(
'then the operations are applied causally and the row holds the '
'updated value.',
() async {
final row = await Person.db.findById(session, remotePerson.id!);
expect(row, isNotNull);
expect(row!.name, remoteUpdate.value);
},
);
test(
'then the CRDT field metadata records the update HLC.',
() async {
final field = await CrdtDataField.db.findFirstRow(
session,
where: (t) =>
t.row.uuidRowId.equals(remotePerson.id) &
t.column.name.equals(Person.t.name.columnName),
include: CrdtDataField.include(node: CrdtNode.include()),
);
expect(field, isNotNull);
expect(field!.hlc, remoteUpdate.hlc);
},
);
});
},
);
group(
'Given a remote insert and a newer remote update that were already merged, ',
() {
late Person remotePerson;
late CrdtMergeUpdate remoteUpdate;
late Hlc rowHlcAfterFirstMerge;
late List<Hlc> fieldHlcsAfterFirstMerge;
setUp(() async {
final remoteNodeId = const Uuid().v7obj();
remotePerson = Person(id: const Uuid().v7obj(), name: 'remote');
final hlc = Hlc(DateTime.now().toUtc(), 0, remoteNodeId);
final remoteInsert = CrdtMergeInsert(
tableName: Person.t.tableName,
uuidRowId: remotePerson.id!,
uuidNodeId: hlc.nodeId,
hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter,
data: remotePerson,
);
remoteUpdate = CrdtMergeUpdate(
tableName: Person.t.tableName,
uuidRowId: remotePerson.id!,
uuidNodeId: remoteNodeId,
hlcDatetime: remoteInsert.hlc.datetime.advance(),
hlcCounter: 0,
columnName: Person.t.name.columnName,
value: 'updated remotely',
);
mergeSet = [remoteInsert, remoteUpdate];
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(remotePerson.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
rowHlcAfterFirstMerge = row!.hlc;
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(remotePerson.id),
include: CrdtDataField.include(node: CrdtNode.include()),
orderBy: (t) => t.columnId,
);
fieldHlcsAfterFirstMerge = [for (final field in fields) field.hlc];
});
group('when the same merge set is replayed, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the row data does not change.', () async {
final row = await Person.db.findById(session, remotePerson.id!);
expect(row, isNotNull);
expect(row!.name, remoteUpdate.value);
});
test('then the CRDT row and field metadata do not change.', () async {
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(remotePerson.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(remotePerson.id),
include: CrdtDataField.include(node: CrdtNode.include()),
orderBy: (t) => t.columnId,
);
expect(row!.hlc, rowHlcAfterFirstMerge);
expect([for (final field in fields) field.hlc], fieldHlcsAfterFirstMerge);
});
test('then no tombstone metadata is created by the replay.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(remotePerson.id),
);
expect(tombstone, isNull);
});
});
},
);
group(
'Given a table with a row deleted in generation four and a newer remote insert payload, ',
() {
@ -442,4 +687,5 @@ void main() {
extension on DateTime {
DateTime advance() => add(const Duration(milliseconds: 1));
DateTime retreat() => subtract(const Duration(milliseconds: 1));
}

View file

@ -0,0 +1,560 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_shared/serverpod_offline_sync_shared.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../test_tools/client_session.dart';
void main() {
initTestClientSession();
group(
'Given a visible row claiming a unique value and a newer remote insert '
'that claims the same value, ',
() {
late Unique winner;
late Unique loser;
late CrdtMergeInsert remoteInsert;
setUp(() async {
winner = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: 'shared-name'),
transaction: tx,
),
);
loser = Unique(id: const Uuid().v7obj(), name: 'shared-name');
remoteInsert = CrdtMergeInsert(
tableName: Unique.t.tableName,
uuidRowId: loser.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await _rowHlc(winner.id!)).datetime.advance(),
hlcCounter: 0,
data: loser,
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then both rows remain visible and the incoming insert receives a '
'deterministic conflict-free value.',
() async {
final rows = await Unique.db.find(session);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == winner.id).name,
'shared-name',
);
expect(
rows.singleWhere((row) => row.id == loser.id).name,
'shared-name__conflict__${loser.id!.uuid}',
);
},
);
});
group('when the same losing insert was merged and is replayed, ', () {
late Hlc loserRowHlcAfterFirstMerge;
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
loserRowHlcAfterFirstMerge = await _rowHlc(loser.id!);
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then the replay does not rewrite the resolved unique values.',
() async {
final rows = await Unique.db.find(session);
expect(
rows.singleWhere((row) => row.id == winner.id).name,
'shared-name',
);
expect(
rows.singleWhere((row) => row.id == loser.id).name,
'shared-name__conflict__${loser.id!.uuid}',
);
},
);
test('then the replay does not change the CRDT row metadata.', () async {
expect(await _rowHlc(loser.id!), loserRowHlcAfterFirstMerge);
});
});
},
);
group(
'Given a visible row claiming a unique value and an older remote insert '
'that claims the same value, ',
() {
late Unique existingLoser;
late Unique incomingWinner;
late CrdtMergeInsert remoteInsert;
setUp(() async {
existingLoser = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: 'shared-name'),
transaction: tx,
),
);
incomingWinner = Unique(id: const Uuid().v7obj(), name: 'shared-name');
remoteInsert = CrdtMergeInsert(
tableName: Unique.t.tableName,
uuidRowId: incomingWinner.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await _rowHlc(existingLoser.id!)).datetime.retreat(),
hlcCounter: 0,
data: incomingWinner,
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then the older incoming claim keeps the unique value and the '
'existing visible row is released to a conflict-free value.',
() async {
final rows = await Unique.db.find(session);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == incomingWinner.id).name,
'shared-name',
);
expect(
rows.singleWhere((row) => row.id == existingLoser.id).name,
'shared-name__conflict__${existingLoser.id!.uuid}',
);
},
);
test(
'then the silently released row does not receive CRDT field metadata.',
() async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(existingLoser.id),
);
expect(fields, isEmpty);
},
);
});
},
);
group(
'Given two visible rows and an older remote update that claims the unique '
'value of the row with the newer claim, ',
() {
late Unique updatedWinner;
late Unique existingLoser;
late CrdtMergeUpdate remoteUpdate;
setUp(() async {
updatedWinner = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: 'original-name'),
transaction: tx,
),
);
// Guarantees the existing claim HLC is newer than the remote update.
await Future<void>.delayed(const Duration(milliseconds: 5));
existingLoser = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: 'shared-name'),
transaction: tx,
),
);
remoteUpdate = CrdtMergeUpdate(
tableName: Unique.t.tableName,
uuidRowId: updatedWinner.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await _rowHlc(updatedWinner.id!)).datetime.advance(),
hlcCounter: 0,
columnName: Unique.t.name.columnName,
value: 'shared-name',
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteUpdate],
userId: testCrdtUserId,
);
});
test(
'then the older incoming claim keeps the unique value and the row '
'with the newer claim is released to a conflict-free value.',
() async {
final rows = await Unique.db.find(session);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == updatedWinner.id).name,
'shared-name',
);
expect(
rows.singleWhere((row) => row.id == existingLoser.id).name,
'shared-name__conflict__${existingLoser.id!.uuid}',
);
},
);
});
},
);
group(
'Given a visible child claiming a nullable unique foreign key and a newer '
'remote insert that claims the same parent, ',
() {
late Person parent;
late UniqueSetNullChild winner;
late UniqueSetNullChild loser;
late CrdtMergeInsert remoteInsert;
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
parent = await Person.db.insertRow(
session,
Person(id: const Uuid().v7obj(), name: 'unique claim parent'),
transaction: tx,
);
winner = await UniqueSetNullChild.db.insertRow(
session,
UniqueSetNullChild(
id: const Uuid().v7obj(),
name: 'first claimer',
parentId: parent.id,
),
transaction: tx,
);
});
loser = UniqueSetNullChild(
id: const Uuid().v7obj(),
name: 'second claimer',
parentId: parent.id,
);
final winnerHlc = await _rowHlc(winner.id!);
final parentHlc = await _rowHlc(parent.id!);
final winnerClaimHlc = winnerHlc.maxBetween(parentHlc);
remoteInsert = CrdtMergeInsert(
tableName: UniqueSetNullChild.t.tableName,
uuidRowId: loser.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: winnerClaimHlc.datetime.advance(),
hlcCounter: 0,
data: loser,
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then both children remain visible and the loser is released to a '
'null unique value.',
() async {
final rows = await UniqueSetNullChild.db.find(session);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == winner.id).parentId,
parent.id,
);
expect(
rows.singleWhere((row) => row.id == loser.id).parentId,
isNull,
);
},
);
});
},
);
group(
'Given two databases and two remote inserts that claim the same unique '
'value with identical HLCs from different nodes, ',
() {
late CrdtDatabaseSession singleBatchSession;
late CrdtDatabaseSession splitBatchSession;
late Unique olderClaimRow;
late Unique newerClaimRow;
late CrdtMergeInsert olderClaimInsert;
late CrdtMergeInsert newerClaimInsert;
setUp(() async {
singleBatchSession = session;
splitBatchSession = CrdtDatabaseSession.wraps(
await createAdditionalTestSession(),
syncTables: [Unique.t],
);
await splitBatchSession.db.initialize();
// The HLC node id is the only tie-break between the two claims.
final nodeIds = [const Uuid().v7obj(), const Uuid().v7obj()]
..sort((left, right) => left.uuid.compareTo(right.uuid));
final claimDatetime = DateTime.now().toUtc();
olderClaimRow = Unique(id: const Uuid().v7obj(), name: 'shared-name');
newerClaimRow = Unique(id: const Uuid().v7obj(), name: 'shared-name');
olderClaimInsert = CrdtMergeInsert(
tableName: Unique.t.tableName,
uuidRowId: olderClaimRow.id!,
uuidNodeId: nodeIds.first,
hlcDatetime: claimDatetime,
hlcCounter: 0,
data: olderClaimRow,
);
newerClaimInsert = CrdtMergeInsert(
tableName: Unique.t.tableName,
uuidRowId: newerClaimRow.id!,
uuidNodeId: nodeIds.last,
hlcDatetime: claimDatetime,
hlcCounter: 0,
data: newerClaimRow,
);
});
group(
'when one database merges one batch and the other merges split '
'batches in reverse arrival order, ',
() {
setUp(() async {
await singleBatchSession.db.mergeChanges(
[newerClaimInsert, olderClaimInsert],
userId: testCrdtUserId,
);
await splitBatchSession.db.mergeChanges(
[newerClaimInsert],
userId: testCrdtUserId,
);
await splitBatchSession.db.mergeChanges(
[olderClaimInsert],
userId: testCrdtUserId,
);
});
test(
'then both databases keep the node-id tie-break winner and '
'release the loser to the same conflict-free value.',
() async {
for (final databaseSession in [
singleBatchSession,
splitBatchSession,
]) {
final rows = await Unique.db.find(databaseSession);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == olderClaimRow.id).name,
'shared-name',
);
expect(
rows.singleWhere((row) => row.id == newerClaimRow.id).name,
'shared-name__conflict__${newerClaimRow.id!.uuid}',
);
}
},
);
},
);
},
);
group(
'Given a visible UniqueUuid row claiming a UUID unique value and a newer '
'remote insert that claims the same value, ',
() {
final sharedValue = const Uuid().v7obj();
late UniqueUuid winner;
late UniqueUuid loser;
late CrdtMergeInsert remoteInsert;
setUp(() async {
winner = await session.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueUuid.db.insertRow(
session,
UniqueUuid(id: const Uuid().v7obj(), value: sharedValue),
transaction: tx,
),
);
loser = UniqueUuid(id: const Uuid().v7obj(), value: sharedValue);
remoteInsert = CrdtMergeInsert(
tableName: UniqueUuid.t.tableName,
uuidRowId: loser.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await _rowHlc(winner.id!)).datetime.advance(),
hlcCounter: 0,
data: loser,
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then both rows remain visible and the incoming insert receives a '
'deterministic conflict-free UUID unique value.',
() async {
final rows = await UniqueUuid.db.find(session);
final expectedConflictValue = const Uuid().v5obj(
Namespace.oid.value,
'${UniqueUuid.t.tableName}.${UniqueUuid.t.value.columnName}:'
'${sharedValue.uuid}__conflict__${loser.id!.uuid}',
);
expect(rows, hasLength(2));
expect(
rows.singleWhere((row) => row.id == winner.id).value,
sharedValue,
);
expect(
rows.singleWhere((row) => row.id == loser.id).value,
expectedConflictValue,
);
},
);
});
},
);
group(
'Given a unique value previously claimed by a locally deleted row and a '
'remote insert that claims it, ',
() {
late Unique deletedRow;
late Unique incomingRow;
late CrdtMergeInsert remoteInsert;
setUp(() async {
deletedRow = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.insertRow(
session,
Unique(id: const Uuid().v7obj(), name: 'shared-name'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Unique.db.deleteRow(session, deletedRow, transaction: tx),
);
incomingRow = Unique(id: const Uuid().v7obj(), name: 'shared-name');
remoteInsert = CrdtMergeInsert(
tableName: Unique.t.tableName,
uuidRowId: incomingRow.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await _rowHlc(deletedRow.id!)).datetime.advance(),
hlcCounter: 0,
data: incomingRow,
);
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
[remoteInsert],
userId: testCrdtUserId,
);
});
test(
'then the hidden claim does not conflict and the incoming row keeps '
'the unique value.',
() async {
final rows = await Unique.db.find(session);
expect(rows, hasLength(1));
expect(rows.single.id, incomingRow.id);
expect(rows.single.name, 'shared-name');
},
);
test(
'then the hidden row keeps its released unique value.',
() async {
final hiddenRow = await Unique.db.findById(
testSession,
deletedRow.id!,
);
expect(hiddenRow, isNotNull);
expect(
hiddenRow!.name,
'shared-name__deleted__${deletedRow.id!.uuid}',
);
},
);
});
},
);
}
Future<Hlc> _rowHlc(UuidValue rowId) async {
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(rowId),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
return crdtRow!.hlc;
}
extension on DateTime {
DateTime advance() => add(const Duration(milliseconds: 1));
DateTime retreat() => subtract(const Duration(milliseconds: 1));
}

Some files were not shown because too many files have changed in this diff Show more