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__/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__/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/__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', + }) +}) 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) =>