Skip to content

J5 · Relations, migrations et transactions ​

Jour 5 · lundi 16 novembre · Livrable : Tasks liées aux Projects, migrations

Les relations TypeORM s'écrivent comme en JPA, mais rien n'est chargé implicitement et il n'y a pas de proxy : chaque requête dit ce qu'elle joint. Ce chapitre ajoute les tâches à TaskFlow, remplace synchronize par des migrations versionnées dans git, et écrit la première opération transactionnelle : archiver un projet avec toutes ses tâches. Le point qui surprend le plus après Spring : il n'y a pas de @Transactional. La transaction est un callback, et son manager doit être transmis à chaque requête.

Objectifs ​

  • Modéliser OneToMany, ManyToOne et ManyToMany, et savoir quel côté porte la clé étrangère.
  • Choisir comment charger une relation : eager, lazy ou relations à la demande.
  • Reconnaître un problème N+1 et le corriger avec leftJoinAndSelect.
  • Générer, appliquer et annuler des migrations, et désactiver synchronize.
  • Écrire une transaction avec dataSource.transaction(), puis avec un QueryRunner.
  • Livrer l'entité Task, ses endpoints, l'archivage transactionnel et la première migration.

Spring → NestJS ​

Spring / JPATypeORMÀ retenir
@OneToMany(mappedBy = "project")@OneToMany(() => Task, (task) => task.project)Côté inverse : aucune colonne
@ManyToOne + @JoinColumn(name = "project_id")@ManyToOne(() => Project, …) + @JoinColumn({ name: 'project_id' })Côté propriétaire : il porte la clé étrangère
@ManyToMany + @JoinTable@ManyToMany(() => Tag) + @JoinTable()Une entité d'association dès que la jointure porte des données
FetchType.LAZY (proxy)Rien n'est chargé par défaut ; lazy = propriété Promise<T>Pas de proxy, pas de LazyInitializationException
FetchType.EAGER{ eager: true }Ignoré par le QueryBuilder
JOIN FETCH, @EntityGraphrelations: { tasks: true }, leftJoinAndSelectLe chargement se décide à chaque requête
Flyway, LiquibaseMigrations TypeORM : migration:generate, run, revertGénérées par différence entre les entités et la base
ddl-auto=none + Flywaysynchronize: false + migrationsLe schéma n'évolue plus que par migration
@TransactionaldataSource.transaction(async (manager) => …)Pas d'équivalent déclaratif natif : le manager se propage à la main
TransactionTemplate.execute(…)dataSource.transaction(…)L'analogie la plus juste
PlatformTransactionManager (commit / rollback manuels)QueryRunnerContrôle manuel, release() obligatoire
CascadeType.ALLcascade: true (ORM) ou onDelete: 'CASCADE' (base)Deux mécanismes distincts

Comprendre ​

Relations : OneToMany, ManyToOne, ManyToMany ​

Comme en JPA, une relation a un côté propriétaire, qui porte la clé étrangère, et un côté inverse, purement objet. Pour la paire projet ↔ tâches :

  • @ManyToOne(() => Project, (project) => project.tasks) sur Task est le côté propriétaire. Avec @JoinColumn({ name: 'project_id' }), il définit la colonne project_id et sa contrainte.
  • @OneToMany(() => Task, (task) => task.project) sur Project est le côté inverse : aucune colonne. En TypeORM, il ne peut pas exister seul ; le @ManyToOne correspondant est obligatoire. L'inverse n'est pas vrai : un @ManyToOne peut se passer de @OneToMany, comme Task.assignee.
  • Les fonctions () => Task retardent la lecture de la classe : project.entity.ts et task.entity.ts s'importent mutuellement, et l'une des deux classes n'est pas encore définie quand l'autre est décorée. La seconde fonction désigne la propriété inverse, comme mappedBy.
ts
@Entity('projects')
export class Project {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 100 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string | null;

