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.
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
? (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);
}

View file

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

View file

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

View file

@ -5,7 +5,7 @@ import '../protocol/protocol.dart';
/// 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]
/// is null, only the root table and [where] are merged.
///
@ -85,7 +85,7 @@ extension on Table {
/// - [CrdtDataRow.id] = [CrdtDataDeleted.rowId].
///
/// 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).
Expression? get whereNotDeletedOnTombstone {
if (id is! ColumnUuid) return null;
@ -98,6 +98,6 @@ extension on Table {
CrdtDataRowTable(tableRelation: foreignTableRelation),
);
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
extends: BaseHlc
table: crdt_data_tombstone
database: all
fields:
### Row that was deleted.
### Row whose visibility is tracked.
row: CrdtDataRow?, relation(name=row_deleted, field=rowId, onDelete=Cascade)
rowId: int
### The node that updated the field.
node: CrdtNode?, relation
### When the row was deleted.
isDeleted: bool
### Monotone causal-length flag. Odd values are visible and even values are deleted.
clFlag: int
### Why the row entered its current visibility generation.
reason: CrdtDataDeletedReason
indexes:
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.
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)
### 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
extends: CrdtMergeChange
fields:
### Whether the row is deleted or restored.
isDeleted: bool
### Monotone causal-length flag. Odd values are visible and even values are deleted.
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!.isDeleted, true);
expect(tombstone.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
});
test('then the row still exist on the person table.', () async {
@ -110,6 +112,7 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.clFlag, 2);
expect(tombstone.hlc, equals(firstDeletedTombstone.hlc));
});
});
@ -311,7 +314,10 @@ void main() {
final tombstones = await CrdtDataDeleted.db.updateWhere(
session,
columnValues: (t) => [t.isDeleted(false)],
columnValues: (t) => [
t.clFlag(3),
t.reason(CrdtDataDeletedReason.userReinsert),
],
where: (t) => t.row.uuidRowId.equals(person.id),
);
@ -333,6 +339,8 @@ void main() {
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.clFlag, 4);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
});
});
});
@ -385,6 +393,7 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
});
test('then the organization row still exist on the organization table.', () async {
@ -405,6 +414,7 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, true);
expect(tombstone.reason, CrdtDataDeletedReason.cascadeDelete);
});
});

View file

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

View file

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

View file

@ -40,7 +40,8 @@ void main() {
uuidNodeId: remoteNodeId,
hlcDatetime: rowHlc.datetime.advance(),
hlcCounter: 0,
isDeleted: true,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
);
mergeSet = [remoteDelete];
@ -68,11 +69,145 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isTrue);
expect(tombstone.clFlag, 2);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
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, ', () {
late Person person;
late Hlc tombstoneHlc;
@ -108,7 +243,8 @@ void main() {
uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.advance(),
hlcCounter: 0,
isDeleted: false,
clFlag: 3,
reason: CrdtDataDeletedReason.userReinsert,
);
mergeSet = [remoteRestore];
@ -137,6 +273,8 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse);
expect(tombstone.clFlag, 3);
expect(tombstone.reason, CrdtDataDeletedReason.userReinsert);
});
});
});
@ -182,7 +320,8 @@ void main() {
uuidNodeId: remoteNodeId,
hlcDatetime: tombstoneHlc.datetime.retreat(),
hlcCounter: 0,
isDeleted: true,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
);
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(
'Given an empty table and a remote delete for a non-existing row, ',
() {
@ -232,7 +447,8 @@ void main() {
uuidNodeId: remoteNodeId,
hlcDatetime: DateTime.now().toUtc(),
hlcCounter: 0,
isDeleted: true,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
);
mergeSet = [remoteDelete];

View file

@ -242,7 +242,7 @@ void main() {
// to ensure that tombstone conflict resolution never falls back to the
// row-level HLC.
test(
'then a CRDT tombstone record is inserted with deleted set to false.',
'then the initial visible generation metadata is lazily touched.',
() async {
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
@ -252,7 +252,10 @@ void main() {
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isFalse);
expect(tombstone.clFlag, 1);
expect(tombstone.reason, CrdtDataDeletedReason.userInsert);
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 {

View file

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