chore: Move the drift_offline_first to legacy in favor of Serverpod implementation (#32)

This commit is contained in:
Marcelo Mendonça Soares 2026-04-05 23:20:21 -03:00 • committed by Marcelo Soares
commit cbc472523b
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
4 changed files with 13575 additions and 284 deletions

View file

@ -0,0 +1,35 @@
import 'package:drift_offline_sync/src/database/triggers.dart';
import 'package:test/test.dart';
import '../utils/database.dart';
void main() {
group('Given a database with an OfflineSyncMigrator', () {
group('when running the migration', () {
setUp(() async {
// Insert a new row to trigger the migration.
await database.managers.todosTable.create(
(t) => t(content: 'test'),
);
});
test('then the triggers are created for every synchronized table.', () async {
final allTriggerNames = await database.getCrdtTriggers();
for (final table in database.createMigrator().synchronizedTables) {
final triggerNames = allTriggerNames
.where((name) => name.startsWith('__crdt__${table.actualTableName}__'))
.toList();
expect(triggerNames, contains('__crdt__${table.actualTableName}__insert'));
expect(triggerNames, contains('__crdt__${table.actualTableName}__update'));
expect(triggerNames, contains('__crdt__${table.actualTableName}__delete'));
}
});
});
});
// TODO: Add a test for the case where a table has no primary key.
// TODO: Add a test for the case where a table has multiple primary key columns.
// TODO: Add a test for the case where a table has only primary key columns.
}

View file

@ -0,0 +1,465 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:test/test.dart';
import '../utils/database.dart';
import '../utils/tables.dart';
void main() {
group('Given a database with an empty synchronized table', () {
group('when inserting a new row on a synchronized table', () {
const targetContent = 'test';
final targetDate = DateTime(2026, 1, 13);
late int createdRowId;
late String rowId;
setUp(() async {
createdRowId = await database.managers.todosTable.create(
(t) => t(
content: targetContent,
targetDate: Value(targetDate),
),
);
rowId = createdRowId.toString();
});
test('then the row exists in the target table.', () async {
final row = await database.managers.todosTable.getSingle();
expect(row, isNotNull);
expect(row.content, targetContent);
expect(row.targetDate, targetDate);
});
group('then the trigger is executed and the CRDT data table', () {
late String isDeletedColumnName;
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
isDeletedColumnName = migrator.crdtDb.sqlBuilder.isDeletedColumnName;
});
test('has one entry for each column of the target table.', () async {
expect(
allCrdtDataEntries.map((e) => e.columnName).toSet(),
equals(
database.todosTable.$columns.map((c) => c.$name).toSet()
..add(isDeletedColumnName),
),
);
});
test('has the raw values inserted correctly.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
expect(crdtData.unwrappedValue, targetContent);
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
expect(
targetDateCrdtData.unwrappedValue,
targetDate.toIso8600StringWithOffset(),
);
});
test('has the same HLC timestamp for each inserted column.', () async {
final targetHlc = allCrdtDataEntries.first.hlcTimestamp;
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
expect(crdtData.hlcTimestamp, equals(targetHlc));
final targetDateCrdtData = allCrdtDataEntries.getField(rowId, 'target_date');
expect(targetDateCrdtData.hlcTimestamp, equals(targetHlc));
});
});
});
});
group('Given a database with a synchronized table with a row', () {
const initialContent = 'test';
late int createdRowId;
late Hlc createdHlc;
late String rowId;
setUp(() async {
createdRowId = await database.managers.todosTable.create(
(t) => t(content: initialContent),
);
rowId = createdRowId.toString();
final migrator = database.createMigrator();
final allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
createdHlc = allCrdtDataEntries.first.hlcTimestamp;
});
group('when updating the existing row with a different value', () {
const targetContent = 'test2';
final targetDate = DateTime(2026, 1, 13);
const updatedColumnNames = ['content', 'target_date'];
setUp(() async {
await database.managers.todosTable.update(
(t) => t(
id: Value(RowId(createdRowId)),
content: const Value(targetContent),
targetDate: Value(targetDate),
),
);
});
test('then the row has been updated in the target table.', () async {
final row = await database.managers.todosTable.getSingle();
expect(row, isNotNull);
expect(row.content, targetContent);
expect(row.targetDate, targetDate);
});
group(
'then the trigger is executed and the updated row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
});
test('has the raw values updated correctly.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, 'content');
expect(crdtData.unwrappedValue, targetContent);
final targetDateCrdtData = allCrdtDataEntries.getField(
rowId,
'target_date',
);
expect(
targetDateCrdtData.unwrappedValue,
targetDate.toIso8600StringWithOffset(),
);
});
test('has the same HLC timestamp for each updated column.', () async {
final updatedEntriesHlc = allCrdtDataEntries
.where((entry) => entry.rowId == rowId)
.where((entry) => updatedColumnNames.contains(entry.columnName))
.map((entry) => entry.hlcTimestamp);
expect(updatedEntriesHlc.toSet(), hasLength(1));
});
test('has the updated HLC timestamp greater than the created.', () async {
final updatedEntry = allCrdtDataEntries.getField(rowId, 'content');
expect(updatedEntry.hlcTimestamp, greaterThan(createdHlc));
});
test('has not changed the HLC timestamp for non-updated columns.', () async {
final otherEntriesHlc = allCrdtDataEntries
.where((entry) => entry.rowId == rowId)
.where((entry) => !updatedColumnNames.contains(entry.columnName))
.map((entry) => entry.hlcTimestamp);
expect(otherEntriesHlc, everyElement(equals(createdHlc)));
});
},
);
});
group('when updating the existing row with the same value', () {
setUp(() async {
await database.managers.todosTable.update(
(t) => t(
id: Value(RowId(createdRowId)),
content: const Value(initialContent),
),
);
});
group(
'then the trigger is executed and the updated row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
});
test('has not changed the HLC timestamp for the updated column.', () async {
final updatedEntry = allCrdtDataEntries.getField(rowId, 'content');
expect(updatedEntry.hlcTimestamp, equals(createdHlc));
});
},
);
});
group('when deleting the existing row', () {
setUp(() async {
await database.managers.todosTable
.filter((t) => t.id.equals(RowId(createdRowId)))
.delete();
});
test('then the row has been deleted in the target table.', () async {
final row = await database.managers.todosTable.getSingleOrNull();
expect(row, isNull);
});
group(
'then the trigger is executed and the deleted row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
late String isDeletedColumnName;
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 true.', () async {
final crdtData = allCrdtDataEntries.getField(rowId, isDeletedColumnName);
expect(crdtData.unwrappedValue, 1);
});
test('has the deleted HLC timestamp greater than the created.', () async {
final deletedEntry = allCrdtDataEntries.getField(
rowId,
isDeletedColumnName,
);
expect(deletedEntry.hlcTimestamp, greaterThan(createdHlc));
});
},
);
});
});
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)),
);
},
);
},
);
});
});
group('Given a database with an empty synchronized table and triggers disabled', () {
group('when inserting a new row on a synchronized table', () {
const targetContent = 'test';
final targetDate = DateTime(2026, 1, 13);
setUp(() async {
final migrator = database.createMigrator();
await migrator.crdtDb.disableTriggers();
await database.managers.todosTable.create(
(t) => t(
content: targetContent,
targetDate: Value(targetDate),
),
);
});
test('then the row exists in the target table.', () async {
final row = await database.managers.todosTable.getSingle();
expect(row, isNotNull);
expect(row.content, targetContent);
expect(row.targetDate, targetDate);
});
group('then the trigger is not executed and the CRDT data table', () {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
});
test('has no entries.', () async {
expect(allCrdtDataEntries, isEmpty);
});
});
});
});
group(
'Given a database with a synchronized table with a row and triggers disabled',
() {
late int createdRowId;
setUp(() async {
createdRowId = await database.managers.todosTable.create(
(t) => t(content: 'test'),
);
final migrator = database.createMigrator();
await migrator.crdtDb.managers.crdtDataTable.delete();
await migrator.crdtDb.disableTriggers();
});
group('when updating the existing row with a different value', () {
const targetContent = 'test2';
final targetDate = DateTime(2026, 1, 13);
setUp(() async {
await database.managers.todosTable.update(
(t) => t(
id: Value(RowId(createdRowId)),
content: const Value('test2'),
targetDate: Value(DateTime(2026, 1, 13)),
),
);
});
test('then the row has been updated in the target table.', () async {
final row = await database.managers.todosTable.getSingle();
expect(row, isNotNull);
expect(row.content, targetContent);
expect(row.targetDate, targetDate);
});
group(
'then the trigger is not executed and the updated row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
});
test('has no entries.', () async {
expect(allCrdtDataEntries, isEmpty);
});
},
);
});
group('when deleting the existing row', () {
setUp(() async {
await database.managers.todosTable
.filter((t) => t.id.equals(RowId(createdRowId)))
.delete();
});
test('then the row has been deleted in the target table.', () async {
final row = await database.managers.todosTable.getSingleOrNull();
expect(row, isNull);
});
group(
'then the trigger is not executed and the deleted row in the CRDT data table',
() {
late List<CrdtDataEntry> allCrdtDataEntries;
setUp(() async {
final migrator = database.createMigrator();
allCrdtDataEntries = await migrator.crdtDb.managers.crdtDataTable.get();
});
test('has no entries.', () async {
expect(allCrdtDataEntries, isEmpty);
});
},
);
});
},
);
}
extension on Iterable<CrdtDataEntry> {
CrdtDataEntry getField(String rowId, String fieldName) {
return firstWhere((entry) => entry.rowId == rowId && entry.columnName == fieldName);
}
}
extension on DateTime {
String toIso8600StringWithOffset() {
final offset = toLocal().timeZoneOffset.inHours;
final sign = offset >= 0 ? '+' : '-';
return '${toIso8601String()} $sign${offset.abs().toString().padLeft(2, '0')}:00';
}
}