  @Column({ type: 'boolean', default: false })
  archived: boolean;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;

  // softDelete() fills this column; normal find() calls then skip the row.
  @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
  deletedAt: Date | null;

  @OneToMany(() => Task, (task) => task.project)
  tasks: Task[];

  @OneToMany(() => ProjectMember, (member) => member.project)
  members: ProjectMember[];
}

Pour un many-to-many, TypeORM crée la table de jointure à partir d'un @JoinTable() placé sur le côté propriétaire :

ts
// Exemple hors TaskFlow : des étiquettes sur les tâches
@Entity('tags')
export class Tag {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 50, unique: true })
  name: string;
}

// Dans Task (côté propriétaire) :
@ManyToMany(() => Tag)
@JoinTable({ name: 'task_tags', joinColumn: { name: 'task_id' }, inverseJoinColumn: { name: 'tag_id' } })
tags: Tag[];

Dès que le lien porte une donnée (date d'adhésion, rôle dans le projet), modélisez-le comme une entité, avec deux @ManyToOne. C'est le choix de TaskFlow pour l'appartenance d'un utilisateur à un projet, et le même conseil qu'en JPA :

ts
// The join table of a many-to-many relation, modelled as its own entity so it
// can carry data (joinedAt). Composite primary key: (project_id, user_id).
@Entity('project_members')
export class ProjectMember {
  @PrimaryColumn({ name: 'project_id', type: 'uuid' })
  projectId: string;

  @PrimaryColumn({ name: 'user_id', type: 'uuid' })
  userId: string;

  @ManyToOne(() => Project, (project) => project.members, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'project_id' })
  project: Project;

  @ManyToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: User;

  @CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
  joinedAt: Date;
}

Deux mécanismes de « cascade » coexistent, ne les confondez pas :

OptionAgitEffet
onDelete: 'CASCADE' / 'SET NULL'Dans la base, sur la clé étrangèreUn DELETE du projet supprime ses tâches, quelle que soit l'origine du DELETE
cascade: true (ou ['insert', 'update']…)Dans TypeORM, au save()Enregistre aussi les objets liés présents dans la propriété, comme CascadeType

TaskFlow n'utilise que onDelete. Une suppression logique (softDelete) est un UPDATE : elle ne déclenche aucune de ces cascades.

Chargement : eager, lazy, relations ​

Par défaut, TypeORM ne charge aucune relation. Après findOneBy({ id }), project.tasks vaut undefined : ni tableau vide, ni proxy. C'est la grande différence avec JPA, où un @ManyToOne est EAGER par défaut et une collection LAZY derrière un proxy.

ModeDéclarationEffetQuand l'utiliser
À la demandefind({ relations: { tasks: true } }) ou leftJoinAndSelectJointure dans la requête qui en a besoinPar défaut
eager@ManyToOne(() => Project, { eager: true })Chargé par tous les find* ; ignoré par le QueryBuilder ; un seul côté de la relation peut l'êtreRarement : une petite relation toujours utile
lazyPropriété typée Promise<Task[]>Une requête au premier await project.tasksRarement : chaque accès est une requête cachée
ts
// Exemple hors TaskFlow : relation lazy
@OneToMany(() => Task, (task) => task.project)
tasks: Promise<Task[]>;

const tasks = await project.tasks; // requête SQL à cet instant

relations en TypeORM 1.1

Le plan écrit relations: [], la forme tableau de TypeORM 0.3 (relations: ['tasks']). En TypeORM 1.1, l'option est typée FindOptionsRelations<Entity> et n'accepte que la forme objet : relations: { tasks: true }, ou imbriquée, relations: { tasks: { assignee: true } }.

Par défaut, les relations demandées sont chargées par jointure. relationLoadStrategy: 'query' les charge par requêtes séparées, utile quand plusieurs collections jointes multiplient les lignes.

TaskFlow n'utilise ni eager ni lazy. Il écrit les liens par les colonnes de clé étrangère (projectId, assigneeId) et lit les relations dans des jointures explicites, comme l'innerJoin sur project.members de la recherche des projets (QueryBuilder).

Le problème N+1 ​

Une requête pour la liste, puis une par élément : c'est le N+1, connu en JPA avec les collections LAZY. En TypeORM, il vient d'une boucle avec await, ou d'une relation lazy parcourue en boucle :

ts
// Exemple hors TaskFlow : 1 requête pour les projets, puis 1 par projet
const projects = await this.projects.find();
for (const project of projects) {
  project.tasks = await this.tasks.findBy({ projectId: project.id, archived: false });
}

Correction avec une seule requête et une jointure :

ts
// Exemple hors TaskFlow : 1 requête
const projects = await this.projects
  .createQueryBuilder('project')
  .leftJoinAndSelect('project.tasks', 'task', 'task.archived = :archived', { archived: false })
  .orderBy('project.createdAt', 'DESC')
  .getMany();
  • leftJoinAndSelect joint et remplit project.tasks ; leftJoin seul ne sert qu'à filtrer.
  • La condition de jointure (troisième argument) garde les projets sans tâche active, alors qu'un where sur task les éliminerait.
  • Autres corrections : find({ relations: { tasks: true } }), ou deux requêtes, la seconde avec In(projectIds), puis un regroupement en mémoire.
  • Pour compter les requêtes, ajoutez temporairement logging: true aux options de la DataSource.
  • Pour paginer une requête avec jointure, utilisez skip / take (J4).

Migrations ​

Une migration est un script versionné qui fait évoluer le schéma ; une table de suivi retient celles déjà appliquées. C'est le principe de Flyway et Liquibase, avec une différence : TypeORM génère la migration en comparant les entités à la base actuelle, sous forme d'une classe TypeScript avec up() et down().

Le CLI TypeORM ne démarre pas Nest. Il lui faut sa propre DataSource, qui partage les options de l'application (postgresOptions, voir J4) :

ts
// The TypeORM CLI does not start Nest, so it gets its own DataSource.
// It reads the same .env file as the app.
try {
  process.loadEnvFile(`.env.${process.env.NODE_ENV ?? 'development'}`);
} catch {
  // No env file: rely on variables already set (CI, Docker).
}

export default new DataSource({
  ...postgresOptions(databaseConfig()),
  entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')],
});

package.json l'enveloppe dans des scripts :

ScriptCommande exécutéeEffet
npm run migration:generate src/database/migrations/Nomtypeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate …Écrit <timestamp>-Nom.ts avec le SQL qui aligne la base sur les entités
npm run migration:run… migration:runApplique les migrations en attente, dans l'ordre
npm run migration:revert… migration:revertExécute le down() de la dernière migration appliquée
npm run migration:run:prodtypeorm migration:run -d dist/database/data-source.jsApplique les migrations compilées dans dist/, sans ts-node : la commande utilisée dans l'image Docker

Le CLI propose aussi migration:create (une migration vide, pour une reprise de données écrite à la main) et migration:show (l'état de chaque migration).

Ce qu'il faut savoir pour les utiliser sans surprise :

  • synchronize: false. Une fois les migrations en place, plus rien d'autre ne modifie le schéma. TaskFlow le fixe dans postgresOptions, avec le motif des fichiers de migration et le nom de la table de suivi, typeorm_migrations (l'équivalent de flyway_schema_history).
  • Une transaction. Par défaut, migration:run applique toutes les migrations en attente dans une seule transaction. Le DDL étant transactionnel en PostgreSQL, un échec annule tout.
  • Générer sur une base à jour. La migration générée est une différence : sur une base en retard, elle contiendrait des changements déjà décrits par d'autres migrations. Lancez migration:run avant migration:generate.
  • Relire le SQL. TypeORM ne détecte pas les renommages : une propriété renommée apparaît comme un DROP COLUMN suivi d'un ADD COLUMN. Corrigez à la main en RENAME COLUMN avant de valider.
  • Ne jamais modifier une migration appliquée. Écrivez-en une nouvelle.
  • Vérifier en CI. L'option --check de migration:generate sort en erreur si les entités demandent un changement que les migrations n'ont pas. Le pipeline de TaskFlow ne l'utilise pas.

TaskFlow n'exécute pas les migrations au démarrage de l'application : elles se lancent explicitement, avec npm run migration:run en local et dataSource.runMigrations() dans la préparation des tests e2e (J10). En déploiement, c'est une étape à part, avant la nouvelle version : l'image Docker ne contient que le code compilé, et npm run migration:run:prod, lancé dans un conteneur ponctuel, y applique les migrations de dist/ (J11).

Transactions ​

ts
// Exemple hors TaskFlow : la forme générale
await this.dataSource.transaction(async (manager) => {
  await manager.save(entity);
  await manager.update(Task, { projectId }, { archived: true });
});

dataSource.transaction() prend une connexion du pool, envoie BEGIN, appelle le callback avec un EntityManager lié à cette connexion, puis envoie COMMIT si la promesse du callback est résolue, ou ROLLBACK si elle est rejetée (l'erreur est ensuite relancée). Le niveau d'isolation se passe en premier argument : dataSource.transaction('SERIALIZABLE', async (manager) => …).

Toutes les requêtes de la transaction doivent passer par manager : manager.save(), manager.update(), manager.getRepository(Task), ou manager.withRepository(this.tasks) pour réutiliser un repository injecté. Un repository injecté appelé directement prend une autre connexion du pool : sa requête s'exécute hors de la transaction, et n'est pas annulée en cas d'échec.

Pourquoi pas de @Transactional ? Spring attache la transaction au thread courant (un ThreadLocal) : tout repository appelé pendant la méthode la retrouve. Node traite toutes les requêtes sur le même thread, entrelacées à chaque await ; il n'y a pas de « transaction du thread ». L'équivalent Node du ThreadLocal pour du code asynchrone existe (AsyncLocalStorage) et des bibliothèques communautaires s'en servent pour reproduire @Transactional, mais ni Nest ni TypeORM ne le font nativement. La pratique de base est donc de propager le manager en paramètre :

ts
// Exemple hors TaskFlow : une méthode utilisable dans et hors d'une transaction
archiveTasks(projectId: string, manager: EntityManager = this.dataSource.manager) {
  return manager.update(Task, { projectId }, { archived: true });
}

L'analogie Spring la plus juste n'est pas @Transactional mais TransactionTemplate.execute(status -> …).

QueryRunner ​

Le QueryRunner donne le contrôle manuel de la connexion et de la transaction, comme un PlatformTransactionManager utilisé à la main. dataSource.transaction() fait exactement ces étapes pour vous :

ts
// Exemple hors TaskFlow : l'archivage écrit avec un QueryRunner
async archiveWithQueryRunner(id: string): Promise<Project | null> {
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    const project = await queryRunner.manager.findOneBy(Project, { id });
    if (project) {
      project.archived = true;
      await queryRunner.manager.save(project);
      await queryRunner.manager.update(Task, { projectId: id }, { archived: true });
    }
    await queryRunner.commitTransaction();
    return project;
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release(); // rend la connexion au pool, dans tous les cas
  }
}

Utilisez-le quand le callback ne suffit pas : décider du commit en fonction d'un résultat intermédiaire, choisir l'isolation au moment de startTransaction('SERIALIZABLE'), ou exécuter du SQL brut avec queryRunner.query() dans la transaction. Sinon, préférez dataSource.transaction(), qui ne peut pas oublier le release().

Cas TaskFlow ​

L'entité Task ​

ts
@Entity('tasks')
@Index('idx_tasks_project_status', ['projectId', 'status'])
export class Task {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 200 })
  title: string;

  @Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status', default: TaskStatus.Todo })
  status: TaskStatus;

  // A PostgreSQL `date` comes back as a 'YYYY-MM-DD' string, not a Date.
  @Column({ name: 'due_date', type: 'date', nullable: true })
  dueDate: string | null;

  @Column({ type: 'boolean', default: false })
  archived: boolean;

  // Keeping the foreign key as a plain column avoids loading the relation
  // just to read or set an id.
  @Column({ name: 'project_id', type: 'uuid' })
  projectId: string;

  @ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'project_id' })
  project: Project;

  @Column({ name: 'assignee_id', type: 'uuid', nullable: true })
  assigneeId: string | null;

  @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'assignee_id' })
  assignee: User | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}
  • status est un enum PostgreSQL nommé task_status, TODO par défaut ; les quatre valeurs (TODO, IN_PROGRESS, DONE, OVERDUE) viennent de task-status.enum.ts (statuts).
  • dueDate est une colonne date : la valeur revient en chaîne 'YYYY-MM-DD', d'où le type string | null. Le DTO de création impose ce format (@Matches + @IsDateString({ strict: true })).
  • projectId est une colonne ordinaire et la clé de la relation project : elles partagent project_id. On crée ou filtre une tâche par projectId sans charger le projet, et c'est cette colonne qui rend la clé non nulle dans le schéma.
  • assignee est facultatif, avec onDelete: 'SET NULL' : supprimer un utilisateur laisse ses tâches sans responsable.
  • archived sert à l'archivage du projet ; l'index composite idx_tasks_project_status sert la liste des tâches d'un projet filtrée par statut.

Les endpoints : POST /projects/:id/tasks et PATCH /tasks/:id/status ​

ts
@ApiTags('tasks')
@ApiBearerAuth()
@Controller()
export class TasksController {
  constructor(private readonly tasks: TasksService) {}

  @Post('projects/:projectId/tasks')
  create(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Body() dto: CreateTaskDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.create(projectId, dto, user);
  }

  // Pipes convert and validate one parameter, like a Spring Converter:
  // "?status=foo" is rejected with 400 before the handler runs.
  @Get('projects/:projectId/tasks')
  list(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Query('status', new ParseEnumPipe(TaskStatus, { optional: true })) status: TaskStatus | undefined,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.list(projectId, status, user);
  }

  @Patch('tasks/:id/status')
  updateStatus(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateTaskStatusDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.updateStatus(id, dto.status, user);
  }
}
  • @Controller() sans préfixe permet de déclarer deux familles d'URL dans le même contrôleur : /v1/projects/:projectId/tasks et /v1/tasks/:id/status.
  • ParseUUIDPipe et ParseEnumPipe refusent un identifiant ou un statut invalide avant le handler (J6).

La création et la liste, côté service :

ts
async create(projectId: string, dto: CreateTaskDto, user: AuthUser): Promise<Task> {
  // 404 if the project is unknown, deleted, or not one of the caller's.
  await this.projects.assertAccess(projectId, user);
  // Setting the foreign key column is enough: no need to load the Project.
  return this.tasks.save(
    this.tasks.create({
      title: dto.title,
      dueDate: dto.dueDate ?? null,
      assigneeId: dto.assigneeId ?? null,
      projectId,
    }),
  );
}

