chore: Reorganize the package
This commit is contained in:
parent
d813653587
commit
a93f021a7b
21 changed files with 34 additions and 34 deletions
|
|
@ -3,13 +3,13 @@
|
|||
/// More docs go here.
|
||||
library;
|
||||
|
||||
export 'src/crdt.dart';
|
||||
export 'src/crdt/extensions/convert.dart';
|
||||
export 'src/crdt/extensions/entry.dart';
|
||||
export 'src/crdt/extensions/extract.dart';
|
||||
export 'src/crdt/extensions/sync.dart';
|
||||
export 'src/crdt_base.dart';
|
||||
export 'src/database.dart';
|
||||
export 'src/crdt/base.dart';
|
||||
export 'src/crdt/crdt.dart';
|
||||
export 'src/database/database.dart';
|
||||
export 'src/database/extensions/convert.dart';
|
||||
export 'src/database/extensions/entry.dart';
|
||||
export 'src/database/extensions/extract.dart';
|
||||
export 'src/database/extensions/sync.dart';
|
||||
export 'src/database/setup.dart';
|
||||
export 'src/hlc/converter.dart';
|
||||
export 'src/migrator.dart';
|
||||
export 'src/setup.dart';
|
||||
|
|
|
|||
106
lib/src/crdt/base.dart
Normal file
106
lib/src/crdt/base.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:crdt/crdt.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../database/database.dart';
|
||||
import '../hlc/stateful.dart';
|
||||
|
||||
/// Base class for CRDT implementations.
|
||||
///
|
||||
/// This class provides the basic functionality for a CRDT implementation.
|
||||
/// It is not meant to be used directly, but rather to be extended by concrete
|
||||
/// implementations.
|
||||
abstract class CrdtBase {
|
||||
/// Creates a new instance of [CrdtBase].
|
||||
CrdtBase({required this.userId, required this.nodeId});
|
||||
|
||||
/// The user ID for the CRDT system.
|
||||
final String userId;
|
||||
|
||||
/// The node ID of the CRDT.
|
||||
final String nodeId;
|
||||
|
||||
/// The stateful canonical time of the CRDT.
|
||||
late final StatefulHlc _statefulCanonicalTime = StatefulHlc.cached(userId, nodeId);
|
||||
|
||||
/// The canonical time of the CRDT.
|
||||
Hlc get canonicalTime => _statefulCanonicalTime.lastHlc;
|
||||
|
||||
/// Returns the last modified timestamp, optionally filtering for or against a
|
||||
/// specific node id. Useful to get "modified since" timestamps for executing
|
||||
/// synchronization.
|
||||
Future<Hlc?> getLastModified({
|
||||
String? onlyNodeId,
|
||||
String? exceptNodeId,
|
||||
});
|
||||
|
||||
/// Get a list of [CrdtDataEntry] with the changes to be merged.
|
||||
///
|
||||
/// Set the filtering parameters to to generate subsets:
|
||||
/// [onlyNodeId] only records set by the specified node.
|
||||
/// [exceptNodeId] only records not set by the specified node.
|
||||
/// [modifiedAfter] records modified after the specified [Hlc].
|
||||
Future<Iterable<CrdtDataEntry>> getChangeset({
|
||||
String? onlyNodeId,
|
||||
String? exceptNodeId,
|
||||
Hlc? modifiedAfter,
|
||||
});
|
||||
|
||||
/// Save the [changeset] to the database.
|
||||
///
|
||||
/// When saving, must be careful to only override values that are older than
|
||||
/// the [Hlc] of the changeset entry. If there are any guardrails to be
|
||||
/// applied to the saved data, they should be applied here.
|
||||
///
|
||||
/// The [receivedHlcs] is a list of HLCs, one for each external node. It must
|
||||
/// be saved separately since it will be used to request updates from external
|
||||
/// nodes on next syncs.
|
||||
///
|
||||
/// It is recommended to use a transaction to ensure the integrity of the
|
||||
/// data and allow for rollback in case of errors.
|
||||
///
|
||||
/// Note that this method is only meant to be used internally by the [merge]
|
||||
/// method and should not be called directly.
|
||||
@protected
|
||||
Future<void> saveChangeset(
|
||||
Iterable<CrdtDataEntry> changeset,
|
||||
Iterable<Hlc> receivedHlcs,
|
||||
);
|
||||
|
||||
/// Merge [changeset] with the local dataset.
|
||||
Future<void> merge(Iterable<CrdtDataEntry> changeset) async {
|
||||
if (changeset.isEmpty) return;
|
||||
|
||||
final receivedHlcs = _extractLastReceivedHlcs(changeset);
|
||||
await saveChangeset(changeset, receivedHlcs);
|
||||
|
||||
if (receivedHlcs.isEmpty) return;
|
||||
_statefulCanonicalTime.merge(receivedHlcs.max);
|
||||
}
|
||||
|
||||
/// Extracts the last received HLCs from the changeset.
|
||||
Iterable<Hlc> _extractLastReceivedHlcs(Iterable<CrdtDataEntry> changeset) {
|
||||
final receivedHlcs = <String, Hlc>{};
|
||||
for (final entry in changeset) {
|
||||
if (entry.hlcTimestamp.nodeId == nodeId) continue;
|
||||
receivedHlcs.addOrUpdate(entry.hlcTimestamp);
|
||||
}
|
||||
return receivedHlcs.values;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<Hlc> {
|
||||
Hlc get max => reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
extension on Map<String, Hlc> {
|
||||
void addOrUpdate(Hlc hlc) {
|
||||
final registeredHlc = this[hlc.nodeId];
|
||||
if (registeredHlc == null) {
|
||||
this[hlc.nodeId] = hlc;
|
||||
} else if (registeredHlc < hlc) {
|
||||
this[hlc.nodeId] = hlc;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
lib/src/crdt/crdt.dart
Normal file
135
lib/src/crdt/crdt.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:crdt/crdt.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/database.dart';
|
||||
import 'base.dart';
|
||||
|
||||
/// A CRDT implementation for offline-first synchronization.
|
||||
class OfflineSyncCrdt extends CrdtBase {
|
||||
/// Creates a new instance of [OfflineSyncCrdt].
|
||||
///
|
||||
/// The [nodeId] must be unique across the system.
|
||||
OfflineSyncCrdt(this._db, {required super.userId, required super.nodeId});
|
||||
|
||||
final CrdtDatabase _db;
|
||||
|
||||
@override
|
||||
// TODO: Remove this method lately if it is not needed - as it seems it will.
|
||||
Future<Hlc?> getLastModified({String? onlyNodeId, String? exceptNodeId}) async {
|
||||
if (onlyNodeId != null && exceptNodeId != null) {
|
||||
throw ArgumentError('Only one of onlyNodeId or exceptNodeId can be provided.');
|
||||
}
|
||||
|
||||
// NOTE: Although `max` has a better performance for the general use case,
|
||||
// when the column is indexed, `order by` with `limit 1` will be just as fast.
|
||||
// The plus of using the order by approach is the native deserialization of
|
||||
// the Hlc object by Drift.
|
||||
final rowWithMaxLastSyncHlc = await _db.managers.crdtDataTable
|
||||
.filter((o) {
|
||||
if (onlyNodeId != null) {
|
||||
return o.hlcTimestamp.column.contains(onlyNodeId);
|
||||
}
|
||||
if (exceptNodeId != null) {
|
||||
return o.hlcTimestamp.column.contains(exceptNodeId).not();
|
||||
}
|
||||
return const Constant(true);
|
||||
})
|
||||
.orderBy((o) => o.hlcTimestamp.desc())
|
||||
.limit(1)
|
||||
.getSingleOrNull();
|
||||
|
||||
return rowWithMaxLastSyncHlc?.hlcTimestamp;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Iterable<CrdtDataEntry>> getChangeset({
|
||||
String? onlyNodeId,
|
||||
String? exceptNodeId,
|
||||
Hlc? modifiedAfter,
|
||||
}) async {
|
||||
assert(onlyNodeId == null || exceptNodeId == null);
|
||||
|
||||
// Modified times use the local node id
|
||||
modifiedAfter = modifiedAfter?.apply(nodeId: nodeId);
|
||||
|
||||
final query = _db.managers.crdtDataTable.filter((o) {
|
||||
Expression<bool> condition = const Constant(true);
|
||||
|
||||
if (onlyNodeId != null) {
|
||||
condition &= o.hlcTimestamp.column.contains(onlyNodeId);
|
||||
}
|
||||
if (exceptNodeId != null) {
|
||||
condition &= o.hlcTimestamp.column.contains(exceptNodeId).not();
|
||||
}
|
||||
if (modifiedAfter != null) {
|
||||
condition &= o.hlcTimestamp.column.isBiggerThanValue(modifiedAfter.toString());
|
||||
}
|
||||
return condition;
|
||||
});
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveChangeset(
|
||||
Iterable<CrdtDataEntry> changeset,
|
||||
Iterable<Hlc> receivedHlcs,
|
||||
) async {
|
||||
await _db.transaction(() async {
|
||||
await _saveChangeset(changeset);
|
||||
await _applyCompensationRules();
|
||||
await _saveMergedHlcs(receivedHlcs);
|
||||
});
|
||||
}
|
||||
|
||||
/// Save the [changeset] to the database.
|
||||
///
|
||||
/// Should be called within a transaction. Must ensure that only newer values
|
||||
/// are saved to the database.
|
||||
Future<void> _saveChangeset(Iterable<CrdtDataEntry> changeset) async {
|
||||
return _db.managers.crdtDataTable.bulkCreate(
|
||||
(_) => changeset.map((e) => e.toCompanion(false)),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
onConflict: DoUpdate.withExcluded(
|
||||
(old, excluded) => CrdtDataTableCompanion.custom(
|
||||
rawValue: excluded.rawValue,
|
||||
hlcTimestamp: excluded.hlcTimestamp,
|
||||
),
|
||||
where: (old, excluded) => old.hlcTimestamp.isSmallerThan(excluded.hlcTimestamp),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies compensation rules to the CRDT data table to preserve invariants.
|
||||
///
|
||||
/// If the any invariant fails to be preserved, a flag will be set on the
|
||||
/// control table to prevent further merges until the issue is resolved by
|
||||
/// the server and a repair merge is pushed to this node.
|
||||
Future<void> _applyCompensationRules() async {
|
||||
// TODO: Implement this.
|
||||
}
|
||||
|
||||
/// Save the [mergedHlcs] to the database.
|
||||
///
|
||||
/// Should be called within a transaction after the changeset has been saved
|
||||
/// and properly compensated.
|
||||
Future<void> _saveMergedHlcs(Iterable<Hlc> mergedHlcs) async {
|
||||
return _db.managers.crdtMergeHlcTable.bulkCreate(
|
||||
(t) => mergedHlcs.map(
|
||||
(e) => t(
|
||||
userId: userId,
|
||||
nodeId: e.nodeId,
|
||||
lastReceivedHlc: e,
|
||||
),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
onConflict: DoUpdate.withExcluded(
|
||||
(old, excluded) => CrdtMergeHlcTableCompanion.custom(
|
||||
lastReceivedHlc: excluded.lastReceivedHlc,
|
||||
),
|
||||
where: (old, excluded) =>
|
||||
old.lastReceivedHlc.isSmallerThan(excluded.lastReceivedHlc),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
lib/src/database/database.dart
Normal file
73
lib/src/database/database.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../utils/sql_builder.dart';
|
||||
import 'tables/compensation.dart';
|
||||
import 'tables/control.dart';
|
||||
import 'tables/data.dart';
|
||||
import 'tables/merge.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
/// The main database class for CRDT synchronization.
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
CrdtDataTable,
|
||||
CrdtControlTable,
|
||||
CrdtCompensationTable,
|
||||
CrdtMergeHlcTable,
|
||||
],
|
||||
)
|
||||
class CrdtDatabase extends _$CrdtDatabase {
|
||||
/// Creates a new instance of [CrdtDatabase] with the provided [executor].
|
||||
CrdtDatabase(
|
||||
super.e, {
|
||||
required this.userId,
|
||||
required this.nodeId,
|
||||
required this.synchronizedTables,
|
||||
});
|
||||
|
||||
/// The user ID for the CRDT system.
|
||||
final String userId;
|
||||
|
||||
/// The node ID for the CRDT system.
|
||||
final String nodeId;
|
||||
|
||||
/// Tables to be synchronized with CRDT.
|
||||
final List<TableInfo> synchronizedTables;
|
||||
|
||||
/// The SQL builder for the CRDT data table.
|
||||
late final sqlBuilder = CrdtDataSqlBuilder(this);
|
||||
|
||||
/// Whether the database is using the SQLite or Postgres dialect.
|
||||
bool get isPostgres => executor.dialect == SqlDialect.postgres;
|
||||
|
||||
/// Not actually used, but required by the generated code. Will be ignored because
|
||||
/// the [CrdtDatabase] always receives an already opened executor. No migrations
|
||||
/// should ever be run on this database to avoid messing with the user schema.
|
||||
@override
|
||||
int schemaVersion = 1;
|
||||
}
|
||||
|
||||
/// Extension methods for the [List<TableInfo>] class to find a synchronized table.
|
||||
extension FindSynchronizedTable on List<TableInfo> {
|
||||
/// Gets the table names of the synchronized tables.
|
||||
Iterable<String> get tableNames => map((t) => t.actualTableName);
|
||||
|
||||
/// Gets the synchronized table information for the given table name.
|
||||
///
|
||||
/// If the table is not found, an [ArgumentError] is thrown.
|
||||
TableInfo find<T extends Insertable>([String? tableName]) {
|
||||
if (tableName == null && T == dynamic) {
|
||||
throw ArgumentError('Table name is required when T is dynamic.');
|
||||
}
|
||||
return firstWhere(
|
||||
(t) =>
|
||||
(tableName == null && t is TableInfo<Table, T>) ||
|
||||
t.actualTableName == tableName,
|
||||
orElse: () => throw ArgumentError(
|
||||
'Table ${tableName != null ? '"$tableName"' : 'for type "$T"'} not '
|
||||
'found in synchronized tables.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/src/database/extensions/convert.dart
Normal file
59
lib/src/database/extensions/convert.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../hlc/converter.dart';
|
||||
import '../database.dart';
|
||||
|
||||
/// Extensions for [CrdtDatabase] to convert data to [CrdtDataEntry].
|
||||
extension CrdtDatabaseConvertExtensions on CrdtDatabase {
|
||||
/// Converts the [Insertable] object to a list of [CrdtDataEntry].
|
||||
///
|
||||
/// Note that the generated entries will have the same HLC timestamp for all
|
||||
/// columns and will overwrite individual column updates if newer.
|
||||
///
|
||||
/// Returns a list of [CrdtDataEntry].
|
||||
Iterable<CrdtDataEntry> convertToCrdtDataEntry<T extends Insertable>(
|
||||
T data,
|
||||
Hlc hlcTimestamp,
|
||||
) {
|
||||
final json = data.toJsonForDatabase(_generationContext);
|
||||
final tableInfo = synchronizedTables.whereType<TableInfo<dynamic, T>>().single;
|
||||
final rowIdValues = [for (final c in tableInfo.$primaryKey) json[c.$name]];
|
||||
|
||||
return tableInfo.$columns.map(
|
||||
(c) => CrdtDataEntry(
|
||||
userId: userId,
|
||||
tblName: tableInfo.actualTableName,
|
||||
columnName: c.$name,
|
||||
rowId: rowIdValues.join(sqlBuilder.rowIdSeparator),
|
||||
rawValue: json[c.$name]?.toDriftAny(),
|
||||
hlcTimestamp: hlcTimestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets a [GenerationContext] for the database.
|
||||
GenerationContext get _generationContext => GenerationContext.fromDb(this);
|
||||
}
|
||||
|
||||
extension on Insertable {
|
||||
Map<String, Object?> toJsonForDatabase(GenerationContext context) {
|
||||
final expressions = toColumns(false);
|
||||
return {
|
||||
for (final e in expressions.entries) e.key: e.value.extractValue(context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension on Expression {
|
||||
Object? extractValue(GenerationContext context) {
|
||||
final exp = this;
|
||||
if (exp is! Variable) {
|
||||
throw ArgumentError.value(exp, 'exp', 'Expected a Variable');
|
||||
}
|
||||
return exp.mapToSimpleValue(context);
|
||||
}
|
||||
}
|
||||
|
||||
extension on Object {
|
||||
DriftAny? toDriftAny() => DriftAny(this);
|
||||
}
|
||||
25
lib/src/database/extensions/entry.dart
Normal file
25
lib/src/database/extensions/entry.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import '../../hlc/converter.dart';
|
||||
import '../database.dart';
|
||||
|
||||
/// Extensions for the [CrdtDataEntry] class.
|
||||
extension CrdtDataEntryUnwrapExtensions on CrdtDataEntry {
|
||||
/// Returns the unwrapped raw value, if any.
|
||||
Object? get unwrappedValue => rawValue?.rawSqlValue;
|
||||
}
|
||||
|
||||
/// Extensions for an [Iterable] of [CrdtDataEntry].
|
||||
extension CrdtDataEntryExtensions on Iterable<CrdtDataEntry> {
|
||||
/// Gets the affected tables.
|
||||
Iterable<String> get affectedTables => map((e) => e.tblName).toSet();
|
||||
|
||||
/// Gets the affected row IDs.
|
||||
Iterable<String> get affectedRowIds => map((e) => e.rowId).toSet();
|
||||
|
||||
/// Gets the entries for a given table.
|
||||
Iterable<CrdtDataEntry> forTable(String tableName) =>
|
||||
where((e) => e.tblName == tableName);
|
||||
|
||||
/// Returns the entries with the HLC replaced by the [hlcTimestamp].
|
||||
Iterable<CrdtDataEntry> withHlc(Hlc hlcTimestamp) =>
|
||||
map((e) => e.copyWith(hlcTimestamp: hlcTimestamp));
|
||||
}
|
||||
52
lib/src/database/extensions/extract.dart
Normal file
52
lib/src/database/extensions/extract.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database.dart';
|
||||
|
||||
/// Extensions for [CrdtDatabase] to extract data from the CRDT data table.
|
||||
extension CrdtDatabaseExtractExtensions on CrdtDatabase {
|
||||
/// Gets a single data entry from the CRDT data table.
|
||||
Future<T?> getSingleFromCrdtData<T extends Insertable>(String rowId) async {
|
||||
final data = await getFromCrdtData<T>([rowId]);
|
||||
return data.firstOrNull;
|
||||
}
|
||||
|
||||
/// Gets a list of data entries from the CRDT data table.
|
||||
Future<Iterable<T>> getFromCrdtData<T extends Insertable>([
|
||||
Iterable<String>? rowIds,
|
||||
]) async {
|
||||
final tableInfo = synchronizedTables.find<T>();
|
||||
final tableName = tableInfo.actualTableName;
|
||||
final crdtDataEntries = await managers.crdtDataTable.filter(
|
||||
(o) {
|
||||
var condition = o.tblName.equals(tableName);
|
||||
if (rowIds != null) {
|
||||
condition &= o.rowId.isIn(rowIds);
|
||||
}
|
||||
return condition;
|
||||
},
|
||||
).get();
|
||||
|
||||
return tableInfo.fromCrdtDataEntries<T>(crdtDataEntries);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions for [TableInfo] to convert [CrdtDataEntry] to [Insertable].
|
||||
extension ConvertCrdtDataEntry on TableInfo {
|
||||
/// Converts [CrdtDataEntry] to [Insertable].
|
||||
Future<Iterable<T>> fromCrdtDataEntries<T extends Insertable>(
|
||||
Iterable<CrdtDataEntry> entries,
|
||||
) async {
|
||||
final foundRowIds = entries.map((e) => e.rowId).toSet();
|
||||
return [
|
||||
for (final rowId in foundRowIds)
|
||||
await map({
|
||||
for (final c in $columns)
|
||||
c.$name: entries
|
||||
.firstWhere((e) => e.rowId == rowId && e.columnName == c.$name)
|
||||
.rawValue
|
||||
?.rawSqlValue,
|
||||
})
|
||||
as T,
|
||||
];
|
||||
}
|
||||
}
|
||||
136
lib/src/database/extensions/sync.dart
Normal file
136
lib/src/database/extensions/sync.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:crdt/crdt.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../utils/sql_builder.dart';
|
||||
import '../database.dart';
|
||||
import 'transactions.dart';
|
||||
|
||||
/// Extensions for [CrdtDatabase] to sync data to the target tables.
|
||||
extension CrdtDatabaseSyncExtensions on CrdtDatabase {
|
||||
/// Upserts rows in the target table from the CRDT data table.
|
||||
///
|
||||
/// This is the inverse operation of what CRDT triggers do. It reads from the
|
||||
/// CRDT data table and reconstructs rows to insert/update in the target table.
|
||||
/// Handles deletions and fields that are not changed in the filter criteria.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [rowIdsPerTable]: Map of table names to row IDs to sync. If not provided,
|
||||
/// all tables will be synchronized. Passing null for a table will synchronize
|
||||
/// all rows in that table.
|
||||
/// - [sinceHlc]: Optional HLC timestamp to filter by. The filter is inclusive,
|
||||
/// meaning that rows with the exact same HLC timestamp will also be included.
|
||||
Future<void> syncCrdtDataToTables({
|
||||
Map<String, Iterable<String>?>? rowIdsPerTable,
|
||||
Hlc? sinceHlc,
|
||||
}) async {
|
||||
return transactionWithDeferredConstraints(() async {
|
||||
final tableNames = rowIdsPerTable?.keys ?? synchronizedTables.tableNames;
|
||||
for (final tableName in tableNames) {
|
||||
await _syncCrdtDataToTable(
|
||||
tableName: tableName,
|
||||
rowIds: rowIdsPerTable?[tableName],
|
||||
sinceHlc: sinceHlc,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Syncs the CRDT data to the target table.
|
||||
///
|
||||
/// Must be called within a transaction with deferred constraints to ensure that
|
||||
/// the foreign key constraints are not violated. The transaction is not created
|
||||
/// by this method to allow grouping multiple table syncs into one transaction
|
||||
/// to avoid nested transactions.
|
||||
Future<void> _syncCrdtDataToTable({
|
||||
required String tableName,
|
||||
Iterable<String>? rowIds,
|
||||
Hlc? sinceHlc,
|
||||
}) async {
|
||||
final deleteSql = _getDeleteFromCrdtDataSql(
|
||||
tableName: tableName,
|
||||
rowIds: rowIds,
|
||||
sinceHlc: sinceHlc,
|
||||
);
|
||||
|
||||
final upsertSql = _getSaveFromCrdtDataSql(
|
||||
tableName: tableName,
|
||||
rowIds: rowIds,
|
||||
sinceHlc: sinceHlc,
|
||||
);
|
||||
|
||||
await customStatement(deleteSql);
|
||||
await customStatement(upsertSql);
|
||||
}
|
||||
|
||||
/// Generates the SQL to delete rows in the target table from the CRDT data table.
|
||||
String _getDeleteFromCrdtDataSql({
|
||||
required String tableName,
|
||||
Iterable<String>? rowIds,
|
||||
Hlc? sinceHlc,
|
||||
}) {
|
||||
return '''
|
||||
DELETE FROM "$tableName"
|
||||
WHERE ${_getWhereClause(tableName, rowIds, sinceHlc, isDeleted: true)};''';
|
||||
}
|
||||
|
||||
// TODO: Unify this with the SQL for the CRDT triggers.
|
||||
/// Generates SQL to upsert rows in the target table from the CRDT data table.
|
||||
String _getSaveFromCrdtDataSql({
|
||||
required String tableName,
|
||||
Iterable<String>? rowIds,
|
||||
Hlc? sinceHlc,
|
||||
}) {
|
||||
final tableInfo = synchronizedTables.find(tableName);
|
||||
|
||||
final columnSelects = tableInfo.persistedColumns.map((column) {
|
||||
final whereClause = 'WHERE c."column_name" = \'$column\'';
|
||||
return 'MAX(c."raw_value") FILTER ($whereClause) AS "$column"';
|
||||
});
|
||||
|
||||
final updateSets = tableInfo.nonPrimaryKeyColumns.map((column) {
|
||||
final newField = 'EXCLUDED."$column"';
|
||||
final originalField = '"$tableName"."$column"';
|
||||
return '"$column" = COALESCE($newField, $originalField)';
|
||||
});
|
||||
|
||||
final onConflict = updateSets.isEmpty
|
||||
? 'DO NOTHING'
|
||||
: 'DO UPDATE\nSET ${updateSets.join(',\n ')}';
|
||||
|
||||
return '''
|
||||
INSERT INTO "$tableName" (
|
||||
${tableInfo.persistedColumns.map((c) => '"$c"').join(', ')}
|
||||
)
|
||||
SELECT ${columnSelects.join(',\n ')}
|
||||
FROM ${sqlBuilder.crdtDataTableName} AS c
|
||||
WHERE ${_getWhereClause(tableName, rowIds, sinceHlc)}
|
||||
GROUP BY c."row_id"
|
||||
ON CONFLICT (${tableInfo.$primaryKey.quotedNames.join(', ')})
|
||||
$onConflict;''';
|
||||
}
|
||||
|
||||
String _getWhereClause(
|
||||
String tableName,
|
||||
Iterable<String>? rowIds,
|
||||
Hlc? sinceHlc, {
|
||||
bool isDeleted = false,
|
||||
}) {
|
||||
final whereConditions = <String>[
|
||||
'"user_id" = \'$userId\'',
|
||||
'"table_name" = \'$tableName\'',
|
||||
if (rowIds != null && rowIds.isNotEmpty)
|
||||
'row_id IN (${rowIds.map((id) => "'$id'").join(', ')})',
|
||||
if (sinceHlc != null) '"hlc_timestamp" >= \'$sinceHlc\'',
|
||||
if (isDeleted) '"${sqlBuilder.isDeletedColumnName}" = TRUE',
|
||||
];
|
||||
return whereConditions.join(' AND ');
|
||||
}
|
||||
}
|
||||
|
||||
extension on TableInfo {
|
||||
Iterable<String> get persistedColumns =>
|
||||
$columns.where((c) => c.generatedAs == null).map((c) => c.$name);
|
||||
|
||||
Iterable<String> get nonPrimaryKeyColumns =>
|
||||
persistedColumns.where((c) => !$primaryKey.map((p) => p.$name).contains(c));
|
||||
}
|
||||
36
lib/src/database/extensions/transactions.dart
Normal file
36
lib/src/database/extensions/transactions.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import '../database.dart';
|
||||
|
||||
/// Extensions for [CrdtDatabase] to execute transactions.
|
||||
extension CrdtDatabaseTransactionsExtensions on CrdtDatabase {
|
||||
/// Executes the [operation] in a transaction with the constraints deferred.
|
||||
///
|
||||
/// On Postgres, this will result in deferring the constraints for the duration
|
||||
/// of the operation. On SQLite, this will result in disabling the foreign keys
|
||||
/// before the operation and restoring to the previous value afterwards.
|
||||
Future<T> transactionWithDeferredConstraints<T>(
|
||||
Future<T> Function() operation,
|
||||
) async {
|
||||
if (isPostgres) {
|
||||
return transaction(() async {
|
||||
await customStatement('SET CONSTRAINTS ALL DEFERRED');
|
||||
return operation();
|
||||
});
|
||||
}
|
||||
|
||||
return _withForeignKeysDisabled(
|
||||
() => transaction(operation),
|
||||
);
|
||||
}
|
||||
|
||||
Future<T> _withForeignKeysDisabled<T>(Future<T> Function() operation) async {
|
||||
const pragma = 'PRAGMA foreign_keys';
|
||||
final originalValueResult = await customSelect(pragma).getSingle();
|
||||
final originalValue = originalValueResult.read<int>('foreign_keys');
|
||||
try {
|
||||
await customStatement('$pragma = OFF');
|
||||
return await operation();
|
||||
} finally {
|
||||
await customStatement('$pragma = $originalValue');
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/src/database/setup.dart
Normal file
57
lib/src/database/setup.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:drift/native.dart';
|
||||
|
||||
import '/src/hlc/stateful.dart';
|
||||
import 'tables/data.dart';
|
||||
import 'triggers.dart';
|
||||
|
||||
/// The database setup function to register the HLC function.
|
||||
///
|
||||
/// Will register the `next_hlc_timestamp` function in the SQLite3 database for usage
|
||||
/// in the triggers that keep track of changes in the database.
|
||||
///
|
||||
/// If using Dart only, must be passed to the [NativeDatabase] constructor as `setup`
|
||||
/// parameter. If using Flutter, this function must be passed to the `driftDatabase`
|
||||
/// function as `setup` parameter.
|
||||
DatabaseSetup get registerHlcFunction {
|
||||
return (database) {
|
||||
database.createFunction(
|
||||
functionName: nextHlcFunction,
|
||||
function: (args) {
|
||||
if (args.isEmpty) throw _MissingArgumentsError();
|
||||
if (args.length == 1) throw _MissingNodeIdError();
|
||||
if (args.length > 2) throw _TooManyArgumentsError();
|
||||
|
||||
final userId = args[0] as String?;
|
||||
if (userId == null) throw _MissingUserIdError();
|
||||
|
||||
final nodeId = args[1] as String?;
|
||||
if (nodeId == null) throw _MissingNodeIdError();
|
||||
|
||||
return StatefulHlc.cached(userId, nodeId).increment().toString();
|
||||
},
|
||||
// argumentCount: const AllowedArgumentCount(2),
|
||||
deterministic: false,
|
||||
directOnly: false,
|
||||
);
|
||||
|
||||
// Load the last HLC for the user from the control table.
|
||||
try {
|
||||
database.select(CrdtDataTable.getLastHlcTimestampQuery()).forEach((row) {
|
||||
final userId = row.values.first! as String;
|
||||
final lastHlcTimestamp = hlcConverter.fromSql(row.values.last! as String);
|
||||
StatefulHlc.initialize(userId, lastHlcTimestamp);
|
||||
});
|
||||
} on Exception catch (_) {
|
||||
// Ignore errors, as the HLC will be initialized to zero.
|
||||
// This is expected to happen on a new database.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class _MissingArgumentsError extends ArgumentError {}
|
||||
|
||||
class _MissingUserIdError extends _MissingArgumentsError {}
|
||||
|
||||
class _MissingNodeIdError extends _MissingArgumentsError {}
|
||||
|
||||
class _TooManyArgumentsError extends ArgumentError {}
|
||||
61
lib/src/database/tables/compensation.dart
Normal file
61
lib/src/database/tables/compensation.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
/// CRDT Compensation rules.
|
||||
///
|
||||
/// This table stores the compensation rules for each table and column.
|
||||
///
|
||||
@DataClassName('CrdtCompensationEntry')
|
||||
class CrdtCompensationTable extends Table {
|
||||
@override
|
||||
String get tableName => '__crdt_compensation';
|
||||
|
||||
/// Name of the table that data belongs to.
|
||||
late final tblName = text().named('table_name')();
|
||||
|
||||
/// Name of the column this data belongs to.
|
||||
late final columnName = text()();
|
||||
|
||||
/// Type of constraint this compensation rule applies to.
|
||||
late final constraintType = textEnum<ConstraintType>()();
|
||||
|
||||
/// Compensation rule to preserve invariants.
|
||||
late final compensationRule = textEnum<CompensationRule>()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {tblName, columnName, constraintType};
|
||||
}
|
||||
|
||||
/// Type of constraint this compensation rule applies to.
|
||||
enum ConstraintType {
|
||||
/// Unique constraint.
|
||||
unique,
|
||||
|
||||
/// Foreign key constraint.
|
||||
foreignKey,
|
||||
}
|
||||
|
||||
/// Available compensation rules for CRDT operations.
|
||||
enum CompensationRule {
|
||||
/// No compensation rule.
|
||||
none,
|
||||
|
||||
/// Handle unique key conflicts by merging values.
|
||||
// ignore: constant_identifier_names
|
||||
on_unique_conflict_merge,
|
||||
|
||||
/// Handle unique key conflicts by overwriting the existing value.
|
||||
// ignore: constant_identifier_names
|
||||
on_unique_conflict_overwrite,
|
||||
|
||||
/// Handle foreign key deletion by deleting this related row.
|
||||
// ignore: constant_identifier_names
|
||||
on_fk_deleted_delete_this,
|
||||
|
||||
/// Handle foreign key deletion by setting the relation on this row to null.
|
||||
// ignore: constant_identifier_names
|
||||
on_fk_deleted_set_null,
|
||||
|
||||
/// Handle foreign key deletion by restoring the related row.
|
||||
// ignore: constant_identifier_names
|
||||
on_fk_deleted_restore_related,
|
||||
}
|
||||
37
lib/src/database/tables/control.dart
Normal file
37
lib/src/database/tables/control.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../hlc/converter.dart';
|
||||
|
||||
// Required for the generated code to know the HLC and converters.
|
||||
export '../../hlc/converter.dart';
|
||||
|
||||
/// CRDT control table.
|
||||
///
|
||||
/// This table stores metadata about the CRDT synchronization state for each user.
|
||||
///
|
||||
@DataClassName('CrdtControlEntry')
|
||||
class CrdtControlTable extends Table {
|
||||
@override
|
||||
String get tableName => '__crdt_control';
|
||||
|
||||
/// Identifier for the user or client.
|
||||
late final userId = text()();
|
||||
|
||||
/// Node identifier for the client.
|
||||
late final nodeId = text()();
|
||||
|
||||
/// Whether CRDT triggers are currently enabled for this user.
|
||||
late final crdtTriggersOn = boolean().withDefault(const Constant(true))();
|
||||
|
||||
/// The schema version of the CRDT data.
|
||||
late final schemaVersion = integer()();
|
||||
|
||||
/// The last HLC timestamp when the user synchronized.
|
||||
late final lastSyncHlc = text().nullable().map(nullableHlcConverter)();
|
||||
|
||||
/// The last HLC timestamp when merged changes were applied.
|
||||
late final lastApplyHlc = text().nullable().map(nullableHlcConverter)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {userId, nodeId};
|
||||
}
|
||||
69
lib/src/database/tables/data.dart
Normal file
69
lib/src/database/tables/data.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../hlc/converter.dart';
|
||||
|
||||
// Required for the generated code to know the HLC and converters.
|
||||
export '../../hlc/converter.dart';
|
||||
|
||||
/// CRDT data table.
|
||||
///
|
||||
/// This table stores the actual data encoded as JSON strings and all CRDT required
|
||||
/// metadata to perform conflict resolution and synchronization. Schema is designed
|
||||
/// to be generic enough to support various data types and structures. Stores the
|
||||
/// data for all tables that are registered for CRDT synchronization, simplifying
|
||||
/// the process of conflict resolution and data merging.
|
||||
///
|
||||
/// This same schema will be used on a client and server, with the difference that
|
||||
/// the client will have the `user_id` column with a single value, while the server
|
||||
/// will have all users' data in the same table. To avoid performance issues, on a
|
||||
/// Postgres server, the `user_id` and `table_name` columns are partition keys.
|
||||
@TableIndex(
|
||||
name: 'idx_crdt_data_tbl_name_column_name_row_id',
|
||||
columns: {#userId, #tblName, #columnName, #rowId},
|
||||
unique: true,
|
||||
)
|
||||
@TableIndex(
|
||||
name: 'idx_crdt_data_hlc_timestamp',
|
||||
columns: {#userId, #hlcTimestamp},
|
||||
)
|
||||
@DataClassName('CrdtDataEntry')
|
||||
class CrdtDataTable extends Table {
|
||||
@override
|
||||
String get tableName => '__crdt_data';
|
||||
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
|
||||
/// Identifier for the user or client that owns the data.
|
||||
late final userId = text()();
|
||||
|
||||
/// Name of the table that data belongs to.
|
||||
late final tblName = text().named('table_name')();
|
||||
|
||||
/// Name of the column this data belongs to.
|
||||
late final columnName = text()();
|
||||
|
||||
/// Unique identifier for the row in the table.
|
||||
late final rowId = text()();
|
||||
|
||||
/// Hybrid Logical Clock timestamp of the data update.
|
||||
late final hlcTimestamp = text().map(hlcConverter)();
|
||||
|
||||
/// Raw value of the data.
|
||||
late final rawValue = sqliteAny().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {userId, tblName, columnName, rowId};
|
||||
|
||||
/// Query to get the last HLC timestamp for each user.
|
||||
///
|
||||
/// Declared here to ensure the query is synchronized with the table.
|
||||
static String getLastHlcTimestampQuery() {
|
||||
return '''
|
||||
SELECT user_id,
|
||||
max(hlc_timestamp) as last_hlc_timestamp
|
||||
FROM __crdt_data
|
||||
GROUP BY 1
|
||||
''';
|
||||
}
|
||||
}
|
||||
33
lib/src/database/tables/merge.dart
Normal file
33
lib/src/database/tables/merge.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../hlc/converter.dart';
|
||||
|
||||
// Required for the generated code to know the HLC and converters.
|
||||
export '../../hlc/converter.dart';
|
||||
|
||||
/// CRDT merge HLC table.
|
||||
///
|
||||
/// This table stores the last HLC timestamp received from each node when merging
|
||||
/// changes, so that the next merges can be performed incrementally.
|
||||
///
|
||||
@TableIndex(
|
||||
name: 'idx_crdt_merge_hlc_timestamp',
|
||||
columns: {#userId, #lastReceivedHlc},
|
||||
)
|
||||
@DataClassName('CrdtMergeHlcEntry')
|
||||
class CrdtMergeHlcTable extends Table {
|
||||
@override
|
||||
String get tableName => '__crdt_merge_hlc';
|
||||
|
||||
/// Identifier for the user or client.
|
||||
late final userId = text()();
|
||||
|
||||
/// Node identifier for the client.
|
||||
late final nodeId = text()();
|
||||
|
||||
/// The last HLC timestamp received from this node when merging changes.
|
||||
late final lastReceivedHlc = text().map(hlcConverter)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {userId, nodeId};
|
||||
}
|
||||
148
lib/src/database/triggers.dart
Normal file
148
lib/src/database/triggers.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../utils/sql_builder.dart';
|
||||
|
||||
/// The name of the HLC function used in the database.
|
||||
const nextHlcFunction = 'next_hlc_timestamp';
|
||||
|
||||
/// The operation that was performed on a row.
|
||||
// ignore: public_member_api_docs
|
||||
enum Operation { insert, update, delete }
|
||||
|
||||
// TODO: The [OfflineSyncTriggers] class should be reworked since the SQL statements
|
||||
// now are no longer universal as the one developed originally.
|
||||
|
||||
/// Provides the CRDT control trigger statements for the database.
|
||||
abstract class OfflineSyncTriggers extends CrdtDataSqlBuilder {
|
||||
/// Creates a new instance of [OfflineSyncTriggers].
|
||||
OfflineSyncTriggers(super.crdtDb);
|
||||
|
||||
/// Wraps the provided SQL statement as a CREATE TRIGGER statement.
|
||||
///
|
||||
/// The received [innerSql] will be expected to go inside a BEGIN...END block.
|
||||
String wrapAsCreateTriggerStatement({
|
||||
required Operation operation,
|
||||
required String tableName,
|
||||
required String triggerName,
|
||||
required String innerSql,
|
||||
});
|
||||
|
||||
/// Gets the name of the trigger for a specific operation on a table.
|
||||
String _getTriggerName(TableInfo<Table, Object?> table, Operation operation) =>
|
||||
'${prefix}__${table.actualTableName}__${operation.name}';
|
||||
|
||||
/// Returns the SQL statement to insert a new row into the CRDT data table.
|
||||
///
|
||||
/// This statement is compatible with all major SQL dialects like SQLite,
|
||||
/// Postgres, MySQL, SQL Server, etc. So the dialect-specific subclass only
|
||||
/// needs to wrap it in the appropriate CREATE TRIGGER statement.
|
||||
///
|
||||
/// For some dialects, it will only be needed to enable support for quoted
|
||||
/// identifiers that are used in the statement for maximum compatibility. On
|
||||
/// MySQL this is done by setting the `SQL_MODE` to `ANSI_QUOTES` and on SQL
|
||||
/// Server this is done by setting the `QUOTED_IDENTIFIER` option to `ON`.
|
||||
String _getInsertStatement({
|
||||
required TableInfo<Table, Object?> table,
|
||||
required Operation operation,
|
||||
}) {
|
||||
final tableName = table.actualTableName;
|
||||
|
||||
final uniqueRowId = getUniqueRowId(
|
||||
table,
|
||||
tableAlias: operation == Operation.insert ? 'NEW' : 'OLD',
|
||||
);
|
||||
|
||||
final columnNamesWithoutPrimaryKey = operation == Operation.delete
|
||||
? [isDeletedColumnName]
|
||||
: table.$columns
|
||||
.map((c) => c.$name)
|
||||
// MAYBE: If we remove the primary key columns from the list, some tables
|
||||
// might be completely ignored (like many-to-many relationship tables).
|
||||
// Do we really need them? Probably yes if we want to avoid deserializing
|
||||
// the split rowId into the original primary key columns, which can have
|
||||
// different types.
|
||||
// .where((c) => !table.$primaryKey.map((p) => p.$name).contains(c))
|
||||
.toList();
|
||||
|
||||
final columnInserts = columnNamesWithoutPrimaryKey.map((c) {
|
||||
return ' SELECT\n${[
|
||||
' \'$c\' AS "column_name"',
|
||||
' ${c == isDeletedColumnName ? 'TRUE' : 'NEW."$c"'} AS "raw_value"',
|
||||
if (operation == Operation.update) ' NEW."$c" IS NOT OLD."$c" AS "value_changed"',
|
||||
].join(',\n')}';
|
||||
});
|
||||
|
||||
final whereClause = operation == Operation.update
|
||||
? '\n WHERE (value_changed = TRUE)'
|
||||
: '';
|
||||
|
||||
return '''
|
||||
INSERT OR REPLACE INTO $crdtDataTableName (
|
||||
${crdtDataAllColumns.join(', ')}
|
||||
)
|
||||
SELECT
|
||||
'${crdtDb.userId}' AS "user_id",
|
||||
'$tableName' AS "table_name",
|
||||
column_name,
|
||||
$uniqueRowId AS "row_id",
|
||||
"hlc_timestamp",
|
||||
raw_value
|
||||
FROM (
|
||||
SELECT $nextHlcFunction('${crdtDb.userId}', '${crdtDb.nodeId}') AS "hlc_timestamp"
|
||||
), (
|
||||
${columnInserts.join('\n UNION ALL\n')}
|
||||
)$whereClause;''';
|
||||
}
|
||||
|
||||
/// Returns the SQL statements to create the triggers for the changelog table.
|
||||
List<String> generateCreateTriggerStatements(TableInfo<Table, Object?> table) {
|
||||
return [
|
||||
for (final op in Operation.values)
|
||||
wrapAsCreateTriggerStatement(
|
||||
operation: op,
|
||||
tableName: table.actualTableName,
|
||||
triggerName: _getTriggerName(table, op),
|
||||
innerSql: _getInsertStatement(
|
||||
table: table,
|
||||
operation: op,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the CRDT control trigger statements for the SQLite3 database.
|
||||
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
|
||||
/// Creates a new instance of [Sqlite3OfflineSyncTriggers].
|
||||
Sqlite3OfflineSyncTriggers(super.crdtDb);
|
||||
|
||||
@override
|
||||
String wrapAsCreateTriggerStatement({
|
||||
required Operation operation,
|
||||
required String tableName,
|
||||
required String triggerName,
|
||||
required String innerSql,
|
||||
}) =>
|
||||
'''
|
||||
CREATE TRIGGER IF NOT EXISTS $triggerName
|
||||
AFTER ${operation.name.toUpperCase()} ON "$tableName"
|
||||
BEGIN
|
||||
$innerSql
|
||||
END;''';
|
||||
}
|
||||
|
||||
/// Extension methods for the [GeneratedDatabase] class to get the CRDT trigger names.
|
||||
extension GetDatabaseCrdtTriggers on GeneratedDatabase {
|
||||
/// Returns the names of the CRDT triggers in the database.
|
||||
Future<List<String>> getCrdtTriggers() async {
|
||||
final result = await customSelect(
|
||||
'''\
|
||||
SELECT type, name, sql
|
||||
FROM sqlite_master
|
||||
WHERE (type = "trigger")
|
||||
AND (name LIKE "%%__crdt__%%");''',
|
||||
).get();
|
||||
|
||||
return result.map((row) => row.read<String>('name')).toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '/src/database.dart';
|
||||
import 'crdt.dart';
|
||||
import 'triggers.dart';
|
||||
import 'crdt/crdt.dart';
|
||||
import 'database/database.dart';
|
||||
import 'database/triggers.dart';
|
||||
|
||||
/// Runs migrations declared by a [MigrationStrategy].
|
||||
class OfflineSyncMigrator extends Migrator {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '/src/database.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
/// Provides the base building blocks for SQL statements to interact with the
|
||||
/// CRDT data table.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:drift_offline_first/drift_offline_first.dart';
|
||||
import 'package:drift_offline_first/src/triggers.dart';
|
||||
import 'package:drift_offline_first/src/database/triggers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:drift_offline_first/src/triggers.dart';
|
||||
import 'package:drift_offline_first/src/database/triggers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/database.dart';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:drift_offline_first/src/database/triggers.dart';
|
||||
import 'package:drift_offline_first/src/migrator.dart';
|
||||
import 'package:drift_offline_first/src/triggers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils/database.dart';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue