syncly_flutter 0.1.0
syncly_flutter: ^0.1.0 copied to clipboard
Backend-agnostic offline synchronization with a durable SQLite outbox, retries, pulls, tombstones, and conflict handling.
Syncly Flutter #
Syncly Flutter is a backend-agnostic offline synchronization engine. It stores records locally in SQLite, writes every change to a durable outbox, and only marks a mutation as synchronized after your backend acknowledges it.
Features #
- Durable SQLite outbox that survives app restarts.
- Real backend acknowledgement through
SyncTransport. - Version-aware upserts and conflict responses.
- Cursor-based incremental pulls.
- Persisted exponential retry scheduling and error details.
- Offline deletes using tombstones.
- Server-wins, local-wins, last-write-wins, merge, and manual conflicts.
- Automatic sync after connectivity is restored, or manual sync on demand.
Platform support #
| Platform | Minimum version | Native package manager |
|---|---|---|
| Android | API 21 | Gradle |
| iOS | 13.0 | Swift Package Manager or CocoaPods |
| macOS | 10.15 | Swift Package Manager or CocoaPods |
The package requires Dart 3.8 and Flutter 3.32 or newer.
Installation #
dependencies:
syncly_flutter: ^0.1.0
flutter pub get
Connect a backend #
Syncly does not assume a specific REST, Firebase, Supabase, or database schema. Provide a transport that maps mutations to your backend API:
final transport = CallbackSyncTransport(
onPush: (mutation) async {
final response = await myApi.pushChange(
collection: mutation.key,
id: mutation.id,
operation: mutation.operation.name,
data: mutation.data,
baseVersion: mutation.baseVersion,
idempotencyKey: mutation.idempotencyKey,
);
if (response.isConflict) {
return SyncPushResult.conflict(
SyncRemoteRecord(
id: response.record.id,
key: mutation.key,
data: response.record.data,
updatedAt: response.record.updatedAt,
version: response.record.version,
isDeleted: response.record.isDeleted,
),
);
}
return SyncPushResult.accepted(
remoteVersion: response.version,
);
},
onPull: (cursor) async {
final page = await myApi.pullChanges(cursor: cursor);
return SyncPullResult(
records: page.records.map(toSyncRemoteRecord).toList(),
cursor: page.nextCursor,
hasMore: page.hasMore,
);
},
);
Your backend should use idempotencyKey to safely deduplicate repeated writes
and baseVersion for conditional updates. Throw an exception for unavailable
or failed requests; Syncly retains the mutation and schedules a retry.
Initialize #
final storage = LocalStorage();
await storage.init();
final manager = SyncManager(
localStorage: storage,
transport: transport,
connectivityMonitor: ConnectivityMonitor(
internetProbe: myApi.isReachable, // Optional backend-specific check.
),
conflictHandler: const ConflictHandler(
strategy: ConflictResolutionStrategy.lastWriteWins,
),
);
Save, update, and delete offline #
final task = await manager.save('tasks', {
'id': 'task-123',
'title': 'Prepare release notes',
'completed': false,
});
await manager.save('tasks', {
...task.data,
'completed': true,
});
await manager.delete(task.id);
These calls update SQLite immediately and queue a mutation. Deletion hides the record locally but keeps a tombstone until the backend accepts it.
Synchronize #
final report = await manager.syncNow();
print('Pushed: ${report.pushed}');
print('Pulled: ${report.pulled}');
print('Conflicts: ${report.conflicts}');
print('Failures: ${report.failures}');
Concurrent calls share one in-flight run. Individual backend failures are reported and persisted rather than falsely marking records as synchronized.
Listen for detailed lifecycle events:
final subscription = manager.onSyncEvent.listen((event) {
debugPrint('${event.type}: ${event.message}');
});
Read local records #
final all = await storage.getAllData();
final unsynced = await storage.getUnsyncedData();
final synced = await storage.getSyncedData();
React to changes caused by local writes, backend acknowledgements, pulls, conflict resolution, and deletion:
final records = storage.watchAllData();
await for (final snapshot in records) {
rebuildUi(snapshot);
}
Conflict handling #
final handler = ConflictHandler(
strategy: ConflictResolutionStrategy.manual,
manualConflictResolver: (local, remote) {
return local.copyWith(
data: {...remote.data, ...local.data},
updatedAt: DateTime.now().toUtc(),
);
},
);
Remote versions are opaque strings; they can represent integer revisions, database row versions, ETags, or another backend-specific token.
Migration from 0.0.x #
Version 0.1.0 requires the transport argument on SyncManager. Existing
SQLite databases are migrated automatically to schema version 4. Previously
unsynchronized records are added to the durable outbox during migration.
Cleanup #
await subscription.cancel();
manager.dispose();
await storage.close();
Current limitations #
- Applications must provide a backend transport.
- Background execution must be integrated by the host application.
- Local database encryption is not included.
- Web, Linux, and Windows are not currently supported.
The example application includes a process-local transport demonstrating the complete contract. Replace it with network API calls in a production app.
See the changelog for release details.