Dart binding¶
DecentDB ships two Dart-facing layers:
bindings/dart/dart/— the packageddecentdbDart APIbindings/dart/flutter/— thedecentdb_fluttermobile package for Flutter apps on Android and iOStests/bindings/dart/— the smoke-validation package used in CI and repository validation
Native library requirement¶
Build the shared library from the repository root:
The Dart package loads the built shared library from DECENTDB_NATIVE_LIB or an explicit libraryPath:
- Linux:
target/debug/libdecentdb.so - macOS:
target/debug/libdecentdb.dylib - Windows:
target/debug/decentdb.dll
For Flutter/Dart desktop packaging, GitHub Releases also publish small platform-native archives that contain just the FFI library:
decentdb-dart-native-<tag>-Linux-x64.tar.gzdecentdb-dart-native-<tag>-Linux-arm64.tar.gzdecentdb-dart-native-<tag>-macOS-arm64.tar.gzdecentdb-dart-native-<tag>-Windows-x64.zip
Each archive extracts to the platform-native library file (libdecentdb.so, libdecentdb.dylib, or decentdb.dll) so desktop apps can bundle it directly.
Flutter Mobile Package¶
decentdb_flutter is the first-class mobile package. It keeps the SQL/database API in the pure Dart decentdb package and adds mobile packaging, path, key-provider, sidecar, lifecycle, and diagnostics helpers.
Initial mobile support tiers:
| Platform | Target | Tier |
|---|---|---|
| Android Flutter app-private storage | API 26+, arm64-v8a device, x86_64 emulator | Tier 2 until a documented real-device lane exists |
| iOS Flutter app-private Application Support storage | iOS 15+, arm64 device, x86_64 simulator | Tier 2 until a documented real-device lane exists |
| Android widgets/services/providers sharing one DB | Multiprocess mobile access | Candidate/unsupported until separately tested |
| iOS app extensions sharing one DB | App group storage | Candidate/unsupported until separately tested |
The mobile package uses default native loading in the happy path:
- Android:
DynamicLibrary.open('libdecentdb.so')from the standard Flutter/Gradle native library layout. - iOS:
DynamicLibrary.process()for the static/XCFramework link model.
Android/iOS candidate artifacts are built by:
bindings/dart/scripts/build_mobile_android.sh --strict
bindings/dart/scripts/build_mobile_ios.sh --strict
bindings/dart/scripts/check_mobile_artifacts.sh --platform android --artifact-root target/mobile-artifacts/android --strict
bindings/dart/scripts/check_mobile_artifacts.sh --platform ios --artifact-root target/mobile-artifacts/ios --strict
Install built artifacts into the mobile package layout with:
The dedicated Mobile Native Artifacts GitHub Actions workflow builds unsigned candidate Android and iOS artifacts on tag pushes and manual dispatch. Use bindings/dart/scripts/mobile_benchmark_guardrails.sh to record artifact size guardrails and the placeholder runtime metrics that device/simulator benchmark lanes fill in once accepted baselines exist.
Mobile Quick Start¶
import 'package:decentdb_flutter/decentdb_flutter.dart';
final db = await DecentDbMobile.openAppDatabase('app.ddb');
try {
db.execute('CREATE TABLE IF NOT EXISTS items (id INT64 PRIMARY KEY, name TEXT)');
} finally {
db.close();
}
The v1 mobile database-set helper returns the files that app backup, restore, export, and delete workflows must treat together:
final path = await DecentDbMobile.appDatabasePath('app.ddb');
final files = DecentDbMobile.databaseSetPaths(path);
That set is app.ddb, app.ddb.wal, app.ddb.sync-journal, and app.ddb.coord when process coordination is enabled. Future authoritative sidecars require a spec or ADR update before mobile backup/restore obligations change.
DecentDbMobile.noBackupDatabasePath(name) returns an app-private device-local subdirectory path for replicas or caches that should not be restored. The host app is still responsible for any platform-specific backup-exclusion flag.
Mobile Lifecycle¶
Mobile apps should use one owning isolate per native database handle. The recommended async pattern is AsyncDatabase; its worker isolate owns the native handle and all pending futures fail with AsyncDatabaseClosed if the worker closes or terminates before replying.
On background/inactive transitions, stop admitting new app writes, finish or cancel in-flight work, optionally checkpoint, and close when no OS-approved background task is active. After process kill or isolate restart, old Database, AsyncDatabase, Statement, and AsyncStatement objects are invalid; reopen the database and recreate statements.
Reactive watch/change-stream wrappers are not exposed by the Flutter mobile package yet. The native C ABI has watch foundations, but mobile background, close, and restart semantics need dedicated lifecycle tests before the package claims a stable reactive mobile API.
Mobile Diagnostics¶
Use redacted summaries for logging and support bundles:
final summary = DecentDbMobile.openOptionsSummary(
'encryption_key_hex=001122;process_coordination=required',
);
DecentDbAbiMismatchException reports the expected ABI, loaded ABI, artifact source when known, and recovery guidance. Align the decentdb, decentdb_flutter, and native artifact versions when this error appears.
Quick start¶
import 'package:decentdb/decentdb.dart';
void main() {
final db = Database.open(
'app.ddb',
libraryPath: '/absolute/path/to/libdecentdb.so',
);
db.execute('CREATE TABLE users (id INT64 PRIMARY KEY, name TEXT NOT NULL)');
final insert = db.prepare(r'INSERT INTO users VALUES ($1, $2)');
insert.bindAll([1, 'Ada']);
insert.execute();
insert.dispose();
final rows = db.query('SELECT id, name FROM users ORDER BY id');
print(rows.single['name']);
db.close();
}
Open modes and lifecycle¶
The Dart wrapper now exposes distinct open modes backed by the stable C ABI:
Database.open(path, ...)— open-or-createDatabase.create(path, ...)— create-onlyDatabase.openExisting(path, ...)— open-onlyDatabase.memory(...)Database.close()Database.inTransaction
The package also installs a Dart Finalizer so a leaked Database handle is still released if the object is garbage-collected, but callers should still close explicitly.
Local on-disk databases can require cross-process WAL coordination through typed open parameters:
final db = Database.open(
'app.ddb',
processCoordination: ProcessCoordinationMode.required,
processCoordinationTimeoutMs: 30000,
);
Statement API¶
Statement is now backed by native prepared statements (ddb_stmt_t), not by re-sending the SQL text for every execution.
Available operations include:
bindNull,bindInt64,bindBool,bindFloat64,bindText,bindBlob,bindDecimal,bindDateTimebindAll([...])reset()andclearBindings()execute()query()step()/readRow()nextPage(pageSize)dispose()
Supported high-level bind values in bindAll(...):
nullintbooldoubleStringUint8ListDateTimeDecimalValue
Result type mapping¶
Rows returned from query(), step() / readRow(), and nextPage() decode native values into Dart objects:
| DecentDB type | Dart result value |
|---|---|
INT64 | int |
FLOAT64 | double |
BOOL | bool |
TEXT | String |
BLOB, UUID, GEOMETRY, GEOGRAPHY | Uint8List |
DECIMAL | DecimalValue |
TIMESTAMP | UTC DateTime |
ENUM | DecentDBEnumValue(typeId, labelId) |
IPADDR / INET | canonical String |
CIDR | canonical String |
DATE | UTC DateTime at midnight |
TIME | Duration since midnight |
TIMESTAMPTZ | UTC DateTime |
INTERVAL | DecentDBIntervalValue(months, days, microseconds) |
MACADDR / MACADDR8 | canonical lowercase String |
String parameters can be used for typed semantic columns when the SQL target column is known.
Rows use an O(1) column-name index map, so row['column_name'] no longer performs a linear scan.
Streaming and pagination¶
step() and nextPage() stream from native row-view buffers without materializing the full result set in Dart:
final stmt = db.prepare('SELECT id, name FROM users ORDER BY id');
while (stmt.step()) {
final row = stmt.readRow();
print(row['name']);
}
stmt.dispose();
final stmt = db.prepare('SELECT id, name FROM users ORDER BY id');
while (true) {
final page = stmt.nextPage(128);
for (final row in page.rows) {
print(row['name']);
}
if (page.isLast) break;
}
stmt.dispose();
query() still returns all rows but internally chunks at 256 rows via the streaming path. nextPage() invalidates any row from a prior step() call, and vice versa. Binding, resetting, or clearing bindings also invalidates streaming state.
Batch execution¶
Batch helpers execute many rows in a single FFI call, which is significantly faster than per-row bind/execute loops:
final stmt = db.prepare(r'INSERT INTO users VALUES ($1, $2)');
db.transaction(() {
stmt.executeBatchTyped('it', [
[1, 'Alice'],
[2, 'Bob'],
[3, 'Charlie'],
]);
});
stmt.dispose();
Available batch methods:
executeBatchInt64(List<int> values)— one-column INT64 batchexecuteBatchI64TextF64(List<(int, String, double)> rows)—(INT64, TEXT, FLOAT64)triple batchexecuteBatchTyped(String signature, List<List<Object?>> rows)— mixed-type batch using ani/t/fsignature string
Re-execute helpers¶
Re-execute helpers combine reset, bind, and execute into a single FFI call for hot DML loops:
final stmt = db.prepare(r'UPDATE counters SET val = $1 WHERE id = 1');
stmt.rebindInt64Execute(42);
stmt.dispose();
Available re-execute methods:
rebindInt64Execute(int value)— reset, bind INT64 at position 1, executerebindTextInt64Execute(String text, int value)— reset, bind(TEXT, INT64), executerebindInt64TextExecute(int value, String text)— reset, bind(INT64, TEXT), execute
Fused bind+step helpers¶
For extremely hot query paths, fused helpers combine binding and stepping into a single FFI boundary crossing:
final stmt = db.prepare('SELECT id, name, score FROM t WHERE id = $1');
// Single-row lookup returning a primitive tuple
final result = stmt.bindInt64StepI64TextF64(1, 42);
if (result != null) {
print('Name: ${result.$2}');
}
stmt.dispose();
Available fused methods:
bindInt64Step(int index, int value)— bind INT64 and stream one row view (returnstrueif row available, usereadRow())bindInt64StepI64TextF64(int index, int value)— bind INT64 and return a strongly-typed(int, String, double)?tuple directly
Schema helpers¶
The packaged wrapper exposes:
db.schema.listTables()/listTablesInfo()db.schema.describeTable(name)/getTableColumns(name)db.schema.getTableDdl(name)db.schema.listIndexes()db.schema.listViews()/listViewsInfo()db.schema.getViewDdl(name)db.schema.listTriggers()db.schema.getToolingMetadata()db.schema.describeQueryContract(sql)
Rich schema snapshot¶
getSchemaSnapshot() returns the complete schema in one call with rich typed metadata:
final snapshot = db.schema.getSchemaSnapshot();
print('v${snapshot.snapshotVersion}, cookie=${snapshot.schemaCookie}');
for (final table in snapshot.tables) {
print('Table ${table.name} (temp=${table.temporary}, rows=${table.rowCount})');
print(' DDL: ${table.ddl}');
for (final fk in table.foreignKeys) {
print(' FK: ${fk.columns} -> ${fk.referencedTable}(${fk.referencedColumns})');
}
for (final column in table.columns) {
if (column.generatedSql != null) {
print(' Generated: ${column.name} = ${column.generatedSql} (${column.generatedStored ? "STORED" : "VIRTUAL"})');
}
for (final check in column.checks) {
print(' Check: ${check.name ?? "<unnamed>"}: ${check.expressionSql}');
}
}
}
The snapshot model includes:
SchemaSnapshot— top-level container withtables,views,indexes,triggersSchemaTableInfo— DDL, row count, primary key columns, foreign keys, check constraints, generated columnsSchemaViewInfo— DDL, SQL text, column names, dependenciesSchemaIndexInfo— DDL, kind, uniqueness, partial-index predicate, include columnsSchemaTriggerInfo— DDL, target kind, timing, events, event mask, for-each-row flagSchemaCheckConstraintInfo— optional name and expression SQL
All collections are deterministically ordered by name.
Branch and snapshot workflow¶
Database.branchWorkflow exposes native named-snapshot and branch workflows through the stable C ABI. Branch-local SQL can target main or any named branch and supports typed positional parameters.
final workflow = db.branchWorkflow;
final baseline = workflow.createSnapshot('baseline');
final scratch = workflow.createBranch('scratch', from: baseline.name);
final write = workflow.executeSql(
scratch.name,
r'INSERT INTO users VALUES ($1, $2)',
[3, 'Carol'],
);
print('affected=${write.affectedRows}');
final page = workflow.querySql(
scratch.name,
r'SELECT id, name FROM users WHERE id >= $1 ORDER BY id',
params: [1],
pageSize: 100,
);
for (final row in page.rows) {
print("${row['id']}: ${row['name']}");
}
final diff = workflow.diff('main', scratch.name);
print('added=${diff.addedRowCount}, updated=${diff.updatedRowCount}');
final preview = workflow.merge(scratch.name, 'main', dryRun: true);
print('clean=${preview.clean}, conflicts=${preview.conflictCount}');
Available operations:
createSnapshot(name)/listSnapshots()/deleteSnapshot(name)createBranch(name, from: ref)/listBranches()/deleteBranch(name)renameBranch(name, newName)commitBranch(name, message)/branchLog(name)diff(leftRef, rightRef)restore(branchName, targetRef, dryRun: true)merge(sourceBranch, targetRef, dryRun: true)executeSql(branchName, sql, [params])querySql(branchName, sql, params: [...], pageSize: n)
Branch references can be main, a branch name, a named snapshot, or a head ID where the operation supports historical refs. restore and merge default to dry-run mode so callers can inspect the effect before mutating branch state.
Branch SQL parameters support null, int, bool, double, String, Uint8List, DateTime, DecimalValue, and UuidValue. Results use the same Row and ResultPage shapes as normal queries; executeSql returns a BranchExecutionResult with columns, rows, affectedRows, returnsRows, and firstPage(pageSize).
The branch model types are:
BranchInfo— branch identity, current/base head IDs, timestamps, andisMainNamedSnapshot— retained snapshot identity, source branch/head, LSN, and timestampBranchLogEntry— head history entries with parent head, optional message, optional SQL, and timestampBranchDiffReport,BranchTableDiff, andBranchRowDiff— row-level primary-key diff summariesBranchRestoreReport— dry-run or applied restore summaryBranchMergeReport,BranchMergeChange, andBranchMergeConflict— merge preview/application summaries and conflict details
WAL maintenance¶
Evicts the shared WAL cache entry for an on-disk database. Call only after all handles for that path are closed.
Validation commands¶
Package suite:
Manual package validation:
cargo build -p decentdb
cd bindings/dart/dart
dart analyze lib/ test/ benchmarks/
DECENTDB_NATIVE_LIB=../../../target/debug/libdecentdb.so dart test --reporter expanded
Smoke path:
Console example:
cd bindings/dart/examples/console
dart pub get
DECENTDB_NATIVE_LIB=../../../../target/debug/libdecentdb.so dart run main.dart
Benchmark:
cd bindings/dart/dart
dart pub get
DECENTDB_NATIVE_LIB=../../../target/debug/libdecentdb.so dart run benchmarks/bench_fetch.dart --count 100000 --point-reads 5000 --fetchmany-batch 1024 --db-prefix dart_bench_fetch
Notes¶
Database.open(options: ...)passes native open options through the stable C ABI, including cross-process coordination and write-queue optionsDatabase.executeQueued(sql)andDatabase.writeQueueMetrics()expose the engine-owned write queue for self-contained queued writes- the example under
bindings/dart/examples/flutter_desktop/is still a desktop-oriented reference rather than a real Flutter SDK app - DecentDB remains a one-writer / many-readers engine; keep that concurrency model in mind when sharing database handles across isolates or threads