fix: Reject updates referencing soft-deleted rows (#36)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Marcelo Soares <marcelo.me.soares@gmail.com>
This commit is contained in:
Copilot 2026-05-07 16:31:19 -03:00 • committed by GitHub
commit 539f27b5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -205,6 +205,8 @@ class CrdtMutationRecorder {
List<Column>? columns,
Transaction transaction,
) async {
await _assertVisibleForeignKeyTargets(updatedRows, columns, transaction);
await _forTrackedRows(updatedRows, transaction, (
tableName,
rowIds,
@ -220,6 +222,101 @@ class CrdtMutationRecorder {
});
}
Future<void> _assertVisibleForeignKeyTargets<T extends TableRow>(
List<T> rows,
List<Column>? columns,
Transaction transaction,
) async {
if (rows.isEmpty) return;
if (!isCrdtTracked<T>(rows.first.table)) return;
final tableName = rows.first.table.tableName;
final tableDefinition = _tableDefinitionsByName[tableName];
if (tableDefinition == null) return;
final updatedColumnNames = columns?.map((column) => column.columnName).toSet();
final foreignKeysToCheck = [
for (final foreignKey in tableDefinition.foreignKeys)
if (foreignKey.columns.length == 1 &&
foreignKey.referenceColumns.length == 1 &&
(updatedColumnNames == null ||
updatedColumnNames.contains(foreignKey.columns.single)) &&
_isCrdtTrackedTableName(foreignKey.referenceTable))
foreignKey,
];
if (foreignKeysToCheck.isEmpty) return;
final invalid = await _findInvalidForeignKeyReference(
childTableName: tableName,
childRowIds: rows.uuidRowIds,
foreignKeys: foreignKeysToCheck,
transaction: transaction,
);
if (invalid == null) return;
final foreignKey = foreignKeysToCheck[invalid.foreignKeyIndex];
throw Exception(
'Cannot reference deleted row ${foreignKey.referenceTable}.'
'${foreignKey.referenceColumns.single} = ${invalid.value}.',
);
}
/// Finds the first child row whose foreign key points at a parent row that
/// is missing or tombstoned, returning the index of the offending foreign
/// key in [foreignKeys] together with the offending value.
///
/// All [foreignKeys] are checked in a single round-trip via `UNION ALL`.
/// This avoids serializing the just-updated rows to JSON only to extract a
/// single column value, and keeps the assertion at one query regardless of
/// how many foreign key columns are being updated.
Future<({int foreignKeyIndex, Object value})?> _findInvalidForeignKeyReference({
required String childTableName,
required Set<UuidValue> childRowIds,
required List<ForeignKeyDefinition> foreignKeys,
required Transaction transaction,
}) async {
if (foreignKeys.isEmpty) return null;
final userId = _getHlcManager(transaction).normalizedUserId;
final escapedChildTable = _escapeIdentifier(childTableName);
final whereRowIds = _sqlLiteralList(childRowIds);
final foreignKeyBranches = foreignKeys.indexed.map((e) {
final (index, foreignKey) = e;
final childColumn = _escapeIdentifier(foreignKey.columns.single);
final parentTable = _escapeIdentifier(foreignKey.referenceTable);
final parentColumn = _escapeIdentifier(
foreignKey.referenceColumns.single,
);
final (parentTableId, _) = _schema[foreignKey.referenceTable]!;
return '''
SELECT $index AS fk_idx, c."$childColumn" AS invalid_value
FROM "$escapedChildTable" c
LEFT JOIN "$parentTable" p
ON p."$parentColumn" = c."$childColumn"
LEFT JOIN "crdt_data_rows" r
ON r."userId" = $userId AND r."tblId" = $parentTableId AND r."uuidRowId" = p."id"
LEFT JOIN "crdt_data_tombstone" tomb
ON tomb."rowId" = r."id"
WHERE c."id" IN ($whereRowIds)
AND c."$childColumn" IS NOT NULL
AND (p."id" IS NULL OR tomb."isDeleted" = TRUE)
''';
});
final result = await _db.unsafeQuery(
'${foreignKeyBranches.join('UNION ALL\n')}LIMIT 1',
transaction: transaction,
);
if (result.isEmpty) return null;
return (
foreignKeyIndex: result.first[0] as int,
value: result.first[1] as Object,
);
}
Future<void> _forTrackedRows<T extends TableRow>(
List<T> rows,
Transaction transaction,