feat: Replace the isDeleted flag by an int CLFlag (#55)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Marcelo Mendonça Soares 2026-05-31 14:17:59 -03:00 • committed by GitHub
commit b8ae05c76d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 406 additions and 49 deletions

View file

@ -24,10 +24,13 @@ extension CrdtDataRowExtension on CrdtDataRow {
/// Extensions for the [CrdtDataDeleted] class. /// Extensions for the [CrdtDataDeleted] class.
extension CrdtDataDeletedExtension on CrdtDataDeleted { extension CrdtDataDeletedExtension on CrdtDataDeleted {
/// The HLC represented by this tombstone. /// Whether this visibility generation hides the row.
bool get isDeleted => clFlag.isEven;
/// The HLC represented by this visibility generation.
Hlc get hlc => node == null Hlc get hlc => node == null
? (throw StateError( ? (throw StateError(
'Fetch the CRDT tombstone with the node included to get the HLC.', 'Fetch the CRDT visibility generation with the node included to get the HLC.',
)) ))
: toHlcForNode(node!.uuidNodeId); : toHlcForNode(node!.uuidNodeId);
} }

View file

@ -466,7 +466,8 @@ class CrdtSync {
tableName: tableName, tableName: tableName,
uuidRowId: tombstone.row!.uuidRowId, uuidRowId: tombstone.row!.uuidRowId,
uuidNodeId: tombstone.node!.uuidNodeId, uuidNodeId: tombstone.node!.uuidNodeId,
isDeleted: tombstone.isDeleted, clFlag: tombstone.clFlag,
reason: tombstone.reason,
); );
} }
} }

View file

