From 831e31220e5ad5362c3648a8db48f9f3abb66ccb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:22:34 +0200 Subject: [PATCH 1/5] perf: submit async SQLite operations through native FIFO --- README.md | 2 + .../tests/unit/specs/DatabaseQueue.spec.ts | 74 ++++++++++++++++-- .../cpp/hybridObjects/HybridNitroSQLite.cpp | 28 +++++-- .../cpp/operations.cpp | 27 +++++++ .../cpp/operations.hpp | 12 ++- .../src/DatabaseQueue.ts | 77 +++++++++++++++---- .../src/__tests__/DatabaseQueue.test.ts | 74 +++++++++++++++++- .../src/operations/execute.ts | 4 +- 8 files changed, 270 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 959bbf0f..6f8cf22a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ const db = open({ name: 'myDb.sqlite' }) Async operations submitted on the opened `db` connection outside a transaction callback run in call order. Async work waits for an active transaction to finish, while a conflicting sync operation or `close()` throws a busy error. +You can submit several `executeAsync` calls together with `Promise.all`. NitroSQLite sends them to a native FIFO on that connection, so the next query can start without waiting for JavaScript to process the previous result. A single connection still executes one SQL operation at a time. Transactions wait for earlier queries to finish and hold the connection until the callback completes. + `NitroSQLite.native` bypasses this JavaScript queue. Native calls keep each individual SQLite handle safe, but mixing them with a session transaction can still run statements inside that transaction. A build with `SQLITE_THREADSAFE=0` also remains unsafe when different database handles run concurrently unless the caller serializes every SQLite call globally. --- diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index e5822ce2..271a45a4 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -220,9 +220,11 @@ export default function registerDatabaseQueueUnitTests() { }) await transactionStarted.promise - const externalWrite = testDb.executeAsync( - 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', - [2, 'external', 2, 2], + const externalWrites = Array.from({ length: 24 }, (_, index) => + testDb.executeAsync( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + [index + 2, `external-${index}`, 2, 2], + ), ) finishTransaction.resolve() @@ -231,11 +233,11 @@ export default function registerDatabaseQueueUnitTests() { } catch (error) { expect((error as Error).message).toContain('rollback transaction') } - await externalWrite + await Promise.all(externalWrites) expect( testDb.execute<{ id: number }>('SELECT id FROM User').results, - ).toEqual([{ id: 2 }]) + ).toEqual(Array.from({ length: 24 }, (_, index) => ({ id: index + 2 }))) }) it('returns distinct insert IDs from parallel async inserts', async () => { @@ -257,6 +259,68 @@ export default function registerDatabaseQueueUnitTests() { ) }) + it('starts a transaction after an earlier burst of async writes finishes', async () => { + testDb.execute('CREATE TABLE TransactionBarrier (value INTEGER)') + const writes = Array.from({ length: 24 }, (_, index) => + testDb.executeAsync( + 'INSERT INTO TransactionBarrier (value) VALUES (?)', + [index], + ), + ) + const transaction = testDb.transaction( + async (tx) => + tx.execute<{ total: number }>( + 'SELECT count(*) AS total FROM TransactionBarrier', + ).results[0]?.total, + ) + + await Promise.all(writes) + expect(await transaction).toBe(24) + }) + + it('runs native async statements in submission order', async () => { + const dbName = 'native-fifo-order' + dropDatabaseIfExists(dbName) + NitroSQLite.native.open(dbName) + + try { + NitroSQLite.native.execute( + dbName, + 'CREATE TABLE NativeQueueInsert (id INTEGER PRIMARY KEY AUTOINCREMENT, value INTEGER)', + ) + const results = await Promise.all( + Array.from({ length: 64 }, (_, index) => + NitroSQLite.native.executeAsync( + dbName, + 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + [index], + ), + ), + ) + + expect(results.map((result) => result.insertId)).toEqual( + Array.from({ length: 64 }, (_, index) => index + 1), + ) + + const batch = NitroSQLite.native.executeBatchAsync(dbName, [ + { + query: 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + params: [[64], [65]], + }, + ]) + const afterBatch = NitroSQLite.native.executeAsync( + dbName, + 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + [66], + ) + await batch + expect((await afterBatch).insertId).toBe(67) + } finally { + NitroSQLite.native.close(dbName) + dropDatabaseIfExists(dbName) + } + }) + it('rejects sync work and close while async work is pending', async () => { const dbName = 'busy-close' dropDatabaseIfExists(dbName) diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index 94c576dd..bec2da18 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -13,13 +13,14 @@ #include #include #include +#include #include #include namespace margelo::nitro::rnnitrosqlite { // Copy any JS-backed ArrayBuffers on the JS thread so they can be safely -// accessed from the background thread used by Promise::async. +// accessed from the connection's background worker. static std::optional copyArrayBufferParamsForBackground(const std::optional& params) { if (!params) { return std::nullopt; @@ -59,6 +60,23 @@ static std::vector copyArrayBufferParamsForBackground(const std::vec return copiedCommands; } +template +static std::shared_ptr> enqueueConnectionOperation(const SQLiteConnectionPtr& connection, Operation&& operation) { + auto promise = Promise::create(); + try { + connection->enqueueAsync([promise, operation = std::forward(operation)]() mutable { + try { + promise->resolve(operation()); + } catch (...) { + promise->reject(std::current_exception()); + } + }); + } catch (...) { + promise->reject(std::current_exception()); + } + return promise; +} + const std::string getDocPath(const std::optional& location) { std::string tempDocPath = std::string(HybridNitroSQLite::docPath); if (location) { @@ -139,8 +157,8 @@ HybridNitroSQLite::executeAsync(const std::string& dbName, const std::string& qu return Promise>::rejected(std::current_exception()); } - return Promise>::async( - [connection, query, copiedParams]() -> std::shared_ptr { + return enqueueConnectionOperation>( + connection, [connection, query, copiedParams]() -> std::shared_ptr { auto result = sqliteExecute(connection, query, copiedParams); return result; }); @@ -166,7 +184,7 @@ std::shared_ptr> HybridNitroSQLite::executeBatchAsync( return Promise::rejected(std::current_exception()); } - return Promise::async([connection, copiedCommands]() -> BatchQueryResult { + return enqueueConnectionOperation(connection, [connection, copiedCommands]() -> BatchQueryResult { auto result = sqliteExecuteBatch(connection, copiedCommands); return BatchQueryResult(result.rowsAffected); }); @@ -184,7 +202,7 @@ std::shared_ptr> HybridNitroSQLite::loadFileAsync(const } catch (...) { return Promise::rejected(std::current_exception()); } - return Promise::async([connection, location]() -> FileLoadResult { + return enqueueConnectionOperation(connection, [connection, location]() -> FileLoadResult { const auto result = importSqlFile(connection, location); return FileLoadResult(result.commands, result.rowsAffected); }); diff --git a/packages/react-native-nitro-sqlite/cpp/operations.cpp b/packages/react-native-nitro-sqlite/cpp/operations.cpp index 8c99db01..6ac4d19b 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.cpp @@ -4,6 +4,7 @@ #include "logs.hpp" #include "utils.hpp" #include +#include #include #include #include @@ -54,6 +55,32 @@ void SQLiteConnection::close() noexcept { database = nullptr; } +void SQLiteConnection::enqueueAsync(std::function operation) { + std::lock_guard lock(asyncQueueMutex); + if (!asyncWorkerRunning) { + // The worker holds this connection alive until it has drained every operation. + ThreadPool::shared().run([connection = shared_from_this()] { connection->drainAsync(); }); + asyncWorkerRunning = true; + } + asyncQueue.push(std::move(operation)); +} + +void SQLiteConnection::drainAsync() { + while (true) { + std::function operation; + { + std::lock_guard lock(asyncQueueMutex); + if (asyncQueue.empty()) { + asyncWorkerRunning = false; + return; + } + operation = std::move(asyncQueue.front()); + asyncQueue.pop(); + } + operation(); + } +} + void sqliteOpenDb(const std::string& dbName, const std::string& docPath) { std::lock_guard lifecycleLock(dbLifecycleMutex); { diff --git a/packages/react-native-nitro-sqlite/cpp/operations.hpp b/packages/react-native-nitro-sqlite/cpp/operations.hpp index 549d68e5..29642f0f 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.hpp @@ -2,8 +2,10 @@ #include "hybridObjects/HybridNitroSQLiteQueryResult.hpp" #include "types.hpp" +#include #include #include +#include #include #include @@ -12,7 +14,7 @@ namespace margelo::rnnitrosqlite { // Calls against one connection are serialized by `mutex`. Separate connections // intentionally remain independent, so SQLITE_THREADSAFE=0 still requires the // caller to serialize SQLite calls globally. -struct SQLiteConnection final { +struct SQLiteConnection final : std::enable_shared_from_this { SQLiteConnection(std::string name, sqlite3* database); ~SQLiteConnection(); @@ -20,10 +22,18 @@ struct SQLiteConnection final { SQLiteConnection& operator=(const SQLiteConnection&) = delete; void close() noexcept; + void enqueueAsync(std::function operation); const std::string name; sqlite3* database; std::recursive_mutex mutex; + +private: + void drainAsync(); + + std::mutex asyncQueueMutex; + std::queue> asyncQueue; + bool asyncWorkerRunning = false; }; using SQLiteConnectionPtr = std::shared_ptr; diff --git a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts index 1ad4d258..567f1910 100644 --- a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts +++ b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts @@ -1,6 +1,7 @@ import NitroSQLiteError from './NitroSQLiteError' export interface QueuedOperation { + kind: 'statement' | 'exclusive' /** * Starts the operation */ @@ -10,6 +11,8 @@ export interface QueuedOperation { export type DatabaseQueue = { queue: QueuedOperation[] inProgress: boolean + activeStatements: number + draining: boolean } const databaseQueues = new Map() @@ -21,7 +24,12 @@ export function openDatabaseQueue(dbName: string) { ) } - databaseQueues.set(dbName, { queue: [], inProgress: false }) + databaseQueues.set(dbName, { + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) } export function closeDatabaseQueue(dbName: string) { @@ -57,7 +65,22 @@ export function getDatabaseQueue(dbName: string) { export function queueOperationAsync( dbName: string, callback: () => Promise, -) { +): Promise { + return enqueueOperation(dbName, 'exclusive', callback) +} + +export function queueStatementAsync( + dbName: string, + callback: () => Promise, +): Promise { + return enqueueOperation(dbName, 'statement', callback) +} + +function enqueueOperation( + dbName: string, + kind: QueuedOperation['kind'], + callback: () => Promise, +): Promise { const databaseQueue = getDatabaseQueue(dbName) return new Promise((resolve, reject) => { @@ -68,32 +91,59 @@ export function queueOperationAsync( } catch (error) { reject(error) } finally { - databaseQueue.inProgress = false - startOperationAsync(databaseQueue) + if (kind === 'statement') { + databaseQueue.activeStatements-- + if (databaseQueue.activeStatements === 0) { + databaseQueue.inProgress = false + } + } else { + databaseQueue.inProgress = false + } + startNextOperations(databaseQueue) } } const operation: QueuedOperation = { + kind, start, } databaseQueue.queue.push(operation) - startOperationAsync(databaseQueue) + startNextOperations(databaseQueue) }) } -function startOperationAsync(queue: DatabaseQueue) { - // Queue is empty or in progress. Bail out. - if (queue.inProgress || queue.queue.length === 0) { +function startNextOperations(queue: DatabaseQueue) { + if (queue.draining || (queue.inProgress && queue.activeStatements === 0)) { return } - queue.inProgress = true + queue.draining = true + try { + while (queue.queue.length > 0) { + const exclusiveIndex = queue.queue.findIndex( + (operation) => operation.kind === 'exclusive', + ) + const statementCount = + exclusiveIndex === -1 ? queue.queue.length : exclusiveIndex + + if (statementCount > 0) { + const statements = queue.queue.splice(0, statementCount) + queue.inProgress = true + queue.activeStatements += statements.length + for (const statement of statements) statement.start() + continue + } - const operation = queue.queue.shift()! - setImmediate(() => { - operation.start() - }) + if (queue.activeStatements > 0) return + + queue.inProgress = true + queue.queue.shift()!.start() + return + } + } finally { + queue.draining = false + } } export function startOperationSync( @@ -115,5 +165,6 @@ export function startOperationSync( return callback() } finally { databaseQueue.inProgress = false + startNextOperations(databaseQueue) } } diff --git a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts index fcf15fe1..35886ac6 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts @@ -4,6 +4,7 @@ import { isDatabaseOpen, openDatabaseQueue, queueOperationAsync, + queueStatementAsync, startOperationSync, throwIfDatabaseIsNotOpen, } from '../DatabaseQueue' @@ -25,7 +26,12 @@ describe('DatabaseQueue', () => { openDatabaseQueue(dbName) expect(isDatabaseOpen(dbName)).toBe(true) - expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + expect(getDatabaseQueue(dbName)).toMatchObject({ + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) expect(() => openDatabaseQueue(dbName)).toThrow('already open') closeDatabaseQueue(dbName) @@ -80,7 +86,71 @@ describe('DatabaseQueue', () => { await expect(two).resolves.toBe(2) await expect(three).resolves.toBe(3) expect(started).toEqual([1, 2, 3]) - expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + expect(getDatabaseQueue(dbName)).toMatchObject({ + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) + }) + + it('submits a burst of statements before waiting for native results', async () => { + openDatabaseQueue(dbName) + const first = deferred() + const started: number[] = [] + const operations = Array.from({ length: 64 }, (_, index) => + queueStatementAsync(dbName, () => { + started.push(index) + return index === 0 ? first.promise : Promise.resolve(index) + }), + ) + + expect(started).toEqual(Array.from({ length: 64 }, (_, index) => index)) + expect(getDatabaseQueue(dbName).activeStatements).toBe(64) + expect(() => startOperationSync(dbName, () => 1)).toThrow('busy') + expect(() => closeDatabaseQueue(dbName)).toThrow('busy') + + first.resolve(0) + expect(await Promise.all(operations)).toEqual(started) + expect(getDatabaseQueue(dbName).inProgress).toBe(false) + }) + + it('waits for every earlier statement before starting a transaction', async () => { + openDatabaseQueue(dbName) + const first = deferred() + const second = deferred() + const order: string[] = [] + const one = queueStatementAsync(dbName, () => { + order.push('first') + return first.promise + }) + const two = queueStatementAsync(dbName, () => { + order.push('second') + return second.promise + }) + const transaction = queueOperationAsync(dbName, async () => { + order.push('transaction') + }) + const after = queueStatementAsync(dbName, async () => { + order.push('after') + }) + + expect(order).toEqual(['first', 'second']) + second.resolve() + await two + expect(order).toEqual(['first', 'second']) + first.resolve() + await Promise.all([one, transaction, after]) + expect(order).toEqual(['first', 'second', 'transaction', 'after']) + }) + + it('starts queued async work after a synchronous operation completes', async () => { + openDatabaseQueue(dbName) + let pending: Promise | undefined + startOperationSync(dbName, () => { + pending = queueStatementAsync(dbName, async () => 42) + }) + await expect(pending).resolves.toBe(42) }) it('keeps queues for different databases independent', async () => { diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index bc75a84a..8cdcddc6 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -4,7 +4,7 @@ import NitroSQLiteError from '../NitroSQLiteError' import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import { isDatabaseOpen, - queueOperationAsync, + queueStatementAsync, startOperationSync, } from '../DatabaseQueue' @@ -58,7 +58,7 @@ export async function executeAsyncManaged( query: string, params?: SQLiteQueryParams, ): Promise> { - return queueOperationAsync(dbName, () => + return queueStatementAsync(dbName, () => executeAsyncNative(dbName, query, params), ) } From 5d6895a910a1136ecd1cd71d36168113cdc3eeb5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:32:18 +0200 Subject: [PATCH 2/5] fix: schedule FIFO through public Nitro Promise API --- packages/react-native-nitro-sqlite/cpp/operations.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-nitro-sqlite/cpp/operations.cpp b/packages/react-native-nitro-sqlite/cpp/operations.cpp index 6ac4d19b..918dd3cd 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.cpp @@ -4,7 +4,7 @@ #include "logs.hpp" #include "utils.hpp" #include -#include +#include #include #include #include @@ -59,7 +59,7 @@ void SQLiteConnection::enqueueAsync(std::function operation) { std::lock_guard lock(asyncQueueMutex); if (!asyncWorkerRunning) { // The worker holds this connection alive until it has drained every operation. - ThreadPool::shared().run([connection = shared_from_this()] { connection->drainAsync(); }); + Promise::async([connection = shared_from_this()] { connection->drainAsync(); }); asyncWorkerRunning = true; } asyncQueue.push(std::move(operation)); From e3ade179cb1a409be5232039bcdf3b39577eb586 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:32:30 +0200 Subject: [PATCH 3/5] fix(ci): build iOS against a generic simulator --- .github/workflows/build-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index c120e18e..9042c38c 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -112,7 +112,7 @@ jobs: -scheme NitroSQLiteExample \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -destination 'generic/platform=iOS Simulator' \ -showBuildTimingSummary \ ONLY_ACTIVE_ARCH=YES \ build \ From 736e65c0befbd971de7ec82c606a6e4b381ded29 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 23 Sep 2026 14:01:06 +0200 Subject: [PATCH 4/5] fix: preserve FIFO and transaction ordering --- .../tests/unit/specs/DatabaseQueue.spec.ts | 103 ++++++++++++++---- .../cpp/NitroSQLiteOperations.cpp | 9 +- .../cpp/hybridObjects/HybridNitroSQLite.cpp | 15 ++- .../src/__tests__/executeBatch.test.ts | 24 ++++ .../src/__tests__/session.test.ts | 24 +++- .../src/__tests__/transaction.test.ts | 81 ++++++++++++++ .../src/operations/executeBatch.ts | 4 +- .../src/operations/session.ts | 4 +- .../src/operations/transaction.ts | 26 ++++- 9 files changed, 261 insertions(+), 29 deletions(-) diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index 271a45a4..0554764a 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -87,33 +87,20 @@ export default function registerDatabaseQueueUnitTests() { expect(testDbQueue.inProgress).toBe(false) }) - it('multiple executeBatchAsync operations are queued', async () => { + it('submits multiple executeBatchAsync operations together', async () => { const executeBatch1Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) - - expect(testDbQueue.queue.length).toBe(0) - expect(testDbQueue.inProgress).toBe(true) - const executeBatch2Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) - - expect(testDbQueue.queue.length).toBe(1) - expect(testDbQueue.inProgress).toBe(true) - const executeBatch3Promise = testDb.executeBatchAsync(TEST_BATCH_COMMANDS) - expect(testDbQueue.queue.length).toBe(2) - expect(testDbQueue.inProgress).toBe(true) - - await executeBatch1Promise - - expect(testDbQueue.queue.length).toBe(1) - expect(testDbQueue.inProgress).toBe(true) - - await executeBatch2Promise - expect(testDbQueue.queue.length).toBe(0) expect(testDbQueue.inProgress).toBe(true) + expect(testDbQueue.activeStatements).toBe(3) - await executeBatch3Promise + await Promise.all([ + executeBatch1Promise, + executeBatch2Promise, + executeBatch3Promise, + ]) expect(testDbQueue.queue.length).toBe(0) expect(testDbQueue.inProgress).toBe(false) @@ -278,6 +265,53 @@ export default function registerDatabaseQueueUnitTests() { expect(await transaction).toBe(24) }) + it('keeps a batch atomic between async statements', async () => { + testDb.execute('CREATE TABLE BatchBarrier (value INTEGER PRIMARY KEY)') + const before = testDb.executeAsync( + 'INSERT INTO BatchBarrier (value) VALUES (1)', + ) + const batch = testDb.executeBatchAsync([ + { query: 'INSERT INTO BatchBarrier (value) VALUES (2)' }, + { query: 'INSERT INTO BatchBarrier (value) VALUES (1)' }, + ]) + const after = testDb.executeAsync( + 'INSERT INTO BatchBarrier (value) VALUES (3)', + ) + + await before + let batchError: unknown + try { + await batch + } catch (error) { + batchError = error + } + expect(batchError).toBeInstanceOf(NitroSQLiteError) + await after + expect( + testDb.execute<{ value: number }>( + 'SELECT value FROM BatchBarrier ORDER BY value', + ).results, + ).toEqual([{ value: 1 }, { value: 3 }]) + }) + + it('rejects synchronous transaction work while an async query is pending', async () => { + await testDb.transaction(async (tx) => { + const pending = tx.executeAsync('SELECT 1') + let syncError: unknown + try { + tx.execute('SELECT 2') + } catch (error) { + syncError = error + } + expect(syncError).toBeInstanceOf(NitroSQLiteError) + expect((syncError as Error).message).toContain( + 'Await all tx.executeAsync', + ) + await pending + expect(tx.execute('SELECT 2').results).toEqual([{ '2': 2 }]) + }) + }) + it('runs native async statements in submission order', async () => { const dbName = 'native-fifo-order' dropDatabaseIfExists(dbName) @@ -321,6 +355,35 @@ export default function registerDatabaseQueueUnitTests() { } }) + it('continues the native FIFO after a query fails', async () => { + const dbName = 'native-fifo-recovery' + dropDatabaseIfExists(dbName) + NitroSQLite.native.open(dbName) + + try { + const failed = NitroSQLite.native.executeAsync( + dbName, + 'SELECT * FROM MissingTable', + ) + const next = NitroSQLite.native.executeAsync( + dbName, + 'SELECT 42 AS value', + ) + + let queryError: unknown + try { + await failed + } catch (error) { + queryError = error + } + expect(queryError).toBeInstanceOf(Error) + expect((await next).results).toEqual([{ value: 42 }]) + } finally { + NitroSQLite.native.close(dbName) + dropDatabaseIfExists(dbName) + } + }) + it('rejects sync work and close while async work is pending', async () => { const dbName = 'busy-close' dropDatabaseIfExists(dbName) diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index 7621e8cd..47e89078 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -75,7 +76,13 @@ void SQLiteConnection::drainAsync() { operation = std::move(asyncQueue.front()); asyncQueue.pop(); } - operation(); + try { + operation(); + } catch (const std::exception& error) { + LOGE("Async operation on database %s failed while settling its promise: %s", name.c_str(), error.what()); + } catch (...) { + LOGE("Async operation on database %s failed while settling its promise", name.c_str()); + } } } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index b5e64ae3..6141b43b 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -65,10 +65,23 @@ static std::shared_ptr> enqueueConnectionOperation(const SQLiteC auto promise = Promise::create(); try { connection->enqueueAsync([promise, operation = std::forward(operation)]() mutable { + std::optional result; try { - promise->resolve(operation()); + result.emplace(operation()); } catch (...) { promise->reject(std::current_exception()); + return; + } + // Resolving may dispatch to JavaScript and throw after the native promise + // has settled. Do not try to reject that same promise again. + try { + promise->resolve(std::move(*result)); + } catch (...) { + if (promise->isPending()) { + promise->reject(std::current_exception()); + return; + } + throw; } }); } catch (...) { diff --git a/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts index 6a8fa798..d39c5f5a 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/executeBatch.test.ts @@ -4,6 +4,8 @@ import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' import NitroSQLiteError from '../NitroSQLiteError' import { executeBatch, executeBatchAsync } from '../operations/executeBatch' +import { executeAsync } from '../operations/execute' +import { deferred, nativeResult } from './testUtils' const dbName = 'batch-test' const commands = [{ query: 'INSERT INTO item VALUES (?)', params: [1] }] @@ -56,6 +58,28 @@ describe('executeBatch', () => { ) }) + it('submits a batch between async statements without waiting for JavaScript settlement', async () => { + openDatabaseQueue(dbName) + const firstResult = deferred>() + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockReturnValueOnce(firstResult.promise) + .mockResolvedValue(nativeResult()) + jest + .mocked(HybridNitroSQLite.executeBatchAsync) + .mockResolvedValue({ rowsAffected: 1 }) + + const first = executeAsync(dbName, 'SELECT first') + const batch = executeBatchAsync(dbName, commands) + const last = executeAsync(dbName, 'SELECT last') + + expect(HybridNitroSQLite.executeBatchAsync).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledTimes(2) + + firstResult.resolve(nativeResult()) + await Promise.all([first, batch, last]) + }) + it('converts synchronous and asynchronous errors and releases the queue', async () => { openDatabaseQueue(dbName) jest.mocked(HybridNitroSQLite.executeBatch).mockImplementation(() => { diff --git a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts index e44b0be6..1eea7f74 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/session.test.ts @@ -3,7 +3,7 @@ jest.mock('../nitro') import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, isDatabaseOpen } from '../DatabaseQueue' import { open } from '../operations/session' -import { nativeResult } from './testUtils' +import { deferred, nativeResult } from './testUtils' const dbName = 'session-test' const options = { name: dbName, location: 'data' } @@ -72,6 +72,28 @@ describe('open', () => { ) }) + it('submits a file import without waiting for an earlier async query to settle', async () => { + const db = open(options) + const firstResult = deferred>() + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockReturnValueOnce(firstResult.promise) + .mockResolvedValue(nativeResult()) + jest + .mocked(HybridNitroSQLite.loadFileAsync) + .mockResolvedValue({ commands: 1 }) + + const first = db.executeAsync('SELECT first') + const imported = db.loadFileAsync('/tmp/statements.sql') + const last = db.executeAsync('SELECT last') + + expect(HybridNitroSQLite.loadFileAsync).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.executeAsync).toHaveBeenCalledTimes(2) + + firstResult.resolve(nativeResult()) + await Promise.all([first, imported, last]) + }) + it('rejects duplicate opens without replacing the original connection', () => { const db = open(options) diff --git a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts index 60524814..b202e27a 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts @@ -58,6 +58,87 @@ describe('transaction', () => { ) }) + it('rejects synchronous transaction work until earlier async queries settle', async () => { + const pendingQuery = deferred>() + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockImplementation((_name, query) => + query === 'SELECT pending' + ? pendingQuery.promise + : Promise.resolve(nativeResult()), + ) + + await transaction(dbName, async (tx) => { + const pending = tx.executeAsync('SELECT pending') + expect(() => tx.execute('SELECT sync')).toThrow( + 'Await all tx.executeAsync', + ) + expect(() => tx.commit()).toThrow('Await all tx.executeAsync') + expect(() => tx.rollback()).toThrow('Await all tx.executeAsync') + + pendingQuery.resolve(nativeResult()) + await pending + expect(tx.execute('SELECT sync').rows.length).toBe(0) + }) + + expect(HybridNitroSQLite.execute).toHaveBeenLastCalledWith( + dbName, + 'COMMIT', + undefined, + ) + }) + + it('waits for unawaited async queries before rolling back', async () => { + const pendingQuery = deferred>() + const queryStarted = deferred() + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockImplementation((_name, query) => + query === 'SELECT pending' + ? pendingQuery.promise + : Promise.resolve(nativeResult()), + ) + + const pendingTransaction = transaction(dbName, async (tx) => { + tx.executeAsync('SELECT pending') + queryStarted.resolve() + }) + await queryStarted.promise + expect(HybridNitroSQLite.execute).not.toHaveBeenCalled() + + pendingQuery.resolve(nativeResult()) + await expect(pendingTransaction).rejects.toThrow( + 'Await all tx.executeAsync', + ) + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1) + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'ROLLBACK', + undefined, + ) + }) + + it('rolls back after an async query rejects', async () => { + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockImplementation((_name, query) => + query === 'SELECT failed' + ? Promise.reject(new Error('query failed')) + : Promise.resolve(nativeResult()), + ) + + await expect( + transaction(dbName, async (tx) => { + await tx.executeAsync('SELECT failed') + }), + ).rejects.toThrow('query failed') + expect(HybridNitroSQLite.execute).toHaveBeenCalledWith( + dbName, + 'ROLLBACK', + undefined, + ) + }) + it('starts an exclusive transaction and does not commit after an explicit commit', async () => { await transaction( dbName, diff --git a/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts b/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts index 983e40e7..f263694c 100644 --- a/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts +++ b/packages/react-native-nitro-sqlite/src/operations/executeBatch.ts @@ -1,6 +1,6 @@ import { HybridNitroSQLite } from '../nitro' import { - queueOperationAsync, + queueStatementAsync, startOperationSync, throwIfDatabaseIsNotOpen, } from '../DatabaseQueue' @@ -28,7 +28,7 @@ export async function executeBatchAsync( ): Promise { throwIfDatabaseIsNotOpen(dbName) - return queueOperationAsync(dbName, async () => { + return queueStatementAsync(dbName, async () => { try { return await HybridNitroSQLite.executeBatchAsync(dbName, commands) } catch (error) { diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index 6b4d5a6d..26ce4332 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -16,7 +16,7 @@ import { closeDatabaseQueue, isDatabaseOpen, openDatabaseQueue, - queueOperationAsync, + queueStatementAsync, startOperationSync, } from '../DatabaseQueue' @@ -86,7 +86,7 @@ export function open( HybridNitroSQLite.loadFile(options.name, location), ), loadFileAsync: (location: string) => - queueOperationAsync(options.name, async () => { + queueStatementAsync(options.name, async () => { try { return await HybridNitroSQLite.loadFileAsync(options.name, location) } catch (error) { diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index 25f68b2d..283f0b4d 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -16,6 +16,15 @@ export const transaction = async ( throwIfDatabaseIsNotOpen(dbName) let isFinished = false + const pendingAsyncStatements = new Set>() + + const throwIfAsyncPending = () => { + if (pendingAsyncStatements.size > 0) { + throw new NitroSQLiteError( + `Cannot run synchronous operation on transaction ${dbName} while async queries are pending. Await all tx.executeAsync calls first.`, + ) + } + } const executeOnTransaction = ( query: string, @@ -26,6 +35,7 @@ export const transaction = async ( `Cannot execute query on finalized transaction: ${dbName}`, ) } + throwIfAsyncPending() return executeNative(dbName, query, params) } @@ -38,7 +48,13 @@ export const transaction = async ( `Cannot execute query on finalized transaction: ${dbName}`, ) } - return executeAsyncNative(dbName, query, params) + const pending = executeAsyncNative(dbName, query, params) + pendingAsyncStatements.add(pending) + pending.then( + () => pendingAsyncStatements.delete(pending), + () => pendingAsyncStatements.delete(pending), + ) + return pending } const commit = () => { @@ -47,6 +63,7 @@ export const transaction = async ( `Cannot execute commit on finalized transaction: ${dbName}`, ) } + throwIfAsyncPending() isFinished = true return executeNative(dbName, 'COMMIT') } @@ -57,6 +74,7 @@ export const transaction = async ( `Cannot execute rollback on finalized transaction: ${dbName}`, ) } + throwIfAsyncPending() isFinished = true return executeNative(dbName, 'ROLLBACK') } @@ -80,8 +98,12 @@ export const transaction = async ( return result } catch (executionError) { if (!isFinished) { + isFinished = true + // All queued native calls must finish before ROLLBACK can run + // synchronously on this connection. + await Promise.allSettled(pendingAsyncStatements) try { - rollback() + executeNative(dbName, 'ROLLBACK') } catch (rollbackError) { throw NitroSQLiteError.fromError(rollbackError) } From 0976b18a3918b2d224b21ea1f9ef019dd6e205df Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 23 Sep 2026 14:10:46 +0200 Subject: [PATCH 5/5] test: observe queued batch rejection immediately --- example/tests/unit/specs/DatabaseQueue.spec.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index 0554764a..414a60f9 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -274,17 +274,16 @@ export default function registerDatabaseQueueUnitTests() { { query: 'INSERT INTO BatchBarrier (value) VALUES (2)' }, { query: 'INSERT INTO BatchBarrier (value) VALUES (1)' }, ]) + const batchErrorPromise = batch.then( + () => undefined, + (error: unknown) => error, + ) const after = testDb.executeAsync( 'INSERT INTO BatchBarrier (value) VALUES (3)', ) await before - let batchError: unknown - try { - await batch - } catch (error) { - batchError = error - } + const batchError = await batchErrorPromise expect(batchError).toBeInstanceOf(NitroSQLiteError) await after expect(