mirror of https://github.com/immich-app/immich.git
refactor(mobile): split store into repo and service (#16199)
* refactor(mobile): migrate store * refactor(mobile): expand abbreviations * chore(mobile): fix lint --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/16201/head
parent
8634c59850
commit
aeb3e0a84f
@ -0,0 +1,34 @@
|
||||
# Domain Layer
|
||||
|
||||
This directory contains the domain layer of Immich. The domain layer is responsible for the business logic of the app. It includes interfaces for repositories, models, services and utilities. This layer should never depend on anything from the presentation layer or from the infrastructure layer.
|
||||
|
||||
## Structure
|
||||
|
||||
- **[Interfaces](./interfaces/)**: These are the interfaces that define the contract for data operations.
|
||||
- **[Models](./models/)**: These are the core data classes that represent the business models.
|
||||
- **[Services](./services/)**: These are the classes that contain the business logic and interact with the repositories.
|
||||
- **[Utils](./utils/)**: These are utility classes and functions that provide common functionalities used across the domain layer.
|
||||
|
||||
```
|
||||
domain/
|
||||
├── interfaces/
|
||||
│ └── user.interface.dart
|
||||
├── models/
|
||||
│ └── user.model.dart
|
||||
├── services/
|
||||
│ └── user.service.dart
|
||||
└── utils/
|
||||
└── date_utils.dart
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The domain layer provides services that implement the business logic by consuming repositories through dependency injection. Services are exposed through Riverpod providers in the root `providers` directory.
|
||||
|
||||
```dart
|
||||
// In presentation layer
|
||||
final userService = ref.watch(userServiceProvider);
|
||||
final user = await userService.getUser(userId);
|
||||
```
|
||||
|
||||
The presentation layer should never directly use repositories, but instead interact with the domain layer through services.
|
||||
@ -0,0 +1,3 @@
|
||||
abstract interface class IDatabaseRepository {
|
||||
Future<T> transaction<T>(Future<T> Function() callback);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
abstract interface class IStoreRepository {
|
||||
Future<bool> insert<T>(StoreKey<T> key, T value);
|
||||
|
||||
Future<T?> tryGet<T>(StoreKey<T> key);
|
||||
|
||||
Stream<T?> watch<T>(StoreKey<T> key);
|
||||
|
||||
Stream<StoreUpdateEvent> watchAll();
|
||||
|
||||
Future<bool> update<T>(StoreKey<T> key, T value);
|
||||
|
||||
Future<void> delete<T>(StoreKey<T> key);
|
||||
|
||||
Future<void> deleteAll();
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
class StoreService {
|
||||
final IStoreRepository _storeRepository;
|
||||
|
||||
final Map<int, dynamic> _cache = {};
|
||||
late final StreamSubscription<StoreUpdateEvent> _storeUpdateSubscription;
|
||||
|
||||
StoreService._({
|
||||
required IStoreRepository storeRepository,
|
||||
}) : _storeRepository = storeRepository;
|
||||
|
||||
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
|
||||
static StoreService? _instance;
|
||||
static StoreService get I {
|
||||
if (_instance == null) {
|
||||
throw UnsupportedError("StoreService not initialized. Call init() first");
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
// TODO: Replace the implementation with the one from create after removing the typedef
|
||||
/// Initializes the store with the given [storeRepository]
|
||||
static Future<StoreService> init({
|
||||
required IStoreRepository storeRepository,
|
||||
}) async {
|
||||
_instance ??= await create(storeRepository: storeRepository);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// Initializes the store with the given [storeRepository]
|
||||
static Future<StoreService> create({
|
||||
required IStoreRepository storeRepository,
|
||||
}) async {
|
||||
final instance = StoreService._(storeRepository: storeRepository);
|
||||
await instance._populateCache();
|
||||
instance._storeUpdateSubscription = instance._listenForChange();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// Fills the cache with the values from the DB
|
||||
Future<void> _populateCache() async {
|
||||
for (StoreKey key in StoreKey.values) {
|
||||
final storeValue = await _storeRepository.tryGet(key);
|
||||
_cache[key.id] = storeValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// Listens for changes in the DB and updates the cache
|
||||
StreamSubscription<StoreUpdateEvent> _listenForChange() =>
|
||||
_storeRepository.watchAll().listen((event) {
|
||||
_cache[event.key.id] = event.value;
|
||||
});
|
||||
|
||||
/// Disposes the store and cancels the subscription. To reuse the store call init() again
|
||||
void dispose() async {
|
||||
await _storeUpdateSubscription.cancel();
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
/// Returns the stored value for the given key (possibly null)
|
||||
T? tryGet<T>(StoreKey<T> key) => _cache[key.id];
|
||||
|
||||
/// Returns the stored value for the given key or if null the [defaultValue]
|
||||
/// Throws a [StoreKeyNotFoundException] if both are null
|
||||
T get<T>(StoreKey<T> key, [T? defaultValue]) {
|
||||
final value = tryGet(key) ?? defaultValue;
|
||||
if (value == null) {
|
||||
throw StoreKeyNotFoundException(key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Asynchronously stores the value in the DB and synchronously in the cache
|
||||
Future<void> put<T>(StoreKey<T> key, T value) async {
|
||||
if (_cache[key.id] == value) return;
|
||||
await _storeRepository.insert(key, value);
|
||||
_cache[key.id] = value;
|
||||
}
|
||||
|
||||
/// Watches a specific key for changes
|
||||
Stream<T?> watch<T>(StoreKey<T> key) => _storeRepository.watch(key);
|
||||
|
||||
/// Removes the value asynchronously from the DB and synchronously from the cache
|
||||
Future<void> delete<T>(StoreKey<T> key) async {
|
||||
await _storeRepository.delete(key);
|
||||
_cache.remove(key.id);
|
||||
}
|
||||
|
||||
/// Clears all values from this store (cache and DB)
|
||||
Future<void> clear() async {
|
||||
await _storeRepository.deleteAll();
|
||||
_cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class StoreKeyNotFoundException implements Exception {
|
||||
final StoreKey key;
|
||||
const StoreKeyNotFoundException(this.key);
|
||||
|
||||
@override
|
||||
String toString() => "Key - <${key.name}> not available in Store";
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
# Infrastructure Layer
|
||||
|
||||
This directory contains the infrastructure layer of Immich. The infrastructure layer is responsible for the implementation details of the app. It includes data sources, APIs, and other external dependencies.
|
||||
|
||||
## Structure
|
||||
|
||||
- **[Entities](./entities/)**: These are the classes that define the database schema for the domain models.
|
||||
- **[Repositories](./repositories/)**: These are the actual implementation of the domain interfaces. A single interface might have multiple implementations.
|
||||
- **[Utils](./utils/)**: These are utility classes and functions specific to infrastructure implementations.
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── entities/
|
||||
│ └── user.entity.dart
|
||||
├── repositories/
|
||||
│ └── user.repository.dart
|
||||
└── utils/
|
||||
└── database_utils.dart
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The infrastructure layer provides concrete implementations of repository interfaces defined in the domain layer. These implementations are exposed through Riverpod providers in the root `providers` directory.
|
||||
|
||||
```dart
|
||||
// In domain/services/user.service.dart
|
||||
final userRepository = ref.watch(userRepositoryProvider);
|
||||
final user = await userRepository.getUser(userId);
|
||||
```
|
||||
|
||||
The domain layer should never directly instantiate repository implementations, but instead receive them through dependency injection.
|
||||
@ -0,0 +1,12 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'store.entity.g.dart';
|
||||
|
||||
/// Internal class for `Store`, do not use elsewhere.
|
||||
@Collection(inheritance: false)
|
||||
class StoreValue {
|
||||
const StoreValue(this.id, {this.intValue, this.strValue});
|
||||
final Id id;
|
||||
final int? intValue;
|
||||
final String? strValue;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
|
||||
// ref: isar/isar_common.dart
|
||||
const Symbol _kzoneTxn = #zoneTxn;
|
||||
|
||||
class IsarDatabaseRepository implements IDatabaseRepository {
|
||||
final Isar _db;
|
||||
const IsarDatabaseRepository(Isar db) : _db = db;
|
||||
|
||||
// Isar do not support nested transactions. This is a workaround to prevent us from making nested transactions
|
||||
// Reuse the current transaction if it is already active, else start a new transaction
|
||||
@override
|
||||
Future<T> transaction<T>(Future<T> Function() callback) =>
|
||||
Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback();
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/repositories/user.repository.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class IsarStoreRepository extends IsarDatabaseRepository
|
||||
implements IStoreRepository {
|
||||
final Isar _db;
|
||||
const IsarStoreRepository(super.db) : _db = db;
|
||||
|
||||
@override
|
||||
Future<bool> deleteAll() async {
|
||||
return await transaction(() async {
|
||||
await _db.storeValues.clear();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<StoreUpdateEvent> watchAll() {
|
||||
return _db.storeValues.where().watch().asyncExpand(
|
||||
(entities) =>
|
||||
Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete<T>(StoreKey<T> key) async {
|
||||
return await transaction(() async => await _db.storeValues.delete(key.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> insert<T>(StoreKey<T> key, T value) async {
|
||||
return await transaction(() async {
|
||||
await _db.storeValues.put(await _fromValue(key, value));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> tryGet<T>(StoreKey<T> key) async {
|
||||
final entity = (await _db.storeValues.get(key.id));
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return await _toValue(key, entity);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> update<T>(StoreKey<T> key, T value) async {
|
||||
return await transaction(() async {
|
||||
await _db.storeValues.put(await _fromValue(key, value));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<T?> watch<T>(StoreKey<T> key) async* {
|
||||
yield* _db.storeValues
|
||||
.watchObject(key.id, fireImmediately: true)
|
||||
.asyncMap((e) async => e == null ? null : await _toValue(key, e));
|
||||
}
|
||||
|
||||
Future<StoreUpdateEvent> _toUpdateEvent(StoreValue entity) async {
|
||||
final key = StoreKey.values.firstWhere((e) => e.id == entity.id);
|
||||
final value = await _toValue(key, entity);
|
||||
return StoreUpdateEvent(key, value);
|
||||
}
|
||||
|
||||
Future<T?> _toValue<T>(StoreKey<T> key, StoreValue entity) async =>
|
||||
switch (key.type) {
|
||||
const (int) => entity.intValue,
|
||||
const (String) => entity.strValue,
|
||||
const (bool) => entity.intValue == 1,
|
||||
const (DateTime) => entity.intValue == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
||||
const (User) => await UserRepository(_db).getByDbId(entity.intValue!),
|
||||
_ => null,
|
||||
} as T?;
|
||||
|
||||
Future<StoreValue> _fromValue<T>(StoreKey<T> key, T value) async {
|
||||
final (int? intValue, String? strValue) = switch (key.type) {
|
||||
const (int) => (value as int, null),
|
||||
const (String) => (null, value as String),
|
||||
const (bool) => (
|
||||
(value as bool) ? 1 : 0,
|
||||
null,
|
||||
),
|
||||
const (DateTime) => (
|
||||
(value as DateTime).millisecondsSinceEpoch,
|
||||
null,
|
||||
),
|
||||
const (User) => (
|
||||
(await UserRepository(_db).update(value as User)).isarId,
|
||||
null
|
||||
),
|
||||
_ => throw UnsupportedError(
|
||||
"Unsupported primitive type: ${key.type} for key: ${key.name}",
|
||||
),
|
||||
};
|
||||
return StoreValue(key.id, intValue: intValue, strValue: strValue);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'db.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Isar isar(IsarRef ref) => throw UnimplementedError('isar');
|
||||
@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'db.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$isarHash() => r'69d3a06aa7e69a4381478e03f7956eb07d7f7feb';
|
||||
|
||||
/// See also [isar].
|
||||
@ProviderFor(isar)
|
||||
final isarProvider = Provider<Isar>.internal(
|
||||
isar,
|
||||
name: r'isarProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$isarHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsarRef = ProviderRef<Isar>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
@ -0,0 +1,10 @@
|
||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'store.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
IStoreRepository storeRepository(StoreRepositoryRef ref) =>
|
||||
IsarStoreRepository(ref.watch(isarProvider));
|
||||
@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'store.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$storeRepositoryHash() => r'9f378b96e552151fa14a8c8ce2c30a5f38f436ed';
|
||||
|
||||
/// See also [storeRepository].
|
||||
@ProviderFor(storeRepository)
|
||||
final storeRepositoryProvider = AutoDisposeProvider<IStoreRepository>.internal(
|
||||
storeRepository,
|
||||
name: r'storeRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$storeRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef StoreRepositoryRef = AutoDisposeProviderRef<IStoreRepository>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
Loading…
Reference in New Issue