@ -332,17 +332,18 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
final row = rows[rowKey]; final row = rows[rowKey];
if (row == null) return; if (row == null) return;
final incomingHlc = delete.hlc;
final currentTombstone = tombstones[rowKey]; final currentTombstone = tombstones[rowKey];
if (incomingHlc <= row.hlc.maxBetween(currentTombstone?.hlc)) { final currentClFlag = currentTombstone?.clFlag ?? 1;
return; final currentHlc = currentTombstone?.hlc ?? row.hlc;
} if (delete.clFlag < currentClFlag) return;
if (delete.clFlag == currentClFlag && delete.hlc <= currentHlc) return;
tombstones[rowKey] = await _upsertMergeTombstone( tombstones[rowKey] = await _upsertMergeTombstone(
row, row,
_requireRemoteNode(remoteNodes, delete.uuidNodeId), _requireRemoteNode(remoteNodes, delete.uuidNodeId),
incomingHlc, delete.hlc,
delete.isDeleted, delete.clFlag,
delete.reason,
currentTombstone, currentTombstone,
transaction, transaction,
); );
@ -441,7 +442,8 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
CrdtDataRow row, CrdtDataRow row,
CrdtNode remoteNode, CrdtNode remoteNode,
Hlc incomingHlc, Hlc incomingHlc,
bool isDeleted, int clFlag,
CrdtDataDeletedReason reason,
CrdtDataDeleted? currentTombstone, CrdtDataDeleted? currentTombstone,
Transaction transaction, Transaction transaction,
) async { ) async {
@ -453,7 +455,8 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
nodeId: remoteNode.id!, nodeId: remoteNode.id!,
hlcDatetime: incomingHlc.datetime, hlcDatetime: incomingHlc.datetime,
hlcCounter: incomingHlc.counter, hlcCounter: incomingHlc.counter,
isDeleted: isDeleted, clFlag: clFlag,
reason: reason,
), ),
transaction: transaction, transaction: transaction,
); );
@ -470,13 +473,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
node: remoteNode, node: remoteNode,
hlcDatetime: incomingHlc.datetime, hlcDatetime: incomingHlc.datetime,
hlcCounter: incomingHlc.counter, hlcCounter: incomingHlc.counter,
isDeleted: isDeleted, clFlag: clFlag,
reason: reason,
); );
await CrdtDataDeleted.db.update( await CrdtDataDeleted.db.update(
_session, _session,
[updatedTombstone], [updatedTombstone],
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter, t.isDeleted], columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter, t.clFlag, t.reason],
transaction: transaction, transaction: transaction,
); );
@ -558,12 +562,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
final rowKey = (insert.tableName, insert.uuidRowId); final rowKey = (insert.tableName, insert.uuidRowId);
final currentTombstone = tombstones[rowKey]; final currentTombstone = tombstones[rowKey];
if (currentTombstone == null || incomingHlc > currentTombstone.hlc) { if ((currentTombstone?.clFlag ?? 1) == 1 &&
incomingHlc > (currentTombstone?.hlc ?? currentRow.hlc)) {
tombstones[rowKey] = await _upsertMergeTombstone( tombstones[rowKey] = await _upsertMergeTombstone(
currentRow, currentRow,
remoteNode, remoteNode,
incomingHlc, incomingHlc,
false, 1,
CrdtDataDeletedReason.userInsert,
currentTombstone, currentTombstone,
transaction, transaction,
); );

View file

@ -240,7 +240,12 @@ class CrdtMutationRecorder {
); );
await _touchCrdtRows(crdtDataRows, hlcManager, transaction); await _touchCrdtRows(crdtDataRows, hlcManager, transaction);
await _markCrdtRowsDeleted(crdtDataRows, false, transaction); await _markCrdtRowsDeleted(
crdtDataRows,
false,
CrdtDataDeletedReason.userReinsert,
transaction,
);
await _recordUpdatedFields(reinsertedRows, crdtDataRows, null, transaction); await _recordUpdatedFields(reinsertedRows, crdtDataRows, null, transaction);
}); });
} }
@ -347,7 +352,7 @@ LEFT JOIN "crdt_data_tombstone" tomb
ON tomb."rowId" = r."id" ON tomb."rowId" = r."id"
WHERE c."id" IN ($whereRowIds) WHERE c."id" IN ($whereRowIds)
AND c."$childColumn" IS NOT NULL AND c."$childColumn" IS NOT NULL
AND (p."id" IS NULL OR tomb."isDeleted" = TRUE) AND (p."id" IS NULL OR tomb."clFlag" % 2 = 0)
'''; ''';
}); });
@ -543,6 +548,7 @@ WHERE c."id" IN ($whereRowIds)
deletedRows.uuidRowIds, deletedRows.uuidRowIds,
transaction, transaction,
null, null,
CrdtDataDeletedReason.userDelete,
); );
} }
@ -551,6 +557,7 @@ WHERE c."id" IN ($whereRowIds)
Set<UuidValue> rowIds, Set<UuidValue> rowIds,
Transaction transaction, Transaction transaction,
Set<String>? processedRowIds, Set<String>? processedRowIds,
CrdtDataDeletedReason reason,
) async { ) async {
if (rowIds.isEmpty) return; if (rowIds.isEmpty) return;
if (!_isCrdtTrackedTableName(tableName)) return; if (!_isCrdtTrackedTableName(tableName)) return;
@ -585,7 +592,7 @@ WHERE c."id" IN ($whereRowIds)
processing, processing,
); );
await _releaseUniqueConflicts(tableName, visibleRowIds, transaction); await _releaseUniqueConflicts(tableName, visibleRowIds, transaction);
await _markCrdtRowsDeleted(visibleCrdtRows, true, transaction); await _markCrdtRowsDeleted(visibleCrdtRows, true, reason, transaction);
} finally { } finally {
for (final rowId in unprocessedRowIds) { for (final rowId in unprocessedRowIds) {
processing.remove('$tableName:$rowId'); processing.remove('$tableName:$rowId');
@ -665,6 +672,7 @@ WHERE c."id" IN ($whereRowIds)
Future<void> _markCrdtRowsDeleted( Future<void> _markCrdtRowsDeleted(
List<CrdtDataRow> crdtDataRows, List<CrdtDataRow> crdtDataRows,
bool isDeleted, bool isDeleted,
CrdtDataDeletedReason reason,
Transaction transaction, Transaction transaction,
) async { ) async {
final existingTombs = await CrdtDataDeleted.db.find( final existingTombs = await CrdtDataDeleted.db.find(
@ -682,6 +690,7 @@ WHERE c."id" IN ($whereRowIds)
final rowPk = row.id!; final rowPk = row.id!;
final hlc = hlcManager.increment(); final hlc = hlcManager.increment();
final existing = tombByCrdtRowPk[rowPk]; final existing = tombByCrdtRowPk[rowPk];
final clFlag = _nextClFlag(existing, isDeleted);
if (existing == null) { if (existing == null) {
toInsert.add( toInsert.add(
@ -690,7 +699,8 @@ WHERE c."id" IN ($whereRowIds)
nodeId: hlcManager.normalizedNodeId, nodeId: hlcManager.normalizedNodeId,
hlcDatetime: hlc.datetime, hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter, hlcCounter: hlc.counter,
isDeleted: isDeleted, clFlag: clFlag,
reason: reason,
), ),
); );
} else { } else {
@ -699,7 +709,8 @@ WHERE c."id" IN ($whereRowIds)
nodeId: hlcManager.normalizedNodeId, nodeId: hlcManager.normalizedNodeId,
hlcDatetime: hlc.datetime, hlcDatetime: hlc.datetime,
hlcCounter: hlc.counter, hlcCounter: hlc.counter,
isDeleted: isDeleted, clFlag: clFlag,
reason: reason,
), ),
); );
} }
@ -843,6 +854,7 @@ WHERE c."id" IN ($whereRowIds)
childIds, childIds,
transaction, transaction,
processing, processing,
CrdtDataDeletedReason.cascadeDelete,
); );
} }
} }
@ -925,7 +937,7 @@ LEFT JOIN "crdt_data_rows" r
LEFT JOIN "crdt_data_tombstone" tomb LEFT JOIN "crdt_data_tombstone" tomb
ON tomb."rowId" = r."id" ON tomb."rowId" = r."id"
WHERE d."${_escapeIdentifier(columnName)}" = ${_sqlLiteral(value)} WHERE d."${_escapeIdentifier(columnName)}" = ${_sqlLiteral(value)}
AND (tomb."id" IS NULL OR tomb."isDeleted" = FALSE) AND (tomb."id" IS NULL OR tomb."clFlag" % 2 = 1)
''', ''',
transaction: transaction, transaction: transaction,
); );
@ -1059,6 +1071,12 @@ WHERE "id" IN (${_sqlLiteralList(rowIds)})
String _escapeIdentifier(String identifier) => identifier.replaceAll('"', '""'); String _escapeIdentifier(String identifier) => identifier.replaceAll('"', '""');
int _nextClFlag(CrdtDataDeleted? current, bool isDeleted) {
var next = (current?.clFlag ?? 1) + 1;
if (next.isEven != isDeleted) next++;
return next;
}
List<_UniqueIndexConflictRelease> _syncableUniqueIndexesForTable( List<_UniqueIndexConflictRelease> _syncableUniqueIndexesForTable(
TableDefinition table, TableDefinition table,
) { ) {

View file

@ -5,7 +5,7 @@ import '../protocol/protocol.dart';
/// Merges [where] with CRDT tombstone predicates and mutates [include] in place. /// Merges [where] with CRDT tombstone predicates and mutates [include] in place.
/// ///
/// This method ensures that no rows marked as deleted on the tombstone are /// This method ensures that no rows marked as deleted by the CLFlag are
/// returned for queries with [where] clauses or [include] graphs. If [include] /// returned for queries with [where] clauses or [include] graphs. If [include]
/// is null, only the root table and [where] are merged. /// is null, only the root table and [where] are merged.
/// ///
@ -85,7 +85,7 @@ extension on Table {
/// - [CrdtDataRow.id] = [CrdtDataDeleted.rowId]. /// - [CrdtDataRow.id] = [CrdtDataDeleted.rowId].
/// ///
/// Returns null when [Table] does not use a UUID primary key. Keeps rows /// Returns null when [Table] does not use a UUID primary key. Keeps rows
/// when there is no tombstone row or [CrdtDataDeleted.isDeleted] is false /// when there is no tombstone row or [CrdtDataDeleted.clFlag] is odd
/// (LEFT JOIN null-safe). /// (LEFT JOIN null-safe).
Expression? get whereNotDeletedOnTombstone { Expression? get whereNotDeletedOnTombstone {
if (id is! ColumnUuid) return null; if (id is! ColumnUuid) return null;
@ -98,6 +98,6 @@ extension on Table {
CrdtDataRowTable(tableRelation: foreignTableRelation), CrdtDataRowTable(tableRelation: foreignTableRelation),
); );
final tomb = crdtRowTable.deleted; final tomb = crdtRowTable.deleted;
return (tomb.id.equals(null)) | (tomb.isDeleted.equals(false)); return (tomb.id.equals(null)) | Expression('${tomb.clFlag} % 2 = 1');
} }
} }

