dbas_sqlite
Flutter plugin that provides access to SQLite databases for Android, iOS, macOS, Linux, Windows and Web platforms.
Previously published as
dbas_sqlite_flutter(≤ 1.x) under the repository nameDBAS.SQLite.Flutter. Both the pub package and GitHub repo now usedbas_sqlite.
Features
Cross-Platform Support
- Android - Native library integration
- iOS - xcframework with optimized performance
- macOS - xcframework for both development and production
- Linux - Native shared libraries
- Windows - DLL integration
- Web - WASM SQLite via dedicated Web Worker with OPFS persistence
Connection Pool (WAL Mode)
openDb()automatically creates a C-level connection pool with 1 writer + 4 readers (configurable)- WAL mode enables concurrent reads and writes
- Pool is fully transparent -- no API changes needed
- Reads automatically use pool readers; writes use the dedicated writer
- Falls back to single connection if pool creation fails or
readerPoolSize = 0 - Native pool has mutex-protected reader acquire/release for thread safety
- The writer is pinned to
PRAGMA synchronous=FULLandPRAGMA wal_autocheckpoint=1when it enters WAL, so every commit folds the WAL back into the main.dbfile — committed data is always present in the file a copy, a backup orstreamCopyDbreads, instead of sitting in the-wal. Measured cost: about +2.2 ms per commit (~3.5× on a 2000-commit write loop). The cost is per commit, not per row, so batch bulk writes into a single transaction — N writes inside one transaction pay for one checkpoint.closeDb()andstreamCopyDb()checkpoint on their own;checkpoint()is public for anything that reads the.dbfile by other means - Web uses an equivalent multi-worker pool (1 writer + N reader Web Workers, each its own SQLite connection, coordinated via a
SharedArrayBuffer-backed WAL SHM) so reads and writes run on separate connections just like native. Requires the page to be cross-origin isolated — see Web Setup; without it the web side falls back to a single connection (no read/write concurrency)
Thread Safety & Parallel Readers
- Writer lock: serializes all write operations (
executeSql, transactions) on both web and native - Independent readers: each
executeReadercall returns aDbasSqliteReaderwith its own pool connection -- multiple readers can be active simultaneously - Pool readers are acquired through a Dart-side semaphore with a
kPoolAcquireTimeoutMs(30 s) budget; if none frees up inside it,executeReaderthrowsexecuteReaderPoolAcquireTimeout— it does not silently fall back to the writer. Reads route to the writer only inside a transaction that has written (read-your-writes), and take the writer lock only in single-connection mode, where there is no pool at all - Transactions hold the writer lock for their full duration
- Reads within a transaction use the writer connection to see uncommitted data
- Readers must be closed explicitly via
reader.close()(or auto-closed whenreadRow()returnsfalse) before the connection can be reused closeDb()automatically closes all active readers, rolls back any open transaction (which releases the writer lock that transaction held) and rejects every caller parked on the writer-lock and reader-slot queues -- no risk of use-after-free on lingering readers. It deliberately does not clear a writer-lock hold it does not own: a granted acquire is not a queue entry, and clearing it under a live holder would let a lateropenDb()hand the lock to a second owner.openDb()resets that flag instead, on a connection that is not open and where no holder can exist- Closing a reader waits for every in-flight
readRow()step before finalizing the statement, socloseDb()can never finalize asqlite3_stmta step is still running against (the C layer neither refuses nor blocks that -- it corrupts). A consumer mid-scan when this happens keeps the row its step already produced, and its nextreadRow()throwsreaderClosedDuringScanrather than reporting a truncated list as a complete one. This wait is unbounded -- a timeout could only expire into the corruption it prevents -- so it is not covered by thekNativeOpDrainTimeoutMsceiling below; the reader emits a stall report everyDbasSqliteReader.kStepDrainStallReportMs(5 s) instead - There is a second unbounded wait, and
closeDb()reaches it first:rollback()drains every write dispatched inside the transaction before it issuesROLLBACK, andcloseDb()rolls back before it drains the native-operation registry. So acloseDb()livelocked behind an un-awaited in-transactionexecuteSqlnever reaches the drainkNativeOpDrainTimeoutMsbounds, andcloseDbNativeOpDrainTimeoutcannot be what surfaces it. A.timeout()there would abandon the rollback rather than cancel it, leaving a detachedROLLBACKfree to land on a connection teardown had already moved on to destroying -- so it stays unbounded and reports everyDbasSqlite.kReentrantWriterDrainStallReportMs(5 s) instead - Wire
DbasSqlite.onDiagnosticto receive those stall reports. They are the only signal a wedged teardown ever produces, and the reports' other sink (dart:developer) is dropped whenever no VM service client is subscribed -- i.e. in every release build on a device and everyflutter testrun, which is exactly where a production hang happens.DbasSqlite.onDiagnostic = (message) => myLogger.warn(message);once at app start is enough. Also delivered through it: a failure in any of the three phases of a reader's teardown (counter read,finalizeStmt, connection release), each of which loses a different resource nothing downstream can recover or name, and apoolReleaseReaderthat threw insidesetBusyTimeout(). Also arollback()that failed duringcloseDb()-- teardown continues past one deliberately, which means the WAL fold two steps later runs against a connection SQLite still considers mid-transaction, folds nothing, and letscloseDb()return successfully with committed frames still in the-wal;closeDb()'s documented escape hatch ("callrollback()yourself first") cannot cover the post-drain attempt, because that transaction did not exist when the caller could have. The sink must be synchronous (void Function(String)accepts anasyncbody, but its failure then escapes as an unhandled async error); a synchronous throw is caught rather than allowed to break the close, and the original message is re-emitted throughZone.current.printso a consumer whose logger died first still sees it closeDb()is single-flight: a second call issued while a teardown is in flight joins it and returns with its outcome, rather than reporting success while the pool is still being destroyed.openDb()anddropDb()join that same teardown --isOpened()readsfalsefrom the moment_dbis nulled, which is before the pool is destroyed, so neither may sample it. A teardown that fails therefore surfaces its error fromopenDb()/dropDb()too, instead of being swallowedcloseDb()joins a close that is already in progress rather than skipping it.reader.close()/stmt.close()latchisClosedsynchronously and then suspend, so an un-awaited close (unawaited(reader.close()); await db.closeDb();) leaves a reader that reports itself closed while it still holds a pool connection. Both closes are join-idempotent and the statement sweep waits for themcloseDb()also waits for any operation that has already crossed into native code, since neither the closing flag nor the wait-queue cancellations can recall one. Callers parked in Dart are still rejected immediately. In practice: closing a database with an un-awaited call still in flight takes as long as that call does. The wait is bounded bykNativeOpDrainTimeoutMs(30 s), after whichcloseDb()throwscloseDbNativeOpDrainTimeoutand leaves the connection open rather than destroying it under live native work- A caught
closeDbNativeOpDrainTimeoutdoes not hand back a usable connection. It stays open but stays marked closing: every writer-lock acquire then throwswriterLockWaitCancelled(executeSqloutside a transaction,executeScript,beginTransaction,checkpoint,vacuum,enableWal), every reader-slot acquire throwsreaderSlotWaitCancelled(pooledexecuteReader), andsetBusyTimeoutthrows too.openDb()will not clear it -- the connection is still open, soopenDbreturns early on itsisOpened()guard. Any open transaction is gone as well, sincecloseDb()rolls back as its first step. The only supported next step is to await the outstanding work and callcloseDb()again
Background FFI Worker (Native)
- All heavy FFI operations (
executeSql,prepareQuery,readRow,openDb,closeDb) run on a dedicated background isolate - Column data is pre-fetched into a Dart-side cache after each
readRow--getColumn*calls are synchronous reads from Dart memory, not FFI round-trips - Bind operations remain synchronous on the main isolate for performance
True Streaming I/O (Web)
attachStreamDb(stream): Streams a database to OPFS chunk by chunk viaattachStreamBegin/attachStreamChunk/attachStreamEndwith ACK-based backpressure. The complete file is never buffered in Dart memory -- critical for large databases (500 MB+). Unlike the native implementation this is not atomic:attachStreamBegintruncates the live database before the first chunk and an abort unlinks it, so a stream that fails partway destroys what was there. See Database Content belowgetContent(): Streams the database from OPFS chunk by chunk. Supports both Transferable Streams (Chrome/Firefox) and chunked postMessage fallback (Safari)streamCopyDb(destDbName): Copies between OPFS files chunk by chunk
Database Operations
-
Lifecycle
getInstance(dbName:)- Get singleton instance for a databaseopenDb({readerPoolSize})- Open database with connection pool. Waits for a teardown already in flight before it looks at anything, its own idempotency check includedcloseDb()- Close database connection (rolls back any open transaction, waits for work already inside native code, closes all active readers, then folds the WAL into the main.dbfile)isOpened()- Check connection status. Not an admission check against a close: it readsfalsefrom the instant_dbis nulled, which is before the pool is destroyedgetAppDatabasePath()- Get platform-specific database pathdatabaseExists()- Check if the database file existsdropDb()- Delete the database file (including WAL and SHM). Joins an in-flightcloseDb()first, so it cannot unlink files the C pool is still holding open. Not atomic against a caller that reopens the database concurrently -- arbitrating that needs an admission gate overopenDb()this library does not have, so it is a caller-side ordering errordropDb()cannot see
-
Database Content
attachDb(bytes)- Attach a database from raw bytesattachStreamDb(stream)- Attach a database from a byte stream- Attach atomicity (native): both write a sibling temp file, flush and close it, and only then drop the destination and rename the temp onto it -- so an attach that fails partway is a no-op and the existing database survives. Web is not atomic and this cannot be fixed from this package: the prebuilt worker opens the live OPFS database with mode
w+(truncating it before the first chunk) and unlinks that same path on abort, so a mid-stream failure destroys the database. That worker is a build artifact of the separateDBAS.SQLiterepository and is tracked there. On web, keep your own copy of anything you cannot re-download - An attach REFUSES when a different live instance holds the database.
attachDb/attachStreamDbreplace the file wholesale, so before they touch it they join any teardown this instance still has in flight, close this instance if it is open, and then look at who holdsdbName's instance slot. If that is some otherDbasSqliteand it is open, opening or closing, the attach throwsDbasSqliteExceptionwith codeattachDbInstanceSlotHeldByLiveInstanceand touches nothing -- the file, that instance's pool and the slot are exactly as they were. Replacing the file underneath it would unlink the.db,-wal,-shmand-journalout from under a live connection; closing it instead would be a destructive side effect on an object the caller does not own and cannot report to. Obtain the holder withDbasSqlite.getInstance(dbName: ...)and issue the attach on it, or close it first. A slot held by a closed instance holds no files, so the attach proceeds and leaves it in place streamCopyDb(destDbName)- Stream-copy database to a new name (checkpoints first, so the copy is self-contained — only the main.dbfile is copied)getContent()- Get the raw bytes of the database filecheckpoint()- Fold committed WAL frames into the main.dbfile. Returns aDbasSqliteCheckpointResult(busy,log,checkpointed,isComplete). ReadisComplete(checkpointed == log) —busyis not a success signal: a PASSIVE checkpoint that folds nothing because a reader pins the WAL still reportsbusy: 0andSQLITE_OK. An incomplete fold is recoverable, not an error: the pinned frames fold at the next opportunity
-
SQL Execution
prepareQuery(sql)- Prepare one statement, returns aDbasSqliteStatement. Everything after the first;is silently discarded — no rc, no exception, no log — so a multi-statement string must go toexecuteScriptinsteadexecuteSql(sql, {params, nameParams})- Execute DDL/DML statements with optional positional or named parameters. One statement per call, same limit asprepareQueryexecuteReader(sql, {params, nameParams})- Prepare a SELECT query, returns an independentDbasSqliteReaderexecuteScript(sql)- Run a whole multi-statement script viasqlite3_exec. The only call that executes more than one statement. No bindings (the text must be complete — never interpolate untrusted values), result rows are discarded, execution stops at the first failing statement with everything before it already applied, and there is no atomicity across the script unless you wrap it:await db.transaction((tx) => tx.executeScript(sql)). Returns the last row-changing statement's change count, not a total
-
Transactions
beginTransaction({strict = false})- Begin a new transaction. Idempotent by default: if one is already active this joins it (no second hold on the writer lock).strict: truenever joins — it parks on the writer-lock queue until the active transaction ends, then issues its ownBEGIN. Astrict: truecall from the flow that already owns the transaction is a self-deadlock and fails withwriterLockWaitTimeoutstartedCurrentTransaction- Whether the lastbeginTransaction()issued a realBEGINor took the join path. Read it right after theawait, and use it to decide whether ending the transaction is your job:if (db.startedCurrentTransaction) await db.commit();commit()- Commit the current transaction (idempotent). Pre-flights the writer connection first and throwscommitBlockedByInFlightOperation/commitBlockedByActiveReaderrather than committing out from under an unfinished write or an open writer-routed reader. There is no reference counting — a joiner'scommit()ends the transaction for everyonerollback()- Rollback the current transaction (idempotent). Drains in-flight writes first, then issuesROLLBACKtransaction(action)- Execute an action within a transaction with automatic commit/rollbackisInTransaction- Check if a transaction is currently active
Data Retrieval (DbasSqliteReader)
readRow()- Advance to next row (trueif available,falseand auto-closes when done).falsemeans "no more rows" and nothing else: a reader closed for any other reason -- an explicitclose(), aDbasSqliteStatement.close(),closeDb()'s statement sweep, or an earlier failed step -- throwsreaderClosedDuringScaninstead, so a scan cut short by teardown can never be mistaken for one that ran out of rowsreadRows([amount = 50])- Read up toamountrows in one call. Returns a record({rows, hasMore})whererowsisList<Map<String, ColumnData>>(column name → typedColumnData) andhasMoreis the result of the lastreadRow(true= more rows may follow,false= result set exhausted). It is a plain loop overreadRow(), so it inherits the throw above: a batch cut short by a close raisesreaderClosedDuringScanand the rows gathered so far are discarded, rather than being reported as a completed batch. It never drops a row either: a step that reports a row the cache cannot snapshot raisesreadRowFailedinstead of skipping it and returning the list short (defensive -- no producer currently emits that state)close()- Manually close the reader and release its connection- Column Access (with nullable variants)
getColumnText(index)/getColumnNullableText(index)- Get string valuegetColumnInt(index)/getColumnNullableInt(index)- Get integer valuegetColumnBool(index)/getColumnNullableBool(index)- Get boolean valuegetColumnDouble(index)/getColumnNullableDouble(index)- Get double valuegetColumnDecimal(index)/getColumnNullableDecimal(index)- Get decimal valuegetColumnDateTime(index)/getColumnNullableDateTime(index)- Get DateTime valuegetColumnTime(index)/getColumnNullableTime(index)- Get Duration valuegetColumnEnum<T>(index, values)/getColumnNullableEnum<T>(index, values)- Get enum valuegetColumnBlob(index)/getColumnNullableBlob(index)- Get binary datagetColumnValue(index)- Get typed value based on SQLite column typeisColumnNull(index)- Check if column is NULLgetColumnType(index)- Get column data typegetColumnName(index)- Get column namegetColumnCount()- Get number of columns
Query Information
getLastInsertedId()- Get last inserted row ID
Installation
Add to your pubspec.yaml:
dependencies:
dbas_sqlite: ^2.9.0
Or install with the Dart CLI:
flutter pub add dbas_sqlite
Setup
Mobile & Desktop
No additional setup required. Native libraries are automatically bundled.
Web Setup
The WASM module and Web Workers are bundled as Flutter assets and loaded automatically by the plugin. Requires a modern browser with OPFS persistence (Chrome 86+, Firefox 111+, Safari 15.2+) served over HTTPS or localhost.
Cross-origin isolation is required for the connection pool. The web pool runs multiple Web Workers that share WAL state through a SharedArrayBuffer, which the browser only exposes when the document is cross-origin isolated (crossOriginIsolated === true). Enable it one of two ways:
-
Server headers (recommended for production): serve the app with
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpon the document response. -
coi-serviceworker.js(works on any host, includingflutter run— no header config): the plugin ships this file atweb/libs/coi-serviceworker.js, andscripts/sqlite/sync_sqlite_lib.(ps1|sh)copies it to the example's web root. For your own app, copycoi-serviceworker.jsinto yourweb/folder and load it first inweb/index.html, as a plain (non-async) script before the Flutter bootstrap:<head> <base href="$FLUTTER_BASE_HREF"> <meta charset="UTF-8"> <script src="coi-serviceworker.js"></script> <!-- ...rest of head... --> </head>It registers a service worker that injects the COOP/COEP headers and reloads the page once. For release builds, build with
flutter build web --pwa-strategy=noneso Flutter's own service worker doesn't take the root scope from it.
Why the web root? A service worker can only control pages within its own URL path, and isolation must apply to the root document — so
coi-serviceworker.jsmust be served from/coi-serviceworker.js, not as a deep/assets/packages/...package asset.
If the page is not cross-origin isolated, the plugin logs the reason (channel dbas_sqlite.lifecycle) and falls back to a single Web Worker (one SQLite connection). The app still runs, but loses read/write concurrency — a write blocked behind an open read cursor can fail with SQLITE_BUSY.
Usage
Basic Example
import 'package:dbas_sqlite/dbas_sqlite.dart';
// Get database instance
final db = await DbasSqlite.getInstance(dbName: 'myapp.db');
// Open database (creates pool with WAL mode)
await db.openDb();
// Create table
final createStmt = await db.prepareQuery('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
age INTEGER
)
''');
try {
await createStmt.executeSql();
} finally {
await createStmt.close();
}
// Insert with positional parameters
final insertStmt = await db.prepareQuery(
'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
);
try {
await insertStmt.executeSql(params: ['John Doe', 'john@example.com', 30]);
print('Inserted user with id: ${insertStmt.getLastInsertedId()}');
} finally {
await insertStmt.close();
}
// Query data
final selectStmt = await db.prepareQuery('SELECT * FROM users WHERE age > ?');
try {
final reader = await selectStmt.executeReader(params: [25]);
try {
while (await reader.readRow()) {
final name = reader.getColumnText(0);
final email = reader.getColumnNullableText(1);
final age = reader.getColumnInt(2);
print('$name ($email) - age: $age');
}
} finally {
await reader.close();
}
} finally {
await selectStmt.close();
}
await db.closeDb();
Statement Reuse
A prepared statement can be executed many times with different params — the bind buffer is replayed against a fresh native handle on each call:
final stmt = await db.prepareQuery('INSERT INTO users (name) VALUES (?)');
try {
for (final name in ['Alice', 'Bob', 'Carol']) {
await stmt.executeSql(params: [name]);
}
} finally {
await stmt.close();
}
Named Parameters
final stmt = await db.prepareQuery(
'INSERT INTO users (name, email, age) VALUES (:name, :email, :age)',
);
try {
await stmt.executeSql(nameParams: {
'name': 'Jane Smith',
'email': 'jane@example.com',
'age': 28,
});
} finally {
await stmt.close();
}
// By default, extra named parameters not present in the SQL are silently
// ignored (C#/SQLite behavior). To throw instead:
db.throwOnMissingNamedParams = true;
Rich Types
import 'package:decimal/decimal.dart';
// Supports Decimal, bool, DateTime, Duration, Enum and Blob
final insStmt = await db.prepareQuery(
'INSERT INTO products (name, price, active) VALUES (?, ?, ?)',
);
try {
await insStmt.executeSql(
params: ['Widget', Decimal.parse('19.99'), true],
);
} finally {
await insStmt.close();
}
final selStmt = await db.prepareQuery('SELECT name, price, active FROM products');
try {
final reader = await selStmt.executeReader();
try {
while (await reader.readRow()) {
final name = reader.getColumnText(0);
final price = reader.getColumnDecimal(1);
final active = reader.getColumnBool(2);
print('$name - \$$price (active: $active)');
}
} finally {
await reader.close();
}
} finally {
await selStmt.close();
}
Transactions
// Automatic commit/rollback
await db.transaction((tx) async {
final stmt = await tx.prepareQuery('INSERT INTO users (name) VALUES (?)');
try {
await stmt.executeSql(params: ['Alice']);
await stmt.executeSql(params: ['Bob']);
} finally {
await stmt.close();
}
});
// Manual control
await db.beginTransaction();
try {
final stmt = await db.prepareQuery('INSERT INTO users (name) VALUES (?)');
try {
await stmt.executeSql(params: ['Alice']);
} finally {
await stmt.close();
}
await db.commit();
} catch (_) {
await db.rollback();
rethrow;
}
// A helper that may or may not be called inside someone else's
// transaction: begin, then only end what you actually started.
await db.beginTransaction();
final owned = db.startedCurrentTransaction; // read before the next await
try {
// ...writes...
if (owned) await db.commit();
} catch (_) {
if (owned) await db.rollback();
rethrow;
}
commit() pre-flights the writer connection: every write started inside
the transaction must be awaited, and every reader opened inside it (once
the transaction has written, reads route to the writer connection for
read-your-writes) must be closed, before commit() is called. Otherwise
it throws commitBlockedByInFlightOperation / commitBlockedByActiveReader
and leaves the transaction untouched — committing there would end the
transaction and hand the writer lock to the next waiter while that work
is still running on the connection. Neither error is transient: finish
the work, then commit again. rollback() does not throw for this — it
drains in-flight writes and then rolls back, because it is also the
cleanup path used by closeDb().
Multi-Statement Scripts
prepareQuery / executeSql run exactly one statement — everything
after the first ; is dropped at prepare time, silently and with a
success return. Use executeScript for anything that holds more than
one statement (runtime DDL plus its indexes, a migration step, a pragma
block):
// Not atomic on its own — wrap it when it must be all-or-nothing.
await db.transaction((tx) => tx.executeScript('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
total TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_orders_user ON orders (user_id, id);
PRAGMA foreign_keys = ON;
'''));
The script takes no bindings (sqlite3_exec has no bind surface), so
build parameterised SQL with prepareQuery instead and never
interpolate untrusted values into a script. Result rows are discarded —
a SELECT in a script runs and yields nothing. Execution stops at the
first statement that fails, and every statement before it has already
run; outside a transaction those are already committed and nothing can
take them back. The return value is the change count of the last
row-changing statement, not a total for the script.
WAL & Checkpoints
Every commit folds the WAL into the main .db file, and closeDb() /
streamCopyDb() fold on their own, so committed data is in the file
that a copy or backup reads. When you read, copy or ship the .db file
by other means, checkpoint explicitly and check the result:
final result = await db.checkpoint();
if (!result.isComplete) {
// A reader is pinning a WAL snapshot: `result.log - result.checkpointed`
// frames stay in the -wal and fold at the next opportunity. Nothing
// committed is lost, but a copy of the main .db file alone would be
// missing them right now — close in-flight readers and try again.
}
isComplete (checkpointed == log) is the only honest test.
result.busy is not: a PASSIVE checkpoint that folds nothing still
reports busy: 0 and success. checkpoint() throws
checkpointInsideTransaction if a transaction is open — SQLite refuses
to checkpoint a connection holding one, so it would fold nothing.
Connection Pool
// Default: 4 readers (WAL mode)
await db.openDb();
// Custom pool size
await db.openDb(readerPoolSize: 8);
// Single connection (no pool)
await db.openDb(readerPoolSize: 0);
Stream Copy
// Copy database to a backup
await db.streamCopyDb('myapp_backup.db');
Error Handling
All public API calls on DbasSqlite, DbasSqliteStatement, and
DbasSqliteReader throw a single DbasSqliteException on failure
(implements Exception). It carries:
DbasSqliteErrorCode code— stable, one unique value per throw site (e.g.bindPositionalFailed,statementClosed,transactionAlreadyActive). Useful for telemetry IDs and test assertions.int? sqliteCode— SQLite primary result code (e.g.19forSQLITE_CONSTRAINT,5forSQLITE_BUSY).nullfor Dart-side failures.int? sqliteUniqueCode— SQLite extended result code, the unique discriminator within a primary code (e.g.2067forSQLITE_CONSTRAINT_UNIQUE,787forSQLITE_CONSTRAINT_FOREIGNKEY).nullwhen the platform didn't queue an extended rc.String message— human-readable description.Object? cause/StackTrace? causeStackTrace— non-nullwhen the exception wraps an underlying failure (the rollback-failed paths ofrollback()/commit()/transaction()).
Two derived enums make recovery branching ergonomic:
DbasSqliteErrorCategory(coarse) —notOpened,busyOrCancelled,prepareFailed,executeFailed,bindFailed,transactionFailed,readerStateFailed,decodeFailed,internal.DbasSqliteSubCategory(fine, SQLite-aware) — derived fromsqliteUniqueCode ?? sqliteCode, so the extended rc wins over the primary. Maps to semantic values:databaseBusy,tableLocked,duplicatedData(UNIQUE/PRIMARY KEY/ROWID violations),foreignKeyViolation,notNullViolation,checkViolation,corruptDatabase,diskFull,readOnlyDatabase,rangeError,valueTooLarge, and ~20 more.
try {
await stmt.executeSql(params: ['alice@example.com']);
} on DbasSqliteException catch (e) {
switch (e.subCategory) {
case DbasSqliteSubCategory.duplicatedData:
// Email already exists — show "use a different address".
break;
case DbasSqliteSubCategory.foreignKeyViolation:
// Referenced row missing — abort or repair.
break;
case DbasSqliteSubCategory.databaseBusy:
// Transient — retry after a backoff.
break;
default:
// Inspect e.code, e.sqliteCode, e.sqliteUniqueCode, e.message,
// and e.cause for the rest. For wrapped failures
// (transactionRollbackAlsoFailed), e.cause holds the original
// exception with its own code/subCategory.
rethrow;
}
}
DbasSqliteStatement also exposes getLastErrorCode() and
getLastUniqueErrorCode() — int? accessors parallel to the
existing getLastError() — populated after every executeSql /
executeReader. Useful when an exception is routed through a
generic handler and the statement is later inspected for telemetry
without rethrowing.
openDb() is idempotent — a second call on an already-open
instance is a no-op. Calling it with a different readerPoolSize
than the original throws DbasSqliteException with code
openDbReopenWithDifferentPoolSize.
openDb() also joins a closeDb() that is still in flight, before
its own idempotency check — an open that raced a teardown used to call
createPool on a file whose previous pool was mid-destruction. One
consequence to plan for: a teardown that fails now surfaces its
error from openDb() rather than only from closeDb(). That is the
safe reading, since the previous connection is still open and still
marked closing, so the open could not have succeeded anyway. It does
not arbitrate an open that was already past that line when the close
started.
Minimum Platform Versions
| Platform | Minimum Version |
|---|---|
| Android | API 35 |
| iOS | 16.0 |
| macOS | 13.0 (Ventura) |
| Linux | x86_64 |
| Windows | x86_64 |
| Web | Modern browsers with OPFS support |
Platform Notes
- iOS/macOS: Uses xcframework for optimal performance
- Android: Native library automatically included (NDK r29)
- Windows/Linux: Dynamic libraries bundled with app
- Web: WASM module runs in a dedicated Web Worker with OPFS persistence. All heavy operations are serialized through the worker — column data is cached in Dart memory after each
readRowfor synchronous access
License
This project is licensed under the terms specified in the LICENSE file.