omni_kv 0.1.0
omni_kv: ^0.1.0 copied to clipboard
Strongly-typed, storage-agnostic key-value framework for Dart and Flutter.
OmniKV #
OmniKV is a strongly-typed, storage-agnostic key-value framework for Dart and Flutter.
It is designed to completely eliminate magic strings, implicit type casting, and runtime parsing errors when dealing with app settings, feature flags, auth tokens, and local caches.
Why OmniKV? #
- ๐ก๏ธ Absolute Type Safety: Keys are bound to their Dart types (
KvKey<int>). No moreprefs.getInt('magic_string') as int. - ๐ Null-Safety by Design: Missing values are handled at compile-time. You either provide a
defaultValueor mark the key.required()(which throws a descriptive exception if the value is missing). - ๐งฉ Capability-Driven: Adapters declare what they support (
Readable,Writable,Watchable). If a backend doesn't support watching, calling.watch()fails at compile-time. - ๐งน Safe Scoped Clearing: Codecs own a
prefix. Callinggateway.clear()only deletes keys that belong to your app, leaving third-party package keys untouched. - ๐งช Mock-Free Testing: Comes with
MemoryKvAdapterbuilt-in. Test your business logic instantly without mocking platform channels.
Ecosystem & Installation #
OmniKV is split into a pure-Dart core and multiple backend adapters.
1. Add the core package:
dart pub add omni_kv
2. Add an adapter and its required storage SDK (Optional): If you want to use a specific backend (like SharedPreferences), you must install both the OmniKV adapter and the official storage package.
flutter pub add omni_kv_shared_preferences shared_preferences
# OR
flutter pub add omni_kv_secure_storage flutter_secure_storage
# OR
flutter pub add omni_kv_hive_ce hive_ce
Quick Start #
1. Define your Keys (The Recommended Way) #
Centralize your keys in one file. OmniKV uses a 2-constructor design to optimize for the most common use-cases while strictly enforcing null-safety:
- The Unnamed Constructor: Used for 90% of keys. It enforces a
defaultValue(which can benullif the type is nullable). - The
.required()Constructor: Used for edge cases where missing data is a critical error. It throws aKvMissingValueExceptionif the value is absent.
We highly recommend adding a Namespace Extension to your KvGateway. This eliminates
boilerplate and allows for beautiful, autocomplete-friendly syntax like gateway.app(.theme).
import 'package:omni_kv/omni_kv.dart';
final class AppKey<T> extends KvKey<T> {
const AppKey(super.name, {required super.defaultValue, super.converter});
const AppKey.required(super.name, {super.converter}) : super.required();
// A key with a default value
static const launchCount = AppKey<int>('app.launch_count', defaultValue: 0);
// An optional key (defaultValue is null)
static const userName = AppKey<String?>('app.user_name', defaultValue: null);
// A required key (throws if missing)
static const token = AppKey<String>.required('app.token');
}
// THE NAMESPACE EXTENSION
// This allows you to write: kv.app(.launchCount)
extension AppKvGatewayNamespace<A extends KvCapability> on KvGateway<A> {
KvEntry<T, A> app<T>(AppKey<T> key) => entry(key);
}
2. Initialize the Gateway #
Wrap your chosen adapter in a KvGateway.
// Using the built-in Memory adapter for pure Dart / Testing
final kv = KvGateway(
MemoryKvAdapter(
codec: const MemoryKvCodec(prefix: 'my_app.'),
),
);
3. Read, Write, and Remove #
Use the fluent namespace API to interact with your keys.
// Write
await
kv.app
(.launchCount).write(1);
// Read (returns 1)
final count = await kv.app(.launchCount).read();
// Exists
final hasToken = await kv.app(.token).exists();
// Remove
await kv.app(.token).remove();
4. Batch Operations #
OmniKV supports asynchronous, ordered execution of multiple operations.
await
kv.batch((tx) async {
await tx.app(.launchCount).write(3);
await tx.app(.userName).write('Alice');
await tx.app(.token).remove();
});
Advanced Usage #
Capabilities #
OmniKV's API is fully modular. The methods available on KvGateway depend entirely on what
interfaces the underlying adapter implements. This ensures you never attempt an unsupported
operation at runtime.
Available capabilities include:
ReadableKvCapability: Enables.read()and.exists().WritableKvCapability: Enables.write().RemovableKvCapability: Enables.remove().ClearableKvCapability: Enables.clear().WatchableKvCapability: Enables.watch()(Streams value changes).BatchableKvCapability: Enables.batch().
Converters #
Converters translate complex Dart types into primitive types safe for databases.
Supported Built-in Converters:
BigIntConverter:.toString()CollectionConverter:ListConverter,SetConverterDateTimeConverter:.toIsoString(),.toMilliseconds()DurationConverter:.toMilliseconds()EnumConverter:.toName(),.toIndex()JsonConverter:.toObject(),.toList()ModelConverter:.toMap(),.toJsonString()RecordConverter:.toMap(),.toJsonString()UriConverter:.toString()InlineConverter: (Takes quickonEncodeandonDecodecallbacks)
Custom Converters
If the built-in converters don't fit your needs, you can create a custom converter by implementing
KvConverter<T, S> (where T is your Dart type, and S is the storage type).
import 'package:omni_kv/omni_kv.dart';
final class ColorHexConverter implements KvConverter<Color?, int?> {
const ColorHexConverter();
@override
int? encode(Color? value) => value?.value;
@override
Color? decode(Object? value) {
if (value == null) return null;
return Color(value as int);
}
}
// Usage:
static const themeColor = AppKey<Color>(
'app.color',
defaultValue: Color(0xFF000000),
converter: ColorHexConverter(),
);
Creating a Custom Adapter #
OmniKV is storage-agnostic. You can easily build your own adapter for Isar, Sqflite, Drift, or
a custom remote API.
To create an adapter, you need two things:
- A Codec (
KvStorageCodec) to handle key-prefixing and raw type validation. - The Adapter (
KvAdapter) that talks to your database.
1. The Codec #
import 'package:omni_kv/omni_kv.dart';
final class CustomKvCodec implements KvStorageCodec {
const CustomKvCodec({this.prefix});
final String? prefix;
@override
String storageKey(String logicalKey) =>
prefix == null ? logicalKey : '$prefix$logicalKey';
@override
String logicalKey(Object? storageKey) {
final key = storageKey as String;
return prefix != null && key.startsWith(prefix!)
? key.substring(prefix!.length)
: key;
}
@override
bool ownsKey(Object? storageKey) =>
prefix == null || (storageKey is String && storageKey.startsWith(prefix!));
@override
Object? encode(Object? value) => value;
@override
Object? decode(Object? value) => value;
}
2. The Adapter #
final class CustomKvAdapter
with SequentialKvBatchCapability // Provides basic batching automatically
implements
KvAdapter,
ReadableKvCapability,
WritableKvCapability,
RemovableKvCapability {
CustomKvAdapter(this.database, {this.codec = const CustomKvCodec()});
final MyDatabase database;
@override
final KvStorageCodec codec;
@override
Future<Object?> read(String key) async {
final rawValue = await database.get(codec.storageKey(key));
return codec.decode(rawValue);
}
@override
Future<bool> contains(String key) async {
return database.has(codec.storageKey(key));
}
@override
Future<void> write(String key, Object? value) async {
if (value == null) {
await remove(key);
return;
}
await database.put(codec.storageKey(key), codec.encode(value));
}
@override
Future<void> remove(String key) async {
await database.delete(codec.storageKey(key));
}
}