Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/content/docs/configuration/android.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,30 @@ To omit R\*Tree from the bundled build, set this boolean in your app's `package.

`enableRTree` defaults to `true` independently of `performanceMode`. A non-boolean value fails Gradle configuration. Disabling it omits the default `SQLITE_ENABLE_RTREE` definition. Do not add `-DSQLITE_ENABLE_RTREE=0` to `nitroSqliteFlags` to disable it: SQLite checks whether the macro is defined, so that value still enables the module. Custom compile definitions can enable the module even when the package setting is `false`.

## Commit I/O

SQLite makes a committed transaction durable by writing it to storage and asking the operating system to flush the written data. In the default rollback journal mode, a commit first copies the original pages into a journal file and flushes it, then writes the new pages to the database file and flushes again, and finally removes the journal. For small writes, these flushes take most of a commit's time.

Android builds compile SQLite with two I/O options that Android's own platform SQLite also uses. Neither changes `PRAGMA synchronous`, and a commit is as durable as before:

- `HAVE_FDATASYNC=1` flushes with `fdatasync()` instead of `fsync()`. On Linux, `fdatasync()` still flushes the metadata needed to read the data back, such as the file size, and skips metadata such as modification times.
- `SQLITE_ENABLE_BATCH_ATOMIC_WRITE=1` commits a transaction through the F2FS filesystem's atomic write when the database file supports it. The filesystem then stores either all of the transaction's pages or none of them, so SQLite does not need a journal file. When the filesystem lacks the feature or the atomic write fails, SQLite commits with the rollback journal as usual.

To build without batch atomic writes, set this boolean in your app's `package.json` and rebuild the native app:

```json
{
"nitroSQLite": {
"enableBatchAtomicWrite": false
}
}
```

`enableBatchAtomicWrite` defaults to `true`, and a non-boolean value fails Gradle configuration. As with R\*Tree, SQLite only checks whether the macro is defined, so `-DSQLITE_ENABLE_BATCH_ATOMIC_WRITE=0` in `nitroSqliteFlags` does not disable it. To flush with `fsync()` instead, add `-DHAVE_FDATASYNC=0` to `nitroSqliteFlags`.

## Threading

The native library opens each database with `SQLITE_OPEN_FULLMUTEX` and serializes calls on each handle. The JavaScript connection helper coordinates work per managed connection and rejects conflicting synchronous work. `nitroSqliteFlags` can change compile-time SQLite behavior, including `SQLITE_THREADSAFE`. Nitro SQLite rejects [independent connections](/docs/guides/multiple-connections) when SQLite was built with `SQLITE_THREADSAFE=0`; other database handles can still run concurrently, so an app disabling mutexes must serialize SQLite calls across the process.
The native library serializes all work on each database handle with its own lock, so it opens handles with `SQLITE_OPEN_NOMUTEX` instead of also taking SQLite's per-call connection mutex. Each connection runs its async queries, batches, and file imports one at a time on a native thread of its own. The thread starts with the connection's first such call and stops once the connection is closed and its pending work has finished. The JavaScript connection helper coordinates work per managed connection and rejects conflicting synchronous work. `nitroSqliteFlags` can change compile-time SQLite behavior, including `SQLITE_THREADSAFE`. Nitro SQLite rejects [independent connections](/docs/guides/multiple-connections) when SQLite was built with `SQLITE_THREADSAFE=0`; other database handles can still run concurrently, so an app disabling mutexes must serialize SQLite calls across the process.

