syncly_flutter 0.1.0 copy "syncly_flutter: ^0.1.0" to clipboard
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.

3
likes
160
points
104
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Backend-agnostic offline synchronization with a durable SQLite outbox, retries, pulls, tombstones, and conflict handling.

Homepage
Repository (GitHub)
View/report issues

License

Apache-2.0 (license)

Dependencies

collection, connectivity_plus, flutter, path, path_provider, sqflite

More

Packages that depend on syncly_flutter

Packages that implement syncly_flutter