View file

@ -0,0 +1,395 @@
import 'package:drift_offline_sync/src/database/triggers.dart';
import 'package:drift_offline_sync/src/migrator.dart';
import 'package:test/test.dart';
import '../utils/database.dart';
import '../utils/user.dart';
void main() {
group('Given a SQLite3 database with a table', () {
late OfflineSyncMigrator migrator;
setUp(() {
migrator = database.createMigrator();
});
group('when generating trigger statements', () {
late List<String> triggers;
setUp(() {
final generator = Sqlite3OfflineSyncTriggers(migrator.crdtDb);
triggers = generator.generateCreateTriggerStatements(database.todosTable);
});
test('then one trigger is created for each operation.', () {
expect(triggers, hasLength(3));
expect(triggers, anyElement(contains('AFTER INSERT ON "todos"')));
expect(triggers, anyElement(contains('AFTER UPDATE ON "todos"')));
expect(triggers, anyElement(contains('AFTER DELETE ON "todos"')));
});
test('then the insert trigger statement is correct.', () {
final insertStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER INSERT ON "todos"'),
);
expect(
insertStatement,
'''\
CREATE TRIGGER IF NOT EXISTS "__crdt__todos__insert"
AFTER INSERT ON "todos"
WHEN (
SELECT MAX("crdt_triggers_on")
FROM "__crdt_control"
WHERE ("user_id" = '${migrator.userId}')
AND ("node_id" = '${migrator.nodeId}')
) = TRUE
BEGIN
INSERT INTO "${Sqlite3OfflineSyncTriggers.hlcCanonicalViewName}" ("user_id")
VALUES ('${migrator.userId}');
INSERT INTO "__crdt_data" (
"user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
)
SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name",
"column_name",
NEW."id" AS "row_id",
(
SELECT (printf('%015d', "last_timestamp") || '-' || printf('%04X', "counter") || '-' || '${migrator.nodeId}')
FROM "${migrator.crdtDb.sqlBuilder.hlcStateTableName}"
WHERE "user_id" = '${migrator.userId}'
) AS "hlc_timestamp",
raw_value
FROM (
SELECT
'id' AS "column_name",
NEW."id" AS "raw_value"
UNION ALL
SELECT
'title' AS "column_name",
NEW."title" AS "raw_value"
UNION ALL
SELECT
'content' AS "column_name",
NEW."content" AS "raw_value"
UNION ALL
SELECT
'target_date' AS "column_name",
NEW."target_date" AS "raw_value"
UNION ALL
SELECT
'category' AS "column_name",
NEW."category" AS "raw_value"
UNION ALL
SELECT
'status' AS "column_name",
NEW."status" AS "raw_value"
UNION ALL
SELECT
'__crdt_is_deleted' AS "column_name",
FALSE AS "raw_value"
);
END;''',
);
});
test('then the update trigger statement is correct.', () {
final updateStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER UPDATE ON "todos"'),
);
expect(
updateStatement,
'''\
CREATE TRIGGER IF NOT EXISTS "__crdt__todos__update"
AFTER UPDATE ON "todos"
WHEN (
SELECT MAX("crdt_triggers_on")
FROM "__crdt_control"
WHERE ("user_id" = '${migrator.userId}')
AND ("node_id" = '${migrator.nodeId}')
) = TRUE
BEGIN
INSERT INTO "${Sqlite3OfflineSyncTriggers.hlcCanonicalViewName}" ("user_id")
VALUES ('${migrator.userId}');
INSERT INTO "__crdt_data" (
"user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
)
SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name",
"column_name",
OLD."id" AS "row_id",
(
SELECT (printf('%015d', "last_timestamp") || '-' || printf('%04X', "counter") || '-' || '${migrator.nodeId}')
FROM "${migrator.crdtDb.sqlBuilder.hlcStateTableName}"
WHERE "user_id" = '${migrator.userId}'
) AS "hlc_timestamp",
raw_value
FROM (
SELECT
'id' AS "column_name",
NEW."id" AS "raw_value",
NEW."id" IS NOT OLD."id" AS "value_changed"
UNION ALL
SELECT
'title' AS "column_name",
NEW."title" AS "raw_value",
NEW."title" IS NOT OLD."title" AS "value_changed"
UNION ALL
SELECT
'content' AS "column_name",
NEW."content" AS "raw_value",
NEW."content" IS NOT OLD."content" AS "value_changed"
UNION ALL
SELECT
'target_date' AS "column_name",
NEW."target_date" AS "raw_value",
NEW."target_date" IS NOT OLD."target_date" AS "value_changed"
UNION ALL
SELECT
'category' AS "column_name",
NEW."category" AS "raw_value",
NEW."category" IS NOT OLD."category" AS "value_changed"
UNION ALL
SELECT
'status' AS "column_name",
NEW."status" AS "raw_value",
NEW."status" IS NOT OLD."status" AS "value_changed"
)
WHERE ("value_changed" = TRUE);
END;''',
);
});
test('then the delete trigger statement is correct.', () {
final deleteStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER DELETE ON "todos"'),
);
expect(
deleteStatement,
'''\
CREATE TRIGGER IF NOT EXISTS "__crdt__todos__delete"
AFTER DELETE ON "todos"
WHEN (
SELECT MAX("crdt_triggers_on")
FROM "__crdt_control"
WHERE ("user_id" = '${migrator.userId}')
AND ("node_id" = '${migrator.nodeId}')
) = TRUE
BEGIN
INSERT INTO "${Sqlite3OfflineSyncTriggers.hlcCanonicalViewName}" ("user_id")
VALUES ('${migrator.userId}');
INSERT INTO "__crdt_data" (
"user_id", "table_name", "column_name", "row_id", "hlc_timestamp", "raw_value"
)
SELECT
'${migrator.userId}' AS "user_id",
'todos' AS "table_name",
"column_name",
OLD."id" AS "row_id",
(
SELECT (printf('%015d', "last_timestamp") || '-' || printf('%04X', "counter") || '-' || '${migrator.nodeId}')
FROM "${migrator.crdtDb.sqlBuilder.hlcStateTableName}"
WHERE "user_id" = '${migrator.userId}'
) AS "hlc_timestamp",
raw_value
FROM (
SELECT
'__crdt_is_deleted' AS "column_name",
TRUE AS "raw_value"
);
END;''',
);
});
});
});
test('Given a SQLite3 database with a table that has only primary key columns '
'when generating trigger statements '
'then the triggers are generated with success.', () {
final migrator = database.createMigrator();
final generator = Sqlite3OfflineSyncTriggers(migrator.crdtDb);
final triggers = generator.generateCreateTriggerStatements(database.pureDefaults);
expect(triggers, hasLength(3));
});
group(
'Given a SQLite3 database with a table that has multiple primary key columns',
() {
late OfflineSyncMigrator migrator;
setUp(() {
migrator = database.createMigrator();
});
group('when generating trigger statements', () {
late List<String> triggers;
setUp(() {
final generator = Sqlite3OfflineSyncTriggers(migrator.crdtDb);
triggers = generator.generateCreateTriggerStatements(database.sharedTodos);
});
test('then the triggers are generated with success.', () {
expect(triggers, hasLength(3));
});
group('then the insert trigger', () {
late final insertStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER INSERT ON "shared_todos"'),
);
test(
'has the "row_id" as a "CONCAT_WS" of all primary key columns.',
() {
expect(
insertStatement,
contains("CONCAT_WS('||', NEW.\"todo\", NEW.\"user\") AS \"row_id\""),
);
},
);
test('has the primary key columns also present as individual entries.', () {
expect(insertStatement, contains("'todo' AS \"column_name\""));
expect(insertStatement, contains("'user' AS \"column_name\""));
});
});
group('then the update trigger', () {
late final updateStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER UPDATE ON "shared_todos"'),
);
test('has the "row_id" as a "CONCAT_WS" of all primary key columns.', () {
expect(
updateStatement,
contains("CONCAT_WS('||', OLD.\"todo\", OLD.\"user\") AS \"row_id\""),
);
});
test('has the primary key columns also present as individual entries.', () {
expect(updateStatement, contains("'todo' AS \"column_name\""));
expect(updateStatement, contains("'user' AS \"column_name\""));
});
});
test(
'then the delete trigger has the "row_id" as a "CONCAT_WS" of all primary key columns.',
() {
final deleteStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER DELETE ON "shared_todos"'),
);
expect(
deleteStatement,
contains("CONCAT_WS('||', OLD.\"todo\", OLD.\"user\") AS \"row_id\""),
);
},
);
});
},
);
group('Given a SQLite3 database with a table that has generated columns', () {
late OfflineSyncMigrator migrator;
setUp(() {
migrator = database.createMigrator();
});
group('when generating trigger statements', () {
late List<String> triggers;
setUp(() {
final generator = Sqlite3OfflineSyncTriggers(migrator.crdtDb);
triggers = generator.generateCreateTriggerStatements(database.categories);
// Pin the column name to avoid having the `isNot(contains())` tests passing
// with no real test if the column is removed or renamed.
expect(
database.categories.descriptionInUpperCase.$name,
'description_in_upper_case',
);
});
test('then the insert trigger excludes generated columns.', () {
final insertStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER INSERT ON "categories"'),
);
expect(insertStatement, isNot(contains("'description_in_upper_case'")));
});
test('then the update trigger excludes generated columns.', () {
final updateStatement = triggers.firstWhere(
(trigger) => trigger.contains('AFTER UPDATE ON "categories"'),
);
expect(updateStatement, isNot(contains("'description_in_upper_case'")));
});
});
});
// 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"
'''),
);
});
});
});
}

View file

@ -0,0 +1,165 @@
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:drift_offline_sync/src/database/triggers.dart';
import 'package:test/test.dart';
import '../utils/crdt_context.dart';
import '../utils/database.dart';
import '../utils/user.dart';
void main() {
group('Given a database with in-SQLite HLC view', () {
test('when reading from the HLC state table for the first time '
'then it returns null.', () async {
final state = await _readHlcState();
expect(state, isNull);
});
test('when inserting into the canonical view '
'then it contains a valid HLC timestamp.', () async {
await _insertIntoCanonicalView();
final state = await _readHlcState();
final expectedHlc = Hlc(
DateTime.now().toUtc().subtract(const Duration(milliseconds: 100)),
0,
testNodeId,
);
expect(state, isNotNull);
expect(state!.toHlc(testNodeId), greaterThan(expectedHlc));
});
});
group('Given HLC state with lower canonical time than wall time', () {
late int previousTimestamp;
setUp(() async {
previousTimestamp = DateTime.now()
.toUtc()
.subtract(const Duration(seconds: 1))
.millisecondsSinceEpoch;
await _seedHlcState(previousTimestamp, 17);
});
test('when inserting into the canonical view '
'then last timestamp becomes wall time and counter resets.', () async {
await _insertIntoCanonicalView();
final state = await _readHlcState();
expect(state, isNotNull);
expect(state!.lastTimestamp, greaterThan(previousTimestamp));
expect(state.counter, 0);
});
});
// NOTE: It is not possible to test the scenario in which the canonical time is equal
// to the wall time since there is no way to override the `unixepoch('now','subsec')`
// return for the tests as we do in the `hlc_increment_test.dart` file.
group('Given HLC state with higher canonical time than wall time', () {
late int previousTimestamp;
setUp(() async {
previousTimestamp = DateTime.now()
.toUtc()
.add(const Duration(seconds: 5))
.millisecondsSinceEpoch;
await _seedHlcState(previousTimestamp, 17);
});
test('when inserting into the canonical view '
'then counter increments and last timestamp is unchanged.', () async {
await _insertIntoCanonicalView();
final state = await _readHlcState();
expect(state, isNotNull);
expect(state!.lastTimestamp, equals(previousTimestamp));
expect(state.counter, 18);
});
});
group(
'Given HLC state with canonical time more than one minute ahead of wall time',
() {
setUp(() async {
final futureTimestamp = DateTime.now()
.toUtc()
.add(const Duration(minutes: 1, seconds: 5))
.millisecondsSinceEpoch;
await _seedHlcState(futureTimestamp, 17);
});
test('when inserting into the canonical view '
'then an exception is thrown with clock drift on message.', () async {
expect(
_insertIntoCanonicalView,
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('Clock drift exceeds 1 minute maximum.'),
),
),
);
});
},
);
group(
'Given HLC state with counter at maximum and canonical time ahead of wall time',
() {
setUp(() async {
final futureTimestamp = DateTime.now()
.toUtc()
.add(const Duration(seconds: 5))
.millisecondsSinceEpoch;
await _seedHlcState(futureTimestamp, 0xFFFF);
});
test(
'when inserting into the canonical view '
'then an exception is thrown with timestamp counter overflow message.',
() async {
expect(
_insertIntoCanonicalView,
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('Timestamp counter overflow: 0xFFFF.'),
),
),
);
},
);
},
);
}
Future<void> _insertIntoCanonicalView() => database.customStatement(
'INSERT INTO "${Sqlite3OfflineSyncTriggers.hlcCanonicalViewName}" ("user_id") '
"VALUES ('$testUserId')",
);
Future<CrdtHlcEntry> _seedHlcState(int lastTimestamp, int counter) {
final (_, crdtDb, _) = database.crdtContext;
return crdtDb.managers.crdtHlcStateTable.createReturning(
(t) => t(
userId: testUserId,
lastTimestamp: lastTimestamp,
counter: counter,
),
);
}
Future<CrdtHlcEntry?> _readHlcState() async {
final (_, crdtDb, _) = database.crdtContext;
return crdtDb.managers.crdtHlcStateTable
.filter((o) => o.userId.equals(testUserId))
.getSingleOrNull();
}