diff --git a/example/tests/unit/specs/operations/transaction.spec.ts b/example/tests/unit/specs/operations/transaction.spec.ts index bc0e3ad3..e6244c7f 100644 --- a/example/tests/unit/specs/operations/transaction.spec.ts +++ b/example/tests/unit/specs/operations/transaction.spec.ts @@ -10,8 +10,52 @@ import { describe, it } from '@tests/TestApi' import type { User } from '@/model/User' import { testDb } from '@tests/db' +const FOREIGN_KEY_COMMIT_ERROR = 'FOREIGN KEY constraint failed' + +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)') + }) +} + 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, auto commit', async () => { const id = chance.integer() const name = chance.name() diff --git a/packages/react-native-nitro-sqlite/src/operations/transaction.ts b/packages/react-native-nitro-sqlite/src/operations/transaction.ts index ae969b3c..fddbcfb4 100644 --- a/packages/react-native-nitro-sqlite/src/operations/transaction.ts +++ b/packages/react-native-nitro-sqlite/src/operations/transaction.ts @@ -8,6 +8,13 @@ import type { import { execute, executeAsync } from './execute' import NitroSQLiteError from '../NitroSQLiteError' +type TransactionState = + | 'active' + | 'committed' + | 'rolledBack' + | 'commitFinalizationFailed' + | 'rollbackFailed' + export const transaction = async ( dbName: string, transactionCallback: (tx: Transaction) => Promise, @@ -15,13 +22,13 @@ export const transaction = async ( ) => { throwIfDatabaseIsNotOpen(dbName) - let isFinished = false + let state: TransactionState = 'active' const executeOnTransaction = ( query: string, params?: SQLiteQueryParams, ): QueryResult => { - if (isFinished) { + if (state !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -33,7 +40,7 @@ export const transaction = async ( query: string, params?: SQLiteQueryParams, ): Promise> => { - if (isFinished) { + if (state !== 'active') { throw new NitroSQLiteError( `Cannot execute query on finalized transaction: ${dbName}`, ) @@ -42,23 +49,35 @@ export const transaction = async ( } const commit = () => { - if (isFinished) { + if (state !== 'active') { throw new NitroSQLiteError( `Cannot execute commit on finalized transaction: ${dbName}`, ) } - isFinished = true - return execute(dbName, 'COMMIT') + try { + const result = execute(dbName, 'COMMIT') + state = 'committed' + return result + } catch (error) { + state = 'commitFinalizationFailed' + throw error + } } const rollback = () => { - if (isFinished) { + if (state !== 'active' && state !== 'commitFinalizationFailed') { throw new NitroSQLiteError( `Cannot execute rollback on finalized transaction: ${dbName}`, ) } - isFinished = true - return execute(dbName, 'ROLLBACK') + try { + const result = execute(dbName, 'ROLLBACK') + state = 'rolledBack' + return result + } catch (error) { + state = 'rollbackFailed' + throw error + } } return await queueOperationAsync(dbName, async () => { @@ -75,15 +94,25 @@ export const transaction = async ( rollback, }) - if (!isFinished) commit() + if (state === 'active') commit() return result } catch (executionError) { - if (!isFinished) { + if (state === 'active' || state === 'commitFinalizationFailed') { try { rollback() } catch (rollbackError) { - throw NitroSQLiteError.fromError(rollbackError) + const primaryError = NitroSQLiteError.fromError(executionError) + const finalizationError = NitroSQLiteError.fromError(rollbackError) + throw new NitroSQLiteError( + `${primaryError.message}\nRollback failed: ${finalizationError.message}`, + { + cause: new AggregateError( + [primaryError, finalizationError], + 'Transaction finalization failed', + ), + }, + ) } }