J5 · Relations, migrations et transactions
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,ManyToOneetManyToMany, et savoir quel côté porte la clé étrangère. - Choisir comment charger une relation :
eager,lazyourelationsà 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 unQueryRunner. - Livrer l'entité
Task, ses endpoints, l'archivage transactionnel et la première migration.
Spring → NestJS
| Spring / JPA | TypeORM | À 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, @EntityGraph | relations: { tasks: true }, leftJoinAndSelect | Le chargement se décide à chaque requête |
| Flyway, Liquibase | Migrations TypeORM : migration:generate, run, revert | Générées par différence entre les entités et la base |
ddl-auto=none + Flyway | synchronize: false + migrations | Le schéma n'évolue plus que par migration |
@Transactional | dataSource.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) | QueryRunner | Contrôle manuel, release() obligatoire |
CascadeType.ALL | cascade: 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)surTaskest le côté propriétaire. Avec@JoinColumn({ name: 'project_id' }), il définit la colonneproject_idet sa contrainte.@OneToMany(() => Task, (task) => task.project)surProjectest le côté inverse : aucune colonne. En TypeORM, il ne peut pas exister seul ; le@ManyToOnecorrespondant est obligatoire. L'inverse n'est pas vrai : un@ManyToOnepeut se passer de@OneToMany, commeTask.assignee.- Les fonctions
() => Taskretardent la lecture de la classe :project.entity.tsettask.entity.tss'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, commemappedBy.
@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 :
// 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 :
// 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 :
| Option | Agit | Effet |
|---|---|---|
onDelete: 'CASCADE' / 'SET NULL' | Dans la base, sur la clé étrangère | Un 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.
| Mode | Déclaration | Effet | Quand l'utiliser |
|---|---|---|---|
| À la demande | find({ relations: { tasks: true } }) ou leftJoinAndSelect | Jointure dans la requête qui en a besoin | Par 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'être | Rarement : une petite relation toujours utile |
lazy | Propriété typée Promise<Task[]> | Une requête au premier await project.tasks | Rarement : chaque accès est une requête cachée |
// Exemple hors TaskFlow : relation lazy
@OneToMany(() => Task, (task) => task.project)
tasks: Promise<Task[]>;
const tasks = await project.tasks; // requête SQL à cet instantrelations 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 :
// 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 :
// 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();leftJoinAndSelectjoint et remplitproject.tasks;leftJoinseul ne sert qu'à filtrer.- La condition de jointure (troisième argument) garde les projets sans tâche active, alors qu'un
wheresurtaskles éliminerait. - Autres corrections :
find({ relations: { tasks: true } }), ou deux requêtes, la seconde avecIn(projectIds), puis un regroupement en mémoire. - Pour compter les requêtes, ajoutez temporairement
logging: trueaux options de laDataSource. - 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) :
// 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 :
| Script | Commande exécutée | Effet |
|---|---|---|
npm run migration:generate src/database/migrations/Nom | typeorm-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:run | Applique les migrations en attente, dans l'ordre |
npm run migration:revert | … migration:revert | Exécute le down() de la dernière migration appliquée |
npm run migration:run:prod | typeorm migration:run -d dist/database/data-source.js | Applique 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 danspostgresOptions, avec le motif des fichiers de migration et le nom de la table de suivi,typeorm_migrations(l'équivalent deflyway_schema_history).- Une transaction. Par défaut,
migration:runapplique 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:runavantmigration:generate. - Relire le SQL. TypeORM ne détecte pas les renommages : une propriété renommée apparaît comme un
DROP COLUMNsuivi d'unADD COLUMN. Corrigez à la main enRENAME COLUMNavant de valider. - Ne jamais modifier une migration appliquée. Écrivez-en une nouvelle.
- Vérifier en CI. L'option
--checkdemigration:generatesort 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
// 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 :
// 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 :
// 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
@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;
}statusest un enum PostgreSQL nommétask_status,TODOpar défaut ; les quatre valeurs (TODO,IN_PROGRESS,DONE,OVERDUE) viennent detask-status.enum.ts(statuts).dueDateest une colonnedate: la valeur revient en chaîne'YYYY-MM-DD', d'où le typestring | null. Le DTO de création impose ce format (@Matches+@IsDateString({ strict: true })).projectIdest une colonne ordinaire et la clé de la relationproject: elles partagentproject_id. On crée ou filtre une tâche parprojectIdsans charger le projet, et c'est cette colonne qui rend la clé non nulle dans le schéma.assigneeest facultatif, aveconDelete: 'SET NULL': supprimer un utilisateur laisse ses tâches sans responsable.archivedsert à l'archivage du projet ; l'index compositeidx_tasks_project_statussert la liste des tâches d'un projet filtrée par statut.
Les endpoints : POST /projects/:id/tasks et PATCH /tasks/:id/status
@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/taskset/v1/tasks/:id/status.ParseUUIDPipeetParseEnumPiperefusent un identifiant ou un statut invalide avant le handler (J6).
La création et la liste, côté service :
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 deProjectsService, queProjectsModuleexporte (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. RenseignerprojectIdsuffit, sans charger leProject.- Un
assigneeIdqui 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 auwhereseulement s'il est fourni (J4).
Le changement de statut :
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) :
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 :
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 :
// 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,
UPDATEdes tâches) passent parmanager: si l'UPDATEdes 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.listne renvoie que les tâchesarchived: false: le test e2e vérifie qu'après l'archivage, la liste des tâches du projet est vide.TypeOrmProjectsStoreutilise l'entitéTaskdirectement, sans importerTasksModule: 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 :
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 :
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éappliqueLa 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é :
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 deproject_members, les clés étrangères avecON DELETE CASCADEouSET NULL, et l'index nommé. - Les clés primaires prennent
uuid_generate_v4()par défaut. down()défaitup()dans l'ordre inverse : contraintes, puis tables, puis types.
Le fichier est versionné avec le changement d'entité qui l'a produit :
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 }ouleftJoinAndSelect(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:prodavant 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 uniquementmanagerà l'intérieur (transactions, archivage). - Une méthode d'un autre service doit participer à la transaction → lui passer le
manageren paramètre (transactions). - Il faut une isolation
SERIALIZABLEou décider du commit à la main →dataSource.transaction('SERIALIZABLE', …)ou unQueryRunner(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 : écrivezrelations: { tasks: true }.eager: truecharge la relation dans tous lesfind*, même quand elle est inutile, mais pas dans leQueryBuilder: 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.
@OneToManysans@ManyToOneen face ne fonctionne pas : le côté inverse a besoin du côté propriétaire.cascade: truen'est pasON DELETE CASCADE: l'un agit ausave()dans TypeORM, l'autre dans la base. Et unsoftDeletene 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: trueavec 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 àProjectpar@ManyToOne/@OneToMany. - [ ] Exposer
POST /projects/:id/tasksetPATCH /tasks/:id/status; vérifier aveccurl: 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'UPDATEdes 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 defindBy), compter les requêtes, puis corriger avecleftJoinAndSelect. - [ ] Réécrire l'archivage avec un
QueryRunneret comparer avecdataSource.transaction(). - [ ] Esquisser un
@ManyToMany(des étiquettes sur les tâches) et comparer avec l'entitéProjectMember. - [ ] Noter dans
LEARNINGS.md:@Transactional↔ transaction explicite,managerpropagé aux requêtes.
Pour aller plus loin
- NestJS : TypeORM (relations, transactions, migrations)
- Documentation TypeORM : relations, migrations, transactions, QueryRunner
- PostgreSQL 16 : contraintes et clés étrangères