Skip to content

Commit 46b6e7d

Browse files
fix: roll back failed transaction commits (#316)
Track transaction finalization states explicitly and preserve commit failures while rolling back deferred constraint errors. Closes #307. Co-authored-by: Christoph Pader <chris@margelo.com>
1 parent dc27349 commit 46b6e7d

5 files changed

Lines changed: 298 additions & 16 deletions

File tree

‎docs/content/docs/guides/transactions.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const transferId = await db.transaction(async (tx) => {
2727

2828
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.
2929

30+
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.
31+
32+
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.
33+
3034
## Keep operations inside the callback
3135

3236
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.

‎example/tests/unit/specs/operations/transaction.spec.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,29 @@ import type { User } from '@/model/User'
1111
import { NitroSQLiteError } from 'react-native-nitro-sqlite'
1212
import { testDb } from '@tests/db'
1313

14+
const FOREIGN_KEY_COMMIT_ERROR = 'FOREIGN KEY constraint failed'
15+
1416
export default function registerTransactionUnitTests() {
1517
describe('transaction', () => {
18+
it('Transaction, rolls back after automatic deferred foreign key commit failure', async () => {
19+
await expectDeferredForeignKeyCommitFailure()
20+
})
21+
22+
it('Transaction, rolls back after manual deferred foreign key commit failure', async () => {
23+
await expectDeferredForeignKeyCommitFailure((tx) => tx.commit())
24+
})
25+
26+
it('Transaction, rolls back a caught manual commit failure', async () => {
27+
await expectDeferredForeignKeyCommitFailure((tx) => {
28+
try {
29+
tx.commit()
30+
} catch (error) {
31+
if (!isNitroSQLiteError(error)) throw error
32+
expect(error.message).toContain(FOREIGN_KEY_COMMIT_ERROR)
33+
}
34+
})
35+
})
36+
1637
it('Transaction, auto commit', async () => {
1738
const id = chance.integer()
1839
const name = chance.name()
@@ -459,3 +480,37 @@ export default function registerTransactionUnitTests() {
459480
})
460481
})
461482
}
483+
484+
async function expectDeferredForeignKeyCommitFailure(
485+
finalize?: (
486+
tx: Parameters<Parameters<typeof testDb.transaction>[0]>[0],
487+
) => void,
488+
) {
489+
testDb.execute('PRAGMA foreign_keys = ON')
490+
testDb.execute('DROP TABLE IF EXISTS Child')
491+
testDb.execute('DROP TABLE IF EXISTS Parent')
492+
testDb.execute('CREATE TABLE Parent (id INTEGER PRIMARY KEY)')
493+
testDb.execute(
494+
'CREATE TABLE Child (parentId INTEGER REFERENCES Parent(id) DEFERRABLE INITIALLY DEFERRED)',
495+
)
496+
497+
try {
498+
await testDb.transaction(async (tx) => {
499+
tx.execute('INSERT INTO Child (parentId) VALUES (1)')
500+
finalize?.(tx)
501+
})
502+
throw new Error(TEST_ERROR_CODES.EXPECT_PROMISE_REJECTION)
503+
} catch (error) {
504+
if (isNitroSQLiteError(error)) {
505+
expect(error.message).toContain(FOREIGN_KEY_COMMIT_ERROR)
506+
} else {
507+
throw new Error(TEST_ERROR_CODES.EXPECT_NITRO_SQLITE_ERROR)
508+
}
509+
}
510+
511+
expect(testDb.execute('SELECT * FROM Child').rows?._array).toEqual([])
512+
513+
await testDb.transaction(async (tx) => {
514+
tx.execute('INSERT INTO Parent (id) VALUES (1)')
515+
})
516+
}

‎packages/react-native-nitro-sqlite/src/__tests__/transaction.test.ts‎

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
jest.mock('../nitro')
22

3+
import Database from 'better-sqlite3'
34
import { HybridNitroSQLite } from '../nitro'
45
import { closeDatabaseQueue, openDatabaseQueue } from '../DatabaseQueue'
56
import { transaction } from '../operations/transaction'
@@ -208,7 +209,8 @@ describe('transaction', () => {
208209
}),
209210
).rejects.toMatchObject({
210211
name: 'NitroSQLiteError',
211-
message: 'rollback failed',
212+
message: 'callback failed\nRollback failed: rollback failed',
213+
cause: expect.any(AggregateError),
212214
})
213215
})
214216

@@ -239,4 +241,152 @@ describe('transaction', () => {
239241
await Promise.all([first, second])
240242
expect(order).toEqual(['first', 'second'])
241243
})
244+
it.each(['automatic', 'manual', 'caught manual'])(
245+
'rolls back a real deferred constraint after a %s commit failure',
246+
async (mode) => {
247+
const sqlite = new Database(':memory:')
248+
sqlite.exec(
249+
'PRAGMA foreign_keys = ON; CREATE TABLE Parent (id INTEGER PRIMARY KEY); CREATE TABLE Child (parentId INTEGER REFERENCES Parent(id) DEFERRABLE INITIALLY DEFERRED)',
250+
)
251+
const execute = (_name: string, query: string) => {
252+
try {
253+
sqlite.exec(query)
254+
return nativeResult()
255+
} catch (error) {
256+
// The addon creates errors outside Jest's realm. The native bridge
257+
// exposes a JavaScript Error, so mirror that boundary here.
258+
if (
259+
typeof error === 'object' &&
260+
error !== null &&
261+
'message' in error &&
262+
typeof error.message === 'string'
263+
) {
264+
throw new Error(error.message, { cause: error })
265+
}
266+
throw error
267+
}
268+
}
269+
jest.mocked(HybridNitroSQLite.execute).mockImplementation(execute)
270+
jest
271+
.mocked(HybridNitroSQLite.executeAsync)
272+
.mockImplementation(async (name, query) => execute(name, query))
273+
try {
274+
await expect(
275+
transaction(dbName, async (tx) => {
276+
tx.execute('INSERT INTO Child VALUES (1)')
277+
if (mode === 'manual') tx.commit()
278+
if (mode === 'caught manual') {
279+
expect(() => tx.commit()).toThrow('FOREIGN KEY constraint failed')
280+
expect(() => tx.execute('SELECT 1')).toThrow(
281+
'finalized transaction',
282+
)
283+
expect(() => tx.executeAsync('SELECT 1')).toThrow(
284+
'finalized transaction',
285+
)
286+
expect(() => tx.commit()).toThrow('finalized transaction')
287+
}
288+
}),
289+
).rejects.toThrow('FOREIGN KEY constraint failed')
290+
expect(sqlite.inTransaction).toBe(false)
291+
expect(
292+
sqlite.prepare('SELECT COUNT(*) AS count FROM Child').get(),
293+
).toEqual({ count: 0 })
294+
await transaction(dbName, async (tx) => {
295+
tx.execute('INSERT INTO Parent VALUES (1)')
296+
})
297+
expect(sqlite.inTransaction).toBe(false)
298+
} finally {
299+
sqlite.close()
300+
}
301+
},
302+
)
303+
304+
it('allows explicit rollback to recover a caught commit failure', async () => {
305+
jest
306+
.mocked(HybridNitroSQLite.execute)
307+
.mockImplementation((_name, query) => {
308+
if (query === 'COMMIT') throw new Error('commit failed')
309+
return nativeResult()
310+
})
311+
await expect(
312+
transaction(dbName, async (tx) => {
313+
expect(() => tx.commit()).toThrow('commit failed')
314+
tx.rollback()
315+
return 'recovered'
316+
}),
317+
).resolves.toBe('recovered')
318+
})
319+
320+
it.each([false, true])(
321+
'rejects a failed manual rollback even when caught: %s',
322+
async (caught) => {
323+
jest.mocked(HybridNitroSQLite.execute).mockImplementation(() => {
324+
throw new Error('rollback failed')
325+
})
326+
await expect(
327+
transaction(dbName, async (tx) => {
328+
if (caught) {
329+
expect(() => tx.rollback()).toThrow('rollback failed')
330+
return
331+
}
332+
tx.rollback()
333+
}),
334+
).rejects.toThrow('rollback failed')
335+
expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(1)
336+
},
337+
)
338+
339+
it('preserves commit and rollback errors in order', async () => {
340+
jest
341+
.mocked(HybridNitroSQLite.execute)
342+
.mockImplementation((_name, query) => {
343+
throw new Error(
344+
query === 'COMMIT' ? 'commit failed' : 'rollback failed',
345+
)
346+
})
347+
await expect(transaction(dbName, async () => {})).rejects.toMatchObject({
348+
message: 'commit failed\nRollback failed: rollback failed',
349+
cause: {
350+
errors: [
351+
expect.objectContaining({ message: 'commit failed' }),
352+
expect.objectContaining({ message: 'rollback failed' }),
353+
],
354+
},
355+
})
356+
})
357+
358+
it('preserves both errors when manual rollback after a failed commit also fails', async () => {
359+
jest
360+
.mocked(HybridNitroSQLite.execute)
361+
.mockImplementation((_name, query) => {
362+
throw new Error(
363+
query === 'COMMIT' ? 'commit failed' : 'rollback failed',
364+
)
365+
})
366+
await expect(
367+
transaction(dbName, async (tx) => {
368+
expect(() => tx.commit()).toThrow('commit failed')
369+
expect(() => tx.rollback()).toThrow('rollback failed')
370+
}),
371+
).rejects.toMatchObject({
372+
message: 'commit failed\nRollback failed: rollback failed',
373+
cause: {
374+
errors: [
375+
expect.objectContaining({ message: 'commit failed' }),
376+
expect.objectContaining({ message: 'rollback failed' }),
377+
],
378+
},
379+
})
380+
expect(HybridNitroSQLite.execute).toHaveBeenCalledTimes(2)
381+
})
382+
383+
it('does not roll back when BEGIN fails', async () => {
384+
jest
385+
.mocked(HybridNitroSQLite.executeAsync)
386+
.mockRejectedValue(new Error('begin failed'))
387+
const callback = jest.fn()
388+
await expect(transaction(dbName, callback)).rejects.toThrow('begin failed')
389+
expect(callback).not.toHaveBeenCalled()
390+
expect(HybridNitroSQLite.execute).not.toHaveBeenCalled()
391+
})
242392
})

0 commit comments

Comments
 (0)