View file

@ -1,20 +1,24 @@
### CRDT tombstone table. ### CRDT visibility generation table.
### ###
### This table stores all tombstones for deleted data. ### This table stores materialized monotone CLFlag metadata after a visibility
### change event has been recorded.
class: CrdtDataDeleted class: CrdtDataDeleted
extends: BaseHlc extends: BaseHlc
table: crdt_data_tombstone table: crdt_data_tombstone
database: all database: all
fields: fields:
### Row that was deleted. ### Row whose visibility is tracked.
row: CrdtDataRow?, relation(name=row_deleted, field=rowId, onDelete=Cascade) row: CrdtDataRow?, relation(name=row_deleted, field=rowId, onDelete=Cascade)
rowId: int rowId: int
### The node that updated the field. ### The node that updated the field.
node: CrdtNode?, relation node: CrdtNode?, relation
### When the row was deleted. ### Monotone causal-length flag. Odd values are visible and even values are deleted.
isDeleted: bool clFlag: int
### Why the row entered its current visibility generation.
reason: CrdtDataDeletedReason
indexes: indexes:
crdt_data_tombstone_row_idx: crdt_data_tombstone_row_idx:

View file

@ -0,0 +1,10 @@
### Why a CRDT row entered its current visibility generation.
enum: CrdtDataDeletedReason
serialized: byIndex
values:
- userInsert
- userDelete
- userReinsert
- restrictRestore
- cascadeDelete
- uniqueLoser

