fix: Repair foreign keys on columns that also carry a unique claim (#151)
This commit is contained in:
parent
f3c462b5d3
commit
51ce999af0
3 changed files with 435 additions and 12 deletions
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group(
|
||||
'Given a parent deletion blocked by a restrict child and a unique SET NULL child,',
|
||||
() {
|
||||
late SyncNode node;
|
||||
late Person parent;
|
||||
late RestrictChild blocker;
|
||||
late UniqueSetNullChild child;
|
||||
late Hlc childHlc;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
parent = Person(id: const Uuid().v7obj(), name: 'parent');
|
||||
blocker = RestrictChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'blocker',
|
||||
parentId: parent.id,
|
||||
);
|
||||
child = UniqueSetNullChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'child',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Person.db.insertRow(node.offlineSync, parent, transaction: tx);
|
||||
await RestrictChild.db.insertRow(node.offlineSync, blocker, transaction: tx);
|
||||
await UniqueSetNullChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
child,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
childHlc = await rowHlc(child.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeDelete(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: Person.t.tableName,
|
||||
uuidRowId: parent.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
clFlag: 2,
|
||||
reason: CrdtDataDeletedReason.userDelete,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
});
|
||||
|
||||
group('when the restrict child is detached locally,', () {
|
||||
late Person? visibleParent;
|
||||
late UniqueSetNullChild? visibleChild;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await RestrictChild.db.updateRow(
|
||||
node.offlineSync,
|
||||
blocker.copyWith(parentId: null),
|
||||
columns: (t) => [t.parentId],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
visibleParent = await Person.db.findById(node.offlineSync, parent.id!);
|
||||
visibleChild = await UniqueSetNullChild.db.findById(
|
||||
node.offlineSync,
|
||||
child.id!,
|
||||
);
|
||||
attempt = await attemptedValue(
|
||||
rowId: child.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
|
||||
test(
|
||||
'then the visible child is repaired to null while its parent is hidden.',
|
||||
() {
|
||||
expect(visibleParent, isNull);
|
||||
expect(visibleChild, isNotNull);
|
||||
expect(visibleChild!.parentId, isNull);
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.foreignKeySetNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'then the original reference survives without an authored repair clock.',
|
||||
() {
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == child.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetNullChild).parentId, parent.id);
|
||||
expect(insert.hlc, childHlc);
|
||||
expect(
|
||||
changes.updates.where((change) => change.uuidRowId == child.id),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group(
|
||||
'Given a SET DEFAULT repair released to null because another child owns the default,',
|
||||
() {
|
||||
late SyncNode node;
|
||||
late Town parent;
|
||||
late Town defaultTown;
|
||||
late Address address;
|
||||
late UniqueSetDefaultChild loser;
|
||||
late UniqueSetDefaultChild winner;
|
||||
late Hlc childHlc;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
final mayor = Person(id: const Uuid().v7obj(), name: 'mayor');
|
||||
parent = Town(id: const Uuid().v7obj(), name: 'parent', mayorId: mayor.id);
|
||||
defaultTown = Town(
|
||||
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||
name: 'default',
|
||||
);
|
||||
address = Address(
|
||||
id: const Uuid().v7obj(),
|
||||
street: 'street',
|
||||
inhabitantId: mayor.id,
|
||||
);
|
||||
winner = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'winner',
|
||||
parentId: defaultTown.id,
|
||||
);
|
||||
loser = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'loser',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Person.db.insertRow(node.offlineSync, mayor, transaction: tx);
|
||||
await Town.db.insert(node.offlineSync, [
|
||||
parent,
|
||||
defaultTown,
|
||||
], transaction: tx);
|
||||
await Address.db.insertRow(node.offlineSync, address, transaction: tx);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
loser,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
childHlc = await rowHlc(loser.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeDelete(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: Town.t.tableName,
|
||||
uuidRowId: parent.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
clFlag: 2,
|
||||
reason: CrdtDataDeletedReason.userDelete,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
});
|
||||
|
||||
group('when an address is detached from the deleted town mayor locally,', () {
|
||||
late UniqueSetDefaultChild? stored;
|
||||
late UniqueSetDefaultChild? owner;
|
||||
late Town? visibleParent;
|
||||
late Town? visibleDefault;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Address.db.updateRow(
|
||||
node.offlineSync,
|
||||
address.copyWith(inhabitantId: null),
|
||||
columns: (t) => [t.inhabitantId],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||
owner = await UniqueSetDefaultChild.db.findById(node.offlineSync, winner.id!);
|
||||
visibleParent = await Town.db.findById(node.offlineSync, parent.id!);
|
||||
visibleDefault = await Town.db.findById(node.offlineSync, defaultTown.id!);
|
||||
attempt = await attemptedValue(
|
||||
rowId: loser.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
|
||||
test('then the null release retains its terminal unique-conflict reason.', () {
|
||||
expect(visibleParent, isNull);
|
||||
expect(visibleDefault, isNotNull);
|
||||
expect(owner!.parentId, defaultTown.id);
|
||||
expect(stored, isNotNull);
|
||||
expect(stored!.parentId, isNull);
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == loser.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||
expect(insert.hlc, childHlc);
|
||||
expect(
|
||||
changes.updates.where((change) => change.uuidRowId == loser.id),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.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';
|
||||
import '../test_tools/crdt_probes.dart';
|
||||
import '../test_tools/sync_topology.dart';
|
||||
|
||||
void main() {
|
||||
initTestClientSession(createSessionPerTest: false);
|
||||
|
||||
group('Given a unique SET DEFAULT loser displaying null for its authored parent,', () {
|
||||
late SyncNode node;
|
||||
late Town parent;
|
||||
late UniqueSetDefaultChild winner;
|
||||
late UniqueSetDefaultChild loser;
|
||||
|
||||
setUpAll(() async {
|
||||
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||
parent = Town(id: const Uuid().v7obj(), name: 'parent');
|
||||
winner = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'winner',
|
||||
parentId: parent.id,
|
||||
);
|
||||
loser = UniqueSetDefaultChild(
|
||||
id: const Uuid().v7obj(),
|
||||
name: 'loser',
|
||||
parentId: parent.id,
|
||||
);
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await Town.db.insert(node.offlineSync, [
|
||||
parent,
|
||||
Town(
|
||||
id: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
|
||||
name: 'default',
|
||||
),
|
||||
], transaction: tx);
|
||||
await UniqueSetDefaultChild.db.insertRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
final winnerHlc = await rowHlc(winner.id!, databaseSession: node.offlineSync);
|
||||
await node.offlineSync.db.mergeChanges([
|
||||
CrdtMergeInsert(
|
||||
uuidSpaceId: testCrdtUserId,
|
||||
tableName: UniqueSetDefaultChild.t.tableName,
|
||||
uuidRowId: loser.id!,
|
||||
uuidNodeId: const Uuid().v7obj(),
|
||||
hlcDatetime: winnerHlc.datetime.add(const Duration(milliseconds: 1)),
|
||||
hlcCounter: 0,
|
||||
data: loser,
|
||||
),
|
||||
], spaceId: testCrdtUserId);
|
||||
loser = (await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!))!;
|
||||
});
|
||||
|
||||
group(
|
||||
'when a full-row upsert echoes the released null and the winner is later deleted,',
|
||||
() {
|
||||
late UniqueSetDefaultChild? stored;
|
||||
late CrdtDataAttemptedValue? attempt;
|
||||
late CrdtMergeSet changes;
|
||||
late UniqueSetDefaultChild? restored;
|
||||
|
||||
setUpAll(() async {
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await UniqueSetDefaultChild.db.upsertRow(
|
||||
node.offlineSync,
|
||||
loser.copyWith(name: 'edited'),
|
||||
conflictColumns: (t) => [t.id],
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
stored = await UniqueSetDefaultChild.db.findById(node.offlineSync, loser.id!);
|
||||
attempt = await attemptedValue(
|
||||
rowId: loser.id!,
|
||||
columnName: 'parentId',
|
||||
databaseSession: node.offlineSync,
|
||||
);
|
||||
changes = await node.sync
|
||||
.collectPendingChanges(
|
||||
node.raw,
|
||||
checkpointsBySpaceUuid: {testCrdtUserId: const []},
|
||||
)
|
||||
.toList();
|
||||
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||
await UniqueSetDefaultChild.db.deleteRow(
|
||||
node.offlineSync,
|
||||
winner,
|
||||
transaction: tx,
|
||||
);
|
||||
});
|
||||
restored = await UniqueSetDefaultChild.db.findById(
|
||||
node.offlineSync,
|
||||
loser.id!,
|
||||
);
|
||||
});
|
||||
|
||||
test('then the authored parent survives behind the unique release.', () {
|
||||
expect(loser.parentId, isNull);
|
||||
expect(stored!.name, 'edited');
|
||||
expect(stored!.parentId, isNull);
|
||||
expect(attempt?.value.toString(), parent.id.toString());
|
||||
expect(attempt?.projectionReason, CrdtProjectionReason.uniqueConflict);
|
||||
});
|
||||
|
||||
test('then outbound sync never authors the column default.', () {
|
||||
final insert = changes.inserts.singleWhere(
|
||||
(change) => change.uuidRowId == loser.id,
|
||||
);
|
||||
expect((insert.data as UniqueSetDefaultChild).parentId, parent.id);
|
||||
expect(
|
||||
changes.updates
|
||||
.where(
|
||||
(change) =>
|
||||
change.uuidRowId == loser.id && change.columnName == 'parentId',
|
||||
)
|
||||
.every((change) => change.value.toString() == parent.id.toString()),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('then the original authored parent is reclaimed.', () {
|
||||
expect(restored!.parentId, parent.id);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue