Cours NestJS · Module 3: TypeORM et bases de données

Les transactions - des opérations sûres sur les tributs

6 min de lecture
Dans cette leçon6

Le légionnaire Marcus remet une coupe en or à Gaius. Dans le code, ce sont deux opérations : retirer la coupe de l'inventaire de Marcus, l'ajouter à celui de Gaius. Tu exécutes la première et, au même instant, le serveur de base de données tombe. Le résultat ? La coupe a disparu du trésor de l'Empire. Marcus ne l'a plus, Gaius ne l'a jamais reçue - elle s'est évaporée entre deux écritures.

Le trésor ne peut pas se le permettre. Il nous faut un moyen de dire à la base : "ces deux opérations sont un seul ordre - exécute les deux ou aucune." Ce moyen s'appelle une transaction.

L'atomicité et le reste d'ACID

La propriété que nous cherchons s'appelle l'atomicité - une transaction est indivisible comme un atome : impossible de l'exécuter à moitié. C'est la première lettre de l'acronyme ACID, qui décrit les garanties d'une transaction :

  • Atomicity - l'atomicité : toutes les opérations, ou aucune,
  • Consistency - la cohérence : la base passe d'un état valide à un autre, sans jamais rester entre les deux,
  • Isolation - l'isolation : les transactions parallèles ne regardent pas les changements inachevés des autres,
  • Durability - la durabilité : une fois validés, les changements survivent même à une coupure de courant.

Retiens surtout ce A - le reste en découle, et c'est de lui qu'il s'agit avec notre coupe.

Le cycle de vie d'une transaction

Toute transaction suit le même chemin, quelle que soit la base ou le langage. D'abord BEGIN - nous annonçons le début, et dès cet instant la base enregistre les changements à part, comme non définitifs. Ensuite nous exécutons les opérations - nos deux écritures. Puis nous vérifions que tout a réussi. Et enfin l'une des deux routes : COMMIT valide l'ensemble et c'est seulement alors que les changements deviennent définitifs, ou ROLLBACK annule tout, comme si la transaction n'avait jamais eu lieu.

Ce schéma - commencer, exécuter, vérifier, valider ou annuler - mérite d'être en tête avant de regarder le code. Tout le reste de cette leçon, ce sont deux façons de l'écrire dans TypeORM.

Première façon : la transaction gérée

La variante la plus simple est dataSource.transaction(). Tu passes une fonction, et TypeORM ouvre lui-même la transaction avant de l'appeler et la ferme ensuite :

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});

La fonction reçoit un seul argument : manager - l'équivalent d'un repository, mais rattaché à cette transaction précise. C'est le détail le plus important de ce code : toutes les opérations doivent passer par manager. Si tu allais y chercher un simple this.tributeRepository.save(...), cette écriture partirait hors de la transaction - et ne serait pas annulée en cas d'erreur.

Alors comment la base sait-elle s'il faut valider ou annuler ? Par les exceptions. Si la fonction va jusqu'au bout sans encombre, TypeORM exécute un COMMIT. Si quoi que ce soit à l'intérieur lève une exception - il exécute un ROLLBACK et relance l'exception. Tu n'écris pas une seule ligne de gestion d'erreur - et c'est ce que je te recommande comme choix par défaut.

Deuxième façon : QueryRunner, ou le contrôle total

Parfois tu dois piloter la transaction à la main - par exemple faire quelque chose entre deux opérations, ou réagir à une erreur précise. Tu recours alors au QueryRunner : un objet représentant une connexion exclusive à la base, sur laquelle tu appelles toi-même chaque étape.

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}

Suivons ce code pas à pas, car chaque ligne correspond à une étape du cycle que tu viens de découvrir. createQueryRunner() crée l'objet, connect() lui réserve une connexion du pool, et startTransaction() est notre BEGIN. Nous exécutons les opérations via queryRunner.manager - de nouveau la même condition : seul ce qui passe par le manager de ce runner appartient à la transaction. Le bloc try se termine par commitTransaction(), tandis que catch appelle rollbackTransaction() et relance l'exception, pour que la couche supérieure sache que le transfert a échoué.

La dernière partie est la plus importante. release() doit se trouver dans finally, car la connexion doit revenir au pool dans tous les cas de figure - après un succès comme après une erreur. L'oublier ne cassera pas un transfert isolé ; la conséquence apparaît plus tard, quand le pool n'a plus de connexions libres et que toute l'application se fige. C'est l'erreur la plus fréquente avec les transactions manuelles.

Note aussi ce que release() ne fait pas : il ne valide ni n'annule quoi que ce soit. Si tu libères un runner sans commit, la transaction est abandonnée - libérer la connexion n'est pas enregistrer les changements.

L'isolation - des transactions côte à côte

Reste le I d'ACID. Quand deux transactions s'exécutent en même temps, la base doit décider ce que l'une voit du travail inachevé de l'autre. Le niveau de cette séparation se passe en argument :

1await queryRunner.startTransaction('SERIALIZABLE');

Le niveau par défaut est généralement 'READ COMMITTED' - tu ne vois que les changements déjà validés par les autres. 'SERIALIZABLE' est le niveau le plus strict : les transactions s'exécutent comme si elles faisaient la queue, l'une après l'autre. Il offre les garanties les plus fortes, au prix du débit - la base oblige plus souvent une transaction à reculer et à réessayer. C'est pourquoi on élève ce niveau en conscience et seulement là où c'est vraiment nécessaire, par exemple sur les opérations touchant au solde du trésor.

Récapitulatif

La coupe de Marcus ne se perdra plus jamais à mi-chemin :

  • une transaction est un groupe d'opérations exécuté atomiquement - toutes, ou aucune,
  • l'acronyme ACID décrit ses garanties, et la lettre A est justement l'atomicité,
  • le cycle de vie est toujours le même : BEGIN, opérations, vérification, COMMIT ou ROLLBACK,
  • dataSource.transaction(async manager => {...}) mène ce cycle à ta place : une sortie sans exception vaut COMMIT, une exception vaut ROLLBACK,
  • QueryRunner donne le contrôle total : createQueryRunner, connect, startTransaction, puis commitTransaction dans try, rollbackTransaction dans catch et release dans finally,
  • dans les deux variantes, les opérations doivent passer par le manager de la transaction - un repository ordinaire agit en dehors d'elle,
  • le niveau d'isolation décide de ce que les transactions voient du travail inachevé des autres.

Dans la prochaine leçon, tu découvriras les seeders - les quartiers-maîtres qui équipent une base fraîche de données initiales. En attendant, retiens ceci : une transaction est un ordre unique composé de plusieurs gestes - la base l'exécutera en entier, ou fera comme si elle ne l'avait jamais entendu.

Code de cette leçon : src/transactions.ts
1// Transactions - Des opérations sûres sur les tributs
2import { Injectable } from '@nestjs/common';
3import { DataSource, QueryRunner, EntityManager } from 'typeorm';
4
5console.log("Transactions - des opérations atomiques sur les données de l'imperium !");
6
7// ===========================================
8// 1. Transaction avec QueryRunner
9// ===========================================
10
11@Injectable()
12export class TributeTransferService {
13  constructor(private dataSource: DataSource) {}
14
15  async transferTribute(
16    fromProvince: string,
17    toProvince: string,
18    amount: number,
19  ): Promise<void> {
20    // Créer un QueryRunner
21    const queryRunner = this.dataSource.createQueryRunner();
22    await queryRunner.connect();
23    await queryRunner.startTransaction();
24
25    try {
26      // Retirer de la province source
27      await queryRunner.query(
28        'UPDATE provinciae SET tribute = tribute - $1 WHERE name = $2',
29        [amount, fromProvince],
30      );
31
32      // Ajouter à la province cible
33      await queryRunner.query(
34        'UPDATE provinciae SET tribute = tribute + $1 WHERE name = $2',
35        [amount, toProvince],
36      );
37
38      // Valider la transaction
39      await queryRunner.commitTransaction();
40      console.log('Transfert terminé : ' + amount + ' de ' + fromProvince + ' vers ' + toProvince);
41    } catch (error) {
42      // Rollback en cas d'erreur
43      await queryRunner.rollbackTransaction();
44      console.error('Transfert annulé : ' + error.message);
45      throw error;
46    } finally {
47      // Libérer la connexion
48      await queryRunner.release();
49    }
50  }
51}
52
53// ===========================================
54// 2. Transaction avec EntityManager
55// ===========================================
56
57@Injectable()
58export class PromotionService {
59  constructor(private dataSource: DataSource) {}
60
61  async promoteLegionary(
62    legionaryId: number,
63    newRank: string,
64  ) {
65    return this.dataSource.transaction(async (manager: EntityManager) => {
66      // Trouver le légionnaire
67      const legionary = await manager.findOneBy('Legionary', {
68        id: legionaryId,
69      });
70
71      if (!legionary) {
72        throw new Error('Legionary non inventus !');
73      }
74
75      const oldRank = legionary.rank;
76
77      // Mettre à jour le rang et l'expérience
78      legionary.rank = newRank;
79      legionary.experience += 1;
80      await manager.save('Legionary', legionary);
81
82      // Enregistrer l'historique de la promotion
83      await manager.save('PromotionHistory', {
84        legionaryId,
85        oldRank,
86        newRank,
87        date: new Date(),
88      });
89
90      console.log('Promotion : ' + oldRank + ' -> ' + newRank);
91      return legionary;
92    });
93  }
94}
95
96// ===========================================
97// 3. Les propriétés ACID
98// ===========================================
99
100// Atomicity  - tout ou rien
101// Consistency - données toujours cohérentes
102// Isolation   - les transactions n'entrent pas en conflit
103// Durability  - validé = permanent
104
105console.log("\n=== RÉSUMÉ DES TRANSACTIONS ===");
106console.log("QueryRunner: connect -> startTransaction -> commit/rollback -> release");
107console.log("dataSource.transaction(manager => {...}) - syntaxe plus simple");
108console.log("ACID - atomicité, cohérence, isolation, durabilité");
109console.log("try/catch/finally - gère toujours les erreurs !");
110

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce qu'une transaction de base de données ?

  2. 2. Que signifie la lettre 'A' dans l'acronyme ACID décrivant les propriétés des transactions ?

Tâches pratiques dans le jeu

  • Tri vertical

    Classe les étapes du cycle de vie d'une transaction de base de données :

  • Éditeur de code

    Crée une méthode transferTributes qui utilise queryRunner.startTransaction(), effectue deux enregistrements et valide (ou annule en cas d'erreur)

  • Clique dans l'ordre

    Classe les éléments du code d'une transaction QueryRunner dans le bon ordre :

  • Tri horizontal

    Classe les éléments du démarrage d'une transaction dans le bon ordre :

  • Éditeur de code

    Écris une méthode de service utilisant dataSource.transaction(async manager => { ... }), qui crée un tribut et met à jour une province