Skip to content
Open
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
49 changes: 35 additions & 14 deletions packages/react-native-nitro-sqlite/src/DatabaseQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,47 @@ function enqueueOperation<Result>(
kind: QueuedOperation['kind'],
callback: () => Promise<Result>,
): Promise<Result> {
const databaseQueue = getDatabaseQueue(dbName)
let databaseQueue: DatabaseQueue
try {
databaseQueue = getDatabaseQueue(dbName)
} catch (error) {
return Promise.reject(error)
}

return new Promise<Result>((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<Result>
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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>()
Expand Down
30 changes: 30 additions & 0 deletions packages/react-native-nitro-sqlite/src/__tests__/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | 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<unknown> | undefined
expect(() => {
pending = executeAsyncManaged(dbName, query, undefined, 'missing-queue')
}).not.toThrow()
await expect(pending).rejects.toThrow('not open')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | undefined
expect(() => {
pending = NitroSQLite.executeRawAsync(dbName, query)
}).not.toThrow()
await expect(pending).rejects.toMatchObject({
name: 'NitroSQLiteError',
message: 'raw bind failed',
})
})
40 changes: 26 additions & 14 deletions packages/react-native-nitro-sqlite/src/operations/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function executeNative<Row extends QueryResultRow = never>(
* @param params Values bound to the placeholders.
* @returns A promise of the query result with typed rows.
*/
export async function executeAsync<Row extends QueryResultRow = never>(
export function executeAsync<Row extends QueryResultRow = never>(
dbName: string,
query: string,
params?: SQLiteQueryParams,
Expand All @@ -74,26 +74,28 @@ export async function executeAsync<Row extends QueryResultRow = never>(
return executeAsyncManaged(dbName, query, params)
}

export async function executeAsyncManaged<Row extends QueryResultRow = never>(
export function executeAsyncManaged<Row extends QueryResultRow = never>(
dbName: string,
query: string,
params?: SQLiteQueryParams,
queueKey: DatabaseQueueKey = dbName,
): Promise<QueryResult<Row>> {
return queueStatementAsync(queueKey, () =>
executeAsyncNative(dbName, query, params),
executeAsyncNative<Row>(dbName, query, params),
)
}

export async function executeAsyncNative<Row extends QueryResultRow = never>(
export function executeAsyncNative<Row extends QueryResultRow = never>(
dbName: string,
query: string,
params?: SQLiteQueryParams,
): Promise<QueryResult<Row>> {
// 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<NitroSQLiteQueryResult>
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,
Expand All @@ -105,10 +107,13 @@ export async function executeAsyncNative<Row extends QueryResultRow = never>(
}
},
)
return buildJSQueryResult<Row>(nativeResult, leadingRows)
} catch (error) {
throw NitroSQLiteError.fromError(error)
return Promise.reject(NitroSQLiteError.fromError(error))
}
return nativeResult.then(
(result) => buildJSQueryResult<Row>(result, leadingRows),
rejectWithNitroSQLiteError,
)
}

/** Execute one SQL statement synchronously by database name, returning rows as arrays.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -187,16 +192,23 @@ export async function executeRawAsyncManaged(
)
}

export async function executeRawAsyncNative(
export function executeRawAsyncNative(
dbName: string,
query: string,
params?: SQLiteQueryParams,
): Promise<NitroSQLiteRawQueryResult> {
let nativeResult: Promise<NitroSQLiteRawQueryResult>
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<Row extends QueryResultRow = never>(
Expand Down
35 changes: 14 additions & 21 deletions packages/react-native-nitro-sqlite/src/operations/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -45,17 +45,15 @@ export function prepare(
throw NitroSQLiteError.fromError(error)
}
},
executeAsync: async <Row extends QueryResultRow = QueryResultRow>(
// Chained instead of `async`, which would add promise layers to every execution.
executeAsync: <Row extends QueryResultRow = QueryResultRow>(
params?: SQLiteQueryParams,
): Promise<QueryResult<Row>> => {
try {
return await queueOperationAsync(queueKey, async () =>
buildJSQueryResult(await nativeStatement.executeAsync(params)),
)
} catch (error) {
throw NitroSQLiteError.fromError(error)
}
},
): Promise<QueryResult<Row>> =>
queueOperationAsync(queueKey, () =>
nativeStatement
.executeAsync(params)
.then((result) => buildJSQueryResult<Row>(result)),
).catch(rejectWithNitroSQLiteError),
executeRaw: (params?: SQLiteQueryParams): NitroSQLiteRawQueryResult => {
try {
return startOperationSync(queueKey, () =>
Expand All @@ -65,17 +63,12 @@ export function prepare(
throw NitroSQLiteError.fromError(error)
}
},
executeRawAsync: async (
executeRawAsync: (
params?: SQLiteQueryParams,
): Promise<NitroSQLiteRawQueryResult> => {
try {
return await queueOperationAsync(queueKey, () =>
nativeStatement.executeRawAsync(params),
)
} catch (error) {
throw NitroSQLiteError.fromError(error)
}
},
): Promise<NitroSQLiteRawQueryResult> =>
queueOperationAsync(queueKey, () =>
nativeStatement.executeRawAsync(params),
).catch(rejectWithNitroSQLiteError),
finalize: () => {
try {
startOperationSync(queueKey, () => nativeStatement.finalize())
Expand Down
22 changes: 17 additions & 5 deletions packages/react-native-nitro-sqlite/src/operations/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <Result>(
callback: () => Promise<Result>,
): Promise<Result> => {
try {
assertCurrentConnection()
} catch (error) {
return Promise.reject(error)
}
return callback()
}

return {
close: () => {
runSyncOperation(() => HybridNitroSQLite.close(connectionId))
Expand Down Expand Up @@ -143,19 +155,19 @@ export function open(
params?: SQLiteQueryParams,
): QueryResult<Row> =>
runOperation(() => executeManaged(connectionId, query, params, queueKey)),
executeAsync: async <Row extends QueryResultRow = never>(
executeAsync: <Row extends QueryResultRow = never>(
query: string,
params?: SQLiteQueryParams,
): Promise<QueryResult<Row>> =>
runOperation(() =>
executeAsyncManaged(connectionId, query, params, queueKey),
runAsyncOperation(() =>
executeAsyncManaged<Row>(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) =>
Expand Down
Loading