async list(projectId: string, status: TaskStatus | undefined, user: AuthUser): Promise<Task[]> {
  await this.projects.assertAccess(projectId, user);
  const where: FindOptionsWhere<Task> = { projectId, archived: false };
  if (status) where.status = status;
  return this.tasks.find({ where, order: { createdAt: 'ASC' } });
}
  • assertAccess() vient de ProjectsService, que ProjectsModule exporte (J3). Il délègue à findOne() : un projet inconnu, supprimé logiquement ou dont l'appelant n'est pas membre donne 404, pour les tâches comme pour le projet lui-même.
  • this.tasks.create({ … }) construit l'entité sans rien écrire ; save() fait l'INSERT. Renseigner projectId suffit, sans charger le Project.
  • Un assigneeId qui ne correspond à aucun utilisateur viole la clé étrangère : le filtre global traduit l'erreur PostgreSQL en 422 (J6).
  • list() ne renvoie que les tâches non archivées, et ajoute le statut au where seulement s'il est fourni (J4).

Le changement de statut :

ts
async updateStatus(id: string, status: TaskStatus, user: AuthUser): Promise<Task> {
  const task = await this.tasks.findOneBy({ id });
  if (!task) throw new NotFoundException(`Task ${id} not found`);
  await this.projects.assertAccess(task.projectId, user);
  if (task.status === status) return task;

  const from = task.status;
  task.status = status;
  const saved = await this.tasks.save(task);
  // Emit only once the change is saved. Listeners run separately: if one
  // fails, this request still succeeds (see NotificationsListener).
  this.events.emit(
    TASK_STATUS_CHANGED,
    new TaskStatusChangedEvent(task.id, task.projectId, from, status, user.id, this.clock()),
  );
  return saved;
}

L'événement émis après la sauvegarde est le sujet de J9. Le parcours, vérifié par le test e2e (l'en-tête Authorization n'est exigé qu'à partir de J8) :

http
POST /v1/projects/5b0c…/tasks
Authorization: Bearer eyJ…
Content-Type: application/json

{ "title": "Write the README", "dueDate": "2026-11-24" }

La réponse est un 201 dont le corps contient "status": "TODO" (la valeur par défaut de la colonne) et "dueDate": "2026-11-24". Puis :

http
PATCH /v1/tasks/<id de la tâche>/status
Content-Type: application/json

{ "status": "DONE" }

200, et GET /v1/projects/<id>/tasks?status=DONE renvoie la tâche. ?status=LATE donne 400.

Archiver un projet et ses tâches ​

POST /v1/projects/:id/archive passe par le contrôleur des projets (@HttpCode(HttpStatus.OK)), puis par ProjectsService.archive, qui vérifie l'accès, puis par le store :

ts
// There is no @Transactional: the transaction is a callback, and every query
// inside it must go through `manager`. An injected repository would run
// outside the transaction.
archive(id: string): Promise<Project | null> {
  return this.dataSource.transaction(async (manager) => {
    const project = await manager.findOneBy(Project, { id });
    if (!project) return null;
    project.archived = true;
    await manager.save(project);
    await manager.update(Task, { projectId: id }, { archived: true });
    return project;
  });
}
  • Les trois requêtes (lecture, sauvegarde du projet, UPDATE des tâches) passent par manager : si l'UPDATE des tâches échoue, le projet n'est pas archivé non plus.
  • manager.update(Task, …) modifie toutes les tâches du projet en une seule requête, sans les charger.
  • TasksService.list ne renvoie que les tâches archived: false : le test e2e vérifie qu'après l'archivage, la liste des tâches du projet est vide.
  • TypeOrmProjectsStore utilise l'entité Task directement, sans importer TasksModule : pas de dépendance circulaire entre modules (J3).

La création d'un projet suit le même modèle, pour écrire le projet et l'appartenance de son créateur ensemble :

ts
async create(data: CreateProjectDto, ownerId: string): Promise<Project> {
  // Project and membership are written together or not at all.
  return this.dataSource.transaction(async (manager) => {
    const project = await manager.save(
      manager.create(Project, { name: data.name, description: data.description ?? null }),
    );
    await manager.insert(ProjectMember, { projectId: project.id, userId: ownerId });
    return project;
  });
}

La première migration ​

Le cycle suivi pour TaskFlow, base de développement vide, synchronize: false :

