diff --git a/docs/SETUP.md b/docs/SETUP.md index a9cd594..5365ecd 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -28,6 +28,9 @@ Default local store: - `Hive` (`USE_HIVE_LOCAL_DB=true` by default) - first-run migration imports legacy `shared_preferences` payload when present +- applies to both: + - local product state (`LocalRepository`) + - sync queue state (`SyncQueueRepository`) Optional migration path: diff --git a/docs/progress.md b/docs/progress.md index af9b482..b9bb867 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,35 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Sync Queue Storage Adapter + Hive Migration + +Extended the storage abstraction approach to sync queue persistence: + +- Added sync storage abstraction: + - `lib/features/sync/storage/sync_state_store.dart` +- Added concrete sync stores: + - `lib/features/sync/storage/sync_state_store_shared_prefs.dart` + - `lib/features/sync/storage/sync_state_store_hive.dart` + - `lib/features/sync/storage/sync_state_store_in_memory.dart` + - provider wiring: `lib/features/sync/storage/sync_state_store_provider.dart` +- Refactored sync queue repository: + - `lib/features/sync/sync_queue_repository.dart` + - no direct `shared_preferences` dependency + - store-agnostic read/write path via `SyncStateStore` + - one-time migration path from legacy shared prefs sync payload into Hive +- Updated tests to use explicit in-memory store overrides where deterministic: + - `test/features/sync/sync_queue_repository_test.dart` + - `test/features/sync/sync_coordinator_test.dart` + - `test/features/local/local_repository_test.dart` + - `test/features/responsive/responsive_views_smoke_test.dart` +- Added migration coverage: + - `test/features/sync/storage/sync_queue_hive_migration_test.dart` + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Local Store Adapter Layer (DB Migration Scaffold) Implemented a persistence boundary so local app state is no longer tightly coupled to `shared_preferences` internals: diff --git a/lib/features/sync/storage/sync_state_store.dart b/lib/features/sync/storage/sync_state_store.dart new file mode 100644 index 0000000..37e8820 --- /dev/null +++ b/lib/features/sync/storage/sync_state_store.dart @@ -0,0 +1,18 @@ +/// Raw persisted payload for sync queue state. +class SyncStateRecord { + const SyncStateRecord({required this.rawState}); + + final String rawState; +} + +/// Persistence boundary for sync queue metadata and pending changes. +abstract interface class SyncStateStore { + /// Reads stored sync state payload. + Future read(); + + /// Writes sync state payload. + Future write({required String rawState}); + + /// Clears stored sync state payload. + Future clear(); +} diff --git a/lib/features/sync/storage/sync_state_store_hive.dart b/lib/features/sync/storage/sync_state_store_hive.dart new file mode 100644 index 0000000..1bce059 --- /dev/null +++ b/lib/features/sync/storage/sync_state_store_hive.dart @@ -0,0 +1,46 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; + +/// Hive-backed sync state store. +class HiveSyncStateStore implements SyncStateStore { + static const String _boxName = 'relationship_saver_sync'; + static const String _stateKey = 'sync_state_json'; + + static bool _initialized = false; + + @override + Future clear() async { + final Box box = await _openBox(); + await box.delete(_stateKey); + } + + @override + Future read() async { + final Box box = await _openBox(); + final String? rawState = box.get(_stateKey) as String?; + if (rawState == null || rawState.isEmpty) { + return null; + } + + return SyncStateRecord(rawState: rawState); + } + + @override + Future write({required String rawState}) async { + final Box box = await _openBox(); + await box.put(_stateKey, rawState); + } + + Future> _openBox() async { + if (!_initialized) { + await Hive.initFlutter(); + _initialized = true; + } + + if (!Hive.isBoxOpen(_boxName)) { + return Hive.openBox(_boxName); + } + + return Hive.box(_boxName); + } +} diff --git a/lib/features/sync/storage/sync_state_store_in_memory.dart b/lib/features/sync/storage/sync_state_store_in_memory.dart new file mode 100644 index 0000000..6b57488 --- /dev/null +++ b/lib/features/sync/storage/sync_state_store_in_memory.dart @@ -0,0 +1,19 @@ +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; + +/// In-memory sync state store for tests. +class InMemorySyncStateStore implements SyncStateStore { + SyncStateRecord? _record; + + @override + Future clear() async { + _record = null; + } + + @override + Future read() async => _record; + + @override + Future write({required String rawState}) async { + _record = SyncStateRecord(rawState: rawState); + } +} diff --git a/lib/features/sync/storage/sync_state_store_provider.dart b/lib/features/sync/storage/sync_state_store_provider.dart new file mode 100644 index 0000000..8cec632 --- /dev/null +++ b/lib/features/sync/storage/sync_state_store_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart'; + +/// Chooses sync queue persistence backend. +final Provider syncStateStoreProvider = + Provider((Ref ref) { + if (AppConfig.useHiveLocalDb) { + return HiveSyncStateStore(); + } + return SharedPrefsSyncStateStore(); + }); diff --git a/lib/features/sync/storage/sync_state_store_shared_prefs.dart b/lib/features/sync/storage/sync_state_store_shared_prefs.dart new file mode 100644 index 0000000..2ce0195 --- /dev/null +++ b/lib/features/sync/storage/sync_state_store_shared_prefs.dart @@ -0,0 +1,32 @@ +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// shared_preferences-backed sync state store. +class SharedPrefsSyncStateStore implements SyncStateStore { + SharedPrefsSyncStateStore({this.stateKey = 'sync_queue_state_v1'}); + + final String stateKey; + + @override + Future clear() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(stateKey); + } + + @override + Future read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? rawState = prefs.getString(stateKey); + if (rawState == null || rawState.isEmpty) { + return null; + } + + return SyncStateRecord(rawState: rawState); + } + + @override + Future write({required String rawState}) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(stateKey, rawState); + } +} diff --git a/lib/features/sync/sync_queue_repository.dart b/lib/features/sync/sync_queue_repository.dart index 21864dc..d3a7a02 100644 --- a/lib/features/sync/sync_queue_repository.dart +++ b/lib/features/sync/sync_queue_repository.dart @@ -1,27 +1,43 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart'; import 'package:relationship_saver/features/sync/sync_state.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; -import 'package:shared_preferences/shared_preferences.dart'; /// Persists queued local mutations and sync cursor metadata. class SyncQueueRepository extends AsyncNotifier { - static const String _storageKey = 'sync_queue_state_v1'; - @override Future build() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - final String? raw = prefs.getString(_storageKey); - if (raw == null || raw.isEmpty) { + final SyncStateStore store = ref.read(syncStateStoreProvider); + SyncStateRecord? record = await store.read(); + + if ((record == null || record.rawState.isEmpty) && + AppConfig.useHiveLocalDb && + store is HiveSyncStateStore) { + final SharedPrefsSyncStateStore legacyStore = SharedPrefsSyncStateStore(); + final SyncStateRecord? legacy = await legacyStore.read(); + if (legacy != null && legacy.rawState.isNotEmpty) { + await store.write(rawState: legacy.rawState); + await legacyStore.clear(); + record = await store.read(); + } + } + + if (record == null || record.rawState.isEmpty) { return SyncState.empty; } try { - final Map json = jsonDecode(raw) as Map; + final Map json = + jsonDecode(record.rawState) as Map; return SyncState.fromJson(json); } on FormatException { - await prefs.remove(_storageKey); + await store.clear(); return SyncState.empty; } } @@ -121,8 +137,9 @@ class SyncQueueRepository extends AsyncNotifier { Future _setState(SyncState next) async { state = AsyncData(next); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_storageKey, jsonEncode(next.toJson())); + await ref + .read(syncStateStoreProvider) + .write(rawState: jsonEncode(next.toJson())); } } diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 960f8a0..f991bbe 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -4,6 +4,8 @@ import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -179,6 +181,7 @@ ProviderContainer _createContainer() { return ProviderContainer( overrides: [ localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), ], ); } diff --git a/test/features/responsive/responsive_views_smoke_test.dart b/test/features/responsive/responsive_views_smoke_test.dart index 83dea43..5eec92e 100644 --- a/test/features/responsive/responsive_views_smoke_test.dart +++ b/test/features/responsive/responsive_views_smoke_test.dart @@ -14,6 +14,8 @@ import 'package:relationship_saver/features/people/people_view.dart'; import 'package:relationship_saver/features/reminders/reminders_view.dart'; import 'package:relationship_saver/features/settings/settings_view.dart'; import 'package:relationship_saver/features/signals/signals_view.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:relationship_saver/features/sync/sync_view.dart'; import 'package:relationship_saver/integrations/backend/backend_gateway_fake.dart'; import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; @@ -102,6 +104,7 @@ Future pumpResponsive( tokenStoreProvider.overrideWithValue(InMemoryTokenStore()), backendGatewayProvider.overrideWithValue(BackendGatewayFake()), localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), ], child: MaterialApp(theme: AppTheme.light(), home: home), ), diff --git a/test/features/sync/storage/sync_queue_hive_migration_test.dart b/test/features/sync/storage/sync_queue_hive_migration_test.dart new file mode 100644 index 0000000..1c74e60 --- /dev/null +++ b/test/features/sync/storage/sync_queue_hive_migration_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'migrates legacy shared prefs sync queue payload into hive store', + () async { + final SyncStateRecord legacyRecord = SyncStateRecord( + rawState: jsonEncode({ + 'cursor': 'legacy-cursor', + 'pendingChanges': >[ + ChangeEnvelope( + schemaVersion: 1, + entityType: 'person', + entityId: 'legacy-person', + op: ChangeOperation.upsert, + modifiedAt: DateTime(2026, 2, 15, 10).toUtc(), + clientMutationId: 'legacy-cm-1', + payload: const {'name': 'Legacy'}, + ).toJson(), + ], + 'lastSyncAt': null, + 'lastAttemptAt': null, + 'lastError': null, + 'lastRejected': >[], + }), + ); + + final SharedPrefsSyncStateStore legacyStore = SharedPrefsSyncStateStore(); + await legacyStore.write(rawState: legacyRecord.rawState); + + final _FakeHiveSyncStore hiveStore = _FakeHiveSyncStore(); + final ProviderContainer container = ProviderContainer( + overrides: [syncStateStoreProvider.overrideWithValue(hiveStore)], + ); + addTearDown(container.dispose); + + final state = await container.read(syncQueueRepositoryProvider.future); + + expect(state.cursor, 'legacy-cursor'); + expect(state.pendingChanges.length, 1); + expect(state.pendingChanges.first.clientMutationId, 'legacy-cm-1'); + + final SyncStateRecord? migrated = await hiveStore.read(); + expect(migrated, isNotNull); + + final SyncStateRecord? legacyAfter = await legacyStore.read(); + expect(legacyAfter, isNull); + }, + ); +} + +class _FakeHiveSyncStore extends HiveSyncStateStore { + SyncStateRecord? _record; + + @override + Future clear() async { + _record = null; + } + + @override + Future read() async => _record; + + @override + Future write({required String rawState}) async { + _record = SyncStateRecord(rawState: rawState); + } +} diff --git a/test/features/sync/sync_coordinator_test.dart b/test/features/sync/sync_coordinator_test.dart index 522a1f3..af0b95e 100644 --- a/test/features/sync/sync_coordinator_test.dart +++ b/test/features/sync/sync_coordinator_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/features/sync/sync_state.dart'; @@ -21,6 +23,7 @@ void main() { overrides: [ backendGatewayProvider.overrideWithValue(_TestGateway()), localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), ], ); addTearDown(container.dispose); @@ -83,6 +86,7 @@ void main() { _TestGateway(pullResult: pullResult), ), localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), ], ); addTearDown(container.dispose); diff --git a/test/features/sync/sync_queue_repository_test.dart b/test/features/sync/sync_queue_repository_test.dart index 5d7c091..705d2cf 100644 --- a/test/features/sync/sync_queue_repository_test.dart +++ b/test/features/sync/sync_queue_repository_test.dart @@ -1,16 +1,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; -import 'package:shared_preferences/shared_preferences.dart'; void main() { - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - test('enqueue persists and can be reloaded', () async { - final ProviderContainer container = ProviderContainer(); + final InMemorySyncStateStore store = InMemorySyncStateStore(); + final ProviderContainer container = ProviderContainer( + overrides: [syncStateStoreProvider.overrideWithValue(store)], + ); await container .read(syncQueueRepositoryProvider.notifier) @@ -20,7 +20,9 @@ void main() { container.dispose(); - final ProviderContainer reloaded = ProviderContainer(); + final ProviderContainer reloaded = ProviderContainer( + overrides: [syncStateStoreProvider.overrideWithValue(store)], + ); addTearDown(reloaded.dispose); final SyncStateData reloadedState = await _readState(reloaded); expect(reloadedState.pending.length, 1); @@ -30,7 +32,11 @@ void main() { test( 'applyPushResult removes completed mutation ids and tracks rejections', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = ProviderContainer( + overrides: [ + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + ], + ); addTearDown(container.dispose); final SyncQueueRepository notifier = container.read(