UNPKG

typeorm-transactional-async-callbacks

Version:

A Transactional Method Decorator for typeorm that uses cls-hooked to handle and propagate transactions between different repositories and service methods. Inpired by Spring Trasnactional Annotation and Sequelize CLS

711 lines (557 loc) 23.6 kB
import { DataSource } from 'typeorm'; import { addTransactionalDataSource, initializeTransactionalContext, IsolationLevel, Propagation, runInTransaction, runOnTransactionCommit, runOnTransactionComplete, runOnTransactionRollback, StorageDriver, TransactionalError, } from '../src'; import { User } from './entities/User.entity'; import { Counter } from './entities/Counter.entity'; import { UserRepository } from './repositories/user.repository'; import { extendUserRepository } from './repositories/extend-user-repository'; import { sleep, getCurrentTransactionId } from './utils'; const dataSource: DataSource = new DataSource({ type: 'postgres', host: 'localhost', port: 5435, username: 'postgres', password: 'postgres', database: 'test', entities: [User, Counter], synchronize: true, }); const storageDriver = process.env.TEST_STORAGE_DRIVER && process.env.TEST_STORAGE_DRIVER in StorageDriver ? StorageDriver[process.env.TEST_STORAGE_DRIVER as keyof typeof StorageDriver] : StorageDriver.CLS_HOOKED; initializeTransactionalContext({ storageDriver }); addTransactionalDataSource(dataSource); beforeAll(async () => { await dataSource.initialize(); }); afterAll(async () => { await dataSource.createEntityManager().clear(User); await dataSource.createEntityManager().clear(Counter); await dataSource.destroy(); }); describe('Transactional', () => { afterEach(async () => { await dataSource.createEntityManager().clear(User); await dataSource.createEntityManager().clear(Counter); }); describe('General', () => { const sources = [ { name: 'DataSource', source: dataSource, }, { name: 'Repository', source: dataSource.getRepository(User), }, { name: 'Entity Manager', source: dataSource.createEntityManager(), }, { name: 'Custom Repository', source: new UserRepository(dataSource), }, { name: 'Extend Repository', source: extendUserRepository(dataSource.getRepository(User)), }, { name: 'Query Builder', source: () => dataSource.createQueryBuilder(), }, ]; describe.each(sources)('$name', ({ source }) => { it('supports basic transactions', async () => { let transactionIdBefore: number | null = null; await runInTransaction(async () => { transactionIdBefore = await getCurrentTransactionId(source); const transactionIdAfter = await getCurrentTransactionId(source); expect(transactionIdBefore).toBeTruthy(); expect(transactionIdBefore).toBe(transactionIdAfter); }); const transactionIdOutside = await getCurrentTransactionId(source); expect(transactionIdOutside).toBe(null); expect(transactionIdOutside).not.toBe(transactionIdBefore); }); it('supports nested transactions', async () => { await runInTransaction(async () => { const transactionIdBefore = await getCurrentTransactionId(source); await runInTransaction(async () => { const transactionIdAfter = await getCurrentTransactionId(source); expect(transactionIdBefore).toBe(transactionIdAfter); }); }); expect.assertions(1); }); it('supports several concurrent transactions', async () => { let transactionA: number | null = null; let transactionB: number | null = null; let transactionC: number | null = null; await Promise.all([ runInTransaction(async () => { transactionA = await getCurrentTransactionId(source); }), runInTransaction(async () => { transactionB = await getCurrentTransactionId(source); }), runInTransaction(async () => { transactionC = await getCurrentTransactionId(source); }), ]); await Promise.all([transactionA, transactionB, transactionC]); expect(transactionA).toBeTruthy(); expect(transactionB).toBeTruthy(); expect(transactionC).toBeTruthy(); expect(transactionA).not.toBe(transactionB); expect(transactionA).not.toBe(transactionC); expect(transactionB).not.toBe(transactionC); }); }); // We want to check that `save` doesn't create any intermediate transactions describe('Repository', () => { it('should not create any intermediate transactions', async () => { let transactionIdA: number | null = null; let transactionIdB: number | null = null; const userRepository = dataSource.getRepository(User); await runInTransaction(async () => { transactionIdA = await getCurrentTransactionId(dataSource); await userRepository.save(new User('John Doe', 100)); }); await runInTransaction(async () => { transactionIdB = await getCurrentTransactionId(dataSource); }); let transactionDiff = transactionIdB! - transactionIdA!; expect(transactionDiff).toBe(1); }); }); describe('Extend Repository', () => { it('should not create any intermediate transactions', async () => { let transactionIdA: number | null = null; let transactionIdB: number | null = null; const customRepository = extendUserRepository(dataSource.getRepository(User)); await runInTransaction(async () => { transactionIdA = await getCurrentTransactionId(dataSource); await customRepository.save(new User('John Doe', 100)); }); await runInTransaction(async () => { transactionIdB = await getCurrentTransactionId(dataSource); }); let transactionDiff = transactionIdB! - transactionIdA!; expect(transactionDiff).toBe(1); }); }); // describe('Query Builder', () => { // it('should not create any intermediate transactions', async () => { // let transactionIdA: number | null = null; // let transactionIdB: number | null = null; // const qb = dataSource.createQueryBuilder(); // await runInTransaction(async () => { // transactionIdA = await getCurrentTransactionId(dataSource); // await qb.insert().into(User).values({ name: 'John Doe', money: 100 }).execute(); // }); // await runInTransaction(async () => { // transactionIdB = await getCurrentTransactionId(dataSource); // }); // let transactionDiff = transactionIdB! - transactionIdA!; // expect(transactionDiff).toBe(1); // }); // }); // describe('Entity Manager', () => { // it('should not create any intermediate transactions', async () => { // let transactionIdA: number | null = null; // let transactionIdB: number | null = null; // await runInTransaction(async () => { // transactionIdA = await getCurrentTransactionId(dataSource); // await dataSource.createEntityManager().save(new User('John Doe', 100)); // }); // await runInTransaction(async () => { // transactionIdB = await getCurrentTransactionId(dataSource); // }); // let transactionDiff = transactionIdB! - transactionIdA!; // expect(transactionDiff).toBe(1); // }); // }); }); // Focus more on the repository, since it's the most common use case describe('Repository', () => { it('supports basic transactions', async () => { const userRepository = new UserRepository(dataSource); let transactionIdBefore: number | null = null; await runInTransaction(async () => { transactionIdBefore = await getCurrentTransactionId(userRepository); await userRepository.createUser('John Doe'); const transactionIdAfter = await getCurrentTransactionId(userRepository); expect(transactionIdBefore).toBeTruthy(); expect(transactionIdBefore).toBe(transactionIdAfter); }); const transactionIdOutside = await getCurrentTransactionId(userRepository); expect(transactionIdOutside).toBe(null); expect(transactionIdOutside).not.toBe(transactionIdBefore); const user = await userRepository.findUserByName('John Doe'); expect(user).toBeDefined(); }); it('should rollback the transaction if an error is thrown', async () => { const userRepository = new UserRepository(dataSource); try { await runInTransaction(async () => { await userRepository.createUser('John Doe'); throw new Error('Rollback transaction'); }); } catch {} const user = await userRepository.findUserByName('John Doe'); expect(user).toBe(null); }); it('supports nested transactions', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionIdBefore = await getCurrentTransactionId(userRepository); await userRepository.createUser('John Doe'); await runInTransaction(async () => { const transactionIdAfter = await getCurrentTransactionId(userRepository); expect(transactionIdBefore).toBe(transactionIdAfter); }); }); expect.assertions(1); }); it('supports several concurrent transactions', async () => { const userRepository = new UserRepository(dataSource); let transactionA: number | null = null; let transactionB: number | null = null; let transactionC: number | null = null; await Promise.all([ runInTransaction(async () => { userRepository.createUser('John Doe'); transactionA = await getCurrentTransactionId(userRepository); }), runInTransaction(async () => { userRepository.createUser('Bob Smith'); transactionB = await getCurrentTransactionId(userRepository); }), runInTransaction(async () => { userRepository.createUser('Alice Watson'); transactionC = await getCurrentTransactionId(userRepository); }), ]); await Promise.all([transactionA, transactionB, transactionC]); expect(transactionA).toBeTruthy(); expect(transactionB).toBeTruthy(); expect(transactionC).toBeTruthy(); expect(transactionA).not.toBe(transactionB); expect(transactionA).not.toBe(transactionC); expect(transactionB).not.toBe(transactionC); }); it("doesn't leak variables to outer scope", async () => { let transactionSetup = false; let transactionEnded = false; const userRepository = new UserRepository(dataSource); let transactionIdOutside: number | null = null; const transaction = runInTransaction(async () => { transactionSetup = true; await sleep(500); const transactionIdInside = await getCurrentTransactionId(userRepository); expect(transactionIdInside).toBeTruthy(); expect(transactionIdOutside).toBe(null); expect(transactionIdInside).not.toBe(transactionIdOutside); transactionEnded = true; }); await new Promise<void>((resolve) => { const interval = setInterval(() => { if (transactionSetup) { clearInterval(interval); resolve(); } }, 200); }); expect(transactionEnded).toBe(false); transactionIdOutside = await getCurrentTransactionId(userRepository); expect(transactionIdOutside).toBe(null); expect(transactionEnded).toBe(false); await transaction; }); }); describe('Extend Repository', () => { it('should rollback the transaction if an error is thrown', async () => { const repo = extendUserRepository(dataSource.getRepository(User)); const name = 'John Doe'; try { await runInTransaction(async () => { await repo.insertUser(name); await repo.insertUser(name); }); } catch {} const user = await repo.findOneBy({ name }); expect(user).toBeNull(); }); }); describe('Propagation', () => { it('should support "REQUIRED" propagation', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionId = await getCurrentTransactionId(userRepository); await userRepository.createUser('John Doe'); await runInTransaction( async () => { await userRepository.createUser('Bob Smith'); const transactionIdNested = await getCurrentTransactionId(userRepository); // We expect the nested transaction to be under the same transaction expect(transactionId).toBe(transactionIdNested); }, { propagation: Propagation.REQUIRED }, ); }); }); it('should support "SUPPORTS" propagation if active transaction exists', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionId = await getCurrentTransactionId(userRepository); await userRepository.createUser('John Doe'); await runInTransaction( async () => { await userRepository.createUser('Bob Smith'); const transactionIdNested = await getCurrentTransactionId(userRepository); // We expect the nested transaction to be under the same transaction expect(transactionId).toBe(transactionIdNested); }, { propagation: Propagation.SUPPORTS }, ); }); }); it('should support "SUPPORTS" propagation if active transaction doesn\'t exist', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction( async () => { const transactionId = await getCurrentTransactionId(userRepository); // We expect the code to be executed without a transaction expect(transactionId).toBe(null); }, { propagation: Propagation.SUPPORTS }, ); }); it('should support "MANDATORY" propagation if active transaction exists', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionId = await getCurrentTransactionId(userRepository); await runInTransaction( async () => { const transactionIdNested = await getCurrentTransactionId(userRepository); // We expect the nested transaction to be under the same transaction expect(transactionId).toBe(transactionIdNested); }, { propagation: Propagation.MANDATORY }, ); }); }); it('should throw an error if "MANDATORY" propagation is used without an active transaction', async () => { const userRepository = new UserRepository(dataSource); await expect( runInTransaction(() => userRepository.find(), { propagation: Propagation.MANDATORY }), ).rejects.toThrowError(TransactionalError); }); it('should support "REQUIRES_NEW" propagation', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionId = await getCurrentTransactionId(userRepository); await runInTransaction( async () => { const transactionIdNested = await getCurrentTransactionId(userRepository); // We expect the nested transaction to be under a different transaction expect(transactionId).not.toBe(transactionIdNested); }, { propagation: Propagation.REQUIRES_NEW }, ); const transactionIdAfter = await getCurrentTransactionId(userRepository); // We expect then the transaction to be the same as before expect(transactionId).toBe(transactionIdAfter); }); }); it('should support "NOT_SUPPORTED" propagation', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { const transactionId = await getCurrentTransactionId(userRepository); await runInTransaction( async () => { const transactionIdNested = await getCurrentTransactionId(userRepository); // We expect the code to be executed without a transaction expect(transactionIdNested).toBe(null); }, { propagation: Propagation.NOT_SUPPORTED }, ); const transactionIdAfter = await getCurrentTransactionId(userRepository); // We expect then the transaction to be the same as before expect(transactionId).toBe(transactionIdAfter); }); }); it('should support "NEVER" propagation if active transaction doesn\'t exist', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction( async () => { const transactionId = await getCurrentTransactionId(userRepository); // We expect the code to be executed without a transaction expect(transactionId).toBe(null); }, { propagation: Propagation.NEVER }, ); }); it('should throw an error if "NEVER" propagation is used with an active transaction', async () => { const userRepository = new UserRepository(dataSource); await runInTransaction(async () => { expect(() => runInTransaction(() => userRepository.find(), { propagation: Propagation.NEVER }), ).rejects.toThrowError(TransactionalError); }); }); }); describe('Hooks', () => { it('should run "runOnTransactionCommit" hook', async () => { const userRepository = new UserRepository(dataSource); const commitSpy = jest.fn(); const rollbackSpy = jest.fn(); const completeSpy = jest.fn(); await runInTransaction(async () => { await userRepository.createUser('John Doe'); runOnTransactionCommit(commitSpy); }); await sleep(1); expect(commitSpy).toHaveBeenCalledTimes(1); expect(rollbackSpy).not.toHaveBeenCalled(); expect(completeSpy).not.toHaveBeenCalled(); }); it('should run "runOnTransactionRollback" hook', async () => { const userRepository = new UserRepository(dataSource); const commitSpy = jest.fn(); const rollbackSpy = jest.fn(); const completeSpy = jest.fn(); try { await runInTransaction(async () => { runOnTransactionRollback(rollbackSpy); await userRepository.createUser('John Doe'); throw new Error('Rollback transaction'); }); } catch {} await sleep(1); expect(rollbackSpy).toHaveBeenCalledTimes(1); expect(commitSpy).not.toHaveBeenCalled(); expect(completeSpy).not.toHaveBeenCalled(); }); it('should run "runOnTransactionComplete" hook', async () => { const userRepository = new UserRepository(dataSource); const commitSpy = jest.fn(); const rollbackSpy = jest.fn(); const completeSpy = jest.fn(); await runInTransaction(async () => { await userRepository.createUser('John Doe'); runOnTransactionComplete(completeSpy); }); await sleep(1); expect(commitSpy).not.toHaveBeenCalled(); expect(rollbackSpy).not.toHaveBeenCalled(); expect(completeSpy).toHaveBeenCalledTimes(1); }); it('should run async "runOnTransactionCommit" hook and wait for it to complete', async () => { const userRepository = new UserRepository(dataSource); let asyncOperationComplete = false; await runInTransaction(async () => { await userRepository.createUser('John Doe'); runOnTransactionCommit(async () => { await sleep(100); asyncOperationComplete = true; }); }); // The transaction should have waited for the async hook to complete expect(asyncOperationComplete).toBe(true); }); it('should run multiple async "runOnTransactionCommit" hooks in parallel', async () => { const userRepository = new UserRepository(dataSource); const results: number[] = []; const start = Date.now(); await runInTransaction(async () => { await userRepository.createUser('John Doe'); // Add three async hooks that take different times to complete runOnTransactionCommit(async () => { await sleep(100); results.push(1); }); runOnTransactionCommit(async () => { await sleep(200); results.push(2); }); runOnTransactionCommit(async () => { await sleep(50); results.push(3); }); }); // All hooks should have completed expect(results).toContain(1); expect(results).toContain(2); expect(results).toContain(3); // Total time should be approximately the longest hook (200ms) plus some overhead // Rather than sequential (350ms) const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(300); }); it('should bubble up rejection from async "runOnTransactionCommit" hook but transaction should commit', async () => { const userRepository = new UserRepository(dataSource); const expectedError = new Error('Async hook error'); // Should reject with our hook error but the transaction should still commit // since the side effects are run post commit await expect( async () => { await runInTransaction(async () => { await userRepository.createUser('John Doe'); runOnTransactionCommit(async () => { throw expectedError; }); }) }).rejects.toThrow(); const user = await userRepository.findUserByName('John Doe'); expect(user).not.toBeNull(); expect(user?.name).toBe('John Doe'); }); }); describe('Isolation', () => { it('should read the most recent committed rows when using READ COMMITTED isolation level', async () => { await runInTransaction( async () => { const userRepository = new UserRepository(dataSource); const totalUsers = await userRepository.count(); expect(totalUsers).toBe(0); // Outside of the transaction await dataSource.transaction(async (manager) => { await manager.save(new User('John Doe', 100)); }); const totalUsers2 = await userRepository.count(); expect(totalUsers2).toBe(1); }, { isolationLevel: IsolationLevel.READ_COMMITTED }, ); }); it("shouldn't see the most recent committed rows when using REPEATABLE READ isolation level", async () => { await runInTransaction( async () => { const userRepository = new UserRepository(dataSource); const totalUsers = await userRepository.count(); expect(totalUsers).toBe(0); // Outside of the transaction await dataSource.transaction(async (manager) => { await manager.save(new User('John Doe', 100)); }); const totalUsers2 = await userRepository.count(); expect(totalUsers2).toBe(0); }, { isolationLevel: IsolationLevel.REPEATABLE_READ }, ); }); }); });