View file

@ -22,7 +22,10 @@ fields:
### The node that inserted the row with the HLC timestamp. ### The node that inserted the row with the HLC timestamp.
node: CrdtNode?, relation node: CrdtNode?, relation
### The tombstone for this row. ### The visibility CLFlag for this row.
###
### Only present after the first visibility change event has been recorded.
### If null, the row is visible and has never been deleted/hidden.
deleted: CrdtDataDeleted?, relation(name=row_deleted, optional) deleted: CrdtDataDeleted?, relation(name=row_deleted, optional)
### The fields for this row. ### The fields for this row.

View file

@ -1,6 +1,9 @@
### A remote tombstone update to merge. ### A remote visibility CLFlag update to merge.
class: CrdtMergeDelete class: CrdtMergeDelete
extends: CrdtMergeChange extends: CrdtMergeChange
fields: fields:
### Whether the row is deleted or restored. ### Monotone causal-length flag. Odd values are visible and even values are deleted.
isDeleted: bool clFlag: int
### Why the row entered its current visibility generation.
reason: CrdtDataDeletedReason

View file

@ -37,6 +37,8 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true); expect(tombstone!.isDeleted, true);
expect(tombstone.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
}); });
test('then the row still exist on the person table.', () async { test('then the row still exist on the person table.', () async {
@ -110,6 +112,7 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true); expect(tombstone!.isDeleted, true);
expect(tombstone.clFlag, 2);
expect(tombstone.hlc, equals(firstDeletedTombstone.hlc)); expect(tombstone.hlc, equals(firstDeletedTombstone.hlc));
}); });
}); });
@ -311,7 +314,10 @@ void main() {
final tombstones = await CrdtDataDeleted.db.updateWhere( final tombstones = await CrdtDataDeleted.db.updateWhere(
session, session,
columnValues: (t) => [t.isDeleted(false)], columnValues: (t) => [
t.clFlag(3),
t.reason(CrdtDataDeletedReason.userReinsert),
],
where: (t) => t.row.uuidRowId.equals(person.id), where: (t) => t.row.uuidRowId.equals(person.id),
); );
@ -333,6 +339,8 @@ void main() {
); );
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true); expect(tombstone!.isDeleted, true);
expect(tombstone.clFlag, 4);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
}); });
}); });
}); });
@ -385,6 +393,7 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true); expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
}); });
test('then the organization row still exist on the organization table.', () async { test('then the organization row still exist on the organization table.', () async {
@ -405,6 +414,7 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true); expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
}); });
}); });

