feat: Implements foreign key handling (#68)
This commit is contained in:
parent
e74caa1400
commit
4580326956
23 changed files with 6630 additions and 189 deletions
|
|
@ -26,3 +26,4 @@ database:
|
|||
sessionLogs:
|
||||
persistentEnabled: false
|
||||
consoleEnabled: true
|
||||
consoleLogFormat: text
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
class: FkChainRoot
|
||||
table: fk_chain_root
|
||||
database: all
|
||||
fields:
|
||||
id: UuidValue?, defaultPersist=random_v7
|
||||
name: String
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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, '
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ void main() {
|
|||
),
|
||||
],
|
||||
);
|
||||
await CrdtDataRow.db.updateRow(
|
||||
session,
|
||||
personCrdtRow.copyWith(
|
||||
visibility: CrdtDataRowVisibility.userDelete,
|
||||
),
|
||||
columns: (t) => [t.visibility],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
|
|
|
|||
|
|
@ -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, ',
|
||||
() {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue