From a9fd64f4ba75ec4af07417bcf93e8244dbf36d24 Mon Sep 17 00:00:00 2001 From: Hamdal Date: Sat, 14 Mar 2026 18:56:48 +0100 Subject: [PATCH] feat: add syncache_hive package with Hive storage backend --- packages/syncache_hive/CHANGELOG.md | 13 + packages/syncache_hive/LICENSE | 21 + packages/syncache_hive/README.md | 175 ++++ packages/syncache_hive/analysis_options.yaml | 10 + .../example/syncache_hive_example.dart | 129 +++ .../syncache_hive/lib/src/hive_store.dart | 488 ++++++++++ packages/syncache_hive/lib/syncache_hive.dart | 50 + packages/syncache_hive/pubspec.yaml | 25 + packages/syncache_hive/pubspec_overrides.yaml | 4 + .../syncache_hive/test/hive_store_test.dart | 920 ++++++++++++++++++ 10 files changed, 1835 insertions(+) create mode 100644 packages/syncache_hive/CHANGELOG.md create mode 100644 packages/syncache_hive/LICENSE create mode 100644 packages/syncache_hive/README.md create mode 100644 packages/syncache_hive/analysis_options.yaml create mode 100644 packages/syncache_hive/example/syncache_hive_example.dart create mode 100644 packages/syncache_hive/lib/src/hive_store.dart create mode 100644 packages/syncache_hive/lib/syncache_hive.dart create mode 100644 packages/syncache_hive/pubspec.yaml create mode 100644 packages/syncache_hive/pubspec_overrides.yaml create mode 100644 packages/syncache_hive/test/hive_store_test.dart diff --git a/packages/syncache_hive/CHANGELOG.md b/packages/syncache_hive/CHANGELOG.md new file mode 100644 index 0000000..265ba3d --- /dev/null +++ b/packages/syncache_hive/CHANGELOG.md @@ -0,0 +1,13 @@ +## 0.1.0 + +- Initial release +- `HiveStore` implementation of `TaggableStore` +- Full support for tag-based cache invalidation +- Pattern-based key matching and deletion +- Automatic JSON serialization for metadata +- Atomic operations - tags embedded in entries for consistency +- State management with `isOpen`/`isClosed` properties +- Graceful handling of corrupted data (returns null, deletes entry) +- Idempotent `close()` method +- Thread-safe `close()` - waits for pending operations to complete +- Graceful handling of invalid tags data (returns empty list) diff --git a/packages/syncache_hive/LICENSE b/packages/syncache_hive/LICENSE new file mode 100644 index 0000000..ec36c2d --- /dev/null +++ b/packages/syncache_hive/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hameed Abdullateef + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/syncache_hive/README.md b/packages/syncache_hive/README.md new file mode 100644 index 0000000..92d88b2 --- /dev/null +++ b/packages/syncache_hive/README.md @@ -0,0 +1,175 @@ +# syncache_hive + +Hive storage backend for [Syncache](https://pub.dev/packages/syncache) - cross-platform persistent caching. + +## Features + +- Persistent storage using [Hive](https://pub.dev/packages/hive) +- Cross-platform support (iOS, Android, Web, Desktop, Pure Dart) +- Full support for tag-based cache invalidation +- Pattern-based key matching and deletion +- Automatic serialization via JSON +- **Atomic operations** - each entry (data + tags) is stored as a single unit + +## Installation + +Add `syncache_hive` to your `pubspec.yaml`: + +```yaml +dependencies: + syncache: ^0.1.0 + syncache_hive: ^0.1.0 + hive: ^2.2.0 +``` + +For Flutter apps, also add `hive_flutter`: + +```yaml +dependencies: + hive_flutter: ^1.1.0 +``` + +## Usage + +### Basic Setup + +```dart +import 'package:hive/hive.dart'; +import 'package:syncache/syncache.dart'; +import 'package:syncache_hive/syncache_hive.dart'; + +void main() async { + // Initialize Hive (required once per app) + Hive.init('path/to/hive'); + + // Open a HiveStore with your data type + final store = await HiveStore.open( + boxName: 'users', + fromJson: User.fromJson, + toJson: (user) => user.toJson(), + ); + + // Create a Syncache instance with the store + final cache = Syncache(store: store); + + // Use the cache + final user = await cache.get( + key: 'user:123', + fetch: (req) => api.getUser('123'), + ); +} +``` + +### Flutter Setup + +```dart +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:syncache/syncache.dart'; +import 'package:syncache_hive/syncache_hive.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize Hive for Flutter + await Hive.initFlutter(); + + final store = await HiveStore.open( + boxName: 'users', + fromJson: User.fromJson, + toJson: (user) => user.toJson(), + ); + + final cache = Syncache(store: store); + + runApp(MyApp(cache: cache)); +} +``` + +### Using Tags + +```dart +// Store with tags for grouped invalidation +await cache.get( + key: 'calendar:events:2024-03', + fetch: fetchEvents, + tags: ['calendar', 'events', 'workspace:123'], +); + +// Invalidate all entries with 'calendar' tag +await cache.invalidateTag('calendar'); +``` + +### Closing the Store + +Remember to close the store when you're done: + +```dart +await store.close(); +``` + +## API Reference + +### HiveStore + +```dart +class HiveStore implements TaggableStore { + /// Opens a HiveStore with the specified box name. + static Future> open({ + required String boxName, + required T Function(Map) fromJson, + required Map Function(T) toJson, + }); + + /// Closes the store and releases resources. + Future close(); +} +``` + +### Inherited from TaggableStore + +- `write(key, entry)` - Store a value +- `writeWithTags(key, entry, tags)` - Store a value with tags +- `read(key)` - Retrieve a value +- `delete(key)` - Delete a value +- `clear()` - Clear all values +- `getTags(key)` - Get tags for a key +- `getKeysByTag(tag)` - Get all keys with a tag +- `deleteByTag(tag)` - Delete all entries with a tag +- `deleteByTags(tags, {matchAll})` - Delete entries matching tags +- `getKeysByPattern(pattern)` - Get keys matching a glob pattern +- `deleteByPattern(pattern)` - Delete keys matching a glob pattern + +## Limitations + +- **Key length**: Hive limits keys to a maximum of 255 characters. Attempting to use longer keys will throw a `HiveError`. +- **Tag replacement**: Unlike `MemoryStore`, calling `write()` on a key that already has tags will remove those tags. Use `writeWithTags()` to explicitly set tags. + +## Serialization + +HiveStore requires `fromJson` and `toJson` functions to serialize your data type. This is because Hive stores data as maps internally. + +```dart +class User { + final String name; + final String email; + + User({required this.name, required this.email}); + + factory User.fromJson(Map json) { + return User( + name: json['name'] as String, + email: json['email'] as String, + ); + } + + Map toJson() => { + 'name': name, + 'email': email, + }; +} +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/packages/syncache_hive/analysis_options.yaml b/packages/syncache_hive/analysis_options.yaml new file mode 100644 index 0000000..78c32b7 --- /dev/null +++ b/packages/syncache_hive/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:lints/recommended.yaml + +linter: + rules: + - prefer_const_constructors + - prefer_const_declarations + - prefer_final_fields + - prefer_final_locals + - avoid_print + - require_trailing_commas diff --git a/packages/syncache_hive/example/syncache_hive_example.dart b/packages/syncache_hive/example/syncache_hive_example.dart new file mode 100644 index 0000000..5b3513c --- /dev/null +++ b/packages/syncache_hive/example/syncache_hive_example.dart @@ -0,0 +1,129 @@ +// ignore_for_file: unused_local_variable, avoid_print + +import 'dart:io'; + +import 'package:hive/hive.dart'; +import 'package:syncache/syncache.dart'; +import 'package:syncache_hive/syncache_hive.dart'; + +/// Example model with JSON serialization. +class User { + final String id; + final String name; + final String email; + + User({required this.id, required this.name, required this.email}); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + name: json['name'] as String, + email: json['email'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'email': email, + }; + + @override + String toString() => 'User($id, $name, $email)'; +} + +Future main() async { + // Initialize Hive with a directory path + final tempDir = await Directory.systemTemp.createTemp('hive_example_'); + Hive.init(tempDir.path); + + // Open a HiveStore with JSON serialization functions + final store = await HiveStore.open( + boxName: 'users', + fromJson: User.fromJson, + toJson: (user) => user.toJson(), + ); + + // Create a Syncache instance with the persistent store + final cache = Syncache(store: store); + + // --- Basic Usage --- + + // Fetch and cache a user + final user = await cache.get( + key: 'user:1', + fetch: (req) async { + // Simulate API call + return User(id: '1', name: 'Alice', email: 'alice@example.com'); + }, + ); + print('Fetched: $user'); + + // Subsequent calls return cached value (no fetch) + final cachedUser = await cache.get( + key: 'user:1', + fetch: (req) async { + throw StateError('Should not be called - using cache'); + }, + ); + print('Cached: $cachedUser'); + + // --- Using Tags --- + + // Store users with tags for grouped invalidation + await cache.get( + key: 'user:2', + fetch: (req) async => User(id: '2', name: 'Bob', email: 'bob@example.com'), + tags: ['team:engineering', 'role:developer'], + ); + + await cache.get( + key: 'user:3', + fetch: (req) async => + User(id: '3', name: 'Carol', email: 'carol@example.com'), + tags: ['team:engineering', 'role:manager'], + ); + + // Invalidate all entries with a specific tag + await cache.invalidateTag('team:engineering'); + print('Invalidated all engineering team members'); + + // --- Pattern-Based Operations --- + + // Store multiple items + await cache.get( + key: 'user:active:4', + fetch: (req) async => + User(id: '4', name: 'Dave', email: 'dave@example.com'), + ); + + await cache.get( + key: 'user:active:5', + fetch: (req) async => User(id: '5', name: 'Eve', email: 'eve@example.com'), + ); + + // Invalidate by pattern (glob-style wildcards) + await cache.invalidate('user:active:*'); + print('Invalidated all active users'); + + // --- Direct Store Operations --- + + // Get keys matching a pattern + final keys = await store.getKeysByPattern('user:*'); + print('Keys matching "user:*": $keys'); + + // Get tags for a specific key + final tags = await store.getTags('user:1'); + print('Tags for user:1: $tags'); + + // --- Cleanup --- + + // Dispose cache and close store + cache.dispose(); + await store.close(); + + // Clean up temp directory + await tempDir.delete(recursive: true); + + print('Done!'); +} diff --git a/packages/syncache_hive/lib/src/hive_store.dart b/packages/syncache_hive/lib/src/hive_store.dart new file mode 100644 index 0000000..ef5183c --- /dev/null +++ b/packages/syncache_hive/lib/src/hive_store.dart @@ -0,0 +1,488 @@ +import 'dart:async'; + +import 'package:hive/hive.dart'; +import 'package:syncache/syncache.dart'; + +/// A Hive-backed implementation of [TaggableStore]. +/// +/// [HiveStore] provides persistent storage using Hive, a fast and +/// lightweight key-value database. Data persists across app restarts. +/// +/// This implementation requires serialization functions to convert +/// between your data type [T] and JSON, since Hive stores data as +/// maps internally. +/// +/// ## Atomicity +/// +/// All operations are atomic - each cache entry (including its tags) +/// is stored as a single Hive entry, ensuring consistency even if +/// the app crashes mid-operation. +/// +/// ## Thread Safety +/// +/// Operations are protected against concurrent close() calls. However, +/// if multiple [HiveStore] instances are opened on the same box name, +/// closing one will affect all instances since they share the underlying +/// Hive box. +/// +/// ## Pattern Syntax +/// +/// Pattern matching methods (`deleteByPattern`, `getKeysByPattern`) support +/// glob-style patterns: +/// - `*` matches any number of characters (including zero) +/// - `?` matches exactly one character +/// +/// Example patterns: +/// - `user:*` matches `user:1`, `user:123`, `user:` +/// - `user:?` matches `user:1`, `user:a` but not `user:12` +/// +/// ## Example +/// +/// ```dart +/// import 'package:hive/hive.dart'; +/// import 'package:syncache/syncache.dart'; +/// import 'package:syncache_hive/syncache_hive.dart'; +/// +/// // Initialize Hive (required once per app) +/// Hive.init('path/to/hive'); // or Hive.initFlutter() for Flutter +/// +/// // Open a HiveStore +/// final store = await HiveStore.open( +/// boxName: 'users', +/// fromJson: User.fromJson, +/// toJson: (user) => user.toJson(), +/// ); +/// +/// // Use with Syncache +/// final cache = Syncache(store: store); +/// +/// // Remember to close when done +/// await store.close(); +/// ``` +/// +/// ## Flutter Usage +/// +/// For Flutter apps, use `hive_flutter` package and initialize with: +/// ```dart +/// await Hive.initFlutter(); +/// ``` +/// +/// See also: +/// - [TaggableStore] for the tagging interface +/// - [Store] for the base storage interface +class HiveStore implements TaggableStore { + /// The Hive box storing cache entries (including embedded tags). + final Box> _box; + + /// Converts a JSON map to the value type [T]. + final T Function(Map json) fromJson; + + /// Converts a value of type [T] to a JSON map. + final Map Function(T value) toJson; + + /// Tracks whether this store has been closed. + bool _isClosed = false; + + /// Tracks the number of pending operations to prevent close during operation. + int _pendingOperations = 0; + + /// Completer that resolves when all pending operations complete. + /// Used by close() to wait for operations to finish. + Completer? _pendingCompleter; + + /// Creates a [HiveStore] with the given box and serialization functions. + /// + /// **Important:** Hive must be initialized before creating a store. + /// Call `Hive.init(path)` or `Hive.initFlutter()` first. + /// + /// Prefer using [HiveStore.open] which handles box opening automatically. + HiveStore({ + required Box> box, + required this.fromJson, + required this.toJson, + }) : _box = box; + + /// Opens a [HiveStore] with the specified box name. + /// + /// **Important:** Hive must be initialized before calling this method. + /// Call `Hive.init(path)` or `Hive.initFlutter()` first. + /// + /// The [fromJson] and [toJson] functions are required to serialize + /// and deserialize your data type [T]. + /// + /// Example: + /// ```dart + /// final store = await HiveStore.open( + /// boxName: 'users', + /// fromJson: User.fromJson, + /// toJson: (user) => user.toJson(), + /// ); + /// ``` + static Future> open({ + required String boxName, + required T Function(Map json) fromJson, + required Map Function(T value) toJson, + }) async { + final box = await Hive.openBox>(boxName); + return HiveStore( + box: box, + fromJson: fromJson, + toJson: toJson, + ); + } + + /// Whether this store has been closed. + /// + /// After closing, all operations will throw [StateError]. + bool get isClosed => _isClosed; + + /// Whether this store is open and ready for operations. + /// + /// Returns `false` if either: + /// - This store instance has been closed via [close] + /// - The underlying Hive box is not open (e.g., closed by another instance) + bool get isOpen => !_isClosed && _box.isOpen; + + /// Checks that the store is not closed and throws [StateError] if it is. + void _checkNotClosed() { + if (_isClosed) { + throw StateError('HiveStore has been closed'); + } + if (!_box.isOpen) { + throw StateError('HiveStore box is not open'); + } + } + + /// Tracks the start of an operation for safe close handling. + /// + /// Must be paired with [_endOperation] in a try/finally block. + void _beginOperation() { + _checkNotClosed(); + _pendingOperations++; + } + + /// Marks an operation as complete. + /// + /// If this was the last pending operation and close() is waiting, + /// signals that close can proceed. + void _endOperation() { + _pendingOperations--; + if (_pendingOperations == 0 && _pendingCompleter != null) { + _pendingCompleter!.complete(); + _pendingCompleter = null; + } + } + + /// Executes an async operation with proper tracking for safe close handling. + Future _withOperation(Future Function() operation) async { + _beginOperation(); + try { + return await operation(); + } finally { + _endOperation(); + } + } + + /// Closes the cache box. + /// + /// This method waits for all pending operations to complete before closing. + /// Call this method when you're done using the store to release resources. + /// After closing, all operations will throw [StateError]. + /// + /// This method is idempotent - calling it multiple times is safe. + Future close() async { + if (_isClosed) return; + _isClosed = true; + + // Wait for pending operations to complete + if (_pendingOperations > 0) { + _pendingCompleter = Completer(); + await _pendingCompleter!.future; + } + + await _box.close(); + } + + @override + Future write(String key, Stored entry) { + return _withOperation(() async { + final data = _serializeEntry(entry, tags: []); + await _box.put(key, data); + }); + } + + @override + Future writeWithTags( + String key, + Stored entry, + List tags, + ) { + return _withOperation(() async { + final data = _serializeEntry(entry, tags: tags); + await _box.put(key, data); + }); + } + + @override + Future?> read(String key) { + return _withOperation(() async { + final data = _box.get(key); + if (data == null) return null; + + try { + return _deserializeEntry(data); + } catch (e) { + // If deserialization fails, the data is corrupted or schema changed. + // Delete the corrupted entry and return null (cache miss). + await _box.delete(key); + return null; + } + }); + } + + @override + Future> getTags(String key) { + return _withOperation(() async { + final data = _box.get(key); + if (data == null) return []; + return _extractTags(data); + }); + } + + @override + Future delete(String key) { + return _withOperation(() async { + await _box.delete(key); + }); + } + + @override + Future clear() { + return _withOperation(() async { + await _box.clear(); + }); + } + + @override + Future deleteByTag(String tag) { + return _withOperation(() async { + final keysToDelete = await _getKeysByTagInternal(tag); + await _deleteKeys(keysToDelete); + }); + } + + @override + Future deleteByTags(List tags, {bool matchAll = false}) { + return _withOperation(() async { + if (tags.isEmpty) return; + + final tagSet = tags.toSet(); + final keysToDelete = []; + + // Copy keys to list to avoid concurrent modification issues + final keys = _box.keys.toList(); + for (final key in keys) { + final data = _box.get(key); + if (data == null) continue; + + final entryTags = _extractTags(data).toSet(); + final shouldDelete = matchAll + ? tagSet.every(entryTags.contains) + : tagSet.any(entryTags.contains); + + if (shouldDelete) { + keysToDelete.add(key as String); + } + } + + await _deleteKeys(keysToDelete); + }); + } + + @override + Future> getKeysByTag(String tag) { + return _withOperation(() => _getKeysByTagInternal(tag)); + } + + /// Internal implementation of getKeysByTag without operation tracking. + /// Used by deleteByTag to avoid nested operation tracking. + Future> _getKeysByTagInternal(String tag) async { + final keys = []; + // Copy keys to list to avoid concurrent modification issues + final boxKeys = _box.keys.toList(); + for (final key in boxKeys) { + final data = _box.get(key); + if (data == null) continue; + + final entryTags = _extractTags(data); + if (entryTags.contains(tag)) { + keys.add(key as String); + } + } + return keys; + } + + @override + Future deleteByPattern(String pattern) { + return _withOperation(() async { + final regex = _patternToRegex(pattern); + // Copy keys to list to avoid concurrent modification issues + final keysToDelete = _box.keys + .toList() + .where((key) => regex.hasMatch(key as String)) + .cast() + .toList(); + await _deleteKeys(keysToDelete); + }); + } + + @override + Future> getKeysByPattern(String pattern) { + return _withOperation(() async { + final regex = _patternToRegex(pattern); + // Copy keys to list to avoid concurrent modification issues + return _box.keys + .toList() + .where((key) => regex.hasMatch(key as String)) + .cast() + .toList(); + }); + } + + // ============================================================ + // Private Helpers + // ============================================================ + + /// Deletes multiple keys efficiently using batch delete. + Future _deleteKeys(List keys) async { + if (keys.isEmpty) return; + await _box.deleteAll(keys); + } + + /// Extracts tags from a serialized entry. + /// + /// Returns an empty list if: + /// - The 'tags' field is missing or null + /// - The 'tags' field contains invalid data + List _extractTags(Map data) { + final tags = data['tags']; + if (tags == null) return []; + if (tags is! List) return []; + + try { + return tags.cast().toList(); + } catch (e) { + // If cast fails (non-string elements), return empty list + return []; + } + } + + // ============================================================ + // Serialization Helpers + // ============================================================ + + /// Serializes a [Stored] entry with its tags for storage in Hive. + Map _serializeEntry( + Stored entry, { + required List tags, + }) { + return { + 'value': toJson(entry.value), + 'meta': _serializeMetadata(entry.meta), + 'tags': tags, + }; + } + + /// Deserializes a Hive map back into a [Stored] entry. + Stored _deserializeEntry(Map data) { + final valueJson = _castMap(data['value'] as Map); + final metaJson = _castMap(data['meta'] as Map); + + return Stored( + value: fromJson(valueJson), + meta: _deserializeMetadata(metaJson), + ); + } + + /// Serializes [Metadata] to a JSON-compatible map. + Map _serializeMetadata(Metadata meta) { + return { + 'version': meta.version, + 'storedAt': meta.storedAt.toIso8601String(), + if (meta.ttl != null) 'ttlMs': meta.ttl!.inMilliseconds, + if (meta.etag != null) 'etag': meta.etag, + if (meta.lastModified != null) + 'lastModified': meta.lastModified!.toIso8601String(), + }; + } + + /// Deserializes a JSON map back into [Metadata]. + Metadata _deserializeMetadata(Map json) { + return Metadata( + version: json['version'] as int, + storedAt: DateTime.parse(json['storedAt'] as String), + ttl: json['ttlMs'] != null + ? Duration(milliseconds: json['ttlMs'] as int) + : null, + etag: json['etag'] as String?, + lastModified: json['lastModified'] != null + ? DateTime.parse(json['lastModified'] as String) + : null, + ); + } + + /// Recursively casts a Hive map to a proper Dart Map. + /// + /// Hive stores maps as `Map`, but JSON serialization + /// typically expects `Map`. This method performs the + /// recursive conversion. + Map _castMap(Map map) { + return map.map((key, value) { + final castKey = key as String; + if (value is Map) { + return MapEntry(castKey, _castMap(value)); + } else if (value is List) { + return MapEntry(castKey, _castList(value)); + } + return MapEntry(castKey, value); + }); + } + + /// Recursively casts a Hive list to handle nested maps. + List _castList(List list) { + return list.map((item) { + if (item is Map) { + return _castMap(item); + } else if (item is List) { + return _castList(item); + } + return item; + }).toList(); + } + + /// Converts a glob pattern to a RegExp. + /// + /// Supported wildcards: + /// - `*` matches any sequence of characters (including empty) + /// - `?` matches exactly one character + /// + /// All other regex metacharacters are escaped. + RegExp _patternToRegex(String pattern) { + final escaped = pattern + .replaceAll(r'\', r'\\') + .replaceAll('.', r'\.') + .replaceAll('+', r'\+') + .replaceAll('(', r'\(') + .replaceAll(')', r'\)') + .replaceAll('[', r'\[') + .replaceAll(']', r'\]') + .replaceAll('{', r'\{') + .replaceAll('}', r'\}') + .replaceAll('^', r'\^') + .replaceAll(r'$', r'\$') + .replaceAll('|', r'\|') + .replaceAll('*', '.*') + .replaceAll('?', '.'); + return RegExp('^$escaped\$'); + } +} diff --git a/packages/syncache_hive/lib/syncache_hive.dart b/packages/syncache_hive/lib/syncache_hive.dart new file mode 100644 index 0000000..81dbab3 --- /dev/null +++ b/packages/syncache_hive/lib/syncache_hive.dart @@ -0,0 +1,50 @@ +/// Hive storage backend for Syncache. +/// +/// This library provides [HiveStore], a persistent storage implementation +/// using Hive that can be used with [Syncache]. +/// +/// ## Getting Started +/// +/// ```dart +/// import 'package:hive/hive.dart'; +/// import 'package:syncache/syncache.dart'; +/// import 'package:syncache_hive/syncache_hive.dart'; +/// +/// void main() async { +/// // Initialize Hive (required once) +/// Hive.init('path/to/hive'); +/// +/// // Open a HiveStore +/// final store = await HiveStore.open( +/// boxName: 'users', +/// fromJson: User.fromJson, +/// toJson: (user) => user.toJson(), +/// ); +/// +/// // Use with Syncache +/// final cache = Syncache(store: store); +/// +/// // Fetch and cache data +/// final user = await cache.get( +/// key: 'user:123', +/// fetch: (req) => api.getUser('123'), +/// ); +/// } +/// ``` +/// +/// ## Flutter Usage +/// +/// For Flutter apps, use the `hive_flutter` package: +/// +/// ```dart +/// import 'package:hive_flutter/hive_flutter.dart'; +/// +/// void main() async { +/// WidgetsFlutterBinding.ensureInitialized(); +/// await Hive.initFlutter(); +/// // ... rest of setup +/// } +/// ``` +library syncache_hive; + +export 'src/hive_store.dart' show HiveStore; diff --git a/packages/syncache_hive/pubspec.yaml b/packages/syncache_hive/pubspec.yaml new file mode 100644 index 0000000..9ad7cdf --- /dev/null +++ b/packages/syncache_hive/pubspec.yaml @@ -0,0 +1,25 @@ +name: syncache_hive +description: >- + Hive storage backend for Syncache. Provides cross-platform persistent caching + with full support for tag-based invalidation, pattern matching, and automatic + JSON serialization. +version: 0.1.0 +repository: https://github.com/Hamdal/syncache +homepage: https://github.com/Hamdal/syncache +issue_tracker: https://github.com/Hamdal/syncache/issues +topics: + - cache + - offline-first + - hive + - persistence + +environment: + sdk: ^3.0.0 + +dependencies: + syncache: ^0.1.0 + hive: ^2.2.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/syncache_hive/pubspec_overrides.yaml b/packages/syncache_hive/pubspec_overrides.yaml new file mode 100644 index 0000000..722b650 --- /dev/null +++ b/packages/syncache_hive/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: syncache +dependency_overrides: + syncache: + path: ../syncache diff --git a/packages/syncache_hive/test/hive_store_test.dart b/packages/syncache_hive/test/hive_store_test.dart new file mode 100644 index 0000000..ef7dc12 --- /dev/null +++ b/packages/syncache_hive/test/hive_store_test.dart @@ -0,0 +1,920 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:hive/hive.dart'; +import 'package:syncache/syncache.dart'; +import 'package:syncache_hive/syncache_hive.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('hive_test_'); + Hive.init(tempDir.path); + }); + + tearDownAll(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + group('HiveStore', () { + late HiveStore> store; + + setUp(() async { + store = await HiveStore.open>( + boxName: 'test_${DateTime.now().millisecondsSinceEpoch}', + fromJson: (json) => json, + toJson: (value) => value, + ); + }); + + tearDown(() async { + if (store.isOpen) { + await store.close(); + } + }); + + group('basic operations', () { + test('stores and retrieves values', () async { + final stored = Stored( + value: {'name': 'Alice', 'age': 30}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user:1', stored); + final result = await store.read('user:1'); + + expect(result, isNotNull); + expect(result!.value['name'], equals('Alice')); + expect(result.value['age'], equals(30)); + expect(result.meta.version, equals(1)); + }); + + test('returns null for non-existent key', () async { + final result = await store.read('nonexistent'); + + expect(result, isNull); + }); + + test('overwrites existing values', () async { + final stored1 = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + final stored2 = Stored( + value: {'name': 'Bob'}, + meta: Metadata(version: 2, storedAt: DateTime.now()), + ); + + await store.write('key', stored1); + await store.write('key', stored2); + final result = await store.read('key'); + + expect(result!.value['name'], equals('Bob')); + expect(result.meta.version, equals(2)); + }); + + test('deletes individual keys', () async { + final stored = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + await store.delete('key'); + final result = await store.read('key'); + + expect(result, isNull); + }); + + test('clears all entries', () async { + final stored1 = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + final stored2 = Stored( + value: {'name': 'Bob'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key1', stored1); + await store.write('key2', stored2); + await store.clear(); + + expect(await store.read('key1'), isNull); + expect(await store.read('key2'), isNull); + }); + }); + + group('edge cases', () { + test('handles empty string key', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('', stored); + final result = await store.read(''); + + expect(result, isNotNull); + expect(result!.value['name'], equals('test')); + }); + + test('handles special characters in keys', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + const specialKey = 'user:@#\$%^&*()_+-=[]{}|;:,.<>?'; + await store.write(specialKey, stored); + final result = await store.read(specialKey); + + expect(result, isNotNull); + expect(result!.value['name'], equals('test')); + }); + + test('handles unicode in keys and values', () async { + final stored = Stored( + value: {'name': '日本語テスト', 'emoji': '🎉🚀'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + const unicodeKey = 'ユーザー:1'; + await store.write(unicodeKey, stored); + final result = await store.read(unicodeKey); + + expect(result, isNotNull); + expect(result!.value['name'], equals('日本語テスト')); + expect(result.value['emoji'], equals('🎉🚀')); + }); + + test('handles keys at max length (255 chars)', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + // Hive has a max key length of 255 characters + final maxLengthKey = 'k' * 255; + await store.write(maxLengthKey, stored); + final result = await store.read(maxLengthKey); + + expect(result, isNotNull); + expect(result!.value['name'], equals('test')); + }); + + test('throws error for keys exceeding 255 chars', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + // Keys longer than 255 characters are rejected by Hive + final tooLongKey = 'k' * 256; + expect( + () => store.write(tooLongKey, stored), + throwsA(isA()), + ); + }); + + test('handles null values in nested structures', () async { + final stored = Stored( + value: { + 'name': 'test', + 'nullField': null, + 'nested': {'also': null}, + }, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result, isNotNull); + expect(result!.value['nullField'], isNull); + expect(result.value['nested']['also'], isNull); + }); + + test('handles empty map values', () async { + final stored = Stored( + value: {}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result, isNotNull); + expect(result!.value, isEmpty); + }); + + test('handles deeply nested structures', () async { + final stored = Stored( + value: { + 'l1': { + 'l2': { + 'l3': { + 'l4': { + 'l5': {'value': 'deep'}, + }, + }, + }, + }, + }, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result, isNotNull); + expect( + result!.value['l1']['l2']['l3']['l4']['l5']['value'], + equals('deep'), + ); + }); + }); + + group('metadata serialization', () { + test('preserves all metadata fields', () async { + final now = DateTime.now(); + const ttl = Duration(minutes: 5); + final lastModified = now.subtract(const Duration(hours: 1)); + + final stored = Stored( + value: {'data': 'test'}, + meta: Metadata( + version: 42, + storedAt: now, + ttl: ttl, + etag: '"abc123"', + lastModified: lastModified, + ), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result, isNotNull); + expect(result!.meta.version, equals(42)); + expect( + result.meta.storedAt.millisecondsSinceEpoch, + equals(now.millisecondsSinceEpoch), + ); + expect(result.meta.ttl, equals(ttl)); + expect(result.meta.etag, equals('"abc123"')); + expect( + result.meta.lastModified!.millisecondsSinceEpoch, + equals(lastModified.millisecondsSinceEpoch), + ); + }); + + test('handles null optional metadata fields', () async { + final stored = Stored( + value: {'data': 'test'}, + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result, isNotNull); + expect(result!.meta.ttl, isNull); + expect(result.meta.etag, isNull); + expect(result.meta.lastModified, isNull); + }); + }); + + group('nested data structures', () { + test('handles nested maps', () async { + final stored = Stored( + value: { + 'user': { + 'profile': { + 'name': 'Alice', + 'settings': {'theme': 'dark'}, + }, + }, + }, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result!.value['user']['profile']['name'], equals('Alice')); + expect( + result.value['user']['profile']['settings']['theme'], + equals('dark'), + ); + }); + + test('handles lists', () async { + final stored = Stored( + value: { + 'items': ['a', 'b', 'c'], + 'numbers': [1, 2, 3], + }, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result!.value['items'], equals(['a', 'b', 'c'])); + expect(result.value['numbers'], equals([1, 2, 3])); + }); + + test('handles lists of maps', () async { + final stored = Stored( + value: { + 'users': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + ], + }, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result!.value['users'][0]['name'], equals('Alice')); + expect(result.value['users'][1]['name'], equals('Bob')); + }); + }); + + group('tag operations', () { + test('writes and retrieves tags', () async { + final stored = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key', stored, ['user', 'admin']); + final tags = await store.getTags('key'); + + expect(tags, containsAll(['user', 'admin'])); + }); + + test('returns empty list for key without tags', () async { + final stored = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final tags = await store.getTags('key'); + + expect(tags, isEmpty); + }); + + test('returns empty list for non-existent key', () async { + final tags = await store.getTags('nonexistent'); + + expect(tags, isEmpty); + }); + + test('write without tags removes existing tags', () async { + final stored = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key', stored, ['tag1', 'tag2']); + await store.write('key', stored); + final tags = await store.getTags('key'); + + expect(tags, isEmpty); + }); + + test('writeWithTags replaces existing tags', () async { + final stored = Stored( + value: {'name': 'Alice'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key', stored, ['tag1', 'tag2']); + await store.writeWithTags('key', stored, ['tag3']); + final tags = await store.getTags('key'); + + expect(tags, equals(['tag3'])); + expect(tags, isNot(contains('tag1'))); + expect(tags, isNot(contains('tag2'))); + }); + + test('deletes entries by single tag', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['tag1']); + await store.writeWithTags('key2', stored, ['tag1', 'tag2']); + await store.writeWithTags('key3', stored, ['tag2']); + + await store.deleteByTag('tag1'); + + expect(await store.read('key1'), isNull); + expect(await store.read('key2'), isNull); + expect(await store.read('key3'), isNotNull); + }); + + test('deleteByTags with matchAll=false deletes any matching', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['a']); + await store.writeWithTags('key2', stored, ['b']); + await store.writeWithTags('key3', stored, ['c']); + + await store.deleteByTags(['a', 'b'], matchAll: false); + + expect(await store.read('key1'), isNull); + expect(await store.read('key2'), isNull); + expect(await store.read('key3'), isNotNull); + }); + + test('deleteByTags with matchAll=true deletes only all matching', + () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['a', 'b']); + await store.writeWithTags('key2', stored, ['a']); + await store.writeWithTags('key3', stored, ['b']); + await store.writeWithTags('key4', stored, ['a', 'b', 'c']); + + await store.deleteByTags(['a', 'b'], matchAll: true); + + expect(await store.read('key1'), isNull); + expect(await store.read('key2'), isNotNull); + expect(await store.read('key3'), isNotNull); + expect(await store.read('key4'), isNull); + }); + + test('deleteByTags with empty list does nothing', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['tag']); + await store.deleteByTags([]); + + expect(await store.read('key1'), isNotNull); + }); + + test('getKeysByTag returns matching keys', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['user']); + await store.writeWithTags('key2', stored, ['user', 'admin']); + await store.writeWithTags('key3', stored, ['admin']); + + final keys = await store.getKeysByTag('user'); + + expect(keys, containsAll(['key1', 'key2'])); + expect(keys, isNot(contains('key3'))); + }); + }); + + group('pattern operations', () { + test('deleteByPattern deletes matching keys', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user:1', stored); + await store.write('user:2', stored); + await store.write('product:1', stored); + + await store.deleteByPattern('user:*'); + + expect(await store.read('user:1'), isNull); + expect(await store.read('user:2'), isNull); + expect(await store.read('product:1'), isNotNull); + }); + + test('getKeysByPattern returns matching keys', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user:1', stored); + await store.write('user:2', stored); + await store.write('product:1', stored); + + final keys = await store.getKeysByPattern('user:*'); + + expect(keys, containsAll(['user:1', 'user:2'])); + expect(keys, isNot(contains('product:1'))); + }); + + test('pattern with single character wildcard', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user:1', stored); + await store.write('user:2', stored); + await store.write('user:10', stored); + + final keys = await store.getKeysByPattern('user:?'); + + expect(keys, containsAll(['user:1', 'user:2'])); + expect(keys, isNot(contains('user:10'))); + }); + + test('pattern matching escapes regex metacharacters', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user.name', stored); + await store.write('username', stored); + + // '.' in pattern should match literal '.', not any character + final keys = await store.getKeysByPattern('user.name'); + + expect(keys, contains('user.name')); + expect(keys, isNot(contains('username'))); + }); + }); + + group('delete also removes tags', () { + test('delete removes associated tags', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key', stored, ['tag1', 'tag2']); + await store.delete('key'); + + // Key should not appear in tag queries + expect(await store.getKeysByTag('tag1'), isEmpty); + expect(await store.getKeysByTag('tag2'), isEmpty); + }); + + test('clear removes all tags', () async { + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.writeWithTags('key1', stored, ['tag1']); + await store.writeWithTags('key2', stored, ['tag2']); + await store.clear(); + + expect(await store.getKeysByTag('tag1'), isEmpty); + expect(await store.getKeysByTag('tag2'), isEmpty); + }); + }); + + group('close and state management', () { + test('isOpen returns true for open store', () async { + expect(store.isOpen, isTrue); + expect(store.isClosed, isFalse); + }); + + test('isOpen returns false after close', () async { + await store.close(); + + expect(store.isOpen, isFalse); + expect(store.isClosed, isTrue); + }); + + test('close is idempotent', () async { + await store.close(); + await store.close(); // Should not throw + + expect(store.isClosed, isTrue); + }); + + test('operations throw StateError after close', () async { + await store.close(); + + expect( + () => store.read('key'), + throwsA(isA()), + ); + expect( + () => store.write( + 'key', + Stored( + value: {'test': 'value'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ), + ), + throwsA(isA()), + ); + expect( + () => store.delete('key'), + throwsA(isA()), + ); + expect( + () => store.clear(), + throwsA(isA()), + ); + expect( + () => store.getTags('key'), + throwsA(isA()), + ); + expect( + () => store.getKeysByTag('tag'), + throwsA(isA()), + ); + expect( + () => store.getKeysByPattern('*'), + throwsA(isA()), + ); + }); + }); + + group('error handling', () { + test('read returns null and deletes corrupted data', () async { + // Store the box name to use consistently + final boxName = 'corrupt_test_${DateTime.now().millisecondsSinceEpoch}'; + + // Write raw data directly to simulate corrupted/incompatible data + final box = await Hive.openBox>(boxName); + await box.put('key', { + 'value': {'data': 'test'}, + 'meta': { + 'version': 1, + 'storedAt': DateTime.now().toIso8601String(), + }, + 'tags': [], + }); + await box.close(); + + // Reopen with a fromJson that throws + final failStore = await HiveStore.open<_FailingType>( + boxName: boxName, + fromJson: (json) => throw const FormatException('Schema changed'), + toJson: (value) => {'data': value.data}, + ); + + // Read should return null (not throw) for corrupted data + final result = await failStore.read('key'); + expect(result, isNull); + + // Verify the corrupted entry was deleted + final checkResult = await failStore.read('key'); + expect(checkResult, isNull); + + await failStore.close(); + }); + + test('handles invalid tags data gracefully', () async { + final boxName = + 'invalid_tags_test_${DateTime.now().millisecondsSinceEpoch}'; + + // Write data with invalid tags (not a list of strings) + final box = await Hive.openBox>(boxName); + await box.put('key', { + 'value': {'name': 'test'}, + 'meta': { + 'version': 1, + 'storedAt': DateTime.now().toIso8601String(), + }, + 'tags': 'not-a-list', // Invalid: should be a list + }); + await box.close(); + + final testStore = await HiveStore.open>( + boxName: boxName, + fromJson: (json) => json, + toJson: (value) => value, + ); + + // getTags should return empty list for invalid tags + final tags = await testStore.getTags('key'); + expect(tags, isEmpty); + + await testStore.close(); + }); + + test('toJson throwing exception propagates error', () async { + final failStore = await HiveStore.open<_FailingType>( + boxName: 'tojson_fail_${DateTime.now().millisecondsSinceEpoch}', + fromJson: (json) => _FailingType(json['data'] as String), + toJson: (value) => throw const FormatException('toJson failed'), + ); + + final stored = Stored( + value: _FailingType('test'), + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + expect( + () => failStore.write('key', stored), + throwsA(isA()), + ); + + await failStore.close(); + }); + }); + + group('concurrent operations', () { + test('close waits for pending operations', () async { + final boxName = + 'concurrent_close_${DateTime.now().millisecondsSinceEpoch}'; + final testStore = await HiveStore.open>( + boxName: boxName, + fromJson: (json) => json, + toJson: (value) => value, + ); + + final stored = Stored( + value: {'name': 'test'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + // Start multiple write operations + final futures = >[]; + for (var i = 0; i < 10; i++) { + futures.add(testStore.write('key$i', stored)); + } + + // Close while operations are pending + final closeFuture = testStore.close(); + + // All operations should complete successfully + await Future.wait(futures); + await closeFuture; + + expect(testStore.isClosed, isTrue); + }); + + test('parallel reads and writes work correctly', () async { + final stored = Stored( + value: {'counter': 0}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + // Write initial value + await store.write('counter', stored); + + // Perform parallel reads + final readFutures = List.generate( + 10, + (_) => store.read('counter'), + ); + + final results = await Future.wait(readFutures); + + // All reads should succeed + for (final result in results) { + expect(result, isNotNull); + expect(result!.value['counter'], equals(0)); + } + }); + + test('operations after close fail immediately', () async { + await store.close(); + + // These should fail immediately, not hang + final stopwatch = Stopwatch()..start(); + + expect( + () => store.read('key'), + throwsA(isA()), + ); + + stopwatch.stop(); + // Should fail very quickly (< 100ms) + expect(stopwatch.elapsedMilliseconds, lessThan(100)); + }); + }); + }); + + group('HiveStore.open', () { + test('creates store with serialization functions', () async { + final store = await HiveStore.open>( + boxName: 'open_test_${DateTime.now().millisecondsSinceEpoch}', + fromJson: (json) => json, + toJson: (value) => value, + ); + + final stored = Stored( + value: {'test': 'value'}, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('key', stored); + final result = await store.read('key'); + + expect(result!.value['test'], equals('value')); + + await store.close(); + }); + }); + + group('HiveStore with custom types', () { + test('works with custom fromJson/toJson', () async { + final store = await HiveStore.open<_TestUser>( + boxName: 'custom_type_${DateTime.now().millisecondsSinceEpoch}', + fromJson: _TestUser.fromJson, + toJson: (user) => user.toJson(), + ); + + final user = _TestUser(name: 'Alice', email: 'alice@example.com'); + final stored = Stored( + value: user, + meta: Metadata(version: 1, storedAt: DateTime.now()), + ); + + await store.write('user:1', stored); + final result = await store.read('user:1'); + + expect(result!.value.name, equals('Alice')); + expect(result.value.email, equals('alice@example.com')); + + await store.close(); + }); + }); + + group('HiveStore persistence', () { + test('data persists across close and reopen', () async { + final boxName = 'persist_test_${DateTime.now().millisecondsSinceEpoch}'; + + // Write data + final store1 = await HiveStore.open>( + boxName: boxName, + fromJson: (json) => json, + toJson: (value) => value, + ); + + final stored = Stored( + value: {'name': 'Alice', 'score': 100}, + meta: Metadata( + version: 5, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ); + + await store1.writeWithTags('key', stored, ['user', 'active']); + await store1.close(); + + // Reopen and verify + final store2 = await HiveStore.open>( + boxName: boxName, + fromJson: (json) => json, + toJson: (value) => value, + ); + + final result = await store2.read('key'); + expect(result, isNotNull); + expect(result!.value['name'], equals('Alice')); + expect(result.value['score'], equals(100)); + expect(result.meta.version, equals(5)); + expect(result.meta.ttl, equals(const Duration(hours: 1))); + + final tags = await store2.getTags('key'); + expect(tags, containsAll(['user', 'active'])); + + await store2.close(); + }); + }); +} + +class _TestUser { + final String name; + final String email; + + _TestUser({required this.name, required this.email}); + + factory _TestUser.fromJson(Map json) { + return _TestUser( + name: json['name'] as String, + email: json['email'] as String, + ); + } + + Map toJson() => {'name': name, 'email': email}; +} + +class _FailingType { + final String data; + _FailingType(this.data); +}