feat: Support specifying the conflicting bias

This commit is contained in:
Marcelo Soares 2026-01-27 15:31:55 -03:00
commit abc16d1895
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
6 changed files with 205 additions and 16 deletions

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import '../migrator.dart';
import '../utils/sql_builder.dart'; import '../utils/sql_builder.dart';
/// The name of the HLC function used in the database. /// The name of the HLC function used in the database.
@ -15,7 +16,13 @@ enum Operation { insert, update, delete }
/// Provides the CRDT control trigger statements for the database. /// Provides the CRDT control trigger statements for the database.
abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder { abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
/// Creates a new instance of [OfflineSyncTriggers]. /// Creates a new instance of [OfflineSyncTriggers].
OfflineSyncTriggers(super.crdtDb); OfflineSyncTriggers(
super.crdtDb, [
this.conflictingBias = ConflictingBias.deleteWins,
]);
/// The bias to use when conflicting operations are detected.
final ConflictingBias conflictingBias;
/// Wraps the provided SQL statement as a CREATE TRIGGER statement. /// Wraps the provided SQL statement as a CREATE TRIGGER statement.
/// ///
@ -52,14 +59,23 @@ abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
tableAlias: operation == Operation.insert ? 'NEW' : 'OLD', tableAlias: operation == Operation.insert ? 'NEW' : 'OLD',
); );
final columnNames = operation == Operation.delete final columnNames = [
? [isDeletedColumnName] if (operation != Operation.delete) ...table.nonGenerated,
: table.nonGenerated; if (operation != Operation.update ||
conflictingBias == ConflictingBias.updateWins)
isDeletedColumnName,
];
final columnInserts = columnNames.map((c) { final columnInserts = columnNames.map((c) {
final columnValue = c == isDeletedColumnName
? operation == Operation.delete
? 'TRUE'
: 'FALSE'
: 'NEW."$c"';
return ' SELECT\n${[ return ' SELECT\n${[
' \'$c\' AS "column_name"', ' \'$c\' AS "column_name"',
' ${c == isDeletedColumnName ? 'TRUE' : 'NEW."$c"'} AS "raw_value"', ' $columnValue AS "raw_value"',
if (operation == Operation.update) ' NEW."$c" IS NOT OLD."$c" AS "value_changed"', if (operation == Operation.update) ' NEW."$c" IS NOT OLD."$c" AS "value_changed"',
].join(',\n')}'; ].join(',\n')}';
}); });
@ -111,7 +127,7 @@ ${columnInserts.join('\n UNION ALL\n')}
/// Provides the CRDT control trigger statements for the SQLite3 database. /// Provides the CRDT control trigger statements for the SQLite3 database.
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers { class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
/// Creates a new instance of [Sqlite3OfflineSyncTriggers]. /// Creates a new instance of [Sqlite3OfflineSyncTriggers].
Sqlite3OfflineSyncTriggers(super.crdtDb); Sqlite3OfflineSyncTriggers(super.crdtDb, [super.conflictingBias]);
@override @override
String wrapAsCreateTriggerStatement({ String wrapAsCreateTriggerStatement({

View file

@ -4,6 +4,15 @@ import 'crdt/crdt.dart';
import 'database/database.dart'; import 'database/database.dart';
import 'database/triggers.dart'; import 'database/triggers.dart';
/// The bias to use when conflicting operations are detected.
enum ConflictingBias {
/// Newer updates are overridden by deletes.
deleteWins,
/// Deletes are undone by newer conflicting updates.
updateWins,
}
/// Runs migrations declared by a [MigrationStrategy]. /// Runs migrations declared by a [MigrationStrategy].
class OfflineSyncMigrator extends Migrator { class OfflineSyncMigrator extends Migrator {
/// Used internally by drift when opening the database. /// Used internally by drift when opening the database.
@ -11,6 +20,7 @@ class OfflineSyncMigrator extends Migrator {
super.database, { super.database, {
required this.userId, required this.userId,
required this.nodeId, required this.nodeId,
this.conflictingBias = ConflictingBias.deleteWins,
/// Restrict tables to synchronize. If not provided, all tables will be considered. /// Restrict tables to synchronize. If not provided, all tables will be considered.
List<TableInfo>? synchronizedTables, List<TableInfo>? synchronizedTables,
@ -38,6 +48,11 @@ class OfflineSyncMigrator extends Migrator {
/// Tables to be synchronized with CRDT. /// Tables to be synchronized with CRDT.
final List<TableInfo> synchronizedTables; final List<TableInfo> synchronizedTables;
/// The bias to use when conflicting operations are detected.
///
/// The default is [ConflictingBias.deleteWins], which is the most common case.
final ConflictingBias conflictingBias;
/// The database instance to apply the CRDT schema to. /// The database instance to apply the CRDT schema to.
late final crdtDb = CrdtDatabase( late final crdtDb = CrdtDatabase(
database.executor, database.executor,
@ -103,7 +118,7 @@ class OfflineSyncMigrator extends Migrator {
Future<void> _createTriggers(TableInfo table) async { Future<void> _createTriggers(TableInfo table) async {
final triggerCreator = switch (database.executor.dialect) { final triggerCreator = switch (database.executor.dialect) {
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb), SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(crdtDb, conflictingBias),
// TODO: Implement the triggers for the Postgres dialect. // TODO: Implement the triggers for the Postgres dialect.
_ => throw UnsupportedError('Unsupported dialect: ${database.executor.dialect}'), _ => throw UnsupportedError('Unsupported dialect: ${database.executor.dialect}'),
}; };

View file

@ -72,7 +72,11 @@ void main() {
// Perform an insert to the database to get the exact changeset that would // Perform an insert to the database to get the exact changeset that would
// be generated by the CRDT triggers. // be generated by the CRDT triggers.
await database.tableWithEveryColumnType.insertOne(createdData); await database.tableWithEveryColumnType.insertOne(createdData);
expectedChangeset = await crdtDb.crdtDataTable.all().get(); expectedChangeset = await crdtDb.managers.crdtDataTable
.filter(
(f) => f.columnName.equals(crdtDb.sqlBuilder.isDeletedColumnName).not(),
)
.get();
testHlc = expectedChangeset.first.hlcTimestamp; testHlc = expectedChangeset.first.hlcTimestamp;
await crdtDb.crdtDataTable.deleteAll(); await crdtDb.crdtDataTable.deleteAll();
}); });

View file

@ -59,7 +59,10 @@ void main() {
() async { () async {
final changeset = await crdt.getChangeset(); final changeset = await crdt.getChangeset();
final changesetColumns = changeset.map((e) => e.columnName).toSet(); final changesetColumns = changeset.map((e) => e.columnName).toSet();
final expectedColumns = database.todosTable.$columns.map((c) => c.$name); final expectedColumns = [
...database.todosTable.$columns.map((c) => c.$name),
crdtDb.sqlBuilder.isDeletedColumnName,
];
expect(changeset.length, expectedColumns.length); expect(changeset.length, expectedColumns.length);
expect(changesetColumns, equals(expectedColumns)); expect(changesetColumns, equals(expectedColumns));

View file

@ -33,17 +33,22 @@ void main() {
}); });
group('then the trigger is executed and the CRDT data table', () { group('then the trigger is executed and the CRDT data table', () {
late String isDeletedColumnName;
late List<CrdtDataEntry> allCrdtDataEntries; late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async { setUp(() async {
final migrator = database.createMigrator(); final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get(); allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
}); });
test('has one entry for each column of the target table.', () async { test('has one entry for each column of the target table.', () async {
expect( expect(
allCrdtDataEntries.map((e) => e.columnName).toSet(), allCrdtDataEntries.map((e) => e.columnName).toSet(),
equals(database.todosTable.$columns.map((c) => c.$name).toSet()), equals(
database.todosTable.$columns.map((c) => c.$name).toSet()
..add(isDeletedColumnName),
),
); );
}); });
@ -81,11 +86,11 @@ void main() {
createdRowId = await database.managers.todosTable.create( createdRowId = await database.managers.todosTable.create(
(t) => t(content: initialContent), (t) => t(content: initialContent),
); );
rowId = createdRowId.toString();
final migrator = database.createMigrator(); final migrator = database.createMigrator();
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get(); final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
createdHlc = allCrdtDataEntries.first.hlcTimestamp; createdHlc = allCrdtDataEntries.first.hlcTimestamp;
rowId = createdRowId.toString();
}); });
group('when updating the existing row with a different value', () { group('when updating the existing row with a different value', () {
@ -206,24 +211,23 @@ void main() {
'then the trigger is executed and the deleted row in the CRDT data table', 'then the trigger is executed and the deleted row in the CRDT data table',
() { () {
late List<CrdtDataEntry> allCrdtDataEntries; late List<CrdtDataEntry> allCrdtDataEntries;
late String rowId; late String isDeletedColumnName;
setUp(() async { setUp(() async {
final migrator = database.createMigrator(); final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get(); allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
rowId = createdRowId.toString();
}); });
test('has the deleted flag column set to true.', () async { test('has the deleted flag column set to true.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, '__crdt_is_deleted'); final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
expect(crdtData.unwrappedValue, 1); expect(crdtData.unwrappedValue, 1);
}); });
test('has the deleted HLC timestamp greater than the created.', () async { test('has the deleted HLC timestamp greater than the created.', () async {
final deletedEntry = allCrdtDataEntries.getField( final deletedEntry = allCrdtDataEntries.getField(
rowId, rowId,
'__crdt_is_deleted', isDeletedColumnName,
); );
expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc)); expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc));
}); });
@ -231,6 +235,93 @@ void main() {
); );
}); });
}); });
group('Given a database with a previously deleted row from a synchronized table', () {
const initialContent = 'test';
late String isDeletedColumnName;
late int createdRowId;
late Hlc deletedHlc;
late String rowId;
setUp(() async {
createdRowId = await database.managers.todosTable.create(
(t) => t(content: initialContent),
);
rowId = createdRowId.toString();
final migrator = database.createMigrator();
final deleted = await database.managers.todosTable
.filter((t) => t.id.equals(RowId(createdRowId)))
.delete();
expect(deleted, equals(1));
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
deletedHlc = allCrdtDataEntries.getField(rowId, isDeletedColumnName).hlcTimestamp;
});
group('when re-inserting the previously deleted row', () {
setUp(() async {
await database.managers.todosTable.create(
(t) => t(
id: Value(RowId(createdRowId)),
content: initialContent,
),
);
});
test(
'then the row has been re-inserted in the target table with the same data.',
() async {
final row = await database.managers.todosTable.getSingle();
expect(row, isNotNull);
expect(row.id, equals(createdRowId));
expect(row.content, initialContent);
},
);
group(
'then the trigger is executed and the re-inserted row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
});
test('has the deleted flag column set to false.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
expect(crdtData.unwrappedValue, 0);
});
test(
'has the deleted flag HLC timestamp greater than the deleted.',
() async {
final deletedEntry = allCrdtDataEntries.getField(
rowId,
isDeletedColumnName,
);
expect(deletedEntry.hlcTimestamp, greaterThan(deletedHlc));
},
);
test(
'has the HLC timestamp updated for newly inserted columns.',
() async {
expect(
allCrdtDataEntries.map((e) => e.hlcTimestamp),
everyElement(greaterThan(deletedHlc)),
);
},
);
},
);
});
});
} }
extension on Iterable<CrdtDataEntry> { extension on Iterable<CrdtDataEntry> {

View file

@ -74,6 +74,10 @@ BEGIN
SELECT SELECT
'status' AS "column_name", 'status' AS "column_name",
NEW."status" AS "raw_value" NEW."status" AS "raw_value"
UNION ALL
SELECT
'__crdt_is_deleted' AS "column_name",
FALSE AS "raw_value"
) )
WHERE TRUE WHERE TRUE
ON CONFLICT ("user_id", "table_name", "column_name", "row_id") ON CONFLICT ("user_id", "table_name", "column_name", "row_id")
@ -312,4 +316,60 @@ END;''',
}); });
}); });
}); });
// This test assures that the above tests are for [ConflictingBias.deleteWins]
// and avoids the need for duplicating all tests.
test('Given a SQLite3 migrator with no specified conflicting bias '
'then it generates the triggers with the deleteWins conflicting bias.', () {
final migrator = database.createMigrator();
expect(migrator.conflictingBias, ConflictingBias.deleteWins);
});
group('Given a SQLite3 database with a table and updateWins conflicting bias', () {
late OfflineSyncMigrator migrator;
late String isDeletedColumnName;
setUp(() {
migrator = OfflineSyncMigrator(
database,
userId: testUserId,
nodeId: testNodeId,
conflictingBias: ConflictingBias.updateWins,
synchronizedTables: [
database.todosTable,
],
);
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
});
group('when generating trigger statements', () {
late List<String> triggers;
setUp(() {
final generator = Sqlite3OfflineSyncTriggers(
migrator.crdtDb,
ConflictingBias.updateWins,
);
triggers = generator.generateCreateTriggerStatements(database.todosTable);
});
test('then the update trigger includes the deleted flag column.', () {
final updateStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER UPDATE ON "todos"'),
);
expect(
updateStatement,
contains('''
UNION ALL
SELECT
'$isDeletedColumnName' AS "column_name",
FALSE AS "raw_value",
NEW."__crdt_is_deleted" IS NOT OLD."__crdt_is_deleted" AS "value_changed"
'''),
);
});
});
});
} }