Skip to content

feat: add modern nativescript-sqlite - #4

Open
edusperoni wants to merge 16 commits into
mainfrom
feat/sqlite-plugin
Open

edusperoni wants to merge 16 commits into
mainfrom
feat/sqlite-plugin

Conversation

@edusperoni

Copy link
Copy Markdown
Owner

No description provided.

@edusperoni
edusperoni force-pushed the feat/sqlite-plugin branch from f814e56 to 99af821 Compare May 20, 2026 03:52
edusperoni and others added 11 commits June 23, 2026 21:22
…MA key

* The key was interpolated into the statement unquoted, so a key containing a single quote produced invalid SQL and failed the open.
* That also made SQLCipher raw keys unusable: they are recognised from the string value x'<hex>', which could not survive the broken quoting.
* Add encryptionKeyFormat: 'passphrase' | 'raw' (default passphrase). A raw key skips the PBKDF2 derivation each pooled connection would otherwise run, which costs no strength when the key is full-entropy random.
* Reject a raw-looking key that did not ask for raw. SQLCipher switches to key bytes on its own for anything shaped like x'<64 hex>', and which key you get must not depend on the shape of a string.
* Verified against the SQLCipher CLI: the escaped form opens a raw-keyed database, while an unquoted operand is a blob literal that PRAGMA rejects outright.
@nrwl/js is no longer installed, so any change that makes these projects
affected failed the build job with 'Unable to resolve @nrwl/js:tsc'.
… a single writer on both platforms (#8)

* feat: add modern nativescript-sqlite

* fix: transactions should wait on on another

* chore: revert version number to 0.0.1 in package.json

* chore: 0.0.2

* feat: add encryption support and new getArray methods to SQLite plugin

* feat: add android v8 sqlite implementation

* Remove headers from the Android native SQLite implementation

* fix(sqlite): free Android DBInstance on close and GC

* fix(sqlite): stop silently truncating multi-statement SQL

* test(sqlite): add implementation-agnostic benchmark suite

* test(sqlite): assert row counts in bulk read benchmarks

* test(sqlite): report benchmark failures through a file

* test(sqlite): verify row counts after write benchmarks

* feat(sqlite): build the Android implementation against V8 14.9

Targets the V8 embedded in @nativescript/android 9.1. The public V8 headers
are no longer vendored: include.gradle downloads the release pinned in
native/v8-headers.properties, verifies its SHA-256 and caches the extracted
headers under the Gradle user home. nscsqlite.v8IncludeDir bypasses the
download and nscsqlite.v8HeadersCacheDir relocates the cache.

The link stub now comes from whichever runtime flavor is present, since all
of them export the public V8 API.

* chore(demo): use @nativescript/android 9.1

* perf(sqlite): serve awaited statement chains without returning to the looper

A statement dispatched from the continuation of the previous one is answered
by the spinning worker within microseconds. Going back to the looper in
between blocks the JS thread in epoll_wait and costs a thread wake-up per
statement, which dominated awaited loops (about 18us per statement on
@nativescript/android 9.1, against 1us of actual work). The drain now keeps
polling the queues fed during the batch it just ran, bounded per completion
and per looper callback.

Completion queues are shared-ownership and the drain no longer touches the
dispatcher after running a completion, because close() deletes the
DBInstance from inside its last one.

* test(sqlite): add a headless correctness-test trigger

nscbench=test runs the demo's correctness suite through a wrapper that calls
each test individually and writes pass/fail plus the captured console output to
nscsqlite-test-<label>.json, so a release build can be checked from adb.
testSQLCipher is skipped unless the runtime reports a SQLCipher build.

* feat(sqlite): implement the Android binding on Node-API

Mirrors the V8 backend's JS-visible API on top of Node-API: the same NSCSQLite
class, argument handling, result shapes and error codes, the same per-connection
FIFO dispatchers, and the same JSON fast path for async results.

Completions reach the JS thread through one threadsafe function per env, which
drains the whole pending queue per signal; when a continuation dispatches more
work the drain spins briefly for the answer instead of paying a looper wake-up
per awaited statement.

* feat(sqlite): select the Android binding backend at build time

nscsqlite.backend=v8|napi (or NSCSQLITE_BACKEND, since Gradle cannot pass a
dotted property through the environment) picks the source set and the headers:
the napi build takes Node-API headers from the runtime AAR's Prefab package and
never downloads or includes V8.

* feat(sqlite): resolve the native class from the napi module when needed

The napi backend has no global to install into: its Node-API module is reached
through the runtime's own require(), which webpack must be kept from rewriting.

* feat(sqlite): report the real SQLite build in getRuntimeInfo on iOS

The iOS side answered with a hard-coded stub, so the one API that tells
an app which SQLite it actually linked was useless on the platform where
the app chooses that SQLite itself.

The compile-option enumeration is guarded: a SQLite built with
SQLITE_OMIT_COMPILEOPTION_DIAGS has no sqlite3_compileoption_get to link
against.

* build(sqlite): let the app choose the Android SQLite, and download the presets

Android had no equivalent of what iOS gets for free: on iOS the plugin
declares no SQLite dependency and links whatever the Podfile provides, so
an app can ship a custom engine without the plugin knowing. Android has
no link line an app can influence, so the equivalent is a directory of
its own:

    <App_Resources>/Android/nscsqlite/CMakeLists.txt

defining one target, `nscsqlite_sqlite`, which the plugin add_subdirectory()s
and links. `nscsqlite.sqliteProjectDir` overrides the location.

`bundled` and `sqlite3mc` are now instances of that same contract rather
than a second code path, so the presets double as worked examples. The
`sqlcipher` preset is gone: sqlite3mc compiled with CODEC_TYPE_SQLCIPHER in
legacy mode reads and writes SQLCipher 4 databases through the same
`encryptionKey`, bundles its own crypto, and does not drag a ~3 MB
libcrypto.so per ABI into the APK — while the old preset only built at all
because its vendored amalgamation had been patched away from upstream.
`custom` is gone because it never worked: the NDK toolchain re-roots
find_library() under the NDK, so it could not see the user's directory.

Both amalgamations are downloaded and checksum-verified at build time
instead of being vendored, which removes 572k lines from the repository.
The upstream zip is byte-identical to the amalgamation it replaces.

`nscsqlite.sqliteFlags` is now additive on top of each preset's defaults
rather than replacing them, with a required set the user cannot drop.

Two link options apply to every configuration now: --exclude-libs,ALL, so
libnscsqlite.so exports no sqlite3_* symbols and cannot be bound to by
another SQLite in the process, and max-page-size=16384, which Play requires
for apps targeting API 35+.

* feat(sqlite): bind through Node-API by default on Android

The V8 backend reaches into the runtime's V8 through its public C++ headers,
which are not part of any stable ABI: the pointer-compression defines and the
header version have to match the V8 inside @nativescript/android exactly, so
every runtime upgrade needs a matching header pin and a rebuild. Node-API is
versioned and stable, and the default build now downloads nothing to compile
against.

The V8 backend stays selectable with nscsqlite.backend=v8 as the reference
implementation the Node-API one is measured against.

* perf(sqlite): call the Android native object directly instead of through a Proxy

Every property access on the database object went through a Proxy `get`
trap that minted a fresh closure, so each of the thousands of calls a busy
app makes allocated one and defeated inline caching — all to rewrap native
errors as SQLiteError. The rewrapping is now explicit at each call site,
which is more lines but no allocation and a monomorphic call.

* feat(sqlite): bring the Android database options to parity with iOS

encryptionKeyFormat, onOpen and serialized existed only on iOS, so an app
using any of them could not run on Android at all. The three land together
because they share one ordering constraint inside the open sequence:
PRAGMA key, then onOpen, then journal_mode=WAL and query_only. onOpen has
to see the decrypted database and still precede every query, and WAL has to
come after both.

The PRAGMA key operand is now quoted the way iOS quotes it, so a key
containing a single quote can neither break the statement nor inject into
it, and SQLCipher's raw-key form still reaches the codec as text.

serialized runs everything on one connection. That connection is opened
FULLMUTEX rather than NOMUTEX because on Android the synchronous methods
run on the JavaScript thread while async work runs on a dispatcher thread,
and both use the same handle. Note the resulting ordering difference from
iOS, which queues sync calls behind async ones with dispatch_sync: an
un-awaited async write followed by a sync read may not see the write here.

Failures during open now surface as an open error carrying the SQLite code
rather than succeeding and failing on the first query, which is what iOS
has always done; a wrong encryption key is the case that matters. The open
error also reaches JavaScript with a numeric code, so the TypeScript layer
can build a real SQLiteError instead of a bare Error.

Every public operation on a connection now holds a recursive mutex. SQLite's
own FULLMUTEX serializes individual calls but not the "call fails, then read
sqlite3_errmsg" sequence: another thread preparing a statement on the same
handle frees the pending error, so a failing statement could read freed
memory or report the other thread's message.

The *Sync methods and getRuntimeInfo reported a closed database as a
rejected promise, which no caller could catch; they throw now, and only the
async methods reject.

The compile-option enumeration in getRuntimeInfo is guarded: a SQLite built
with SQLITE_OMIT_COMPILEOPTION_DIAGS has no sqlite3_compileoption_get to
link against.

* fix(sqlite): checkpoint the WAL when the database is closed

close() dispatched every connection's close concurrently on its own thread,
so each one could still see its siblings attached. SQLite only checkpoints
and unlinks the -wal file when the closing connection can take an exclusive
lock on the database, which none of them could get; the WAL was left on disk
until some later close in the same process happened to win the race. Seen in
half of the device runs, with a 450 KB -wal beside a 4 KB database.

The readers now close first and the writer only once the last of them is
done.

* test(sqlite): cover the new options and encrypted WAL on a device

The correctness suite decided whether to run its encryption test by looking
for the compile option EXTRA_INIT=sqlcipher_extra_init, which sqlite3mc does
not define — so the test silently skipped on exactly the build that can
encrypt. No compile option or pragma identifies a codec across engines;
whether a keyed database is unreadable without its key does, so that is the
probe now. It also makes the test meaningful: it previously only checked
that a keyed database round-trips, which a plaintext build passes too.

The encrypted-WAL test exercises a keyed database under the writer + reader
pool with WAL: concurrent readers against a running writer, prepared
statements, the sync connection, durability across close and reopen, wrong
and missing keys, and a fixture written by official SQLCipher on the host.
It adapts to the build it finds: on a codec-less SQLite it runs the same
sequence unkeyed, so WAL, the pool and durability are still covered, and on
the bundled preset it requires the keyed open to be refused.

* docs(sqlite): document the Android SQLite build and how to replace it

The Android half of the README described three flavours that no longer
exist. It now covers choosing between the two presets, what sqlite3mc does
and does not give you, the cost of a passphrase, and the directory an app
drops in to compile its own SQLite — with five worked CMakeLists files under
docs/android-custom-sqlite, four of them reduced from builds that were
actually linked against the plugin.

Encryption caveats are their own section because they are not an Android
topic: `PRAGMA key` against an engine with no codec succeeds and writes
plaintext on both platforms, and the only proof that works on any engine is
to reopen a keyed database without its key and require that to fail.

* test(sqlite): satisfy the demo typecheck in the encrypted-WAL test

`PRAGMA integrity_check` was read through a generic that does not satisfy
the SQLiteRow constraint, and the codec mode was assigned from inside a
callback, which narrowed it away from its declared type at the point it is
read.

* fix(sqlite): write the whole blob placeholder in iOS select results

The prefix was appended with a hard-coded length of 11 against a
12-character literal, so the trailing colon never made it into the JSON.
Any select returning a BLOB therefore produced `{"__blob__"0}` and the
result failed to parse, taking the app down with a SyntaxError.

Deriving the length from the literal keeps the two from drifting apart
again.

* fix(sqlite): report the primary SQLite code in SQLiteError on iOS

Extended result codes are enabled on every connection, so sqlite3_errcode
returns the extended form and a UNIQUE violation surfaced as 2067 rather
than the SQLITE_CONSTRAINT (19) the exported constants describe. Code
comparing against those constants never matched.

extendedCode is unchanged and still carries the full value.

* feat(sqlite): describe the single-writer connection model in the API

The synchronous methods used to run on a connection of their own, so a
sync write and an async write were two writers on one file arbitrated only
by the busy timeout — and a sync write issued while an async transaction
was open stalled the JavaScript thread for the whole timeout and then
failed, because that transaction needed the JavaScript thread to finish.

They now share the writer with the asynchronous methods, ordered behind
whatever is already queued on it. That makes the semantics worth stating
rather than discovering: a sync read sees an open transaction's uncommitted
rows, a sync call after un-awaited writes sees all of them, and executeSync
refuses to silently join a transaction.

SyncTransaction, transactionSync(), the sync methods on the transaction
objects and the joinTransaction escape hatch are the sanctioned ways to do
synchronous work inside a transaction. asyncOpen and initialized() move
connection opening — which with a passphrase is a PBKDF2 derivation per
connection — off the JavaScript thread.

* feat(sqlite): run Android sync calls on the writer, and open connections off the JS thread

The dedicated sync connection is gone. Every synchronous method now runs on
the writer, claimed through the writer pool's queue mutex: inline on the
calling thread when the pool is idle (12-14 ns, no thread hop and no wake)
and otherwise queued behind the work already there, so sync and async calls
are mutually exclusive and FIFO. The claim is re-entrant for the thread
holding it, which is what lets transactionSync hold the writer across a
whole callback while everything dispatched during it stays queued.

Because the exclusion is ours, serialized mode goes back to
SQLITE_OPEN_NOMUTEX and regains the statement cache.

Connections now open on the thread that owns them, so a passphrase costs
one derivation on the JavaScript thread instead of poolSize + 2. asyncOpen
moves the writer's open off it too, and initialized() reports the outcome;
its promise is created per call, so an open that fails with nobody watching
cannot become an unhandled rejection.

commitTransaction and rollbackTransaction previously discarded the result of
COMMIT/ROLLBACK and always resolved — a failed commit looked like success.
They report it now, and a failed COMMIT is followed by a ROLLBACK, because
leaving the transaction open would let the next queued write join it.

Also fixes a pre-existing teardown race: the V8 dispatcher closed its
eventfd before joining the worker that writes to it, so a close racing an
in-flight statement could write to a reused descriptor.

* feat(sqlite): run iOS sync calls on the writer, and open connections off the JS thread

Mirrors the Android change. The dedicated sync connection is gone: every
synchronous method resolves the connection and queue that own its work and
runs through one dispatch_sync, so there is no serialized/pooled split left.
transactionSync holds the writer for the whole callback by suspending its
queue, so work dispatched from inside the callback stays queued until the
transaction has committed or rolled back.

Readers are opened by their own queues, behind a group the writer leaves,
because only the writer opens with SQLITE_OPEN_CREATE and a reader that won
the race against a database that did not exist yet failed with
SQLITE_CANTOPEN. A reader that fails to open is no longer skipped: it keeps
its slot and reads routed to it report why it failed.

A synchronous open failure raised an NSException and the TypeScript layer
read the SQLite code from nativeException.userInfo, which @nativescript/ios
8.9.5 does not expose at all — so every such failure reached JavaScript as
SQLITE_ERROR whatever the cause. The open returns an NSError instead and
both routes now produce the same message and the same code.

The sync selects gained the blob side-channel the async ones already had,
so a BLOB read through getSync comes back as an ArrayBuffer rather than an
unhydrated placeholder, and getArraySync returns the first row like its
asynchronous twin instead of every row.

Transaction ids are now validated on the asynchronous in-transaction
methods too; they previously ignored the id and ran on the writer whatever
was passed.

* test(demo): trigger the headless runs from iOS launch arguments

The benchmark and test runners could only be started from an Android
launch intent, so nothing below the argument parsing had ever run on iOS.
Launch arguments carry the same three values there.

* build(demo): link SQLite in the iOS demo

The plugin deliberately declares no SQLite of its own on iOS and links
whatever the app provides, which the demo never did — so an iOS build
failed with undefined _sqlite3_* symbols and the demo had apparently never
been built for the platform.

* test(sqlite): cover the single-writer model, sync transactions and background opens

Two of these caught real bugs on their first run on a simulator: readers
racing the writer for SQLITE_OPEN_CREATE under asyncOpen, and a
transactionSync callback returning a promise.

getArraySync's existing assertion used a query matching one row, so it
could not tell the method apart from selectArraySync; it now queries two.

* docs(sqlite): document the single-writer connection model

The README described a dedicated connection behind the *Sync methods and
`poolSize + 2` connections keyed on the JavaScript thread, neither of which
is true any more. It now covers what the sync methods share with the async
ones and what that means for what they see, the three sanctioned ways to do
synchronous work inside a transaction, opening off the JavaScript thread
with asyncOpen and initialized(), and the two remaining differences between
the platforms.

The passphrase cost table is remeasured, and says plainly that the readers'
derivations moved off the JavaScript thread rather than disappearing: they
reappear as latency on the first read routed to each reader.

The thenable message is aligned with the one iOS reports.

* fix(sqlite): wake the pool's workers when an inline claim is released during shutdown

A worker that parks while the pool is claimed waits for busy_ to clear, and the
release only signalled when tasks were queued, so a shutdown begun under a
claim could never be joined.

* docs(sqlite): state where the SQLCipher interop was verified, and the open latency as it is

* docs(sqlite): list what still differs between iOS and Android

* build: use the @nx/js executor in the remaining packages

@nrwl/js is no longer installed, so any change that makes these projects
affected failed the build job with 'Unable to resolve @nrwl/js:tsc'.

* fix(demo): import the headless runners through @demo/shared

The relative imports into tools/demo tripped @nx/enforce-module-boundaries
and failed the demo's lint target.

* docs(sqlite): describe engine-side encryption without naming a mechanism

* docs(sqlite): drop an example from the auto-extension note

---------

Co-authored-by: Dylan Llewellyn <46717769+herefishyfish@users.noreply.github.com>
…iter first (#9)

* feat(sqlite): let the caller order a connection's open sequence

The sequence was fixed: key, then onOpen, then WAL. Some engines select
their cipher with pragmas that have to precede PRAGMA key; page_size and
auto_vacuum only take on a new file before the journal mode switches; and
not every database wants WAL at all. None of that was reachable, and
writing PRAGMA key by hand in onOpen is not a way to reach it — that
bypasses the plugin's quoting and its passphrase/raw handling, and puts the
key inside a statement.

openSequence names the whole order, with two markers for the steps the
plugin owns and an optional scope so a statement can run on the writer or
the readers alone. onOpen becomes sugar for the default sequence, so there
is one code path and existing behaviour is unchanged.

A sequence that cannot work is refused before anything is opened, including
under asyncOpen, where nothing else reports synchronously. The refusals are
programming errors rather than open failures: combining onOpen with
openSequence, repeating a marker, or putting wal before key with a key set.
Setting encryptionKey without a key marker is refused for a different
reason — a forgotten marker would otherwise write the database unencrypted.

* feat(sqlite): run the caller's open sequence on Android, writer first

The connection layer walks the step list it is handed instead of a fixed
key/onOpen/WAL order, filtering each step by scope — the serialized
connection counts as the writer — and still forcing query_only=ON onto
readers after the whole sequence, where it is not the caller's to move.

A failing step reports its index rather than its text: `open step <i>
failed: <sqlite message>`. Steps skipped by scope keep their index, so the
number matches what the caller wrote. The key step also drops the SQLite
message entirely if it ever contains the key, since SQLite names the token
it choked on.

Readers no longer start opening until the writer's sequence has finished.
A reader that touches a brand-new file first can leave it without the
page_size the writer was about to set, and can hold the shared lock the
writer needs to switch the journal mode. Reader opens are now chained from
the writer's open completion on the runtime thread, which is also where
close() runs, so a connection is either opened and then closed or never
opened at all.

Two consequences of that reordering are handled rather than left: a reader
the pool never started is given the writer's failure, so every method
reports why the database could not be opened instead of claiming it is
closed; and a read issued before the writer's verdict is parked and flushed
when it lands, because awaiting initialized() is documented as optional.

recordOpenFailure is written from the runtime thread while a worker may be
reading it, so the open-failure fields are now guarded by the mutex that
already protects the last error.

* feat(sqlite): run the caller's open sequence on iOS, writer first

Mirrors the Android change. The connection walks the step list it is given,
filtering by scope — the serialized connection counts as the writer — and
query_only=ON still follows the whole sequence on readers. A failing step
reports `open step <i> failed: <sqlite message>`, with steps skipped by
scope keeping their index so the number matches what the caller wrote, and
the key step dropping SQLite's message if it ever contains the key.

Readers already waited on a group the writer leaves, but the writer left it
on its failure path too, so the readers went on to open and fail on their
own. The writer now publishes its verdict before leaving the group and each
reader checks it: on failure no reader opens, and each is given the writer's
error so every method reports why the database could not be opened rather
than that it is closed. The group is still left rather than withheld,
because closing drains those same queues.

PRAGMA query_only=ON was run with its result assigned to a string nobody
read, so a reader that could not be made read-only opened anyway. It is a
step of the sequence now and aborts the open like any other.

A read issued before the writer's verdict needs no special handling here:
the reader queues and their open blocks are both created before the open
call returns, and they are serial, so a later read necessarily queues behind
a block that is itself waiting on the writer.

* fix(sqlite): validate an open step's scope even when its SQL is empty

The scope was only parsed on the way to building a step, which an empty
statement never reaches, so a typo in `on` passed silently.

* test(sqlite): cover the caller-controlled open sequence

The refusals, the default sequence matching its explicit spelling, a new
file keeping its rollback journal when no wal step is given, a writer-scoped
page_size taking effect before WAL, scoping observed from the pool through
cache_size, and a failing step naming its index without repeating the
statement.

Two of these exist because the reordering broke guarantees the tests did
not reach: a pooled read issued before initialization has to queue rather
than be turned away, and on a failed open it has to report why the database
could not be opened rather than that it is closed. The existing asyncOpen
test missed both by awaiting initialized() first and by using the sync path,
which runs on the writer.

The codec-gated case is the one that proves a step really runs before the
key: a database created under a non-default cipher cannot be opened by the
default sequence with the same key, and can be with the same sequence.

* docs(sqlite): document how to control the open sequence

The ordering constraints are SQLite's rather than the plugin's, so they are
stated as such: the key before anything that reads the file, WAL never
before the key, page_size and auto_vacuum only on a new file before WAL.

Also why not to hand-write PRAGMA key — the plugin quotes it, decides
passphrase against raw key material, and keeping it a marker keeps it out
of a statement — and what leaving out the wal marker costs a reader pool.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant