The legionary Marcus hands a golden chalice to Gaius. In code that is two operations: subtract the chalice from Marcus's inventory, add it to Gaius's. You perform the first one, and at that very moment the database server goes down. The result? The chalice has vanished from the Empire's treasury. Marcus no longer has it, Gaius never received it - it evaporated between two writes.
The treasury cannot afford that. We need a way to tell the database: "these two operations are one order - perform both or neither." That way is a transaction.
The property we are after is called atomicity - a transaction is indivisible like an atom: it cannot be performed halfway. It is the first letter of the acronym ACID, which describes a transaction's guarantees:
Remember the A above all - the rest follows from it, and it is what our chalice is about.
Every transaction walks the same road, whatever the database or the language. First BEGIN - we announce the start, and from that moment the database records changes on the side, as impermanent. Then we perform the operations - our two writes. Next we check that everything succeeded. And finally one of two roads: COMMIT confirms the whole thing and only now do the changes become permanent, or ROLLBACK undoes everything as if the transaction had never happened.
This scheme - begin, perform, verify, commit or roll back - is worth holding in your head before we look at code. All the rest of this lesson is two ways of writing it down in TypeORM.
The simplest variant is
dataSource.transaction(). You pass a function, and TypeORM opens the transaction before calling it and closes it afterwards:1await this.dataSource.transaction(async (manager) => {
2 await manager.update(Tribute, tributeId, { legionariusze: { id: toLegionaryId } });
3 await manager.increment(Legionary, { id: toLegionaryId }, 'tributeCount', 1);
4 await manager.decrement(Legionary, { id: fromLegionaryId }, 'tributeCount', 1);
5});The function receives one argument:
manager - the equivalent of a repository, but bound to this particular transaction. That is the most important detail of this code: every operation must go through manager. If you reached inside for a plain this.tributeRepository.save(...), that write would run outside the transaction - and would not be undone on error.So how does the database know whether to commit or roll back? From exceptions. If the function runs to its end peacefully, TypeORM performs a COMMIT. If anything inside throws - it performs a ROLLBACK and rethrows the exception. You write no error-handling line at all - and that is what I recommend as the default choice, @name.
Sometimes you need to steer the transaction by hand - to do something between operations, say, or to react to one specific error. Then you reach for a QueryRunner: an object representing one exclusive database connection, on which you call each stage yourself.
1const queryRunner = this.dataSource.createQueryRunner();
2
3await queryRunner.connect();
4await queryRunner.startTransaction();
5
6try {
7 await queryRunner.manager.update(Tribute, tributeId, {
8 legionariusze: { id: toLegionaryId },
9 });
10 await queryRunner.manager.increment(
11 Legionary, { id: toLegionaryId }, 'tributeCount', 1,
12 );
13
14 await queryRunner.commitTransaction();
15} catch (error) {
16 await queryRunner.rollbackTransaction();
17 throw error;
18} finally {
19 await queryRunner.release();
20}Let's walk this code step by step, because every line matches one stage of the lifecycle you met above.
createQueryRunner() creates the object, connect() reserves a connection from the pool for it, and startTransaction() is our BEGIN. We perform operations through queryRunner.manager - again the same condition as before: only what goes through this runner's manager belongs to the transaction. The try block ends with commitTransaction(), while catch calls rollbackTransaction() and rethrows, so the layer above knows the transfer failed.The last part matters most.
release() must sit in finally, because the connection has to return to the pool in every scenario - after success and after failure alike. Skipping it will not break a single transfer; the consequence shows up later, when the pool runs out of free connections and the whole application stalls. This is the most common mistake with manual transactions.Note also what
release() does not do: it neither commits nor rolls back anything. If you release a runner without committing, the transaction is discarded - freeing the connection is not saving the changes.That leaves the I from ACID. When two transactions run at the same time, the database must decide how much one sees of the other's unfinished work. You pass the level of that separation as an argument:
1await queryRunner.startTransaction('SERIALIZABLE');The default level is usually
'READ COMMITTED' - you see only changes others have already committed. 'SERIALIZABLE' is the strictest level: transactions execute as if queued, one after another. It gives the strongest guarantees, but at the cost of throughput - the database more often forces one transaction to back off and retry. That is why we raise the level deliberately and only where it is truly needed, for instance on treasury balance operations.Marcus's chalice will never again be lost halfway:
dataSource.transaction(async manager => {...}) runs that cycle for you: a clean exit means COMMIT, an exception means ROLLBACK,createQueryRunner, connect, startTransaction, then commitTransaction in try, rollbackTransaction in catch and release in finally,manager - a plain repository works outside it,In the next lesson you will meet seeders - the quartermasters who equip a fresh database with starting data. For now remember: a transaction is one order made of many moves - the database will carry it out whole, or pretend it never heard it.