diff --git a/.flutter-plugins b/.flutter-plugins index e2591cd..2cf4e38 100644 --- a/.flutter-plugins +++ b/.flutter-plugins @@ -9,3 +9,6 @@ path_provider_android=/Users/dan/.pub-cache/hosted/pub.dev/path_provider_android path_provider_foundation=/Users/dan/.pub-cache/hosted/pub.dev/path_provider_foundation-2.3.2/ path_provider_linux=/Users/dan/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ path_provider_windows=/Users/dan/.pub-cache/hosted/pub.dev/path_provider_windows-2.2.1/ +sqflite=/Users/dan/.pub-cache/hosted/pub.dev/sqflite-2.4.1/ +sqflite_android=/Users/dan/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/ +sqflite_darwin=/Users/dan/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6dd7f5f..032ba18 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,4 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { @@ -12,8 +10,6 @@ "kind": "build", "isDefault": true }, - "problemMatcher": [], - "detail": "Runs a specific Flutter test file on the Chrome platform." }, { "label": "Native tests", @@ -24,8 +20,6 @@ "kind": "build", "isDefault": true }, - "problemMatcher": [], - "detail": "Runs a specific Flutter test file on the Chrome platform." }, { "label": "Web tests", @@ -41,8 +35,6 @@ "kind": "build", "isDefault": true }, - "problemMatcher": [], - "detail": "Runs a specific Flutter test file on the Chrome platform." } ] } diff --git a/README.md b/README.md index aad04d7..a4cb0f9 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,8 @@ The currently available persistence options are broken down by platform: ### Native -* **FilePersistor**: The default file-based persistence implementation for native platforms. Documents are stored in one or more files based on the persistence configuration. +* **SqlitePersistor**: A SQLite persistence implementation using [sqflite](https://pub.dev/packages/sqflite). Documents are distributed in rows based on their persistence configuration. +* **FilePersistor**: A file-based persistence implementation for native platforms. Documents are stored in one or more files based on the persistence configuration. ### Web diff --git a/example/lib/main.dart b/example/lib/main.dart index 07cd0a7..1d13d12 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,15 +6,16 @@ import 'package:uuid/uuid.dart'; const uuid = Uuid(); +final logger = Logger('Playground'); + void main() async { WidgetsFlutterBinding.ensureInitialized(); Loon.configure( persistor: Persistor.current(), - enableLogging: true, ); - await Loon.hydrate(); + await logger.measure('Hydrate', () => Loon.hydrate()); runApp(const MyApp()); } @@ -130,40 +131,42 @@ class _MyHomePageState extends State { userSnap.data.name.startsWith(_controller.text), ), builder: (context, usersSnap) { - return ListView.builder( - shrinkWrap: true, - itemCount: usersSnap.length, - itemBuilder: (context, index) { - final userSnap = usersSnap[index]; - final user = userSnap.data; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(user.name), - TextButton( - onPressed: () { - _showEditDialog(userSnap.doc); - }, - child: const Text('Edit'), - ), - TextButton( - onPressed: () { - UserModel.store.doc(userSnap.id).delete(); - }, - child: Text( - 'Remove', - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith( - color: Colors.red, - ), + return Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: usersSnap.length, + itemBuilder: (context, index) { + final userSnap = usersSnap[index]; + final user = userSnap.data; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(user.name), + TextButton( + onPressed: () { + _showEditDialog(userSnap.doc); + }, + child: const Text('Edit'), + ), + TextButton( + onPressed: () { + UserModel.store.doc(userSnap.id).delete(); + }, + child: Text( + 'Remove', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: Colors.red, + ), + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ); }, ), @@ -182,18 +185,24 @@ class _MyHomePageState extends State { FloatingActionButton( onPressed: () { final id = uuid.v4(); - final doc = UserModel.store.doc(id); - - if (!doc.exists()) { + UserModel.store.doc(id).create(UserModel(name: 'User $id')); + }, + child: const Icon(Icons.add), + ), + const SizedBox(width: 24), + FloatingActionButton.extended( + label: const Text('Load test (10000)'), + onPressed: () { + for (int i = 0; i < 10000; i++) { + final id = uuid.v4(); UserModel.store.doc(id).create(UserModel(name: 'User $id')); } }, - child: const Icon(Icons.add), ), const SizedBox(width: 24), FloatingActionButton( onPressed: () { - UserModel.store.delete(); + Loon.clearAll(); }, child: const Icon(Icons.delete), ), diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 15a1671..80bf357 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import flutter_secure_storage_macos import path_provider_foundation +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 1454de7..f482579 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -5,11 +5,15 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS DEPENDENCIES: - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: flutter_secure_storage_macos: @@ -18,11 +22,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin SPEC CHECKSUMS: flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 diff --git a/example/pubspec.lock b/example/pubspec.lock index a27cdf2..62e22d7 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -331,10 +331,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.8" pointycastle: dependency: transitive description: @@ -372,6 +372,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" + url: "https://pub.dev" + source: hosted + version: "2.5.4+5" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -396,6 +436,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: diff --git a/lib/loon.dart b/lib/loon.dart index 2f848f9..f783bb8 100644 --- a/lib/loon.dart +++ b/lib/loon.dart @@ -3,9 +3,10 @@ library loon; import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' hide Key; +import 'package:loon/persistor/file_persistor/file_persistor.dart'; +import 'package:loon/persistor/indexed_db_persistor/indexed_db_persistor.dart'; import 'package:uuid/uuid.dart'; import 'dart:collection'; -import 'persistor/platform_persistor/platform_persistor.dart'; export 'widgets/query_stream_builder.dart'; export 'widgets/document_stream_builder.dart'; diff --git a/lib/persistor/data_store_manager.dart b/lib/persistor/data_store_manager.dart index 76f80fe..67ce145 100644 --- a/lib/persistor/data_store_manager.dart +++ b/lib/persistor/data_store_manager.dart @@ -9,32 +9,37 @@ class DataStoreManager { /// The duration by which to throttle persistence changes to the file system. final Duration persistenceThrottle; + /// The global persistor settings. final PersistorSettings settings; final void Function()? onSync; final void Function(String text) onLog; - /// The resolver that contains a mapping of documents to the file data store in which - /// the document is currently stored. - final DataStoreResolver resolver; + final DataStoreFactory factory; - /// The index of [DualDataStore] objects by store name. - final Map index = {}; + final Future Function() _clearAll; - final DataStoreFactory factory; + final DataStoreResolverConfig resolverConfig; /// The sync lock is used to block operations from accessing the file system while there is an ongoing sync /// operation and conversely blocks a sync from starting until the ongoing operation holding the lock has finished. final _syncLock = Lock(); + late final _logger = Logger('DataStoreManager', output: onLog); + /// The sync timer is used to throttle syncing changes to the file system using /// the given [persistenceThrottle]. After an that mutates the file system operation runs, it schedules /// a sync to run on a timer. When the sync runs, it acquires the [_syncLock], blocking any operations /// from being processed until the sync completes. Timer? _syncTimer; - late final _logger = Logger('DataStoreManager', output: onLog); + /// The resolver that contains a mapping of documents to the file data store in which + /// the document is currently stored. + DataStoreResolver resolver; + + /// The index of [DualDataStore] objects by store name. + Map index = {}; DataStoreManager({ required this.persistenceThrottle, @@ -42,9 +47,11 @@ class DataStoreManager { required this.onLog, required this.settings, required this.factory, - required DataStoreResolverConfig resolverConfig, + required this.resolverConfig, required Set initialStoreNames, - }) : resolver = DataStoreResolver(resolverConfig) { + required Future Function() clearAll, + }) : _clearAll = clearAll, + resolver = DataStoreResolver(resolverConfig) { for (final name in initialStoreNames) { index[name] = DualDataStore(name, factory: factory); } @@ -311,12 +318,11 @@ class DataStoreManager { // Cancel any pending sync, since all data stores are being cleared immediately. _cancelSync(); - await Future.wait([ - ...index.values.map((dataStore) => dataStore.delete()), - resolver.delete(), - ]); + await _clearAll(); - index.clear(); + // Reset the data store index and resolver. + index = {}; + resolver = DataStoreResolver(resolverConfig); }); } } diff --git a/lib/persistor/file_persistor/file_persistor.dart b/lib/persistor/file_persistor/file_persistor.dart index ba97be4..ff93985 100644 --- a/lib/persistor/file_persistor/file_persistor.dart +++ b/lib/persistor/file_persistor/file_persistor.dart @@ -31,7 +31,7 @@ class FilePersistor extends Persistor { super.settings = const PersistorSettings(), super.persistenceThrottle = const Duration(milliseconds: 100), DataStoreEncrypter? encrypter, - }) : encrypter = encrypter ?? DataStoreEncrypter(), + }) : encrypter = encrypter = encrypter ?? DataStoreEncrypter(), logger = Logger('FilePersistor', output: Loon.logger.log); void _onMessage(dynamic message) { @@ -110,7 +110,7 @@ class FilePersistor extends Persistor { return Isolate.spawn( FilePersistorWorker.init, initMessage, - debugName: 'Loon worker', + debugName: 'FilePersistorWorker', ); }); diff --git a/lib/persistor/file_persistor/file_persistor_worker.dart b/lib/persistor/file_persistor/file_persistor_worker.dart index 7b2a4df..7148ab9 100644 --- a/lib/persistor/file_persistor/file_persistor_worker.dart +++ b/lib/persistor/file_persistor/file_persistor_worker.dart @@ -121,6 +121,13 @@ class FilePersistorWorker { resolverConfig: FileDataStoreResolverConfig( file: File("${directory.path}/${DataStoreResolver.name}.json"), ), + clearAll: () async { + try { + await directory.delete(recursive: true); + } on PathNotFoundException { + return; + } + }, ); await manager.init(); diff --git a/lib/persistor/indexed_db_persistor/indexed_db_data_store_config.dart b/lib/persistor/indexed_db_persistor/indexed_db_data_store_config.dart index 8620885..3650bf4 100644 --- a/lib/persistor/indexed_db_persistor/indexed_db_data_store_config.dart +++ b/lib/persistor/indexed_db_persistor/indexed_db_data_store_config.dart @@ -3,7 +3,7 @@ import 'dart:js_interop'; import 'package:loon/loon.dart'; import 'package:loon/persistor/data_store.dart'; import 'package:loon/persistor/data_store_resolver.dart'; -import 'package:loon/persistor/indexed_db_persistor/indexed_db_persistor.dart'; +import 'package:loon/persistor/indexed_db_persistor/web_indexed_db_persistor.dart'; class IndexedDBDataStoreConfig extends DataStoreConfig { IndexedDBDataStoreConfig( diff --git a/lib/persistor/indexed_db_persistor/indexed_db_persistor.dart b/lib/persistor/indexed_db_persistor/indexed_db_persistor.dart index 435de33..069b9e3 100644 --- a/lib/persistor/indexed_db_persistor/indexed_db_persistor.dart +++ b/lib/persistor/indexed_db_persistor/indexed_db_persistor.dart @@ -1,154 +1,2 @@ -import 'dart:async'; -import 'dart:js_interop'; -import 'package:loon/loon.dart'; -import 'package:loon/persistor/data_store.dart'; -import 'package:loon/persistor/data_store_encrypter.dart'; -import 'package:loon/persistor/data_store_manager.dart'; -import 'package:loon/persistor/data_store_persistence_payload.dart'; -import 'package:loon/persistor/data_store_resolver.dart'; -import 'package:loon/persistor/indexed_db_persistor/indexed_db_data_store_config.dart'; -import 'package:web/web.dart'; - -typedef IndexedDBTransactionCallback = Future Function( - String name, - IDBRequest? Function(IDBObjectStore objectStore) execute, [ - IDBTransactionMode mode, -]); - -class IndexedDBPersistor extends Persistor { - static const _dbName = 'loon'; - static const _dbVersion = 1; - static const _storeName = 'store'; - static const keyPath = 'id'; - static const valuePath = 'value'; - - late final DataStoreManager _manager; - final DataStoreEncrypter encrypter; - late IDBDatabase _db; - - bool _initialized = false; - - final _logger = Logger('IndexedDBPersistor'); - - IndexedDBPersistor({ - super.onPersist, - super.onClear, - super.onClearAll, - super.onHydrate, - super.settings = const PersistorSettings(), - super.persistenceThrottle = const Duration(milliseconds: 100), - super.onSync, - DataStoreEncrypter? encrypter, - }) : encrypter = encrypter ?? DataStoreEncrypter(); - - Future _initDB() async { - final completer = Completer(); - final request = window.indexedDB.open(_dbName, _dbVersion); - request.onupgradeneeded = ((Event _) { - // If an upgrade is needed, then the DB can be set earlier in the request lifecycle - // when the upgrade is processed, otherwise it is set when the request completes below. - _db = request.result as IDBDatabase; - - if (!_initialized) { - _db = request.result as IDBDatabase; - _initialized = true; - } - - if (_db.objectStoreNames.length == 0) { - _db.createObjectStore( - _storeName, - IDBObjectStoreParameters(keyPath: keyPath.toJS), - ); - } - }).toJS; - request.onerror = ((ExternalDartReference error) { - return completer.completeError( - request.error?.message ?? 'unknown error initializing IndexedDB', - ); - }).toJS; - request.onsuccess = - ((ExternalDartReference _) => completer.complete()).toJS; - - await completer.future; - - _db = request.result as IDBDatabase; - } - - Future runTransaction( - String name, - IDBRequest? Function(IDBObjectStore objectStore) execute, [ - IDBTransactionMode mode = 'readonly', - ]) async { - final completer = Completer(); - - final transaction = _db.transaction(_storeName.toJS, mode); - final objectStore = transaction.objectStore(_storeName); - transaction.oncomplete = - ((ExternalDartReference _) => completer.complete()).toJS; - transaction.onerror = ((ExternalDartReference _) => - completer.completeError('$name error')).toJS; - - final request = execute(objectStore); - - await _logger.measure(name, () => completer.future); - - return request?.result.dartify() as T; - } - - @override - Future init() async { - await Future.wait([encrypter.init(), _initDB()]); - - final result = await runTransaction('Init', (objectStore) { - return objectStore.getAllKeys(); - }); - final initialStoreNames = List.from(result) - .where((name) => name != DataStoreResolver.name) - .map((name) => - name.replaceAll(':${DataStoreEncrypter.encryptedName}', '')) - .toSet(); - - _manager = DataStoreManager( - persistenceThrottle: persistenceThrottle, - onSync: onSync, - onLog: _logger.log, - settings: settings, - initialStoreNames: initialStoreNames, - factory: (name, encrypted) => DataStore( - IndexedDBDataStoreConfig( - encrypted ? '$name:${DataStoreEncrypter.encryptedName}' : name, - encrypted: encrypted, - encrypter: encrypter, - runTransaction: runTransaction, - ), - ), - resolverConfig: IndexedDBDataStoreResolverConfig( - runTransaction: runTransaction, - ), - ); - - await _manager.init(); - } - - @override - clear(List collections) { - return _manager.clear( - collections.map((collection) => collection.path).toList(), - ); - } - - @override - clearAll() { - return _manager.clearAll(); - } - - @override - hydrate([refs]) { - return _manager.hydrate(refs?.map((ref) => ref.path).toList()); - } - - @override - persist(docs) { - return _manager.persist(DataStorePersistencePayload(docs)); - } -} +export 'stub_indexed_db_persistor.dart' + if (dart.library.html) 'web_indexed_db_persistor.dart'; diff --git a/lib/persistor/indexed_db_persistor/js_interop_stub.dart b/lib/persistor/indexed_db_persistor/js_interop_stub.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/persistor/indexed_db_persistor/stub_indexed_db_persistor.dart b/lib/persistor/indexed_db_persistor/stub_indexed_db_persistor.dart new file mode 100644 index 0000000..132a2a1 --- /dev/null +++ b/lib/persistor/indexed_db_persistor/stub_indexed_db_persistor.dart @@ -0,0 +1,38 @@ +import 'package:loon/loon.dart'; + +class IndexedDBPersistor extends Persistor { + IndexedDBPersistor({ + super.onClear, + super.onClearAll, + super.onHydrate, + super.onPersist, + super.onSync, + super.persistenceThrottle, + super.settings, + }); + + @override + Future clear(List collections) { + throw UnimplementedError(); + } + + @override + Future clearAll() { + throw UnimplementedError(); + } + + @override + Future hydrate([List? refs]) { + throw UnimplementedError(); + } + + @override + Future init() { + throw UnimplementedError(); + } + + @override + Future persist(List docs) { + throw UnimplementedError(); + } +} diff --git a/lib/persistor/indexed_db_persistor/web_indexed_db_persistor.dart b/lib/persistor/indexed_db_persistor/web_indexed_db_persistor.dart new file mode 100644 index 0000000..76c61d4 --- /dev/null +++ b/lib/persistor/indexed_db_persistor/web_indexed_db_persistor.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'package:loon/loon.dart'; +import 'package:loon/persistor/data_store.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; +import 'package:loon/persistor/data_store_manager.dart'; +import 'package:loon/persistor/data_store_persistence_payload.dart'; +import 'package:loon/persistor/data_store_resolver.dart'; +import 'package:loon/persistor/indexed_db_persistor/indexed_db_data_store_config.dart'; +import 'package:web/web.dart'; + +typedef IndexedDBTransactionCallback = Future Function( + String name, + IDBRequest? Function(IDBObjectStore objectStore) execute, [ + IDBTransactionMode mode, +]); + +class IndexedDBPersistor extends Persistor { + static const _dbName = 'loon'; + static const _dbVersion = 1; + static const _storeName = 'store'; + static const keyPath = 'key'; + static const valuePath = 'value'; + + late final DataStoreManager _manager; + final DataStoreEncrypter encrypter; + late IDBDatabase _db; + + bool _initialized = false; + + final _logger = Logger('IndexedDBPersistor'); + + IndexedDBPersistor({ + super.onPersist, + super.onClear, + super.onClearAll, + super.onHydrate, + super.settings = const PersistorSettings(), + super.persistenceThrottle = const Duration(milliseconds: 100), + super.onSync, + DataStoreEncrypter? encrypter, + }) : encrypter = encrypter ?? DataStoreEncrypter(); + + Future _initDB() async { + final completer = Completer(); + final request = window.indexedDB.open(_dbName, _dbVersion); + request.onupgradeneeded = ((Event _) { + // If an upgrade is needed, then the DB can be set earlier in the request lifecycle + // when the upgrade is processed, otherwise it is set when the request completes below. + _db = request.result as IDBDatabase; + + if (!_initialized) { + _db = request.result as IDBDatabase; + _initialized = true; + } + + if (_db.objectStoreNames.length == 0) { + _db.createObjectStore( + _storeName, + IDBObjectStoreParameters(keyPath: keyPath.toJS), + ); + } + }).toJS; + request.onerror = ((ExternalDartReference error) { + return completer.completeError( + request.error?.message ?? 'unknown error initializing IndexedDB', + ); + }).toJS; + request.onsuccess = + ((ExternalDartReference _) => completer.complete()).toJS; + + await completer.future; + + _db = request.result as IDBDatabase; + } + + Future runTransaction( + String name, + IDBRequest? Function(IDBObjectStore objectStore) execute, [ + IDBTransactionMode mode = 'readonly', + ]) async { + final completer = Completer(); + + final transaction = _db.transaction(_storeName.toJS, mode); + final objectStore = transaction.objectStore(_storeName); + transaction.oncomplete = + ((ExternalDartReference _) => completer.complete()).toJS; + transaction.onerror = ((ExternalDartReference _) => + completer.completeError('$name error')).toJS; + + final request = execute(objectStore); + + await _logger.measure(name, () => completer.future); + + return request?.result.dartify() as T; + } + + @override + Future init() async { + await Future.wait([encrypter.init(), _initDB()]); + + final result = await runTransaction('Init', (objectStore) { + return objectStore.getAllKeys(); + }); + final initialStoreNames = List.from(result) + .where((name) => name != DataStoreResolver.name) + .map((name) => + name.replaceAll(':${DataStoreEncrypter.encryptedName}', '')) + .toSet(); + + _manager = DataStoreManager( + persistenceThrottle: persistenceThrottle, + onSync: onSync, + onLog: _logger.log, + settings: settings, + initialStoreNames: initialStoreNames, + factory: (name, encrypted) => DataStore( + IndexedDBDataStoreConfig( + encrypted ? '$name:${DataStoreEncrypter.encryptedName}' : name, + encrypted: encrypted, + encrypter: encrypter, + runTransaction: runTransaction, + ), + ), + resolverConfig: IndexedDBDataStoreResolverConfig( + runTransaction: runTransaction, + ), + clearAll: () => runTransaction( + 'clearAll', + (objectStore) => objectStore.clear(), + 'readwrite', + ), + ); + + await _manager.init(); + } + + @override + clear(List collections) { + return _manager.clear( + collections.map((collection) => collection.path).toList(), + ); + } + + @override + clearAll() { + return _manager.clearAll(); + } + + @override + hydrate([refs]) { + return _manager.hydrate(refs?.map((ref) => ref.path).toList()); + } + + @override + persist(docs) { + return _manager.persist(DataStorePersistencePayload(docs)); + } +} diff --git a/lib/persistor/persistor.dart b/lib/persistor/persistor.dart index 6d97542..b2ffad0 100644 --- a/lib/persistor/persistor.dart +++ b/lib/persistor/persistor.dart @@ -156,7 +156,18 @@ abstract class Persistor { Duration persistenceThrottle = const Duration(milliseconds: 100), PersistorSettings settings = const PersistorSettings(), }) { - return PlatformPersistor( + if (kIsWeb) { + return IndexedDBPersistor( + onPersist: onPersist, + onClear: onClear, + onClearAll: onClearAll, + onHydrate: onHydrate, + settings: settings, + persistenceThrottle: persistenceThrottle, + ); + } + + return FilePersistor( onPersist: onPersist, onClear: onClear, onClearAll: onClearAll, diff --git a/lib/persistor/platform_persistor/native_persistor.dart b/lib/persistor/platform_persistor/native_persistor.dart deleted file mode 100644 index db505d7..0000000 --- a/lib/persistor/platform_persistor/native_persistor.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:loon/loon.dart'; -import 'package:loon/persistor/data_store_encrypter.dart'; -import 'package:loon/persistor/file_persistor/file_persistor.dart'; - -class PlatformPersistor extends FilePersistor { - PlatformPersistor({ - super.onPersist, - super.onClear, - super.onClearAll, - super.onHydrate, - super.onSync, - super.settings = const PersistorSettings(), - super.persistenceThrottle = const Duration(milliseconds: 100), - DataStoreEncrypter? encrypter, - }); -} diff --git a/lib/persistor/platform_persistor/platform_persistor.dart b/lib/persistor/platform_persistor/platform_persistor.dart deleted file mode 100644 index 9dda9c5..0000000 --- a/lib/persistor/platform_persistor/platform_persistor.dart +++ /dev/null @@ -1 +0,0 @@ -export 'native_persistor.dart' if (dart.library.html) 'web_persistor.dart'; diff --git a/lib/persistor/platform_persistor/web_persistor.dart b/lib/persistor/platform_persistor/web_persistor.dart deleted file mode 100644 index b0451e7..0000000 --- a/lib/persistor/platform_persistor/web_persistor.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:loon/loon.dart'; -import 'package:loon/persistor/data_store_encrypter.dart'; -import 'package:loon/persistor/indexed_db_persistor/indexed_db_persistor.dart'; - -class PlatformPersistor extends IndexedDBPersistor { - PlatformPersistor({ - super.onPersist, - super.onClear, - super.onClearAll, - super.onHydrate, - super.onSync, - super.settings = const PersistorSettings(), - super.persistenceThrottle = const Duration(milliseconds: 100), - DataStoreEncrypter? encrypter, - }); -} diff --git a/lib/persistor/sqlite_persistor/sqlite_data_store_config.dart b/lib/persistor/sqlite_persistor/sqlite_data_store_config.dart new file mode 100644 index 0000000..0e42c28 --- /dev/null +++ b/lib/persistor/sqlite_persistor/sqlite_data_store_config.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'package:loon/loon.dart'; +import 'package:loon/persistor/data_store.dart'; +import 'package:loon/persistor/data_store_resolver.dart'; +import 'package:loon/persistor/sqlite_persistor/sqlite_persistor.dart'; +import 'package:sqflite/sqflite.dart'; + +const _tableName = SqlitePersistor.tableName; +const _keyColumn = SqlitePersistor.keyColumn; +const _valueColumn = SqlitePersistor.valueColumn; + +class SqliteDataStoreConfig extends DataStoreConfig { + SqliteDataStoreConfig( + super.name, { + required Database db, + required super.encrypted, + required super.encrypter, + required super.logger, + }) : super( + hydrate: () async { + final rows = await db.query( + _tableName, + columns: [_valueColumn], + where: '$_keyColumn = ?', + whereArgs: [name], + ); + + if (rows.isEmpty) { + return null; + } + + final value = rows.first[_valueColumn] as String; + + final json = + jsonDecode(encrypted ? encrypter.decrypt(value) : value); + final store = ValueStore(); + + for (final entry in json.entries) { + final resolverPath = entry.key; + final valueStore = ValueStore.fromJson(entry.value); + store.write(resolverPath, valueStore); + } + + return store; + }, + persist: (store) async { + final value = jsonEncode(store.extract()); + + await db.insert( + _tableName, + { + _keyColumn: name, + _valueColumn: encrypted ? encrypter.encrypt(value) : value, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }, + delete: () async { + await db.delete( + _tableName, + where: '$_keyColumn = ?', + whereArgs: [name], + ); + }, + ); +} + +class SqliteDataStoreResolverConfig extends DataStoreResolverConfig { + static const name = DataStoreResolver.name; + + SqliteDataStoreResolverConfig({ + required Database db, + }) : super( + hydrate: () async { + final rows = await db.query( + _tableName, + columns: [_valueColumn], + where: '$_keyColumn = ?', + whereArgs: [name], + ); + + if (rows.isEmpty) { + return null; + } + + final json = jsonDecode(rows.first[_valueColumn] as String); + return ValueRefStore(json); + }, + persist: (store) => db.insert( + _tableName, + { + _keyColumn: name, + _valueColumn: jsonEncode(store.inspect()), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ), + delete: () async { + await db.delete( + _tableName, + where: '$_keyColumn = ?', + whereArgs: [name], + ); + }, + ); +} diff --git a/lib/persistor/sqlite_persistor/sqlite_persistor.dart b/lib/persistor/sqlite_persistor/sqlite_persistor.dart new file mode 100644 index 0000000..3631f09 --- /dev/null +++ b/lib/persistor/sqlite_persistor/sqlite_persistor.dart @@ -0,0 +1,113 @@ +import 'package:loon/loon.dart'; +import 'package:loon/persistor/data_store.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; +import 'package:loon/persistor/data_store_manager.dart'; +import 'package:loon/persistor/data_store_persistence_payload.dart'; +import 'package:loon/persistor/data_store_resolver.dart'; +import 'package:loon/persistor/sqlite_persistor/sqlite_data_store_config.dart'; +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; + +class SqlitePersistor extends Persistor { + static const dbName = 'loon.db'; + static const dbVersion = 1; + + static const tableName = 'store'; + static const keyColumn = 'key'; + static const valueColumn = 'value'; + + final _logger = Logger('SqlitePersistor'); + + late final DataStoreManager _manager; + final DataStoreEncrypter encrypter; + late final Database db; + + SqlitePersistor({ + super.onPersist, + super.onClear, + super.onClearAll, + super.onHydrate, + super.settings = const PersistorSettings(), + super.persistenceThrottle = const Duration(milliseconds: 100), + super.onSync, + DataStoreEncrypter? encrypter, + }) : encrypter = encrypter ?? DataStoreEncrypter(); + + Future _initDB() async { + final databasesPath = await getDatabasesPath(); + String path = join(databasesPath, dbName); + + db = await openDatabase( + path, + version: dbVersion, + onCreate: (db, version) async { + await db.execute( + ''' + CREATE TABLE $tableName ( + $keyColumn TEXT PRIMARY KEY, + $valueColumn TEXT + ) + ''', + ); + }, + ); + } + + @override + Future init() async { + await Future.wait([encrypter.init(), _initDB()]); + + final records = await db.query( + tableName, + columns: [keyColumn], + ); + final initialStoreNames = records + .map((record) => (record[keyColumn] as String) + .replaceAll(':${DataStoreEncrypter.encryptedName}', '')) + .where((name) => name != DataStoreResolver.name) + .toSet(); + + _manager = DataStoreManager( + persistenceThrottle: persistenceThrottle, + onSync: onSync, + onLog: _logger.log, + settings: settings, + initialStoreNames: initialStoreNames, + factory: (name, encrypted) => DataStore( + SqliteDataStoreConfig( + db: db, + encrypted ? '$name:${DataStoreEncrypter.encryptedName}' : name, + encrypted: encrypted, + encrypter: encrypter, + logger: _logger, + ), + ), + resolverConfig: SqliteDataStoreResolverConfig(db: db), + clearAll: () => db.delete(tableName), + ); + + await _manager.init(); + } + + @override + clear(List collections) { + return _manager.clear( + collections.map((collection) => collection.path).toList(), + ); + } + + @override + clearAll() { + return _manager.clearAll(); + } + + @override + hydrate([refs]) { + return _manager.hydrate(refs?.map((ref) => ref.path).toList()); + } + + @override + persist(docs) { + return _manager.persist(DataStorePersistencePayload(docs)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f2d1df6..b68e122 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,11 +16,13 @@ dependencies: path: ^1.8.3 path_provider: ^2.1.5 web: ^1.1.0 + sqflite: ^2.4.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + sqflite_common_ffi: ^2.3.4 dependency_overrides: # Temporary override for WASM support until this issue is addressed: diff --git a/test/core/loon_test.dart b/test/core/loon_test.dart index a40e7dd..37b00be 100644 --- a/test/core/loon_test.dart +++ b/test/core/loon_test.dart @@ -202,8 +202,6 @@ void main() { }); test('Returns serializable persisted document snapshots', () { - Loon.configure(persistor: TestPersistor()); - final userDoc = TestUserModel.store.doc('1'); final userDoc2 = Loon.collection('users').doc('2'); final userDoc3 = Loon.collection('users').doc('3'); diff --git a/test/core/persistor/persistor_test.dart b/test/core/persistor/persist_manager_test.dart similarity index 96% rename from test/core/persistor/persistor_test.dart rename to test/core/persistor/persist_manager_test.dart index 7c2f439..2d0affc 100644 --- a/test/core/persistor/persistor_test.dart +++ b/test/core/persistor/persist_manager_test.dart @@ -9,7 +9,9 @@ void main() { Loon.clearAll(); }); - group('Persistor', () { + /// Tests the behavior of the [PersistManager] to check that it properly batches + /// and sequences persistence operations. + group('Persist Manager', () { test( 'Batches contiguous persistence operations', () async { diff --git a/test/native/persistor/file_persistor_test.dart b/test/core/persistor/persistor_test_runner.dart similarity index 78% rename from test/native/persistor/file_persistor_test.dart rename to test/core/persistor/persistor_test_runner.dart index 26b2f06..29d7dbf 100644 --- a/test/native/persistor/file_persistor_test.dart +++ b/test/core/persistor/persistor_test_runner.dart @@ -1,54 +1,88 @@ -import 'dart:convert'; -import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:loon/loon.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; -// ignore: depend_on_referenced_packages -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -// ignore: depend_on_referenced_packages -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -import '../../models/test_file_persistor.dart'; -import '../../models/test_large_model.dart'; +import '../../models/test_data_store_encrypter.dart'; +import '../../models/test_persistor.dart'; +import '../../models/test_persistor_completer.dart'; import '../../models/test_user_model.dart'; -import '../../utils.dart'; - -late Directory testDirectory; -final logger = Logger('FilePersistorTest'); - -class MockPathProvider extends Fake - with MockPlatformInterfaceMixin - implements PathProviderPlatform { - getApplicationDocumentsDirectory() { - return testDirectory; +typedef PersistorFactory = T Function({ + void Function(Set batch)? onPersist, + void Function(Set collections)? onClear, + void Function()? onClearAll, + void Function(Json data)? onHydrate, + void Function()? onSync, + PersistorSettings settings, + Duration persistenceThrottle, + DataStoreEncrypter? encrypter, +}); + +/// Test runner for running the full suite of persistor tests against a persistor implementation +/// including [FilePersistor], [IndexedDBPersistor], etc. +void persistorTestRunner({ + required PersistorFactory factory, + required Future Function( + T persistor, + String storeName, { + bool encrypted, + }) getStore, +}) { + late T persistor; + late TestPersistCompleter completer; + + Future get( + String store, { + bool encrypted = false, + }) async { + return getStore(persistor, store, encrypted: encrypted); } - @override - getApplicationDocumentsPath() async { - return testDirectory.path; + Future exists( + String storeName, { + bool encrypted = false, + }) async { + final result = await get(storeName, encrypted: encrypted); + return result != null; } -} -void main() { - late PersistorCompleter completer; + void configure({ + PersistorSettings settings = const PersistorSettings(), + }) { + completer = TestPersistCompleter(); + persistor = factory( + persistenceThrottle: const Duration(milliseconds: 1), + settings: settings, + encrypter: TestDataStoreEncrypter(), + onPersist: (docs) { + completer.persistComplete(); + }, + onHydrate: (refs) { + completer.hydrateComplete(); + }, + onClear: (collections) { + completer.clearComplete(); + }, + onClearAll: () { + completer.clearAllComplete(); + }, + onSync: () { + completer.syncComplete(); + }, + ); - setUp(() { - completer = TestFilePersistor.completer = PersistorCompleter(); - testDirectory = Directory.systemTemp.createTempSync('test_dir'); - Directory("${testDirectory.path}/loon").createSync(); - final mockPathProvider = MockPathProvider(); - PathProviderPlatform.instance = mockPathProvider; + Loon.configure(persistor: persistor); + } - Loon.configure(persistor: TestFilePersistor()); - }); + group('Persistor Test Runner', () { + setUp(() async { + configure(); + }); - tearDown(() async { - testDirectory.deleteSync(recursive: true); - await Loon.clearAll(); - }); + tearDown(() async { + await Loon.clearAll(); + }); - group('FilePersistor', () { group( 'persist', () { @@ -80,11 +114,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -105,9 +136,7 @@ void main() { ); // The resolver is not necessary for data only persisted in the root data store. - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - expect(resolverFile.existsSync(), false); + expect(await exists('__resolver__'), false); }, ); @@ -129,11 +158,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -162,11 +188,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -199,11 +222,8 @@ void main() { await completer.onSync; - var file = File('${testDirectory.path}/loon/__store__.json'); - var json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -220,11 +240,8 @@ void main() { await completer.onSync; - file = File('${testDirectory.path}/loon/__store__.json'); - json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -250,11 +267,9 @@ void main() { userCollection.doc('1').create(TestUserModel('User 1')); userCollection.doc('2').create(TestUserModel('User 2')); - final file = File('${testDirectory.path}/loon/__store__.json'); - await completer.onSync; - expect(file.existsSync(), true); + expect(await exists('__store__'), true); // If all documents of a file are deleted, the file itself should be deleted. userCollection.doc('1').delete(); @@ -262,7 +277,7 @@ void main() { await completer.onSync; - expect(file.existsSync(), false); + expect(await exists('__store__'), false); }, ); @@ -299,13 +314,8 @@ void main() { await completer.onSync; - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final friendsFile = File('${testDirectory.path}/loon/friends.json'); - var storeJson = jsonDecode(storeFile.readAsStringSync()); - var friendsJson = jsonDecode(friendsFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -319,7 +329,7 @@ void main() { ); expect( - friendsJson, + await get('friends'), { "users__2__friends": { "users": { @@ -336,12 +346,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "friends": 1, @@ -370,9 +376,8 @@ void main() { await completer.onSync; - friendsJson = jsonDecode(friendsFile.readAsStringSync()); expect( - friendsJson, + await get('friends'), { "users__2__friends": { "users": { @@ -388,9 +393,8 @@ void main() { }, ); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { "friends": 1, @@ -419,11 +423,10 @@ void main() { await completer.onSync; - expect(friendsFile.existsSync(), false); + expect(await exists('friends'), false); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -458,14 +461,8 @@ void main() { await completer.onSync; - final usersFile = File('${testDirectory.path}/loon/users.json'); - final otherUsersFile = - File('${testDirectory.path}/loon/other_users.json'); - var usersJson = jsonDecode(usersFile.readAsStringSync()); - var otherUsersJson = jsonDecode(otherUsersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users__1": { "users": { @@ -478,7 +475,7 @@ void main() { ); expect( - otherUsersJson, + await get('other_users'), { "users__2": { "users": { @@ -490,12 +487,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -522,9 +515,9 @@ void main() { await completer.onSync; - expect(usersFile.existsSync(), false); + expect(await exists('users'), false); expect( - otherUsersJson, + await get('other_users'), { "users__2": { "users": { @@ -536,9 +529,8 @@ void main() { }, ); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { "other_users": 1, @@ -562,11 +554,10 @@ void main() { await completer.onSync; - expect(otherUsersFile.existsSync(), false); + expect(await exists('other_users'), false); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -612,15 +603,11 @@ void main() { await completer.onSync; - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final usersFile = File('${testDirectory.path}/loon/users.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - // All data is stored in the users.json - expect(storeFile.existsSync(), false); + expect(await exists('__store__'), false); expect( - usersJson, + await get('users'), { "users": { "users": { @@ -641,12 +628,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -697,13 +680,8 @@ void main() { await completer.onSync; - final usersFile = File('${testDirectory.path}/loon/users.json'); - final friendsFile = File('${testDirectory.path}/loon/friends.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - final friendsJson = jsonDecode(friendsFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users": { "users": { @@ -717,7 +695,7 @@ void main() { ); expect( - friendsJson, + await get('friends'), { "users__2__friends": { "users": { @@ -734,15 +712,11 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - // Since the friends collection under users__2 specifies a "friends" key, // this overrides the parent path's "users" key on the users collection and // therefore resolves the friends documents to the "friends" data store. expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -811,13 +785,8 @@ void main() { await completer.onSync; - final users1File = File('${testDirectory.path}/loon/users_1.json'); - final users2File = File('${testDirectory.path}/loon/users_2.json'); - final users1Json = jsonDecode(users1File.readAsStringSync()); - final users2Json = jsonDecode(users2File.readAsStringSync()); - expect( - users1Json, + await get('users_1'), { "users__1": { "users": { @@ -837,7 +806,7 @@ void main() { ); expect( - users2Json, + await get('users_2'), { "users__2": { "users": { @@ -856,12 +825,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -936,19 +901,8 @@ void main() { await completer.onSync; - final users1File = File('${testDirectory.path}/loon/users_1.json'); - final users2File = File('${testDirectory.path}/loon/users_2.json'); - final users1Json = jsonDecode(users1File.readAsStringSync()); - final users2Json = jsonDecode(users2File.readAsStringSync()); - final friends1File = - File('${testDirectory.path}/loon/friends_1.json'); - final friends2File = - File('${testDirectory.path}/loon/friends_2.json'); - final friends1Json = jsonDecode(friends1File.readAsStringSync()); - final friends2Json = jsonDecode(friends2File.readAsStringSync()); - expect( - users1Json, + await get('users_1'), { "users__1": { "users": { @@ -960,7 +914,7 @@ void main() { }, ); expect( - friends1Json, + await get('friends_1'), { "users__1__friends__1": { "users": { @@ -977,7 +931,7 @@ void main() { ); expect( - users2Json, + await get('users_2'), { "users__2": { "users": { @@ -989,7 +943,7 @@ void main() { }, ); expect( - friends2Json, + await get('friends_2'), { "users__2__friends__2": { "users": { @@ -1005,12 +959,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users_1": 1, @@ -1099,13 +1049,8 @@ void main() { await completer.onSync; - final usersFile = File('${testDirectory.path}/loon/users.json'); - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - final storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users": { "users": { @@ -1119,7 +1064,7 @@ void main() { ); expect( - storeJson, + await get('__store__'), { "users__2__friends": { "users": { @@ -1136,15 +1081,11 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - // Since the friends collection under users__2 specifies a "friends" key, // this overrides the parent path's "users" key on the users collection and // therefore resolves the friends documents to the "friends" data store. expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -1220,12 +1161,8 @@ void main() { await completer.onSync; - final storeFile = - File('${testDirectory.path}/loon/__store__.json'); - var storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -1245,9 +1182,7 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - expect(resolverFile.existsSync(), false); + expect(await exists('__resolver__'), false); updatedUsersCollection .doc('1') @@ -1255,15 +1190,10 @@ void main() { await completer.onSync; - final otherUsersFile = - File('${testDirectory.path}/loon/other_users.json'); - var otherUsersJson = - jsonDecode(otherUsersFile.readAsStringSync()); - - expect(storeFile.existsSync(), false); + expect(await exists('__store__'), false); expect( - otherUsersJson, + await get('other_users'), { "users": { "users": { @@ -1283,10 +1213,8 @@ void main() { }, ); - final resolverJson = - jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { "other_users": 1, @@ -1341,12 +1269,8 @@ void main() { await completer.onSync; - final storeFile = - File('${testDirectory.path}/loon/__store__.json'); - var storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -1366,9 +1290,7 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - expect(resolverFile.existsSync(), false); + expect(await exists('__resolver__'), false); updatedUsersCollection .doc('1') @@ -1376,9 +1298,8 @@ void main() { await completer.onSync; - storeJson = jsonDecode(storeFile.readAsStringSync()); expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -1390,12 +1311,8 @@ void main() { }, ); - final users1File = - File('${testDirectory.path}/loon/users_1.json'); - var users1Json = jsonDecode(users1File.readAsStringSync()); - expect( - users1Json, + await get('users_1'), { "users__1": { "users": { @@ -1414,10 +1331,8 @@ void main() { }, ); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1480,16 +1395,8 @@ void main() { await completer.onSync; - final storeFile = - File('${testDirectory.path}/loon/__store__.json'); - expect(storeFile.existsSync(), false); - - final usersFile = - File('${testDirectory.path}/loon/users.json'); - var usersJson = jsonDecode(usersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users": { "users": { @@ -1509,13 +1416,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1534,10 +1436,8 @@ void main() { await completer.onSync; - var storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -1557,11 +1457,10 @@ void main() { }, ); - expect(usersFile.existsSync(), false); + expect(await exists('users'), false); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1617,16 +1516,8 @@ void main() { await completer.onSync; - final storeFile = - File('${testDirectory.path}/loon/__store__.json'); - expect(storeFile.existsSync(), false); - - final users1File = - File('${testDirectory.path}/loon/users_1.json'); - var users1Json = jsonDecode(users1File.readAsStringSync()); - expect( - users1Json, + await get('users_1'), { "users__1": { "users": { @@ -1645,12 +1536,8 @@ void main() { }, ); - final users2File = - File('${testDirectory.path}/loon/users_2.json'); - var users2Json = jsonDecode(users2File.readAsStringSync()); - expect( - users2Json, + await get('users_2'), { "users__2": { "users": { @@ -1662,13 +1549,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1697,10 +1579,8 @@ void main() { await completer.onSync; - var storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { ValueStore.root: { "users": { @@ -1719,11 +1599,10 @@ void main() { }, ); - expect(users1File.existsSync(), false); + expect(await exists('users_1'), false); - users2Json = jsonDecode(users2File.readAsStringSync()); expect( - users2Json, + await get('users_2'), { "users__2": { "users": { @@ -1735,9 +1614,8 @@ void main() { }, ); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1802,12 +1680,8 @@ void main() { await completer.onSync; - final usersFile = - File('${testDirectory.path}/loon/users.json'); - var usersJson = jsonDecode(usersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users": { "users": { @@ -1827,13 +1701,8 @@ void main() { }, ); - var resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1852,15 +1721,10 @@ void main() { await completer.onSync; - expect(usersFile.existsSync(), false); - - final updatedUsersFile = - File('${testDirectory.path}/loon/updated_users.json'); - final updatedUsersJson = - jsonDecode(updatedUsersFile.readAsStringSync()); + expect(await exists('users'), false); expect( - updatedUsersJson, + await get('updated_users'), { "users": { "users": { @@ -1880,12 +1744,8 @@ void main() { }, ); - resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -1941,12 +1801,8 @@ void main() { await completer.onSync; - final usersFile = - File('${testDirectory.path}/loon/users.json'); - var usersJson = jsonDecode(usersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users__1": { "users": { @@ -1972,13 +1828,8 @@ void main() { }, ); - var resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2005,9 +1856,8 @@ void main() { await completer.onSync; - usersJson = jsonDecode(usersFile.readAsStringSync()); expect( - usersJson, + await get('users'), { "users__1": { "users": { @@ -2019,14 +1869,9 @@ void main() { }, ); - final updatedUsersFile = - File('${testDirectory.path}/loon/updated_users.json'); - final updatedUsersJson = - jsonDecode(updatedUsersFile.readAsStringSync()); - // Both `users__2` and its subcollections should have been moved to the `updated_users` data store. expect( - updatedUsersJson, + await get('updated_users'), { "users__2": { "users": { @@ -2045,12 +1890,8 @@ void main() { }, ); - resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2109,11 +1950,7 @@ void main() { await completer.onSync; - final usersFile = - File('${testDirectory.path}/loon/users.json'); - var usersJson = jsonDecode(usersFile.readAsStringSync()); - - expect(usersJson, { + expect(await get('users'), { "users": { "users": { "__values": { @@ -2124,13 +1961,8 @@ void main() { } }); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2149,8 +1981,7 @@ void main() { await completer.onSync; - usersJson = jsonDecode(usersFile.readAsStringSync()); - expect(usersJson, { + expect(await get('users'), { "users": { "users": { "__values": { @@ -2160,12 +1991,8 @@ void main() { } }); - final users1File = - File('${testDirectory.path}/loon/users_1.json'); - var users1Json = jsonDecode(users1File.readAsStringSync()); - expect( - users1Json, + await get('users_1'), { "users__1": { "users": { @@ -2177,9 +2004,8 @@ void main() { }, ); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2236,11 +2062,7 @@ void main() { await completer.onSync; - final users1File = - File('${testDirectory.path}/loon/users_1.json'); - var users1Json = jsonDecode(users1File.readAsStringSync()); - - expect(users1Json, { + expect(await get('users_1'), { "users__1": { "users": { "__values": { @@ -2250,11 +2072,7 @@ void main() { } }); - final users2File = - File('${testDirectory.path}/loon/users_2.json'); - var users2Json = jsonDecode(users2File.readAsStringSync()); - - expect(users2Json, { + expect(await get('users_2'), { "users__2": { "users": { "__values": { @@ -2264,13 +2082,8 @@ void main() { } }); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - var resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2299,10 +2112,7 @@ void main() { await completer.onSync; - final usersFile = - File('${testDirectory.path}/loon/users.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - expect(usersJson, { + expect(await get('users'), { "users": { "users": { "__values": { @@ -2312,10 +2122,8 @@ void main() { } }); - expect(users1File.existsSync(), false); - - users2Json = jsonDecode(users2File.readAsStringSync()); - expect(users2Json, { + expect(await exists('users_1'), false); + expect(await get('users_2'), { "users__2": { "users": { "__values": { @@ -2325,9 +2133,8 @@ void main() { } }); - resolverJson = jsonDecode(resolverFile.readAsStringSync()); expect( - resolverJson, + await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, @@ -2361,11 +2168,9 @@ void main() { test( 'Stores documents under the given value key', () async { - Loon.configure( - persistor: TestFilePersistor( - settings: PersistorSettings( - key: Persistor.key('users'), - ), + configure( + settings: PersistorSettings( + key: Persistor.key('users'), ), ); @@ -2381,11 +2186,8 @@ void main() { await completer.onSync; - final usersFile = File('${testDirectory.path}/loon/users.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "": { "users": { @@ -2399,13 +2201,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -2421,16 +2218,14 @@ void main() { test( 'Stores documents using the given key builder', () async { - Loon.configure( - persistor: TestFilePersistor( - settings: PersistorSettings( - key: Persistor.keyBuilder((snap) { - return switch (snap.data) { - TestUserModel _ => 'users', - _ => Persistor.defaultKey.value, - }; - }), - ), + configure( + settings: PersistorSettings( + key: Persistor.keyBuilder((snap) { + return switch (snap.data) { + TestUserModel _ => 'users', + _ => Persistor.defaultKey.value, + }; + }), ), ); @@ -2450,11 +2245,8 @@ void main() { await completer.onSync; - final usersFile = File('${testDirectory.path}/loon/users.json'); - final usersJson = jsonDecode(usersFile.readAsStringSync()); - expect( - usersJson, + await get('users'), { "users__1": { "users": { @@ -2468,12 +2260,8 @@ void main() { }, ); - final storeFile = - File('${testDirectory.path}/loon/__store__.json'); - final storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, + await get('__store__'), { "posts__1": { "posts": { @@ -2488,13 +2276,8 @@ void main() { }, ); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = - jsonDecode(resolverFile.readAsStringSync()); - expect( - resolverJson, + await get('__resolver__'), { "__refs": { "users": 1, @@ -2537,11 +2320,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "root": { @@ -2576,11 +2356,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "users": { @@ -2631,10 +2408,7 @@ void main() { await completer.onSync; - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final storeJson = jsonDecode(storeFile.readAsStringSync()); - - expect(storeJson, { + expect(await get('__store__'), { "": { "users": { "__values": { @@ -2656,11 +2430,7 @@ void main() { }, }); - final myFriendsFile = - File('${testDirectory.path}/loon/my_friends.json'); - final myFriendsJson = jsonDecode(myFriendsFile.readAsStringSync()); - - expect(myFriendsJson, { + expect(await get('my_friends'), { "users__1__friends": { "users": { "1": { @@ -2674,11 +2444,7 @@ void main() { } }); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - - expect(resolverJson, { + expect(await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, "my_friends": 1, @@ -2701,19 +2467,13 @@ void main() { } }); + Loon.configure(persistor: null); await Loon.clearAll(); + configure(); - expect(storeFile.existsSync(), false); - expect(myFriendsFile.existsSync(), false); - expect(resolverFile.existsSync(), false); - - storeFile.writeAsStringSync(jsonEncode(storeJson)); - myFriendsFile.writeAsStringSync(jsonEncode(myFriendsJson)); - resolverFile.writeAsStringSync(jsonEncode(resolverJson)); - - // After clearing the data and reinitializing it from disk to verify with hydration, - // the persistor needs to be re-created so that it re-reads all data stores from disk. - Loon.configure(persistor: TestFilePersistor()); + expect(userCollection.exists(), false); + expect(friendsCollection.exists(), false); + expect(userFriendsCollection.exists(), false); await Loon.hydrate(); @@ -2789,10 +2549,7 @@ void main() { await completer.onSync; - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final storeJson = jsonDecode(storeFile.readAsStringSync()); - - expect(storeJson, { + expect(await get('__store__'), { "": { "users": { "__values": { @@ -2809,12 +2566,7 @@ void main() { } }); - final userFriendsFile = - File('${testDirectory.path}/loon/user_friends.json'); - final userFriendsJson = - jsonDecode(userFriendsFile.readAsStringSync()); - - expect(userFriendsJson, { + expect(await get('user_friends'), { "users__1__friends": { "users": { "1": { @@ -2828,11 +2580,7 @@ void main() { } }); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - final resolverJson = jsonDecode(resolverFile.readAsStringSync()); - - expect(resolverJson, { + expect(await get('__resolver__'), { "__refs": { Persistor.defaultKey.value: 1, "user_friends": 1, @@ -2855,15 +2603,13 @@ void main() { } }); + Loon.configure(persistor: null); await Loon.clearAll(); + configure(); - storeFile.writeAsStringSync(jsonEncode(storeJson)); - userFriendsFile.writeAsStringSync(jsonEncode(userFriendsJson)); - resolverFile.writeAsStringSync(jsonEncode(resolverJson)); - - // After clearing the data and reinitializing it from disk to verify with hydration, - // the persistor needs to be recreated so that it re-reads all data stores from disk. - Loon.configure(persistor: TestFilePersistor()); + expect(userCollection.exists(), false); + expect(friendsCollection.exists(), false); + expect(userFriendsCollection.exists(), false); await Loon.hydrate([userCollection]); @@ -2911,52 +2657,6 @@ void main() { }, ); - test('Hydrates large persistence files', () async { - int size = 20000; - - final store = ValueStore(); - List models = - List.generate(size, (_) => generateRandomModel()); - - for (final model in models) { - store.write('users__${model.id}', model.toJson()); - } - - final file = File('${testDirectory.path}/loon/users.json'); - file.writeAsStringSync(jsonEncode({ - ValueStore.root: store.inspect(), - })); - - await Loon.hydrate(); - - final largeModelCollection = Loon.collection( - 'users', - fromJson: TestLargeModel.fromJson, - toJson: (user) => user.toJson(), - ); - - final queryResponseSize = await logger.measure( - 'Lazy parse large collection query', - () async { - return largeModelCollection.get().length; - }, - ); - - // The second query should be significantly faster, since it does not need to lazily - // parse each of the documents from their JSON representation. - await logger.measure( - 'Already parsed collection query', - () async { - return largeModelCollection.get().length; - }, - ); - - expect( - queryResponseSize, - size, - ); - }); - test( "Hydrates root documents into the root collection", () async { @@ -2970,11 +2670,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "root": { @@ -2986,11 +2683,9 @@ void main() { }, ); + Loon.configure(persistor: null); await Loon.clearAll(); - - Loon.configure(persistor: TestFilePersistor()); - - file.writeAsStringSync(jsonEncode(json)); + configure(); await Loon.hydrate(); @@ -3023,11 +2718,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "root": { @@ -3044,11 +2736,9 @@ void main() { }, ); + Loon.configure(persistor: null); await Loon.clearAll(); - - file.writeAsStringSync(jsonEncode(json)); - - Loon.configure(persistor: TestFilePersistor()); + configure(); await Loon.hydrate([currentUserDoc]); @@ -3060,7 +2750,7 @@ void main() { ), ); - expect(userCollection.get(), []); + expect(userCollection.exists(), false); }, ); @@ -3083,11 +2773,8 @@ void main() { await completer.onSync; - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - expect( - json, + await get('__store__'), { "": { "root": { @@ -3104,11 +2791,9 @@ void main() { }, ); + Loon.configure(persistor: null); await Loon.clearAll(); - - file.writeAsStringSync(jsonEncode(json)); - - Loon.configure(persistor: TestFilePersistor()); + configure(); await Loon.hydrate([Collection.root]); @@ -3120,7 +2805,7 @@ void main() { ), ); - expect(userCollection.get(), []); + expect(userCollection.exists(), false); }, ); }); @@ -3140,17 +2825,15 @@ void main() { userCollection.doc('1').create(TestUserModel('User 1')); userCollection.doc('2').create(TestUserModel('User 2')); - final file = File('${testDirectory.path}/loon/__store__.json'); - await completer.onSync; - expect(file.existsSync(), true); + expect(await exists('__store__'), true); userCollection.delete(); await completer.onSync; - expect(file.existsSync(), false); + expect(await exists('__store__'), false); }, ); @@ -3164,7 +2847,7 @@ void main() { fromJson: TestUserModel.fromJson, toJson: (user) => user.toJson(), persistorSettings: PersistorSettings( - key: Persistor.keyBuilder((snap) => 'users${snap.id}'), + key: Persistor.keyBuilder((snap) => 'users_${snap.id}'), ), ); @@ -3172,23 +2855,19 @@ void main() { userCollection.doc('2').create(TestUserModel('User 2')); userCollection.doc('3').create(TestUserModel('User 3')); - final file1 = File('${testDirectory.path}/loon/users1.json'); - final file2 = File('${testDirectory.path}/loon/users2.json'); - final file3 = File('${testDirectory.path}/loon/users3.json'); - await completer.onSync; - expect(file1.existsSync(), true); - expect(file2.existsSync(), true); - expect(file3.existsSync(), true); + expect(await exists('users_1'), true); + expect(await exists('users_2'), true); + expect(await exists('users_3'), true); userCollection.delete(); await completer.onSync; - expect(file1.existsSync(), false); - expect(file2.existsSync(), false); - expect(file3.existsSync(), false); + expect(await exists('users_1'), false); + expect(await exists('users_2'), false); + expect(await exists('users_3'), false); }, ); @@ -3213,20 +2892,17 @@ void main() { userCollection.doc('2').create(TestUserModel('User 2')); friendsCollection.doc('1').create(TestUserModel('Friend 1')); - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final friendsFile = File('${testDirectory.path}/loon/friends.json'); - await completer.onSync; - expect(storeFile.existsSync(), true); - expect(friendsFile.existsSync(), true); + expect(await exists('__store__'), true); + expect(await exists('friends'), true); userCollection.delete(); await completer.onSync; - expect(storeFile.existsSync(), false); - expect(friendsFile.existsSync(), false); + expect(await exists('__store__'), false); + expect(await exists('friends'), false); }, ); @@ -3251,13 +2927,10 @@ void main() { friendsCollection.doc('1').create(TestUserModel('Friend 1')); friendsCollection.doc('2').create(TestUserModel('Friend 2')); - final file = File('${testDirectory.path}/loon/__store__.json'); - await completer.onSync; - Json json = jsonDecode(file.readAsStringSync()); expect( - json, + await get('__store__'), { "": { "users": { @@ -3280,9 +2953,8 @@ void main() { await completer.onSync; - json = jsonDecode(file.readAsStringSync()); expect( - json, + await get('__store__'), { "": { "friends": { @@ -3317,19 +2989,15 @@ void main() { userCollection.doc('1').create(TestUserModel('User 1')); userCollection.doc('2').create(TestUserModel('User 2')); - final file = File('${testDirectory.path}/loon/users.json'); - final resolverFile = - File('${testDirectory.path}/loon/__resolver__.json'); - await completer.onSync; - expect(file.existsSync(), true); - expect(resolverFile.existsSync(), true); + expect(await exists('users'), true); + expect(await exists('__resolver__'), true); await Loon.clearAll(); - expect(file.existsSync(), false); - expect(resolverFile.existsSync(), false); + expect(await exists('users'), false); + expect(await exists('__resolver__'), false); }, ); }, @@ -3338,7 +3006,7 @@ void main() { test('Sequences operations correctly', () async { final List operations = []; Loon.configure( - persistor: TestFilePersistor( + persistor: TestPersistor( onClear: (_) { operations.add('clear'); }, @@ -3387,4 +3055,428 @@ void main() { ); }); }); + + group( + 'Encrypted Persistor Test Runner', + () { + const encryptedSettings = PersistorSettings(encrypted: true); + + setUp(() { + configure(settings: encryptedSettings); + }); + + tearDown(() async { + await Loon.clearAll(); + }); + + group('hydrate', () { + test( + 'Merges data from plaintext and encrypted persistence files into collections', + () async { + final userCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: false), + ); + final encryptedUsersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: true), + ); + + userCollection.doc('1').create(TestUserModel('User 1')); + encryptedUsersCollection.doc('2').create(TestUserModel('User 2')); + + await completer.onSync; + + expect(await get('__store__'), { + "": { + "users": { + "__values": { + '1': {'name': 'User 1'}, + }, + }, + } + }); + + expect(await get('__store__', encrypted: true), { + "": { + "users": { + "__values": { + '2': {'name': 'User 2'}, + }, + }, + } + }); + + Loon.configure(persistor: null); + await Loon.clearAll(); + configure(settings: encryptedSettings); + + expect(userCollection.exists(), false); + + await Loon.hydrate(); + + expect( + userCollection.get(), + [ + DocumentSnapshot( + doc: userCollection.doc('1'), + data: TestUserModel('User 1'), + ), + DocumentSnapshot( + doc: userCollection.doc('2'), + data: TestUserModel('User 2'), + ), + ], + ); + }, + ); + + // This scenario takes a bit of a description. In the situation where a file for a collection is unencrypted, + // but encryption settings now specify that the collection should be encrypted, then the unencrypted file should + // be hydrated into memory, but any subsequent persistence calls for that collection should move the updated data + // from the unencrypted data store to the encrypted data store. Once all the data has been moved, the unencrypted + // file should be deleted. + test('Encrypts collections hydrated from unencrypted files', () async { + configure(settings: const PersistorSettings()); + + final usersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + ); + + usersCollection.doc('1').create(TestUserModel('User 1')); + usersCollection.doc('2').create(TestUserModel('User 2')); + + await completer.onSync; + + expect( + await get('__store__'), + { + "": { + "users": { + "__values": { + "1": {"name": "User 1"}, + "2": {"name": "User 2"}, + } + } + } + }, + ); + + Loon.configure(persistor: null); + await Loon.clearAll(); + configure(settings: encryptedSettings); + + await Loon.hydrate(); + + expect( + usersCollection.get(), + [ + DocumentSnapshot( + doc: usersCollection.doc('1'), + data: TestUserModel('User 1'), + ), + DocumentSnapshot( + doc: usersCollection.doc('2'), + data: TestUserModel('User 2'), + ), + ], + ); + + usersCollection.doc('3').create(TestUserModel('User 3')); + + await completer.onSync; + + // The new user should have been written to the encrypted store file, since the persistor was configured with encryption + // enabled globally. + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "3": {'name': 'User 3'}, + } + }, + } + }, + ); + + // The existing hydrated data should still be unencrypted, as the documents are not moved until they are updated. + expect( + await get('__store__'), + { + "": { + "users": { + "__values": { + "1": {"name": "User 1"}, + "2": {"name": "User 2"}, + } + } + } + }, + ); + + usersCollection.doc('1').update(TestUserModel('User 1 updated')); + usersCollection.doc('2').update(TestUserModel('User 2 updated')); + + await completer.onSync; + + // The documents should now have been updated to exist in the encrypted store file. + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1 updated'}, + "2": {'name': 'User 2 updated'}, + "3": {"name": "User 3"}, + }, + }, + } + }, + ); + + // The now empty plaintext root file should have been deleted. + expect(await exists('__store__'), false); + }); + }); + + group( + 'persist', + () { + test('Encrypts data when enabled globally for all collections', + () async { + final userCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + ); + + final user1 = TestUserModel('User 1'); + final userDoc1 = userCollection.doc('1'); + final user2 = TestUserModel('User 2'); + final userDoc2 = userCollection.doc('2'); + + userDoc1.create(user1); + userDoc2.create(user2); + + await completer.onSync; + + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1'}, + "2": {'name': 'User 2'}, + } + } + } + }, + ); + }); + + test('Encrypts data when explicitly enabled for a collection', + () async { + configure(settings: const PersistorSettings()); + + final friendsCollection = Loon.collection( + 'friends', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + ); + + final usersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: true), + ); + + friendsCollection.doc('1').create(TestUserModel('Friend 1')); + usersCollection.doc('1').create(TestUserModel('User 1')); + usersCollection.doc('2').create(TestUserModel('User 2')); + + await completer.onSync; + + expect( + await get('__store__'), + { + "": { + "friends": { + "__values": { + "1": {'name': 'Friend 1'}, + } + }, + } + }, + ); + + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1'}, + "2": {'name': 'User 2'}, + } + }, + } + }, + ); + }); + test('Subcollections inherit parent encryption settings', () async { + configure(settings: const PersistorSettings()); + + final usersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: true), + ); + + final user1FriendsCollection = + usersCollection.doc('1').subcollection( + 'friends', + fromJson: TestUserModel.fromJson, + toJson: (friend) => friend.toJson(), + ); + + usersCollection.doc('1').create(TestUserModel('User 1')); + usersCollection.doc('2').create(TestUserModel('User 2')); + user1FriendsCollection.doc('1').create(TestUserModel('Friend 1')); + + await completer.onSync; + + expect(await exists('__store__'), false); + + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1'}, + "2": {'name': 'User 2'}, + }, + "1": { + "friends": { + "__values": { + "1": { + "name": "Friend 1", + } + } + } + } + }, + } + }, + ); + }); + + test('Subcollections can override parent encryption settings', + () async { + configure(settings: const PersistorSettings()); + + final usersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: true), + ); + + final user1FriendsCollection = usersCollection + .doc('1') + .subcollection( + 'friends', + fromJson: TestUserModel.fromJson, + toJson: (friend) => friend.toJson(), + persistorSettings: const PersistorSettings(encrypted: false), + ); + + usersCollection.doc('1').create(TestUserModel('User 1')); + usersCollection.doc('2').create(TestUserModel('User 2')); + user1FriendsCollection.doc('1').create(TestUserModel('Friend 1')); + + await completer.onSync; + + expect( + await get('__store__'), + { + "": { + "users": { + "1": { + "friends": { + "__values": { + "1": { + "name": "Friend 1", + } + } + } + } + }, + } + }, + ); + + expect( + await get('__store__', encrypted: true), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1'}, + "2": {'name': 'User 2'}, + }, + }, + } + }, + ); + }); + + test( + 'Does not encrypt data when explicitly disabled for a collection', + () async { + final usersCollection = Loon.collection( + 'users', + fromJson: TestUserModel.fromJson, + toJson: (user) => user.toJson(), + persistorSettings: const PersistorSettings(encrypted: false), + ); + + usersCollection.doc('1').create(TestUserModel('User 1')); + usersCollection.doc('2').create(TestUserModel('User 2')); + + await completer.onSync; + + expect( + await get('__store__'), + { + "": { + "users": { + "__values": { + "1": {'name': 'User 1'}, + "2": {'name': 'User 2'}, + } + }, + } + }, + ); + + expect(await exists('__store__', encrypted: true), false); + }, + ); + }, + ); + }, + ); } diff --git a/test/models/test_file_persistor.dart b/test/models/test_file_persistor.dart deleted file mode 100644 index 19b4d12..0000000 --- a/test/models/test_file_persistor.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:loon/loon.dart'; -import 'package:loon/persistor/file_persistor/file_persistor.dart'; - -import '../utils.dart'; -import 'test_data_store_encrypter.dart'; - -class TestFilePersistor extends FilePersistor { - static var completer = PersistorCompleter(); - - TestFilePersistor({ - PersistorSettings? settings, - void Function(Set batch)? onPersist, - void Function(Set collections)? onClear, - void Function()? onClearAll, - void Function(Json data)? onHydrate, - void Function()? onSync, - }) : super( - // To make tests run faster, in the test environment the persistence throttle - // is decreased to 1 millisecond. - persistenceThrottle: const Duration(milliseconds: 1), - settings: settings ?? const PersistorSettings(), - encrypter: TestDataStoreEncrypter(), - onPersist: (docs) { - onPersist?.call(docs); - completer.persistComplete(); - }, - onHydrate: (refs) { - onHydrate?.call(refs); - completer.hydrateComplete(); - }, - onClear: (collections) { - onClear?.call(collections); - completer.clearComplete(); - }, - onClearAll: () { - onClearAll?.call(); - completer.clearAllComplete(); - }, - onSync: () { - onSync?.call(); - completer.syncComplete(); - }, - ); -} diff --git a/test/models/test_indexed_db_persistor.dart b/test/models/test_indexed_db_persistor.dart deleted file mode 100644 index 5d1a946..0000000 --- a/test/models/test_indexed_db_persistor.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:convert'; -import 'dart:js_interop'; - -import 'package:loon/loon.dart'; -import 'package:loon/persistor/data_store_encrypter.dart'; -import 'package:loon/persistor/indexed_db_persistor/indexed_db_persistor.dart'; - -import '../utils.dart'; -import 'test_data_store_encrypter.dart'; - -class TestIndexedDBPersistor extends IndexedDBPersistor { - static var completer = PersistorCompleter(); - - TestIndexedDBPersistor({ - PersistorSettings? settings, - void Function(Set batch)? onPersist, - void Function(Set collections)? onClear, - void Function()? onClearAll, - void Function(Json data)? onHydrate, - void Function()? onSync, - }) : super( - // To make tests run faster, in the test environment the persistence throttle - // is decreased to 1 millisecond. - persistenceThrottle: const Duration(milliseconds: 1), - settings: settings ?? const PersistorSettings(), - encrypter: TestDataStoreEncrypter(), - onPersist: (docs) { - onPersist?.call(docs); - completer.persistComplete(); - }, - onHydrate: (refs) { - onHydrate?.call(refs); - completer.hydrateComplete(); - }, - onClear: (collections) { - onClear?.call(collections); - completer.clearComplete(); - }, - onClearAll: () { - onClearAll?.call(); - completer.clearAllComplete(); - }, - onSync: () { - onSync?.call(); - completer.syncComplete(); - }, - ); - - Future getStore( - String storeName, { - bool encrypted = false, - }) async { - final result = await runTransaction('Get', (objectStore) { - final objectStoreName = encrypted - ? '$storeName:${DataStoreEncrypter.encryptedName}' - : storeName; - - return objectStore.get(objectStoreName.toJS); - }); - - if (result == null) { - return null; - } - - final value = result[IndexedDBPersistor.valuePath]; - - return jsonDecode(encrypted ? encrypter.decrypt(value) : value); - } -} diff --git a/test/models/test_large_model.dart b/test/models/test_large_model.dart deleted file mode 100644 index 92b8036..0000000 --- a/test/models/test_large_model.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:loon/loon.dart'; - -class TestLargeModel { - final String id; - final double amount; - final String name; - final DateTime createdAt; - final DateTime updatedAt; - final double secondaryAmount; - final String description; - - TestLargeModel({ - required this.id, - required this.amount, - required this.name, - required this.createdAt, - required this.updatedAt, - required this.secondaryAmount, - required this.description, - }); - - TestLargeModel.fromJson(Json json) - : id = json['id'], - amount = json['amount'], - name = json['name'], - createdAt = DateTime.parse(json['createdAt']), - updatedAt = DateTime.parse(json['updatedAt']), - secondaryAmount = json['secondaryAmount'], - description = json['description']; - - Json toJson() { - return { - "id": id, - "amount": amount, - "name": name, - "createdAt": createdAt.toIso8601String(), - "updatedAt": updatedAt.toIso8601String(), - "secondaryAmount": secondaryAmount, - "description": description, - }; - } -} diff --git a/test/models/test_persistor.dart b/test/models/test_persistor.dart index 9e17220..6e60a37 100644 --- a/test/models/test_persistor.dart +++ b/test/models/test_persistor.dart @@ -1,14 +1,14 @@ import 'package:loon/loon.dart'; -import '../utils.dart'; +import 'test_persistor_completer.dart'; import 'test_user_model.dart'; /// A dummy persistor used in the test environment that doesn't actually engage with any persistence storage /// mechanism (file system, etc) and is just used to test the base [Persistor] batching and de-duping. class TestPersistor extends Persistor { - static final completer = PersistorCompleter(); - final List> seedData; + static final completer = TestPersistCompleter(); + TestPersistor({ void Function(Set batch)? onPersist, void Function(Set collections)? onClear, diff --git a/test/models/test_persistor_completer.dart b/test/models/test_persistor_completer.dart new file mode 100644 index 0000000..0b97176 --- /dev/null +++ b/test/models/test_persistor_completer.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +/// A type of completer that is reset after its current completion result is observed by a subscriber +/// to its future. +class _ResetCompleter { + Completer _completer = Completer(); + + void complete([T? value]) { + if (!_completer.isCompleted) { + _completer.complete(value); + } + } + + Future get future async { + await _completer.future; + _completer = Completer(); + } +} + +class TestPersistCompleter { + final _onPersistCompleter = _ResetCompleter(); + final _onClearCompleter = _ResetCompleter(); + final _onHydrateCompleter = _ResetCompleter(); + final _onClearAllCompleter = _ResetCompleter(); + final _onSyncCompleter = _ResetCompleter(); + + void persistComplete() { + _onPersistCompleter.complete(); + } + + void clearComplete() { + _onClearCompleter.complete(); + } + + void clearAllComplete() { + _onClearAllCompleter.complete(); + } + + void hydrateComplete() { + _onHydrateCompleter.complete(); + } + + void syncComplete() { + _onSyncCompleter.complete(); + } + + Future get onPersist { + return _onPersistCompleter.future; + } + + Future get onClear { + return _onClearCompleter.future; + } + + Future get onClearAll { + return _onClearAllCompleter.future; + } + + Future get onHydrate { + return _onHydrateCompleter.future; + } + + Future get onSync { + return _onSyncCompleter.future; + } +} diff --git a/test/native/file_persistor_test.dart b/test/native/file_persistor_test.dart new file mode 100644 index 0000000..0fbf945 --- /dev/null +++ b/test/native/file_persistor_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; +import 'package:loon/persistor/file_persistor/file_persistor.dart'; + +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +// ignore: depend_on_referenced_packages +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../core/persistor/persistor_test_runner.dart'; + +late Directory testDirectory; + +class MockPathProvider extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + getApplicationDocumentsDirectory() { + return testDirectory; + } + + @override + getApplicationDocumentsPath() async { + return testDirectory.path; + } +} + +void main() { + testDirectory = Directory.systemTemp.createTempSync('test_dir'); + Directory("${testDirectory.path}/loon").createSync(); + final mockPathProvider = MockPathProvider(); + PathProviderPlatform.instance = mockPathProvider; + + group('FilePersistor', () { + persistorTestRunner( + getStore: ( + persistor, + name, { + bool encrypted = false, + }) async { + final storeName = + encrypted ? '$name.${DataStoreEncrypter.encryptedName}' : name; + final file = File('${testDirectory.path}/loon/$storeName.json'); + + final exists = await file.exists(); + if (!exists) { + return null; + } + + final value = await file.readAsString(); + + return jsonDecode( + encrypted ? persistor.encrypter.decrypt(value) : value, + ); + }, + factory: FilePersistor.new, + ); + }); +} diff --git a/test/native/persistor/encrypted_file_persistor_test.dart b/test/native/persistor/encrypted_file_persistor_test.dart deleted file mode 100644 index 7765b67..0000000 --- a/test/native/persistor/encrypted_file_persistor_test.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:loon/loon.dart'; -import '../../models/test_file_persistor.dart'; -import '../../models/test_user_model.dart'; -import '../../utils.dart'; - -// ignore: depend_on_referenced_packages -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -// ignore: depend_on_referenced_packages -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -late Directory testDirectory; - -class MockPathProvider extends Fake - with MockPlatformInterfaceMixin - implements PathProviderPlatform { - getApplicationDocumentsDirectory() { - return testDirectory; - } - - @override - getApplicationDocumentsPath() async { - return testDirectory.path; - } -} - -void main() { - late PersistorCompleter completer; - late TestFilePersistor persistor; - - setUp(() { - testDirectory = Directory.systemTemp.createTempSync('test_dir'); - final mockPathProvider = MockPathProvider(); - PathProviderPlatform.instance = mockPathProvider; - - completer = TestFilePersistor.completer = PersistorCompleter(); - persistor = TestFilePersistor( - settings: const PersistorSettings(encrypted: true), - ); - }); - - tearDown(() async { - testDirectory.deleteSync(recursive: true); - await Loon.clearAll(); - }); - - group('Encrypted FilePersistor', () { - group('hydrate', () { - setUp(() { - Loon.configure(persistor: persistor); - }); - - test( - 'Merges data from plaintext and encrypted persistence files into collections', - () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: false), - ); - final encryptedUsersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: true), - ); - - userCollection.doc('1').create(TestUserModel('User 1')); - encryptedUsersCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - final storeJson = jsonDecode(storeFile.readAsStringSync()); - - expect(storeJson, { - "": { - "users": { - "__values": { - '1': {'name': 'User 1'}, - }, - }, - } - }); - - final encryptedStoreFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - final encryptedStoreJson = jsonDecode( - persistor.encrypter.decrypt(encryptedStoreFile.readAsStringSync()), - ); - - expect(encryptedStoreJson, { - "": { - "users": { - "__values": { - '2': {'name': 'User 2'}, - }, - }, - } - }); - - // Reinitialize the persistor ahead of hydration. - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: true), - ), - ); - - await Loon.hydrate(); - - expect( - userCollection.get(), - [ - DocumentSnapshot( - doc: userCollection.doc('1'), - data: TestUserModel('User 1'), - ), - DocumentSnapshot( - doc: userCollection.doc('2'), - data: TestUserModel('User 2'), - ), - ], - ); - }, - ); - - // This scenario takes a bit of a description. In the situation where a file for a collection is unencrypted, - // but encryption settings now specify that the collection should be encrypted, then the unencrypted file should - // be hydrated into memory, but any subsequent persistence calls for that collection should move the updated data - // from the unencrypted data store to the encrypted data store. Once all the data has been moved, the unencrypted - // file should be deleted. - test('Encrypts collections hydrated from unencrypted files', () async { - Loon.configure(persistor: TestFilePersistor()); - - final usersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - - usersCollection.doc('1').create(TestUserModel('User 1')); - usersCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - final storeFile = File('${testDirectory.path}/loon/__store__.json'); - var storeJson = jsonDecode(storeFile.readAsStringSync()); - - expect( - storeJson, - { - "": { - "users": { - "__values": { - "1": {"name": "User 1"}, - "2": {"name": "User 2"}, - } - } - } - }, - ); - - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: true), - ), - ); - - await Loon.hydrate(); - - expect( - usersCollection.get(), - [ - DocumentSnapshot( - doc: usersCollection.doc('1'), - data: TestUserModel('User 1'), - ), - DocumentSnapshot( - doc: usersCollection.doc('2'), - data: TestUserModel('User 2'), - ), - ], - ); - - usersCollection.doc('3').create(TestUserModel('User 3')); - - await completer.onSync; - - final encryptedStoreFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - var encryptedStoreJson = - decryptData(encryptedStoreFile.readAsStringSync()); - - // The new user should have been written to the encrypted store file, since the persistor was configured with encryption - // enabled globally. - expect( - encryptedStoreJson, - { - "": { - "users": { - "__values": { - "3": {'name': 'User 3'}, - } - }, - } - }, - ); - - // The existing hydrated data should still be unencrypted, as the documents are not moved until they are updated. - storeJson = jsonDecode(storeFile.readAsStringSync()); - expect( - storeJson, - { - "": { - "users": { - "__values": { - "1": {"name": "User 1"}, - "2": {"name": "User 2"}, - } - } - } - }, - ); - - usersCollection.doc('1').update(TestUserModel('User 1 updated')); - usersCollection.doc('2').update(TestUserModel('User 2 updated')); - - await completer.onSync; - - // The documents should now have been updated to exist in the encrypted store file. - encryptedStoreJson = decryptData(encryptedStoreFile.readAsStringSync()); - - expect( - encryptedStoreJson, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1 updated'}, - "2": {'name': 'User 2 updated'}, - "3": {"name": "User 3"}, - }, - }, - } - }, - ); - - // The now empty plaintext root file should have been deleted. - expect(storeFile.existsSync(), false); - }); - }); - - group( - 'persist', - () { - test('Encrypts data when enabled globally for all collections', - () async { - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: true), - ), - ); - - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - - final user1 = TestUserModel('User 1'); - final userDoc1 = userCollection.doc('1'); - final user2 = TestUserModel('User 2'); - final userDoc2 = userCollection.doc('2'); - - userDoc1.create(user1); - userDoc2.create(user2); - - await completer.onSync; - - final file = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - final json = decryptData(file.readAsStringSync()); - - expect( - json, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - } - } - } - }, - ); - }); - - test('Encrypts data when explicitly enabled for a collection', - () async { - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: false), - ), - ); - - final friendsCollection = Loon.collection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - - final usersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: true), - ); - - friendsCollection.doc('1').create(TestUserModel('Friend 1')); - usersCollection.doc('1').create(TestUserModel('User 1')); - usersCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - - expect( - json, - { - "": { - "friends": { - "__values": { - "1": {'name': 'Friend 1'}, - } - }, - } - }, - ); - - final encryptedFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - final encryptedJson = decryptData(encryptedFile.readAsStringSync()); - - expect( - encryptedJson, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - } - }, - } - }, - ); - }); - test('Subcollections inherit parent encryption settings', () async { - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: false), - ), - ); - - final usersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: true), - ); - - final user1FriendsCollection = usersCollection.doc('1').subcollection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (friend) => friend.toJson(), - ); - - usersCollection.doc('1').create(TestUserModel('User 1')); - usersCollection.doc('2').create(TestUserModel('User 2')); - user1FriendsCollection.doc('1').create(TestUserModel('Friend 1')); - - await completer.onSync; - - final file = File('${testDirectory.path}/loon/__store__.json'); - expect(file.existsSync(), false); - - final encryptedFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - final encryptedJson = decryptData(encryptedFile.readAsStringSync()); - - expect( - encryptedJson, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - }, - "1": { - "friends": { - "__values": { - "1": { - "name": "Friend 1", - } - } - } - } - }, - } - }, - ); - }); - - test('Subcollections can override parent encryption settings', - () async { - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: false), - ), - ); - - final usersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: true), - ); - - final user1FriendsCollection = usersCollection.doc('1').subcollection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (friend) => friend.toJson(), - persistorSettings: const PersistorSettings(encrypted: false), - ); - - usersCollection.doc('1').create(TestUserModel('User 1')); - usersCollection.doc('2').create(TestUserModel('User 2')); - user1FriendsCollection.doc('1').create(TestUserModel('Friend 1')); - - await completer.onSync; - - final file = File('${testDirectory.path}/loon/__store__.json'); - final fileJson = jsonDecode(file.readAsStringSync()); - - expect( - fileJson, - { - "": { - "users": { - "1": { - "friends": { - "__values": { - "1": { - "name": "Friend 1", - } - } - } - } - }, - } - }, - ); - - final encryptedFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - final encryptedJson = decryptData(encryptedFile.readAsStringSync()); - - expect( - encryptedJson, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - }, - }, - } - }, - ); - }); - - test( - 'Does not encrypt data when explicitly disabled for a collection', - () async { - Loon.configure( - persistor: TestFilePersistor( - settings: const PersistorSettings(encrypted: true), - ), - ); - - final usersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: false), - ); - - usersCollection.doc('1').create(TestUserModel('User 1')); - usersCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - final file = File('${testDirectory.path}/loon/__store__.json'); - final json = jsonDecode(file.readAsStringSync()); - - expect( - json, - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - } - }, - } - }, - ); - - final encryptedFile = - File('${testDirectory.path}/loon/__store__.encrypted.json'); - expect(encryptedFile.existsSync(), false); - }, - ); - }, - ); - }); -} diff --git a/test/native/sqlite_persistor_test.dart b/test/native/sqlite_persistor_test.dart new file mode 100644 index 0000000..0e54329 --- /dev/null +++ b/test/native/sqlite_persistor_test.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; +import 'package:loon/persistor/sqlite_persistor/sqlite_persistor.dart'; +import '../core/persistor/persistor_test_runner.dart'; + +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + sqfliteFfiInit(); + // Change the default factory for unit testing calls for SQFlite. + databaseFactory = databaseFactoryFfi; + + group('SqlitePersistor', () { + persistorTestRunner( + getStore: ( + persistor, + storeName, { + bool encrypted = false, + }) async { + final key = encrypted + ? '$storeName:${DataStoreEncrypter.encryptedName}' + : storeName; + + final records = await persistor.db.query( + SqlitePersistor.tableName, + columns: [SqlitePersistor.valueColumn], + where: '${SqlitePersistor.keyColumn} = ?', + whereArgs: [key], + ); + + if (records.isEmpty) { + return null; + } + + final value = records.first[SqlitePersistor.valueColumn] as String; + + return jsonDecode( + encrypted ? persistor.encrypter.decrypt(value) : value, + ); + }, + factory: SqlitePersistor.new, + ); + }); +} diff --git a/test/utils.dart b/test/utils.dart index adcc39d..fca9b3b 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -1,107 +1,12 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; import 'package:encrypt/encrypt.dart'; import 'package:loon/loon.dart'; import 'package:uuid/uuid.dart'; -import './models/test_large_model.dart'; const uuid = Uuid(); final testEncryptionKey = Key.fromSecureRandom(32); -/// A type of completer that is reset after its current completion result is observed by a subscriber -/// to its future. -class ResetCompleter { - Completer _completer = Completer(); - - void complete([T? value]) { - if (!_completer.isCompleted) { - _completer.complete(value); - } - } - - Future get future async { - await _completer.future; - _completer = Completer(); - } -} - -class PersistorCompleter { - final _onPersistCompleter = ResetCompleter(); - final _onClearCompleter = ResetCompleter(); - final _onHydrateCompleter = ResetCompleter(); - final _onClearAllCompleter = ResetCompleter(); - final _onSyncCompleter = ResetCompleter(); - - void persistComplete() { - _onPersistCompleter.complete(); - } - - void clearComplete() { - _onClearCompleter.complete(); - } - - void clearAllComplete() { - _onClearAllCompleter.complete(); - } - - void hydrateComplete() { - _onHydrateCompleter.complete(); - } - - void syncComplete() { - _onSyncCompleter.complete(); - } - - Future get onPersist { - return _onPersistCompleter.future; - } - - Future get onClear { - return _onClearCompleter.future; - } - - Future get onClearAll { - return _onClearAllCompleter.future; - } - - Future get onHydrate { - return _onHydrateCompleter.future; - } - - Future get onSync { - return _onSyncCompleter.future; - } -} - -TestLargeModel generateRandomModel() { - var random = Random(); - return TestLargeModel( - id: uuid.v4(), - amount: random.nextDouble() * 100, - name: 'Name ${random.nextInt(100)}', - createdAt: DateTime.now().subtract(Duration(days: random.nextInt(100))), - updatedAt: DateTime.now(), - secondaryAmount: random.nextDouble() * 200, - description: 'Description ${random.nextInt(100)}', - ); -} - -Future generateLargeModelSampleFile(int size) { - List models = - List.generate(size, (_) => generateRandomModel()); - - final modelsMap = models.fold({}, (acc, model) { - acc['users:${model.id}'] = model.toJson(); - return acc; - }); - - String jsonContent = jsonEncode(modelsMap); - File file = File('./test/samples/large_model_sample.json'); - return file.writeAsString(jsonContent); -} - String encryptData(Json json) { final iv = IV.fromSecureRandom(16); final encrypter = Encrypter(AES(testEncryptionKey, mode: AESMode.cbc)); diff --git a/test/web/indexed_db_persistor_test.dart b/test/web/indexed_db_persistor_test.dart new file mode 100644 index 0000000..c41b3d2 --- /dev/null +++ b/test/web/indexed_db_persistor_test.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:loon/persistor/data_store_encrypter.dart'; +import 'package:loon/persistor/indexed_db_persistor/web_indexed_db_persistor.dart'; +import '../core/persistor/persistor_test_runner.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + persistorTestRunner( + getStore: ( + persistor, + storeName, { + bool encrypted = false, + }) async { + final result = await persistor.runTransaction('Get', (objectStore) { + final objectStoreName = encrypted + ? '$storeName:${DataStoreEncrypter.encryptedName}' + : storeName; + + return objectStore.get(objectStoreName.toJS); + }); + + if (result == null) { + return null; + } + + final value = result[IndexedDBPersistor.valuePath]; + + return jsonDecode(encrypted ? persistor.encrypter.decrypt(value) : value); + }, + factory: IndexedDBPersistor.new, + ); +} diff --git a/test/web/persistor/encrypted_indexed_db_persistor_test.dart b/test/web/persistor/encrypted_indexed_db_persistor_test.dart deleted file mode 100644 index dc75b6c..0000000 --- a/test/web/persistor/encrypted_indexed_db_persistor_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:loon/loon.dart'; -import '../../models/test_indexed_db_persistor.dart'; -import '../../models/test_user_model.dart'; -import '../../utils.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late PersistorCompleter completer; - late TestIndexedDBPersistor persistor; - - setUp(() { - completer = TestIndexedDBPersistor.completer = PersistorCompleter(); - persistor = TestIndexedDBPersistor(); - - Loon.configure(persistor: persistor); - }); - - tearDown(() async { - await Loon.clearAll(); - }); - - group('Encrypted IndexedDBPersistor', () { - test( - 'Persists encrypted data', - () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: false), - ); - final encryptedUsersCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: const PersistorSettings(encrypted: true), - ); - - userCollection.doc('1').create(TestUserModel('User 1')); - encryptedUsersCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - expect(await persistor.getStore('__store__'), { - "": { - "users": { - "__values": { - '1': {'name': 'User 1'}, - }, - }, - } - }); - - expect(await persistor.getStore('__store__', encrypted: true), { - "": { - "users": { - "__values": { - '2': {'name': 'User 2'}, - }, - }, - } - }); - - Loon.configure(persistor: null); - await Loon.clearAll(); - - expect(userCollection.exists(), false); - expect(encryptedUsersCollection.exists(), false); - - // Reinitialize the persistor ahead of hydration. - Loon.configure( - persistor: TestIndexedDBPersistor( - settings: const PersistorSettings(encrypted: true), - ), - ); - - await Loon.hydrate(); - - expect( - userCollection.get(), - [ - DocumentSnapshot( - doc: userCollection.doc('1'), - data: TestUserModel('User 1'), - ), - DocumentSnapshot( - doc: userCollection.doc('2'), - data: TestUserModel('User 2'), - ), - ], - ); - }, - ); - }); -} diff --git a/test/web/persistor/indexed_db_persistor_test.dart b/test/web/persistor/indexed_db_persistor_test.dart deleted file mode 100644 index bf8572c..0000000 --- a/test/web/persistor/indexed_db_persistor_test.dart +++ /dev/null @@ -1,351 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:loon/loon.dart'; - -import '../../models/test_indexed_db_persistor.dart'; -import '../../models/test_user_model.dart'; -import '../../utils.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late PersistorCompleter completer; - late TestIndexedDBPersistor persistor; - - setUp(() { - persistor = TestIndexedDBPersistor(); - completer = TestIndexedDBPersistor.completer = PersistorCompleter(); - - Loon.configure(persistor: persistor); - }); - - tearDown(() async { - await Loon.clearAll(); - }); - - group('IndexedDBPersistor', () { - group( - 'Persist', - () { - test('Persists documents', () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - - final userDoc = userCollection.doc('1'); - final userDoc2 = userCollection.doc('2'); - - final user = TestUserModel('User 1'); - final user2 = TestUserModel('User 2'); - - userDoc.create(user); - userDoc2.create(user2); - userDoc2 - .subcollection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ) - .doc('1') - .create(user); - - await completer.onSync; - - expect( - await persistor.getStore('__store__'), - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - }, - "2": { - "friends": { - "__values": { - "1": {'name': 'User 1'}, - } - } - } - } - } - }, - ); - }); - }, - ); - - group('hydrate', () { - test('Hydrates documents', () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - final friendsCollection = Loon.collection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - - final userFriendsCollection = - Loon.collection('users').doc('1').subcollection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: - PersistorSettings(key: Persistor.key('my_friends')), - ); - - userCollection.doc('1').create(TestUserModel('User 1')); - userCollection.doc('2').create(TestUserModel('User 2')); - - friendsCollection.doc('1').create(TestUserModel('Friend 1')); - friendsCollection.doc('2').create(TestUserModel('Friend 2')); - - userFriendsCollection.doc('3').create(TestUserModel('Friend 3')); - - final currentUserDoc = Loon.doc('current_user_id'); - currentUserDoc.create('1'); - - await completer.onSync; - - expect(await persistor.getStore('__store__'), { - "": { - "users": { - "__values": { - "1": {"name": "User 1"}, - "2": {"name": "User 2"}, - } - }, - "friends": { - "__values": { - "1": {"name": "Friend 1"}, - "2": {"name": "Friend 2"}, - } - }, - "root": { - "__values": { - "current_user_id": "1", - }, - } - }, - }); - - expect(await persistor.getStore('my_friends'), { - "users__1__friends": { - "users": { - "1": { - "friends": { - "__values": { - "3": {"name": "Friend 3"}, - } - } - } - } - } - }); - - expect(await persistor.getStore('__resolver__'), { - "__refs": { - Persistor.defaultKey.value: 1, - "my_friends": 1, - }, - "__values": { - ValueStore.root: Persistor.defaultKey.value, - }, - "users": { - "__refs": { - "my_friends": 1, - }, - "1": { - "__refs": { - "my_friends": 1, - }, - "__values": { - "friends": "my_friends", - }, - } - } - }); - - // Set the persistor to null before clearing the store so that the persisted data is not deleted. - Loon.configure(persistor: null); - await Loon.clearAll(); - - expect(userCollection.exists(), false); - expect(friendsCollection.exists(), false); - expect(userFriendsCollection.exists(), false); - expect(currentUserDoc.exists(), false); - - // Then reinitialize a new persistor so that it reads the persisted data on hydration. - Loon.configure(persistor: TestIndexedDBPersistor()); - - await Loon.hydrate(); - - expect( - userCollection.get(), - [ - DocumentSnapshot( - doc: userCollection.doc('1'), - data: TestUserModel('User 1'), - ), - DocumentSnapshot( - doc: userCollection.doc('2'), - data: TestUserModel('User 2'), - ), - ], - ); - - expect(friendsCollection.get(), [ - DocumentSnapshot( - doc: friendsCollection.doc('1'), - data: TestUserModel('Friend 1'), - ), - DocumentSnapshot( - doc: friendsCollection.doc('2'), - data: TestUserModel('Friend 2'), - ), - ]); - - expect(userFriendsCollection.get(), [ - DocumentSnapshot( - doc: userFriendsCollection.doc('3'), - data: TestUserModel('Friend 3'), - ), - ]); - - expect( - currentUserDoc.get(), - DocumentSnapshot(doc: currentUserDoc, data: '1'), - ); - }); - }); - - group( - 'clear', - () { - test( - "Deletes collections and their subcollections", - () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - ); - final friendsCollection = userCollection.doc('1').subcollection( - 'friends', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: PersistorSettings( - key: Persistor.key('friends'), - ), - ); - - userCollection.doc('1').create(TestUserModel('User 1')); - userCollection.doc('2').create(TestUserModel('User 2')); - friendsCollection.doc('1').create(TestUserModel('Friend 1')); - - await completer.onSync; - - expect( - await persistor.getStore('__store__'), - { - "": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - } - } - } - }, - ); - - expect( - await persistor.getStore('friends'), - { - "users__1__friends": { - "users": { - "1": { - "friends": { - "__values": { - "1": {'name': 'Friend 1'}, - } - } - } - } - } - }, - ); - - userCollection.delete(); - - await completer.onSync; - - expect( - await persistor.getStore('__store__'), - null, - ); - - expect( - await persistor.getStore('friends'), - null, - ); - }, - ); - }, - ); - - group( - 'clearAll', - () { - test( - "Deletes all file data stores", - () async { - final userCollection = Loon.collection( - 'users', - fromJson: TestUserModel.fromJson, - toJson: (user) => user.toJson(), - persistorSettings: PersistorSettings( - key: Persistor.key('users'), - ), - ); - - userCollection.doc('1').create(TestUserModel('User 1')); - userCollection.doc('2').create(TestUserModel('User 2')); - - await completer.onSync; - - expect(await persistor.getStore('users'), { - "users": { - "users": { - "__values": { - "1": {'name': 'User 1'}, - "2": {'name': 'User 2'}, - } - } - } - }); - expect(await persistor.getStore('__resolver__'), { - "__refs": { - Persistor.defaultKey.value: 1, - "users": 1, - }, - "__values": { - ValueStore.root: Persistor.defaultKey.value, - "users": "users", - }, - }); - - await Loon.clearAll(); - - expect(await persistor.getStore('users'), null); - expect(await persistor.getStore('__resolver__'), null); - }, - ); - }, - ); - }); -}