A plug-and-play, domain-agnostic sync engine for Flutter offline-first apps.
This package automates the bridge between Isar (local embedded database) and Appwrite (backend as a service), keeping your data consistent across devices even under unstable connectivity.
Building offline-first apps involves a lot of moving parts:
- Persistent local cache
- Pushing local changes when connectivity returns (reconciliation)
- Receiving remote changes in real time
- Conflict resolution (race conditions)
- Multi-tenancy: ensuring users only see their own data
This engine encapsulates all of that complexity into a declarative configuration, so you can focus on your app features instead of sync infrastructure.
The engine follows the Single Source of Truth (SSOT) principle: your UI reads and writes exclusively to Isar. The engine mirrors those changes to Appwrite in the background.
Flutter UI <--> Isar (local DB)
|
Sync Engine
/ \
Push (pending) Pull (remote)
| |
Appwrite Realtime events
Data flow:
- UI writes to Isar with
syncStatus = SyncStatus.pending - Isar watcher triggers the push for that collection
- On
initialize(), the engine pulls all remote documents (reconcile) - Appwrite Realtime pushes incoming changes as they happen
Add to your pubspec.yaml:
dependencies:
appwrite_isar_sync_engine:
git:
url: https://github.com/egionCode/appwrite_isar_sync_engine.git
ref: mainAny collection that needs to be synced must implement this interface:
import 'package:appwrite_isar_sync_engine/appwrite_isar_sync_engine.dart';
@Collection()
class Note implements SyncableModel {
// Isar internal auto-increment ID
Id isarId = Isar.autoIncrement;
// Your document ID (UUID or Appwrite's $id). Must be unique.
@Index(unique: true, replace: true)
@override
late String id;
late String content;
// Required by SyncableModel: used for conflict resolution
@override
late DateTime updatedAt;
@Enumerated(EnumType.name)
@override
late SyncStatus syncStatus;
}SyncableModel contract:
| Member | Type | Purpose |
|---|---|---|
id |
String |
Appwrite document ID |
updatedAt |
DateTime |
Timestamp for conflict resolution |
syncStatus |
SyncStatus (get + set) |
Tracks local sync state |
SyncStatus values: synced, pending, error.
final engine = AppwriteIsarSyncEngine(
databases: appwriteDatabases,
realtime: appwriteRealtime,
isar: localIsar,
collections: [
SyncCollectionConfig<Note>(
databaseId: 'main_db',
collectionId: 'notes',
// Isar model -> Appwrite document payload
toRemoteMap: (note) => {
'content': note.content,
'updatedAt': note.updatedAt.toIso8601String(),
},
// Appwrite document -> Isar model
fromRemoteMap: (data, existing) => Note()
..isarId = existing?.isarId ?? Isar.autoIncrement
..id = data['\$id']
..content = data['content']
..updatedAt = DateTime.parse(data['updatedAt']),
// Closures that isolate Isar query knowledge inside your app
getPendingItems: (isar) =>
isar.notes.where().syncStatusEqualTo(SyncStatus.pending).findAll(),
getLocalById: (isar, id) =>
isar.notes.where().idEqualTo(id).findFirst(),
saveLocal: (isar, note) =>
isar.writeTxn(() => isar.notes.put(note)),
watchPendingItems: (isar) =>
isar.notes.where().syncStatusEqualTo(SyncStatus.pending).watch(),
// Optional: Appwrite query filters applied on every pull
getPullQueries: () => [
Query.equal('userId', currentUserId),
],
),
],
);
await engine.initialize();Call engine.dispose() when the engine is no longer needed (e.g., on user logout):
engine.dispose();When a remote document arrives (via Realtime or Pull), the engine compares updatedAt timestamps:
- Remote is newer: local record is overwritten
- Local is newer: remote is ignored
- Same timestamp and local is already
synced: no write
When pushing a pending item, the engine checks if the document exists in Appwrite first. If it does not exist (404), it creates it. Otherwise, it updates it. This handles the case where Appwrite documents were deleted remotely.
Remote documents are fetched in pages of 100. All pages are processed before the reconcile cycle completes.
Use getPullQueries to pass dynamic Appwrite query filters (e.g., Query.equal('userId', uid)). The engine applies them on every pull, so each user only syncs their own data.
Do not call isar.delete() directly on records that need to propagate deletion across devices. Instead, add an isDeleted field to your model and, when the user deletes a record:
- Set
isDeleted = true - Set
updatedAt = DateTime.now() - Set
syncStatus = SyncStatus.pending
The engine will push the update to Appwrite. Other devices receive it via Realtime or Pull and can hide the record accordingly.
The engine exposes a ValueNotifier<SyncState> you can listen to:
ValueListenableBuilder<SyncState>(
valueListenable: engine.stateNotifier,
builder: (context, state, _) {
if (state.isSyncing) {
return LinearProgressIndicator(value: state.progress);
}
if (state.error != null) {
return Text('Sync error: ${state.error}');
}
return const SizedBox.shrink();
},
);SyncState fields:
| Field | Type | Description |
|---|---|---|
isSyncing |
bool |
Whether a sync cycle is in progress |
currentTask |
String |
Description of the current operation |
progress |
double |
Progress from 0.0 to 1.0 |
error |
String? |
Last error message, if any |
The engine has no knowledge of your domain models. To wire it up:
- Add the dependency
- Implement
SyncableModelon any Isar collection you want to sync - Provide closures in
SyncCollectionConfigthat perform Isar operations for that specific collection
The engine calls those closures instead of accessing your generated Isar code directly, keeping the package fully decoupled from your schema.
Made with ❤️ by Victor / egionCode.