bash
npm run migration:generate src/database/migrations/InitialSchema   # écrit <timestamp>-InitialSchema.ts
npm run migration:run                                              # applique, enregistre dans typeorm_migrations
npm run migration:generate src/database/migrations/Check           # "No changes in database schema were found"
npm run migration:revert                                           # exécute down()
npm run migration:run                                              # réapplique

La troisième commande confirme que les entités et la base sont alignées : TypeORM affiche « No changes in database schema were found », n'écrit aucun fichier et sort en erreur. Le fichier généré :

ts
import { MigrationInterface, QueryRunner } from "typeorm";

export class InitialSchema1793093891613 implements MigrationInterface {
    name = 'InitialSchema1793093891613'

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`CREATE TYPE "public"."user_role" AS ENUM('ADMIN', 'MEMBER')`);
        await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying(254) NOT NULL, "password_hash" character varying NOT NULL, "role" "public"."user_role" NOT NULL DEFAULT 'MEMBER', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
        await queryRunner.query(`CREATE TYPE "public"."task_status" AS ENUM('TODO', 'IN_PROGRESS', 'DONE', 'OVERDUE')`);
        await queryRunner.query(`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(200) NOT NULL, "status" "public"."task_status" NOT NULL DEFAULT 'TODO', "due_date" date, "archived" boolean NOT NULL DEFAULT false, "project_id" uuid NOT NULL, "assignee_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`);
        await queryRunner.query(`CREATE INDEX "idx_tasks_project_status" ON "tasks"  ("project_id", "status") `);
        await queryRunner.query(`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, "description" text, "archived" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`);
        await queryRunner.query(`CREATE TABLE "project_members" ("project_id" uuid NOT NULL, "user_id" uuid NOT NULL, "joined_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_b3f491d3a3f986106d281d8eb4b" PRIMARY KEY ("project_id", "user_id"))`);
        await queryRunner.query(`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
        await queryRunner.query(`ALTER TABLE "tasks" ADD CONSTRAINT "FK_855d484825b715c545349212c7f" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
        await queryRunner.query(`ALTER TABLE "project_members" ADD CONSTRAINT "FK_b5729113570c20c7e214cf3f58d" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
        await queryRunner.query(`ALTER TABLE "project_members" ADD CONSTRAINT "FK_e89aae80e010c2faa72e6a49ce8" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE "project_members" DROP CONSTRAINT "FK_e89aae80e010c2faa72e6a49ce8"`);
        await queryRunner.query(`ALTER TABLE "project_members" DROP CONSTRAINT "FK_b5729113570c20c7e214cf3f58d"`);
        await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_855d484825b715c545349212c7f"`);
        await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`);
        await queryRunner.query(`DROP TABLE "project_members"`);
        await queryRunner.query(`DROP TABLE "projects"`);
        await queryRunner.query(`DROP INDEX "public"."idx_tasks_project_status"`);
        await queryRunner.query(`DROP TABLE "tasks"`);
        await queryRunner.query(`DROP TYPE "public"."task_status"`);
        await queryRunner.query(`DROP TABLE "users"`);
        await queryRunner.query(`DROP TYPE "public"."user_role"`);
    }

}
  • Deux types enum (user_role, task_status), quatre tables, la clé primaire composite de project_members, les clés étrangères avec ON DELETE CASCADE ou SET NULL, et l'index nommé.
  • Les clés primaires prennent uuid_generate_v4() par défaut.
  • down() défait up() dans l'ordre inverse : contraintes, puis tables, puis types.

Le fichier est versionné avec le changement d'entité qui l'a produit :

bash
git add src/database/migrations src/tasks/entities
git commit -m "feat(tasks): entité Task et première migration"

Situations ​

  • Vous devez renvoyer un projet avec ses tâches → relations: { tasks: true } ou leftJoinAndSelect (chargement).
  • Une liste déclenche une requête par ligne → un N+1 : une jointure ou une requête In(ids) (N+1).
  • Le schéma doit évoluer → modifier l'entité, migration:generate, relire, migration:run, commiter les deux ensemble ; au déploiement, npm run migration:run:prod avant la nouvelle version (migrations, première migration, J11).
  • La dernière migration est fausse → migration:revert, corriger, régénérer ; jamais modifier une migration déjà appliquée ailleurs (migrations).
  • Plusieurs écritures doivent réussir ou échouer ensemble → dataSource.transaction() et uniquement manager à l'intérieur (transactions, archivage).
  • Une méthode d'un autre service doit participer à la transaction → lui passer le manager en paramètre (transactions).
  • Il faut une isolation SERIALIZABLE ou décider du commit à la main → dataSource.transaction('SERIALIZABLE', …) ou un QueryRunner (QueryRunner).
  • Le lien entre deux entités porte une donnée → une entité d'association plutôt qu'un @ManyToMany (relations).

Pièges ​

Un repository injecté dans une transaction

this.tasks.update(...) à l'intérieur de dataSource.transaction(async (manager) => …) s'exécute sur une autre connexion, hors transaction : en cas d'échec, cette écriture reste. Passez par manager, ou manager.withRepository(this.tasks).

QueryRunner jamais libéré

Sans release() dans un finally, chaque appel garde une connexion. Le pool s'épuise et l'application finit par attendre indéfiniment une connexion libre.

await oublié dans le callback

Si une requête n'est pas attendue, le callback se termine sans elle : le COMMIT peut partir avant qu'elle s'exécute, et son erreur n'annule rien. La règle no-floating-promises le signale (J1).

  • relations: ['tasks'] (syntaxe TypeORM 0.3) ne compile plus en TypeORM 1.1 : écrivez relations: { tasks: true }.
  • eager: true charge la relation dans tous les find*, même quand elle est inutile, mais pas dans le QueryBuilder : le même code se comporte différemment selon l'API utilisée.
  • Une relation lazy parcourue dans une boucle est un N+1 invisible à la lecture du code.
  • @OneToMany sans @ManyToOne en face ne fonctionne pas : le côté inverse a besoin du côté propriétaire.
  • cascade: true n'est pas ON DELETE CASCADE : l'un agit au save() dans TypeORM, l'autre dans la base. Et un softDelete ne déclenche ni l'un ni l'autre.
  • Générer une migration sur une base en retard produit une migration qui duplique les précédentes.
  • Garder synchronize: true avec des migrations : le schéma change hors migrations, et les migrations générées ensuite ne correspondent plus à rien (synchronize).

Exercices ​

  • [ ] Créer l'entité Task (title, status enum, dueDate, projectId), liée à Project par @ManyToOne / @OneToMany.
  • [ ] Exposer POST /projects/:id/tasks et PATCH /tasks/:id/status ; vérifier avec curl : 201, 200, puis 400 pour un statut inconnu.
  • [ ] Écrire l'archivage transactionnel d'un projet et de toutes ses tâches (POST /v1/projects/:id/archive). Lever volontairement une erreur après l'UPDATE des tâches et vérifier dans Adminer que rien n'a changé.
  • [ ] Désactiver synchronize, générer la première migration, relire le SQL, l'appliquer, régénérer (« No changes »), annuler, réappliquer, puis la versionner dans git.
  • [ ] Provoquer un N+1 avec logging: true (une boucle de findBy), compter les requêtes, puis corriger avec leftJoinAndSelect.
  • [ ] Réécrire l'archivage avec un QueryRunner et comparer avec dataSource.transaction().
  • [ ] Esquisser un @ManyToMany (des étiquettes sur les tâches) et comparer avec l'entité ProjectMember.
  • [ ] Noter dans LEARNINGS.md : @Transactional ↔ transaction explicite, manager propagé aux requêtes.

Pour aller plus loin ​