From 3e90e005c7be92c14d91a68da7d76abf55481beb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 14 Nov 2026 12:30:34 +0100 Subject: [PATCH 1/2] perf: drop promise layers from async queries executeAsync, executeRawAsync and prepared statement executions went through several async functions and an awaiting queue wrapper, which added promises and microtask turns to every query. They now chain the native promise directly and still report synchronous errors as rejected promises. Co-Authored-By: Claude Opus 5.5 --- .../src/DatabaseQueue.ts | 49 +++++++++++++------ .../src/__tests__/execute.test.ts | 30 ++++++++++++ .../src/operations/execute.ts | 40 +++++++++------ .../src/operations/prepare.ts | 35 ++++++------- .../src/operations/session.ts | 22 +++++++-- 5 files changed, 122 insertions(+), 54 deletions(-) diff --git a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts index 3e47c224..3f84c1a0 100644 --- a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts +++ b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts @@ -86,26 +86,47 @@ function enqueueOperation( kind: QueuedOperation['kind'], callback: () => Promise, ): Promise { - const databaseQueue = getDatabaseQueue(dbName) + let databaseQueue: DatabaseQueue + try { + databaseQueue = getDatabaseQueue(dbName) + } catch (error) { + return Promise.reject(error) + } return new Promise((resolve, reject) => { - async function start() { + const finish = () => { + if (kind === 'statement') { + databaseQueue.activeStatements-- + if (databaseQueue.activeStatements === 0) { + databaseQueue.inProgress = false + } + } else { + databaseQueue.inProgress = false + } + startNextOperations(databaseQueue) + } + + // Chains the operation directly instead of awaiting it in an async function, + // which saves a promise and several microtask turns per queued query. + const start = () => { + let operation: Promise try { - const result = await callback() - resolve(result) + operation = Promise.resolve(callback()) } catch (error) { reject(error) - } finally { - if (kind === 'statement') { - databaseQueue.activeStatements-- - if (databaseQueue.activeStatements === 0) { - databaseQueue.inProgress = false - } - } else { - databaseQueue.inProgress = false - } - startNextOperations(databaseQueue) + finish() + return } + operation.then( + (result) => { + resolve(result) + finish() + }, + (error: unknown) => { + reject(error) + finish() + }, + ) } const operation: QueuedOperation = { diff --git a/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts index e56dd39b..80fc2476 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts @@ -151,4 +151,34 @@ describe('execute', () => { rows: { length: 0 }, }) }) + + it('rejects instead of throwing when the native call throws synchronously', async () => { + openDatabaseQueue(dbName) + jest.mocked(HybridNitroSQLite.executeAsync).mockImplementationOnce(() => { + throw new Error('bind failed') + }) + + let pending: Promise | undefined + expect(() => { + pending = executeAsync(dbName, query) + }).not.toThrow() + await expect(pending).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'bind failed', + }) + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockResolvedValueOnce(nativeResult()) + await expect(executeAsync(dbName, query)).resolves.toMatchObject({ + rows: { length: 0 }, + }) + }) + + it('rejects instead of throwing when a managed queue is not open', async () => { + let pending: Promise | undefined + expect(() => { + pending = executeAsyncManaged(dbName, query, undefined, 'missing-queue') + }).not.toThrow() + await expect(pending).rejects.toThrow('not open') + }) }) diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index 0aff4973..bd2f66c9 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -62,7 +62,7 @@ export function executeNative( * @param params Values bound to the placeholders. * @returns A promise of the query result with typed rows. */ -export async function executeAsync( +export function executeAsync( dbName: string, query: string, params?: SQLiteQueryParams, @@ -74,26 +74,28 @@ export async function executeAsync( return executeAsyncManaged(dbName, query, params) } -export async function executeAsyncManaged( +export function executeAsyncManaged( dbName: string, query: string, params?: SQLiteQueryParams, queueKey: DatabaseQueueKey = dbName, ): Promise> { return queueStatementAsync(queueKey, () => - executeAsyncNative(dbName, query, params), + executeAsyncNative(dbName, query, params), ) } -export async function executeAsyncNative( +export function executeAsyncNative( dbName: string, query: string, params?: SQLiteQueryParams, ): Promise> { + // Not `async`: every extra promise layer is a measurable part of a short query's cost. + // Large results arrive in batches, so rows are converted while SQLite reads the rest. + let leadingRows: Row[] | undefined + let nativeResult: Promise try { - // Large results arrive in batches, so rows are converted while SQLite reads the rest. - let leadingRows: Row[] | undefined - const nativeResult = await HybridNitroSQLite.executeAsync( + nativeResult = HybridNitroSQLite.executeAsync( dbName, query, params, @@ -105,10 +107,13 @@ export async function executeAsyncNative( } }, ) - return buildJSQueryResult(nativeResult, leadingRows) } catch (error) { - throw NitroSQLiteError.fromError(error) + return Promise.reject(NitroSQLiteError.fromError(error)) } + return nativeResult.then( + (result) => buildJSQueryResult(result, leadingRows), + rejectWithNitroSQLiteError, + ) } /** Execute one SQL statement synchronously by database name, returning rows as arrays. @@ -164,7 +169,7 @@ export function executeRawNative( * @returns A promise of rows and column names in SQL column order, with affected row count and insert ID. * @throws NitroSQLiteError as a rejected promise if the query fails. */ -export async function executeRawAsync( +export function executeRawAsync( dbName: string, query: string, params?: SQLiteQueryParams, @@ -176,7 +181,7 @@ export async function executeRawAsync( return executeRawAsyncManaged(dbName, query, params) } -export async function executeRawAsyncManaged( +export function executeRawAsyncManaged( dbName: string, query: string, params?: SQLiteQueryParams, @@ -187,16 +192,23 @@ export async function executeRawAsyncManaged( ) } -export async function executeRawAsyncNative( +export function executeRawAsyncNative( dbName: string, query: string, params?: SQLiteQueryParams, ): Promise { + let nativeResult: Promise try { - return await HybridNitroSQLite.executeRawAsync(dbName, query, params) + nativeResult = HybridNitroSQLite.executeRawAsync(dbName, query, params) } catch (error) { - throw NitroSQLiteError.fromError(error) + return Promise.reject(NitroSQLiteError.fromError(error)) } + return nativeResult.catch(rejectWithNitroSQLiteError) +} + +/** Rethrows any error as a NitroSQLiteError, for use as a promise rejection handler. */ +export function rejectWithNitroSQLiteError(error: unknown): never { + throw NitroSQLiteError.fromError(error) } export function buildJSQueryResult( diff --git a/packages/react-native-nitro-sqlite/src/operations/prepare.ts b/packages/react-native-nitro-sqlite/src/operations/prepare.ts index ce0e5de7..48054a74 100644 --- a/packages/react-native-nitro-sqlite/src/operations/prepare.ts +++ b/packages/react-native-nitro-sqlite/src/operations/prepare.ts @@ -7,7 +7,7 @@ import type { SQLiteQueryParams, } from '../types' import type { NitroSQLiteRawQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' -import { buildJSQueryResult } from './execute' +import { buildJSQueryResult, rejectWithNitroSQLiteError } from './execute' import { queueOperationAsync, startOperationSync } from '../DatabaseQueue' import type { DatabaseQueueKey } from '../DatabaseQueue' @@ -45,17 +45,15 @@ export function prepare( throw NitroSQLiteError.fromError(error) } }, - executeAsync: async ( + // Chained instead of `async`, which would add promise layers to every execution. + executeAsync: ( params?: SQLiteQueryParams, - ): Promise> => { - try { - return await queueOperationAsync(queueKey, async () => - buildJSQueryResult(await nativeStatement.executeAsync(params)), - ) - } catch (error) { - throw NitroSQLiteError.fromError(error) - } - }, + ): Promise> => + queueOperationAsync(queueKey, () => + nativeStatement + .executeAsync(params) + .then((result) => buildJSQueryResult(result)), + ).catch(rejectWithNitroSQLiteError), executeRaw: (params?: SQLiteQueryParams): NitroSQLiteRawQueryResult => { try { return startOperationSync(queueKey, () => @@ -65,17 +63,12 @@ export function prepare( throw NitroSQLiteError.fromError(error) } }, - executeRawAsync: async ( + executeRawAsync: ( params?: SQLiteQueryParams, - ): Promise => { - try { - return await queueOperationAsync(queueKey, () => - nativeStatement.executeRawAsync(params), - ) - } catch (error) { - throw NitroSQLiteError.fromError(error) - } - }, + ): Promise => + queueOperationAsync(queueKey, () => + nativeStatement.executeRawAsync(params), + ).catch(rejectWithNitroSQLiteError), finalize: () => { try { startOperationSync(queueKey, () => nativeStatement.finalize()) diff --git a/packages/react-native-nitro-sqlite/src/operations/session.ts b/packages/react-native-nitro-sqlite/src/operations/session.ts index 7a84e004..e71538fa 100644 --- a/packages/react-native-nitro-sqlite/src/operations/session.ts +++ b/packages/react-native-nitro-sqlite/src/operations/session.ts @@ -84,6 +84,18 @@ export function open( return callback() } + // Like an `async` wrapper, reports a closed connection as a rejection, without its extra promise. + const runAsyncOperation = ( + callback: () => Promise, + ): Promise => { + try { + assertCurrentConnection() + } catch (error) { + return Promise.reject(error) + } + return callback() + } + return { close: () => { runSyncOperation(() => HybridNitroSQLite.close(connectionId)) @@ -143,19 +155,19 @@ export function open( params?: SQLiteQueryParams, ): QueryResult => runOperation(() => executeManaged(connectionId, query, params, queueKey)), - executeAsync: async ( + executeAsync: ( query: string, params?: SQLiteQueryParams, ): Promise> => - runOperation(() => - executeAsyncManaged(connectionId, query, params, queueKey), + runAsyncOperation(() => + executeAsyncManaged(connectionId, query, params, queueKey), ), executeRaw: (query: string, params?: SQLiteQueryParams) => runOperation(() => executeRawManaged(connectionId, query, params, queueKey), ), - executeRawAsync: async (query: string, params?: SQLiteQueryParams) => - runOperation(() => + executeRawAsync: (query: string, params?: SQLiteQueryParams) => + runAsyncOperation(() => executeRawAsyncManaged(connectionId, query, params, queueKey), ), prepare: (query: string) => From 8507047d5c12570c31c655baf8a7f29ced79d1e9 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 14 Nov 2026 12:39:06 +0100 Subject: [PATCH 2/2] test: cover synchronous throws in queued and raw async calls Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/DatabaseQueue.test.ts | 14 ++++++++++++++ .../src/__tests__/executeRaw.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+) 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 35886ac6..d4ac5985 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts @@ -94,6 +94,20 @@ describe('DatabaseQueue', () => { }) }) + it('rejects and releases the queue when a callback throws synchronously', async () => { + openDatabaseQueue(dbName) + const failed = queueStatementAsync(dbName, () => { + throw new Error('sync failure') + }) + + await expect(failed).rejects.toThrow('sync failure') + expect(getDatabaseQueue(dbName)).toMatchObject({ + inProgress: false, + activeStatements: 0, + }) + await expect(queueOperationAsync(dbName, async () => 1)).resolves.toBe(1) + }) + it('submits a burst of statements before waiting for native results', async () => { openDatabaseQueue(dbName) const first = deferred() diff --git a/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts index 0982390e..5fca09b0 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/executeRaw.test.ts @@ -110,3 +110,18 @@ it('normalizes native errors and releases the managed queue after failure', asyn }) await expect(NitroSQLite.executeRawAsync(dbName, query)).resolves.toBe(raw) }) + +it('rejects instead of throwing when the native raw call throws synchronously', async () => { + jest.mocked(HybridNitroSQLite.executeRawAsync).mockImplementationOnce(() => { + throw new Error('raw bind failed') + }) + + let pending: Promise | undefined + expect(() => { + pending = NitroSQLite.executeRawAsync(dbName, query) + }).not.toThrow() + await expect(pending).rejects.toMatchObject({ + name: 'NitroSQLiteError', + message: 'raw bind failed', + }) +})