unified_local_data 2.0.0
unified_local_data: ^2.0.0 copied to clipboard
Engine-agnostic local storage for Flutter. One API over Hive, Isar or your own engine, with typed objects, queries, reactive streams and transactions.
๐ฆ unified_local_data
A universal, engine-agnostic local database abstraction layer for Flutter.
Swap between Hive, Isar, or any future engine โ without changing a single line of your app logic.
Table of Contents #
- Overview
- Why unified_local_data?
- Architecture
- Supported Engines
- Installation
- Quick Start
- Upgrading to 2.0
- Core API Reference
- Native Engine Access
- Swapping Engines
- Extending with a New Engine
- Error Handling
- Stream Extensions
- Design Patterns
- Folder Structure
- FAQ
- License
Overview #
unified_local_data is a production-grade Dart package that provides a single, unified interface for all local database operations in Flutter apps. It decouples your application logic from any specific database engine using the Strategy Pattern, enabling you to switch between Hive CE and Isar Community โ or plug in any future engine โ with zero refactoring.
This package contains the API and the Hive engine, and runs on every Flutter platform, the web included. The Isar engine is a separate package, unified_local_data_isar, so apps that don't use Isar don't ship its native library.
The package offers two data access paths:
- Generic Path โ Engine-agnostic
put/get/putObjectAPI using JSON serialization. Fully portable across all engines. - Native Path โ Direct access to engine-native features like Hive
TypeAdapterand Isar@collectionwith full index, binary speed, and native query support.
Why unified_local_data? #
| Pain Point | Solution |
|---|---|
| Locked into one database across all projects | Engine-agnostic interface โ pick per project |
| Migrating databases requires rewriting data layer | Swap via a single config change |
| Inconsistent API between Hive and Isar | One unified API for all engines |
| No reactive support out of the box | Built-in Stream watching for keys and collections |
| Complex objects need boilerplate per engine | Generic DataModelAdapter<T> handles serialization universally |
| Querying differs wildly between engines | Unified QueryBuilder<T> with filter, sort, limit, offset |
| Adding a new engine means rewriting everything | Implement UnifiedDataSource for it; code written against the interface doesn't change |
| Can't use Hive TypeAdapters or Isar @collection natively | NativeCollectionAccessor<T> gives full engine-native power |
Architecture #
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ APPLICATION LAYER โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Generic Path (portable) โ Native Path (per engine) โ
โ โ โ
โ UnifiedDataSource โ NativeCollectionAccessor<T> โ
โ put / get / putObject โ Hive: TypeAdapter โ
โ stored as JSON โ Isar: @collection โ
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UnifiedDataSourceFactory.create() โ
โ (singleton ยท picks the engine ยท init) โ
โโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HiveDataSource โ โ IsarDataSource โ
โ (this package) โ โ (unified_local_data_isar) โ
โ implements โ โ implements โ
โ UnifiedDataSource โ โ UnifiedDataSource โ
โ โ โ โ
โ Generic: Box<dynamic> โ โ Generic: IsarKvEntry โ
โ Native: Box<T> โ โ Native: IsarCollection<T> โ
โ + TypeAdapter โ โ + @collection โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Supported Engines #
| Engine | Use Case | Comes from | Platforms |
|---|---|---|---|
| Hive CE (hive_ce) | Fast key-value storage, lightweight apps, caching | This package | All, including web |
| Isar Community (isar_community) | Complex queries, relational data, large datasets | unified_local_data_isar | All but web |
| Your Engine | Anything else, by implementing UnifiedDataSource |
Your code | Yours to choose |
Installation #
flutter pub add unified_local_data
Or add it to your pubspec.yaml yourself:
dependencies:
unified_local_data: ^2.0.0
For the Isar engine, add unified_local_data_isar instead. It depends on this package and re-exports it:
flutter pub add unified_local_data_isar
Each package depends on its engine, so the generic path needs nothing else. If you use the native path and import hive_ce or isar_community in your own code, add that package to your pubspec.yaml too, along with its generator for build_runner.
Quick Start #
Initialize with Hive #
import 'package:unified_local_data/unified_local_data.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
final db = await UnifiedDataSourceFactory.create(
UnifiedDataSourceConfig.hive(
directory: dir.path,
preloadBoxes: ['settings', 'cache'],
),
);
await db.put<String>('settings', 'language', 'en');
final language = await db.get<String>('settings', 'language');
}
| Option | Description |
|---|---|
directory |
Where Hive keeps its box files. Ignored on the web. |
preloadBoxes |
Collections opened during init(). |
collections |
Collections that drop() always covers, without opening them at init(). See below. |
encryption |
An AES key for every box. |
The Hive engine records the name of every collection it opens in a reserved box, _uld_collections, so that drop() can reach collections the app hasn't opened since it started. Don't use that name for a collection.
Versions before 1.2.0 kept no such record. If your app has shipped with an earlier version, pass its collection names as collections, so drop() also covers boxes written before the update:
UnifiedDataSourceConfig.hive(
directory: dir.path,
collections: ['auth', 'app_config', 'map_memory'],
);
Initialize with Isar #
With unified_local_data_isar added:
import 'package:unified_local_data_isar/unified_local_data_isar.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
final db = await UnifiedDataSourceFactory.create(
IsarDataSourceConfig(
directory: dir.path,
maxSizeMiB: 512,
inspector: true,
),
);
await db.put<int>('analytics', 'launch_count', 42);
final count = await db.get<int>('analytics', 'launch_count');
}
That's it. The rest of your code uses the same
UnifiedDataSourceAPI regardless of which engine is active.
Upgrading to 2.0 #
2.0 moves the Isar engine into its own package. Hive-only apps need no code changes; bump the constraint to ^2.0.0.
| 1.x | 2.0 |
|---|---|
UnifiedDataSourceConfig.isar(...) |
IsarDataSourceConfig(...), from unified_local_data_isar |
IsarDataSource, IsarDataSourceConfig imported from unified_local_data |
Import package:unified_local_data_isar/unified_local_data_isar.dart |
schemas: List<dynamic> |
schemas: List<CollectionSchema<dynamic>>, optional |
DatabaseEngine and UnifiedDataSourceConfig.engine |
Removed. Check the config's type instead. |
| A config subclass had no way into the factory | Override createDataSource(); see Extending |
Isar's data is untouched: the Isar engine stores it exactly as 1.x did.
Core API Reference #
All methods are available on the UnifiedDataSource interface returned by UnifiedDataSourceFactory.create().
Primitives #
Store and retrieve simple Dart types: String, int, double, bool, and lists of them.
Lists and maps read back differently on each engine. Isar round-trips values through JSON, so a list comes back as
List<dynamic>. Hive keeps typed lists, but a map comes back asMap<dynamic, dynamic>once the app restarts. To read the same way on both:
- Read lists as
List<dynamic>and cast them:(await db.get<List<dynamic>>('c', 'k'))?.cast<String>().- Store maps with
putObject/getObject(see Complex Objects), notput/get.get<Map<String, dynamic>>throws aDataSourceReadExceptionon Hive after a restart.
await db.put<String>('settings', 'theme', 'dark');
final theme = await db.get<String>('settings', 'theme');
await db.delete('settings', 'theme');
await db.deleteAll('settings', ['theme', 'locale']);
await db.clear('settings');
await db.drop(collection: 'settings');
await db.drop();
| Method | Signature | Description |
|---|---|---|
put<T> |
Future<void> put<T>(String collection, String key, T value) |
Stores a value by key |
get<T> |
Future<T?> get<T>(String collection, String key) |
Retrieves a value by key |
delete |
Future<void> delete(String collection, String key) |
Removes a single key |
deleteAll |
Future<void> deleteAll(String collection, List<String> keys) |
Removes several keys in one call |
clear |
Future<void> clear(String collection) |
Removes all entries in a collection |
drop |
Future<void> drop({String? collection}) |
Deletes a collection from disk, or the whole database when collection is omitted |
dispose |
Future<void> dispose() |
Closes the database and releases all resources |
clearvsdrop:clearempties a collection but keeps it available for reuse.dropremoves the collection itself. Callingdrop()with no argument wipes the entire database, including collections the app hasn't opened since it started.
- Hive, native:
dropdeletes the box files.- Hive, web:
dropempties each box but keeps its IndexedDB database. Deleting the database would wait until every other connection to it closed, and a connection held by another tab of the app never does. Another open tab still reads the old values from its in-memory copy until it reloads.- Isar:
dropdeletes every entry of the collection, or clears the whole database.
Complex Objects (Generic Path) #
Use DataModelAdapter<T> to serialize/deserialize any custom Dart object. This path stores objects as JSON strings, making it fully portable across all engines.
Tip: If your model class already implements
toJson/fromJson, you can optionally have it implement theDataModel<T>interface as a marker contract:abstract interface class DataModel<T> { JsonMap toJson(); T fromJson(JsonMap json); }The adapter (
DataModelAdapter<T>) is what the API actually uses;DataModel<T>is an optional structural contract you can adopt for consistency.
Step 1 โ Define your model:
class User {
final String id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};
}
Step 2 โ Create an adapter:
final userAdapter = DataModelAdapter<User>(
fromJson: User.fromJson,
toJson: (user) => user.toJson(),
);
Step 3 โ Store and retrieve:
final user = User(id: '1', name: 'Yasin', email: 'yasin@example.com');
await db.putObject<User>('users', 'user_1', user, userAdapter);
final retrieved = await db.getObject<User>('users', 'user_1', userAdapter);
final allUsers = await db.getAllObjects<User>('users', userAdapter);
| Method | Signature | Description |
|---|---|---|
putObject<T> |
Future<void> putObject<T>(String collection, String key, T value, DataModelAdapter<T> adapter) |
Stores a complex object |
getObject<T> |
Future<T?> getObject<T>(String collection, String key, DataModelAdapter<T> adapter) |
Retrieves a complex object |
getAllObjects<T> |
Future<List<T>> getAllObjects<T>(String collection, DataModelAdapter<T> adapter) |
Retrieves all objects in a collection |
Batch Operations #
Write multiple entries in a single call for better performance.
await db.putAll<String>('settings', {
'theme': 'dark',
'language': 'en',
'font_size': '16',
});
await db.putAllObjects<User>('users', {
'user_1': User(id: '1', name: 'Yasin', email: 'yasin@example.com'),
'user_2': User(id: '2', name: 'Ali', email: 'ali@example.com'),
}, userAdapter);
| Method | Signature | Description |
|---|---|---|
putAll<T> |
Future<void> putAll<T>(String collection, Map<String, T> entries) |
Batch stores primitives |
putAllObjects<T> |
Future<void> putAllObjects<T>(String collection, Map<String, T> entries, DataModelAdapter<T> adapter) |
Batch stores complex objects |
Collection Utilities #
final keys = await db.getAllKeys('users');
final exists = await db.containsKey('users', 'user_1');
final total = await db.count('users');
final allValues = await db.getAll<String>('settings');
| Method | Signature | Description |
|---|---|---|
getAll<T> |
Future<List<T>> getAll<T>(String collection) |
All raw values in a collection |
getAllKeys |
Future<List<String>> getAllKeys(String collection) |
All keys in a collection |
containsKey |
Future<bool> containsKey(String collection, String key) |
Checks key existence |
count |
Future<int> count(String collection) |
Entry count in a collection |
Reactive Streams #
Subscribe to real-time changes on keys, collections, or object collections.
db.watch<String>('settings', 'theme').listen((value) {
print('Theme changed to: $value');
});
db.watchObject<User>('users', 'user_1', userAdapter).listen((user) {
print('User changed: ${user?.name}');
});
db.watchAll<String>('settings').listen((values) {
print('Settings updated: $values');
});
db.watchAllObjects<User>('users', userAdapter).listen((users) {
print('Users updated: ${users.length}');
});
| Method | Signature | Description |
|---|---|---|
watch<T> |
Stream<T?> watch<T>(String collection, String key) |
Watches a single key for changes |
watchObject<T> |
Stream<T?> watchObject<T>(String collection, String key, DataModelAdapter<T> adapter) |
Watches a single key as a typed object; emits null when the key is absent |
watchAll<T> |
Stream<List<T>> watchAll<T>(String collection) |
Watches entire collection |
watchAllObjects<T> |
Stream<List<T>> watchAllObjects<T>(String collection, DataModelAdapter<T> adapter) |
Watches collection as typed objects |
All streams emit the current value immediately upon subscription, then emit on every subsequent change.
Query Builder #
Build fluent, chainable queries with filtering, sorting, pagination, and reactive watching.
final activeUsers = await db
.query<User>('users', userAdapter)
.where((user) => user.name.startsWith('Y'))
.sortBy((a, b) => a.name.compareTo(b.name))
.limit(20)
.offset(0)
.findAll();
final firstMatch = await db
.query<User>('users', userAdapter)
.where((user) => user.email.contains('@example.com'))
.findFirst();
final total = await db
.query<User>('users', userAdapter)
.where((user) => user.id.isNotEmpty)
.count();
db.query<User>('users', userAdapter)
.where((user) => user.name.contains('Yasin'))
.sortBy((a, b) => a.name.compareTo(b.name))
.limit(10)
.watch()
.listen((users) {
print('Matching users: ${users.length}');
});
| Method | Signature | Description |
|---|---|---|
query<T> |
QueryBuilder<T> query<T>(String collection, DataModelAdapter<T> adapter) |
Creates a query builder |
.where() |
QueryBuilder<T> where(bool Function(T) clause) |
Adds a filter predicate |
.sortBy() |
QueryBuilder<T> sortBy(int Function(T, T) comparator) |
Sets sort order |
.limit() |
QueryBuilder<T> limit(int count) |
Limits result count |
.offset() |
QueryBuilder<T> offset(int start) |
Skips first N results |
.findAll() |
Future<List<T>> findAll() |
Executes and returns all matches |
.findFirst() |
Future<T?> findFirst() |
Executes and returns first match |
.count() |
Future<int> count() |
Returns number of matches |
.deleteAll() |
Future<bool> deleteAll() |
Deletes all matched objects (native engines only; throws on generic in-memory path) |
.watch() |
Stream<List<T>> watch() |
Watches query results reactively |
Transactions #
Wrap multiple operations in a transaction for atomicity.
await db.transaction(() async {
await db.put<String>('settings', 'theme', 'dark');
await db.put<String>('settings', 'language', 'en');
await db.putObject<User>('users', 'user_1', newUser, userAdapter);
});
Hive: Operations are executed sequentially (Hive has no native transaction support). If one fails, the writes before it stay.
Isar: The action runs in one native
writeTxn. Every data source call inside it, and every write throughnative<T>(), joins that transaction, and so does a nestedtransaction(). If the action throws, all of its writes are rolled back. Don't callrawInstance.writeTxn()inside it: Isar doesn't nest transactions.On both engines,
transactionpasses on whatever the action throws, unchanged.
Native Engine Access #
Understanding the Two Paths #
The package provides two distinct ways to store complex objects:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ DECISION FLOWCHART โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Need to swap engines freely across projects? โ
โ โโ YES โ Use Generic Path โ
โ โ db.putObject / db.getObject โ
โ โ Uses DataModelAdapter<T> + JSON serialization โ
โ โ โ
Works on ALL engines without changes โ
โ โ โ ๏ธ No engine-native indexes or binary speed โ
โ โ โ
โ โโ NO โ Engine is fixed for this project? โ
โ โโ YES โ Use Native Path โ
โ โ db.native<T>('collection') โ
โ โ โ
Full Hive TypeAdapter support โ
โ โ โ
Full Isar @collection + indexes โ
โ โ โ
Binary speed, zero JSON overhead โ
โ โ โ ๏ธ Models are engine-specific โ
โ โ โ
โ โโ Need Isar's full .filter().sortBy() generated DSL? โ
โ โโ Cast to IsarDataSource, use rawInstance โ
โ โ
Complete Isar native query power โ
โ โ Completely tied to Isar โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
| Path | Portability | Performance | Query Power | Best For |
|---|---|---|---|---|
Generic (putObject) |
โ Any engine | Good | In-memory filter | Settings, cache, portable data |
Native (db.native<T>()) |
โ ๏ธ Engine-specific models | Best | In-memory filter | Domain entities with TypeAdapter / @collection |
Raw (rawInstance) |
โ Single engine only | Best | Full native DSL | Complex Isar queries with composite indexes |
NativeCollectionAccessor API #
The NativeCollectionAccessor<T> interface provides a unified API for working with engine-native models:
| Method | Signature | Description |
|---|---|---|
put |
Future<void> put(T item) |
Stores a single native object |
putAll |
Future<void> putAll(List<T> items) |
Batch stores native objects |
getById |
Future<T?> getById(dynamic id) |
Retrieves by ID |
getAll |
Future<List<T>> getAll() |
Retrieves all objects |
deleteById |
Future<void> deleteById(dynamic id) |
Deletes by ID |
deleteAll |
Future<void> deleteAll() |
Clears all objects |
count |
Future<int> count() |
Returns object count |
watchAll |
Stream<List<T>> watchAll() |
Watches all objects reactively |
watchById |
Stream<T?> watchById(dynamic id) |
Watches a single object reactively |
query |
QueryBuilder<T> query() |
Creates a chainable query builder |
Hive โ Native TypeAdapter Flow #
Use Hive's native TypeAdapter for maximum binary serialization speed.
Step 1 โ Define your Hive model:
import 'package:hive_ce/hive_ce.dart';
part 'user_entity.g.dart';
@HiveType(typeId: 1)
class UserEntity extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String email;
@HiveField(3)
final DateTime createdAt;
UserEntity({
required this.id,
required this.name,
required this.email,
required this.createdAt,
});
}
Step 2 โ Run code generation:
dart run build_runner build --delete-conflicting-outputs
Step 3 โ Register adapter, open typed box, and use:
import 'package:hive_ce/hive_ce.dart';
import 'package:unified_local_data/unified_local_data.dart';
import 'package:path_provider/path_provider.dart';
import 'models/user_entity.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationDocumentsDirectory();
Hive.registerAdapter(UserEntityAdapter());
final db = await UnifiedDataSourceFactory.create(
UnifiedDataSourceConfig.hive(directory: dir.path),
);
final hiveDb = db as HiveDataSource;
await hiveDb.openTypedBox<UserEntity>('users');
final users = db.native<UserEntity>('users');
await users.put(UserEntity(
id: '1',
name: 'Yasin',
email: 'yasin@example.com',
createdAt: DateTime.now(),
));
await users.putAll([
UserEntity(
id: '2',
name: 'Ali',
email: 'ali@example.com',
createdAt: DateTime.now(),
),
]);
// NOTE: HiveNativeAccessor.putAll() uses box.addAll() โ items are appended
// with auto-incremented integer keys. For keyed upsert, call put() individually.
final allUsers = await users.getAll();
final yasin = await users.getById('1');
users.watchAll().listen((userList) {
print('Users changed: ${userList.length}');
});
final filtered = await users
.query()
.where((u) => u.name.startsWith('Y'))
.sortBy((a, b) => a.createdAt.compareTo(b.createdAt))
.limit(10)
.findAll();
}
Isar โ Native @collection Flow #
With the Isar engine, db.native<T>() works on your own @collection classes, and IsarDataSource.rawInstance gives you Isar's full query language. Both are covered in the unified_local_data_isar README.
When to Use Which Path #
| Scenario | Recommended Path | Why |
|---|---|---|
| App settings, feature flags, simple cache | Generic put/get |
Simple key-value, no need for native features |
| User profile, auth tokens | Generic putObject |
Portable across engines, easy to serialize |
| Large product catalog with search | Native db.native<T>() |
Benefits from Isar indexes and binary speed |
| Chat messages with complex queries | Native + Raw | Need Isar composite indexes and full-text search |
| Offline-first app with sync | Native db.native<T>() |
Need full TypeAdapter/collection for performance |
| Shared package used across many projects | Generic path only | Maximum portability, no engine lock-in |
| Prototype / MVP | Generic path | Ship fast, optimize later |
Swapping Engines #
The entire point of this package โ swap your database with one config change:
// Using Hive
final db = await UnifiedDataSourceFactory.create(
UnifiedDataSourceConfig.hive(directory: dir.path),
);
// Switch to Isar, from unified_local_data_isar โ no other code changes
// needed (for Generic Path)
final db = await UnifiedDataSourceFactory.create(
IsarDataSourceConfig(directory: dir.path),
);
Swap at runtime:
// reset() calls dispose() on the current instance, then clears it
await UnifiedDataSourceFactory.reset();
await UnifiedDataSourceFactory.create(
IsarDataSourceConfig(directory: dir.path),
);
final db = UnifiedDataSourceFactory.instance;
You can also dispose a specific instance directly:
await db.dispose(); // closes the DB and frees all resources
Note:
create()automatically disposes any existing instance before creating the new one, so you don't need to callreset()first when simply switching configs.Code using the Generic Path (
put/get/putObject) swaps seamlessly. Code using the Native Path (db.native<T>()) requires engine-specific models and may need adjustment when switching engines.
Extending with a New Engine #
UnifiedDataSource is an ordinary interface, so another engine (Drift, Sqflite, โฆ) can sit behind it, in your app or in a package of its own. The Isar engine, unified_local_data_isar, is built exactly this way.
An engine has two parts:
- A
UnifiedDataSourceimplementation. - A
UnifiedDataSourceConfigsubclass whosecreateDataSource()builds it.UnifiedDataSourceFactory.createcallscreateDataSource(), theninit().
Import package:unified_local_data/engine.dart. Besides the whole API, it exports helpers that make an engine behave like the built-in ones:
guardRead,guardWriteandguardStreamreport the engine's errors asDataSourceReadExceptionandDataSourceWriteException, keeping the original ascause.InMemoryQueryBuilderanswersquery()for engines without a query language.
import 'package:unified_local_data/engine.dart';
final class DriftDataSourceConfig extends UnifiedDataSourceConfig {
const DriftDataSourceConfig({required super.directory, required this.name});
final String name;
@override
UnifiedDataSource createDataSource() => DriftDataSource(config: this);
}
final class DriftDataSource implements UnifiedDataSource {
DriftDataSource({required this.config});
final DriftDataSourceConfig config;
@override
Future<void> init() async { /* open the Drift database */ }
@override
Future<T?> get<T>(String collection, String key) => guardRead(
'Failed to get key "$key" from "$collection"',
() async { /* read through Drift */ },
);
@override
QueryBuilder<T> query<T>(String collection, DataModelAdapter<T> adapter) =>
InMemoryQueryBuilder<T>(
fetcher: () => getAllObjects(collection, adapter),
watcher: () => watchAllObjects(collection, adapter),
);
// ... implement all other methods
}
final db = await UnifiedDataSourceFactory.create(
DriftDataSourceConfig(directory: dir.path, name: 'app'),
);
Code written against UnifiedDataSource works with it unchanged.
Error Handling #
The package uses a sealed exception hierarchy for exhaustive, compile-time checked error handling:
try {
final value = await db.get<String>('settings', 'theme');
} on DataSourceNotInitializedException {
// Database not initialized โ call init() first
} on DataSourceReadException catch (e) {
print('Read error: ${e.message}, cause: ${e.cause}');
} on DataSourceWriteException catch (e) {
print('Write error: ${e.message}, cause: ${e.cause}');
} on DataNotFoundException catch (e) {
// Requested data does not exist
} on UnsupportedOperationException catch (e) {
// Operation not supported by current engine
} on LocalDataSourceException catch (e) {
// Catch-all for any data source error
}
| Exception | When |
|---|---|
DataSourceNotInitializedException |
init() was not called before usage |
DataSourceReadException |
A read operation fails (corruption, cast error, etc.) |
DataSourceWriteException |
A write operation fails (disk full, permission, etc.) |
DataNotFoundException |
Explicitly requested data does not exist |
UnsupportedOperationException |
Operation not available on current engine |
Every operation on the data source reports its failures this way, on both engines:
- Reads throw
DataSourceReadException; writes, deletes,clearanddropthrowDataSourceWriteException. The engine's own error is incause, and the original stack trace is kept. - The message names the key and the collection, for example
Failed to get key "camera" from "map_memory". - Watch streams (
watch,watchObject,watchAll,watchAllObjects) emit their errors asDataSourceReadExceptioninstead of throwing them. transactionpasses on whatever its action throws, unchanged.- The native path (
NativeCollectionAccessor,openTypedBox's box, Isar'srawInstance) is the engine itself and throws the engine's errors.
Stream Extensions #
The package includes utility extensions for working with reactive streams:
import 'package:unified_local_data/unified_local_data.dart';
db.watchAll<String>('settings')
.debounceTime(Duration(milliseconds: 300))
.listen((values) {
print('Debounced update: $values');
});
db.watch<String>('settings', 'theme')
.distinctUntilChanged()
.listen((value) {
print('Theme actually changed to: $value');
});
| Extension | Description |
|---|---|
.debounceTime(Duration) |
Waits for a pause in emissions before forwarding |
.distinctUntilChanged() |
Filters out consecutive duplicate values |
Design Patterns #
| Pattern | Where | Purpose |
|---|---|---|
| Strategy | UnifiedDataSource interface + engine implementations |
Swap database engine without changing consumer code |
| Factory | UnifiedDataSourceFactory + UnifiedDataSourceConfig.createDataSource() |
Each config builds its own engine; the factory opens it and keeps it |
| Adapter | DataModelAdapter<T> + NativeCollectionAccessor<T> |
Bridge between domain models and storage |
| Repository | Each engine implementation | Encapsulates all data access behind a single contract |
| Singleton | UnifiedDataSourceFactory._instance |
Single database instance across the app |
| Builder | QueryBuilder<T> chain |
Fluent query construction with deferred execution |
| Sealed Class | Exceptions hierarchy | Exhaustive compile-time checked exception types |
| Abstract Class | UnifiedDataSourceConfig |
Shared config base with engine-specific subclasses |
Folder Structure #
unified_local_data/ # The GitHub repository
โโโ packages/
โโโ unified_local_data/ # This package
โ โโโ pubspec.yaml
โ โโโ example/lib/main.dart # Minimal Hive example
โ โโโ lib/
โ โ โโโ unified_local_data.dart # The public API
โ โ โโโ engine.dart # The API plus helpers for engine authors
โ โ โโโ src/
โ โ โโโ core/
โ โ โ โโโ unified_data_source.dart # The UnifiedDataSource interface
โ โ โ โโโ unified_data_source_config.dart # Base config; builds the data source
โ โ โ โโโ unified_data_source_factory.dart # UnifiedDataSourceFactory
โ โ โ โโโ data_model.dart # DataModel<T> + DataModelAdapter<T>
โ โ โ โโโ native_collection_accessor.dart # Native engine access interface
โ โ โ โโโ query_builder.dart # QueryBuilder<T> + in-memory builder
โ โ โ โโโ exceptions.dart # Sealed exception hierarchy
โ โ โ โโโ guard.dart # Wraps engine errors in those exceptions
โ โ โ โโโ typedefs.dart # Shared type aliases
โ โ โโโ engines/hive/
โ โ โ โโโ hive_data_source.dart # Hive implementation
โ โ โ โโโ hive_native_accessor.dart # Hive TypeAdapter accessor
โ โ โ โโโ hive_config.dart # Hive-specific configuration
โ โ โโโ extensions/
โ โ โโโ stream_extensions.dart # debounceTime, distinctUntilChanged
โ โโโ test/ # Hive on the VM and in the browser
โโโ unified_local_data_isar/ # The Isar engine, published separately
โโโ example/lib/main.dart
โโโ lib/
โ โโโ unified_local_data_isar.dart # Re-exports unified_local_data
โ โโโ src/ # IsarDataSource, IsarDataSourceConfig, ...
โโโ test/ # Isar on the VM
FAQ #
Can I use this in a pure Dart project (no Flutter)?
No. The package depends on the Flutter SDK, so it needs a Flutter project. Its tests do run on the Dart VM through flutter test, with a temporary directory in place of path_provider.
Do I need to register Hive TypeAdapters manually?
Generic Path: No. Objects are serialized to JSON via DataModelAdapter<T>, avoiding the need for Hive TypeAdapter registration.
Native Path: Yes. Register your TypeAdapters before opening typed boxes:
Hive.registerAdapter(UserEntityAdapter());
final hiveDb = db as HiveDataSource;
await hiveDb.openTypedBox<UserEntity>('users');
Do I need to define Isar @collection schemas?
Generic Path: No. The package uses an internal IsarKvEntry collection for key-value storage.
Native Path: Yes. Define your @collection classes, run build_runner, and pass schemas at initialization:
IsarDataSourceConfig(
directory: dir.path,
schemas: [ProductEntitySchema, OrderEntitySchema],
)
Can I mix Generic and Native paths in the same project?
Absolutely. This is a common and recommended pattern:
// Generic: app settings (portable)
await db.put<String>('settings', 'theme', 'dark');
// Native: domain entities (engine-optimized)
final products = db.native<ProductEntity>('products');
await products.put(myProduct);
Can I access the raw Hive/Isar instance?
Yes. Both engine implementations expose their raw instance for advanced use cases:
// Isar
final isarDb = db as IsarDataSource;
final isar = isarDb.rawInstance;
// Hive โ access typed boxes directly
final hiveDb = db as HiveDataSource;
final box = await hiveDb.openTypedBox<UserEntity>('users');
This is intentionally not part of the UnifiedDataSource interface to maintain abstraction.
Is this thread-safe?
Yes. Both Hive and Isar handle concurrent access internally. The package uses async/await throughout and avoids shared mutable state beyond the singleton instance.
What happens if I call methods before init()?
A DataSourceNotInitializedException is thrown immediately. Always call UnifiedDataSourceFactory.create() before accessing the database.
How do I encrypt my data?
With Hive, pass an encryption config:
UnifiedDataSourceConfig.hive(
directory: dir.path,
encryption: HiveEncryptionConfig(key: mySecureKey),
);
Isar Community does not support encryption natively. You can encrypt at the application layer using the DataModelAdapter<T> serialization step.
When should I call dispose()?
Call dispose() when your app is shutting down or when you want to explicitly release database resources. Both engine implementations close all open boxes/databases and clear internal caches.
// Dispose directly on the instance
await db.dispose();
// Or via the factory (also clears the singleton)
await UnifiedDataSourceFactory.reset();
UnifiedDataSourceFactory.create() automatically calls dispose() on any existing instance before opening the new one, so manual disposal is only needed on app exit or in tests.
How do I handle schema migrations?
Generic Path: Since objects are stored as JSON, schema evolution is handled by your model's fromJson factory. Add default values for new fields and handle missing keys gracefully.
Native Path (Hive): Hive TypeAdapters handle field additions via @HiveField indices. New fields with higher indices are backward-compatible.
Native Path (Isar): Isar handles schema migrations automatically for non-breaking changes (adding fields, indexes). For breaking changes, consult the Isar migration guide.
What if my engine doesn't support a specific operation?
The engine implementation should throw UnsupportedOperationException for operations it cannot fulfill. The sealed exception hierarchy allows you to handle this gracefully:
try {
await db.transaction(() async { /* ... */ });
} on UnsupportedOperationException {
// Fallback: execute operations without transaction
}
License #
MIT License
Copyright (c) 2026
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.
Built with โค๏ธ for the Flutter community
One interface. Any engine. Zero rewrites.
Generic when you need portability. Native when you need power.