flutter_local_db 3.0.6
flutter_local_db: ^3.0.6 copied to clipboard
Local database for Flutter on Rust and LMDB 1.0: tables from your own models, Diesel-style queries, joins, transactions and streams, plus simple key-value records.
flutter_local_db #
An embedded database for Flutter that stores the models your app already
has. One entry point, LocalDB: key-value records as simple as ever, and
tables with a query API modelled on Diesel — indexes, a
query planner, joins, aggregates, transactions with savepoints and reactive
queries. The engine is
offline_first_core,
written in Rust on LMDB 1.0.2; the query language is
db_dsl.
await LocalDB.init();
final t = User.table;
await t.insert([ana, luis]);
final adults = await t
.filter(t.city.eq('Lima').and(t.age.gt(30)))
.order(t.age.desc())
.limit(20); // Result<List<User>, DbError>
- Your models, as they are. A model carries its table in one line
(
static final table = DbTable<User>(...)) next to thefromJsonandtoJsonit already has — the class your app decodes from its API. No table classes, no code generation, no macros. - Nothing to register.
LocalDB.init()takes no list of tables: each table defines itself the first time it is used. - Typed fields, written for you.
t.cityandt.agecome from an extension that the db_dsl_lints analyzer plugin writes from the model with one quick fix, and checks whenever the model changes. - Queries run when awaited, on the database of the table or on the
transaction around them: there is no
execute(db)to write. - Indexes: single, composite and unique, maintained in the same transaction as their rows and built over existing rows when added.
- Queries:
filter,orFilter,order,limit,offset,count,sum,avg,min,max, projections,groupBywithhaving, inner and left joins, andexplainto see the chosen index. - Transactions: commit on
Ok, rollback onErr, savepoints, read snapshots and atomic batches. - Reactive:
watchemits a query's rows again after every committed write to its table. Resulteverywhere:Okwith the value orErrwith a typedDbError; nothing throws for an outcome.- Platforms: Android, iOS, macOS, Linux and Windows. The library ships prebuilt and a build hook bundles it: nothing to configure. The web has the key-value API.
- Off the UI isolate: every call runs on a database isolate.
Install #
dependencies:
flutter_local_db: ^3.0.0
Flutter 3.38 or later (build hooks). There is no platform setup: the build hook adds the native library of each target (Android ABIs, iOS device and simulator, macOS, Linux and Windows on x64 and arm64) to the app.
Open #
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final opened = await LocalDB.init();
runApp(opened.when(ok: (_) => const App(), err: (error) => StartupError(error)));
}
LocalDB.init opens the database of the app (in a flutter_local_db
directory of the documents directory on Android and iOS, the support
directory elsewhere; or at path:). Records and tables share one file.
Tables are not listed: a table defines itself the first time one of its queries is awaited — a new table is created, and indexes added to or removed from an existing one are built or dropped — and its queries run on this database from then on. Concurrent first uses define it once.
A table used for the first time inside a transaction answers
Err(DbErrorCode.tableNotReady): defining needs the database to itself,
which the transaction holds. Use the table once before (a screen usually
watches it first), or define it up front with
LocalDB.init(tables: [users, posts]), which also builds its indexes at
start-up.
Records: key-value #
No tables needed, on every platform including the web:
await LocalDB.init();
await LocalDB.Post('settings', {'theme': 'dark'});
final settings = await LocalDB.GetById('settings'); // Ok(null) when missing
await LocalDB.Put('settings', {'theme': 'light'});
await LocalDB.Delete('settings');
final all = await LocalDB.GetAll();
- A key is 1 to 511 bytes of UTF-8, without a NUL character, on every
platform; anything else answers a
validationerror before touching the database. (The native lookups take the key as a C string: a NUL would cut it short and reach another record.) GetAllnever leaves a record out. A stored record that cannot be read makes it answer anErrnaming that record, instead of a shorter list that looks complete; delete that key to recover.exportAllbehaves the same.
Tables from your models #
final class User {
const User({this.id, required this.name, required this.city, required this.age});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int?,
name: json['name'] as String,
city: json['city'] as String,
age: json['age'] as int,
);
// The table of this model: its name, its key and how to read a row.
static final table = DbTable<User>(
'users',
key: 'id',
fromJson: User.fromJson,
autoIncrement: true, // optional: a null id is generated on insert
indexes: [
Index(['city', 'age']), // optional
Index.unique(['name']),
],
);
final int? id;
final String name;
final String city;
final int age;
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'city': city, 'age': age};
}
- The table is a
staticof the model: a query has no instance to inherit from, and Dart has no static inheritance, so this is the closest to "the class is the table" — without a global. fromJsonis the one thing a table needs: Dart cannot call a constructor through a type, so it is passed once.toJsonis implicit: a row is stored as your model writes itself (itstoJson(), the methodjsonEncodeuses), with nested models, dates and enums converted the same way. PasstoJson: (user) => user.toMap()only when your model names it otherwise.
Typed fields #
Queries name fields as getters of the table, with the type the model stores:
/// The fields of `User` for queries, read from its `toJson`.
extension UserFields on DbTable<User> {
/// The stored `id`.
Field<int> get id => field('id');
/// The stored `name`.
Field<String> get name => field('name');
/// The stored `city`.
Field<String> get city => field('city');
/// The stored `age`.
Field<int> get age => field('age');
}
final t = User.table;
await t.filter(t.city.eq('Lima')); // t.cty or t.age.eq('x') do not compile
You do not type this extension. Enable the analyzer plugin in
analysis_options.yaml (not in pubspec.yaml) and restart the analysis
server:
plugins:
db_dsl_lints: ^0.1.0
Then DbTable<User>(...) is underlined — 'User' stores fields its table
cannot query: id, name, city, age — and its quick fix, Write the query
fields from the model (Ctrl+. / ⌘. in VS Code, Alt+Enter in Android
Studio), writes the extension after the model. When the model gains,
renames or retypes a field, the table or the stale getter is flagged again
and one quick fix rewrites it. It is plain code in your file: no
build_runner, no .g.dart.
In CI, run dart analyze (it works in Flutter projects): flutter analyze
does not report plugin diagnostics yet (Flutter 3.47). See
db_dsl_lints for every check.
A field is a path in the stored rows, compared as a Dart type;
field<V>(path) is the building block of those getters and works on its
own too:
final zip = users.field<String>('address.zip'); // nested
final status = orders.field<Status>('status'); // enums, by name
final placed = orders.field<DateTime>('placed'); // ISO 8601, as toJson writes it
final total = orders.field<Money>( // any type, as your model stores it
'total',
encode: (money) => money.cents,
decode: (stored) => Money(stored as int),
);
ISO 8601 dates sort correctly when they are all UTC with the same
precision: store toUtc() dates, or numbers, to sort or range over them.
Query #
The examples below write city, age, name… for the typed fields of the
table (users.city, users.age, users.name).
| Diesel (Rust) | flutter_local_db |
|---|---|
users.filter(city.eq("Lima")) |
users.filter(city.eq('Lima')) |
.and(...) / .or(...) / not(...) |
.and(...) / .or(...) / .not() |
.or_filter(...) |
.orFilter(...) |
ne, gt, ge, lt, le |
ne, gt, ge, lt, le |
eq_any, ne_all |
eqAny, neAll |
between, not_between |
between, notBetween |
is_null, is_not_null |
isNull(), isNotNull() |
like, ilike |
like, ilike (text fields) |
.order(age.desc()), .then_order_by(...) |
.order(age.desc()), .thenOrderBy(...) |
.limit(n), .offset(n) |
.limit(n), .offset(n) |
.load(conn), .first(conn) |
await query, .first() |
.count().get_result(conn) |
.count() |
users.find(id).first(conn) |
await users.find(id) |
sum, avg, min, max |
.sum(field), .avg(field), .min(field), .max(field) |
group_by, having |
.groupBy([city]).count('people').having(...) |
inner_join, left_join |
.innerJoin(posts, on: id, equals: authorId), .leftJoin(...) |
final page = await users
.filter(city.eqAny(['Lima', 'Bogotá']))
.orFilter(age.lt(18))
.order(name.asc())
.limit(50)
.offset(100);
final ana = await users.find(1); // Ok(null) when missing
final average = await users.filter(city.eq('Lima')).avg(age);
Many-to-many relations go through a bridge table: Relation<User, Skill>
with attach, detach, targetsOf and sourcesOf, which read only the
neighbours of a row (see db_dsl's
README).
Comparisons follow SQL: a value only compares with values of its kind, and
NULL or a missing field matches no comparison (use isNull()). Without
order, the order of the rows is unspecified.
Write #
await users.insert([ana, luis]); // the number of rows
final saved = await users.insert([nuevo]).getResults(); // rows with generated ids
await users.insert([ana]).onConflictDoNothing();
await users.insert([ana]).onConflictReplace();
await users.update().filter(city.eq('Lima')).set(age, 40);
await posts.update().filter(postId.eq('p1')).increment(views, 1);
await users.delete().filter(age.lt(18));
// Fails, changing nothing, unless exactly one row matches:
await users.delete().filter(id.eq(7)).expectAffectedRows(1);
An insert of an existing primary key answers Err with duplicateKey, and
a duplicate value of a unique index with uniqueViolation, unless
onConflictDoNothing or onConflictReplace says otherwise. Every statement
is atomic.
Transactions #
final result = await LocalDB.transaction<int>((tx) async {
// Awaited inside, so they run on the transaction.
final inserted = await users.insert([ana]);
if (inserted case Err(:final error)) {
return Err(error); // everything rolls back
}
// A savepoint: when it answers Err, only its own writes are undone.
await tx.savepoint((_) => users.update().filter(id.eq(2)).set(age, 30));
return users.all().count(); // sees the transaction's writes
}); // committed here when the body answered Ok
final snapshot = await LocalDB.readTransaction((tx) async {
// One consistent view, while other writes go on.
return users.all().count();
});
await LocalDB.atomicBatch([
users.insert([ana]),
users.update().filter(id.eq(2)).set(age, 30),
]); // all or nothing, in one round trip
- A write that fails inside a transaction makes it rollback-only: the commit
answers
transactionAbortedeven when the error was handled. Put recoverable work in asavepoint. - Other writes of the database wait until the transaction ends. A transaction
idle for longer than
idleTimeout(30 s by default) is rolled back.
Watch #
StreamBuilder(
stream: users.filter(city.eq('Lima')).order(name.asc()).watch(),
builder: (context, snapshot) => switch (snapshot.data) {
Ok(:final data) => UserList(users: data),
_ => const SizedBox.shrink(),
},
);
watch emits the rows at once and again after every committed write to the
table.
Offline-first sync #
Declare the remote on the table, and every write records its change in the
same commit as the row: a crash, a rolled back transaction or a full disk
can never keep the row and lose its pending change. Your code talks to the
server; LocalDB.sync knows what is pending, what is being sent and what
the server confirmed:
static final table = DbTable<Note>('notes', key: 'id', fromJson: Note.fromJson, syncWith: 'primary');
await Note.table.insert([draft]); // the change is recorded, same commit
// Push: lease a batch, send it, record exactly what the server stored.
if (await LocalDB.sync.claim('primary') case Ok(data: final batch) when !batch.isEmpty) {
final versions = await api.push(batch.envelopes); // your HTTP: a version per envelope
await LocalDB.sync.applyPushResult('primary', PushResult(
leaseId: batch.leaseId,
acknowledged: [
for (final (i, envelope) in batch.envelopes.indexed)
SyncAcknowledgement.of(envelope, versions[i]),
],
));
}
// Pull: a page and its checkpoint are stored together, or not at all.
if (await LocalDB.sync.status('primary') case Ok(data: final status)) {
await LocalDB.sync.applyRemote('primary', await api.pull(after: status.checkpoint));
}
- An acknowledgement settles one mutation and revision: the ACK of revision 7 never confirms revision 8 written while 7 was being sent.
- Every retry sends the same envelope (
mutationId, bytes,baseVersion), so the server deduplicates bymutationId. - Server changes never come back as local changes; over a pending local
change they become a conflict (
LocalDB.sync.conflicts), resolved withacceptRemote,keepLocalormerged, only if the row did not change since. - A deleted row keeps a tombstone until the server acknowledged the
deletion; recreating its key meanwhile answers
tombstonePending. LocalDB.sync.stateOf(Note.table, id)answerspending,synced,conflict,blockedorunknown, with the revisions.
The rules and every operation are in db_dsl's
PROTOCOL.md
("Sync"); MemoryEngine follows them too, for tests. The web has no tables,
so LocalDB.sync answers unsupportedPlatform there.
Indexes and explain #
The planner looks rows up by primary key, or picks the index with the most
leading eq fields plus a range on the next one, or an index that already
follows order (stopping after limit). explain shows the choice:
final plan = await users.filter(city.eq('Lima').and(age.gt(30))).order(age.desc()).explain();
// {table: users, access: index_scan, index: by_city_age,
// descending: true, presorted: true, exact: true}
exact: true means the index alone decides the filter: rows are not checked
again, and count reads no row at all. Add an index when explain reports
full_scan for a query on a large table.
Another database #
Tables belong to the database of LocalDB.init. Another database of the
app is the exception: it defines the tables it is given, and is named on the
queries that go there:
switch (await LocalDB.open('${dir.path}/archive', tables: [users])) {
case Ok(data: final archive):
await users.insert([old]).execute(archive);
final archived = await users.all().load(archive);
case Err(:final error):
report(error);
}
A table belongs to the first database that defines it. Give a table to
another database only after the app's database has used it (or list it in
LocalDB.init(tables: [...])); otherwise it stays with the other one, and
its unnamed queries go there.
Durability #
await LocalDB.init(
options: const DbOptions(durability: Durability.noMetaSync),
);
Durability |
A committed transaction after a power loss |
|---|---|
full (default) |
is kept: data and metadata are flushed on every commit |
noMetaSync |
may be undone (the last one), never corrupted; one flush per commit instead of two |
noSync |
the last ones may be undone; flushing is left to the OS. An app crash loses nothing |
On Apple hardware a flush is F_FULLFSYNC, which takes milliseconds: group
writes in a transaction or atomicBatch rather than committing row by row.
The database file grows as needed: DbOptions.initialSize (64 MiB) and
maxSize (16 GiB) are address space reserved for the memory map, not disk.
Errors #
Every operation answers a Result. DbError is a sealed family, so a
switch handles every kind of failure:
switch (await users.insert([ana])) {
case Ok(:final data):
showSaved(data);
case Err(error: ConstraintError()): // duplicate key, unique index
showAlreadyExists();
case Err(error: TransactionError()): // closed, aborted, expired
retry();
case Err(:final error): // schema, storage, engine
report(error);
}
error.code (DbErrorCode) names the exact cause: duplicateKey,
uniqueViolation, affectedRowsMismatch, rowMapping, keyTooLarge,
mapFull, legacyFormat, notOpen, tableNotReady and the rest listed in
the API docs.
How it works #
your code
│ LocalDB.Post / GetById … await users.filter(…)
▼ ▼
key-value records (1.x API) db_dsl: builders → JSON requests (protocol v1)
│ │
└──────────────┬────────────────────┘
▼
db_dsl's worker isolate one per isolate that uses it; every call runs here,
│ never on the UI isolate
▼ dart:ffi (@Native, resolved against the bundled library)
offline_first_core (Rust) planner, indexes, transactions
│
▼
LMDB 1.0.2 (natdb) one file: <path>.lmdb
main the key-value records
t:<table> the rows of each table, by primary key
i:<table>:<index> each secondary index
- One file, two APIs.
LocalDB.initopens the tables (a db_dslDatabaseon the native engine) and then the key-value records, on the same<path>.lmdb; they live in separate LMDB databases, so they never collide. - The native library ships with the app.
hook/build.dartpicks the prebuilt offline_first_core of the target (native/<os>/<architecture>/) and declares it as a code asset; Flutter bundles it and the@Nativebindings resolve against it. Nothing is loaded by path. - Queries are planned in Rust. A filter, an order or a count is one
request; the engine looks rows up by key or through the best index and
returns only the result.
explainshows the choice. - Everything is a
Result. Native errors cross the FFI as JSON and become a typedDbError; nothing throws for an outcome. - On the web, the key-value records use IndexedDB; tables need the native engine and are not available there.
Using it well #
- Call
LocalDB.init()once, beforerunApp, and keep it open. Tables define themselves on it the first time they are used. - Let the analyzer plugin write the typed fields, and run
dart analyzein CI:flutter analyzedoes not show plugin diagnostics yet. - Use a table once before its first transaction (a screen usually
watches it first), or list it in
LocalDB.init(tables: [...]): a first use inside a transaction answerstableNotReady. - Group writes in
LocalDB.transactionorLocalDB.atomicBatch: a durable commit flushes the disk (milliseconds on Apple hardware). - Index what you filter and order by, and check it with
explain(). - Write
orderwhenever order matters; without it the order is unspecified. - Store dates in UTC (
toUtc()), or as numbers, to sort or range over them. - Show a
watchstream instead of reloading after each write: it emits again after every committed write to its table. - Handle
ErrbyDbErrorkind anderror.code(aswitchcovers them all), never by message. - Do not store secrets here: the file is readable on a rooted or jailbroken device and in a desktop user directory. Use the platform keychain.
- Test without a device on the host (
flutter testbundles the host library), or on db_dsl'sMemoryEngine.
Platforms #
| Platform | Architectures | Minimum |
|---|---|---|
| Android | arm64-v8a, armeabi-v7a, x86_64, x86 (16 KB pages) | API 21 |
| iOS | device arm64; simulator arm64, x86_64 | iOS 13 |
| macOS | arm64, x86_64 | macOS 10.15 |
| Linux | x86_64, arm64 | glibc 2.35 |
| Windows | x64, arm64 | Windows 10 |
| Web | key-value records on IndexedDB | — |
Tables need the native engine: on the web, LocalDB.init(tables: ...)
answers unsupportedPlatform, and a table query answers notOpen.
Benchmarks #
Same data and operations for every engine: 10 000 rows
(id, name, email, age, city, active) with an index on (city, age). Each
cell is the median of 3 rounds, measured from Dart in a profile (AOT) build
of the benchmark app on an Apple M1 Max, macOS 26.7, Flutter
3.47.5. Rows are grouped by what a commit guarantees.
| Engine | Durability | Runs on | Insert 10k rows, 1 transaction (per row) | Insert, 1 transaction per row | Find by primary key | Indexed query, limit 50 | Indexed count | Update by key |
|---|---|---|---|---|---|---|---|---|
| flutter_local_db 3.0 (full) | data and metadata flushed per commit | database isolate | 7.1 µs | 9.28 ms | 45.8 µs | 107.5 µs | 68.9 µs | 9.37 ms |
| SQLite 3.53.4 (synchronous=FULL) | WAL flushed per commit (fullfsync on Apple) | calling isolate | 2.7 µs | 5.02 ms | 3.7 µs | 46.0 µs | 48.9 µs | 4.95 ms |
| drift 2 (synchronous=FULL) | WAL flushed per commit (fullfsync on Apple) | background isolate | 3.7 µs | 4.89 ms | 53.2 µs | 154.1 µs | 102.9 µs | 5.08 ms |
| flutter_local_db 3.0 (no_meta_sync) | data flushed per commit | database isolate | 6.4 µs | 5.10 ms | 48.0 µs | 112.3 µs | 70.8 µs | 5.55 ms |
| flutter_local_db 3.0 (no_sync) | no flush per commit | database isolate | 5.8 µs | 73.2 µs | 42.2 µs | 110.3 µs | 71.6 µs | 69.9 µs |
| SQLite 3.53.4 (synchronous=OFF) | no flush per commit (WAL) | calling isolate | 1.6 µs | 25.0 µs | 3.4 µs | 37.9 µs | 47.4 µs | 31.1 µs |
| Hive CE 2 | no flush per write | calling isolate | 2.3 µs | 61.4 µs | 0.4 µs | 17.8 µs | 445.5 µs | 42.5 µs |
| Sembast 3 | no flush per write | calling isolate | 41.1 µs | 149.6 µs | 1.3 µs | 69.3 µs | 1.40 ms | 147.9 µs |
How to read it:
- Where the work runs decides the small operations. sqlite3, Hive CE and Sembast run on the calling isolate: a lookup costs microseconds, and a slow query or a flush blocks the UI for as long as it takes. flutter_local_db and drift hand every call to another isolate, which costs a round trip of tens of microseconds and never blocks the UI. Against drift, the same design, flutter_local_db is faster on lookups, indexed queries and counts.
- A durable LMDB commit flushes twice (data, then the metadata page);
SQLite in WAL mode flushes once.
noMetaSyncflushes once and matches it. - Hive CE and Sembast keep every row in memory. Lookups are fast, and queries and counts scan all the rows.
Reproduce with cd benchmark && flutter drive --profile -d macos --driver test_driver/integration_test.dart --target integration_test/benchmark_test.dart
(any device works; the table is saved to benchmark/build/benchmark.md).
Migrating #
- From 2.0: one entry point (
LocalDB), tables from your models, queries that run when awaited,Resultinstead of exceptions. - From 1.x: LMDB 1.0 cannot read the files of 1.x; export with
LocalDB.exportAll()from a version of your app on 1.6, then import withLocalDB.importAll().
Both are in MIGRATION.md.
Roadmap #
Not in 3.0 yet, planned for later 3.x versions (none of them breaks the 3.0 API):
- A binary wire format and handles for the C ABI (the protocol is versioned, so the public API stays).
- Tables on the web.
License #
MIT. See LICENSE.