View file

@ -92,7 +92,8 @@ void main() {
nodeId: 1, nodeId: 1,
hlcDatetime: DateTime.now().toUtc(), hlcDatetime: DateTime.now().toUtc(),
hlcCounter: 1, hlcCounter: 1,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
); );

View file

@ -63,13 +63,13 @@ void main() {
expect(fieldCount, 0); expect(fieldCount, 0);
}); });
test('then no CRDT tombstone is created for the row.', () async { test('then no CRDT visibility generation is created for the row.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow( final clFlag = await CrdtDataDeleted.db.findFirstRow(
session, session,
where: (t) => t.row.uuidRowId.equals(person.id), where: (t) => t.row.uuidRowId.equals(person.id),
); );
expect(tombstone, isNull); expect(clFlag, isNull);
}); });
}); });

View file

@ -40,7 +40,8 @@ void main() {
uuidNodeId: remoteNodeId, uuidNodeId: remoteNodeId,
hlcDatetime: rowHlc.datetime.advance(), hlcDatetime: rowHlc.datetime.advance(),
hlcCounter: 0, hlcCounter: 0,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
); );
mergeSet = [remoteDelete]; mergeSet = [remoteDelete];
@ -68,11 +69,145 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isTrue); expect(tombstone!.isDeleted, isTrue);
expect(tombstone.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
expect(tombstone.hlc, remoteDelete.hlc); expect(tombstone.hlc, remoteDelete.hlc);
}); });
}); });
}); });
group(
'Given a table with an implicitly visible row and an older remote delete, ',
() {
late Person person;
late Hlc rowHlc;
late UuidValue remoteNodeId;
late CrdtMergeDelete remoteDelete;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'older remote delete'),
transaction: tx,
),
);
final crdtRow = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(person.id),
include: CrdtDataRow.include(node: CrdtNode.include()),
);
rowHlc = crdtRow!.hlc;
remoteNodeId = const Uuid().v7obj();
remoteDelete = CrdtMergeDelete(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: remoteNodeId,
hlcDatetime: rowHlc.datetime.retreat(),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the row is hidden by the later generation.', () async {
expect(await Person.db.findById(session, person.id!), isNull);
});
test('then the winning generation keeps its event HLC and node.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
expect(tombstone, isNotNull);
expect(tombstone!.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
expect(tombstone.hlc, remoteDelete.hlc);
expect(tombstone.node!.uuidNodeId, remoteNodeId);
});
});
},
);
group('Given a table with a deleted row and a same-generation newer event, ', () {
late Person person;
late Hlc tombstoneHlc;
late UuidValue remoteNodeId;
late CrdtMergeDelete remoteDelete;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'same generation'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
tombstoneHlc = tombstone!.hlc;
remoteNodeId = const Uuid().v7obj();
remoteDelete = CrdtMergeDelete(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.advance(),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.cascadeDelete,
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the same-generation metadata tie-break uses the newer HLC.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
expect(tombstone, isNotNull);
expect(tombstone!.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
expect(tombstone.hlc, remoteDelete.hlc);
expect(tombstone.node!.uuidNodeId, remoteNodeId);
});
});
});
group('Given a table with a deleted row and a newer remote restore, ', () { group('Given a table with a deleted row and a newer remote restore, ', () {
late Person person; late Person person;
late Hlc tombstoneHlc; late Hlc tombstoneHlc;
@ -108,7 +243,8 @@ void main() {
uuidNodeId: remoteNodeId, uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.advance(), hlcDatetime: tombstoneHlc.datetime.advance(),
hlcCounter: 0, hlcCounter: 0,
isDeleted: false, clFlag: 3,
reason: CrdtDataDeletedReason.userReinsert,
); );
mergeSet = [remoteRestore]; mergeSet = [remoteRestore];
@ -137,6 +273,8 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse); expect(tombstone!.isDeleted, isFalse);
expect(tombstone.clFlag, 3);
expect(tombstone.reason, CrdtDataDeletedReason.userReinsert);
}); });
}); });
}); });
@ -182,7 +320,8 @@ void main() {
uuidNodeId: remoteNodeId, uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.retreat(), hlcDatetime: tombstoneHlc.datetime.retreat(),
hlcCounter: 0, hlcCounter: 0,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
); );
mergeSet = [remoteRestore]; mergeSet = [remoteRestore];
@ -217,6 +356,82 @@ void main() {
}); });
}); });
group(
'Given a table with a restored row and an older remote delete from a later generation, ',
() {
late Person person;
late Hlc tombstoneHlc;
late UuidValue remoteNodeId;
late CrdtMergeDelete remoteDelete;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'deleted later'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(session, person, transaction: tx),
);
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
tombstoneHlc = tombstone!.hlc;
remoteNodeId = const Uuid().v7obj();
remoteDelete = CrdtMergeDelete(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.retreat(),
hlcCounter: 0,
clFlag: 4,
reason: CrdtDataDeletedReason.userDelete,
);
mergeSet = [remoteDelete];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the row is hidden by the later generation.', () async {
expect(await Person.db.findById(session, person.id!), isNull);
});
test('then the tombstone records the later generation.', () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
expect(tombstone, isNotNull);
expect(tombstone!.clFlag, 4);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
expect(tombstone.hlc, remoteDelete.hlc);
expect(tombstone.node!.uuidNodeId, remoteNodeId);
});
});
},
);
group( group(
'Given an empty table and a remote delete for a non-existing row, ', 'Given an empty table and a remote delete for a non-existing row, ',
() { () {
@ -232,7 +447,8 @@ void main() {
uuidNodeId: remoteNodeId, uuidNodeId: remoteNodeId,
hlcDatetime: DateTime.now().toUtc(), hlcDatetime: DateTime.now().toUtc(),
hlcCounter: 0, hlcCounter: 0,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
); );
mergeSet = [remoteDelete]; mergeSet = [remoteDelete];

View file

@ -242,7 +242,7 @@ void main() {
// to ensure that tombstone conflict resolution never falls back to the // to ensure that tombstone conflict resolution never falls back to the
// row-level HLC. // row-level HLC.
test( test(
'then a CRDT tombstone record is inserted with deleted set to false.', 'then the initial visible generation metadata is lazily touched.',
() async { () async {
final tombstone = await CrdtDataDeleted.db.findFirstRow( final tombstone = await CrdtDataDeleted.db.findFirstRow(
session, session,
@ -252,7 +252,10 @@ void main() {
expect(tombstone, isNotNull); expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse); expect(tombstone!.isDeleted, isFalse);
expect(tombstone.clFlag, 1);
expect(tombstone.reason, CrdtDataDeletedReason.userInsert);
expect(tombstone.hlc, remoteInsert.hlc); expect(tombstone.hlc, remoteInsert.hlc);
expect(tombstone.node!.uuidNodeId, remoteNodeId);
}, },
); );
}); });
@ -364,6 +367,77 @@ void main() {
}); });
}, },
); );
group(
'Given a table with a row deleted in generation four and a newer remote insert payload, ',
() {
late Person person;
late CrdtMergeInsert remoteInsert;
setUp(() async {
person = await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(
session,
Person(name: 'deleted locally'),
transaction: tx,
),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.insertRow(session, person, transaction: tx),
);
await session.db.transactionForUser(
testCrdtUserId,
(tx) => Person.db.deleteRow(session, person, transaction: tx),
);
final clFlag = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
include: CrdtDataDeleted.include(node: CrdtNode.include()),
);
remoteInsert = CrdtMergeInsert(
tableName: Person.t.tableName,
uuidRowId: person.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: clFlag!.hlc.datetime.advance(),
hlcCounter: clFlag.hlc.counter,
data: person.copyWith(name: 'remote payload'),
);
mergeSet = [remoteInsert];
});
group('when merging, ', () {
setUp(() async {
await session.db.mergeChanges(
mergeSet,
userId: testCrdtUserId,
);
});
test('then the row stays hidden.', () async {
expect(await Person.db.findById(session, person.id!), isNull);
});
test('then the CRDT CLFlag stays at generation four.', () async {
final clFlag = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(person.id),
);
expect(clFlag, isNotNull);
expect(clFlag!.clFlag, 4);
expect(clFlag.reason, CrdtDataDeletedReason.userDelete);
});
});
},
);
} }
extension on DateTime { extension on DateTime {

View file

@ -64,7 +64,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: rowId, uuidRowId: rowId,
uuidNodeId: requesterNodeId, uuidNodeId: requesterNodeId,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),
@ -111,7 +112,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: rowId, uuidRowId: rowId,
uuidNodeId: requesterNodeId, uuidNodeId: requesterNodeId,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),
@ -167,7 +169,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: const Uuid().v7obj(), uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(), uuidNodeId: const Uuid().v7obj(),
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),
@ -195,7 +198,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: const Uuid().v7obj(), uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(), uuidNodeId: const Uuid().v7obj(),
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),
@ -259,7 +263,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: rowId, uuidRowId: rowId,
uuidNodeId: requesterNodeId, uuidNodeId: requesterNodeId,
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),
@ -289,7 +294,8 @@ void main() {
tableName: 'person', tableName: 'person',
uuidRowId: const Uuid().v7obj(), uuidRowId: const Uuid().v7obj(),
uuidNodeId: const Uuid().v7obj(), uuidNodeId: const Uuid().v7obj(),
isDeleted: true, clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
), ),
], ],
), ),

View file

@ -125,6 +125,8 @@ void main() {
expect(mergeChunks, hasLength(2)); expect(mergeChunks, hasLength(2));
expect(mergeChunks.map((chunk) => chunk.changes.length), [2, 1]); expect(mergeChunks.map((chunk) => chunk.changes.length), [2, 1]);
final changes = mergeChunks.expand((chunk) => chunk.changes).toList();
expect(changes.whereType<CrdtMergeInsert>(), hasLength(3));
expect(events.last, isA<CrdtSyncEndOfBatch>()); expect(events.last, isA<CrdtSyncEndOfBatch>());
}, },
); );