dbas_sqlite 2.9.0
dbas_sqlite: ^2.9.0 copied to clipboard
Flutter plugin that access SQLite for Android, iOS, macOS, Linux, Windows and Web.
Changelog #
All notable changes to this project will be documented in this file.
2.9.0 - 2026-07-30 #
A closeDb()-safety release. Teardown destroys a connection, a pool
and every sqlite3_stmt hanging off them, and it was doing that while
live calls were still inside native code holding exactly those
resources. Two independent blind spots, one lifecycle step apart, and
neither was reachable from the other's fix:
- a read still inside
executeReader's prepare window — it holds a connection and a statement but is not yet a reader, so the statement sweep cannot see it at all; and - a read that already is a reader, suspended in
readRow()with a step dispatched against its statement — the sweep sees it, and finalizes the handle out from under the step.
Both are fixed below. The second one changes what a consumer observes when a scan is cut short, deliberately — see Changed.
They are also the same mistake twice: a latch meaning "close has
STARTED", read as "close has FINISHED". A sweep for that one shape
found it live in eight more places — a retiring reader's cleanup,
commit()'s pre-flight, the instance map's ordering, dropDb(), the
attach guard, and three on web. Each is fixed below on its own terms rather than behind a
shared abstraction: the layers involved share no type, and a unifying
interface buys nothing the call sites cannot already express. Alongside
them, three defects of other shapes that teardown could not survive
either — a transaction that opens during the drain and is then left
open, a writer-lock flag cleared by something that never owned it, and
an attach that destroyed the database it was replacing.
What this release does not cover — one defect that lives in a different repository, three fixes that ship without tests, three defensive guards or invariants no test can drive, one platform- conditional test, and one window left deliberately open — is under Not covered rather than left to be found later.
Fixed #
-
closeDb()could walk straight past a read that was already inside native code — deadlocking on the pool route and corrupting memory on the writer route. One blind spot, two consequences, decided only by which connection the read had been routed to.closeDb's statement sweep relies onstmt.close(), andDbasSqliteStatement.close()awaits the active reader — but_activeReaderis not assigned until the very end ofexecuteReader. A read suspended anywhere between acquiring its connection and that assignment therefore has_activeReader == null, soclose()returned having awaited nothing while already latching_closed, and_activeStatements.clear()then disowned the statement outright. The read was holding a checked-out pool reader (or the writer) and a livesqlite3_stmtthe whole time.What happened next depended on the route, and both outcomes were fatal. On a pool reader: nothing ever called
PoolReleaseReaderfor it — the caller's ownfinally { await stmt.close(); }hitif (_closed) returnand cascaded to nothing — soClosePool, which blocks until every checked-out reader is back, waited forever andcloseDb()never returned. On the writer (read-your-writes: inside a transaction, after a write — the shape a migration produces) there was no such protection at all: the C header is explicit that "the writer is NOT checkout-tracked (it has no release call)", soClosePoolproceeded and force-closed it throughcloseDbCore(force=true), finalizing the livesqlite3_stmtand freeing theSQLiteDb. The readerexecuteReaderthen handed its caller pointed at freed memory, and the nextreadRow()/close()on it was a SIGSEGV that kills the process rather than raising anything catchable.rollback()did not cover it either: its drain waits only on operations that registered a dispatch, and reads deliberately register none.A fix covering only the pool route would clear the deadlock and leave the segfault fully alive, so the routes are now covered by construction rather than by two parallel special cases: a connection-wide native-operation registry, with the read's registration taken at
executeReader's entry — synchronously, before the firstawait, and above theuseWriterrouting branch. One registration, both routes, and a future third route inherits the cover automatically.closeDb()drains that registry afterrollback()and before the statement sweep; the ordering is load-bearing in both directions, because the drain returns once each operation has handed its resources to an owner the sweep can find, and the sweep is what then closes the reader and returns its pool connection.The registry generalises the existing, already-proven
_ReentrantWriterOpmachinery, which solves "wait for work that crossed into native code" for the rollback case but is scoped to reentrant writer operations and therefore never saw the pool reads at all.executeSql,executeScript,beginTransaction,commit,rollback,vacuum,enableWal,checkpoint(), the WAL foldstreamCopyDb()runs before it reads the file, andsetBusyTimeout— the whole call, writer pragma included, not just its reader loop — register too. Two bodies of native work stay out, and each is covered by something else:- the PASSIVE checkpoint
closeDbruns on its way out (takeWriterLock: false, which already means "I am teardown") is reached only after the drain, so registering it would make teardown wait on itself; and - everything a
DbasSqliteReaderdoes —readRow's steps, and thefinalizeStmt+poolReleaseReaderin itsonClose. Covered instead by the reader's own step drain and by the statement sweep, which reaches a reader through its statement and joins a close that is already in progress rather than reading a synchronously-latchedisClosedas "nothing to do". The next entry is where both halves are spelled out.
- the PASSIVE checkpoint
-
closeDb()finalized asqlite3_stmtwhile the reader that owns it had a step in flight against that exact handle. The sibling of the blind spot above, one step later in the same lifecycle: there the read had not become a reader yet and the casualty was the connection under it; here it is a reader —_activeReaderpoints at it and the sweep can see it — and the casualty is the statement inside it. Pre-existing, and not something the registry above covers.A consumer suspended in
await reader.readRow()is a worker-isolate dispatch against a livesqlite3_stmt.readRowregisters nothing with the native-operation registry, so_drainNativeOpsfinds it empty andcloseDbwalks straight into its statement sweep:stmt.close()→reader.close()→onClose→finalizeStmt, on the handle the step is using._dispatchpicks the least-loaded worker, so the step and the finalize genuinely land on two different OS threads.The C layer neither refuses nor blocks — it corrupts.
FinalizeStmthas no busy check, no refcount and no step-in-progress flag;ReadRowresolves the pointer, dropsdb_stmts_lock, then runssqlite3_stepand writess->affectedRows/lastInsertedId/lastErrorunlocked.resolve_stmt's own contract says the pointer is stable only "so long as the caller does NOT concurrently FinalizeStmt the same handle from another thread." Widening the C lock is off-design —db_stmts_lockis documented as held only briefly and never across a step. AndSQLitePool.activeOpsprotects the connection, not the statement, so the pool-route protection above buys nothing here: the reader is checked out and will be released; the corruption is on the statement inside it, before release.The reader now serialises its own teardown against its own steps.
readRowpublishes the step it dispatched before suspending on it, andclose()waits for every published step — ignoring their results, which belong toreadRow— before it letsonClosefinalize anything. That covers every route by construction, for every caller: an explicitreader.close(),DbasSqliteStatement.close(),executeScalar'sfinally, andcloseDb's sweep all funnel through the one method.closeDbwaits for the steps, exactly as it already waits for one dispatch anywhere else.The published steps are held in a set, not a single slot. Nothing rejects two un-awaited
readRowcalls on one reader, and a single slot would be overwritten by the second — leaving the first still insidesqlite3_step, referenced by nothing, and finalized out from under it by aclose()that believed it had drained. That is the sameFinalizeStmt-under-sqlite3_stepcorruption, reached through the fix, and_dispatchpicks the least-loaded worker with no per-handle affinity, so the two steps do not even share an OS thread. ConcurrentreadRowon one reader remains a useless shape — the calls race the row cache, so each may read the other's row — but nothing rejects it, so it must not be able to corrupt memory.Extending the native-operation registry to cover
readRowwas rejected rather than overlooked: an open cursor would register a fresh operation per row, which would blockcloseDbfor a whole table scan (the drain only ends when the registry is empty) and make self-deadlock newly reachable from anycloseDb()issued inside areadRowloop. The wait added here is unbounded on purpose: a timeout could only expire into finalizing the handle anyway, and throwing instead would leaveonCloseunrun — statement never finalized, pool reader never released — which wedgesClosePooljust as hard with less information. Unbounded is not allowed to mean silent, though, so the wait reports how long it has been waiting and for how many steps everyDbasSqliteReader.kStepDrainStallReportMs(5 s) until they hand back — the one wait in the library that no timeout will ever surface. WireDbasSqlite.onDiagnosticto actually receive that report; see Added. -
closeDb()'s statement sweep skipped a close that was already in progress, which turned a self-healing race into a permanent hang.DbasSqliteReader.close()latchesisClosedsynchronously and then suspends — on its step drain, and again ononClose, whosefinalizeStmtis a real worker dispatch. For that whole stretch a reader reports itself closed while still holding a checked-out pool connection and a livesqlite3_stmt, and the sweep read that flag as "nothing left to do":DbasSqliteStatement.close()skipped a reader that was alreadyisClosed, and returned immediately once its own_closedwas set._activeStatements.clear()then disowned the statement outright, nothing released the pool connection, andClosePool— which blocks until every checked-out reader is back — waited forever.closeDb()never returned, and there is no timeout on that path: the registry drain has already run by then, and the reader's step drain is unbounded by design.The live shape is
unawaited(reader.close())(orunawaited(stmt.close())) followed bycloseDb()— logout while a list view is mid-scan, the same shape this release is named for.DbasSqliteStatement.close()is now join-idempotent, exactly likeDbasSqliteReader.close()already was: concurrent callers observe one completion future, so the sweep waits for an in-progress close instead of walking past it, and it no longer conditions onisClosedat all.Independently, the pool pointer is now retained across the destructive dispatch instead of simply dropped before it. The pool struct lives until
ClosePoolreturns, andPoolReleaseReaderis exactly the call it is blocked waiting for — so a reader release arriving in that window has to find a live pointer. Dropping it turned that release into a silent early return, which is the same hang by a second route. The public field is still cleared before the dispatch, which is what keeps a second concurrentcloseDb()from dispatchingclosePoolon the same pointer twice and stops a new read routing into a pool that is going away; only the release path sees the retained copy. (The writer handle is dropped outright: nothing is waiting on it, andisOpened()/getTotalChanges()must not hand a being-freed pointer back into FFI.) -
A second concurrent
closeDb()reported success while the pool was still being destroyed — and cleared the retained pool pointer on its way past. By the time the first call is suspended insideclosePool, every step ofcloseDb()short-circuits:rollbackon!_isInTransaction, the registry drain on an empty registry, the statement sweep on an already-cleared statement set, the PASSIVE checkpoint on!isOpened(). So a second call walked the whole method in a handful of microtask turns — against a worker-isolate round trip — and both told its caller the database was closed when the pool was mid-destruction (a lie a caller may act on:dropDb()on a live pool) and re-ran the capture-and-null with a null pointer, wiping_closingPoolPtrout from under the first call for essentially the wholeclosePoolwindow. A reader release arriving there then found both fields null and returned silently, which is exactly the unbounded hang the retained pointer exists to remove.closeDb()is now single-flight, mirroringopenDb(): a second call joins the teardown in flight and returns with its outcome, error included. Independently, only the branch that actually captured a pointer publishes one, so the invariant does not depend on the join alone. (The capture-and-null itself was already correct — it is atomic against the event loop, so only one caller can ever dispatchClosePool.) -
setBusyTimeout()andenableWal()could register with the native-operation registry aftercloseDbhad already drained it, and then reach native code on a connection being torn down. The drain runs once. Most registry callers are saved by a second gate —_acquireWriterLockand_acquireReaderSlotboth reject while closing — but these two had neither, and "is the database open", their only other guard, is still true at every one ofcloseDb's post-drain suspension points (the statement sweep, the PASSIVE checkpoint, theclosePoolawait).setBusyTimeout()acquires pool readers directly, bypassing the Dart-side semaphore; arriving after the drain it held readersClosePoolwas already blocked on, and its release loop then read a_poolPtrthatcloseDbhad nulled — aTypeErrorthrown out of afinally, leaking every reader it had acquired.enableWal()dispatches the journal-mode switch and both writer pragmas — several worker round-trips on the writer connection — whileclosePoolis on its way to force-closing that same writer viacloseDbCore(force=true). Measured during teardown, it registered after the drain had completed and succeeded.Both now reject while the database is closing:
setBusyTimeout()withreaderSlotWaitCancelledandenableWal()withwriterLockWaitCancelled— the code each one's own gate would have used.setBusyTimeout()additionally holds the pool pointer in a local so its release loop cannot observe a nulled field, and guards eachpoolReleaseReaderindividually: that call is synchronous FFI, and one throw used to abort the loop and strand every reader after it, which is the same leak through a different door. -
A retiring reader's cleanup erased a SUCCESSOR's claim on the statement, and teardown then disowned a live cursor. The reader teardown closure in
executeReadercleared_activeReaderunconditionally, on the reasoning that the reader being closed is the one in the slot. It is not — andexecuteReader's own admission guard is what makes it not. That guard readsisClosed, which latches synchronously at the top ofclose(), so while a predecessor is still draining its steps and still inside this very closure'sfinalizeStmtand release round trips, a secondexecuteReaderon the same statement is legitimately admitted and publishes itself into_activeReader. The predecessor's cleanup then ran and cleared it.What the successor lost was the only slot anything can reach it through.
closeDb's statement sweep resolves a reader through its statement, so it foundnull, closed nothing, and_activeStatements.clear()disowned the statement outright — and the two routes then fail the two ways this release is named for. On the pool route the successor's checked-out reader was never released, soClosePoolblocked forever with no diagnostic. On the writer routecommit()'s pre-flight saw a quiescent writer and could commit out from under a live cursor, andClosePool(force)finalized a livesqlite3_stmt.Admitting the successor is correct — serialising close-then-requery instead was considered and rejected, since it makes an un-awaited
close()block the next query on a worker round trip — so the fix belongs on the cleanup, which is now identity-guarded: it clears the slot only while the slot is still its own claim._activeReaderand_activeReaderUsesWriterclear together, under the one check. Guarding only the first would leavehasOpenWriterReaderInternalreportingfalsefor a live writer-bound successor, which is the same badCOMMITby a shorter path. -
commit()'s pre-flight reported the writer quiescent while a writer-bound reader still had a step running on it, and then handed the writer lock on.hasOpenWriterReaderInternal— the predicate that decides whether aCOMMITis safe — readDbasSqliteReader.isClosed, andisClosedlatches the instant a close starts. Its justification was explicit, and it proved the wrong thing: a statement leaves_activeStatementsonly on the last line ofclose(), andclose()first awaitsreader.close(), "so this getter is alreadyfalsebefore the statement can leave the set". All that establishes is that the getter goesfalseearly — and for a predicate whosefalseauthorisesCOMMIT, and the_releaseWriterLock()after it that hands the writer to the next FIFO waiter to prepare, bind, step and finalize on, early is precisely the failure the refusal's own message describes. TheCOMMITdispatched concurrently with the step, on the same connection.The predicate no longer consults a close-start latch at all. It reads
_activeReader != null && _activeReaderUsesWriter— the SLOT, which already carries the drain-complete fact:_activeReaderis cleared in exactly one place, the identity-guarded clear at the end ofexecuteReader'sonCloseclosure, which runs after that closure has read its counters, finalized thesqlite3_stmtand released the connection, and which the reader's_doClosereaches only once its in-flight steps have drained. So the slot empties exactly when native code is finished with the reader, never before. Late is survivable in a way early is not: a caller that getscommitBlockedByActiveReadercloses its reader and commits again, with nothing disturbed.An earlier version of this fix added a second flag on the reader (
isFullyClosedInternal, set at the end of_doClose) and the predicate consulted it as well. That flag has been removed: it could only ever lag the slot clear, never lead it, so_activeReader != null && _activeReader!.isFullyClosedInternalwas unreachable by construction and no run could distinguish the two forms. Three separate mutations of it — replacing its term withtrue &&, deleting the assignment, and moving the assignment off thefinally— each left the whole suite green, while reverting the predicate toisClosedstill fails the twocommit()tests. What those tests pin is "do not consult a latch that flips at close-start", and the slot satisfies that on its own. Thefinally-placement argument that used to appear here described a wedge — "would refuse every latercommit()for the lifetime of the statement" — that cannot occur, sinceonCloseclears the slot before it rethrows. A mechanism advertised as live and inert by construction is removed rather than annotated, the same call made for thestmtCloseFailurescounter below. -
closeDb()could return SUCCESS with a transaction still open and committed frames stranded in the-wal. Teardown rolls back as its first step and then drains the native-operation registry — butbeginTransactionpublishes_isInTransactiononly after itsBEGINround trip returns, and that round trip is exactly what the drain waits for, not that rollback. So abeginTransactionalready granted the writer lock when_closinglatched completes during the drain, and teardown resumes into a transaction that did not exist when it looked. The WAL fold then early-returns (SQLite refuses to checkpoint a connection holding a transaction), socloseDb()reported success with committed frames still sitting in the-wal, and theROLLBACKthat transaction needed was never issued at all._performClosenow re-checks_isInTransactionafter the drain and rolls back again. A check-and-retry, not a loop, and that bound is provable rather than assumed: onlybeginTransactionever sets the flag, it does so holding the writer lock, that lock is exclusive,_closingrejects every new acquire, and the writer wait queue was already cancelled — so at most one straggler can exist, and once this rollback clears the flag nothing can set it again. -
Teardown cleared the writer-lock-held flag under a live holder, so two callers could end up owning the writer lock at once.
_cancelWriterWaitQueuerejects everyone parked on the FIFO queue, which is its job; it also cleared_writerLockHeld, which it does not own. The holder is not a queue entry — a granted acquire leaves the queue empty — so what that line cleared was abeginTransactionorexecuteSqlgranted the lock before_closinglatched and still inside its dispatch.Inert for as long as
_closingrejects every acquire, and live the moment_performOpenclears it: a new acquire is granted while the pre-close holder still believes it owns the lock, and that holder's eventual_releaseWriterLock()then hands the lock to a second waiter. Two concurrent owners, and the Dart-side writer serialization is void._cancelWriterWaitQueueno longer touches the flag;_performOpenresets it instead — the one place a reset is sound, because it runs on a connection that is not open, where no holder it could disown exists. It mirrors the_readerSlotsAvailablereset beside it. Both halves were needed, and fixing either alone masks the other: without the reset, a hold nothing released is carried across the whole close and inherited by the next connection, where every writer queues behind a holder that cannot exist any more, onekWriterLockWaitTimeoutMsat a time. -
The per-name instance map was maintained BY KEY where every writer of it meant BY OBJECT, so
closeDb,attachDbandattachStreamDbcould each evict a live instance that was not theirs._instance.remove(dbName)is "remove me" only while the slot is still the caller's claim — and a stale reference (an instance whose owncloseDb()already released the slot, which stays a perfectly usable Dart object and is exactly what a consumer that cached one holds) arrives with nothing of its own left to release. A redundantcloseDb()on one evicted a live successor, leaving the nextgetInstanceto build a third object over that successor's pool. The attach paths were worse: they drove both halves off the map rather than off the receiver, so_instance[dbName]!.closeDb()tore down whatever the map currently held and the removal after it fired unconditionally. Every removal is now identity-guarded, and both halves of the attach precheck namethis.closeDb()also published the instance as gone BEFORE it destroyed the pool. AgetInstancearriving in that window built a secondDbasSqliteover a file whose pool was still being destroyed, and itsopenDb()would then callcreatePoolon it:POOL_ALREADY_ACTIVEon web, and on native two C pools coexisting over one file, each with its own writer and its own independent Dart writer lock, both driving the one static platform delegate (DbasSqlitePlatform._delegateis never cleared on close). The removal now happens after the destructive dispatch, so that window resolves to the instance that is closing — the truthful answer, which is that this database is not gone yet. It is deliberately not in afinally: a teardown that throws leaves the connection alive and asks the caller to await the outstanding work and close again, so the instance has to stay discoverable for exactly that retry.Moving the removal does not cover this alone — it changes which object races the destruction, not whether one does — so
openDb()now joins an in-flight teardown before anything else, its own idempotency check included.isOpened()was never an admission check against a close:_performClosenulls_dbbefore awaiting the destructive dispatch, deliberately, so it readsfalsefor that whole window while the C pool is still alive and still holding all three files. The two are halves of one fix — with no open able to complete while a teardown is in flight, an instance cannot be open again at the moment its own teardown releases the slot, which is what makes the identity guard sound. That join also changes whatopenDb()can throw; see Changed. -
dropDb()deleted the.db,-waland-shmwhile acloseDb()was still tearing the pool down. It sampledisOpened(), which means "teardown has started finishing", not "the connection is gone":_dbis nulled before the destructive dispatch is awaited, soisOpened()readsfalsefor that entire window while the C pool is still folding the-waland still holding all three files open. A drop landing there skipped the close outright and unlinked them underneath — a pool checkpointing on into an inode with no name where the unlink succeeds, and a raisedFileSystemExceptionwith no drop performed where the platform refuses to delete an open file.It also closed a second route into the reader-checkout hang two entries above:
DbasSqlitePlatform.dropDbremoves this database's delegate, so apoolReleaseReaderarriving afterwards threw a rawTypeErrorthat the reader'sonCloseswallows — checkout never returned,ClosePoolblocked, no diagnostic anywhere.dropDb()now joins_closingDb, the marker that is non-null for exactly that window and no other, and a teardown that fails propagates its error out ofdropDb()rather than being swallowed: a close that could not finish may still hold the files.isOpened()survives as a re-check after the join rather than theelseit used to be — reading it once before the join would be the same defect one line up, a decision taken from a sample the wait itself invalidates — and the path resolve is hoisted above the join, so no suspension point is left between the re-check and the deletion: leaving it where it was left anawaitinside the very gap the re-check exists to close, which is half a fix. That hoist is an argued invariant, not a covered one, and is recorded as such: moving the resolve back below the re-check leaves the whole suite green, because exploiting the gap needs anopenDb— acreatePoolround trip across a worker isolate — to COMPLETE inside a path resolve, and there is no seam over that resolve to hold one there. A test for it would be a race against the machine rather than a property of the code.attachDb/attachStreamDbcarry the same placement, for the same reason and with the same caveat. One pass, not a loop: unlike the post-drain rollback above there is no termination argument to be had here, becauseopenDbis public and ungated — see Not covered. -
An attach that failed partway destroyed the database it was replacing.
attachDbandattachStreamDbdeleted the destination first and wrote to the real path afterwards, so fromdropDbuntil the last chunk landed there was no database at that path — only an absence, and then a fragment. The length of that window is the caller's to decide, not this library's: the stream is caller-supplied, and where it is network-fed (the app's login path is) the window is the whole download. A source that failed partway therefore destroyed the database it was replacing and left a truncated file in its place, with nothing left to retry against.Both now write a sibling temp, flush it, close it, and only then drop the destination and rename the temp onto it — so a failed attach is a no-op. Sibling is load-bearing rather than tidy:
renameis atomic only within one filesystem, and a temp in a system temp directory would silently degrade into a copy, reopening exactly the window the temp exists to close. The scratch suffix is deliberately not onedropDbsweeps, becausedropDbruns between the write and the rename and would otherwise delete the replacement one line before it is swapped in.attachDb's window was much smaller —writeAsBytesover an in-memory buffer, not a download — but not zero, and not zero-consequence:writeAsBytestruncates before it copies.Keeping the destination in place and restoring the previous bytes on failure was rejected: it needs the whole previous database held somewhere for the duration — which is the temp file again, only now on the failure path where it is least affordable — and it still loses the database if the process dies mid-restore.
streamCopyDbstill deletes its destination first, and may: its source is a local file already settled by a checkpoint before any deletion happens, so nothing that can still fail decides the outcome. The asymmetry is in the source, not in the deletion order. A residual remains and is stated rather than papered over: betweendropDbreturning andrenamecompleting the old database is gone and the new one is not yet in place. Nothing that can take time happens there — the payload is already written and fsynced — so it is one metadata operation rather than a transfer, but closing it entirely needs a rename that is also a delete of the SQLite side files, which no filesystem offers as one operation.Native only. The web implementation is unchanged and cannot be fixed from this repository; see Not covered.
-
Web: three places where a pool that had STARTED closing reported itself already closed. The same shape as the native fixes above, with a rejected worker RPC rather than a use-after-free at the end of it — which lowers the severity, not the diagnosis.
DbasSqliteNativeWebreported the pool quiescent while afinalizeStmtround trip was in flight.finalizeStmtremoves the statement from_stmtsfirst — that removal is what makes a double-dispatch impossible, so it has to stay — and only then awaitsstreamFinalize, leaving the finalize in neither counter for the whole await: out of_stmts, and never in_inFlightCalls, because finalize is deliberately ungated (gating it is the deadlock that ungating avoids — the destructive op holds the gate while waiting for the finalize). A destructive op arriving there found_poolQuiescenttrue and skipped its drain outright, closing the pool underneath the finalize. Message ordering was the previous defence and is not sufficient: it covers only the order the worker processes two messages in, says nothing about the Dart-side pending RPC the close rejects regardless, and nothing at all about the skipped drain. A new_inFlightFinalizescounter — incremented synchronously after theremove, with noawaitbetween the two, so no instant exists at which the pool looks quiescent — closes it. Leaving the entry in_stmtsuntil the finalize completes was rejected: two concurrentfinalizeStmt(handle)calls would then both dispatch for the same cursor, the second landing on one the worker had already released.DbasSqliteWebReaderPool.close()is now join-idempotent. It wasif (_closed) return;, and_closedlatches at the top of the close, so a second closer —_teardownLivePoolrunning on a sibling shim instance that shares the pool object — returned while the first close was still tearing workers down, and was then free to boot a replacement against OPFS files the outgoing workers had not released. ItsstreamFinalizecarried the same guard under the comment "workers gone; statements implicitly finalized" — false for the whole duration of a close, with the workers alive and the cursor still open. It joins the close future instead, which makes the old comment true at the moment it is given.- The same fix in the single-worker fallback,
DbasSqliteWebPool.finalizeStmt, which now joins the existing_closingbarrier instead of answeringFuture.value()while the worker is still alive with the statement still open.
All three ship without automated tests; see Not covered.
-
A failure inside the reader's teardown closure went only to
dart:developer, which reaches nobody where it matters. That closure runs three separate sequentialtryblocks — read the per-statement counters, finalize thesqlite3_stmt, release the connection — and each loses something different and unrecoverable, all of it reported to a sink that is discarded whenever no VM service client is subscribed, i.e. every release build on a device and everyflutter testrun. All three now report throughDbasSqlite.onDiagnosticas well, each naming the specific resource or result that was lost: which accessors are now permanently stale and why they can never be obtained again for that execution; that a handle is leaked for the lifetime of the process and that on the pool path this report is the only signal there is; or, for a failed release, that a pool reader stays checked out andClosePoolwill block on it, or that the writer lock is held forever. "onClose failed" is not a diagnosis.developer.logis kept alongside because it is the only sink that carries the error object and its stack, and only the first failure's stack survives the rethrow at the end of the closure. -
A reader-slot acquire given the C layer's documented non-blocking budget parked on an unbounded wait instead of failing.
timeoutMs <= 0is the non-blocking form on the C side, andacquireReaderConnectionInternalpasses it straight through topoolAcquireReaderBlockingbecause it is — so the Dart gate one layer above reading the same value as "wait, with no deadline" gave one parameter two opposite meanings in adjacent layers, and the meaning that won was the one that never returns. The deadline timer was also the only thing that could ever fail a queued waiter, and it was installed only for a positive budget: a caller that asked not to wait at all joined the queue with nothing able to fail it, and could be completed only by an unrelated slot release or by teardown's queue cancellation.It now throws
readerSlotWaitTimeoutimmediately — the same failure an expired deadline raises, because it describes the same fact: no slot was available inside the window the caller allowed — and the timer is installed unconditionally, so no waiter can queue without one. Latent in production:kPoolAcquireTimeoutMsis aconstand the only route to a non-positive budget is the test-onlydebugPoolAcquireTimeoutMs. It is fixed anyway because a Dart layer contradicting a documented C convention is a defect waiting for its first caller, and because thePoolAcquireStatuscoverage below needs a probe that fails rather than hangs. -
readRows()dropped a row instead of reporting it. A row whose column set came back null was skipped withcontinueand the list returned short — the silent truncation the rest of this release exists to prevent, inside the method that documents it cannot happen. It now raises. No producer currently emitsSQLITE_ROWwith a null column set, so this is defensive; a defence that silently loses a row is worse than none. -
closeDb()handed the writer pointer back into FFI while it was mid-free.closePool/closeDbare dispatched to a worker isolate, so the main isolate keeps running across them — and anything reading_dbin that window (isOpened(),getTotalChanges(),getDbFileName()) called straight into native code with a pointer the C side was in the middle of destroying. The handle is now captured into a local and the field nulled before the destructive dispatch is awaited, so a concurrent caller observes a closed connection — a state it already handles — instead of a freed one. The pool pointer is deliberately the other way round; see the sweep entry above for why. -
The attach guard was the fifth
isOpened()-as-"the connection is gone" site, and the only close-admission decision left that did not join_closingDb._releaseInstanceSlotForAttach— shared byattachDbandattachStreamDb— readisOpened()alone, and that predicate readsfalsefor the whole destructive window while the C pool still holds all four files. A close parked in its dispatch therefore skipped the guard's own close, found the slot still its own, removed it mid-teardown, and handed the file to the platform attach — whose first act is a file-leveldropDb, i.e. four unlinks under a live pool. On POSIX the unlink succeeds and the closing pool keeps writing into an inode with no name, re-creating sidecars beside the newly attached database: a foreign-wal, which opens with no error and silently serves another database's rows while passingintegrity_check. The removed slot also letgetInstancebuild a second instance whoseopenDb()saw_closingDb == nulland so could not join the teardown at all. It now joins_closingDbfirst, withdropDb's shape and fordropDb's reason. -
An attach issued on a stale reference replaced the files of a LIVE successor, and this release introduced that. The old, map-driven form closed
_instance[dbName]!— the successor — before replacing the file, which was its own defect: a destructive side effect on an object the caller has nothing to do with. Namingthisinstead fixed that half and left the successor neither closed nor removed, so the attach unlinked its four files underneath it andgetInstancethen handed that same successor back — itsopenDb()either early-returning with a handle bound to a deleted inode (silent data loss, no error) or throwing a pool-size mismatch. Doing neither is the one option that corrupts.The attach now refuses, with the new
attachDbInstanceSlotHeldByLiveInstancecode, when the slot's occupant is a different instance that is open, opening or closing. It touches nothing on that path: the file, the occupant's pool and the slot are exactly as they were, and the caller is told which instance holds the database. Reviving the close was considered and rejected — it tears down statements and readers belonging to a consumer this call cannot report to, which is the action-at-a-distance the identity guards in this release exist to remove — and refusing is the direction taken everywhere else a call would otherwise proceed over live state (commitBlockedByActiveReaderis the same trade). A slot held by a quiescent other instance holds no files: the attach proceeds and leaves it in place, which is what the stale-reference test pins. -
A
rollback()that failed during teardown reported only through a sink this package documents as dead.closeDb's two best-effort rollbacks caught todeveloper.logand continued — the continuing is right, a failed rollback must not skip the statement sweep, the queue cancellations or the pool close. The reporting was not:rollback()clears_isInTransactionin itsfinallyeither way, so the WAL fold two steps later runs against a connection SQLite still considers mid-transaction, folds nothing, andcloseDb()returns SUCCESS with committed frames still in the-wal— the identical harm the post-drain re-check was added to prevent, recreated on the re-check's own failure path.closeDb's documented escape hatch does not reach it: for the post-drain attempt the straggler's transaction did not exist when the caller could have rolled it back. It now also reports throughDbasSqlite.onDiagnostic, naming the phase and that committed frames may remain unfolded. -
An attach's scratch-file cleanup could replace the failure that brought it there. The
finallythat removes the sibling temp was unguarded, on the argument that "the DATABASE is intact — what is lost is only the description of why the attach failed". That holds while the temp is being written and stops holding oncedropDbhas run:dropDbattempts all four deletions and reports which failed, so the destination can be half-deleted, and a.dbgone with a-walleft behind is a corrupt-looking next open rather than an intact database. The two failures also correlate — on Windows the lock that makes a delete of the live path throw is the same kind of thing that makes the temp delete throw — so the surviving error was systematically the one naming.attach.tmp. The cleanup is now guarded on its own and reports throughDbasSqlite.onDiagnosticinstead of raising. -
reportDiagnosticInternalpublished todeveloper.logoutside its own guard. It is called between teardown phases — the readeronCloseclosure reports its counter read, then finalizes the statement, then releases the connection — so a throw escaping it skips every phase after it, and a release that never runs strands a pool checkoutClosePoolblocks on. Thedeveloper.logcall is now inside a guard of its own, and the sink-failure fallback no longer claims a publish that did not happen. -
(web) The pool quiescence drain destroyed the pool with no diagnostic when it timed out.
_runExclusive'son TimeoutExceptionreset the state and let the destructive op proceed in silence — and the new_inFlightFinalizescounter adds a second route into it that is a slow worker, not a leak. It now reports_inFlightCalls,_stmts.lengthand_inFlightFinalizesthroughDbasSqlite.onDiagnostic; nothing can read them afterwards, since the reset is the next statement. -
(web) A reader-pool close that FAILED was swallowed, and the close is now memoized.
_doCloselogged todeveloper.logand returned, so a failed close is never retried and every later joiner — includingDbasSqliteNativeWeb._teardownLivePoolon a sibling shim — resolves successfully off it, asserting the very claim that failed: that the workers are gone and their OPFS files released. The nextbootWebLivePoolthen races files the outgoing workers may still own. It now reports throughDbasSqlite.onDiagnostic, which is the only signal there is. -
(test seam)
DbasSqlitePlatform.debugResetDelegates()broke unrelated open databases. It cleared the whole static delegate map, which accumulates an entry for every database a run has touched — instrumented, the first call in this package's suite dropped fifteen, only one of which belonged to the injecting test. Every accessor resolves through_delegate[name]!and only_getInterfacerepopulates a key, reached solely fromgetInstance/createPool, so a cleared entry belonging to an open database is never repaired: its next call dies on a null check,closeDb()included. It now restores exactly whatdebugInjectDelegatedisplaced, under an identity guard, and leaves every other entry alone.
Changed #
-
closeDb()now blocks until the connection is quiescent, and can throw. Callers merely parked in Dart are still rejected outright, but an operation already inside native code cannot be recalled, so it is waited for. In practice:closeDb()on a database with an un-awaited call still in flight now takes as long as that call does, instead of racing it. That wait — the native-operation registry drain, which is the one this bullet is about — is bounded byDbasSqlite.kNativeOpDrainTimeoutMs(30 s).On expiry it throws the new
DbasSqliteErrorCode.closeDbNativeOpDrainTimeout(categorybusyOrCancelled, alongside the two existingcloseDbBusy*codes), naming the operations still outstanding, and leaves the connection open. Logging and proceeding was rejected rather than overlooked: the next steps free the connection those operations are still using, so continuing is the use-after-free. Failing the close leaks a handle, which keeps the process alive and the failure diagnosable — and the only supported next step is to await the outstanding work and callcloseDb()again."Left open" does NOT mean "usable". The connection stays open, but it also stays marked closing, and there is no way to clear that short of a
closeDb()that succeeds. Concretely, for as long as a caught timeout is left unresolved:- every writer-lock acquire throws
writerLockWaitCancelled, which takes outexecuteSqloutside a transaction,executeScript,beginTransaction,checkpoint(),vacuum()andenableWal(); - every reader-slot acquire throws
readerSlotWaitCancelled, which takes out every pooledexecuteReader; and setBusyTimeout()throwsreaderSlotWaitCancelledtoo.
openDb()cannot rescue it: the connection is still open, soopenDbreturns early on itsisOpened()guard and never reaches the one line that clears the closing latch. Any open transaction is gone as well —closeDb()rolls back as its first step, long before the drain expires — so the writer lock has been released and the transaction cannot be resumed. Await the work that is still in flight, callcloseDb()again, and open a fresh connection if the flow needs one.That 30 s ceiling bounds the registry drain, not
closeDb(). The statement sweep runs after the drain and closes every open reader, and a reader waits for its own in-flightreadRowsteps unboundedly — see the second Fixed entry for why a timeout there could only expire into the corruption it prevents. So acloseDb()on a connection with a parked reader step blocks for as long as that step takes, with no ceiling at all; what it does instead of failing is emit a stall report everyDbasSqliteReader.kStepDrainStallReportMs(5 s), throughDbasSqlite.onDiagnosticas well asdart:developer.And there is a second unbounded wait, UPSTREAM of that ceiling entirely.
closeDb()rolls back before it drains the registry, androllback()first waits for every write dispatched inside the transaction (_drainReentrantWriterOps). That wait is unbounded too, and because it runs first, acloseDb()livelocked behind an un-awaited in-transactionexecuteSqlnever reaches the drainkNativeOpDrainTimeoutMsbounds — socloseDbNativeOpDrainTimeoutcannot be what surfaces it, and nothing else can either. Bounding it was considered and rejected for a reason a.timeout()cannot get around: a timeout does not cancel the drain, it abandons it, and the detachedrollback()would then still be free to issue a realROLLBACKon a connection teardown had already moved on to destroying — reopening the stranded-transaction race the post-drain re-check exists to close. So it stays unbounded and stops being silent instead, reporting everyDbasSqlite.kReentrantWriterDrainStallReportMs(5 s) through the same two sinks. The report counts the pending dispatches live rather than from the snapshot it is awaiting: a count that keeps changing means dispatches are churning and the drain is not converging, a steady one means a single write never handed back. Same message, two bugs. - every writer-lock acquire throws
-
A
DbasSqliteReadertorn down mid-scan now THROWS instead of reporting exhaustion —readRow()returningfalsemeans "no more rows", and now means nothing else. Previouslyclose()set a flag and the nextreadRow()returnedfalseat its guard, so a scan cut short by teardown was indistinguishable from one that ran out of rows. Awhile (await readRow())loop building a list handed its caller a silently truncated result with no error of any kind — the exact failure mode this library's own docs condemn elsewhere.readRow()(and thereforereadRows(), which is a plain loop over it) now throwsDbasSqliteErrorCode.readerClosedDuringScanwhen the reader was closed for any reason other than running out of rows: an explicitreader.close(), aDbasSqliteStatement.close(),closeDb's statement sweep, or an earlier failed step. Exhaustion — and only exhaustion — still answersfalse. The throw happens at the guard, before any contact with the finalized handle.The breaking surface is wider than teardown, and the commonest case is not a teardown at all: code that closes a reader (or its statement) after a partial scan and then calls
readRow()again used to get a quietfalse, and now gets an exception. Awhile (await readRow())loop that runs to exhaustion is unaffected; a loop thatbreaks early and re-probes the reader afterwards is not. The exception's message names which of the three closes actually happened, so a deliberateclose()is no longer reported as a database shutdown.A consumer suspended in
readRow()at the moment of teardown still receives the row its step already produced (it was read before anything was torn down, and the row cache is pure Dart); its nextreadRow()is the one that throws.executeScalar()inherits this, and it is the one place where the new throw replaces anullrather than afalse. It runs a singlereadRow(), so a reader torn down betweenexecuteReaderreturning and that first step now makes it throwreaderClosedDuringScanwhere it previously returnednull— indistinguishable, until now, from a query that genuinely matched no rows.nullfromexecuteScalar()now means "no row, or SQL NULL", and nothing else.Consumer impact, accepted rather than softened. In
dbas_base_app,watchSelectListFromTypeandbatchWatchSelectListFromTyperun theirreadRowloop outside the shared reader slot, and their rationale block calls it "a bounded, uninterruptible drain" — an assumption this change deliberately breaks under teardown. The live shape is logout while a list view is mid-scan (Authentication.logout→Database.closeDb, which never cancels outstanding watch streams). Such a consumer now gets an error event instead of a silently truncated list. That is the intended trade: a stream that fails loudly during shutdown is recoverable; a list that is quietly missing rows is not, and cannot even be detected. -
openDb()waits for a teardown already in flight, and a failed teardown now surfaces its error fromopenDb()instead of being swallowed. The join itself is the second half of the instance-map fix in Fixed; the part a consumer sees is the error. AcloseDb()that throws — the native-op drain timeout being the reachable case — used to be observable only by whoever calledcloseDb(), and anopenDb()racing it now inherits that failure. That is the safe reading rather than a regression: the previous connection is still open and still marked closing, so the open could not have succeeded anyway, and reporting the real cause beats returning early on anisOpened()guard that hands back a connection nothing can use.It is not a cure for an open that was already past that line when the close started; that one lands its
createPoolwhenever it lands. Closing it needs cross-single-flight arbitration betweenopenDbandcloseDb, which is a different mechanism and not in this release. -
dropDb()now blocks for as long as an in-flightcloseDb()takes, and can throw that close's error. Previously it sampledisOpened()and proceeded; see Fixed for what that sample actually meant. AdropDb()issued while a teardown is running is now as slow as the teardown, and a teardown that fails fails the drop rather than letting it delete files the pool may still be holding. It remains not atomic against a caller that reopens the database concurrently — see Not covered. -
DbasSqliteErrorCode.closeDbBusyWithStmtFinalizeFailuresis removed, and with it aFixedbullet this release should never have carried. An earlier draft of this entry claimed that a statement which failed to finalize during teardown "is now reported throughDbasSqlite.onDiagnosticbefore the pool dispatch". Both halves of that claim were false, and the mechanism behind them was dead from the day it was written.Nothing was ever counted. The counter had exactly one feeder — the sweep's
catcharoundstmt.closeForTeardownInternal()— and that catch is unreachable:closeForTeardownInternalmemoizesDbasSqliteStatement._doClose, whose only failable step is the reader close, which_doClosecatches and logs without rethrowing, while everything after it is two field assignments and aSet.remove. So the count was pinned at zero by construction, and so were both things it gated — theonDiagnosticreport, and thecloseDbBusyWithStmtFinalizeFailuresarm of the single-connectionSQLITE_BUSYthrow, which described a state unreachable by construction.The consequence they described could not arise either. The reader's
onClosereleases the connection in a third, separatetry, unconditionally — so a finalize that fails still hands the pool reader back and can strand nothing. What remains is a leakedsqlite3_stmt, which the sweep'sdeveloper.logreports honestly. Making_doCloserethrow so the mechanism would finally fire was considered and rejected: it would change teardown's error-propagation shape for a leak already mitigated by that unconditional release. The sweep's catch stays, relabelled defensive — it guards a future change to_doClose, not today's contract, and nothing downstream is allowed to depend on it. Reporting an unreachable site toonDiagnosticis what produced the false claim in the first place, so it is not repeated.A consumer switching exhaustively on
DbasSqliteErrorCodeloses one value and must drop that arm. Nothing can ever have matched it at runtime.
Added #
-
DbasSqlite.onDiagnostic— a static, nullable sink for the diagnostics this package has no other way to deliver.nullby default; wire it once at app start:DbasSqlite.onDiagnostic = (message) => myLogger.warn(message);Every such diagnostic also goes to
dart:developer'slog, but that reaches nobody where it matters:developer.logpublishes to the VM serviceLoggingstream and is discarded when no service client is subscribed — so a release build on a device drops it,flutter testdrops it, and aflutter rundebug session is the only place it shows up. What reports through here today:DbasSqliteReader.close()'s step-drain stall report — one of the two waits in this library that are deliberately unbounded, and the justification for that wait being unbounded at all;rollback()'s reentrant-writer drain stall report — the other one, and the onecloseDb()reaches first, so a teardown livelocked behind an un-awaited in-transactionexecuteSqlnever even reaches the drainkNativeOpDrainTimeoutMsbounds;- a failure in any of the three phases of the reader teardown
closure in
DbasSqliteStatement.executeReader— the counter read, thefinalizeStmtand the connection release — each of which loses a different resource or result that nothing downstream can recover or even name; and - a
poolReleaseReaderthat threw insidesetBusyTimeout(), which strands a readerClosePoolwill block on.
The sink must be synchronous. The type is
void Function(String), and Dart accepts anasyncbody there, but the returned future is dropped: a failure inside one escapes as an unhandled asynchronous error instead of being contained. Only a synchronous throw is covered by the guarantee below. Hand the message to something that buffers and do the awaiting elsewhere. A slow sink delays teardown, so keep it cheap either way.A synchronous throw cannot break a close, and cannot silence the report either. The exception is caught rather than propagated — a consumer's logger must not be able to fail a database close — and the original message is then re-emitted through
Zone.current.print, which reaches logcat / oslog in a release build and stdout underflutter test. Falling back todart:developeralone would fall back to the sink this whole mechanism exists to replace, so a consumer whose logger is torn down before the database — a common shutdown order, and shutdown is exactly when a stall report fires — would get complete silence about a wedged teardown. -
DbasSqlite.debugInFlightNativeOpCount(@visibleForTesting) — how many operations are currently registered as inside native code. The invariant this release rests on is thatcloseDbreaches its destructive dispatch only once this is zero, and there is no other way to observe that from outside. -
DbasSqlite.debugNativeOpDrainTimeoutMs(@visibleForTesting) — test-only override forkNativeOpDrainTimeoutMs, so the drain-timeout path can be exercised in milliseconds. Same shape and same caveats asdebugWriterLockWaitTimeoutMs. -
DbasSqlite.debugBeforeDestructiveClose(@visibleForTesting) — synchronous observation point invoked at the exact instantcloseDbis about to dispatchclosePool/closeDb, i.e. the last moment the ordering above can still be checked. -
DbasSqliteStatement.debugBeforeReaderTransfer(@visibleForTesting) — test-only rendezvous insideexecuteReader's prepare window. That window is the one stretch of the call no other seam can observe: native resources are already held, yet_activeReaderis stillnull. -
DbasSqliteStatement.debugBeforeScalarReadRow(@visibleForTesting) — test-only rendezvous insideexecuteScalar's one-row window, afterexecuteReaderhas handed back an open reader and before the singlereadRowthat reads it. A seam is the only way to reach it: every step in between is pure Dart, so the window is microtasks wide and another async flow is always scheduled wholly before it or wholly after it. It is also the windowexecuteScalar's contract is about — the one place in this release where a throw replaces a value rather than afalse. -
DbasSqliteErrorCode.readerClosedDuringScan— raised byreadRow()/readRows()on a reader that was closed before its result set was exhausted. CategorybusyOrCancelled, alongsidereaderSlotWaitCancelledandwriterLockWaitCancelled: nothing about the reader's lifecycle was misused, an in-progress scan was cut short by something else, and the remedy is the same — stop iterating. See Changed for what it replaced. -
DbasSqliteReader.debugInsideReadRowStep(@visibleForTesting) — test-only rendezvous insidereadRow's step window, after the native step has been dispatched against a livesqlite3_stmtand beforereadRowconsumes its result. That window is the one stretch of a reader's life no other seam can observe:isClosedis stillfalse, the row cache still holds the previous row, and the parent statement's_activeReaderhas pointed at it sinceexecuteReaderreturned. It composes into the step future rather than sitting beside it, so "this reader has a step outstanding" stays true for exactly as long as the hook parks. Do not await a close of the reader from inside the hook: the close waits for the hook, so the hook waiting for the close deadlocks — unboundedly, and visible only through the stall report below. -
DbasSqliteReader.kStepDrainStallReportMs(5000) — how oftenclose()reports that it is still waiting for an in-flightreadRowstep, throughDbasSqlite.onDiagnosticanddart:developerboth. It does not bound the wait; it exists so the one wait in this library that no timeout will ever surface is at least diagnosable.DbasSqliteReader.debugStepDrainStallReportMs(@visibleForTesting) overrides it in milliseconds, andDbasSqliteReader.debugStepDrainStallReports(@visibleForTesting) counts the reports emitted by that reader — a per-reader counter on a reader that teardown owns, so it proves the timer fires but says nothing about the message reaching anyone; that is what the sink is for. -
DbasSqlite.debugReleasablePoolPtr(@visibleForTesting) — the pool pointer a reader release would use right now, including the copy retained acrosscloseDb's destructive dispatch. The property it pins — "a release arriving whileClosePoolis blocked can still reach the pool" — has no other observable, and the only behavioural way to check it is to let a release be dropped and watch teardown wedge, which fails a suite by hanging it rather than by failing an assertion. It reads the same private member the release path reads, not a second copy of the expression: a seam that re-derived_poolPtr ?? _closingPoolPtrwould prove the pointer was retained and never that the release consults it, so dropping the fallback would leave every test green. -
DbasSqliteReader.debugInFlightStepCount(@visibleForTesting) — how manyreadRowsteps are currently published against a reader. The only non-destructive witness that a step reached the drain's set before anything could observe it; the alternative — starting a close from inside the step window and seeing whether it waits — is the corruption under test. -
DbasSqlite.kReentrantWriterDrainStallReportMs(5000) — how oftenrollback()'s in-transaction write drain reports that it is still waiting, throughDbasSqlite.onDiagnosticanddart:developerboth. It does not bound the wait, and neither doeskNativeOpDrainTimeoutMs:closeDb()reaches this drain first, so the 30 s ceiling is never even upstream of it. The sibling ofDbasSqliteReader.kStepDrainStallReportMs, for the same reason — see Changed.DbasSqlite.debugReentrantWriterDrainStallReportMs(@visibleForTesting) overrides it in milliseconds (clamped to a 1 ms floor), andDbasSqlite.debugReentrantWriterDrainStallReports(@visibleForTesting) counts the reports that drain has emitted — the report is a fire-and-forget side effect with no other observable, and an unbounded wait that quietly stopped reporting would be silent again. The timer is armed lazily, on the first snapshot that actually has something to wait for, so the common case that never suspends allocates nothing. -
DbasSqliteErrorCode.attachDbInstanceSlotHeldByLiveInstance— raised byattachDb/attachStreamDbwhen a differentDbasSqliteholdsdbName's instance slot and is open, opening or closing. CategorybusyOrCancelled, joining the twocloseDb*codes: nothing is closed, something else is still using the database, and the remedy is to let it finish and retry. See Fixed. -
DbasSqlite.debugInsideDestructiveClose(@visibleForTesting) — test-only rendezvous that holds teardown open at the START of its destructive window: after_dbhas been nulled (soisOpened()already readsfalse) and before theclosePool/closeDbdispatch is issued. It cannot hold that window open until the dispatch returns — it has already returned by the timeclosePoolis called — so a test that needs the later half, the stretch whereClosePoolis blocked waiting for a checkout, wraps the platform delegate's ownclosePoolinstead. Not a duplicate ofdebugBeforeDestructiveClose, which is synchronous by construction because it exists to sample state at an exact instant and awaiting there would change the ordering it reports. This one is the opposite: it keeps the window open for as long as a test needs, and every defect that reads "close has started" as "close has finished" is reachable only from inside it. The alternative — racing a realclosePoolround trip against a handful ofFileoperations — makes the test a property of the machine. Parking here is safe because the pool is still fully intact. -
DbasSqlite.debugBeforeBeginTransactionDispatch(@visibleForTesting) — test-only rendezvous insidebeginTransaction's dispatch window: the writer lock is granted and the native operation registered, yet_isInTransactionis stillfalse. That is the one stretch ofbeginTransactionno other seam can observe, and it is precisely the straggler whose transaction becomes visible during teardown's drain. -
DbasSqlite.debugWriterLockHeld(@visibleForTesting) — whether the Dart-side writer lock is held right now. The only witness there is for the flag's ownership rule: the holder is not a queue entry, sodebugWriterLockWaitQueueLengthreads the same0whether the lock is held or free. Reading it mid-teardown is what distinguishes a hold that survived from one cleared by something that never owned it. -
DbasSqlitePlatform.debugInjectDelegate/debugResetDelegates(@visibleForTesting) — route every later platform call for a database through an injectedDbasSqliteNativeInterface. This is the only injection point there is, not a shortcut around a nicer one:DbasSqlitePlatformis afinal class, so a double for it is impossible from outside its own library, whileDbasSqliteNativeInterfaceis a plain abstract class and is what every platform call actually resolves through. It exists to make reachable the native outcomes the real FFI layer cannot be asked to produce on demand — a specificPoolAcquireStatus, afinalizeStmtthat fails, apoolReleaseReaderthat can be observed rather than inferred. Wrap the real delegate and override only the calls under test. Reset in afinally/addTearDown: the map is static, so a leaked wrapper intercepts every later test's calls for that database. The reset is scoped to what was injected — it restores exactly the entry each injection displaced, under an identity guard, and touches nothing else. See Fixed for why the earlier whole-mapclear()was a process-wide effect dressed up as a test teardown. -
DbasSqliteNativeWeb.debugPoolQuiescent(web, package-internal) — a non-destructive witness for the web pool's quiescence predicate. The behavioural alternative cannot distinguish "the drain waited" from "the finalize won the race anyway": both worker round trips are real and either can settle first, and the shim swallows a rejected finalize (streamFinalizelogs rather than throws), so the returned rc issqliteOkon both sides of the fix. It ships unexercised — see Not covered.
Not covered #
Seven things this release does not cover, in the order they appear
below: one defect that lives in a different repository, one set of fixes
that ships without tests, three defensive guards or invariants no test
can drive, one test that is conditional on the host platform, and one
window left open deliberately. They are stated here because a release
that quietly omits them is how the false stmtCloseFailures bullet above
came to be written in the first place.
-
The web attach is still non-atomic, and cannot be fixed from this repository. The native fix in Fixed has no web counterpart, because on web the destructive step is not in Dart: the worker's
handleAttachStreamBegindoesFS.open(dbPath, "w+"), truncating the live OPFS database before the first chunk arrives, andhandleAttachStreamAbortdoesFS.unlink(dbPath)— so a mid-stream failure deletes the database rather than merely truncating it. Both live in the prebuiltweb/libs/dbas_sqlite_worker.js, a build artifact synced from the separatedailysoftwaresystems/DBAS.SQLiterepository. Closing it means teaching the chunked protocol to write to a scratch path and swap atattachStreamEnd, with an abort that unlinks the scratch — a change tracked there, not here. A Dart-side workaround was rejected: exporting the existing database first and re-attaching it on failure materialises the whole database in memory on the one path that exists to avoid exactly that, and still loses it if the tab dies mid-restore. The divergence is recorded as a doc comment on the webattachStreamDb, so two implementations of one interface differ explicitly rather than silently. -
The three web quiescence fixes ship without automated tests. Two independent reasons, and the second is the one that matters. The web harness could not be executed here at all — the local chromedriver (147) does not match the installed Chrome (150), and
flutter test --platform chromecould not connect a browser. But even with a working harness, an integration assertion on these three could not fail: the shim swallows the rejected finalize, so the observable rc issqliteOkon both sides of every one of these fixes, and a test asserting on it would pass against the unfixed code too.debugPoolQuiescentexists as the non-destructive witness such a test would need; it is currently unused. The fixes are argued from the code and from the native siblings they mirror, and that is stated here rather than implied to be coverage. -
The
PoolAcquireStatusacquire budget is only partly pinned. The elapsed adjustment — that a Dart-side wait is subtracted from the budget handed to the C layer — is covered by test. The.clamp(1, timeoutMs)around it is defensive and untestable, and no coverage is claimed for it. Neither bound can be driven: elapsed time is non-negative, so the remainder can never exceedtimeoutMsand the upper bound never binds; and a Dart wait that consumed the whole budget fails at the semaphore withreaderSlotWaitTimeoutinstead of arriving here, so reaching the lower bound would take an event-loop interleaving a test can hope for but not construct. The non-blocking form no longer reaches the expression at all — it throws one layer up. -
The path-resolve hoist in
dropDb/attachDb/attachStreamDbis an argued invariant, not a covered one. Resolving the database path before the join is what leaves no suspension point between the final admission decision and the dispatch that acts on it — but moving it back below leaves the whole suite green. Exploiting the gap needs anopenDb(acreatePoolround trip across a worker isolate) to COMPLETE inside a path resolve, and there is no seam over that resolve to hold one there, so a test would be a race against the machine rather than a property of the code. What the invariant asks of a future edit is stated in the doc comments instead: anawaitreintroduced there reopens the window, and nothing will turn red. -
Two defensive guards in the diagnostic path cannot be driven.
DbasSqliteReader's stall-report timer clamps its period to a 1 ms floor; removing the clamp is green, becauseTimer.periodicaccepts a zero or negativeDurationand fires anyway, so no value ofdebugStepDrainStallReportMsdistinguishes the two. AndreportDiagnosticInternal'sdeveloper.logcall is now wrapped in a guard of its own so a throw there cannot skip the teardown phases that follow — butdeveloper.logcannot be made to throw for a plain message and a plain name, so the guard ships unexercised. Both are labelled defensive in the code rather than claimed as covered. The stall report's content is pinned: the message must name how many steps the drain snapshot is waiting for, which reddens a report that read the live (already-cleared) set instead. -
The attach scratch-file cleanup test is conditional on the host platform. Making the cleanup fail needs an open handle to block a delete, which is Windows semantics; POSIX unlinks an open file happily. The test probes for the behaviour at runtime and marks itself skipped where it cannot produce the condition, rather than asserting something that cannot fail there.
-
dropDb()is not atomic against a caller that reopens the database concurrently. The join and the post-join re-check in Fixed close the window this method's own wait opens; they do not arbitrate against anopenDb()that arrives from outside.while (isOpened())would have no termination argument at all —openDbis public, ungated and callable at any moment, so a caller that keeps reopening starves the drop forever — and a fixed number of passes would be an arbitrary constant guaranteeing nothing. Neither is written. Arbitrating this needs an admission gate overopenDbthat no version of this library has; until then it is a caller-side ordering error, and onedropDb()cannot see.
2.8.4 - 2026-07-28 #
Added #
-
executeScript(String sql)— runs a whole multi-statement script, the only call in this library that executes more than one statement.prepareQuery+DbasSqliteStatement.executeSqldo onesqlite3_prepare_v2, one step and one finalize, and the C layer hands the prepare anullptrtail pointer, so everything after the first;is discarded before SQLite ever sees it — with no rc, no exception and no log. Measured through the shipped public API, aCREATE TABLE …; CREATE UNIQUE INDEX …; PRAGMA foreign_keys = ON;string returnedrc=0and lefttables=1, indexes=0, foreign_keys=0. Splitting on;in Dart was rejected rather than attempted:sqlite3_completeis not exported by the shipped binary, so a correct splitter would need a hand-rolled lexer over quoted literals, comments andBEGIN … ENDtrigger bodies, and consumer scripts carryCHECKbodies of arbitrary SQL.executeScriptinstead exposes the path that was already correct and already wired end to end but never public —sqlite3_exec, the same entry pointbeginTransaction/commit/rollback/vacuumgo through.It documents the semantics it inherits rather than hiding them. Result rows are discarded (a
SELECTinside a script runs and yields nothing — useprepareQuery+executeReaderto read rows). There are no bindings:sqlite3_exechas no bind surface, so the text must be complete, parameterised SQL belongs onprepareQuery, and untrusted values must never be interpolated into a script. Execution stops at the first statement that fails, with every statement before it already applied — and outside a transaction those are already committed, so there is no atomicity across the script unless the caller wraps it:await db.transaction((tx) => tx.executeScript(sql)). The return value is the connection'ssqlite3_changes64read after the script, so it is the count of the last row-changing statement, not a total. Calling it inside a transaction is deliberately allowed (vacuum/checkpointreject there only because SQLite itself refuses them): wrapping is the documented remedy for the non-atomicity, so a guard would have left callers nothing but the unsafe mode. -
checkpoint()— folds committed WAL frames into the main.dbfile and reports exactly how far it got, as aDbasSqliteCheckpointResult(busy,log,checkpointed,isComplete). Rarely needed now that every commit,closeDb()andstreamCopyDb()fold on their own; reach for it when you are about to read, copy or ship the main.dbfile by other means and need to know, not assume, that the data is in it.busyis not a success signal. A PASSIVE checkpoint that folds nothing because a reader pins the WAL still reportsbusy: 0andSQLITE_OK— measured(busy: 0, log: 10, checkpointed: 0)— which by that flag alone is indistinguishable from a full fold.isComplete(checkpointed == log) is the only honest test; a non-WAL database reportslog == checkpointed == -1, which reads as complete and correctly means "nothing was left behind". An incomplete fold is not an error either: a reader holding a WAL snapshot pins every frame above it, no checkpoint mode can fold those, and they fold at the next opportunity. That is also why the blocking modes are not offered — measured against this library,TRUNCATEwaited out the entire 5 sbusy_timeout(5034 ms) under a pinned reader snapshot and then folded exactly the frames PASSIVE folded in ~0 ms. -
beginTransaction({bool strict = false})— opt-in real serialization. The default (strict: false) is unchanged and still idempotent: a call made while a transaction is already active does nothing rather than taking a second hold on the writer lock. Astrict: truecall never joins — it parks on the writer-lock FIFO queue until the active transaction'scommit()/rollback()releases the lock, and only then issues its ownBEGIN TRANSACTION, so it always leavesstartedCurrentTransactionastrue. Uncontended, the two modes behave identically.strict: truefrom the flow that already owns the transaction is a self-deadlock by construction — it parks, and the only thing that could wake it is acommit()/rollback()that flow can no longer reach — so the writer-lock wait is now bounded (see Changed) and such a call fails withDbasSqliteErrorCode.writerLockWaitTimeoutinstead of hanging. Treat that error as a bug in the calling code, not a transient. -
startedCurrentTransaction—truewhen the most recentbeginTransaction()on this instance issued a realBEGIN TRANSACTION,falsewhen it took the idempotent join path. Read it immediately after theawait, before any other suspension. It exists so a caller can tell whether ending the transaction is its own job:if (db.startedCurrentTransaction) await db.commit();skips the call entirely when you merely joined someone else's transaction. There is no reference counting — a joiner'scommit()ends the transaction for everyone — so this is the supported way to avoid that hazard.
Fixed #
-
Committed data could sit in the
-walindefinitely, so any read of the main.dbfile alone silently missed it. Nothing on the Dart side issued a WAL pragma at open, so the native writer inherited SQLite's stockwal_autocheckpoint=1000(the web worker issues=1itself, so this was native-only), andcloseDbperformed no checkpoint of its own on either path. What made the common case look healthy was only SQLite's last-connection auto-checkpoint, which disappears the moment anything else still has the database open. Measured: 200 committed inserts left the main.dbat 4096 bytes with 832 KB sitting in the-waland the table absent from a copy of the main file alone — andcloseDb()on a database another connection still had open left it exactly there. Anything reading the main file by itself (a file copy,streamCopyDb, a backup, a sync that ships the file) saw a truncated or entirely empty database, with no error of any kind.The writer now has
PRAGMA synchronous=FULLandPRAGMA wal_autocheckpoint=1pinned when it enters WAL, in that order —synchronousgoverns the fsync a fold performs, so it must be pinned before the second pragma turns every commit into a fold.synchronous=FULLwas already the effective value (the prebuilt C library reportsDEFAULT_SYNCHRONOUS=2) and is issued explicitly so this database's durability stops depending on an invisible, unpinned compile-time default of a binary nothing in the Dart describes.closeDbnow checkpoints itself, after its rollback and its statement sweep: a checkpoint issued while a transaction is still open folds nothing (SQLite refuses to checkpoint a connection holding one) and an open reader pins the frames above its snapshot, so that ordering is load-bearing, not incidental.streamCopyDbcheckpoints before the raw file read, since it copies only the main.dband deletes the destination's-wal/-shm. Both fold PASSIVE and report a shortfall throughdart:developerrather than failing the operation.Behavior change for consumers: every commit now checkpoints. The cost is real — see the write-throughput note under Changed, where the mitigation is spelled out.
-
A joining
commit()could end the transaction while its real owner still had work in flight on the writer connection.beginTransaction()is documented, published and test-pinned as idempotent, and stays that way: a second caller's begin is a no-op, and any caller'scommit()ends the transaction for everyone (there is no reference counting). What was missing was protection for the work the owner still had running when that happened. The reentrant write path indbas_sqlite_statement.darttook a one-timeisInTransactionsnapshot and thereafter used the writer connection holding no claim on the writer lock, and FFI dispatch is not connection-pinned (prefer-free worker selection), so that work genuinely kept running after another caller'sCOMMIThad ended the transaction and handed the lock to the next FIFO waiter. The writer-lock accounting itself was already 1:1 correct — the defect was lifetime and ownership, not counting.commit()now pre-flights before issuingCOMMITand throwscommitBlockedByInFlightOperation(anexecuteSql, anexecuteScript, or the prepare phase of anexecuteReader, started inside this transaction and not finished) orcommitBlockedByActiveReader(a reader opened inside this transaction and routed to the writer connection for read-your-writes) rather than racing the connection. Readers on a pool connection are never affected — a WAL pool read does not touch the writer. The pre-flight runs before the transaction flag or the writer lock is touched, and deliberately bypasses the auto-rollback recovery: it means "called at the wrong time", not "the database failed", so the transaction is left completely untouched and the caller can await the write or close the reader and commit again. Measured, not assumed: a live writer-routed cursor produces no error at all today — it kept stepping four more rows after its transaction had committed and the lock had been handed on.SQLITE_BUSYis the production symptom under real isolate timing, not what the harness sees.Behavior change for consumers:
commit()can now throwcommitBlockedByInFlightOperationandcommitBlockedByActiveReader(bothDbasSqliteErrorCategory.transactionFailed). Neither is transient — await the write or close the reader, then commit again. -
An un-awaited write racing
rollback()completed silently and the row survived the rollback. The previous justification for leavingrollback()un-gated held that the only outcomes wereSQLITE_ABORTor harmless completion, "never a silent, permanently-persisted write". That is false: 10/10 reproducible.executeSqlreplays its bind buffer one bind per dispatch round-trip, so a write dispatched withoutawaitinside an open transaction is still walking a chain of pending dispatches whenrollback()runs; theROLLBACKslips between two of them and the step then executes on a connection already back in autocommit mode. The row commits on its own, survives the rollback permanently, and no error is raised on either side. A bind-width sweep (2 to 401 binds) shows the window is inherent to the dispatch model, not an artifact of wide statements.rollback()now drains every in-flight writer dispatch before issuingROLLBACK. It drains rather than throws becauserollback()iscloseDb()'s cleanup path and the error-recovery path throughout this class — a new way for it to fail would be a regression, not a safety improvement. Readers are deliberately not drained: aSELECTcannot persist anything past aROLLBACK, and SQLite tolerates aROLLBACKwith live statements on the connection (unlikeCOMMIT).Behavior change for consumers:
rollback()now waits for in-flight writes to finish instead of returning while they are still running. -
commit()swallowed a rollback failure during its own COMMIT-failure recovery. WhenCOMMITfailed, the implicit recovery is torollback(); if that rollback failed too, its failure was logged and the originalCOMMITexception rethrown, leaving the caller unable to tell "recovered" from "state unknown".commit()'s own documentation claimed it mirroredtransaction()'s handling of the identical shape; it did not. It now throwscommitRollbackAlsoFailedwith the originalCOMMITfailure preserved oncause(and its stack oncauseStackTrace), lifting the original'ssqliteCode/sqliteUniqueCodeonto the wrapper. It gets its own code rather than reusingtransactionRollbackAlsoFailed, so a barecommit()can be told apart from one made throughtransaction(). When only the rollback recovery succeeds, the originalcommitFailedis still rethrown unchanged.commit()also gained theisOpened()guardbeginTransaction()/vacuum()already had, so a database closed while a transaction was still marked active yieldscommitDatabaseNotOpenedinstead of a raw null-check error. -
enableWal()was a second door into WAL mode that bypassed the WAL writer policy entirely. The policy above is established in the pooled-open path, butopenDb(readerPoolSize: 0)opens injournal_mode=deleteand never runs it — so a followingenableWal()produced a WAL database carrying the stockwal_autocheckpoint=1000with nosynchronouspin: exactly the silent-loss configuration the pooled path had just been fixed for, reached through the public API by a different entrance. Both doors now route through one shared policy step, so the two cannot drift apart, and whichever door a database enters WAL through it leaves with the same guarantees. A policy failure insideenableWal()throws (walSynchronousFullFailed/walAutoCheckpointFailed) and leaves the connection open, unlike the open path which tears its half-built pool down — this one is live and may hold statements, readers and a transaction that are notenableWal's to destroy. -
enableWal()inside a transaction succeeded or failed purely on the journal mode it happened to find. Measured: SQLite forbids both halves of the call inside an open transaction — it cannot switch journal modes there, andPRAGMA synchronousanswers "Safety level may not be changed inside a transaction". So the call could only ever verify, never establish, and it was not even consistent about that: on a database already in WAL the journal-mode statement was a silent no-op success, while on ajournal_mode=deletedatabase the same call failed hard. Both configurations now answer alike withenableWalInsideTransaction, rejected up front before any pragma runs, mirroring the existingcheckpointInsideTransaction/vacuumInsideTransactionguards.Behavior change for consumers:
enableWal()inside a transaction now always throws — but only for a call that established nothing either way. Commit or roll back first.
Changed #
-
Every commit now checkpoints, and that costs write throughput.
PRAGMA wal_autocheckpoint=1means each commit folds the WAL back into the main.dbfile, which is what makes committed data actually present in the file a copy, a backup or a sync reads. Measured over three trials of 2000 single-row commits: 1667 ms → 6070 ms, about 3.5×, i.e. roughly +2.2 ms per commit. BareINSERT/UPDATE/DELETEoutside a transaction is included — each is an implicit transaction that commits.The cost is per commit, not per row, so the mitigation is batching: N writes inside one
beginTransaction()/commit()pair (or onetransaction()) pay for one checkpoint, not N. If a bulk path — a first-login sync, a migration, an import loop — got noticeably slower on this version, this is the change responsible, and wrapping the loop in a single transaction is the fix. -
The writer-lock wait is now bounded for every acquirer. Callers parked on the writer-lock FIFO queue (
executeSqloutside a transaction, anexecuteReaderon a pool-less database,executeScript,beginTransaction— including everystrict: truecall —checkpoint,streamCopyDbandvacuum) previously waited forever. They now give up afterkWriterLockWaitTimeoutMs— 30 s, the writer-side twin of the existingkPoolAcquireTimeoutMs— and throwDbasSqliteErrorCode.writerLockWaitTimeout, categorisedbusyOrCancelled. An unbounded wait is not "safe by default": the caller most likely to be starved is the flow that already owns the lock, and nothing can ever wake it, so the bound turns a silently wedged flow into a diagnosable error. A timed-out waiter removes itself from the queue before failing, so the lock is never handed to a caller that no longer wants it. Genuine contention behind a write that holds the lock longer than 30 s is a retryablebusyOrCancelled; astrict: trueself-deadlock is not, and retrying it will time out again. -
prepareQuery/DbasSqliteStatement.executeSqlare now documented as one statement per call. The behaviour is unchanged and is now pinned by a test — everything after the first;is dropped at prepare time — but the dartdoc said "Multiple statements may be prepared on the sameDbasSqlite", which reads as reassurance in exactly the wrong direction, and the real limit was admitted only in one aside about an unrelated pragma. It is now stated on both methods, cross-referencingexecuteScript. That the limit went undocumented is part of what let the truncation ship.
2.8.3 - 2026-05-27 #
Fixed #
-
getColumnDateTimemislabeled naive stored timestamps as local, causing…Zvs no-Zdivergence. SQLite stores timestamps as text, and the project convention is that every persisted timestamp is UTC. The reader previously returnedDateTime.parse(stored)directly, which yields a local (isUtc == false)DateTimefor a naive string (no offset /Z). Mixed with UTC-flagged values written elsewhere, the same column ended up holding both2026-…Zand2026-…strings; lexical SQL comparison of those is only coincidentally correct and brokeORDER BY/WHEREaround the format boundary, while Dart equality treatsDateTime(local) != DateTime(utc)even for the same instant.getColumnDateTimenow interprets the stored value as UTC: an explicit offset /Zis honored, and a naive string is re-flagged as UTC wall-clock viaDateTime.utc(...)without shifting by the device timezone (.toUtc()would corrupt it). The returned value always hasisUtc == true, so ordering and equality never diverge.getColumnNullableDateTimeinherits the fix (it delegates togetColumnDateTime).Behavior change for consumers:
getColumnDateTime/getColumnNullableDateTimenow return UTC-flaggedDateTimes. Code that relied on the previous local-flagged result (e.g. formatting the components for display without an explicit timezone conversion) should convert to the user timezone at the presentation layer instead.
2.8.2 - 2026-05-26 #
Fixed #
- Concurrent
openDb()calls raced a second pool creation for the same file (web:POOL_ALREADY_ACTIVE).openDb()'sisOpened()fast-path guard staysfalseuntil_dbis assigned, which only happens after thecreatePoolawait. Two or moreopenDb()calls that arrived before the first finished therefore all observed_db == null, fell through, and each issued its owncreatePoolfor the same database file. On web the pool layer is process-wide and rejected the second create withPOOL_ALREADY_ACTIVE("a ConnectionPool is already active for dbName …"); on native it silently leaked a duplicate pool. The real-world trigger was a consumer starting several queue processors together (sendData / receiveData / log), each resolving the same user database concurrently.openDb()is now single-flight: concurrent callers await one in-flight open instead of racing, upholding the documented idempotency contract under concurrency. (No retry — the duplicate create is structurally prevented.)
2.8.1 - 2026-05-26 #
Fixed #
- Segfault during
closeDb()on a pool with parked reader-slot waiters. When the database was closed while one reader held the only slot and others were parked, the held reader'sonClosereleased the slot and granted a parked waiter, which then raced into the native pool's blocking acquire on one worker isolate whilecloseDbdispatchedClosePoolon another — tearing down the pool's lock/condvar underneath the parked acquire (observed as a SIGSEGV in test finalization on CI).closeDb()now latches a closing flag and drains both the writer-lock and reader-slot wait queues before sweeping statements, and_acquireReaderSlot/_acquireWriterLockreject synchronously withDbasSqliteErrorCode.readerSlotWaitCancelled/writerLockWaitCancelledonce a close is in flight, so no caller can enter the native pool during teardown. (NativeClosePoolis hardened in lockstep to drain in-flight acquires and checked-out readers before destroying the pool.)
Changed #
- Pool reader acquisition now reports a specific status instead of a
bare null. A failed
poolAcquireReaderBlockingdistinguishesclosing(terminal — the pool is tearing down) fromtimeout(transient — no reader freed in the window) andinvalid(the pool is gone), mirroring the nativePoolLastAcquireStatus()accessor. A reader acquire that loses the race to a concurrent close now surfaces asreaderSlotWaitCancelledrather than being misreported asexecuteReaderPoolAcquireTimeout.
2.8.0 - 2026-05-25 #
Added #
- Web: true read/write concurrency via a multi-worker connection
pool.
openDb()on web now drives the nativecreatePoolcoordinator — 1 writer + N reader Web Workers, each with its own SQLite connection, coordinated through aSharedArrayBuffer-backed WAL SHM. Reads dispatch to reader connections and writes to the writer connection, exactly like the native FFI pool, so a long-lived read cursor can no longer block a write. ThecreatePoolhost is instantiated on the main thread (the pool's workers are therefore not nested workers — widest browser support, one IPC hop). coi-serviceworker.jsis now shipped with the package (built and minified from the native web source) and placed at the example web root byscripts/sqlite/sync_sqlite_lib.(ps1|sh). It makes a page cross-origin isolated without server header config — see Web Setup in the README.
Fixed #
- Web:
BEGIN TRANSACTION failed: [SQLITE_BUSY] Cannot write while a read statement is open on this worker; finalize first. Every web DB operation previously ran through a single Web Worker (one SQLite connection), so a background read holding a cursor open (e.g. ApiQueue session resolution) made a concurrentBEGIN(e.g. the login migrator) fail withSQLITE_BUSY. Reads and writes now use separate pooled connections, so the collision is structurally impossible. Writes additionally hold the EXCLUSIVE cross-handle fence while read-only statements (including read-your-writes SELECTs on the writer connection) hold SHARED — decided by the newly exposed nativesqlite3_stmt_readonly— so there is no torn-page / autocheckpoint window either.
Changed #
- Web now requires cross-origin isolation (
crossOriginIsolated === true) to get the concurrent pool, becauseSharedArrayBufferis only available in that context. Serve the document withCross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corp, or use the bundledcoi-serviceworker.js. When the page is not cross-origin isolated the plugin logs the reason (channeldbas_sqlite.lifecycle) and falls back to the legacy single-worker connection — the app keeps running but loses read/write concurrency (and the write-while-read limitation returns). No public API changed. - Requires the matching rebuilt native web bundle (
dbas_sqlite.js+dbas_sqlite_worker.js) inweb/libs/: it adds thecreatePooldbNamemain-thread-host option and theGetStmtReadonlyexport. Runscripts/sqlite/sync_sqlite_lib.(ps1|sh)to refresh the vendored bundle and dropcoi-serviceworker.jsat the example web root.
2.7.5 - 2026-05-23 #
Fixed #
- Web:
databaseExistswas a creating probe, breaking native parity — the implementation routed through the worker (DbasSqliteWebPool.create()→pool.send('exists')), which forced aninitround-trip.initcalls the WASM lib'sinitPersistentFS, which both opens the SQLite DB (create-or-open) and walksopenOpfsHandlescallingopfsDir.getFileHandle(name, {create: true})for the four SQLite files (name.db,-journal,-wal,-shm). Net effect: everydatabaseExistsinvocation materialised the file in OPFS, and the very nextexistsaction returnedtrue. Native FFI'sdatabaseExistsis a no-side-effectFile(path).existsSync()— calling it on a non-existent file leaves it non-existent. The web behaviour broke any consumer that useddatabaseExistsas a "first-time bootstrap?" gate, e.g.SessionLifecycle._defaultSessionWriterupserting a row before the migrator created the schema. Symptom seen in consumers: "no such table: dbas_Session" on first-ever login.
Changed #
- Web:
databaseExistsnow probes OPFS directly from Dart vianavigator.storage.getDirectory().getDirectoryHandle("dbas_data", {create: false}).getFileHandle("<dbName>", {create: false}). The WASM lib is not involved, no worker is spun up, no file is created — matching native FFI's "is the file on disk?" semantics 1:1. A live_poolshort-circuits totrue(file is loaded by definition) so the hot path stays cheap.
2.7.4 - 2026-05-23 #
Fixed #
- Web:
executeSqlStepFailed [SQLITE_BIND_RANGE]when a named bind isn't present in the prepared SQL —DbasSqliteStatement.bindNameParametersdocuments that missing named params are silently skipped to matchMicrosoft.Data.Sqlite, and_replayBindsimplements that skip per rc on every native FFI bind. On web the contract was broken because the shim'sbindName*methods buffered Dart-side and always returnedsqliteOk; the real bind happened later inreadRowAndCachevia one batchedbindParamscall against the worker. The WASMbindParamsis all-or-nothing, so a single missing named slot threwSQLITE_RANGEfor the entire batch and surfaced asexecuteSqlStepFailedinstead of being skipped. Consumers whose query builder emits a named param that doesn't appear in the SQL (e.g. a recursive-join column auto-added by a higher-level ORM) hit the failure on every read; downstream the failing read could cascade into "no such table" errors when a migration ledger probe couldn't complete and the schema wasn't created.
Changed #
- Web: per-call bind round-trips, eager rc —
DbasSqliteNativeWeb's positional and namedbind*methods now each make ONE worker round-trip via the newDbasSqliteWebPool.bindParam(singular) action and return the SQLite rc the worker reported, exactly mirroring native FFI's per-callsqlite3_bind_*semantics. The statement layer's_replayBindstherefore sees the same per-bind rcs on both platforms (includingSQLITE_RANGEon missing named params, which it silently skips or throws based onthrowOnMissingNamedParams). The Dart-side bind buffer in_WebStmtState(setPositional/setNamed/mergedParams/bindsFlushed) and the buffered flush block at the top ofreadRowAndCacheare gone — the binds are already on the worker by the time the first row is fetched. - Web:
DbasSqliteWebPool.bindParam(singular) added — wraps the worker'sbindParamaction so the platform shim can bind one slot at a time and surface per-call rcs.
2.7.3 - 2026-05-23 #
Fixed #
- Web:
StateError: Pool is closed for "<dbName>"after a probe call —DbasSqliteNativeWeb.databaseExists/attachDb/attachStreamDb/getContent/dropDball calledDbasSqliteWebPool.create()for a supposedly throwaway pool and thenpool.close()d it.create()is a get-or-create against a process-global_poolsmap keyed bydbName, so when a long-lived pool was already running for that DB, every probe returned that live pool — and the trailingclose()tore it down. SubsequentprepareQuery/executeSqlagainst the sameDbasSqliteinstance then blew up becauseDbasSqlite.openDbis now idempotent (skips whenisOpened()reports true) and the platform shim's stale_dbOpenedflag was stilltrueeven though the underlying pool was dead. Symptom seen in consumers: aselectFirst/executeReadershortly after any caller that exercised one of those five probes threwDbasSqliteException(executeReaderPrepareFailed)wrappingStateError: Pool is closed for "<dbName>".
Changed #
- Web:
DbasSqliteNativeWeb.isOpenednow derives from the underlying pool's liveness (_pool != null && !_pool.isClosed) instead of a separately-maintained_dbOpenedflag. Single source of truth means the shim cannot lie about open-state after a probe-side close, so the idempotentDbasSqlite.openDb()contract stays honest. The probe methods that previously closed a shared pool by mistake now reuse the live pool (read-only probes:databaseExists) or fully tear down via a new_teardownLivePool()helper before running against a transient pool (destructive probes:attachDb/attachStreamDb/getContent/dropDb).closeDb/closePoolshare the same teardown helper so state-reset is centralised. - Web:
DbasSqliteWebPool.isClosedgetter added so the platform shim can detect externally-torn-down pools and trigger a freshcreateon the next operation. - Web:
_ensurePoolnow clears_stmtswhen overwriting a stale-closed pool, symmetrically with_teardownLivePool. Without this, cached prepared-statement handles bound to the dead worker's WASM heap would leak into the fresh worker and be rejected withUNKNOWN_HANDLEon next use. - Web:
_withTempPoolfinally-block now preserves the original error. If the action throws and the cleanuppool.close()also throws, the close failure is logged viadart:developerinstead of masking the root cause. - Web:
databaseExistsretries via a transient pool if the live pool is closed mid-probe, so callers never see a rawStateErrorbubble out of the platform shim from a concurrent teardown race. - Tests: regression integration test added to
example/integration_test/dbas_sqlite_web_test.dart(databaseExists on a live pool does not tear it down) covering the exact pre-fix symptom.
2.7.2 - 2026-05-22 #
Fixed #
- Windows integration-test build (MSB3073 on Flutter 3.44) — the C++
unit-test target's
gtest_discover_testswas running at POST_BUILD, invokingdbas_sqlite_test.exebefore its DLL search path was set up and exiting 1. Switched toDISCOVERY_MODE PRE_TESTso discovery defers toctesttime;flutter drive/flutter build windowsno longer trip the discovery step at all. Matches the current Flutter plugin template. example/ios/Runner.xcodeproj/project.pbxprojsimulator slice typo — header search paths referencedios-arm64_x86_x64-simulator(extrax); the actual xcframework slice isios-arm64_x86_64-simulator. Fixed in 3 build configs (6 occurrences total).- Lint:
prefer_initializing_formalsinlib/src/dbas_sqlite_reader.dart— theDbasSqliteReader.internalconstructor now usesrequired this._conn/_handle/_platform/_onCloseinstead of an init list.
Changed #
-
README title — renamed from
DBAS.SQLite.Fluttertodbas_sqliteto match the pub package and GitHub repo. The 2.7.1 note about "keeping the old display name as human-facing branding" is superseded by this change; a one-line "previously published asdbas_sqlite_flutter…" pointer is added in its place for long-tail upgraders. -
Defensive xcframework slice-name fix in
scripts/sqlite/sync_sqlite_lib.{sh,ps1}— after copying the upstreamdbas_sqlite.xcframeworkintoios/dbas_sqlite/andmacos/dbas_sqlite/, the scripts now rename any slice directory containing_x86_x64to_x86_64. Today's upstream ships the correct name, so the loop is a no-op; the fix prevents a future upstream typo from breaking podspec / SPM.binaryTarget/ header-search paths. -
Stale name cleanup — three remaining references to the old
DBAS.SQLite.Flutter/dbas_sqlite_flutternames that don't affect any consumer-visible surface:.github/CODEOWNERSheader comment..idea/DBAS.SQLite.Flutter.iml→.idea/dbas_sqlite.iml,.idea/modules.xmlupdated to match.example/ios/Runner.xcodeproj/project.pbxproj—dbas_sqlite_flutter.frameworkPBXFileReferenceand itsFrameworksgroup child removed (pre-2.0 pub name, no longer produced), plus 3${PODS_CONFIGURATION_BUILD_DIR}/dbas_sqlite_flutter/...and 3${PODS_CONFIGURATION_BUILD_DIR}/integration_test/...header search paths removed (post pod-deintegrate dead refs). The activedbas_sqlite.xcframeworkentries already supersede them.
Historical
CHANGELOGentries,READMEbody content describing prior names, and the.claude/skills/dbas-sqlite-flutter/skill descriptor are left unchanged.
2.7.1 - 2026-05-22 #
Added #
-
Swift Package Manager manifests —
ios/dbas_sqlite/Package.swiftandmacos/dbas_sqlite/Package.swift, completing the SPM scaffolding shipped in 2.7.0. Both declare aFlutterFrameworkpackage dependency and a.binaryTargetpointing at the in-treedbas_sqlite.xcframework; the-all_loadlinker flag preserves the static-xcframework workaround previously provided by the example app'sPodfilepost_install.Plan in
.plans/spm-followup.mdwas overridden — Flutter #186934 is still open upstream. The plugin-identity mismatch the PR fixes is side-stepped here by renaming the repo and source root todbas_sqlite(matching the declaredPackage(name:)). Local development and direct git consumers build cleanly; pub.dev consumers whose pub-cache extraction directory carries adbas_sqlite-<version>suffix may still hit theunable to override packageerror until the upstream fix lands.
Changed #
- Minimum Flutter / Dart —
environment.flutterbumped to>=3.44.0andenvironment.sdkto^3.12.0. CI's pinnedflutter-versionfollows. - Example app migrated to SPM-only —
pod deintegrateran for bothexample/iosandexample/macos;Podfile,Podfile.lock, thePodsreference in eachRunner.xcworkspace, and thePods/.../Pods-Runner.{debug,release}.xcconfigincludes in theFlutter/*.xcconfigfiles are gone. The post_install-force_loadworkaround now lives inPackage.swift'slinkerSettings. CocoaPods consumers of the plugin itself are unaffected — both podspecs still passpod lib lint. - Repository rename follow-up —
homepage:in both pubspecs, the security-advisory URL inSECURITY.md, and the GitHub App token'srepositories:field in the release workflow all switched fromDBAS.SQLite.Fluttertodbas_sqlite. README title and Claude skill descriptor still carry the old display name (kept intentionally — those are human-facing branding, not GitHub-API surface). - Refreshed
example/pubspec.lock(transitivemeta,test_apibumps Flutter 3.44.0 allows) and the Flutter-generated SPM integration entries inexample/{ios,macos}/Runner.xcodeproj.
2.7.0 - 2026-05-22 #
Added #
-
DbasSqliteException— single exception type thrown by the public API ofDbasSqlite,DbasSqliteStatement, andDbasSqliteReader. Fields:DbasSqliteErrorCode code— stable per-throw-site identifier (40+ values, 1:1 with throw sites; useful for telemetry IDs and test assertions).int? sqliteCode— SQLite primary result code (e.g.19forSQLITE_CONSTRAINT,5forSQLITE_BUSY).nullfor.dartfactory throws.int? sqliteUniqueCode— SQLite extended result code (e.g.2067forSQLITE_CONSTRAINT_UNIQUE,787forSQLITE_CONSTRAINT_FOREIGNKEY).nullwhen the platform didn't queue an extended rc or for.dartfactory throws.String message— human-readable description.Object? cause+StackTrace? causeStackTrace— non-null when this exception wraps an underlying failure (the rollback-after-failed-transaction path and the rollback's own catch branch).
Factories:
DbasSqliteException.dart(code, message, {cause, causeStackTrace})— Dart-side condition (closed DB, format/range errors, timeouts, queue cancellations). Both rcs arenull.DbasSqliteException.sqlite(code, message, {required int sqliteCode, int? sqliteUniqueCode, cause, causeStackTrace})— native SQLite failure.
Two derived enums help consumers branch:
DbasSqliteErrorCategory(coarse) —notOpened,busyOrCancelled,prepareFailed,executeFailed,bindFailed,transactionFailed,readerStateFailed,decodeFailed,internal. Available ascode.categoryorexception.category.DbasSqliteSubCategory(fine, SQLite-aware) — derived fromsqliteUniqueCode ?? sqliteCode, so extended codes win over their primary counterparts:databaseBusy(SQLITE_BUSY=5),tableLocked(SQLITE_LOCKED=6),duplicatedData(SQLITE_CONSTRAINT_UNIQUE=2067,_PRIMARYKEY=1555,_ROWID=2579 — covers UNIQUE column constraints, UNIQUE indexes, and PRIMARY KEY duplicates),foreignKeyViolation(787),notNullViolation(1299),checkViolation(275),corruptDatabase,diskFull,readOnlyDatabase,valueTooLarge,rangeError, and ~20 more. Available asexception.subCategory.
Both codes flow end-to-end on both platforms:
- Native — the bundled C lib's
GetExtendedErrorCodeFFI entry point feeds the platform'sgetUniqueErrorCode; the primary is derived asextended & 0xFFviagetErrorCode. - Web — the worker's
postErrenvelope carriesrc/extendedRc;DbasSqliteWebPoolconstructs an internalDbasSqliteWebWorkerErrorfrom them, and the web shim caches them in fields read bygetErrorCode/getUniqueErrorCode.
-
DbasSqliteStatement.getLastErrorCode()andgetLastUniqueErrorCode()— int-valued accessors parallel to the existinggetLastError()string accessor. Populated by bothexecuteSql(from the thrown exception's codes) andexecuteReader(from the connection's error state at reader-close time). Useful for callers that route exceptions through a generic handler and later inspect the statement for telemetry without rethrowing. -
DbasSqlite.openDb()is now idempotent. A second call on an already-open instance is a no-op. Calling with a differentreaderPoolSizethrowsDbasSqliteExceptionwith codeopenDbReopenWithDifferentPoolSize— pool resizing isn't supported; close the database first.
Changed (breaking) #
-
Every previously-exposed
StateError,Exception,TimeoutException,FormatException,ArgumentError, andUnsupportedErrorthrown byDbasSqlite,DbasSqliteStatement, andDbasSqliteReaderis now aDbasSqliteException. Code that caught a specific type (e.g.on TimeoutException catch,on FormatException catch) will no longer match — catchDbasSqliteException(or any supertype likeException) and branch one.code,e.category, ore.subCategory.getColumnDecimal/getColumnTimepreviously threwFormatException; nowDbasSqliteExceptionwithinvalidDecimalFormat/invalidTimeFormat/invalidTimeComponent.getColumnEnumpreviously threwArgumentError; nowinvalidEnumIndex.- The two
bindXxxpaths that hit an unsupported type previously threwUnsupportedError; nowunsupportedPositionalBindType/unsupportedNamedBindType. - The pool-saturated reader-acquire previously threw
TimeoutException; nowreaderSlotWaitTimeout(Dart-side semaphore wait) orexecuteReaderPoolAcquireTimeout(C-side pool wait). - All "database is not opened" / "statement is closed" guards
previously threw
StateError; now various…DatabaseNotOpenedandstatementClosedcodes.
Fixed #
-
closeDb()no longer aborts teardown whenrollback()fails. Previously a failed in-flight ROLLBACK skipped statement cleanup, queue cancellation, and pool close, leaving the cache and OS resources dangling. The rollback failure is now logged viadart:developerand teardown continues. -
rollback(),commit(), andtransaction()preserve the underlying error. When ROLLBACK fails (or when bothaction/commitand the subsequent rollback fail), the inner exception is now attached asDbasSqliteException.causewith its stack trace oncauseStackTrace. When the inner failure is itself aDbasSqliteException, both itssqliteCodeandsqliteUniqueCodeare lifted onto the outer exception so programmatic recovery onsubCategorykeeps working across the wrap.commit()now mirrorstransaction()'s behaviour: if the implicit rollback after a commit failure also fails, the rollback error is logged and the original COMMIT exception is rethrown (previously the rollback error masked the commit error).
2.6.0 - 2026-05-07 #
Added #
-
DbasSqliteReader.readRows([int amount = 50])— batch row reader that advances up toamountrows in a single call and returns a record({List<Map<String, ColumnData>> rows, bool hasMore}). Each row is a column-name →ColumnDatamap, preserving the SQLite type, raw value, and null flag for downstream typed access. ThehasMoreflag carries the boolean result of the lastreadRowcall, so callers can drive paginated reads without an extra step to probe for end-of-set:final reader = await stmt.executeReader(); while (true) { final (:rows, :hasMore) = await reader.readRows(); for (final row in rows) { final col = row['name']!; // col.value, col.isNull, SqliteColumnType.fromInt(col.type) } if (!hasMore) break; }Returns an empty list with
hasMore: falseimmediately whenamount <= 0. Pure Dart wrapper overreadRow— no native interface, platform, or stub changes. Snapshots each row from the per-readerRowDatacache before the next step overwrites it, so intermediate rows are preserved even though the cache itself is not retained. -
ColumnDataexported from the public barrel (lib/dbas_sqlite.dart) so consumers ofreadRowscan reference the row-cell type directly. Previously internal-only.
2.5.3 - 2026-05-07 #
Fixed #
- CI: pub.dev publish job triggered Node.js 20 deprecation warning.
The reusable workflow
dart-lang/setup-dart/.github/workflows/publish.yml@v1internally pinned an oldersetup-dartSHA still running on Node.js 20, which GitHub will force off on June 2, 2026. Pinned the reusable workflow past the@v1tag to commitcb71272(2026-04-01), which bumps the inner pin tosetup-dartv1.7.2 (Node.js 24). No release behavior change; clears the deprecation warning and avoids breakage when Node.js 20 is removed from runners.
2.5.2 - 2026-05-07 #
Fixed #
- Windows / Linux / macOS build broke for consumers of the published
package. The platform
CMakeLists.txtfiles had aPOST_BUILDcopy_if_differentstep pointing at../native_libs/sqlite/<os>/.../dbas_sqlite.<ext>, butnative_libs/is excluded from the published tarball by.pubignore(it is the local staging tree that duplicates the platform-folder binaries). The copy therefore failed at consumer build time withMSB3073on Windows and equivalent CMake errors on Linux/macOS. Repointed the source path to the platform-folder copy that is actually shipped (<platform>/libs/...), which was already the value used bydbas_sqlite_bundled_libraries.
2.5.1 - 2026-05-07 #
Fixed #
-
Worker-pool / reader-pool deadlock under fan-out parallel reads. A
Future.waitof NexecuteReadercalls (where N exceeded the reader-pool size) could deadlock the entire pool until the 30 s C-side timeout fired. EachexecuteReaderdispatchedpool_acquire_reader_blockingto a worker isolate; once every worker was parked inside the C blocking acquire, no worker remained to processprepareQuery/finalizeStmtfor the in-flight reads, so no read could finish, no reader could be released, and every acquire timed out together. Reproduces with the defaultreaderPoolSize: 4and any caller that fans out 6+ pre-write reads in parallel (e.g. an FK-graph dependency walker).Fix: gate entry to
poolAcquireReaderBlockingthrough a Dart-level FIFO semaphore sized to the reader pool. Excess callers wait in Dart microtasks instead of occupying a worker isolate, so at least two workers (the auto-bumpedreaderPoolSize + 2headroom) remain free for the non-blocking read steps. Once a reader is released, the C handle is returned to the C pool BEFORE the Dart slot is signalled — so the next semaphore-granted caller's C-side acquire finds a free reader immediately. The C-side timeout becomes a safety net rather than the primary contention bound.No public API change; behaviour is automatic. Single-connection mode (
readerPoolSize: 0) is unaffected — it goes through the writer lock, not the pool. -
pub.dev Web platform-support and WASM compatibility scoring. The public API chain (
dbas_sqlite.dart→DbasSqliteStatement→DbasSqliteReader→DbasSqlitePlatform→DbasSqliteNativeInterface) was unconditionally importingpackage:path_provider/path_provider.dart,dart:io, andpackage:flutter/services.darteven though the call sites were already runtime-gated bykIsWeb. pub.dev's static analyser walks every unconditional import, so the web build graph reachedpath_provider(which doesn't declare Web support) anddart:io(incompatible with WASM), costing both Platform-support points and the WASM badge.Fix: the path-resolving and test-detection helpers move behind conditional-import selectors in
lib/src/helpers/paths/andlib/src/helpers/test_mode/; FFI-only routines (getLibraryPath,_resolveTestBaseDir) move from the abstractDbasSqliteNativeInterfacedown intoDbasSqliteNativeAppBase(FFI-only, never loaded on web); the dead-codeprepareLibIfNeededis removed entirely. The web build graph no longer reachespath_providerordart:io.No public API change.
2.5.0 - 2026-05-06 #
Added #
DbasSqliteStatement.executeScalar({params, nameParams})— runs the prepared statement as a SELECT and returns the first column of the first row as adynamic(typed by SQLite column kind:int,double,String,Uint8List). Returnsnullwhen the query produces no rows or the first column is SQL NULL. Closes both the reader and the statement before returning, so the statement becomes single-use. Same input parameters and connection routing asexecuteReader.
Changed #
-
In-transaction read routing is now automatic.
executeReaderandexecuteScalarroute through a pool reader (native) or the writer worker (web) until the firstexecuteSqlruns in the current transaction; after that, subsequent in-tx reads switch to the writer connection so they observe the transaction's uncommitted writes (read-your-writes). Previously, in-tx reads always used the writer connection on native, serialising parallel pre-write validation behind the single writer. NowFuture.wait([executeReader, executeReader, ...])issued before any write in a transaction runs concurrently against the pool. After anyexecuteSql, the routing flips automatically; oncommit/rollbackit resets. No caller-side flag needed. -
Web in-transaction reads no longer throw. Previously, calling
executeReaderinside a transaction on web threwUnsupportedErrorbecause the bundled JS worker can't return SELECT rows through the writer-onlypool.execchannel. The library now routes web reads through the writer worker regardless of transaction state — the web pool fronts a single worker holding the writer connection, so SELECTs observe in-flight transactional state automatically. -
Web SELECT path is now streaming.
executeReader/executeScalaron web no longer materialise the entire result set in the worker before the first row reaches Dart. The platform layer uses the worker bundle's per-statement RPC so reads stream one chunk at a time across the worker boundary — matching the native FFI behaviour exactly.executeScalarover a 10k-row table now issues a singlereadRowround-trip instead of fetching all 10k rows. -
Web platform implementation unified with native. Web now implements the full per-stmt platform interface (
prepareQuery/bind*/readRowAndCache/finalizeStmt/getStmt*).DbasSqliteStatementandDbasSqliteReaderno longer have anykIsWebbranches — both platforms run the exact same Dart code path; only the platform-delegate implementation differs.- On web,
bind*calls buffer Dart-side and flush via onebindParamsround-trip on the first step, matching the worker's batch-bind shape. - The first row fetch uses the worker's single-row
readRowaction soexecuteScalarissues exactly one row's worth of work and no waste; subsequent fetches use the chunkedreadRowsaction with a 50-row chunk so a 10k-row scan is ~200 round-trips instead of the ~10000 a per-row pipeline would require (worker bundle v4.5.0). - Per-stmt counters (
getStmtAffectedRows/getStmtLastInsertedId) are eagerly captured on everySQLITE_DONEstep (covering plain DML,INSERT … RETURNING, and SELECT readers alike), so the synchronous platform getters return correct values without extra round-trips at read time. - Statements that mix
?N(positional) and:name(named) markers are bound via twobindParamsworker calls (one per shape); SQLite's bind slots are independent so the calls accumulate, matching native FFI's per-slot bind semantics.
Internally, the
WebQueryBuffer/WebRowStreamshims, theexecuteStatementWrite/executeStatementReadentry points, and the_executeSqlWeb/_executeReaderWebbranches inDbasSqliteStatementare all gone. Public API surface is unchanged. - On web,
Fixed #
-
Empty SELECT result sets now expose column metadata on web. The pre-2.5.0 web path could only recover column names from row 0, so
getColumnCount()/getColumnName(i)returned0/''for an empty result. The streaming path captures column metadata fromprepareQuery, so the metadata is populated before the firstreadRow()step regardless of whether any rows match. -
Large SQLite
INTEGERvalues on web round-trip as Dartint. Values outside the int32 range (which the worker emits as JS BigInt) are now classified asINTEGER(type 1) and materialised through JSNumber(bigint)into a Dartint, matchinggetColumnInt(idx)on native. Previously these would surface as TEXT (type 3) because the Dart-side type-detection branch fell through. Values within the 53-bit Dart-on-web safe integer range are exact; values beyond that are truncated, which matches Dart's ownintprecision on web.
2.4.4 - 2026-05-05 #
Re-publish of 2.4.1. Earlier release-pipeline runs (2.4.1 – 2.4.3) were blocked by GitHub App configuration, pub.dev OIDC wiring, and a tag-pattern mismatch on pub.dev's automated-publishing config. Package contents are identical to what 2.4.1 was meant to ship.
2.4.1 - 2026-05-05 #
First public pub.dev release under the verified publisher dailysoftwaresystems.com. Functionally identical to 2.4.0 — this release exists to ship the build / packaging / governance fixes needed to publish.
Changed #
- License: relicensed from proprietary to Apache 2.0, matching
the sibling
DBAS.SQLitenative lib. The Apache license includes an explicit patent grant, which is appropriate for a plugin that ships prebuilt native binaries via FFI. - README install snippet updated to the pub.dev syntax
(
dbas_sqlite: ^2.4.1) instead of the git URL.
Fixed #
- macOS desktop link failure: the macOS podspec did not declare
s.libraries = 'c++', so consumer apps failed to link withUndefined symbols: std::__1::*, ___cxa_throw, ___gxx_personality_v0. Added the libc++ link declaration; iOS was already correct. - Windows desktop DLL bundling: the
<package_name>_bundled_librariesCMake variable still used the pre-rename name, so Flutter no longer saw the bundle declaration and the runner kept loading a stale DLL that didn't exportGetSqliteVersion. Renamed to match the new package name. - Android Gradle compile: replaced the
org.yaml.snakeyaml.Yamlpubspec parse inandroid/build.gradlewith a regex match — newer Gradle versions no longer ship snakeyaml on the default classpath. - AGP 9 forward-compat: added
android.newDsl=falsetoexample/android/gradle.properties. Flutter apps that depend on plugins are not yet supported on AGP 9 (flutter/flutter#181383) — this flag preserves the old DSL parsing so the build keeps working when AGP 9 lands. Remove it once Flutter completes its AGP 9 migration.
Added #
PipelineGitHub Actions workflow (.github/workflows/ci.yml): PR runsflutter analyze+ native tests + web integration tests; push-to-main with a bumpedversion:creates a GitHub release; tag push triggers OIDC publish to pub.dev.SECURITY.md— disclosure policy pointing security reports tosecurity@dailysoftwaresystems.com.CODEOWNERS— every PR requires review from the DBAS dev team.
Internal #
- Plugin renamed across native folders:
dbas_sqlite_flutter_pluginC++ classes →dbas_sqlite_plugin, KotlinDbasSqliteFlutterPlugin→DbasSqlitePlugin, SwiftDbasSqliteFlutterPlugin→DbasSqlitePlugin, podspec files renamed, Android namespacecom.dailysoftwaresystems.dbas.sqlite.flutter→com.dailysoftwaresystems.dbas.sqlite. No public Dart API change — the package name was alreadydbas_sqlitein 2.4.0.
2.4.0 - 2026-05-05 #
Breaking Changes #
- Package renamed from
dbas_sqlite_fluttertodbas_sqlite: update your imports and pubspec dependency. The library export path stays the same —package:dbas_sqlite/dbas_sqlite.dart. db.executeSql(...),db.executeReader(...)anddb.getLastInsertedId()removed. Replaced by an explicitDbasSqliteStatementreturned fromdb.prepareQuery(sql). The statement owns parameter binding and execution;getAffectedRows/getLastInsertedId/getLastErrormove from the database to the statement (per-statement, race-free under concurrent inserts).DbasSqliteReadercolumn accessors are unchanged from v2.3.x — only the path that produces a reader is new.
Added #
DbasSqliteStatement: prepared statement object with fluent positional and named bind methods,executeSql/executeReaderexecution modes, per-statementgetAffectedRows/getLastInsertedId/getLastError, andclose. The bind buffer survives a failed execute so the caller can fix one slot and retry.- Multiple statements + readers per database: the upgraded native lib lets multiple prepared statements live on a single connection; on Dart, two statements with overlapping
executeReadercalls each get their own pool slot and run in parallel. - Multi-isolate FFI worker pool: replaces the single worker isolate. Worker count auto-floors to
max(workerPoolSize, readerPoolSize + 2)so blocking pool acquires can never starve concurrent releases. Dead workers are removed from the dispatch rotation; dispatch is prefer-free over round-robin. PoolAcquireReaderBlockingintegration:executeReaderblocks up toDbasSqlite.kPoolAcquireTimeoutMs(default 30 s) for a free pool slot instead of silently falling back to the writer. On timeout, throwsTimeoutExceptionwith a clear message.- New utility methods on
DbasSqlite:getSqliteVersion,getTotalChanges,getDbFileName,setBusyTimeout,enableWal. - Web in-transaction reads route through
pool.exec(writer worker, EXCLUSIVE MRSW fence) so reads observe in-flight transactional state. - Web pool dead-state surfacing: when the JS pool can't return rows for an in-transaction SELECT (current bundled worker), the Dart side throws a clear
UnsupportedErrorinstead of silently returning empty. - Opaque FFI structs:
DbasSqliteDbStructandDbasSqlitePoolStructare nowOpaque {}. The native C lib has changed layout across versions; treating the structs as opaque eliminates the silent-misread risk and aligns with the C header's stated ABI policy. - 15 new tests covering: counter cache after reader auto-close, column metadata before first row, bind error rc surfacing, bind buffer preservation on failure,
setBusyTimeouttermination + busy-reader contract, multi-statement concurrency, statement reuse, per-statement state isolation, and forgotten-statement cleanup oncloseDb.
Changed #
prepareQueryat the platform layer now returns({int handle, int columnCount, List<String> columnNames})so column metadata is available to the reader BEFORE the firstreadRowcall.- Per-stmt counters are read BEFORE finalize in the reader's onClose closure. Reading them after finalize would always return -1 (stale-handle sentinel).
- Bind methods at the platform layer return
Future<int>; the FFI variant awaits the worker dispatch so bind errors (SQLITE_RANGE / SQLITE_TOOBIG / SQLITE_NOMEM / stale handle) propagate to the caller instead of being silently swallowed. closeDbcleanup discipline: tracked statements are closed first, then the pool is force-drained viaClosePool(defensive), then on the single-connection pathCloseDbis called and any returnedSQLITE_BUSYraises a loudStateErrorinstead of silent leak.rollbacknow wraps a failedROLLBACKin aStateErrorand rethrows so callers know the C-side autocommit state may be inconsistent — instead of silently clearing_isInTransaction.dropDbnow attempts every deletion (.db,-wal,-shm,-journal) and aggregates failures into a singleFileSystemExceptionso partial cleanup is impossible.DbasSqliteReader.close()caches its close future so concurrent close calls (auto-close onDONE+ explicit close) all observe the same completion instead of one returning early while cleanup is still mid-flight.DbasSqliteReader.readRow()error-path readsgetLastStmtError(handle)(per-stmt) instead ofgetLastDbError(conn)(connection-scoped) — fixes a v2.3.x latent bug where errors from one statement's step could be masked by another's.- Web
enableWalnow actively verifies viaPRAGMA journal_modeinstead of a silent no-op.
Removed #
setWriteMode/beginTransactionLease/endTransactionLeaseindirection onDbasSqliteNativeInterfaceand its forwarders. Direct routing throughpool.exec/pool.querymakes them obsolete.DbasSqliteNativeAppIO/AOT variant (dbas_sqlite_native_app_io.dart): the conditional export selector always picked the FFI variant on every platform that hasdart.library.ffi, which is every Flutter target except web. The IO/AOT variant was dead code; removed.- Old
lib/src/native/dbas_sqlite_row_cache.dart: relocated tolib/src/dbas_sqlite_row_cache.dart. The cache is now an owned-by-reader concern, not a native-internal concern. Per-stmt counter / lastError fields removed fromRowDatasince they live onDbasSqliteStatement.
Migration Guide #
// Before (2.3.x)
import 'package:dbas_sqlite_flutter/dbas_sqlite.dart';
final affected = await db.executeSql(
'INSERT INTO users (name) VALUES (?)',
params: ['Alice'],
);
final id = db.getLastInsertedId();
final reader = await db.executeReader(
'SELECT * FROM users WHERE id > ?', params: [0],
);
while (await reader.readRow()) { ... }
await reader.close();
// After (2.4.0)
import 'package:dbas_sqlite/dbas_sqlite.dart';
final insertStmt = await db.prepareQuery('INSERT INTO users (name) VALUES (?)');
try {
final affected = await insertStmt.executeSql(params: ['Alice']);
final id = insertStmt.getLastInsertedId();
} finally {
await insertStmt.close();
}
final selectStmt = await db.prepareQuery('SELECT * FROM users WHERE id > ?');
try {
final reader = await selectStmt.executeReader(params: [0]);
try {
while (await reader.readRow()) { ... }
} finally {
await reader.close();
}
} finally {
await selectStmt.close();
}
A statement can be reused with different params per execute — 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();
}
2.3.0 - 2026-04-13 #
Breaking Changes #
executeReadernow returnsDbasSqliteReader: Instead of storing reader state on theDbasSqliteinstance,executeReaderreturns an independentDbasSqliteReaderobject. All column access methods (getColumnText,getColumnInt,readRow,isColumnNull, etc.) are now on the reader, not onDbasSqlite.closeReader()removed fromDbasSqlite: Usereader.close()on the returnedDbasSqliteReaderinstead.readRow()removed fromDbasSqlite: Usereader.readRow()on the returnedDbasSqliteReaderinstead.- All
getColumn*methods removed fromDbasSqlite: Use the corresponding methods onDbasSqliteReaderinstead. - Readers must be explicitly closed: The old auto-cleanup (
_closePendingReader) no longer exists. Readers that don't exhaust all rows must be closed withreader.close()before the connection can be reused.readRow()still auto-closes when it returnsfalse.
Added #
DbasSqliteReaderclass: Independent reader object returned byexecuteReader. Each reader owns its own database connection (from the pool or writer fallback) and prepared statement. Multiple readers can coexist simultaneously, enabling parallel reads.getColumnValue(index)onDbasSqliteReader: Returns the typed value of a column based on its SQLite type (int, double, text, blob, or null).- Active reader tracking:
DbasSqlitenow tracks all open readers.closeDb()automatically closes every active reader before shutting down the pool/connection, preventing use-after-free on lingering readers. - Exported
DbasSqliteReaderfrom the package barrel file.
Changed #
- Pool reader acquisition is non-blocking:
executeReadernow tries to acquire a pool reader without waiting. If all readers are busy, it falls back to the writer connection immediately instead of blocking. - Reader lock removed: The serializing reader lock (
_acquireReaderLock/_releaseReaderLock) is no longer used byexecuteReader, since each reader independently manages its own pool connection lifecycle. closeDb()closes active readers: All openDbasSqliteReaderinstances are closed before the database connection is shut down, ensuring pool connections and writer locks are properly released.
Migration Guide #
// Before (2.2.x)
await db.executeReader('SELECT * FROM users');
while (await db.readRow()) {
print(db.getColumnText(0));
}
await db.closeReader();
// After (2.3.0)
final reader = await db.executeReader('SELECT * FROM users');
while (await reader.readRow()) {
print(reader.getColumnText(0));
}
await reader.close();
Multiple parallel readers are now possible:
final r1 = await db.executeReader('SELECT * FROM orders');
final r2 = await db.executeReader('SELECT * FROM products');
// Both active simultaneously, each on their own pool connection
while (await r1.readRow()) { /* ... */ }
while (await r2.readRow()) { /* ... */ }
await r1.close();
await r2.close();
2.2.0 - 2026-04-11 #
Breaking Changes #
- Unified writer lock: The async writer lock now applies on both web and native (previously web used a separate lease mechanism). Concurrent
executeSqlcalls on web are now properly serialized instead of interleaving atawaitpoints. This fixes data corruption from concurrent writes but means web writes are now queued, matching native behavior. - Web
executeSqlerrors propagate:DbasSqliteNativeWeb.executeSqlno longer catches exceptions and returns-1. Errors fromBEGIN TRANSACTION,COMMIT, andROLLBACKnow propagate to callers instead of being silently swallowed. - Web
databaseExistspropagates infrastructure errors: Previously returnedfalsefor any error (including OPFS unavailable, worker crash). Now uses the worker'sexistsaction and lets infrastructure failures propagate.
Added #
- Background isolate FFI worker: All heavy native FFI operations (
executeSql,prepareQuery,readRow,openDb,closeDb,createPool,closePool) now run on a dedicated background isolate viaDbasSqliteIsolateWorker. Bind operations remain on the main isolate for synchronous access. This prevents FFI calls from blocking the UI thread. - Row data cache (
RowData/ColumnData): Shared between native and web paths. AfterreadRow, all column values are cached in Dart memory for synchronous access — no FFI round-trips forgetColumn*calls. - True streaming web attach (
attachStreamBegin/attachStreamChunk/attachStreamEnd): Database imports on web now stream chunk-by-chunk to the worker with ACK-based backpressure. The complete database is never buffered in Dart memory — critical for 500 MB+ databases. - Streaming web export:
getContent()on web now uses theexportStreamprotocol, handling both Transferable Streams (Chrome/Firefox) and chunked postMessage fallback (Safari) with ACK-based backpressure. - BigInt handling for
lastInsertId: Emscriptenlong longreturns (JS BigInt) are now correctly converted to Dartintvia JSNumber()interop. List<int>blob binding:executeSqlandexecuteReadernow accept plainList<int>in addition toUint8Listfor blob parameters.- C-level connection pool with mutex: The native C library pool (
CreatePool/PoolAcquireReader/PoolReleaseReader) now haspthread_mutex_t(POSIX) /CRITICAL_SECTION(Windows) protection for thread-safe reader acquire/release. transaction()rollback error reporting: If both the action and rollback fail, aStateErroris thrown containing both error messages instead of silently discarding the rollback failure.- 88 native unit tests, 25 web integration tests.
Changed #
- Web pool architecture: Replaced the old multi-slot web pool with a per-database
DbasSqliteWebPoolbacked by a single Web Worker. Each database gets its own worker with OPFS persistence. - Web worker protocol: Updated to match DBAS.SQLite 3.1.x worker —
exec,query,batch,drop,streamCopy,attachStreamBegin/Chunk/End,exportStream,exists,close. close()ordering:DbasSqliteWebPool.close()now sends theclosecommand to the worker before setting_closed = true, ensuring the worker gets a chance to flush WAL data and release OPFS locks.- Platform delegate re-creation:
DbasSqlitePlatform.createPoolandopenDbnow lazily re-create the delegate afterdropDbremoves it, fixing null pointer crashes on the drop → open cycle. importScriptsURL: ThelibUrlsent to the web worker is now relative to the worker script location (dbas_sqlite.js) instead of the page root, fixing doubled-path errors.
Fixed #
- Concurrent writes on web: Three or more concurrent
executeSqlcalls no longer corrupt shared buffered state (_pendingSql,_isWriteQuery). The unified writer lock serializes them. getLastInsertedIdreturning 0 on web: The Emscriptenlong longreturn value (JS BigInt) is now correctly converted to Dartint.- Blob binding for
List<int>:List<int>.generate(...)and other non-Uint8Listinteger lists are now accepted as blob parameters. close()not sending worker shutdown: The worker now receives thecloseaction before termination.postMessageerrors leaking completers: IfpostMessagethrows (e.g.DataCloneError), the registered handler/completer is cleaned up and completed with an error instead of hanging forever.attachStreamAbortwrong ID: The abort message now uses the original session ID for correct worker-side correlation._readStreamToBytesreader lock leak: TheReadableStreamreader lock is now released in afinallyblock on both success and error paths.- Unknown ReadableStream chunk types:
_readStreamToBytesnow throwsStateErroron unrecognized chunk types instead of silently dropping bytes. exportContentStreamhang: Added 120-second timeout to prevent indefinite hangs if the worker stops responding.- Isolate
ReceivePortstream errors: AddedonErrorhandler that fails all pending requests instead of leaving them hanging.
Removed #
DbasSqliteConnectionPool: Replaced by the C-level pool managed throughDbasSqliteNativeInterface.- Web transaction lease methods:
beginTransactionLease/endTransactionLeaseare now no-ops — transactions use the unified writer lock.
2.1.2 - 2026-04-09 #
- Web streamed attach:
attachStreamDbnow sends chunks individually to the Web Worker via a begin/chunk/end protocol instead of buffering the entire file in Dart memory - Renamed database directory from
datatodbas_dataacross all platforms - Improved error handling: cleanup failures during stream attach are now logged instead of silently swallowed
- Updated
attachStreamDbdoc comment to reflect the new OPFS-backed streaming implementation
2.1.1 - 2026-04-07 #
- Adjust pipes
2.1.0 - 2026-04-07 #
- Adjust pipes
2.0.10 - 2026-04-06 #
- Adjust pipes
2.0.9 - 2026-04-06 #
- Fixed
executeSqlandexecuteReaderonly catching SQLite error codes -1 and 1 fromprepareQuery— all non-zero codes (e.g. SQLITE_BUSY, SQLITE_NOMEM) are now properly detected, preventingreadRowfrom operating on a NULL statement - Fixed
_bindParametersonly catching error codes -1 and 1 — all non-zero bind results (e.g. SQLITE_RANGE for out-of-bounds index) are now caught - Fixed writer-lock deadlock when
executeReaderorexecuteSqlis called while a previous reader session is still open (e.g. caller read partial rows without callingcloseReader); pending readers are now automatically closed before acquiring locks - Fixed
executeSqlnot finalizing the prepared statement whengetAffectedRowsthrows —closeReaderis now guaranteed via try-finally - Error messages from
prepareQueryand_bindParametersfailures now include the SQLite error code for easier debugging getLastDbErroris now captured beforecloseReaderon prepare failures to prevent potential loss of error context
2.0.7 - 2026-04-06 #
- Unified
readRowresponse handling betweenexecuteSqlandreadRowinto a shared_readRowAndValidatemethod - Replaced magic number
20with_sqliteMisuseconstant
2.0.6 - 2026-04-06 #
- Named parameter binding now silently skips parameters not found in the prepared statement, matching C#/SQLite behavior
- Extra named parameters no longer throw — only actual bind errors are raised
- Added
throwOnMissingNamedParamsoption to throw on unknown named parameters (defaults tofalse)
2.0.3 - 2026-04-04 #
- Updated minimum platform versions: Android API 35, iOS 16.0, macOS 13.0 (Ventura)
- Updated Android compileSdk to 35, NDK r29
- Fixed CocoaPods base configuration warnings on macOS
- Fixed
--project-rootflag in run scripts causing Flutter crash - Fixed glob patterns in
sync_sqlite_lib.sh
2.0.1 - 2026-04-03 #
- Connection Pool (WAL mode):
openDb()now creates a pool with 1 writer + N readers (default 4), configurable viareaderPoolSizeparameter - Pool is fully automatic and transparent -- reads use pool readers, writes use the writer, no API changes needed
- Falls back to single connection if pool creation fails or
readerPoolSize = 0 - Thread safety: Writer mutex serializes all write operations (executeSql, transactions). Reader mutex serializes read sessions. Writer and reader locks are independent, allowing concurrent reads and writes via WAL mode
- Transactions hold the writer lock for their full duration; reads within a transaction use the writer connection to see uncommitted data
- Web Worker architecture: WASM module now runs inside a dedicated Web Worker (required for OPFS
createSyncAccessHandle). Bind calls are buffered and flushed to the worker onreadRow. Column data is pre-fetched and cached for sync access - New:
streamCopyDb(destDbName)- Stream-copy the current database to a new name with automatic cleanup of destination WAL/SHM files - New:
attachStreamDb(stream)- Attach a database from a byte stream - New: Connection pool support wired through the full native stack (C FFI, IO/AOT, Web, stubs)
- New:
DbasSqlitePoolStructFFI struct mapping the CSQLitePoolstruct - Updated native C library with pool functions:
CreatePool,PoolGetWriter,PoolAcquireReader,PoolReleaseReader,ClosePool - Updated JS wrapper with pool support and OPFS persistence
closeDb()properly cleans up pool, releases all locks, and unblocks any waiterscloseReader()releases the correct lock (reader lock for pool readers, writer lock for fallback)- Added
isOpened()guards after lock acquisition to handlecloseDbduring pending operations - 55 unit tests covering pool, thread safety, concurrent operations, transactions, and all data types
1.6.2 - 2026-03-12 #
C SQLite lib ReadRowcapture error messages inside last error
1.6.1 - 2026-03-11 #
commit()now performs automatic rollback if the COMMIT fails- Added
syncWebDb: truetobeginTransaction(),commit()androllback()for web persistence
1.6.0 - 2026-03-11 #
- Added Transaction API:
beginTransaction(),commit(),rollback() - Added
transaction()helper with automatic commit and rollback on error - Added
isInTransactiongetter to check active transaction state closeDb()now automatically rolls back any pending transaction before closing- Fixed typo in
_bindParameterserror message (extra})
1.5.1 - 2026-03-11 #
- Podspec versions now automatically read from
pubspec.yaml - Updated README installation version reference
1.5.0 - 2026-03-11 #
- Refactored native layer with Template Method pattern (
DbasSqliteNativeAppBase) - Added FFI implementation (
dbas_sqlite_native_app_ffi.dart) withDynamicLibraryloading - Added IO/AOT implementation (
dbas_sqlite_native_app_io.dart) with@Nativeannotations - Introduced platform selector (
dbas_sqlite_native_app_selector.dart) with conditional exports - Simplified
closeDbimplementation - Adjusted pipes and build configuration
1.4.8 - 2026-03-11 #
- Simplified
closeDbflow
1.4.7 - 2026-03-11 #
- Fixed memory leaks in
dbas_sqlite_native_app_io.dart
1.4.6 - 2026-03-11 #
- Internal adjustments and fixes
1.4.5 - 2026-03-11 #
- Fixed reader resource leaks
1.4.4 - 2026-03-10 #
- Internal improvements
1.4.3 - 2026-03-10 #
- Removed unused imports
1.4.2 - 2026-03-10 #
- Adjusted
dropDbbehavior - Removed unused imports
1.4.1 - 2026-03-10 #
- Enhanced web platform support
- Updated versions and dependencies
- Updated example project iOS version
- Updated Flutter plugins
1.4.0 - 2026-03-10 #
- Upgraded SQLite to version 3.52.0
- Updated all native binaries for all platforms
1.3.1 - 2026-02-28 #
- Added
getColumnTime()to readDurationvalues from columns - Added
getColumnNullableTime()nullable variant
1.3.0 - 2025-11-07 #
- Upgraded SQLite to version 3.51.0
- Updated all native binaries for all platforms
1.2.12 - 2025-11-06 #
- Enhanced
boolbinding support —true/falsemapped to1/0
1.2.11 - 2025-11-05 #
- Enhanced error messages for better debugging
1.2.10 - 2025-11-05 #
- Enhanced
isOpened()reliability
1.2.9 - 2025-11-05 #
- Added existence check before
dropDbto prevent errors on non-existent databases
1.2.8 - 2025-11-05 #
- Automatically close database before dropping it
1.2.7 - 2025-11-05 #
- Added
closeReader()as a public method
1.2.6 - 2025-11-05 #
- Reader now auto-closes when all rows have been read (
readRowreturnsfalse)
1.2.5 - 2025-11-05 #
- Fixed
closeReaderbehavior - Fixed
getContentto properly read database file bytes
1.2.4 - 2025-11-05 #
- Fixed error handling order in SQL execution
1.2.3 - 2025-11-05 #
- Enhanced error reporting for failed
readRowoperations - Added misuse detection (error code 20) with descriptive message
1.2.2 - 2025-11-05 #
- Enhanced
getLastDbErrorhandling
1.2.1 - 2025-11-05 #
- Fixed
getLastDbErrornull pointer handling
1.2.0 - 2025-11-05 #
- Fixed parameter binding — both positional and named parameters
- Added
executeSqloverload withparamsandnameParamssupport - Added
executeReaderoverload withparamsandnameParamssupport
1.1.7 - 2025-10-25 #
- Added
getContent()to read raw database file bytes
1.1.6 - 2025-10-24 #
- Fixed
getColumnNamereturn value handling
1.1.5 - 2025-10-24 #
- Added
getColumnName(index)to retrieve column names from query results
1.1.4 - 2025-10-20 #
- Synced native libraries across all platforms
1.1.3 - 2025-10-20 #
- Added
dropDb()to delete database files (including WAL and SHM)
1.1.2 - 2025-09-03 #
- Added
getLastInsertedId()to retrieve the last auto-increment row ID
1.1.1 - 2025-09-01 #
- Fixed naming conventions
1.1.0 - 2025-09-01 #
- Added
attachDb(bytes)to create/replace a database from raw bytes - Added
databaseExists()to check if the database file exists - Added support for multiple database instances via
getInstance(dbName:)
1.0.6 - 2025-08-13 #
- Added
attachDboption for importing databases from byte arrays
1.0.5 - 2025-08-08 #
- Fixed public exports
1.0.4 - 2025-08-08 #
- Added
getColumnDateTime()andgetColumnNullableDateTime()for DateTime columns - Added
getColumnEnum()andgetColumnNullableEnum()for enum columns - Added
getColumnBool()andgetColumnNullableBool()for boolean columns - Added
getColumnDecimal()andgetColumnNullableDecimal()for Decimal columns - Added nullable variants for all column getters
1.0.3 - 2025-08-08 #
- Added
GetColumnNamefeature at native level
1.0.2 - 2025-08-07 #
- Enhanced native library bundling for all platforms
1.0.1 - 2025-08-07 #
- Enhanced CMakeLists for Windows and Linux builds
- Improved automatic DLL/SO copy in post-build steps
1.0.0 - 2025-08-06 #
- 🎉 First stable release
- Cross-platform support: Android, iOS, macOS, Linux, Windows, Web
- Core SQLite operations:
openDb,closeDb,executeSql,prepareQuery,readRow - Parameter binding by index (1-based) and by name (
:param,@param,$param) - Column data retrieval: text, int, float, double, blob, null check, column type, column count
getAffectedRows()andgetLastDbError()- Web support via JavaScript SQLite with IndexedDB persistence
- Native FFI integration for mobile and desktop platforms
- xcframework for iOS and macOS
- Automatic native library bundling via CMake (Windows, Linux) and podspec (iOS, macOS)
- CI/CD pipeline
- Example app with basic usage
- Unit tests for core operations
0.x.x - 2025-07-26 to 2025-08-06 #
- Initial development and platform bring-up
- WIP implementations for all platforms
- SQLite FFI layer development
- Test infrastructure setup