diff --git a/README.md b/README.md index 66a9eaf..6515a78 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,89 @@ It keeps data synchronized across devices with deterministic conflict resolution └────────────────────────┘ └────────────────────────┘ ```` + +## Bloc-Like Architecture (New) + +The package now supports a Bloc-like mental model for teams that prefer event/state flows with minimal boilerplate. + +| Bloc Concept | Package Equivalent | +| --- | --- | +| Event | `SyncEvent` | +| State | `SyncState` | +| Bloc | `SyncController` | +| Repository | `SyncRepository` | +| Data source | `LocalDataSource` + `CloudDataSource` | +| Queue store | `SyncLogStore` | + +### Contracts + +```dart +abstract class LocalDataSource { + Future insert(T data); + Future update(T data); + Future delete(String id); + Future getById(String id); + Future> getAll(); +} + +abstract class CloudDataSource { + Future create(T data); + Future update(T data); + Future delete(String id); + Future fetch(String id); + Future> fetchAll(); +} +``` + +### Sync queue model + +```dart +enum SyncOperationType { create, update, delete } +enum SyncStatus { pending, syncing, synced, failed } +``` + +Each local write appends a `SyncLog`, then `StartSync` replays pending logs using latest-first ordering. + +### Events and states + +```dart +AddData, UpdateData, DeleteData, StartSync, RetryFailed +SyncInitial, SyncInProgress, SyncSuccess, SyncFailure +``` + +### Recommended wiring + +```dart +final repository = SyncRepository( + local: localDataSource, + cloud: cloudDataSource, + logStore: InMemorySyncLogStore(), + idResolver: (user) => user.id, +); + +final controller = SyncController(repository: repository); +final engine = OfflineSyncEngine(controller: controller); + +await engine.add(user); +await engine.sync(); +``` + +### Architecture diagram + +``` +UI + ↓ +OfflineSyncEngine + ↓ +SyncController + ↓ +SyncRepository + ↓ +LocalDataSource CloudDataSource + ↓ + SyncLogStore +``` + ## Installation ```bash diff --git a/lib/offline_sync_engine.dart b/lib/offline_sync_engine.dart index 59b8e97..cb37834 100644 --- a/lib/offline_sync_engine.dart +++ b/lib/offline_sync_engine.dart @@ -18,3 +18,16 @@ export 'src/sync/sync_manager.dart'; // Built-in Implementations (ready to use) export 'src/implementations/in_memory_database.dart'; export 'src/implementations/in_memory_cloud.dart'; + + +// Bloc-like architecture (vNext) +export 'src/bloc_like/contracts/local_data_source.dart'; +export 'src/bloc_like/contracts/cloud_data_source.dart'; +export 'src/bloc_like/contracts/sync_log_store.dart'; +export 'src/bloc_like/contracts/in_memory_sync_log_store.dart'; +export 'src/bloc_like/models/sync_log.dart'; +export 'src/bloc_like/events/sync_event.dart'; +export 'src/bloc_like/states/sync_state.dart'; +export 'src/bloc_like/repository/sync_repository.dart'; +export 'src/bloc_like/controller/sync_controller.dart'; +export 'src/bloc_like/engine/offline_sync_engine.dart'; diff --git a/lib/src/bloc_like/contracts/cloud_data_source.dart b/lib/src/bloc_like/contracts/cloud_data_source.dart new file mode 100644 index 0000000..2aff9cd --- /dev/null +++ b/lib/src/bloc_like/contracts/cloud_data_source.dart @@ -0,0 +1,7 @@ +abstract class CloudDataSource { + Future create(T data); + Future update(T data); + Future delete(String id); + Future fetch(String id); + Future> fetchAll(); +} diff --git a/lib/src/bloc_like/contracts/in_memory_sync_log_store.dart b/lib/src/bloc_like/contracts/in_memory_sync_log_store.dart new file mode 100644 index 0000000..5fcfdd9 --- /dev/null +++ b/lib/src/bloc_like/contracts/in_memory_sync_log_store.dart @@ -0,0 +1,43 @@ +import '../models/sync_log.dart'; +import 'sync_log_store.dart'; + +class InMemorySyncLogStore implements SyncLogStore { + final List _logs = []; + + @override + Future add(SyncLog log) async { + _logs.add(log); + } + + @override + Future> getFailedLogs() async { + return _logs.where((log) => log.status == SyncStatus.failed).toList(); + } + + @override + Future> getPendingLogs() async { + return _logs.where((log) => log.status == SyncStatus.pending).toList(); + } + + @override + Future markFailed(String logId) async { + _update(logId, SyncStatus.failed); + } + + @override + Future markSynced(String logId) async { + _update(logId, SyncStatus.synced); + } + + @override + Future markSyncing(String logId) async { + _update(logId, SyncStatus.syncing); + } + + void _update(String logId, SyncStatus status) { + final index = _logs.indexWhere((log) => log.id == logId); + if (index == -1) return; + + _logs[index] = _logs[index].copyWith(status: status); + } +} diff --git a/lib/src/bloc_like/contracts/local_data_source.dart b/lib/src/bloc_like/contracts/local_data_source.dart new file mode 100644 index 0000000..f50a083 --- /dev/null +++ b/lib/src/bloc_like/contracts/local_data_source.dart @@ -0,0 +1,7 @@ +abstract class LocalDataSource { + Future insert(T data); + Future update(T data); + Future delete(String id); + Future getById(String id); + Future> getAll(); +} diff --git a/lib/src/bloc_like/contracts/sync_log_store.dart b/lib/src/bloc_like/contracts/sync_log_store.dart new file mode 100644 index 0000000..89ab77b --- /dev/null +++ b/lib/src/bloc_like/contracts/sync_log_store.dart @@ -0,0 +1,10 @@ +import '../models/sync_log.dart'; + +abstract class SyncLogStore { + Future add(SyncLog log); + Future> getPendingLogs(); + Future> getFailedLogs(); + Future markSyncing(String logId); + Future markSynced(String logId); + Future markFailed(String logId); +} diff --git a/lib/src/bloc_like/controller/sync_controller.dart b/lib/src/bloc_like/controller/sync_controller.dart new file mode 100644 index 0000000..87d97c2 --- /dev/null +++ b/lib/src/bloc_like/controller/sync_controller.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import '../events/sync_event.dart'; +import '../repository/sync_repository.dart'; +import '../states/sync_state.dart'; + +class SyncController { + final SyncRepository repository; + final StreamController _states = + StreamController.broadcast(); + + SyncState _current = const SyncInitial(); + + SyncController({required this.repository}) { + _states.add(_current); + } + + Stream get states => _states.stream; + + SyncState get currentState => _current; + + Future handle(SyncEvent event) async { + _emit(const SyncInProgress()); + + try { + if (event is AddData) { + await repository.add(event.data); + } else if (event is UpdateData) { + await repository.update(event.data); + } else if (event is DeleteData) { + await repository.delete(event.id); + } else if (event is StartSync) { + await repository.syncPending(); + } else if (event is RetryFailed) { + await repository.retryFailed(); + } else { + throw UnsupportedError( + 'Unsupported SyncEvent type: ${event.runtimeType}'); + } + + _emit(const SyncSuccess()); + } catch (e) { + _emit(SyncFailure(e.toString())); + rethrow; + } + } + + void _emit(SyncState state) { + _current = state; + _states.add(state); + } + + Future dispose() async { + await _states.close(); + } +} diff --git a/lib/src/bloc_like/engine/offline_sync_engine.dart b/lib/src/bloc_like/engine/offline_sync_engine.dart new file mode 100644 index 0000000..1b4200e --- /dev/null +++ b/lib/src/bloc_like/engine/offline_sync_engine.dart @@ -0,0 +1,26 @@ +import '../controller/sync_controller.dart'; +import '../events/sync_event.dart'; +import '../states/sync_state.dart'; + +class OfflineSyncEngine { + final SyncController _controller; + + OfflineSyncEngine({required SyncController controller}) + : _controller = controller; + + Stream get states => _controller.states; + + SyncState get currentState => _controller.currentState; + + Future add(T data) => _controller.handle(AddData(data)); + + Future update(T data) => _controller.handle(UpdateData(data)); + + Future delete(String id) => _controller.handle(DeleteData(id)); + + Future sync() => _controller.handle(const StartSync()); + + Future retryFailed() => _controller.handle(const RetryFailed()); + + Future dispose() => _controller.dispose(); +} diff --git a/lib/src/bloc_like/events/sync_event.dart b/lib/src/bloc_like/events/sync_event.dart new file mode 100644 index 0000000..7ae9238 --- /dev/null +++ b/lib/src/bloc_like/events/sync_event.dart @@ -0,0 +1,26 @@ +abstract class SyncEvent { + const SyncEvent(); +} + +class AddData extends SyncEvent { + final T data; + const AddData(this.data); +} + +class UpdateData extends SyncEvent { + final T data; + const UpdateData(this.data); +} + +class DeleteData extends SyncEvent { + final String id; + const DeleteData(this.id); +} + +class StartSync extends SyncEvent { + const StartSync(); +} + +class RetryFailed extends SyncEvent { + const RetryFailed(); +} diff --git a/lib/src/bloc_like/models/sync_log.dart b/lib/src/bloc_like/models/sync_log.dart new file mode 100644 index 0000000..913d105 --- /dev/null +++ b/lib/src/bloc_like/models/sync_log.dart @@ -0,0 +1,35 @@ +enum SyncOperationType { create, update, delete } + +enum SyncStatus { pending, syncing, synced, failed } + +class SyncLog { + final String id; + final String entityId; + final SyncOperationType operation; + final DateTime timestamp; + final SyncStatus status; + + const SyncLog({ + required this.id, + required this.entityId, + required this.operation, + required this.timestamp, + required this.status, + }); + + SyncLog copyWith({ + String? id, + String? entityId, + SyncOperationType? operation, + DateTime? timestamp, + SyncStatus? status, + }) { + return SyncLog( + id: id ?? this.id, + entityId: entityId ?? this.entityId, + operation: operation ?? this.operation, + timestamp: timestamp ?? this.timestamp, + status: status ?? this.status, + ); + } +} diff --git a/lib/src/bloc_like/repository/sync_repository.dart b/lib/src/bloc_like/repository/sync_repository.dart new file mode 100644 index 0000000..b611497 --- /dev/null +++ b/lib/src/bloc_like/repository/sync_repository.dart @@ -0,0 +1,148 @@ +import '../contracts/cloud_data_source.dart'; +import '../contracts/local_data_source.dart'; +import '../contracts/sync_log_store.dart'; +import '../models/sync_log.dart'; + +typedef EntityIdResolver = String Function(T value); + +class SyncRepository { + final LocalDataSource local; + final CloudDataSource cloud; + final SyncLogStore logStore; + final EntityIdResolver idResolver; + + SyncRepository({ + required this.local, + required this.cloud, + required this.logStore, + required this.idResolver, + }); + + Future add(T data) async { + final id = idResolver(data); + await local.insert(data); + await logStore.add( + SyncLog( + id: _logId(), + entityId: id, + operation: SyncOperationType.create, + try { + await logStore.add( + SyncLog( + id: _logId(), + entityId: id, + operation: SyncOperationType.create, + timestamp: DateTime.now(), + status: SyncStatus.pending, + ), + ); + } catch (_) { + // Roll back local insert if logging the sync intent fails. + await local.delete(id); + rethrow; + } + } + + Future update(T data) async { + final id = idResolver(data); + // Capture previous state to allow rollback if logging fails. + final previous = await local.getById(id); + await local.update(data); + try { + await logStore.add( + SyncLog( + id: _logId(), + entityId: id, + operation: SyncOperationType.update, + timestamp: DateTime.now(), + status: SyncStatus.pending, + ), + ); + } catch (_) { + // Attempt to restore previous state if available. + if (previous != null) { + await local.update(previous); + } + rethrow; + } + } + + Future delete(String id) async { + // Capture the entity before deletion to allow rollback if logging fails. + final existing = await local.getById(id); + await local.delete(id); + try { + await logStore.add( + SyncLog( + id: _logId(), + entityId: id, + operation: SyncOperationType.delete, + timestamp: DateTime.now(), + status: SyncStatus.pending, + ), + ); + } catch (_) { + // Attempt to restore the deleted entity if it previously existed. + if (existing != null) { + await local.insert(existing); + } + rethrow; + } + } + + Future syncPending() async { + final logs = await logStore.getPendingLogs(); + await _syncLogs(logs); + } + + Future retryFailed() async { + final logs = await logStore.getFailedLogs(); + await _syncLogs(logs); + } + + Future _syncLogs(List logs) async { + final sorted = [...logs] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + for (final log in sorted) { + try { + await logStore.markSyncing(log.id); + await _execute(log); + await logStore.markSynced(log.id); + } catch (e, s) { + await logStore.markFailed(log.id); + rethrow; + } + } + } + + Future _execute(SyncLog log) async { + switch (log.operation) { + case SyncOperationType.create: + final data = await local.getById(log.entityId); + if (data == null) { + throw StateError( + 'Local data not found for create operation on entityId ${log.entityId}', + ); + } + await cloud.create(data); + return; + case SyncOperationType.update: + final data = await local.getById(log.entityId); + if (data == null) { + // Treat missing local data as an error so the log is marked as failed. + throw StateError('Cannot update: local entity not found for id ${log.entityId}'); + } + await cloud.update(data); + return; + case SyncOperationType.delete: + await cloud.delete(log.entityId); + return; + } + } + + String _logId() { + final micros = DateTime.now().microsecondsSinceEpoch; + return 'sync_log_$micros'; + } +} diff --git a/lib/src/bloc_like/states/sync_state.dart b/lib/src/bloc_like/states/sync_state.dart new file mode 100644 index 0000000..3f85e63 --- /dev/null +++ b/lib/src/bloc_like/states/sync_state.dart @@ -0,0 +1,20 @@ +abstract class SyncState { + const SyncState(); +} + +class SyncInitial extends SyncState { + const SyncInitial(); +} + +class SyncInProgress extends SyncState { + const SyncInProgress(); +} + +class SyncSuccess extends SyncState { + const SyncSuccess(); +} + +class SyncFailure extends SyncState { + final String message; + const SyncFailure(this.message); +} diff --git a/test/bloc_like_architecture_test.dart b/test/bloc_like_architecture_test.dart new file mode 100644 index 0000000..8861b25 --- /dev/null +++ b/test/bloc_like_architecture_test.dart @@ -0,0 +1,86 @@ +import 'package:offline_sync_engine/offline_sync_engine.dart'; +import 'package:test/test.dart'; + +class _User { + final String id; + final String name; + + const _User(this.id, this.name); +} + +class _InMemoryLocalUsers implements LocalDataSource<_User> { + final Map _users = {}; + + @override + Future delete(String id) async { + _users.remove(id); + } + + @override + Future> getAll() async => _users.values.toList(); + + @override + Future<_User?> getById(String id) async => _users[id]; + + @override + Future insert(_User data) async { + _users[data.id] = data; + } + + @override + Future update(_User data) async { + _users[data.id] = data; + } +} + +class _InMemoryCloudUsers implements CloudDataSource<_User> { + final Map _users = {}; + + @override + Future create(_User data) async { + _users[data.id] = data; + } + + @override + Future delete(String id) async { + _users.remove(id); + } + + @override + Future<_User?> fetch(String id) async => _users[id]; + + @override + Future> fetchAll() async => _users.values.toList(); + + @override + Future update(_User data) async { + _users[data.id] = data; + } +} + +void main() { + test('offline sync engine handles add + sync with bloc-like controller', () async { + final local = _InMemoryLocalUsers(); + final cloud = _InMemoryCloudUsers(); + + final repository = SyncRepository<_User>( + local: local, + cloud: cloud, + logStore: InMemorySyncLogStore(), + idResolver: (user) => user.id, + ); + + final controller = SyncController<_User>(repository: repository); + final engine = OfflineSyncEngine<_User>(controller: controller); + + await engine.add(const _User('1', 'Asha')); + await engine.sync(); + + final remote = await cloud.fetch('1'); + + expect(remote?.name, 'Asha'); + expect(engine.currentState, isA()); + + await engine.dispose(); + }); +}