hive_box_manager 1.3.0
hive_box_manager: ^1.3.0 copied to clipboard
Typed, fpdart-first boxes over hive_ce. Reads return an Option instead of null, writes are lazy Tasks, CRUD comes ready-made, and each shape of data gets its own box.
Typed, fpdart-first faΓ§ades over hive_ce boxes. No nulls,
no bare Futures, no hand-written CRUD. Pick the box that fits your data and get on with it.
β¬οΈ Upgrading from
0.0.x? 1.0 is a from-scratch rewrite with a new API, but your data almost always reads in place. MIGRATION.md walks you through it.
Hive is already fast. What it doesn't hand you is a surface that's pleasant to live with, and that's the gap this fills:
- π― Absence is a type, not
null. Reads return anOption(orTaskOption), so "not there" is a case the compiler makes you handle, never a crash waiting to happen. - π§° CRUD is already written. get, put, update, delete, clear and watch ship on every box, so you stop rewriting the same boilerplate for every type you store.
- π§© 6 boxes for 6 real shapes of data, each in an eager and a lazy flavour, so the box fits the problem instead of the other way round.
- π‘οΈ Safer than raw Hive. The write path rejects keys release-mode
hive_ceaccepts and then silently corrupts on, and ints it would hand back changed. The collection boxes close thedynamictrap that breaks a naiveBox<List<T>>orBox<Map<K, V>>on its first post-restart read. All of it pinned by tests against upstream, not assumed. - π At near-native Hive speed.* Reads add a few nanoseconds over raw
hive_ce, and anything that reaches disk a few percent.
* A few surfaces cost more, and what that costs prices every surface rather than quoting one flattering average.
Pure Dart, so it runs anywhere Hive does: Flutter apps, Dart servers, CLIs, and the web.
- π§ Which box?
- π Quickstart
- π§° The box families
- ποΈ Make it yours
- π Eager or lazy? (measured)
- π‘οΈ Safer than raw hive, at near-native speed
- πΊοΈ Roadmap
π§ Which box? #
Start here. Match what you're storing to a family, then grab its eager or lazy variant.
| You're storing | Reach for | Real-world fit |
|---|---|---|
| Many values, one key each | KeyedBox<T, K> |
users, todos, cache entries |
| Exactly one value | SingleValueBox<T> |
a session token, the theme, typed settings |
| A list of values per key | ListBox<T, K> |
tags per post, history per day |
| A set of values per key | SetBox<T, K> |
members per team, favourites per user |
| A map of values per key | MapBox<MK, MV, K> |
prices per store, settings per user |
| Values addressed by 2 parts | DualKeyBox<T, K1, K2> |
(user, day) events, (row, column) grids |
Every family has an eager and a Lazy... twin. Eager or lazy? picks
the axis with measured numbers. Reverse queries ("everything for this user") live on the
dual-key family.
MapBox and DualKeyBox both find a value by 2 parts. MapBox stores each key's map as one
record, read and written whole, which suits small maps you use together. Reach for DualKeyBox
when entries change one at a time, maps grow large, or you look things up by the second part.
π Quickstart #
1. Install #
dart pub add hive_box_manager hive_ce
2. Wire up Hive once #
Engine setup stays hive_ce's, exactly as its docs show:
import 'package:hive_ce/hive.dart';
Hive.init(appDataDirectory.path); // Hive.initFlutter() in Flutter apps
Hive.registerAdapter(TodoAdapter()); // usually generated by hive_ce_generator
None of hive_ce's symbols are re-exported. This package wraps boxes, and the engine stays yours.
A type from another package
One adapter over its toJson / fromJson is enough. Nested types need none of their own:
import 'dart:convert';
final class JsonAdapter<T> extends TypeAdapter<T> {
JsonAdapter(this.typeId, {required this.toJson, required this.fromJson});
@override
final int typeId;
final Map<String, Object?> Function(T value) toJson;
final T Function(Map<String, Object?> json) fromJson;
@override
T read(BinaryReader reader) =>
fromJson(jsonDecode(reader.readString()) as Map<String, Object?>);
@override
void write(BinaryWriter writer, T obj) => writer.writeString(jsonEncode(toJson(obj)));
}
Hive.registerAdapter(
JsonAdapter<Invoice>(1, toJson: (invoice) => invoice.toJson(), fromJson: Invoice.fromJson),
);
For a sealed hierarchy, register it once for the base type.
3. Open a box and use it #
import 'package:hive_box_manager/hive_box_manager.dart';
final todos = await KeyedBox.open<Todo, int>('todos').run();
await todos.put(1, Todo('write the README')).run();
final found = todos.get(1); // Option<Todo>: Some when present, None when absent
final orEmpty = todos.getOr(2, Todo.empty());
todos.watch().listen((event) => print('${event.key} -> ${event.value}'));
That's the whole loop. Reads come straight off the in-memory cache, so they're synchronous.
Writes are lazy Tasks you run() at the edge. And since you get the box from an open
factory, holding one means it's already open. There's no init step to forget.
π§° The box families #
Here's the good part: every family has the same surface, so you learn it once and it carries everywhere. The shape, in short:
- Absence is always
Option/TaskOption, nevernulland never a magic default. - Effects are always
Tasks. Nothing touches disk until you.run(), so pipelines compose first and fire once. watchis typed, andclose()/deleteFromDisk()are terminal (reacquire, don't reuse).
π The full method table (KeyedBox shown, the others mirror it)
KeyedBox<T, K> |
LazyKeyedBox<T, K> |
|
|---|---|---|
get(key) |
Option<T> |
TaskOption<T> |
getOr(key, fallback) |
T |
Task<T> |
values |
Iterable<T> |
Task<List<T>> |
keys / contains / length |
sync | sync, after first open |
put / putAll / delete / deleteAll / clear |
Task<Unit> |
Task<Unit> |
putAllBy(values, {keyOf}) |
Task<Unit> |
Task<Unit> |
update(key, fn, {ifAbsent}) |
Task<T> |
Task<T> |
watch({key}) |
Stream<TypedBoxEvent<T, K>> |
Stream<LazyTypedBoxEvent<T, K>> |
flush / compact / close / deleteFromDisk |
Task<Unit> |
Task<Unit> |
Watch is typed on both axes: an eager delete event still carries the value that was removed, and a
lazy event carries an Option<T> that's None on deletes, because a lazy box holds no value to
hand back.
putAllBy is sugar for the common case where each value already carries its own key, so
Map.fromIterables(values.map((v) => v.id), values) at the call site becomes
putAllBy(values, keyOf: (v) => v.id). It builds no intermediate map, so it comes out a little
cheaper than writing the map yourself (benchmark/python/overhead.py prints by how much).
DualKeyBox takes the same shape with 2 extractors (primaryOf: and secondaryOf:), and ListBox
has putAllGrouped, which collects a flat iterable into one stored list per key.
Keep putAll for everything else, and that is most cases: the key often isn't derivable from the
value at all (primitives, values with no id, keys that come from a grid or a legacy key set), and on
ListBox only putAll can store an empty list, since grouping never produces one.
π KeyedBox #
Many values, each under its own key. The everyday workhorse.
Eager and lazy, side by side
Eager keeps every value in memory once the box opens, so reads are synchronous:
final todos = await KeyedBox.open<Todo, int>('todos').run();
await todos.put(1, Todo('write the README')).run();
final found = todos.get(1); // Option<Todo>
Lazy constructs synchronously and opens itself on the first effect. Values come off disk per read, so reads are effects too:
final archive = LazyKeyedBox<Todo, String>('archive');
await archive.put('2025-w1', Todo('ship 1.0')).run(); // first effect auto-opens
final found = await archive.get('2025-w1').run(); // TaskOption<Todo>
await archive.ensureInitialised().run(); // optional explicit warm-up
π SingleValueBox #
One value, no keys. A token, a theme, a settings class with its own adapter.
Set, read, clear, watch
final session = await SingleValueBox.open<String>('session_token').run();
await session.set('abc123').run();
final token = session.get(); // Option<String>
await session.clear().run(); // the one unset; the next get() is None
session.watch().listen((token) => print(token)); // Some on set, None on clear
The value sits under a fixed internal slot, the same one 0.0.x single boxes used, so that data
reads in place. LazySingleValueBox is the same surface on the lazy axis. update mirrors
Map.update, and getOr(fallback) reads with a default.
ποΈ ListBox #
A list of values per key. Tags on a post, history for a day.
List ergonomics, and the sharp edge it files off
Hive reads a collection of a custom type back as List<dynamic>
(issue #150), so a plain
Box<List<Person>> opens fine and then throws on the first read after a restart. ListBox
restores the element type at the read boundary and stacks list helpers on top:
final tags = await ListBox.open<String, int>('post_tags').run();
await tags.put(1, ['flutter', 'dart']).run(); // any Iterable; stored as a private copy
await tags.add(1, 'hive').run(); // append; an absent key becomes [value]
await tags.remove(1, 'dart').run(); // first occurrence; an absent key is a no-op
final postTags = tags.getOr(1); // List<String>: unmodifiable view, empty when absent
final maybe = tags.get(1); // Option<List<String>>: None = absent, Some([]) = stored empty
Worth knowing:
- Lists you read out are unmodifiable views, and lists you put in are copied. Mutating your
original afterwards never leaks into the box.
add/addAll/removeare read-modify-writes, O(n) in the stored list. - Absent isn't the same as empty.
getkeeps them apart, whilegetOrfolds both to[]on purpose. - List semantics only: order preserved, duplicates allowed. For no duplicates, there's
SetBox. - Elements that compare by identity, Dart's default for a class, never match the fresh copies a
restart hands back. Give the box an
idOf, likeidOf: (member) => member.id, andremovematches by it, an edited copy included. Anything with its own==needs none, and a missing one trips a development assert at the firstremove. - Nesting: a list or set of
int,doubleorStringnests fine (lists ofboolandUint8Listtoo), since hive keeps those typed. So do lists, sets and maps ofdynamic. Anything else nested, aList<Person>say, trips a development assert when you open the box, because the cast only reaches the outer list. Model those as adapter-registered value types instead. Same goes forSetBox. LazyListBoxis the same surface on the lazy axis.
π§Ί SetBox #
A set of values per key, no duplicates. Members of a team, a user's favourites.
Telling elements apart, and add vs upsert
After a restart hive hands back fresh objects, so a plain set can't tell that 2 copies are the same
member. idOf tells it:
final teams = await SetBox.open<Member, String>(
'team_members',
idOf: (member) => member.id,
).run();
await teams.put('core', [Member(1, 'Ada'), Member(2, 'Grace')]).run(); // first one per id wins
await teams.add('core', Member(1, 'Ada L.')).run(); // id 1 is stored, so 'Ada' stays
await teams.upsert('core', Member(1, 'Ada L.')).run(); // replaces 'Ada' where she sits
await teams.remove('core', Member(2, 'Grace')).run(); // a fresh copy, matched by id
final core = teams.getOr('core'); // Set<Member>: unmodifiable view, empty when absent
Worth knowing:
idOfcan return anything that compares by value, a record included. Elements that compare by value are their own id, soSetBox.open<String, int>('favourites')needs none, and one that compares by identity without it trips an assert at the first write.- Sets you read keep their insertion order, even after a restart. They're plain sets though, so
core.contains(...)uses==, notidOf. Look an id up withanyinstead. - Writes are copied, reads come back typed after a restart, and absent isn't empty, all like
ListBox. LazySetBoxis the same surface on the lazy axis.
π MapBox #
A map of values per key. Prices per store, settings per user.
Merging, removing, and the keys a map can hold
Hive reads every map back as Map<dynamic, dynamic>, whatever it held, so a typed read after a
restart throws. MapBox restores both types at the read boundary:
final prices = await MapBox.open<String, double, int>('prices_by_store').run();
await prices.put(1, {'apple': 0.5, 'pear': 0.8}).run(); // stored as a private copy
await prices.addAll(1, {'pear': 0.9, 'plum': 1.2}).run(); // merges, so 'pear' is now 0.9
await prices.remove(1, 'apple').run(); // one entry out, the key stays
final storePrices = prices.getOr(1); // Map<String, double>: unmodifiable view, empty when absent
Worth knowing:
- The box key comes last, like every family:
MapBox<String, double, int>holds aMap<String, double>under eachint. - Inner keys have to compare by value, since a map has no
idOfto fall back on, so key by an id otherwise. One that compares by identity trips an assert at the first write. - An int hive can't store exactly (some past 2^53), as a key or a value, fails with an
ArgumentError, as in every box. addAllandremovefollowMap: the incoming value wins, and removing an absent key or entry writes nothing. Each one rewrites the whole map, and an emptied map keeps its key.- Maps keep their insertion order across a restart. Writes are copied, reads are unmodifiable,
absent isn't empty, and nested values follow
ListBox's rule, all like the other collections. LazyMapBoxis the same surface on the lazy axis.
π DualKeyBox #
2 natural dimensions to your data (user + day, row + column). Address it by both parts, query by either.
Lookups, reverse queries, and the codec choice
final events = await DualKeyBox.open<Event, int, int>('user_events').run();
await events.put(userId, dayIndex, event).run();
final one = events.get(userId, dayIndex); // Option<Event>: exact lookups stay O(1)
final usersWeek = events.queryByPrimary(userId); // List<Event>: everything for this user
final everyoneToday = events.queryBySecondary(dayIndex); // List<Event>: everyone, this day
Queries hand back a plain (possibly empty) list, and in 1.0 they're an honest O(K) scan over
the live key set: free until you call one, exact when you do, documented instead of hidden. Where
a whole key is needed (keys, putAll, watch events) it travels as a (primary, secondary)
record. LazyDualKeyBox matches the surface on the lazy axis (queries return Task<List<T>>).
Both parts round-trip through one DualKeyCodec. (int, int) defaults to the safe
StringCompositeDualCodec (full-range parts, negatives included, no ceilings). If both parts fit
in 16 bits and the numbers matter to you, opt into PackedIntDualCodec
(codec: const PackedIntDualCodec()). It packs both parts into a single u32 key and is
bit-identical to the old 0.0.x .bitShift scheme, so those boxes read in place. The 2
codecs trade off measurably. Codec choice has the head-to-head. Rolling your own
part types? Implement DualKeyCodec<K1, K2> and keep the encoding bijective, or reverse queries
will lie to you.
ποΈ Make it yours #
The boxes do their work through seams you can swap. Every open (and every lazy constructor)
takes these, all optional:
codec:aKeyCodec<K>(orDualKeyCodec<K1, K2>) for key types beyondint/String.observer:aBoxObserverto hear semantic events. Silent when you pass nothing.cipher:ahive_ceHiveCipherfor an encrypted box.- hive pluggables (
keyComparator,compactionStrategy,crashRecovery), passed straight through to the engine.
ποΈ A custom key type
int and String work out of the box. Anything else brings a codec:
final class DateKeyCodec implements KeyCodec<DateTime> {
const DateKeyCodec();
@override
Object encode(DateTime key) => key.toIso8601String();
@override
DateTime decode(Object rawKey) => DateTime.parse(rawKey as String);
}
final byDay = await KeyedBox.open<Todo, DateTime>(
'by_day',
codec: const DateKeyCodec(),
).run();
encode has to produce an int in 0..0xFFFFFFFF or a String of at most 255 UTF-8 bytes.
That's hive's raw key domain, and staying inside it is what keeps your data safe (more on that
just below).
π£ Observing what your code does
Pass an observer and you'll hear every semantic event. Pass none and dispatch costs nothing:
final todos = await KeyedBox.open<Todo, int>(
'todos',
observer: const PrintingBoxObserver(), // dart:developer log, DevTools-filterable
).run();
Extend BoxObserver and override only the events you care about. PrintingBoxObserver is the
ready-made sink. 2 notes on the engine side: hive_ce's own warnings stay on its global logging
channel (its logging options filter them), and the
Hive Inspector DevTools extension is handy for eyeballing box
contents while your observer reports what the code did to them.
π Eager or lazy? (measured) #
Folklore says "lazy opens instantly and saves all the memory." The maintainer benchmark (macOS Apple Silicon, AOT, setup here) disagrees:
- Opening costs about the same either way, and it scales with file size: ~65 ms at 100K int-keyed entries, ~3.3 s at 1M (lazy saves at most 10%, not an order of magnitude). Hive parses every frame to build the keystore on any open. What lazy skips is holding onto the values.
- Keys always live in RAM, eager or lazy: 22 to 64 MB at 100K entries, 210 to 340 MB at 1M. The spread is the key encoding, not the box kind: String keys run 50 to 80% heavier than int keys.
- Reads are where they split. An eager get is ~0.5 to 1 Β΅s from memory. A lazy get pays for a disk read at ~25 Β΅s.
Open cost tracks file size on both axes (the 2 lines sit right on top of each other), while reads are where they part ways:


So reach for eager on hot, value-heavy-read boxes that fit comfortably in RAM, and lazy on value-heavy boxes you read only now and then. Neither opens "instantly" at scale, and keys are a RAM cost you pay regardless.
π₯ When a record won't decode #
An eager box decodes every value while it opens, so one record its adapter can't read stops the
whole box from opening. hive_ce won't skip it (hive_ce#318).
A lazy box decodes on read instead, and values throws an UndecodableValueException naming the
key.
Keep fields you add to a model after its first release nullable, since older records won't have
them. If a box already won't open, open it lazily, delete each key an UndecodableValueException
names, then compact(). The eager open works again after that.
π‘οΈ Safer than raw hive, at near-native speed #
Release-mode hive_ce takes an out-of-range int key or an oversized String key without
complaint, then corrupts quietly: keys wrap into other slots, and an oversized String key can make
the whole box file unreadable on its next open. This package rejects exactly those keys with an
ArgumentError at the call site, before anything reaches disk.
Ints get the same care. hive keeps them as 64-bit floats, so past 2^53 some come back from a
restart as their neighbour, and 2 map keys as one entry. Every box rejects a write holding an int a
float can't hold exactly, wherever it sits: a value, a list or set element, a map key or value, or
deep inside decoded JSON. Most writes fail at the call, and update and the helpers built on it
fail when they run. Nothing reaches disk either way.
A 64-bit id inside your own type goes through your adapter, which this package can't see. Store it as a
Stringor aBigIntthere. To cache a JSON response holding one, store its text.
Times are left to hive. A DateTime or Duration keeps its microseconds for dates from 1691-04-06
to 2248-09-26 and durations shorter than about 278 years. Past that, hive rounds them, so
9999-12-31T23:59:59.999999Z comes back as the start of year 10000, and 2 stamps a microsecond
apart can become 1 set element or map key. Whole milliseconds always come back exact. hive chose
that to keep its file format, so this package doesn't overrule it.
β‘ What that costs #
There is no single number, and any package that gives you one is quoting the surface that flattered it. Wrapper cost depends on how expensive the thing being wrapped is, so it splits by surface:
| Surface | Wrapper cost | Read as |
|---|---|---|
KeyedBox / SingleValueBox, memory reads (get, contains, values) |
1 to 22 ns per op | free |
| Any effect that reaches disk (put, delete, lazy get) | 300 to 760 ns per op, 2 to 7% | free, disk dominates |
ListBox reads |
~265 to 330 ns per get, plus ~10 to 13 ns per custom-type element |
2.8x to 3.1x on one-element lists, 0.9x to 1.8x on thousand-element ones |
Batch writes (putAll, deleteAll) |
1 to 35 ns per entry | free |
| Writes that hold ints | nothing measurable for an int, ~5% for a 20-record JSON response | free, except walking JSON. Types without ints skip it |
| Open time, keystore RAM, file size | no measurable difference | identical |
DualKeyBox eager get |
1.02x (+3 to +21 ns per op) | free |
DualKeyBox putAll |
+45 to +85 ns per entry (int parts), +230 to +310 ns (String parts) | 1.09x to 1.36x by batch size, see below |
Why percentages are the wrong unit for most of this
Some operations are cheap enough that one extra call frame is a big percentage while costing
nothing that matters. A same-slot SingleValueBox.get and a walk over eager values each pay tens
of percent for a few nanoseconds (benchmark/python/overhead.py prints both).
Quoting those percentages would be a lie by arithmetic, so the table gives nanoseconds wherever the
underlying op is that cheap, and percentages only where the denominator is a real disk round-trip.
benchmark/python/overhead.py enforces the same split, and refuses to stand behind a run whose
median and minimum disagree.
Precision, honestly: repeated passes reproduce the bands above and the ordering, not 2 significant figures on any single lane. Treat each figure as an order of magnitude.
DualKeyBox is free to read and costs per entry to batch-write. Eager get is 1.02x, lazy gets
1.01x, reverse queries 0.93 to 1.03x, open and memory are unchanged, and exact lookups stay O(1).
putAll is the one exception: it rebuilds its record-keyed batch into a raw-keyed map, at +45 to
+85 ns per entry for int parts and +230 to +310 ns for String parts. That reads as anywhere
from 1.09x to 1.36x, but the ratio only moves because raw's own per-entry cost grows with the batch
while the wrapper's stays flat, so take the nanoseconds and ignore the multiple.
Where a composite key can cost you, measured
This surface takes (K1, K2) records, and there is one way to encode them that costs ~24x the
others. 8 variants of the same two-part encode, each changing exactly one thing from the one
above it:

Records are free. Generics are free. An extra adapter call frame is free. What costs ~350 ns is a
record type built from a class's own type parameters, sitting in a checked parameter position:
the subtype check resolves through the instantiated type-argument vector and misses AOT's inline
subtype-test cache. Widening that parameter to Object and casting inside does not help, because it
is the same check written out, and making the record type concrete hides the cost while leaving the
trap armed for whoever re-parameterises it next.
So DualKeyBox encodes both parts as separate scalar arguments, which is the free side of that
line. benchmark/key_shape_bench.dart prices every variant and its reader asserts each
relationship, so this stays measured rather than remembered.
ListBox prices differently, because raw hive has no list-valued box to compare against. Its
baseline is the code you would hand-write, and there are 2 of those. Against the version with a
.cast<T>() at the read boundary, ListBox costs the per-get term above. Per element it
depends on what hive hands back. A List<String> comes back typed and goes out without a cast, so
it costs less per element than the hand-roll. A custom type's elements are each checked once at the
read, which is what lets a wrong type fail there and name its key. Against the version without a
cast, your hand-roll is faster and broken: a stored List<Person> reads back as List<dynamic>
after a restart and the cast throws. Memory matches a correct hand-roll on every lane, reads
included, so the read view really is copy-free.
β‘ Codec choice #
2 dual-key codecs ship, and they trade off like this:
| Operation | packed int | String composite |
|---|---|---|
| eager get, 100K entries | 48 ms | 104 ms |
| box open (eager), 100K | 68 ms | 143 ms |
| putAll, 100K | 83 ms | 174 ms |
| reverse query, 100K | 4.8 ms | 11.1 ms |
| keystore RSS after open, 100K | 35 MB | 64 MB |
| box file size, 100K | 1.9 MB | 2.7 MB |
| lazy get / single put | codec-indifferent (disk dominates) |
Medians measured through DualKeyBox itself (macOS Apple Silicon, AOT, constant 1-byte values to
isolate key cost), not through a hand-rolled stand-in. Web is unmeasured. Its ordering is assumed to
follow the VM. StringCompositeDualCodec is the safe default. Reach for PackedIntDualCodec when
these wins matter and both parts fit in 16 bits.
The table rows are the 100K slice of these curves, and the gap widens as boxes grow:


πΊοΈ Roadmap #
What's on the table for 1.x (and what will never land)
Additive candidates for 1.x, in no committed order:
- Indexed reverse queries for very large (>100K) datasets: an inverted-index strategy on
LazyDualKeyBox, swapping out the O(K) scan behind the same query methods. - IsolatedHive support behind the box-acquisition seam (
hive_ceitself recommends it for multi-isolate apps), plusBoxCollectionwrapping if there's demand. - A migration helper API (1.0 documents recipes in MIGRATION.md for now).
- Nested collections beyond the shapes hive keeps typed, on the value-codec seam.
- A consumer fakes package (in-memory faΓ§ades for app tests) and Flutter companions (a
ValueListenableadapter), as separate packages so the core stays pure Dart.
Deliberately never wrapped, because they fight the typed, codec-addressed key model:
auto-increment add / addAll, index-based getAt / putAt / deleteAt / keyAt, toMap,
and valuesBetween. Reach for a raw hive_ce box where those genuinely fit.
