diff --git a/docs/content/docs/guides/transactions.mdx b/docs/content/docs/guides/transactions.mdx index c94055f5..ff3a7196 100644 --- a/docs/content/docs/guides/transactions.mdx +++ b/docs/content/docs/guides/transactions.mdx @@ -27,6 +27,10 @@ const transferId = await db.transaction(async (tx) => { When the callback resolves, the wrapper commits unless you already called `tx.commit()` or `tx.rollback()`. When it rejects or throws, the wrapper rolls back unless the transaction has already been finalized. An explicit `tx.rollback()` does not itself reject the callback: it ends the transaction, and the callback can still resolve. Calls to `tx.execute`, `tx.executeAsync`, `tx.commit`, or `tx.rollback` after finalization throw. +A failed COMMIT can leave SQLite's transaction active, for example when a deferred foreign-key constraint fails. NitroSQLite marks a commit complete only after SQLite accepts it. A failed manual or automatic commit triggers rollback and rejects with the commit error. This also happens if the callback catches a failed `tx.commit()` and returns without explicitly rolling back. After the failure, only `tx.rollback()` is allowed on that transaction handle. + +If rollback also fails, the rejection preserves the original message and appends the rollback error. Its `cause` is an `AggregateError` containing the normalized primary and rollback errors, in that order. A callback that catches a failed manual rollback still causes the transaction promise to reject. A failed BEGIN does not roll back a transaction that the wrapper did not start. + ## Keep operations inside the callback The connection queue is occupied for the whole callback. Use `tx.execute` or `tx.executeAsync` for **every** statement on this database during that callback, including statements in helpers. diff --git a/example/tests/unit/specs/operations/transaction.spec.ts b/example/tests/unit/specs/operations/transaction.spec.ts index eeafbe13..8ab61723 100644 --- a/example/tests/unit/specs/operations/transaction.spec.ts +++ b/example/tests/unit/specs/operations/transaction.spec.ts @@ -11,8 +11,29 @@ import type { User } from '@/model/User' import { NitroSQLiteError } from 'react-native-nitro-sqlite' import { testDb } from '@tests/db' +const FOREIGN_KEY_COMMIT_ERROR = 'FOREIGN KEY constraint failed' + export default function registerTransactionUnitTests() { describe('transaction', () => { + it('Transaction, rolls back after automatic deferred foreign key commit failure', async () => { + await expectDeferredForeignKeyCommitFailure() + }) + + it('Transaction, rolls back after manual deferred foreign key commit failure', async () => { + await expectDeferredForeignKeyCommitFailure((tx) => tx.commit()) + }) + + it('Transaction, rolls back a caught manual commit failure', async () => { + await expectDeferredForeignKeyCommitFailure((tx) => { + try { + tx.commit() + } catch (error) { + if (!isNitroSQLiteError(error)) throw error + expect(error.message).toContain(FOREIGN_KEY_COMMIT_ERROR) + } + }) + }) + it('Transaction, auto commit', async () => { const id = chance.integer() const name = chance.name() @@ -459,3 +480,37 @@ export default function registerTransactionUnitTests() { }) }) } + +async function expectDeferredForeignKeyCommitFailure( + finalize?: ( + tx: Parameters[0]>[0], + ) => void, +) { + testDb.execute('PRAGMA foreign_keys = ON') + testDb.execute('DROP TABLE IF EXISTS Child') + testDb.execute('DROP TABLE IF EXISTS Parent') + testDb.execute('CREATE TABLE Parent (id INTEGER PRIMARY KEY)') + testDb.execute( + 'CREATE TABLE Child (parentId INTEGER REFERENCES Parent(id) DEFERRABLE INITIALLY DEFERRED)', + ) + + try { + await testDb.transaction(async (tx) => { + tx.execute('INSERT INTO Child (parentId) VALUES (1)') + finalize?.(tx) + }) + throw new Error(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION) + } catch (error) { + if (isNitroSQLiteError(error)) { + expect(error.message).toContain(FOREIGN_KEY_COMMIT_ERROR) + } else { + throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR) + } + } + + expect(testDb.execute('SELECT * FROM Child').rows?._array).toEqual([]) + + await testDb.transaction(async (tx) => { + tx.execute('INSERT INTO Parent (id) VALUES (1)') + }) +} 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 b202e27a..df791e7c 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts @@ -1,5 +1,6 @@ jest.mock('../nitro') +import Database from 'better-sqlite3' import { HybridNitroSQLite } from '../nitro' import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue' import { transaction } from '../operations/transaction' @@ -208,7 +209,8 @@ describe('transaction', () => { }), ).rejects.toMatchObject({ name: 'NitroSQLiteError', - message: 'rollback failed', + message: 'callback failed\nRollback failed: rollback failed', + cause: expect.any(AggregateError), }) }) @@ -239,4 +241,152 @@ describe('transaction', () => { await Promise.all([first, second]) expect(order).toEqual(['first', 'second']) }) + it.each(['automatic', 'manual', 'caught manual'])( + 'rolls back a real deferred constraint after a %s commit failure', + async (mode) => { + const sqlite = new Database(':memory:') + sqlite.exec( + 'PRAGMA foreign_keys = ON; CREATE TABLE Parent (id INTEGER PRIMARY KEY); CREATE TABLE Child (parentId INTEGER REFERENCES Parent(id) DEFERRABLE INITIALLY DEFERRED)', + ) + const execute = (_name: string, query: string) => { + try { + sqlite.exec(query) + return nativeResult() + } catch (error) { + // The addon creates errors outside Jest's realm. The native bridge + // exposes a JavaScript Error, so mirror that boundary here. + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ) { + throw new Error(error.message, { cause: error }) + } + throw error + } + } + jest.mocked(HybridNitroSQLite.execute).mockImplementation(execute) + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockImplementation(async (name, query) => execute(name, query)) + try { + await expect( + transaction(dbName, async (tx) => { + tx.execute('INSERT INTO Child VALUES (1)') + if (mode === 'manual') tx.commit() + if (mode === 'caught manual') { + expect(() => tx.commit()).toThrow('FOREIGN KEY constraint failed') + expect(() => tx.execute('SELECT 1')).toThrow( + 'finalized transaction', + ) + expect(() => tx.executeAsync('SELECT 1')).toThrow( + 'finalized transaction', + ) + expect(() => tx.commit()).toThrow('finalized transaction') + } + }), + ).rejects.toThrow('FOREIGN KEY constraint failed') + expect(sqlite.inTransaction).toBe(false) + expect( + sqlite.prepare('SELECT COUNT(*) AS count FROM Child').get(), + ).toEqual({ count: 0 }) + await transaction(dbName, async (tx) => { + tx.execute('INSERT INTO Parent VALUES (1)') + }) + expect(sqlite.inTransaction).toBe(false) + } finally { + sqlite.close() + } + }, + ) + + it('allows explicit rollback to recover a caught commit failure', async () => { + jest + .mocked(HybridNitroSQLite.execute) + .mockImplementation((_name, query) => { + if (query === 'COMMIT') throw new Error('commit failed') + return nativeResult() + }) + await expect( + transaction(dbName, async (tx) => { + expect(() => tx.commit()).toThrow('commit failed') + tx.rollback() + return 'recovered' + }), + ).resolves.toBe('recovered') + }) + + it.each([false, true])( + 'rejects a failed manual rollback even when caught: %s', + async (caught) => { + jest.mocked(HybridNitroSQLite.execute).mockImplementation(() => { + throw new Error('rollback failed') + }) + await expect( + transaction(dbName, async (tx) => { + if (caught) { + expect(() => tx.rollback()).toThrow('rollback failed') + return + } + tx.rollback() + }), + ).rejects.toThrow('rollback failed') + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1) + }, + ) + + it('preserves commit and rollback errors in order', async () => { + jest + .mocked(HybridNitroSQLite.execute) + .mockImplementation((_name, query) => { + throw new Error( + query === 'COMMIT' ? 'commit failed' : 'rollback failed', + ) + }) + await expect(transaction(dbName, async () => {})).rejects.toMatchObject({ + message: 'commit failed\nRollback failed: rollback failed', + cause: { + errors: [ + expect.objectContaining({ message: 'commit failed' }), + expect.objectContaining({ message: 'rollback failed' }), + ], + }, + }) + }) + + it('preserves both errors when manual rollback after a failed commit also fails', async () => { + jest + .mocked(HybridNitroSQLite.execute) + .mockImplementation((_name, query) => { + throw new Error( + query === 'COMMIT' ? 'commit failed' : 'rollback failed', + ) + }) + await expect( + transaction(dbName, async (tx) => { + expect(() => tx.commit()).toThrow('commit failed') + expect(() => tx.rollback()).toThrow('rollback failed') + }), + ).rejects.toMatchObject({ + message: 'commit failed\nRollback failed: rollback failed', + cause: { + errors: [ + expect.objectContaining({ message: 'commit failed' }), + expect.objectContaining({ message: 'rollback failed' }), + ], + }, + }) + expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(2) + }) + + it('does not roll back when BEGIN fails', async () => { + jest + .mocked(HybridNitroSQLite.executeAsync) + .mockRejectedValue(new Error('begin failed')) + const callback = jest.fn() + await expect(transaction(dbName, callback)).rejects.toThrow('begin failed') + expect(callback).not.toHaveBeenCalled() + expect(HybridNitroSQLite.execute).not.toHaveBeenCalled() + }) }) diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index 56885794..9f879bf2 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -13,10 +13,14 @@ import type { DatabaseQueueKey } from '../DatabaseQueue' * Use only the supplied `tx` for work on this database inside the callback. * A successful callback commits unless it explicitly committed or rolled back; * a thrown error rolls back unless the transaction was already finalized. + * Failed commits trigger rollback and rejection unless the callback explicitly rolls back. + * If rollback also fails, the error cause is an AggregateError with both failures. * @param dbName Name of the open database. * @param transactionCallback Async callback receiving the transaction handle. * @param isExclusive Begin an exclusive transaction when true. + * @param queueKey Managed connection queue identifier; defaults to the database name. * @returns The callback's result after the transaction finishes. + * @throws NitroSQLiteError if BEGIN, the callback, COMMIT, or ROLLBACK fails. */ export const transaction = async ( dbName: string, @@ -26,7 +30,10 @@ export const transaction = async ( ) => { throwIfDatabaseIsNotOpen(queueKey) - let isFinished = false + const state: { current: TransactionState } = { + current: { kind: 'notStarted' }, + } + const getState = (): TransactionState => state.current const pendingAsyncStatements = new Set>() const throwIfAsyncPending = () => { @@ -41,7 +48,7 @@ export const transaction = async ( query: string, params?: SQLiteQueryParams, ): QueryResult => { - if (isFinished) { + if (state.current.kind !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -54,7 +61,7 @@ export const transaction = async ( query: string, params?: SQLiteQueryParams, ): Promise> => { - if (isFinished) { + if (state.current.kind !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -69,25 +76,45 @@ export const transaction = async ( } const commit = () => { - if (isFinished) { + if (state.current.kind !== 'active') { throw new NitroSQLiteError( `Cannot execute commit on finalized transaction: ${dbName}`, ) } throwIfAsyncPending() - isFinished = true - return executeNative(dbName, 'COMMIT') + try { + const result = executeNative(dbName, 'COMMIT') + state.current = { kind: 'committed' } + return result + } catch (error) { + state.current = { kind: 'commitFailed', error } + throw error + } } const rollback = () => { - if (isFinished) { + if ( + state.current.kind !== 'active' && + state.current.kind !== 'commitFailed' + ) { throw new NitroSQLiteError( `Cannot execute rollback on finalized transaction: ${dbName}`, ) } throwIfAsyncPending() - isFinished = true - return executeNative(dbName, 'ROLLBACK') + const previousState = getState() + try { + const result = executeNative(dbName, 'ROLLBACK') + state.current = { kind: 'rolledBack' } + return result + } catch (error) { + const failure = + previousState.kind === 'commitFailed' + ? transactionFinalizationError(previousState.error, error) + : error + state.current = { kind: 'rollbackFailed', error: failure } + throw failure + } } return await queueOperationAsync(queueKey, async () => { @@ -97,6 +124,8 @@ export const transaction = async ( isExclusive ? 'BEGIN EXCLUSIVE TRANSACTION' : 'BEGIN TRANSACTION', ) + state.current = { kind: 'active' } + const result = await transactionCallback({ commit, execute: executeOnTransaction, @@ -104,19 +133,31 @@ export const transaction = async ( rollback, }) - if (!isFinished) commit() + const callbackState = getState() + if ( + callbackState.kind === 'commitFailed' || + callbackState.kind === 'rollbackFailed' + ) { + throw callbackState.error + } + if (state.current.kind === 'active') commit() return result } catch (executionError) { - if (!isFinished) { - isFinished = true + if ( + state.current.kind === 'active' || + state.current.kind === 'commitFailed' + ) { + state.current = { kind: 'rollingBack' } // All queued native calls must finish before ROLLBACK can run // synchronously on this connection. await Promise.allSettled(pendingAsyncStatements) try { executeNative(dbName, 'ROLLBACK') + state.current = { kind: 'rolledBack' } } catch (rollbackError) { - throw NitroSQLiteError.fromError(rollbackError) + state.current = { kind: 'rollbackFailed', error: rollbackError } + throw transactionFinalizationError(executionError, rollbackError) } } @@ -124,3 +165,26 @@ export const transaction = async ( } }) } + +type TransactionState = + | { + kind: 'notStarted' | 'active' | 'committed' | 'rolledBack' | 'rollingBack' + } + | { kind: 'commitFailed' | 'rollbackFailed'; error: unknown } + +function transactionFinalizationError( + primary: unknown, + rollback: unknown, +): NitroSQLiteError { + const primaryError = NitroSQLiteError.fromError(primary) + const rollbackError = NitroSQLiteError.fromError(rollback) + return new NitroSQLiteError( + `${primaryError.message}\nRollback failed: ${rollbackError.message}`, + { + cause: new AggregateError( + [primaryError, rollbackError], + 'Transaction finalization failed', + ), + }, + ) +} diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 04f1d2ec..55a075b0 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -49,6 +49,8 @@ export interface NitroSQLiteConnection { detach(alias: string): void /** Run a callback in a queued transaction. The callback must use `tx` for database work. * It commits on success and rolls back on error unless explicitly finalized. + * A failed commit triggers rollback and rejection unless the callback explicitly rolls back. + * If rollback also fails, the error cause is an AggregateError of both failures. * Awaiting another queued operation for this database inside the callback deadlocks. * Synchronous connection methods throw while this transaction is active. * @param transactionCallback Async callback receiving the transaction handle. @@ -201,9 +203,16 @@ export type ExecutePreparedStatementAsync = < /** Handle valid only while its transaction callback is active. */ export interface Transaction { - /** Commit now. Further operations on this transaction throw. */ + /** Commit now. Marks completion only after SQLite accepts COMMIT. + * On failure, only rollback remains available and the wrapper rejects unless + * the callback explicitly rolls back. Throws while async queries are pending. + */ commit(): NitroSQLiteQueryResult - /** Roll back now. Further operations on this transaction throw. */ + /** Roll back now, including after a failed commit. + * Marks completion only after SQLite accepts ROLLBACK. A failed rollback + * rejects the transaction promise even if caught by the callback. + * Throws while async queries are pending or after successful finalization. + */ rollback(): NitroSQLiteQueryResult /** Execute within this transaction on the calling thread. */ execute: ExecuteQuery