Dart/Flutter library for offline-first data handling. Local cache on Drift + server sync.
Principle: read locally → write locally + to outbox → sync() pushes and pulls data.
Minimal checklist: install packages, prepare a Drift database with include for sync tables, then register your tables in SyncEngine.
dependencies:
offline_first_sync_drift: ^0.1.1
offline_first_sync_drift_rest: ^0.1.1
drift: ^2.0.0
json_annotation: ^4.8.0
dev_dependencies:
drift_dev: ^2.0.0
build_runner: ^2.0.0
json_serializable: ^6.7.0build.yaml (modular generation is required for cross-package sharing):
targets:
$default:
builders:
drift_dev:
enabled: false
drift_dev:analyzer:
enabled: true
options: &options
store_date_time_values_as_text: true
drift_dev:modular:
enabled: true
options: *options- Describe your domain tables and add
SyncColumnsto automatically getupdatedAt/deletedAt/deletedAtLocal. - Include the sync tables via
include— this will automatically addsync_outboxandsync_cursors. - Extend
SyncDatabaseMixin, which providesenqueue(),takeOutbox(),setCursor(), and other utilities.
import 'package:drift/drift.dart';
import 'package:offline_first_sync_drift/offline_first_sync_drift.dart';
import 'database.drift.dart';
import 'models/daily_feeling.dart'; // see "Data model" section
@DriftDatabase(
include: {'package:offline_first_sync_drift/src/sync_tables.drift'},
tables: [DailyFeelings],
)
class AppDatabase extends $AppDatabase with SyncDatabaseMixin {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
}SyncEngine connects the local DB and the transport. In tables list each entity: kind is the server name, table is the Drift table reference, fromJson/toJson convert between the local model and the API.
import 'package:offline_first_sync_drift_rest/offline_first_sync_drift_rest.dart';
final transport = RestTransport(
base: Uri.parse('https://api.example.com'),
token: () async => 'Bearer ${await getToken()}',
);
final engine = SyncEngine(
db: db,
transport: transport,
tables: [
SyncableTable<DailyFeeling>(
kind: 'daily_feeling',
table: db.dailyFeelings,
fromJson: DailyFeeling.fromJson,
toJson: (e) => e.toJson(),
toInsertable: (e) => e.toInsertable(),
),
],
);To participate in sync a table must:
- have a string primary key
id; - store
updatedAtin UTC (the server updates this field); - optionally have
deletedAtfor soft-delete anddeletedAtLocalfor local marks; - contain any of your business fields.
Add SyncColumns to get all required system fields automatically — you only describe domain columns. The table automatically implements SynchronizableTable, so you can type-safely distinguish it from regular Drift tables:
import 'package:drift/drift.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:offline_first_sync_drift/offline_first_sync_drift.dart';
part 'daily_feeling.g.dart';
/// Data model (row class).
@JsonSerializable(fieldRename: FieldRename.snake)
class DailyFeeling {
DailyFeeling({
required this.id,
required this.updatedAt,
this.deletedAt,
this.deletedAtLocal,
required this.date,
this.mood,
this.energy,
this.notes,
});
final String id;
final DateTime updatedAt;
final DateTime? deletedAt;
final DateTime? deletedAtLocal;
final DateTime date;
final int? mood;
final int? energy;
final String? notes;
factory DailyFeeling.fromJson(Map<String, dynamic> json) =>
_$DailyFeelingFromJson(json);
Map<String, dynamic> toJson() => _$DailyFeelingToJson(this);
// toInsertable() is generated automatically thanks to generateInsertable: true
}
/// Drift table with all sync fields.
@UseRowClass(DailyFeeling, generateInsertable: true)
class DailyFeelings extends Table with SyncColumns {
TextColumn get id => text()();
IntColumn get mood => integer().nullable()();
IntColumn get energy => integer().nullable()();
TextColumn get notes => text().nullable()();
DateTimeColumn get date => dateTime()();
@override
Set<Column> get primaryKey => {id};
}Use Drift as usual, and for changes follow the pattern “update locally → put the operation into outbox”.
Queries behave the same as standard Drift: data is already in the local DB, queries are instant and offline-friendly.
final all = await db.select(db.dailyFeelings).get();
final today = await (db.select(db.dailyFeelings)
..where((t) => t.date.equals(DateTime.now())))
.getSingleOrNull();
db.select(db.dailyFeelings).watch().listen((list) {
setState(() => _feelings = list);
});Each operation has two steps: first update the local table, then enqueue the operation via db.enqueue(...). For updates, always send baseUpdatedAt (when the record arrived from the server) and changedFields (which fields the user modified).
Future<void> create(DailyFeeling feeling) async {
await db.into(db.dailyFeelings).insert(feeling);
await db.enqueue(UpsertOp(
opId: uuid.v4(),
kind: 'daily_feeling',
id: feeling.id,
localTimestamp: DateTime.now().toUtc(),
payloadJson: feeling.toJson(),
));
}
Future<void> updateFeeling(DailyFeeling updated, Set<String> changedFields) async {
await db.update(db.dailyFeelings).replace(updated);
await db.enqueue(UpsertOp(
opId: uuid.v4(),
kind: 'daily_feeling',
id: updated.id,
localTimestamp: DateTime.now().toUtc(),
payloadJson: updated.toJson(),
baseUpdatedAt: updated.updatedAt,
changedFields: changedFields,
));
}
Future<void> deleteFeeling(String id, DateTime? serverUpdatedAt) async {
await (db.delete(db.dailyFeelings)..where((t) => t.id.equals(id))).go();
await db.enqueue(DeleteOp(
opId: uuid.v4(),
kind: 'daily_feeling',
id: id,
localTimestamp: DateTime.now().toUtc(),
baseUpdatedAt: serverUpdatedAt,
));
}Call sync() manually when needed (pull/push/merge) or enable the auto timer. You can limit kinds if you only need to refresh part of the data.
// Вручную
final stats = await engine.sync();
// Автоматически каждые 5 минут
engine.startAuto(interval: Duration(minutes: 5));
engine.stopAuto();
// Для конкретных таблиц
await engine.sync(kinds: {'daily_feeling', 'health_record'});A conflict happens when data changed both on the client and server. Configure behavior via SyncConfig(conflictStrategy: ...) globally or tableConflictConfigs for specific tables.
| Strategy | Description |
|---|---|
autoPreserve |
(default) Smart merge that keeps all data |
serverWins |
Server version wins |
clientWins |
Client version wins (force push) |
lastWriteWins |
Later timestamp wins |
merge |
Custom merge function |
manual |
Manual resolution via callback |
Default strategy — merges without losing data:
// Локально: {mood: 5, notes: "My notes"}
// На сервере: {mood: 3, energy: 7}
// Результат: {mood: 5, energy: 7, notes: "My notes"}How it works:
- Takes server data as the base
- Applies local changes (only
changedFieldsif provided) - Merges lists without duplicates
- Merges nested objects recursively
- Uses server values for system fields (
id,updatedAt,createdAt) - Sends the result with
X-Force-Update: true
final engine = SyncEngine(
// ...
config: SyncConfig(
conflictStrategy: ConflictStrategy.manual,
conflictResolver: (conflict) async {
// Show a dialog to the user or resolve programmatically
final choice = await showConflictDialog(conflict);
return switch (choice) {
'server' => AcceptServer(),
'client' => AcceptClient(),
'merge' => AcceptMerged({...}),
'defer' => DeferResolution(),
_ => DiscardOperation(),
};
},
),
);final engine = SyncEngine(
// ...
config: SyncConfig(
conflictStrategy: ConflictStrategy.merge,
mergeFunction: (local, server) {
return {...server, ...local};
},
),
);
// Built-in helpers
ConflictUtils.defaultMerge(local, server);
ConflictUtils.deepMerge(local, server);
ConflictUtils.preservingMerge(local, server, changedFields: {'mood'});final engine = SyncEngine(
// ...
tableConflictConfigs: {
'user_settings': TableConflictConfig(
strategy: ConflictStrategy.clientWins,
),
},
);SyncEngine emits an event stream that is handy for UI indicators, logging, and metrics.
// Subscribe to events
engine.events.listen((event) {
switch (event) {
case SyncStarted(:final phase):
print('Начало: $phase');
case SyncProgress(:final done, :final total):
print('Прогресс: $done/$total');
case SyncCompleted(:final stats):
print('Готово: pushed=${stats.pushed}, pulled=${stats.pulled}');
case ConflictDetectedEvent(:final conflict):
print('Конфликт: ${conflict.entityId}');
case SyncErrorEvent(:final error):
print('Ошибка: $error');
}
});
// Stats after sync
final stats = await engine.sync();
print('Отправлено: ${stats.pushed}');
print('Получено: ${stats.pulled}');
print('Конфликтов: ${stats.conflicts}');
print('Разрешено: ${stats.conflictsResolved}');
print('Ошибок: ${stats.errors}');The server must support a predictable REST contract: idempotent PUT requests, stable pagination, and conflict checks via updatedAt. See docs/backend_guidelines.md for the full guide with examples and a checklist.
Quick reminder:
- implement CRUD endpoints
/{kind}with filtersupdatedSince,afterId,limit,includeDeleted; - keep
updatedAtand (optionally)deletedAt, setting system fields on the server; - on PUT, validate
_baseUpdatedAt, return409with current data, and supportX-Force-Update+X-Idempotency-Key; - return lists as
{ "items": [...], "nextPageToken": "..." }, building the cursor from(updatedAt, id); - refer to the e2e example in
packages/offline_first_sync_drift_rest/test/e2efor a reference implementation.
The GitHub Actions pipeline .github/workflows/ci.yml runs dart analyze and tests for all workspace packages (packages/offline_first_sync_drift, packages/offline_first_sync_drift_rest, example) on every push and pull request to main/master. Locally you can mirror the same checks with:
dart pub get
dart analyze .
dart test packages/offline_first_sync_drift
dart test packages/offline_first_sync_drift_rest
dart test