Un enum Prisma PostgreSQL désynchronisé ne casse rien au build : dans l’API d’une billetterie événementielle, l’enum TypeScript CommissionStatus déclarait cancelled depuis l’origine, alors que le type PostgreSQL ne l’a jamais contenu. Le premier remboursement aurait levé une erreur en production. La cause est architecturale : les migrations sont du SQL écrit à la main et Prisma ne sert que de client typé, donc rien ne vérifie que le code et la base parlent des mêmes valeurs. Le correctif tient en un test Jest qui lit schema.prisma et compare les enums valeur par valeur, sans connexion à la base.
Pourquoi l’enum Prisma et l’enum PostgreSQL se désynchronisent ?
L’API tourne sous NestJS 10.4, TypeScript 5.8, Prisma 5.22 et PostgreSQL. Les migrations vivent dans un dossier de fichiers .sql, rejoués par un petit script Node qui utilise le driver pg et note chaque fichier appliqué dans une table de suivi. Prisma n’y est pas un outil de migration : schema.prisma est tenu à la main pour générer le client typé.
Une valeur de statut existe donc en trois exemplaires :
| Copie | Où | Qui la vérifie |
|---|---|---|
Type PostgreSQL commission_status | migration SQL | la base, à l’écriture |
Bloc enum CommissionStatus | schema.prisma | le client Prisma généré |
enum CommissionStatus TypeScript | code métier | personne |
Le schema drift (dérive de schéma) désigne l’écart entre ce que le code croit de la base et ce que la base contient réellement. Ce projet l’avait déjà vécu au printemps 2026 : douze tables décrites dans schema.prisma manquaient en base. Le même jour, quatre colonnes de statut texte avaient été converties en enums PostgreSQL, sans que les enums TypeScript soient relus.
Trois dérives, aucune détectable sans base
En instruisant le cas de cancelled, j’ai trouvé deux autres écarts de la même famille :
- Une valeur TypeScript absente de PostgreSQL :
CommissionStatus.CANCELLED. L’écriture aurait échoué au premier remboursement ou à la première annulation d’événement ; si la valeur atteint la base, PostgreSQL répondinvalid input value for enum commission_status: "cancelled". - Une valeur PostgreSQL absente de TypeScript :
disputed. Une commission litigieuse relue par le code ne correspondait à aucune valeur connue de l’enum. - Un enum TypeScript entièrement faux :
PayoutStatusdéclaraitpendingetpaid, deux valeurs que le typepayout_statusn’a jamais acceptées, et ignoraitrequested, la valeur que le service de versement écrit réellement (en chaîne littérale, pas via l’enum).
Aucun de ces écarts ne produit d’erreur de compilation. Le troisième survivait parce que personne n’utilisait l’enum : du code mort, mais faux, sur le chemin de sortie de l’argent.
Le correctif : un test de parité enum qui lit schema.prisma
Côté base, une migration rejouable ajoute la valeur manquante :
ALTER TYPE public.commission_status ADD VALUE IF NOT EXISTS 'cancelled';
⚠️ Depuis PostgreSQL 12, ALTER TYPE … ADD VALUE est accepté dans une transaction, mais la nouvelle valeur n’est utilisable qu’après le commit ; jusqu’à PostgreSQL 11, la commande était refusée dans un bloc de transaction (doc ALTER TYPE). Une migration qui ajoute la valeur puis l’écrit, exécutée comme une seule transaction, échoue.
Côté code, disputed rejoint l’enum TypeScript, et PayoutStatus est réaligné sur la base. Retirer pending du type PostgreSQL n’était pas une option : un enum PostgreSQL ne permet pas de supprimer une valeur sans recréer le type.
Puis le garde-fou, un test Jest 29 (version simplifiée) :
import { readFileSync } from 'fs';
import { join } from 'path';
import { CommissionStatus } from './commission-status.enum';
import { PayoutStatus } from './payout-status.enum';
function prismaEnumValues(name: string): string[] {
const schema = readFileSync(join(__dirname, '../../../prisma/schema.prisma'), 'utf8');
const match = new RegExp(`enum ${name} \\{([^}]*)\\}`).exec(schema);
if (!match) throw new Error(`Enum ${name} introuvable dans schema.prisma`);
return match[1]
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('@@'));
}
describe('Parité des enums TypeScript / PostgreSQL', () => {
it.each([
['CommissionStatus', Object.values(CommissionStatus)],
['PayoutStatus', Object.values(PayoutStatus)],
])('%s a les mêmes valeurs des deux côtés', (name, values) => {
expect([...values].sort()).toEqual(prismaEnumValues(name).sort());
});
});
Le test compare des ensembles triés : une valeur en trop ou en moins de chaque côté le fait échouer, avec un diff Jest qui nomme la valeur fautive. Il aurait attrapé les trois dérives.
Un enum dupliqué entre le code et la base n’est fiable que si un test compare les deux copies ; sans ce test, la première écriture en production s’en charge.
Ce que le test ne couvre pas
Le test suppose que schema.prisma reflète la base. C’est une convention, pas une garantie : chaque migration SQL doit modifier schema.prisma dans le même commit. Vérifier schema.prisma contre la base réelle demande une connexion (prisma db pull par exemple), donc un autre type de contrôle. Le même problème d’enum comme source de vérité existe côté PHP, avec les enums backed de PHP 8.1 ; ici la difficulté vient du fait que la vérité est répartie entre deux langages.
Lessons learned
- Prisma utilisé comme simple client typé ne protège pas contre la dérive des enums. Avec des migrations SQL manuelles, l’enum TypeScript,
schema.prismaet le type PostgreSQL sont trois copies indépendantes. - Une dérive d’enum se découvre à la première écriture de la valeur manquante. Pour un statut rare comme
cancelled, ce moment arrive en production, au premier remboursement. - Un test de parité enum peut lire
schema.prismaau lieu d’interroger la base. Il tourne dans la CI sans PostgreSQL et compare les valeurs dans les deux sens. ALTER TYPE … ADD VALUE IF NOT EXISTSrend l’ajout rejouable, mais la valeur n’est utilisable qu’après le commit. Ajouter la valeur et l’utiliser dans la même transaction échoue.- Un enum TypeScript inutilisé peut quand même être faux. Le code mort qui décrit des statuts donne une fausse documentation au prochain développeur qui s’en sert.