fix: Allow only one releasable column per unique index (#91)
This commit is contained in:
parent
8acfa7f269
commit
af139ea800
12 changed files with 353 additions and 112 deletions
|
|
@ -31,14 +31,20 @@ For each unique index conflict group:
|
||||||
full HLC ordering as the rest of CRDT merge. This includes node id as the HLC
|
full HLC ordering as the rest of CRDT merge. This includes node id as the HLC
|
||||||
tie-break. Row id is only a final fallback for identical HLCs.
|
tie-break. Row id is only a final fallback for identical HLCs.
|
||||||
3. Leave the winner's unique value unchanged.
|
3. Leave the winner's unique value unchanged.
|
||||||
4. Rewrite each loser's visible unique-indexed value to a deterministic
|
4. Rewrite each loser's visible releasable unique-indexed value to a
|
||||||
conflict-free value:
|
deterministic conflict-free value:
|
||||||
- nullable unique values may be released to `null`;
|
- nullable unique values may be released to `null`;
|
||||||
- text values receive a stable `__conflict__<rowId>` suffix;
|
- text values receive a stable `__conflict__<rowId>` suffix;
|
||||||
- UUID values receive a deterministic synthetic UUID.
|
- UUID values receive a deterministic synthetic UUID.
|
||||||
5. Keep all rows visible. Application code can decide whether and how to surface
|
5. Keep all rows visible. Application code can decide whether and how to surface
|
||||||
the changed visible value as a business conflict.
|
the changed visible value as a business conflict.
|
||||||
|
|
||||||
|
For a composite unique index, fixed partition columns such as `scopeId` are not
|
||||||
|
released, and not every remaining indexed column has to be releasable. The index
|
||||||
|
is supported when at least one non-`scopeId` column can be released
|
||||||
|
deterministically. Stable discriminator columns may remain unchanged; changing
|
||||||
|
the releasable column or columns is enough to make the unique tuple conflict-free.
|
||||||
|
|
||||||
The rewritten value is a materialized data value, not a synced conflict class.
|
The rewritten value is a materialized data value, not a synced conflict class.
|
||||||
The core CRDT protocol does not need to expose unique conflict objects to users.
|
The core CRDT protocol does not need to expose unique conflict objects to users.
|
||||||
|
|
||||||
|
|
@ -183,22 +189,27 @@ visibility and FK values, without also needing row aliasing and field folding.
|
||||||
when the loser is released, then the materialized UUID is deterministic from
|
when the loser is released, then the materialized UUID is deterministic from
|
||||||
the table, column, original value, and loser row id.
|
the table, column, original value, and loser row id.
|
||||||
|
|
||||||
|
7. Given a scoped composite unique index contains a stable non-releasable
|
||||||
|
discriminator and a releasable text column, when a loser is released, then the
|
||||||
|
discriminator remains unchanged and the text column receives the deterministic
|
||||||
|
conflict suffix.
|
||||||
|
|
||||||
### Deferred Merge And Overwrite Policies
|
### Deferred Merge And Overwrite Policies
|
||||||
|
|
||||||
7. Given overwrite is enabled for a unique index, when a loser has visible
|
8. Given overwrite is enabled for a unique index, when a loser has visible
|
||||||
restrict children, then FK projection retargets those children through the
|
restrict children, then FK projection retargets those children through the
|
||||||
row alias or blocks the projection deterministically; no visible orphan is
|
row alias or blocks the projection deterministically; no visible orphan is
|
||||||
produced.
|
produced.
|
||||||
|
|
||||||
8. Given merge is enabled for a unique index, when loser rows contain newer
|
9. Given merge is enabled for a unique index, when loser rows contain newer
|
||||||
non-key field values, then field folding follows documented per-column
|
non-key field values, then field folding follows documented per-column
|
||||||
policies and every replica computes the same winner row.
|
policies and every replica computes the same winner row.
|
||||||
|
|
||||||
9. Given a row participates in multiple unique conflict groups, when merge or
|
10. Given a row participates in multiple unique conflict groups, when merge or
|
||||||
overwrite projection runs, then row aliases are deterministic and do not form
|
overwrite projection runs, then row aliases are deterministic and do not form
|
||||||
cycles.
|
cycles.
|
||||||
|
|
||||||
10. Given FK repair changes a unique-indexed visible value, when combined unique
|
11. Given FK repair changes a unique-indexed visible value, when combined unique
|
||||||
and FK projection runs, then the fixed-point result matches a full
|
and FK projection runs, then the fixed-point result matches a full
|
||||||
recomputation oracle.
|
recomputation oracle.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,12 @@ class CrdtUniqueConflictResolver {
|
||||||
required _MergeContext context,
|
required _MergeContext context,
|
||||||
}) {
|
}) {
|
||||||
var uniqueHlc = insert.hlc;
|
var uniqueHlc = insert.hlc;
|
||||||
for (final column in uniqueIndex.columns) {
|
for (final columnName in uniqueIndex.indexedColumns) {
|
||||||
uniqueHlc = uniqueHlc.maxBetween(
|
uniqueHlc = uniqueHlc.maxBetween(
|
||||||
context.incomingFieldHlcs[(
|
context.incomingFieldHlcs[(
|
||||||
insert.tableName,
|
insert.tableName,
|
||||||
insert.uuidRowId,
|
insert.uuidRowId,
|
||||||
column.columnName,
|
columnName,
|
||||||
)],
|
)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +120,7 @@ class CrdtUniqueConflictResolver {
|
||||||
|
|
||||||
final uniqueColumnNamesToRead = {
|
final uniqueColumnNamesToRead = {
|
||||||
for (final uniqueIndex in _recorder._uniqueIndexesForTable(tableDefinition))
|
for (final uniqueIndex in _recorder._uniqueIndexesForTable(tableDefinition))
|
||||||
for (final column in uniqueIndex.columns) column.columnName,
|
...uniqueIndex.indexedColumns,
|
||||||
};
|
};
|
||||||
if (uniqueColumnNamesToRead.isEmpty) {
|
if (uniqueColumnNamesToRead.isEmpty) {
|
||||||
return updates;
|
return updates;
|
||||||
|
|
@ -168,7 +168,7 @@ class CrdtUniqueConflictResolver {
|
||||||
values,
|
values,
|
||||||
conflict.uniqueIndex,
|
conflict.uniqueIndex,
|
||||||
);
|
);
|
||||||
for (final columnName in conflict.uniqueIndex.columnNames) {
|
for (final columnName in conflict.uniqueIndex.releaseColumnNames) {
|
||||||
if (releasedValues.containsKey(columnName)) {
|
if (releasedValues.containsKey(columnName)) {
|
||||||
resolvedUpdates[columnName] = releasedValues[columnName];
|
resolvedUpdates[columnName] = releasedValues[columnName];
|
||||||
values[columnName] = releasedValues[columnName];
|
values[columnName] = releasedValues[columnName];
|
||||||
|
|
@ -198,12 +198,12 @@ class CrdtUniqueConflictResolver {
|
||||||
|
|
||||||
final released = Map<String, Object?>.from(data);
|
final released = Map<String, Object?>.from(data);
|
||||||
final values = {
|
final values = {
|
||||||
for (final column in uniqueIndex.columns)
|
for (final columnName in uniqueIndex.indexedColumns)
|
||||||
column.columnName: released[column.columnName],
|
columnName: released[columnName],
|
||||||
};
|
};
|
||||||
if (values.values.any((value) => value == null)) return released;
|
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(
|
released[column.columnName] = _recorder._conflictFreeValue(
|
||||||
column,
|
column,
|
||||||
values[column.columnName],
|
values[column.columnName],
|
||||||
|
|
@ -221,11 +221,10 @@ class CrdtUniqueConflictResolver {
|
||||||
required _UniqueIndexConflictRelease uniqueIndex,
|
required _UniqueIndexConflictRelease uniqueIndex,
|
||||||
required Transaction transaction,
|
required Transaction transaction,
|
||||||
}) async {
|
}) async {
|
||||||
final columnNames = uniqueIndex.columnNames.toList();
|
|
||||||
final valuesByRowId = await _recorder._readDomainColumnValues(
|
final valuesByRowId = await _recorder._readDomainColumnValues(
|
||||||
tableName,
|
tableName,
|
||||||
{rowId},
|
{rowId},
|
||||||
columnNames,
|
uniqueIndex.indexedColumns,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
final values = valuesByRowId[rowId];
|
final values = valuesByRowId[rowId];
|
||||||
|
|
@ -255,9 +254,8 @@ class CrdtUniqueConflictResolver {
|
||||||
final uniqueIndexesByConflictId = <UuidValue, Set<_UniqueIndexConflictRelease>>{};
|
final uniqueIndexesByConflictId = <UuidValue, Set<_UniqueIndexConflictRelease>>{};
|
||||||
for (final uniqueIndex in uniqueIndexes) {
|
for (final uniqueIndex in uniqueIndexes) {
|
||||||
final columnValues = {
|
final columnValues = {
|
||||||
for (final column in uniqueIndex.columns)
|
for (final columnName in uniqueIndex.indexedColumns)
|
||||||
if (values.containsKey(column.columnName))
|
if (values.containsKey(columnName)) columnName: values[columnName],
|
||||||
column.columnName: values[column.columnName],
|
|
||||||
};
|
};
|
||||||
if (uniqueIndex.scoped) {
|
if (uniqueIndex.scoped) {
|
||||||
columnValues['scopeId'] = _recorder
|
columnValues['scopeId'] = _recorder
|
||||||
|
|
@ -265,7 +263,7 @@ class CrdtUniqueConflictResolver {
|
||||||
.normalizedScopeId;
|
.normalizedScopeId;
|
||||||
}
|
}
|
||||||
final expectedValueCount =
|
final expectedValueCount =
|
||||||
uniqueIndex.columns.length + (uniqueIndex.scoped ? 1 : 0);
|
uniqueIndex.indexedColumns.length + (uniqueIndex.scoped ? 1 : 0);
|
||||||
if (columnValues.length != expectedValueCount) continue;
|
if (columnValues.length != expectedValueCount) continue;
|
||||||
if (columnValues.values.any((value) => value == null)) continue;
|
if (columnValues.values.any((value) => value == null)) continue;
|
||||||
|
|
||||||
|
|
@ -314,7 +312,7 @@ class CrdtUniqueConflictResolver {
|
||||||
required Transaction transaction,
|
required Transaction transaction,
|
||||||
}) async {
|
}) async {
|
||||||
final uniqueColumnNames = {
|
final uniqueColumnNames = {
|
||||||
for (final column in uniqueIndex.columns) column.columnName,
|
...uniqueIndex.indexedColumns,
|
||||||
};
|
};
|
||||||
final missingColumnIds = <int>{};
|
final missingColumnIds = <int>{};
|
||||||
final maxFieldHlc = <Hlc>[];
|
final maxFieldHlc = <Hlc>[];
|
||||||
|
|
@ -374,12 +372,12 @@ class CrdtUniqueConflictResolver {
|
||||||
if (tableDefinition == null) return false;
|
if (tableDefinition == null) return false;
|
||||||
return _recorder
|
return _recorder
|
||||||
._uniqueIndexesForTable(tableDefinition)
|
._uniqueIndexesForTable(tableDefinition)
|
||||||
.any((i) => i.columns.any((c) => c.columnName == columnName));
|
.any((i) => i.indexedColumns.contains(columnName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on _UniqueIndexConflictRelease {
|
extension on _UniqueIndexConflictRelease {
|
||||||
Set<String> get columnNames => {
|
Set<String> get releaseColumnNames => {
|
||||||
for (final column in columns) column.columnName,
|
for (final column in releaseColumns) column.columnName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,16 @@ typedef _ForeignKeyEdge = ({
|
||||||
typedef _ForeignKeyValueKey = (String tableName, String columnName, String value);
|
typedef _ForeignKeyValueKey = (String tableName, String columnName, String value);
|
||||||
|
|
||||||
typedef _UniqueIndexConflictRelease = ({
|
typedef _UniqueIndexConflictRelease = ({
|
||||||
List<_UniqueColumnConflictRelease> columns,
|
List<String> indexedColumns,
|
||||||
|
List<_UniqueColumnConflictRelease> releaseColumns,
|
||||||
bool scoped,
|
bool scoped,
|
||||||
});
|
});
|
||||||
|
|
||||||
typedef _UniqueColumnConflictRelease = ({
|
typedef _UniqueColumnConflictRelease = ({
|
||||||
String columnName,
|
String columnName,
|
||||||
_UniqueConflictReleaseKind kind,
|
CrdtUniqueConflictReleaseKind kind,
|
||||||
});
|
});
|
||||||
|
|
||||||
enum _UniqueConflictReleaseKind {
|
|
||||||
setNull,
|
|
||||||
textSuffix,
|
|
||||||
syntheticUuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _ForeignKeyTargetPresence {
|
enum _ForeignKeyTargetPresence {
|
||||||
absent,
|
absent,
|
||||||
visible,
|
visible,
|
||||||
|
|
@ -1113,12 +1108,12 @@ WHERE c."id" IN ($whereRowIds)
|
||||||
|
|
||||||
final uniqueIndexes = _uniqueIndexesForTable(tableDefinition);
|
final uniqueIndexes = _uniqueIndexesForTable(tableDefinition);
|
||||||
for (final uniqueIndex in uniqueIndexes) {
|
for (final uniqueIndex in uniqueIndexes) {
|
||||||
final columnNames = uniqueIndex.columnNames.toList();
|
final releaseColumnNames = uniqueIndex.releaseColumnNames.toList();
|
||||||
|
|
||||||
final valuesByRowId = await _readDomainColumnValues(
|
final valuesByRowId = await _readDomainColumnValues(
|
||||||
tableName,
|
tableName,
|
||||||
rowIds,
|
rowIds,
|
||||||
columnNames,
|
uniqueIndex.indexedColumns,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
final updatedRowIds = <UuidValue>{};
|
final updatedRowIds = <UuidValue>{};
|
||||||
|
|
@ -1127,7 +1122,7 @@ WHERE c."id" IN ($whereRowIds)
|
||||||
if (values.values.any((value) => value == null)) continue;
|
if (values.values.any((value) => value == null)) continue;
|
||||||
|
|
||||||
final updates = <String, Object?>{};
|
final updates = <String, Object?>{};
|
||||||
for (final column in uniqueIndex.columns) {
|
for (final column in uniqueIndex.releaseColumns) {
|
||||||
updates[column.columnName] = _conflictFreeValue(
|
updates[column.columnName] = _conflictFreeValue(
|
||||||
column,
|
column,
|
||||||
values[column.columnName],
|
values[column.columnName],
|
||||||
|
|
@ -1145,7 +1140,7 @@ WHERE c."id" IN ($whereRowIds)
|
||||||
await recordFieldsUpdatedByTable(
|
await recordFieldsUpdatedByTable(
|
||||||
tableName,
|
tableName,
|
||||||
updatedRowIds,
|
updatedRowIds,
|
||||||
columnNames,
|
releaseColumnNames,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1290,13 +1285,13 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
|
||||||
String releaseSuffix,
|
String releaseSuffix,
|
||||||
) {
|
) {
|
||||||
switch (column.kind) {
|
switch (column.kind) {
|
||||||
case _UniqueConflictReleaseKind.setNull:
|
case CrdtUniqueConflictReleaseKind.setNull:
|
||||||
return null;
|
return null;
|
||||||
case _UniqueConflictReleaseKind.textSuffix:
|
case CrdtUniqueConflictReleaseKind.textSuffix:
|
||||||
if (value is String) {
|
if (value is String) {
|
||||||
return '${value}__${releaseSuffix}__${conflictingId.uuid}';
|
return '${value}__${releaseSuffix}__${conflictingId.uuid}';
|
||||||
}
|
}
|
||||||
case _UniqueConflictReleaseKind.syntheticUuid:
|
case CrdtUniqueConflictReleaseKind.syntheticUuid:
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
return const Uuid().v5obj(
|
return const Uuid().v5obj(
|
||||||
Namespace.oid.value,
|
Namespace.oid.value,
|
||||||
|
|
@ -1414,88 +1409,39 @@ List<_UniqueIndexConflictRelease> _syncableUniqueIndexesForTable(
|
||||||
TableDefinition table,
|
TableDefinition table,
|
||||||
Set<String> syncTableNames,
|
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};
|
final columnsByName = {for (final column in table.columns) column.name: column};
|
||||||
return [
|
return [
|
||||||
for (final index in uniqueIndexes)
|
for (final index in crdtSyncableUniqueIndexesForTable(table, syncTableNames))
|
||||||
_uniqueIndexConflictReleaseForIndex(table, columnsByName, index),
|
_uniqueIndexConflictReleaseForIndex(table, columnsByName, index),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isScopedUniqueIndex(IndexDefinition index) {
|
|
||||||
return index.elements.any(
|
|
||||||
(element) =>
|
|
||||||
element.type == IndexElementDefinitionType.column &&
|
|
||||||
element.definition == 'scopeId',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_UniqueIndexConflictRelease _uniqueIndexConflictReleaseForIndex(
|
_UniqueIndexConflictRelease _uniqueIndexConflictReleaseForIndex(
|
||||||
TableDefinition table,
|
TableDefinition table,
|
||||||
Map<String, ColumnDefinition> columnsByName,
|
Map<String, ColumnDefinition> columnsByName,
|
||||||
IndexDefinition index,
|
IndexDefinition index,
|
||||||
) {
|
) {
|
||||||
final columnNames = index.elements.map((element) => element.definition).toList();
|
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 (
|
return (
|
||||||
scoped: columnNames.contains('scopeId'),
|
scoped: columnNames.contains('scopeId'),
|
||||||
columns: [
|
indexedColumns: indexedColumns,
|
||||||
for (final columnName in columnNames)
|
releaseColumns: releaseColumns,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
final tablesMissingScopeIdRelation = _missingCrdtScopeRelations(
|
||||||
syncTables,
|
syncTables,
|
||||||
tableDefinitionsByName,
|
tableDefinitionsByName,
|
||||||
|
|
@ -217,12 +230,7 @@ bool _isForbiddenGlobalUniqueIndex(
|
||||||
Set<String> syncTableNames,
|
Set<String> syncTableNames,
|
||||||
) {
|
) {
|
||||||
if (!index.isUnique || index.isPrimary) return false;
|
if (!index.isUnique || index.isPrimary) return false;
|
||||||
final includesScopeId = index.elements.any(
|
if (isCrdtScopedUniqueIndex(index)) return false;
|
||||||
(element) =>
|
|
||||||
element.type == IndexElementDefinitionType.column &&
|
|
||||||
element.definition == 'scopeId',
|
|
||||||
);
|
|
||||||
if (includesScopeId) return false;
|
|
||||||
|
|
||||||
return !isCrdtAllowedForeignKeyOnlyUniqueIndex(
|
return !isCrdtAllowedForeignKeyOnlyUniqueIndex(
|
||||||
tableDefinition,
|
tableDefinition,
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,109 @@ bool isCrdtAllowedForeignKeyOnlyUniqueIndex(
|
||||||
|
|
||||||
return indexColumns.every(coveredForeignKeyColumns.contains);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ final demoSyncTables = <Table>[
|
||||||
Types.t,
|
Types.t,
|
||||||
Unique.t,
|
Unique.t,
|
||||||
UniqueComposite.t,
|
UniqueComposite.t,
|
||||||
|
UniqueDiscriminator.t,
|
||||||
UniqueSetNullChild.t,
|
UniqueSetNullChild.t,
|
||||||
UniqueUuid.t,
|
UniqueUuid.t,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ class DemoDebugEndpoint extends Endpoint {
|
||||||
await add<Unique>();
|
await add<Unique>();
|
||||||
await add<UniqueUuid>();
|
await add<UniqueUuid>();
|
||||||
await add<UniqueComposite>();
|
await add<UniqueComposite>();
|
||||||
|
await add<UniqueDiscriminator>();
|
||||||
await add<UniqueSetNullChild>();
|
await add<UniqueSetNullChild>();
|
||||||
await add<Types>();
|
await add<Types>();
|
||||||
await add<FkChainRoot>();
|
await add<FkChainRoot>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
class: UniqueDiscriminator
|
||||||
|
table: unique_discriminator
|
||||||
|
database: all
|
||||||
|
fields:
|
||||||
|
id: UuidValue?, defaultPersist=random_v7
|
||||||
|
### Owner scope of this row. Maintained by the CRDT sync layer.
|
||||||
|
scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
|
||||||
|
categoryId: int
|
||||||
|
name: String, unique(per=[scopeId, categoryId])
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
class: UniqueNoRelease
|
||||||
|
table: unique_no_release
|
||||||
|
database: all
|
||||||
|
fields:
|
||||||
|
id: UuidValue?, defaultPersist=random_v7
|
||||||
|
### Owner scope of this row. Maintained by the CRDT sync layer.
|
||||||
|
scopeId: int?, relation(parent=crdt_scopes, onDelete=Cascade)
|
||||||
|
categoryId: int, unique(per=[scopeId])
|
||||||
|
|
@ -421,6 +421,94 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a remote update that loses a composite unique conflict with a stable discriminator, ',
|
||||||
|
() {
|
||||||
|
late UniqueDiscriminator loser;
|
||||||
|
late CrdtMergeUpdate remoteUpdate;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
await session.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await UniqueDiscriminator.db.insertRow(
|
||||||
|
session,
|
||||||
|
UniqueDiscriminator(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
categoryId: 7,
|
||||||
|
name: 'shared-name',
|
||||||
|
),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
loser = await UniqueDiscriminator.db.insertRow(
|
||||||
|
session,
|
||||||
|
UniqueDiscriminator(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
categoryId: 7,
|
||||||
|
name: 'original-name',
|
||||||
|
),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final loserCrdtRow = await CrdtDataRow.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.uuidRowId.equals(loser.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
remoteUpdate = CrdtMergeUpdate(
|
||||||
|
uuidScopeId: testCrdtUserId,
|
||||||
|
tableName: UniqueDiscriminator.t.tableName,
|
||||||
|
uuidRowId: loser.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: loserCrdtRow!.hlc.datetime.advance(),
|
||||||
|
hlcCounter: loserCrdtRow.hlc.counter,
|
||||||
|
columnName: UniqueDiscriminator.t.name.columnName,
|
||||||
|
value: 'shared-name',
|
||||||
|
);
|
||||||
|
|
||||||
|
mergeSet = [remoteUpdate];
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when merging, ', () {
|
||||||
|
setUp(() async {
|
||||||
|
await session.db.mergeChanges(
|
||||||
|
mergeSet,
|
||||||
|
scopeId: testCrdtUserId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the losing row keeps the discriminator and receives a conflict-free name.',
|
||||||
|
() async {
|
||||||
|
final row = await UniqueDiscriminator.db.findById(session, loser.id!);
|
||||||
|
|
||||||
|
expect(row, isNotNull);
|
||||||
|
expect(row!.categoryId, 7);
|
||||||
|
expect(row.name, 'shared-name__conflict__${loser.id!.uuid}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the stable discriminator does not receive CRDT field metadata.',
|
||||||
|
() async {
|
||||||
|
final fields = await CrdtDataField.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(loser.id),
|
||||||
|
include: CrdtDataField.include(
|
||||||
|
column: CrdtSchemaColumn.include(),
|
||||||
|
node: CrdtNode.include(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fields, hasLength(1));
|
||||||
|
expect(fields.first.column!.name, UniqueDiscriminator.t.name.columnName);
|
||||||
|
expect(fields.first.hlc, remoteUpdate.hlc);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
group(
|
group(
|
||||||
'Given a remote update that loses a UUID unique conflict, ',
|
'Given a remote update that loses a UUID unique conflict, ',
|
||||||
() {
|
() {
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,70 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Given a CRDT database with a scoped composite unique index that has a stable discriminator and a releasable column, '
|
||||||
|
'when the database is initialized, '
|
||||||
|
'then the unique index metadata is accepted.',
|
||||||
|
() async {
|
||||||
|
final crdtSession = CrdtDatabaseSession.wraps(
|
||||||
|
testSession,
|
||||||
|
syncTables: [UniqueDiscriminator.t],
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(crdtSession.db.initialize(), completes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Given a CRDT schema registry with a scoped unique index that has no releasable non-scope column, '
|
||||||
|
'when the registry is created, '
|
||||||
|
'then an error is thrown.',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
() => CrdtSchemaRegistry(
|
||||||
|
session,
|
||||||
|
syncTables: [UniqueNoRelease.t],
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains(
|
||||||
|
'CRDT unique conflict resolution requires at least one '
|
||||||
|
'releasable non-scope column',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Given a CRDT database with a scoped unique index that has no releasable non-scope column, '
|
||||||
|
'when the database is initialized, '
|
||||||
|
'then an error is thrown.',
|
||||||
|
() async {
|
||||||
|
final crdtSession = CrdtDatabaseSession.wraps(
|
||||||
|
testSession,
|
||||||
|
syncTables: [UniqueNoRelease.t],
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
crdtSession.db.initialize(),
|
||||||
|
throwsA(
|
||||||
|
isA<StateError>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains(
|
||||||
|
'CRDT unique conflict resolution requires at least one '
|
||||||
|
'releasable non-scope column',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'Given a CRDT schema registry with a synced table that has the scopeId cascade relation to crdt_scopes, '
|
'Given a CRDT schema registry with a synced table that has the scopeId cascade relation to crdt_scopes, '
|
||||||
'when the registry is created, '
|
'when the registry is created, '
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ Future<void> _initialize() async {
|
||||||
Town.t,
|
Town.t,
|
||||||
Unique.t,
|
Unique.t,
|
||||||
UniqueComposite.t,
|
UniqueComposite.t,
|
||||||
|
UniqueDiscriminator.t,
|
||||||
UniqueSetNullChild.t,
|
UniqueSetNullChild.t,
|
||||||
UniqueUuid.t,
|
UniqueUuid.t,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue