InteractiveFrameworks

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.

  1. adopt runs its three writes inside dataSource.transaction(), through the manager it receives: mark the cat adopted, attach the owner, add the register line. A missing owner is still a 404, and after it the cat is still adoptable.
  2. returnCat does the reverse with an explicit QueryRunner: delete the register line, make the cat adoptable and ownerless, commit; roll back and rethrow on any error, release always.
  3. CatSubscriber tidies a cat's name before every insert, and CatsModule provides 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 before transaction() began.
  • QueryRunnerAlreadyReleasedError, or requests that hang: release() in the wrong place, or commitTransaction() after it.
  • Names arrive untidied: the subscriber is not in providers, so it was never constructed and never pushed itself onto dataSource.subscribers; or listenTo() names the wrong entity.
  • Nest can't resolve dependencies of the CatSubscriber (?): its constructor asks for DataSource, which TypeOrmModule.forRoot() provides; the module is missing or not global.

Remember

  • dataSource.transaction(async (manager) => ...): commit on return, rollback on throw, and only manager is inside.
  • A QueryRunner is the explicit form: connect, start, commit or rollback, release in finally.
  • A subscriber is a provider that pushes itself onto dataSource.subscribers, listens to one entity, and may rewrite event.entity in a before hook.
  • 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.