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

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

View file

@ -2120,12 +2120,24 @@ class CrdtForeignKeyProjector {
if (state.pendingInsertKeys.contains(row.key)) continue;
for (final column in _uniqueResolver.uniqueColumnNamesFor(row.key.$1)) {
final key = (row.key.$1, row.key.$2, column);
row.values[column] = canonicalDomainValue(
final localValue = canonicalDomainValue(
authoredOverlays.containsKey(key)
? authoredOverlays[key]
: state.originalDomain[row.key]?[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 authoredColumnValues = false,
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
List<TableRow> upsertRows = const [],
}) async {
await _foreignKeyProjector.assertVisibleTargets(updatedRows, columns, transaction);
@ -865,6 +866,33 @@ class CrdtMutationRecorder {
'updated',
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
// keeps the claim behind an unchanged displayed alternative.
final authoredOverlays = <MergeFieldKey, Object?>{
@ -883,20 +911,14 @@ class CrdtMutationRecorder {
if ((domainBeforeUpsert[(tableName, row.id)]?.containsKey(columnName) ??
false) &&
(columns != null ||
!projectionValuesEqual(
domainBeforeUpsert[(tableName, row.id)]![columnName],
value,
)))
(!projectionValuesEqual(
domainBeforeUpsert[(tableName, row.id)]![columnName],
value,
) &&
!echoesProjectedNull(row.id as UuidValue, columnName))))
(tableName, row.id as UuidValue, columnName): value,
};
_uniqueResolver.validateAuthoredFields(authoredOverlays);
final implicitForeignKeyRepairFields = columns == null
? await _foreignKeyProjector.findImplicitRepairFields(
tableName: tableName,
rowIds: rowIds,
transaction: transaction,
)
: const <MergeFieldKey>{};
await _recordUpdatedFields(
updatedRows,
crdtDataRows,