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
6 changed files with 435 additions and 12 deletions

View file

@ -477,6 +477,9 @@ class OfflineSyncDatabase implements Database {
tx, tx,
projectionUnchanged: projection.projectionUnchanged, projectionUnchanged: projection.projectionUnchanged,
domainBeforeUpsert: projection.domain, domainBeforeUpsert: projection.domain,
upsertRows: updateColumns == null && projection.domain.isNotEmpty
? prepared.rows
: const [],
); );
if (noReturn) return <T>[]; if (noReturn) return <T>[];
_stripStampedRows(result, prepared); _stripStampedRows(result, prepared);

View file

@ -2120,12 +2120,24 @@ class CrdtForeignKeyProjector {
if (state.pendingInsertKeys.contains(row.key)) continue; if (state.pendingInsertKeys.contains(row.key)) continue;
for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) { for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) {
final key = (row.key.$1, row.key.$2, column); final key = (row.key.$1, row.key.$2, column);
row.values[column] = canonicalDomainValue( final localValue = canonicalDomainValue(
authoredOverlays.containsKey(key) authoredOverlays.containsKey(key)
? authoredOverlays[key] ? authoredOverlays[key]
: state.originalDomain[row.key]?[column], : state.originalDomain[row.key]?[column],
_context.columnsByTableAndName[row.key.$1]?[column], _context.columnsByTableAndName[row.key.$1]?[column],
); );
// An authored unique claim still needs its FK repair when a local
// edit makes the parent hidden. Only a released value may replace it.
if (foreignKeys.reasons.containsKey(key) &&
!authoredOverlays.containsKey(key) &&
projectionValuesEqual(localValue, authoredByField[key])) {
continue;
}
if (!projectionValuesEqual(localValue, row.values[column])) {
// Retaining a unique release supersedes the FK candidate's reason.
foreignKeys.reasons.remove(key);
}
row.values[column] = localValue;
} }
} }
} }

View file

@ -851,6 +851,7 @@ class CrdtMutationRecorder {
bool projectionUnchanged = false, bool projectionUnchanged = false,
bool authoredColumnValues = false, bool authoredColumnValues = false,
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {}, Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
List<TableRow> upsertRows = const [],
}) async { }) async {
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction); await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
@ -865,6 +866,33 @@ class CrdtMutationRecorder {
'updated', 'updated',
transaction, transaction,
); );
final implicitForeignKeyRepairFields = columns == null
? await _foreignKeyProjector.findImplicitRepairFields(
tableName: tableName,
rowIds: rowIds,
transaction: transaction,
)
: const <MergeFieldKey>{};
final upsertInputs = upsertRows.isEmpty || implicitForeignKeyRepairFields.isEmpty
? const <MergeRowKey, Map<String, Object?>>{}
: {
for (final row in upsertRows)
if (row.id case final UuidValue rowId)
(row.table.tableName, rowId):
row.toJsonForDatabase() as Map<String, dynamic>,
};
// Insert defaults must not author over an echoed null that is holding
// a projected FK claim.
bool echoesProjectedNull(UuidValue rowId, String columnName) {
if (!implicitForeignKeyRepairFields.contains((tableName, rowId, columnName))) {
return false;
}
final input = upsertInputs[(tableName, rowId)];
return input != null &&
input[columnName] == null &&
domainBeforeUpsert[(tableName, rowId)]?[columnName] == null;
}
// Explicit columns author even an unchanged null; full-row passthrough // Explicit columns author even an unchanged null; full-row passthrough
// keeps the claim behind an unchanged displayed alternative. // keeps the claim behind an unchanged displayed alternative.
final authoredOverlays = <MergeFieldKey, Object?>{ final authoredOverlays = <MergeFieldKey, Object?>{
@ -883,20 +911,14 @@ class CrdtMutationRecorder {
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ?? if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
false) && false) &&
(columns != null || (columns != null ||
!projectionValuesEqual( (!projectionValuesEqual(
domainBeforeUpsert[(tableName, row.id)]![columnName], domainBeforeUpsert[(tableName, row.id)]![columnName],
value, value,
))) ) &&
!echoesProjectedNull(row.id as UuidValue, columnName))))
(tableName, row.id as UuidValue, columnName): value, (tableName, row.id as UuidValue, columnName): value,
}; };
_uniqueResolver.validateAuthoredFields(authoredOverlays); _uniqueResolver.validateAuthoredFields(authoredOverlays);
final implicitForeignKeyRepairFields = columns == null
? await _foreignKeyProjector.findImplicitRepairFields(
tableName: tableName,
rowIds: rowIds,
transaction: transaction,
)
: const <MergeFieldKey>{};
await _recordUpdatedFields( await _recordUpdatedFields(
updatedRows, updatedRows,
crdtDataRows, crdtDataRows,

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