flutter_local_db 2.0.0 copy "flutter_local_db: ^2.0.0" to clipboard
flutter_local_db: ^2.0.0 copied to clipboard

Diesel-style local database for Flutter on Rust and LMDB 1.0: typed tables, indexed queries, transactions and streams on Android, iOS, macOS, Linux and Windows.

flutter_local_db #

An embedded database for Flutter with a query API modelled on Diesel: typed tables, secondary indexes, a query planner, transactions with savepoints and reactive queries. The engine is offline_first_core, written in Rust on LMDB 1.0.2 through natdb.

  • Tables of JSON rows with a primary key, typed columns and optional auto-increment keys. No code generation.
  • 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, and explain to see the chosen index.
  • Transactions: commit on success, rollback on error, savepoints, read snapshots and atomic batches.
  • Reactive: watch emits a query's rows again after every committed write to its table.
  • Platforms: Android, iOS, macOS, Linux and Windows. The library ships prebuilt and a build hook bundles it: nothing to configure.
  • Off the UI isolate: every call runs on a database isolate.
final db = await LocalDatabase.openNamed('app', tables: [users]);

await users.insert([User(id: 1, name: 'Ana', city: 'Lima', age: 31)]).execute(db);

final adults = await users
    .filter(users.city.eq('Lima') & users.age.ge(18))
    .order(users.age.desc())
    .limit(20)
    .load(db);

Install #

dependencies:
  flutter_local_db: ^2.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.

Define a table #

A table maps a Dart class to rows. Columns name the fields that queries use; the row itself is whatever toJson returns.

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,
  );

  final int? id;
  final String name;
  final String city;
  final int age;

  Map<String, dynamic> toJson() => {'id': id, 'name': name, 'city': city, 'age': age};
}

final class UsersTable extends Table<User> {
  UsersTable() : super('users');

  late final id = integer('id');
  late final name = text('name');
  late final city = text('city');
  late final age = integer('age');

  @override
  Column<Object> get primaryKey => id;

  @override
  bool get autoIncrement => true; // a null id is generated on insert

  @override
  List<Index> get indexes => [
    Index('by_city_age', [city, age]),
    Index.unique('by_name', [name]),
  ];

  @override
  User fromJson(Map<String, dynamic> json) => User.fromJson(json);

  @override
  Map<String, dynamic> toJson(User row) => row.toJson();
}

final users = UsersTable();

Column types: text, integer, real, boolean and dateTime (stored as UTC microseconds, so it sorts; convert with DateTimeColumn.toStorage and fromStorage in your toJson/fromJson). A column name can be a dotted path into nested objects (text('address.city')).

Open #

// In the application support directory:
final db = await LocalDatabase.openNamed('app', tables: [users]);

// Or at a path of your choice (files go to '<path>.lmdb'):
final db = await LocalDatabase.open(path: '${dir.path}/app', tables: [users]);

open defines the tables: a new table is created, and indexes added to or removed from an existing table are built or dropped. Open each database once and share the LocalDatabase object: a second object on the same path shares the files but not the write queue, so its writes can wait behind the other's transactions.

Query #

Diesel (Rust) flutter_local_db
users.filter(city.eq("Lima")) users.filter(users.city.eq('Lima'))
.and(...) / .or(...) / not(...) a & b / a | b / ~a
.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 columns)
.order(age.desc()), .then_order_by(...) .order(users.age.desc()), .thenOrderBy(...)
.limit(n), .offset(n) .limit(n), .offset(n)
.load(conn), .first(conn) .load(db), .first(db)
.count().get_result(conn) .count(db)
users.find(id).first(conn) users.find(id).first(db)
sum, avg, min, max .sum(col, db), .avg(col, db), .min(col, db), .max(col, db)
final page = await users
    .filter(users.city.eqAny(['Lima', 'Bogotá']))
    .orFilter(users.age.lt(18))
    .order(users.name.asc())
    .limit(50)
    .offset(100)
    .load(db);

final ana = await users.find(1).first(db); // null when missing
final average = await users.filter(users.city.eq('Lima')).avg(users.age, db);

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]).execute(db);              // returns the count
final saved = await users.insert([nuevo]).getResults(db); // rows with generated ids
await users.insert([ana]).onConflictDoNothing().execute(db);
await users.insert([ana]).onConflictReplace().execute(db);

await users
    .update()
    .filter(users.city.eq('Lima'))
    .set(users.age, 40)
    .execute(db);

await users.delete().filter(users.age.lt(18)).execute(db);

// Fails, changing nothing, unless exactly one row matches:
await users.delete().filter(users.id.eq(7)).expectAffectedRows(1).execute(db);

An insert of an existing primary key fails with duplicateKey, and a duplicate value of a unique index with uniqueViolation, unless onConflictDoNothing or onConflictReplace says otherwise. Every statement is atomic.

Transactions #

final total = await db.transaction((tx) async {
  await users.insert([ana]).execute(tx);
  await tx.savepoint((sp) async {
    // A failure here rolls back only the savepoint, and rethrows.
    await users.update().filter(users.id.eq(2)).set(users.age, 30).execute(sp);
  });
  return users.all().count(tx); // reads see the transaction's writes
}); // committed here; an exception rolls everything back

