fix: Repair foreign keys on columns that also carry a unique claim (#151)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-21 13:56:58 -03:00 • committed by GitHub
commit 51ce999af0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 435 additions and 12 deletions

View file

@ -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,
);
},
);
});
},
);
}

View file

@ -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,
);
});
});
},
);
}

View file

@ -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);
});
},
);
});
}