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
text/typescript
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 },
);
});
});
});