fix: Allow only one releasable column per unique index (#91)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-03 10:49:23 -03:00 • committed by GitHub
commit af139ea800
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 353 additions and 112 deletions

View file

@ -71,12 +71,12 @@ class CrdtUniqueConflictResolver {
required _MergeContext context,
}) {
var uniqueHlc = insert.hlc;
for (final column in uniqueIndex.columns) {
for (final columnName in uniqueIndex.indexedColumns) {
uniqueHlc = uniqueHlc.maxBetween(
context.incomingFieldHlcs[(
insert.tableName,
insert.uuidRowId,
column.columnName,
columnName,
)],
);
}
@ -120,7 +120,7 @@ class CrdtUniqueConflictResolver {
final uniqueColumnNamesToRead = {
for (final uniqueIndex in _recorder._uniqueIndexesForTable(tableDefinition))
for (final column in uniqueIndex.columns) column.columnName,
...uniqueIndex.indexedColumns,
};
if (uniqueColumnNamesToRead.isEmpty) {
return updates;
@ -168,7 +168,7 @@ class CrdtUniqueConflictResolver {
values,
conflict.uniqueIndex,
);
for (final columnName in conflict.uniqueIndex.columnNames) {
for (final columnName in conflict.uniqueIndex.releaseColumnNames) {
if (releasedValues.containsKey(columnName)) {
resolvedUpdates[columnName] = releasedValues[columnName];
values[columnName] = releasedValues[columnName];
@ -198,12 +198,12 @@ class CrdtUniqueConflictResolver {
final released = Map<String, Object?>.from(data);
final values = {
for (final column in uniqueIndex.columns)
column.columnName: released[column.columnName],
for (final columnName in uniqueIndex.indexedColumns)
columnName: released[columnName],
};
if (values.values.any((value) => value == null)) return released;
for (final column in uniqueIndex.columns) {
for (final column in uniqueIndex.releaseColumns) {
released[column.columnName] = _recorder._conflictFreeValue(
column,
values[column.columnName],
@ -221,11 +221,10 @@ class CrdtUniqueConflictResolver {
required _UniqueIndexConflictRelease uniqueIndex,
required Transaction transaction,
}) async {
final columnNames = uniqueIndex.columnNames.toList();
final valuesByRowId = await _recorder._readDomainColumnValues(
tableName,
{rowId},
columnNames,
uniqueIndex.indexedColumns,
transaction,
);
final values = valuesByRowId[rowId];
@ -255,9 +254,8 @@ class CrdtUniqueConflictResolver {
final uniqueIndexesByConflictId = <UuidValue, Set<_UniqueIndexConflictRelease>>{};
for (final uniqueIndex in uniqueIndexes) {
final columnValues = {
for (final column in uniqueIndex.columns)
if (values.containsKey(column.columnName))
column.columnName: values[column.columnName],
for (final columnName in uniqueIndex.indexedColumns)
if (values.containsKey(columnName)) columnName: values[columnName],
};
if (uniqueIndex.scoped) {
columnValues['scopeId'] = _recorder
@ -265,7 +263,7 @@ class CrdtUniqueConflictResolver {
.normalizedScopeId;
}
final expectedValueCount =
uniqueIndex.columns.length + (uniqueIndex.scoped ? 1 : 0);
uniqueIndex.indexedColumns.length + (uniqueIndex.scoped ? 1 : 0);
if (columnValues.length != expectedValueCount) continue;
if (columnValues.values.any((value) => value == null)) continue;
@ -314,7 +312,7 @@ class CrdtUniqueConflictResolver {
required Transaction transaction,
}) async {
final uniqueColumnNames = {
for (final column in uniqueIndex.columns) column.columnName,
...uniqueIndex.indexedColumns,
};
final missingColumnIds = <int>{};
final maxFieldHlc = <Hlc>[];
@ -374,12 +372,12 @@ class CrdtUniqueConflictResolver {
if (tableDefinition == null) return false;
return _recorder
._uniqueIndexesForTable(tableDefinition)
.any((i) => i.columns.any((c) => c.columnName == columnName));
.any((i) => i.indexedColumns.contains(columnName));
}
}
extension on _UniqueIndexConflictRelease {
Set<String> get columnNames => {
for (final column in columns) column.columnName,
Set<String> get releaseColumnNames => {
for (final column in releaseColumns) column.columnName,
};
}

View file

@ -37,21 +37,16 @@ typedef _ForeignKeyEdge = ({
typedef _ForeignKeyValueKey = (String tableName, String columnName, String value);
typedef _UniqueIndexConflictRelease = ({
List<_UniqueColumnConflictRelease> columns,
List<String> indexedColumns,
List<_UniqueColumnConflictRelease> releaseColumns,
bool scoped,
});
typedef _UniqueColumnConflictRelease = ({
String columnName,
_UniqueConflictReleaseKind kind,
CrdtUniqueConflictReleaseKind kind,
});
enum _UniqueConflictReleaseKind {
setNull,
textSuffix,
syntheticUuid,
}
enum _ForeignKeyTargetPresence {
absent,
visible,
@ -1113,12 +1108,12 @@ WHERE c."id" IN ($whereRowIds)
final uniqueIndexes = _uniqueIndexesForTable(tableDefinition);
for (final uniqueIndex in uniqueIndexes) {
final columnNames = uniqueIndex.columnNames.toList();
final releaseColumnNames = uniqueIndex.releaseColumnNames.toList();
final valuesByRowId = await _readDomainColumnValues(
tableName,
rowIds,
columnNames,
uniqueIndex.indexedColumns,
transaction,
);
final updatedRowIds = <UuidValue>{};
@ -1127,7 +1122,7 @@ WHERE c."id" IN ($whereRowIds)
if (values.values.any((value) => value == null)) continue;
final updates = <String, Object?>{};
for (final column in uniqueIndex.columns) {
for (final column in uniqueIndex.releaseColumns) {
updates[column.columnName] = _conflictFreeValue(
column,
values[column.columnName],
@ -1145,7 +1140,7 @@ WHERE c."id" IN ($whereRowIds)
await recordFieldsUpdatedByTable(
tableName,
updatedRowIds,
columnNames,
releaseColumnNames,
transaction,
);
}
@ -1290,13 +1285,13 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
String releaseSuffix,
) {
switch (column.kind) {
case _UniqueConflictReleaseKind.setNull:
case CrdtUniqueConflictReleaseKind.setNull:
return null;
case _UniqueConflictReleaseKind.textSuffix:
case CrdtUniqueConflictReleaseKind.textSuffix:
if (value is String) {
return '${value}__${releaseSuffix}__${conflictingId.uuid}';
}
case _UniqueConflictReleaseKind.syntheticUuid:
case CrdtUniqueConflictReleaseKind.syntheticUuid:
if (value != null) {
return const Uuid().v5obj(
Namespace.oid.value,
@ -1414,88 +1409,39 @@ List<_UniqueIndexConflictRelease> _syncableUniqueIndexesForTable(
TableDefinition table,
Set<String> syncTableNames,
) {
final uniqueIndexes = table.indexes
.where(
(index) =>
index.isUnique &&
!index.isPrimary &&
index.elements.every(
(element) => element.type == IndexElementDefinitionType.column,
) &&
(_isScopedUniqueIndex(index) ||
isCrdtAllowedForeignKeyOnlyUniqueIndex(
table,
index,
syncTableNames,
)),
)
.toList();
final columnsByName = {for (final column in table.columns) column.name: column};
return [
for (final index in uniqueIndexes)
for (final index in crdtSyncableUniqueIndexesForTable(table, syncTableNames))
_uniqueIndexConflictReleaseForIndex(table, columnsByName, index),
];
}
bool _isScopedUniqueIndex(IndexDefinition index) {
return index.elements.any(
(element) =>
element.type == IndexElementDefinitionType.column &&
element.definition == 'scopeId',
);
}
_UniqueIndexConflictRelease _uniqueIndexConflictReleaseForIndex(
TableDefinition table,
Map<String, ColumnDefinition> columnsByName,
IndexDefinition index,
) {
final columnNames = index.elements.map((element) => element.definition).toList();
final indexedColumns = [
for (final columnName in columnNames)
if (columnName != 'scopeId') columnName,
];
final releaseColumns = <_UniqueColumnConflictRelease>[];
for (final columnName in indexedColumns) {
final column = columnsByName[columnName];
if (column == null) {
throw StateError('No column definition found for ${table.name}.$columnName.');
}
final kind = crdtUniqueConflictReleaseKindForColumn(table, column);
if (kind != null) {
releaseColumns.add((columnName: columnName, kind: kind));
}
}
return (
scoped: columnNames.contains('scopeId'),
columns: [
for (final columnName in columnNames)
if (columnName != 'scopeId')
_uniqueConflictReleaseForColumn(
table,
columnsByName[columnName],
columnName,
),
],
);
}
_UniqueColumnConflictRelease _uniqueConflictReleaseForColumn(
TableDefinition table,
ColumnDefinition? column,
String columnName,
) {
if (column == null) {
throw StateError('No column definition found for ${table.name}.$columnName.');
}
if (column.isNullable) {
return (columnName: columnName, kind: _UniqueConflictReleaseKind.setNull);
}
if (column.columnType == ColumnType.text) {
return (columnName: columnName, kind: _UniqueConflictReleaseKind.textSuffix);
}
if (column.columnType == ColumnType.uuid && !_isForeignKeyColumn(table, columnName)) {
return (columnName: columnName, kind: _UniqueConflictReleaseKind.syntheticUuid);
}
throw StateError(
'CRDT soft deletes cannot release unique conflicts for ${table.name}.$columnName. '
'Only nullable, text, and non-FK UUID unique columns are supported.',
);
}
bool _isForeignKeyColumn(TableDefinition table, String columnName) {
return table.foreignKeys.any(
(fk) => fk.columns.length == 1 && fk.columns.single == columnName,
indexedColumns: indexedColumns,
releaseColumns: releaseColumns,
);
}

View file

@ -58,6 +58,19 @@ class CrdtSchemaRegistry {
);
}
final nonReleasableUniqueIndexes = crdtNonReleasableUniqueIndexViolations(
syncTables,
tableDefinitionsByName,
);
if (nonReleasableUniqueIndexes.isNotEmpty) {
throw StateError(
'CRDT unique conflict resolution requires at least one releasable '
'non-scope column for ${nonReleasableUniqueIndexes.length} unique '
'index(es): ${nonReleasableUniqueIndexes.join(', ')}. '
'Only nullable, text, and non-FK UUID unique columns are supported.',
);
}
final tablesMissingScopeIdRelation = _missingCrdtScopeRelations(
syncTables,
tableDefinitionsByName,
@ -217,12 +230,7 @@ bool _isForbiddenGlobalUniqueIndex(
Set<String> syncTableNames,
) {
if (!index.isUnique || index.isPrimary) return false;
final includesScopeId = index.elements.any(
(element) =>
element.type == IndexElementDefinitionType.column &&
element.definition == 'scopeId',
);
if (includesScopeId) return false;
if (isCrdtScopedUniqueIndex(index)) return false;
return !isCrdtAllowedForeignKeyOnlyUniqueIndex(
tableDefinition,

View file

@ -29,3 +29,109 @@ bool isCrdtAllowedForeignKeyOnlyUniqueIndex(
return indexColumns.every(coveredForeignKeyColumns.contains);
}
@internal
bool isCrdtScopedUniqueIndex(IndexDefinition index) {
return index.elements.any(
(element) =>
element.type == IndexElementDefinitionType.column &&
element.definition == 'scopeId',
);
}
@internal
Iterable<IndexDefinition> crdtSyncableUniqueIndexesForTable(
TableDefinition table,
Set<String> syncTableNames,
) {
return table.indexes.where(
(index) =>
index.isUnique &&
!index.isPrimary &&
index.elements.every(
(element) => element.type == IndexElementDefinitionType.column,
) &&
(isCrdtScopedUniqueIndex(index) ||
isCrdtAllowedForeignKeyOnlyUniqueIndex(
table,
index,
syncTableNames,
)),
);
}
@internal
bool isCrdtForeignKeyColumn(TableDefinition table, String columnName) {
return table.foreignKeys.any(
(fk) => fk.columns.length == 1 && fk.columns.single == columnName,
);
}
/// How a unique-indexed column can be released to a conflict-free value, or
/// null when the column cannot be released deterministically.
@internal
enum CrdtUniqueConflictReleaseKind {
setNull,
textSuffix,
syntheticUuid,
}
@internal
CrdtUniqueConflictReleaseKind? crdtUniqueConflictReleaseKindForColumn(
TableDefinition table,
ColumnDefinition column,
) {
if (column.isNullable) return CrdtUniqueConflictReleaseKind.setNull;
if (column.columnType == ColumnType.text) {
return CrdtUniqueConflictReleaseKind.textSuffix;
}
if (column.columnType == ColumnType.uuid &&
!isCrdtForeignKeyColumn(table, column.name)) {
return CrdtUniqueConflictReleaseKind.syntheticUuid;
}
return null;
}
@internal
List<String> crdtNonReleasableUniqueIndexViolations(
List<Table> syncTables,
Map<String, TableDefinition> tableDefinitionsByName,
) {
final syncTableNames = {for (final table in syncTables) table.tableName};
return [
for (final tableName in syncTableNames)
if (tableDefinitionsByName.containsKey(tableName))
for (final index in crdtSyncableUniqueIndexesForTable(
tableDefinitionsByName[tableName]!,
syncTableNames,
))
if (!_hasReleasableNonScopeUniqueColumn(
tableDefinitionsByName[tableName]!,
index,
))
'$tableName.${index.indexName}',
];
}
bool _hasReleasableNonScopeUniqueColumn(
TableDefinition table,
IndexDefinition index,
) {
final columnsByName = {
for (final column in table.columns) column.name: column,
};
for (final element in index.elements) {
if (element.type != IndexElementDefinitionType.column) continue;
if (element.definition == 'scopeId') continue;
final column = columnsByName[element.definition];
if (column == null) {
throw StateError(
'No column definition found for ${table.name}.${element.definition}.',
);
}
if (crdtUniqueConflictReleaseKindForColumn(table, column) != null) {
return true;
}
}
return false;
}