Default local storage to Hive with legacy migration
This commit is contained in:
+7
-2
@@ -26,7 +26,8 @@ flutter run --dart-define=USE_FAKE_BACKEND=true
|
|||||||
|
|
||||||
Default local store:
|
Default local store:
|
||||||
|
|
||||||
- `shared_preferences` (stable baseline)
|
- `Hive` (`USE_HIVE_LOCAL_DB=true` by default)
|
||||||
|
- first-run migration imports legacy `shared_preferences` payload when present
|
||||||
|
|
||||||
Optional migration path:
|
Optional migration path:
|
||||||
|
|
||||||
@@ -34,7 +35,11 @@ Optional migration path:
|
|||||||
flutter run --dart-define=USE_HIVE_LOCAL_DB=true
|
flutter run --dart-define=USE_HIVE_LOCAL_DB=true
|
||||||
```
|
```
|
||||||
|
|
||||||
When enabled, local app state is stored in Hive via the `LocalDataStore` adapter layer.
|
Use legacy mode explicitly if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --dart-define=USE_HIVE_LOCAL_DB=false
|
||||||
|
```
|
||||||
|
|
||||||
## Run Tests
|
## Run Tests
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,16 @@ Implemented a persistence boundary so local app state is no longer tightly coupl
|
|||||||
- schema migration hook retained and now store-agnostic
|
- schema migration hook retained and now store-agnostic
|
||||||
- Added config flag for local backend selection:
|
- Added config flag for local backend selection:
|
||||||
- `lib/core/config/app_config.dart` -> `USE_HIVE_LOCAL_DB`
|
- `lib/core/config/app_config.dart` -> `USE_HIVE_LOCAL_DB`
|
||||||
|
- default now set to Hive (`true`)
|
||||||
|
- Added one-time migration path from legacy shared prefs into Hive:
|
||||||
|
- `lib/features/local/local_repository.dart`
|
||||||
|
- when Hive is active and empty, legacy payload is imported then cleared
|
||||||
- Added setup docs for switching local persistence backend:
|
- Added setup docs for switching local persistence backend:
|
||||||
- `docs/SETUP.md`
|
- `docs/SETUP.md`
|
||||||
|
- Added migration/storage tests:
|
||||||
|
- `test/features/local/storage/local_data_store_shared_prefs_test.dart`
|
||||||
|
- `test/features/local/storage/local_repository_store_override_test.dart`
|
||||||
|
- `test/features/local/storage/local_repository_hive_migration_test.dart`
|
||||||
|
|
||||||
This prepares the app for a true DB migration path while keeping existing UX and tests stable.
|
This prepares the app for a true DB migration path while keeping existing UX and tests stable.
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class AppConfig {
|
|||||||
|
|
||||||
/// Enables Hive local persistence store instead of shared_preferences.
|
/// Enables Hive local persistence store instead of shared_preferences.
|
||||||
static bool get useHiveLocalDb =>
|
static bool get useHiveLocalDb =>
|
||||||
const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: false);
|
const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: true);
|
||||||
|
|
||||||
/// Runtime override for backend URL (e.g. local settings screen).
|
/// Runtime override for backend URL (e.g. local settings screen).
|
||||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_config.dart';
|
||||||
import 'package:relationship_saver/features/local/local_models.dart';
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
|
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart';
|
||||||
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart';
|
||||||
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -18,7 +21,23 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
@override
|
@override
|
||||||
Future<LocalDataState> build() async {
|
Future<LocalDataState> build() async {
|
||||||
final LocalDataStore store = ref.read(localDataStoreProvider);
|
final LocalDataStore store = ref.read(localDataStoreProvider);
|
||||||
final LocalDataRecord? record = await store.read();
|
LocalDataRecord? record = await store.read();
|
||||||
|
|
||||||
|
if ((record == null || record.rawState.isEmpty) &&
|
||||||
|
AppConfig.useHiveLocalDb &&
|
||||||
|
store is HiveLocalDataStore) {
|
||||||
|
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
|
||||||
|
final LocalDataRecord? legacy = await legacyStore.read();
|
||||||
|
if (legacy != null && legacy.rawState.isNotEmpty) {
|
||||||
|
await store.write(
|
||||||
|
rawState: legacy.rawState,
|
||||||
|
schemaVersion: legacy.schemaVersion,
|
||||||
|
);
|
||||||
|
await legacyStore.clear();
|
||||||
|
record = await store.read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (record == null || record.rawState.isEmpty) {
|
if (record == null || record.rawState.isEmpty) {
|
||||||
final LocalDataState seeded = LocalDataState.seed();
|
final LocalDataState seeded = LocalDataState.seed();
|
||||||
await _persist(seeded);
|
await _persist(seeded);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:relationship_saver/features/local/local_models.dart';
|
import 'package:relationship_saver/features/local/local_models.dart';
|
||||||
import 'package:relationship_saver/features/local/local_repository.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:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -10,7 +12,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('seeds local data on first load', () async {
|
test('seeds local data on first load', () async {
|
||||||
final ProviderContainer container = ProviderContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final state = await container.read(localRepositoryProvider.future);
|
final state = await container.read(localRepositoryProvider.future);
|
||||||
@@ -21,7 +23,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds and removes person with persistence state update', () async {
|
test('adds and removes person with persistence state update', () async {
|
||||||
final ProviderContainer container = ProviderContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final initial = await container.read(localRepositoryProvider.future);
|
final initial = await container.read(localRepositoryProvider.future);
|
||||||
@@ -49,7 +51,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds and removes capture moments', () async {
|
test('adds and removes capture moments', () async {
|
||||||
final ProviderContainer container = ProviderContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final state = await container.read(localRepositoryProvider.future);
|
final state = await container.read(localRepositoryProvider.future);
|
||||||
@@ -73,7 +75,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds, archives, edits, and deletes ideas', () async {
|
test('adds, archives, edits, and deletes ideas', () async {
|
||||||
final ProviderContainer container = ProviderContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final LocalDataState state = await container.read(
|
final LocalDataState state = await container.read(
|
||||||
@@ -124,7 +126,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds, updates, toggles, and deletes reminders', () async {
|
test('adds, updates, toggles, and deletes reminders', () async {
|
||||||
final ProviderContainer container = ProviderContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
final LocalDataState state = await container.read(
|
final LocalDataState state = await container.read(
|
||||||
@@ -172,3 +174,11 @@ void main() {
|
|||||||
expect(afterDelete.reminders.length, beforeCount);
|
expect(afterDelete.reminders.length, beforeCount);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProviderContainer _createContainer() {
|
||||||
|
return ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/local/local_models.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
||||||
|
import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'migrates legacy shared prefs payload into hive store on first load',
|
||||||
|
() async {
|
||||||
|
final LocalDataState legacyState = LocalDataState.seed().copyWith(
|
||||||
|
people: <PersonProfile>[
|
||||||
|
PersonProfile(
|
||||||
|
id: 'legacy-person-1',
|
||||||
|
name: 'Legacy Person',
|
||||||
|
relationship: 'Friend',
|
||||||
|
affinityScore: 82,
|
||||||
|
nextMoment: DateTime(2026, 2, 18, 19),
|
||||||
|
tags: <String>['legacy'],
|
||||||
|
notes: 'from shared prefs',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
|
||||||
|
await legacyStore.write(
|
||||||
|
rawState: jsonEncode(legacyState.toJson()),
|
||||||
|
schemaVersion: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
final _FakeHiveStore hiveStore = _FakeHiveStore();
|
||||||
|
final ProviderContainer container = ProviderContainer(
|
||||||
|
overrides: [localDataStoreProvider.overrideWithValue(hiveStore)],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final LocalDataState loaded = await container.read(
|
||||||
|
localRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loaded.people.first.name, 'Legacy Person');
|
||||||
|
|
||||||
|
final LocalDataRecord? migrated = await hiveStore.read();
|
||||||
|
expect(migrated, isNotNull);
|
||||||
|
expect(migrated!.schemaVersion, 2);
|
||||||
|
|
||||||
|
final LocalDataRecord? legacyAfter = await legacyStore.read();
|
||||||
|
expect(legacyAfter, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeHiveStore extends HiveLocalDataStore {
|
||||||
|
LocalDataRecord? _record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
_record = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocalDataRecord?> read() async => _record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write({
|
||||||
|
required String rawState,
|
||||||
|
required int schemaVersion,
|
||||||
|
}) async {
|
||||||
|
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import 'package:relationship_saver/features/auth/sign_in_view.dart';
|
|||||||
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
|
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
|
||||||
import 'package:relationship_saver/features/home/app_shell.dart';
|
import 'package:relationship_saver/features/home/app_shell.dart';
|
||||||
import 'package:relationship_saver/features/ideas/ideas_view.dart';
|
import 'package:relationship_saver/features/ideas/ideas_view.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/moments/moments_view.dart';
|
import 'package:relationship_saver/features/moments/moments_view.dart';
|
||||||
import 'package:relationship_saver/features/people/people_view.dart';
|
import 'package:relationship_saver/features/people/people_view.dart';
|
||||||
import 'package:relationship_saver/features/reminders/reminders_view.dart';
|
import 'package:relationship_saver/features/reminders/reminders_view.dart';
|
||||||
@@ -99,6 +101,7 @@ Future<void> pumpResponsive(
|
|||||||
overrides: [
|
overrides: [
|
||||||
tokenStoreProvider.overrideWithValue(InMemoryTokenStore()),
|
tokenStoreProvider.overrideWithValue(InMemoryTokenStore()),
|
||||||
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
|
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
|
||||||
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: home),
|
child: MaterialApp(theme: AppTheme.light(), home: home),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:relationship_saver/features/local/local_repository.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/sync_coordinator.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_queue_repository.dart';
|
||||||
import 'package:relationship_saver/features/sync/sync_state.dart';
|
import 'package:relationship_saver/features/sync/sync_state.dart';
|
||||||
@@ -16,7 +18,10 @@ void main() {
|
|||||||
|
|
||||||
test('pushPending flushes queued local mutations', () async {
|
test('pushPending flushes queued local mutations', () async {
|
||||||
final ProviderContainer container = ProviderContainer(
|
final ProviderContainer container = ProviderContainer(
|
||||||
overrides: [backendGatewayProvider.overrideWithValue(_TestGateway())],
|
overrides: [
|
||||||
|
backendGatewayProvider.overrideWithValue(_TestGateway()),
|
||||||
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
@@ -77,6 +82,7 @@ void main() {
|
|||||||
backendGatewayProvider.overrideWithValue(
|
backendGatewayProvider.overrideWithValue(
|
||||||
_TestGateway(pullResult: pullResult),
|
_TestGateway(pullResult: pullResult),
|
||||||
),
|
),
|
||||||
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|||||||
Reference in New Issue
Block a user