final snapshot = await db.readTransaction((tx) async {
  // One consistent view, while other writes go on.
  return (await users.all().count(tx), await users.all().max(users.age, tx));
});

await db.atomicBatch([
  users.insert([ana]),
  users.update().filter(users.id.eq(2)).set(users.age, 30),
]); // all or nothing, in one round trip
  • Inside transaction, run statements on tx. Using db there fails with transactionReentrancy instead of deadlocking.
  • A write that fails inside a transaction makes it rollback-only: the commit fails with transactionAborted even when the error was caught. Put recoverable work in a savepoint.
  • 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<List<User>>(
  stream: users.filter(users.city.eq('Lima')).order(users.name.asc()).watch(db),
  builder: (context, snapshot) => UserList(users: snapshot.data ?? const []),
);

watch emits the rows at once and again after every committed write to the table. db.changes streams the tables each commit wrote.

Indexes and explain #

The planner looks rows up by primary key, or picks the index with the most leading eq columns 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(users.city.eq('Lima') & users.age.gt(30))
    .order(users.age.desc())
    .explain(db);
// {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.

Durability #

final db = await LocalDatabase.open(
  path: path,
  options: const LocalDbOptions(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: LocalDbOptions.initialSize (64 MiB) and maxSize (16 GiB) are address space reserved for the memory map, not disk.

Errors #

Failures throw LocalDbException with a code (LocalDbErrorCode): duplicateKey, uniqueViolation, affectedRowsMismatch, tableNotFound, invalidSchema, schemaMismatch, missingPrimaryKey, keyTooLarge, mapFull, transactionAborted, transactionReentrancy, transactionExpired, closed, legacyFormat, unsupportedPlatform, nativeLibrary and the rest listed in the API docs.

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 LocalDB API on IndexedDB —

The query API needs the native engine: on the web, LocalDatabase.open throws unsupportedPlatform.

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 2.0 (full) data and metadata flushed per commit database isolate 6.4 µs 10.65 ms 29.7 µs 101.5 µs 64.5 µs 10.63 ms
SQLite 3.53.4 (synchronous=FULL) WAL flushed per commit (fullfsync on Apple) calling isolate 2.2 µs 5.14 ms 4.1 µs 42.4 µs 49.4 µs 5.30 ms
drift 2 (synchronous=FULL) WAL flushed per commit (fullfsync on Apple) background isolate 3.2 µs 5.09 ms 43.9 µs 138.1 µs 95.6 µs 5.40 ms
flutter_local_db 2.0 (no_meta_sync) data flushed per commit database isolate 6.5 µs 5.00 ms 33.6 µs 108.7 µs 70.5 µs 5.35 ms
flutter_local_db 2.0 (no_sync) no flush per commit database isolate 5.3 µs 77.0 µs 36.6 µs 109.3 µs 70.0 µs 72.9 µs
SQLite 3.53.4 (synchronous=OFF) no flush per commit (WAL) calling isolate 1.6 µs 26.2 µs 3.5 µs 41.0 µs 47.4 µs 32.8 µs
Hive CE 2 no flush per write calling isolate 2.3 µs 40.6 µs 0.5 µs 18.1 µs 468.5 µs 48.4 µs
Sembast 3 no flush per write calling isolate 37.5 µs 133.7 µs 1.5 µs 70.2 µs 1.38 ms 139.2 µ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 about 30 µs here 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. noMetaSync flushes 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).

Key-value API #

LocalDB is the 1.x API: string keys and JSON values, on every platform including the web (IndexedDB).

await LocalDB.init();
await LocalDB.Post('user-1', {'name': 'Ana'});
final user = await LocalDB.GetById('user-1'); // LocalDbResult<LocalDbModel?, ErrorLocalDb>
await LocalDB.Put('user-1', {'name': 'Ana María'});
await LocalDB.Delete('user-1');
final all = await LocalDB.GetAll();

Results are LocalDbResult (Ok or Err), handled with when, isOk or unwrapOr. LocalDbModel.createdAt and updatedAt are not stored: keep your own timestamps in the data.

Migrating from 1.x #

2.0 stores data with LMDB 1.0, which cannot read the files of 1.x. Export with LocalDB.exportAll() from a version of your app on 1.6, then import with LocalDB.importAll() in 2.0: see MIGRATION.md.

Roadmap #

Not in 2.0, planned for later versions:

  • Offline-first sync: a change log written in the same commit as each row, acknowledged by revision, with tombstones and conflict policies.
  • Joins, associations and GROUP BY aggregates.
  • A binary wire format and handles for the C ABI (the protocol is versioned, so the public API stays).
  • Schema files with generated table classes.
  • The query API on the web.

License #

MIT. See LICENSE.

13
likes
160
points
838
downloads

Documentation

API reference

Publisher

verified publisherjhonacode.com

Weekly Downloads

Diesel-style local database for Flutter on Rust and LMDB 1.0: typed tables, indexed queries, transactions and streams on Android, iOS, macOS, Linux and Windows.

Repository (GitHub)
View/report issues

Topics

#database #storage #offline-first #lmdb #rust

License

MIT (license)

Dependencies

code_assets, ffi, flutter, hooks, logger_rs, path, path_provider, web

More

Packages that depend on flutter_local_db