Transactions and Subscribers
Make an adoption all-or-nothing with dataSource.transaction() and its explicit QueryRunner form, and tidy every cat's name before it is written with an entity subscriber.
What you'll learn
- Run several writes as one transaction with dataSource.transaction() and explain why only the manager it hands over is inside it
- Write the explicit QueryRunner form: connect, start, commit or roll back, release in finally
- Register an entity subscriber as a provider and change an entity in a before hook, whoever inserts it
An adoption is three writes: the cat stops being adoptable, the cat gets an owner, the register gets a line. If the process dies, or the owner turns out not to exist, after the first write and before the third, the shelter has a cat that is neither adoptable nor adopted. The database has a word for "all of these or none of them": a transaction, and TypeORM gives you two ways to ask for one. The second half of this lesson is about a smaller thing that also runs inside the database's view of the world: a subscriber, code that runs when an entity is inserted, updated or removed, whoever did it.
transaction(): the callback form
Inject the DataSource, the object behind every repository, and give it a function. Everything done through the manager it hands you happens inside one transaction, committed when the function returns and rolled back when it throws:
@Injectable()
export class UsersService {
constructor(private dataSource: DataSource) {}
async createMany(users: User[]) {
await this.dataSource.transaction(async (manager) => {
await manager.save(users[0]);
await manager.save(users[1]);
});
}
}
The rule that makes it work: use manager, not the repositories. this.users.save() inside the callback goes through a different connection and is outside the transaction, so it would neither wait for it nor be undone by it. The manager has the whole repository API with the entity as the first argument: manager.findOneBy(Cat, { id }), manager.save(cat), manager.create(Adoption, { ... }), manager.delete(Adoption, { ... }).
A thrown NotFoundException rolls the transaction back and then continues out of transaction() to the exception layer, so the client sees the 404 and the database sees nothing.
QueryRunner: the explicit form
When the transaction cannot be one function, because it spans steps that do not nest or you want to decide yourself when to commit, a QueryRunner spells everything out:
async createMany(users: User[]) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(users[0]);
await queryRunner.manager.save(users[1]);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
}
Four verbs, always in this shape: connect, startTransaction, then commitTransaction on success or rollbackTransaction in catch, and release in finally whatever happened, because a runner that is never released holds a connection forever. The docs' catch swallows the error; a service that wants the client to know rethrows after the rollback.
Subscribers
An entity subscriber listens to events on one entity and runs before or after them:
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
constructor(dataSource: DataSource) {
dataSource.subscribers.push(this);
}
listenTo() {
return User;
}
beforeInsert(event: InsertEvent<User>) {
console.log(`BEFORE USER INSERTED: `, event.entity);
}
}
Registered as a plain provider, it is constructed by Nest, receives the DataSource and adds itself to its subscribers. listenTo says which entity; the methods are named after the events: beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeRemove, afterRemove, afterLoad. A before hook may change event.entity and the change is what gets written, which makes subscribers the place for normalisation that must hold whoever inserts: a name tidied, a slug computed, a timestamp set. The docs add one rule: subscribers cannot be request-scoped, since the data source is one for the application.
Your task
The register must never disagree with the cats.
adoptruns its three writes insidedataSource.transaction(), through the manager it receives: mark the cat adopted, attach the owner, add the register line. A missing owner is still a404, and after it the cat is still adoptable.returnCatdoes the reverse with an explicitQueryRunner: delete the register line, make the cat adoptable and ownerless, commit; roll back and rethrow on any error, release always.CatSubscribertidies a cat's name before every insert, andCatsModuleprovides it so Nest constructs it.
Run the starter first and adopt cat 2 by owner 9: the 404 comes back, and GET /cats/2 shows a cat that is no longer adoptable although nobody owns it. That is the bug transactions exist for.
When it fails
- After a failed adoption the cat is not adoptable: a write happened outside the transaction, through a repository instead of
manager, or beforetransaction()began. QueryRunnerAlreadyReleasedError, or requests that hang:release()in the wrong place, orcommitTransaction()after it.- Names arrive untidied: the subscriber is not in
providers, so it was never constructed and never pushed itself ontodataSource.subscribers; orlistenTo()names the wrong entity. Nest can't resolve dependencies of the CatSubscriber (?): its constructor asks forDataSource, whichTypeOrmModule.forRoot()provides; the module is missing or not global.
Remember
dataSource.transaction(async (manager) => ...): commit on return, rollback on throw, and onlymanageris inside.- A
QueryRunneris the explicit form: connect, start, commit or rollback, release infinally. - A subscriber is a provider that pushes itself onto
dataSource.subscribers, listens to one entity, and may rewriteevent.entityin abeforehook. - The exception still reaches the client after a rollback; the database just never saw the writes.
Stuck? Show a hint
adopt: await this.dataSource.transaction(async (manager) => { ... }) using manager.findOneBy(Cat, { id }), manager.save(cat), manager.findOneBy(Owner, { id: ownerId }), manager.create(Adoption, { catName, ownerName }) and manager.save; throw inside to roll back. returnCat: const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { ...queryRunner.manager...; await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); }. Subscriber: @EventSubscriber(), constructor(dataSource: DataSource) { dataSource.subscribers.push(this); }, listenTo() { return Cat; }, beforeInsert(event) { event.entity.name = tidyName(event.entity.name); }; add CatSubscriber to CatsModule's providers.