The app's `nitroSQLite.threadSafe` and `nitroSQLite.performanceMode` settings also configure Android's SQLite defaults. Custom `nitroSqliteFlags` override individual defaults. The native build additionally applies its CMake compiler options, including `-O2`.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/guides/parameters-and-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,4 @@ try {

When a native `NitroSQLiteException` reaches a managed helper, `error.type` holds a [`NitroSQLiteExceptionType`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteExceptionType) such as `SqlExecutionError`. It is `undefined` for JavaScript errors, including connection queue errors, and unrecognized native categories. These categories are not SQLite's numeric error codes. The native categories are `UnknownError`, `DatabaseCannotBeOpened`, `EncryptionNotEnabled`, `DatabaseCannotBeDecrypted`, `DatabaseNotOpen`, `UnableToAttachToDatabase`, `SqlExecutionError`, `CouldNotLoadFile`, and `NoBatchCommandsProvided`. See [encrypt a database](/docs/guides/encryption#handle-open-failures) for the encryption errors. The current native `DatabaseNotOpen` helper emits `UnableToAttachToDatabase`, and a missing database file emits `SqlExecutionError`. Calls through `NitroSQLite.native` throw raw native errors.

For exact types, see [`QueryResult`](/api/react-native-nitro-sqlite/type-aliases/QueryResult), [`NitroSQLiteQueryResult`](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteQueryResult), and [`NitroSQLiteQueryResultRows`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryResultRows). The row generic narrows `rows._array` and `rows.item()`, but not the raw `results` array. Query results also inherit Nitro hybrid object members; see [native access](/docs/guides/sync-and-async#global-helpers-and-native-access).
For exact types, see [`QueryResult`](/api/react-native-nitro-sqlite/type-aliases/QueryResult), [`NitroSQLiteQueryResult`](/api/react-native-nitro-sqlite/interfaces/NitroSQLiteQueryResult), and [`NitroSQLiteQueryResultRows`](/api/react-native-nitro-sqlite/type-aliases/NitroSQLiteQueryResultRows). The row generic narrows `rows._array` and `rows.item()`, but not the raw `results` array. Query results are plain JavaScript objects; see [native access](/docs/guides/sync-and-async#global-helpers-and-native-access) for results returned without the connection helper.
15 changes: 15 additions & 0 deletions docs/content/docs/guides/performance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ console.log(rows._array)

An async call still consumes device CPU and database I/O. Paginate large result sets instead of bringing every row into JavaScript at once.

## Reuse SQL text

Before SQLite runs a statement, it compiles the SQL text into a program for its virtual machine. Compiling one statement is quick, but the cost adds up when the same statement runs many times, such as an `INSERT` in a loop.

Each connection keeps the compiled programs of its 32 most recently used queries and data changes: statements that start with `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `REPLACE`, `WITH`, or `VALUES`. When `execute()` or `executeAsync()` receives the same SQL text again, it reuses the program with the new parameters. Pass values as parameters rather than formatting them into the SQL so the text stays identical between calls. Other statements, such as `PRAGMA`, `ATTACH`, and `BEGIN`, compile on every call because SQLite can apply their effect while compiling them. Use [prepared statements](/docs/guides/prepared-statements) when you want to manage a compiled statement's lifetime yourself.

```ts
for (const metric of metrics) {
db.execute('INSERT INTO metrics (key, value) VALUES (?, ?)', [
metric.key,
metric.value,
])
}
```

## Group related writes

One `executeBatchAsync()` call executes a fixed set of statements inside a native transaction. Use nested parameter arrays for repeated SQL. Use `db.transaction()` when application logic must inspect a result between writes. Both keep a transaction open while they work; keep the scope short and await each operation inside a transaction callback.
Expand Down
6 changes: 4 additions & 2 deletions docs/content/docs/guides/sync-and-async.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ console.log(smallResult.results, largerResult.results)

Use synchronous calls when immediate results are useful and the work is small. Prefer async calls for queries that may scan many rows, batches, or file imports. [Performance guidance](/docs/guides/performance) covers other ways to keep work bounded.

An async query still delivers its rows to JavaScript, and only the JavaScript thread can turn them into objects. For a large result, `executeAsync()` hands rows over in batches while SQLite reads the rest, so building the objects overlaps with the query instead of starting after it. The promise still resolves once, with every row in order.

An `open()` connection submits ordinary async statements in call order. Native operations on that connection run one at a time in submission order, but await a write before starting a read that depends on it so you can handle write errors. Batches and transactions wait for preceding statements and reserve the connection until complete. A synchronous operation, `close()`, or `delete()` throws a busy error if work is pending or active. Await async work before calling a synchronous method on the same connection:

```ts
Expand All @@ -38,7 +40,7 @@ Prepared statement `executeAsync()` reserves the connection queue until it finis

The [`NitroSQLite` export](/api/react-native-nitro-sqlite/variables/NitroSQLite) also has `execute`, `executeAsync`, `prepare`, `executeBatch`, `executeBatchAsync`, and `transaction` helpers that take a database name. `NitroSQLite.open(options)` is the same helper as the named [`open()` export](/api/react-native-nitro-sqlite/functions/open). Global queries join the default connection's JavaScript queue when the name was opened through `open()`. They do not address independent connections; use the object returned by `open()` for those. Without a default managed connection, global `execute` and `executeAsync` call the native methods directly and still need an open native database handle. Global `prepare`, batches, and transactions need a default connection opened through `open()`.

`NitroSQLite.native` exposes the underlying [`NitroSQLiteNative` hybrid object](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteNative). Its methods take a database name and return raw results without the connection helper's `rows` container. They also throw native errors without converting them to `NitroSQLiteError`.
`NitroSQLite.native` exposes the underlying [`NitroSQLiteNative` hybrid object](/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteNative). Its methods take a database name and return raw results without the connection helper's `rows` container. They also throw native errors without converting them to `NitroSQLiteError`. Its `executeAsync()` accepts an optional `onRows` callback that receives leading rows in batches, in order; the resolved `results` then holds only the rows after the last batch.

```ts
import { NitroSQLite } from 'react-native-nitro-sqlite'
Expand All @@ -52,6 +54,6 @@ try {
}
```

Native calls bypass the JavaScript queue. If you use them alongside an `open()` connection, coordinate access yourself. A native statement can run inside an active connection transaction without joining its callback's sequence. The raw result is a Nitro hybrid object with `name`, `toString()`, `equals(other)`, and `dispose()` members. Disposing it makes that result unusable. `dispose()` is not a database `close()` call, and ordinary garbage collection handles these objects. Do not dispose the shared `NitroSQLite.native` instance during normal database cleanup.
Native calls bypass the JavaScript queue. If you use them alongside an `open()` connection, coordinate access yourself. A native statement can run inside an active connection transaction without joining its callback's sequence. The raw result is a plain JavaScript object with `rowsAffected`, `insertId`, `results`, and `metadata`; ordinary garbage collection reclaims it. `NitroSQLite.native` itself is a Nitro hybrid object with a `dispose()` member. Do not dispose that shared instance during normal database cleanup; `dispose()` is not a database `close()` call.

The `NitroSQLite` export spreads the hybrid instance, but inherited native methods do not appear on that top-level object. Call them through `.native`. If you compile SQLite with `SQLITE_THREADSAFE=0`, also serialize access across separate database handles and native threads. See the [iOS](/docs/configuration/ios#thread-safety-and-performance-mode) and [Android](/docs/configuration/android#threading) configuration pages.
4 changes: 2 additions & 2 deletions docs/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ const config = {
},
{
source:
'/api/react-native-nitro-sqlite/interfaces/NitroSQLiteQueryResult',
destination:
'/api/react-native-nitro-sqlite/hybrid-objects/NitroSQLiteQueryResult',
destination:
'/api/react-native-nitro-sqlite/interfaces/NitroSQLiteQueryResult',
permanent: true,
},
]
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts/check-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ assert.ok(
corePackageHome.indexOf('## Classes'),
'Hybrid Objects must appear before other API groups',
)
for (const name of ['NitroSQLiteNative', 'NitroSQLiteQueryResult']) {
for (const name of ['NitroSQLiteNative']) {
assert.ok(
pagePaths.has(`react-native-nitro-sqlite/hybrid-objects/${name}.mdx`),
`Missing Hybrid Object page: ${name}`,
Expand Down
32 changes: 22 additions & 10 deletions example/tests/unit/specs/operations/execute.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ColumnType } from 'react-native-nitro-sqlite'
import type { NitroSQLiteQueryResult } from '@nitro-sqlite/specs/NitroSQLiteQueryResult.nitro'

const QUERY_RESULT_SIZES = [60, 1_000, 10_000]
// Asynchronous reads hand rows to JavaScript in batches of 256; cover the batch edges.
const ASYNC_BATCH_EDGE_SIZES = [255, 256, 257, 512, 513]
const metadataQuery = `
SELECT
boolean_value,
Expand Down Expand Up @@ -150,14 +152,29 @@ export default function registerExecuteUnitTests() {
const db = createQueryResultTestDb('query_result_rows_async')

try {
for (const size of QUERY_RESULT_SIZES) {
for (const size of [...QUERY_RESULT_SIZES, ...ASYNC_BATCH_EDGE_SIZES]) {
const result = await db.executeAsync(
'SELECT * FROM QueryResultRows ORDER BY id LIMIT ?',
[size],
)

expectQueryResultRows(result, size)
expect(result.results).toBe(result.rows._array)
expect(
result.rows._array.every((row, index) => row.id === index + 1),
).toBe(true)
}

await db.transaction(async (tx) => {
const result = await tx.executeAsync(
'SELECT * FROM QueryResultRows ORDER BY id LIMIT ?',
[1_000],
)
expectQueryResultRows(result, 1_000)
expect(
result.rows._array.every((row, index) => row.id === index + 1),
).toBe(true)
})
} finally {
db.close()
db.delete()
Expand Down Expand Up @@ -248,7 +265,7 @@ export default function registerExecuteUnitTests() {
})

describe('Select', () => {
it('keeps positional columns and repeated result reads independent', () => {
it('keeps positional columns in one plain result array', () => {
const result = testDb.execute(
'SELECT 1 AS duplicate, 2 AS duplicate, 3.5 AS "café", NULL AS nullable, zeroblob(2) AS payload',
)
Expand All @@ -263,14 +280,9 @@ export default function registerExecuteUnitTests() {
).toEqual([0, 0])
expect(result.metadata?.duplicate?.index).toBe(0)

const firstRead = result.results
const secondRead = result.results
expect(secondRead).not.toBe(firstRead)
expect(secondRead[0]).not.toBe(firstRead[0])
firstRead[0]!.duplicate = 9
expect(secondRead[0]?.duplicate).toBe(2)
expect(result.results[0]?.duplicate).toBe(2)
expect(result.rows.item(0)?.duplicate).toBe(2)
// The native result is a plain object: `results` is one array, shared with `rows`.
expect(result.results).toBe(result.results)
expect(result.rows._array).toBe(result.results)
})

it('preserves column metadata for empty results', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/react-native-nitro-sqlite/android/sqlite-flags.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ project.ext.resolveNitroSqliteDefaultFlags = { File appPackageFile, String custo
def threadSafe = readBooleanFlag("threadSafe")
def performanceMode = readBooleanFlag("performanceMode")
def enableRTree = readBooleanFlag("enableRTree")
def enableBatchAtomicWrite = readBooleanFlag("enableBatchAtomicWrite")
def customFlagNames = (customFlags =~ /-D([A-Za-z_][A-Za-z0-9_]*)/).collect { it[1] }.toSet()

def defaultFlags = [threadSafe ? '-DSQLITE_THREADSAFE=1' : '-DSQLITE_THREADSAFE=0']
// Omit the macro when disabled; SQLITE_ENABLE_RTREE=0 still enables SQLite's module.
if (enableRTree) {
defaultFlags += '-DSQLITE_ENABLE_RTREE=1'
}
// Without this SQLite substitutes fsync() for fdatasync(). Linux's fdatasync() also flushes the
// metadata needed to read the data back, such as file size, so commits stay durable. Android's
// platform SQLite makes the same choice.
defaultFlags += '-DHAVE_FDATASYNC=1'
// On F2FS, commit transactions with the filesystem's atomic batch write instead of a rollback
// journal file. SQLite falls back to the journal when the filesystem lacks support.
// Like RTree, SQLite only checks whether the macro is defined.
if (enableBatchAtomicWrite) {
defaultFlags += '-DSQLITE_ENABLE_BATCH_ATOMIC_WRITE=1'
}
if (performanceMode) {
defaultFlags += [
"-DSQLITE_DQS=0",
Expand Down
Loading
Loading