Nodefony
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Migrations de schéma — de la base de dev au déploiement sans interruption

stable@nodefony/drizzlemis à jour 2026-08-30

📍 DocumentationDrizzle — ORM SQLMigrations de schéma

Une base de données ne se met pas à jour toute seule, et elle ne se remplace pas non plus. Une migration est un fichier de SQL versionné qui la fait passer d’une version du schéma à la suivante, en gardant la trace de son passage. Cette page dit comment Nodefony les produit, les applique et les surveille — et surtout comment déployer sans interrompre le service, ce qui est la seule question difficile du sujet.

Deux lecteurs, deux moitiés. En développement, presque rien à faire : le schéma se répare tout seul, et la seule commande à retenir est orm:reset. En exploitation, tout se joue avant le déploiement : un travail d’orchestrateur applique les migrations pendant que l’ancienne version sert encore, avec un compte qui n’est pas celui du trafic.

📖 Lexique#

Terme Ce que ça veut dire
migration un fichier de SQL versionné qui fait passer la base d’une version du schéma à la suivante, et qui garde trace de son passage
schéma la forme de la base : ses tables, ses colonnes, leurs types et leurs contraintes — pas les données qu’elle contient
DDL Data Definition Language — la part du SQL qui crée et modifie le schéma (CREATE TABLE, ALTER TABLE), par opposition à celle qui lit et écrit les données
source de migrations un dossier de migrations livré par quelqu’un : le framework en a une, l’application une autre, un module tiers peut en apporter une
historique la table nodefony_migrations où l’applicateur écrit ce qu’il a posé, quand, et avec quelle empreinte
empreinte la somme de contrôle du contenu d’un fichier de migration — c’est elle qui détecte qu’un fichier déjà appliqué a été modifié après coup
adoption (baseline) déclarer qu’une base existante correspond déjà à certaines migrations, sans les exécuter
dérive un fichier de migration modifié APRÈS avoir été appliqué : l’historique et le disque ne disent plus la même chose
divergence la base ne correspond plus au code, alors que l’historique est complet et que rien n’est en attente
expansion / contraction la façon de changer un schéma en deux déploiements, pour qu’à aucun moment le code en service et la base ne soient incompatibles

Qu’est-ce qu’une migration, et pourquoi le développement n’en a pas besoin#

En développement, Nodefony dérive le schéma du code : les entités que vous déclarez deviennent des CREATE TABLE IF NOT EXISTS au démarrage. C’est immédiat et sans cérémonie — mais IF NOT EXISTS ne fait évoluer aucune table qui existe déjà. Ajoutez un champ à une entité, et la table gardera la forme qu’elle avait.

En production, cette dérivation serait pire qu’inutile : elle poserait des tables dont l’historique ne garderait aucune trace, et personne ne saurait plus d’où elles viennent — exactement la divergence que les migrations existent pour empêcher.

D’où trois modes, un par connecteur, réglés par la clé ddl :

Mode Qui fabrique le schéma Où c’est le défaut
auto dérivé du code au démarrage, et rattrapé (voir plus bas) développement, test
migrate les migrations, appliquées au démarrage sous verrou nulle part — jamais un défaut
none personne ici : un travail extérieur s’en charge tout le reste, production comprise

❗ Important

migrate n’est le défaut d’aucun environnement, et c’est délibéré. Faire migrer le schéma par le processus qui sert le trafic, c’est accepter que N exemplaires démarrant ensemble se disputent la base. Le verrou les sérialise, mais le patron sain reste le travail d’orchestrateur décrit plus bas. migrate existe pour les déploiements sans orchestrateur — un serveur unique, une machine virtuelle.

🚀 Démarrage rapide#

Cinq verbes, et un seul à retenir pour le quotidien du développement.

# Ce que la base a reçu, ce qui reste, et ce qu'il faut taper. N'écrit rien.
nodefony orm:migrate:status

# Applique ce qui est en attente (framework d'abord, application ensuite).
nodefony orm:migrate

# Écrire la migration qui aligne la base sur VOS entités.
nodefony orm:generate --name ajout_du_titre

# Voir le SQL sans l'appliquer — la même validation que la vraie.
nodefony orm:migrate --dry-run

# Adopter une base existante : marquer des migrations comme appliquées, sans les exécuter.
nodefony orm:migrate:baseline

# Reprendre une base qui existait AVANT toute migration : la référence est LUE sur la base.
nodefony orm:migrate:baseline --from-database

# Effacer les marqueurs d'échec, APRÈS avoir regardé ce qui s'est passé.
nodefony orm:migrate:repair

# Développement seulement : supprime et recrée la base du connecteur.
nodefony orm:reset

Toutes acceptent --connector <nom> (défaut : default) et --json. Le flux --json est pur : nodefony orm:migrate:status --json | jq ne casse sur aucune ligne de journal.

Les migrations du framework sont livrées dans le paquet : vous n’avez pas à les produire. Celles de votre application, vous les écrivez avec orm:generate — voir la section suivante.

Côté configuration, il n’y a rien à écrire pour le cas courant : le mode se résout par environnement. Ne le déclarer que pour s’en écarter — un serveur unique qui migre au démarrage :

import { defineConfig, use } from "nodefony";

export default defineConfig(() => ({
  modules: [
    use("@nodefony/drizzle", {
      connectors: {
        // Le démarrage applique les migrations, sous verrou. À réserver aux
        // déploiements SANS orchestrateur — un serveur unique, une machine
        // virtuelle : ailleurs, c'est un travail dédié qui migre, et les
        // exemplaires restent en "none".
        default: { ddl: "migrate" },
      },
      migrations: {
        // Retenir la mise en service tant que le schéma est en retard (défaut),
        // et faire de la divergence une barrière plutôt qu'une observation.
        check: "fail",
        divergence: "fail",
      },
    }),
  ],
}));

Écrire les migrations de votre application — orm:generate#

Vous modifiez une entité, vous tapez un verbe, vous relisez le fichier produit :

nodefony orm:generate --name ajout_du_titre

Il n’y a rien à installer ni à configurer. La commande trouve vos entités là où le générateur les écrit — nodefony/entity/*.ts, dans l’application et dans chacun de ses modules —, produit la migration dans migrations/<dialecte>/, et vous dit ce qu’elle a écrit. L’outil qui calcule la différence est piloté à l’intérieur ; vous n’avez ni sa configuration à tenir, ni son dossier de sortie à connaître, ni son journal à comprendre.

Le dialecte est celui de votre connecteur : vos entités sont du Drizzle natif, donc écrites pour un moteur. C’est ce qui vous laisse toute la puissance du moteur dans une entité — et ce qui fait qu’une migration vaut pour lui seul.

Ce que la commande refuse, et pourquoi c’est une bonne nouvelle#

Une migration est immuable dès qu’une base l’a reçue. Une migration à laquelle il manque une table ne se corrige donc pas : elle se remplace par une suivante, sur toutes les bases qui ont déjà appliqué la première. C’est pour cela que trois situations arrêtent la commande avant qu’elle n’écrive quoi que ce soit — chacune nomme ce qui cloche :

Ce qui est refusé Ce que ça veut dire
une entité enregistrée qu’aucun fichier ne fournit son fichier a été déplacé, renommé, ou ne s’importe pas seul — la migration serait écrite sans sa table
un fichier de l’application qui exporte une table du framework la migration porterait un second CREATE TABLE pour cette table : elle passerait sur une base vierge, et échouerait sur toute base déjà migrée
une migration qui supprime des données les fichiers sont écrits et conservés : ce sont eux qu’il faut lire. C’est leur mise en service qui est retenue, et elle a sa propre garde

Le troisième cas mérite deux précisions.

D’abord, quand une colonne disparaît et qu’une autre apparaît, aucun outil ne peut deviner s’il s’agit d’un renommage — les données suivent — ou d’une suppression suivie d’un ajout — les données sont perdues. C’est une intention, pas une différence de schéma. Rejouez alors la commande dans un terminal, et répondez à la question posée.

Ensuite, il n’y a rien à regénérer pour « assumer » : au moment où ce refus paraît, les fichiers sont déjà écrits et inscrits au journal. Relancer la génération ne produirait plus rien — elle répondrait « le schéma n’a pas bougé ». Les deux issues réelles sont donc celles que le refus propose : annuler les fichiers avec votre outil de gestion de versions, ou les appliquer. C’est orm:migrate qui met en service, et c’est lui qui porte la garde --allow-destructive hors du développement.

Un quatrième refus n’a rien à voir avec votre schéma : l’outil qui écrit les migrations n’est pas installé (NF_GENERATE_TOOL_MISSING). C’est une dépendance de DÉVELOPPEMENT — votre application la déclare, et un npm install la pose. Elle manque quand l’installation s’est faite sans les dépendances de développement (--omit=dev, une image de production). Le refus le dit et donne le geste ; il ne parle pas de votre base, qui n’y est pour rien — appliquer des migrations, lui, ne réclame aucun outil tiers.

Une entité qui pointe vers une table du framework#

Déclarer une référence vers une table du framework (session, audit_event…) est légitime : ce qui est refusé, c’est de ré-exporter cette table depuis vos fichiers. Les tables du framework sont exclues du plan de votre application — elles ont leurs propres migrations, appliquées avant les vôtres. Pour une vraie clé étrangère SQL, écrivez une migration libre.

❗ Important

User n’en fait PAS partie. L’identité est du domaine : la table User appartient à votre application, qui la décrit et en porte les migrations. Le framework ne la livre plus. C’est ce qui vous permet d’y ajouter vos propres champs — ce qu’aucune table du framework n’autorise.

Ce qu’aucun schéma ne peut déduire — la migration libre#

Une vue, un déclencheur, un index particulier, un remplissage de données ne se déduisent d’aucune entité :

nodefony orm:generate --custom --name vue_des_ventes

Vous obtenez un fichier vide, déjà inscrit au journal, que vous écrivez à la main. Il est appliqué comme les autres : une seule fois, dans l’ordre, et son empreinte est gravée.

Reprendre une base qui existait AVANT toute migration#

C’est l’état d’une application qui passe du développement à la production : la base porte ses tables et ses données, et le dossier migrations/ est vide — le mode de développement les a créées au démarrage, sans jamais écrire de fichier.

Dans cet état, orm:generate refuse (NF_GENERATE_DATABASE_NOT_ADOPTED). Ce n’est pas une précaution : la première migration décrirait la création de tables qui existent, elle ne s’appliquerait jamais, et l’adopter graverait dans l’historique un schéma que la base n’a pas — l’état dont plus aucune commande ne sort.

Le refus nomme le geste, et seulement celui qui produira quelque chose. Si la base porte toutes les tables que le code déclare — le cas quand rien n’a changé depuis qu’elle a été faite — il n’y a qu’une commande, et elle EST votre première migration :

nodefony orm:migrate:baseline --from-database

Regénérer derrière ne donnerait rien : le code n’a pas bougé, il n’y a aucun écart à écrire. La suite redevient ordinaire — le jour où vous ajoutez un champ :

nodefony orm:generate --name ajout_du_slug   # produit un ALTER, plus un CREATE
nodefony orm:migrate

Si la base n’en porte qu’une partie — des entités ont été ajoutées depuis —, le refus propose les deux gestes : l’adoption d’abord, la génération ensuite, qui écrira l’écart restant.

La commande lit le schéma de la base, en écrit la migration de référence sous migrations/<dialecte>/0000_<nom>.sql, et l’inscrit comme appliquée. Aucune instruction n’est exécutée sur la base : elle décrit un état déjà atteint. Son corps est laissé exécutable, pour qu’un environnement neuf puisse être monté depuis ces mêmes fichiers.

Deux faits qu’elle publie, et qu’il faut lire :

Ce qu’elle dit Ce que ça veut dire
des tables lues sans être déclarées la base est partagée. L’outil de lecture ne sait pas restreindre son champ ; la génération suivante proposera de les supprimer. Relisez le fichier.
un corps resté en commentaire la référence ne recréerait rien sur une base neuve — l’outil a changé sa mise en forme.

Deux nettoyages ont lieu sans que vous ayez à y penser, et il vaut mieux savoir qu’ils existent :

⚠️ Sur MariaDB, cette commande refuse — et elle dit pourquoi. MariaDB n’a pas de type JSON natif : il l’écrit en longtext assorti d’un CHECK (json_valid(…)). L’outil qui relit les schémas ne sait pas lire ces contraintes, et il lit la base entière avant de filtrer : les tables du framework suffisent donc à le bloquer. Le repli tient en quatre gestes, que le message d’erreur rappelle — relever le schéma (SHOW CREATE TABLE), un orm:generate --custom, y coller le schéma, puis orm:migrate:baseline.

Cela ne concerne que cette commande de reprise. Créer les tables, appliquer les migrations et faire tourner l’application sont inchangés sur MariaDB.

En développement — le schéma se répare tout seul#

C’est le scénario de tous les jours d’une équipe : le back ajoute un champ à une entité, le front tire la branche, et sa base locale date d’avant.

Au démarrage, en mode auto, la connexion se fait en trois temps — et l’ordre est ce qui compte :

  1. les tables manquantes sont créées (CREATE TABLE IF NOT EXISTS) ;
  2. le schéma déclaré est comparé à celui de la base, table par table (compareSchema(), schemaDiff.ts:122) ;
  3. les index sont posés.

Entre les deux, ce qui se rattrape est rattrapé :

ℹ️ Note

Ce troisième temps n’est pas cosmétique. Un index porte sur des colonnes : le poser sur une table à laquelle il en manque une échoue, et cet échec-là tuait le démarrage — le développeur recevait un no such column du pilote, sans nom de connecteur, sans geste, et sans serveur pour aller voir. Les index d’une table encore en écart sont donc sautés : l’écart, lui, a déjà été dit.

Quand le rattrapage ne suffit pas, une seule commande à retenir :

nodefony orm:reset --connector default

Elle supprime et recrée la base, et refuse dès que l’environnement n’est pas development — liste blanche, pas liste noire : staging et tout environnement inconnu refusent aussi.

En production — le patron de déploiement#

Le travail d’orchestrateur, AVANT le déploiement des pods#

Le consensus cloud-native est solide, et Nodefony ne cherche pas à le remplacer : les migrations s’appliquent dans un travail dédié, qui se termine avant que le premier nouvel exemplaire ne démarre. Les pods, eux, tournent en ddl: "none".

# Kubernetes — le patron de référence. Le déploiement attend la fin du travail.
apiVersion: batch/v1
kind: Job
metadata:
  name: nodefony-migrate
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: mon-application:1.4.0
          command: ["npx", "nodefony", "orm:migrate", "--json"]
          env:
            - name: NF_MIGRATE_DATABASE_URL
              valueFrom:
                secretKeyRef: { name: db-migrator, key: url }

Trois propriétés en découlent, et elles valent d’être nommées :

Les droits du compte qui migre ne sont pas ceux du trafic#

Le compte qui applique une migration a besoin de modifier le schéma. Celui qui sert les requêtes, non — et lui laisser ce pouvoir, c’est offrir un DROP TABLE à la première injection réussie.

orm:migrate lit donc NF_MIGRATE_DATABASE_URL en priorité sur l’URL du connecteur, et cette variable n’est lue par personne d’autre : c’est le véhicule du moindre privilège.

-- PostgreSQL : le compte du TRAFIC ne peut que lire et écrire des données.
GRANT CONNECT ON DATABASE app TO app_runtime;
GRANT USAGE ON SCHEMA public TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_runtime;

-- Le compte qui MIGRE possède le schéma, et ne sert jamais de requête applicative.
GRANT CREATE ON SCHEMA public TO app_migrator;
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime;

⚠️ Attention

orm:reset n’est jamais concerné : il ne lit pas NF_MIGRATE_DATABASE_URL. Une variable qui porte des droits de schéma ne doit pas pouvoir désigner la cible d’un effacement.

Déployer sans interruption — expansion, puis contraction#

Pendant un déploiement progressif, deux versions du code parlent à une seule base. La règle qui en découle n’a rien d’optionnel : toute migration doit être compatible avec la version encore en service. Un changement destructif se fait donc en deux déploiements.

Renommer une colonne email en contact_email, sans une seconde d’interruption :

Étape Migration Code déployé
1 — expansion ajouter contact_email, nullable écrit les DEUX colonnes, lit email
2 recopier les données existantes inchangé
3 lit contact_email, écrit les deux
4 — contraction supprimer email ne connaît plus email

Chaque étape est déployable seule, et à aucun moment la base n’est incompatible avec ce qui tourne. C’est plus long que le ALTER TABLE … RENAME d’un seul coup — et c’est la seule façon connue de ne pas couper le service.

Pourquoi il n’y a PAS de sauvegarde automatique#

Aucune commande de Nodefony ne sauvegarde votre base avant d’agir. C’est une décision, pas un manque, et aucun outil de migration sérieux ne le fait non plus.

Une sauvegarde automatique donnerait une assurance qui n’existe pas. Sur une base de production de plusieurs centaines de gigaoctets, la prendre depuis le processus qui migre prendrait des heures, saturerait le disque de l’exemplaire, et échouerait au pire moment. Sur une base répliquée, elle ignorerait les réplicas. Et surtout : une restauration est une décision d’exploitation, avec sa fenêtre d’indisponibilité, sa perte de données assumée entre deux points de reprise, et quelqu’un qui la prend. Un outil qui prétendrait la préparer tout seul inviterait à ne pas y penser.

Ce que l’outil fait à la place, et qui vaut mieux :

La protection réelle contre la perte de données, c’est le patron ci-dessus et votre politique de sauvegarde — pas un fichier .bak pris par un outil qui ne sait rien de votre infrastructure.

La troisième source — le verdict divergent#

Les outils de migration connaissent deux choses : les fichiers, et l’historique. Ils en concluent « tout est appliqué ». Ils ne regardent jamais la base.

Nodefony croise une troisième source (describeDivergence(), divergence.ts:127), et rend un constat qu’aucun outil ne produit en continu :

l’historique est complet, aucune migration n’est en attente — et pourtant la base ne correspond pas au code.

Quelqu’un a passé un ALTER à la main un soir d’astreinte, un correctif d’urgence n’a jamais été reporté, deux environnements ont divergé. Le verdict s’appelle divergent, il s’affiche dans orm:migrate:status, il est publié par la sonde, et il obéit à trois règles :

use("@nodefony/drizzle", {
  // "report" (défaut) — affiche tout écart, ne retient QUE sur une table absente
  // "fail"          — tout écart retient la mise en service
  // "off"           — rien n'est comparé, rien n'est publié
  migrations: { divergence: "fail" },
});

La graduation est unique (divergenceIsBlocking(), explain.ts) : la commande et la sonde de disponibilité lisent la même règle, donc votre passe d’intégration continue et votre orchestrateur ne peuvent pas se contredire.

Il dit CE QUI diverge, pas seulement QU’IL diverge#

Un verdict qui annonce un écart sans le nommer envoie ouvrir un client SQL et comparer table par table, sur une base de production, au pire moment. La sortie porte donc les tables et les colonnes, séparées selon qu’elles se rattrapent ou non — une colonne qui accepte le vide s’ajoute sans rien inventer, une colonne obligatoire exige une décision métier :

nodefony orm:migrate:status --json | jq '.divergence'
{
  "missingTables": ["webhook_endpoint"],
  "blocking": [
    { "table": "User", "column": "tenantId", "type": "text", "nullable": false }
  ],
  "additive": [
    {
      "table": "audit_event",
      "column": "metadata",
      "type": "jsonb",
      "nullable": true
    }
  ]
}

La clé vit au premier niveau, dans le cœur neutre — pas sous driver : un second ORM remplira la même structure, et un jq écrit aujourd’hui ne doit pas graver le nom d’un pilote. Sur une base conforme, la clé est ABSENTE (jamais un objet vide) : .divergence == null suffit à tester.

L’écran lisible en dit autant : le résumé nomme les trois premières entrées de chaque famille, et la liste complète ne se déroule que lorsqu’elle ne tient plus dans la phrase.

Les gestes proposés suivent l’environnement : orm:reset efface, elle n’est acceptée qu’en développement, et elle n’est donc proposée que là. Ailleurs, la sortie renvoie vers l’écriture d’une migration correctrice (orm:generate --custom) puis son application.

Ils suivent aussi ce qui manque. Une table d’entité absente se rattrape par le générateur — il sait la produire, puisque le code la déclare — et c’est orm:generate --name … qui est proposé, pas --custom : envoyer écrire à la main ce que la commande d’à côté écrit seule serait un geste juste pour une colonne et absurde pour une table.

Quand l’historique MENT — repair --forget#

Il existe un état que ni les fichiers ni l’historique ne peuvent signaler seuls : une migration inscrite comme réussie que personne n’a jamais exécutée. L’historique est complet, rien n’est en attente, aucun marqueur d’échec — et pourtant la base ne porte pas les tables.

Deux chemins y mènent, tous deux réels :

Le verdict divergent le voit et NOMME ce qui manque. Mais aucune commande ne « rejoue » une migration que l’historique donne pour appliquée : la réparation ordinaire ne connaît que les marqueurs d’ÉCHEC, et répond « rien à réparer ». C’est une impasse, et une impasse fait faire des gestes qu’on regrette — au banc de découvrabilité, le seul chemin restant était d’effacer la base.

nodefony orm:migrate:status --json                      # NOMME les tables qui manquent
nodefony orm:migrate:repair --forget app/0003_facture   # désinscrit CETTE migration
nodefony orm:migrate                                    # elle est rejouée

Trois propriétés, et elles sont volontaires :

Propriété Pourquoi
il faut la NOMMER <source>/<tag> exactement. Ni motif, ni lot, ni « toutes celles de cette source » : un oubli en masse est le geste qu’on ne veut pas rendre facile.
la base n’est PAS touchée seule la ligne d’historique disparaît. Si la migration avait bien été appliquée, son rejeu échouera — bruyamment, et c’est le comportement voulu.
c’est le produit qui le fait l’interdit de toucher à la table d’historique vise la main dans un client SQL, sans trace. Le geste existe donc ici, borné et journalisé, plutôt qu’ailleurs.

⚠️ N’utilisez cette option que sur un état CONSTATÉ. Le point de départ n’est pas ce refus, c’est orm:migrate:status : c’est lui qui dit quelles tables manquent, donc quelle migration n’a jamais tourné.

Codes de sortie et sortie --json#

La grille est figée, et ne sera jamais réaffectée — des passes d’intégration continue s’y adossent, et en changer le sens casserait des tests que nous ne voyons pas.

Code Ce que ça veut dire
0 à jour, ou appliqué avec succès
1 une action humaine est requise : migrations en attente, dérive, échec, refus destructif, table d’entité absente
2 la commande n’a pas pu travailler : base injoignable, verrou indisponible, usage invalide
# Barrière d'intégration continue : la passe s'arrête si le schéma n'est pas à jour.
nodefony orm:migrate:status --json || exit 1

# Ce qu'un agent lit — jamais une phrase française.
nodefony orm:migrate:status --json | jq -r '.verdict, .nextActions[0].command'

Chaque sortie --json porte formatVersion: 1 au premier niveau. Ajouter un champ est une version mineure ; en retirer ou en renommer un est interdit sur la série majeure.

Le même état, dans la console d’administration#

Ce que la ligne de commande rend, la console le montre — page Migrations (/nodefony/migrate) : le verdict, l’identité du connecteur, les gestes à copier, et une ligne par migration avec sa date, sa durée, son auteur, son déploiement et le motif de son échec.

Trois points du plan d’administration la servent, tous derrière le rôle d’administration :

Point Ce qu’il rend
GET /nodefony/orm/api/migrations?connector= exactement la charge utile de orm:migrate:status --json
GET /nodefony/orm/api/migrations/plan?connector= ce qui S’APPLIQUERAIT, avec le SQL de chaque migration en attente
POST /nodefony/orm/api/migrations/apply?connector= applique — refusé hors développement, en disant par quoi passer

Trois choses valent d’être dites, parce qu’elles décident de ce que vous pouvez croire à l’écran :

⚠️ Pièges#

🧪 Tests & couverture#

Ce qui est prouvé
Applicateur : identité ensembliste, dérive, ordre, échec puis réparation, adoption, idempotence tests/integration/migrator-sqlite.test.ts
Verrou entre process, absence de zombie, DDL non transactionnel migrator-postgres.e2e.test.ts, migrator-mysql.e2e.test.ts
Les cinq verbes sur un boot réel, dans les trois modes migrate-cli.e2e.test.ts (NF_RUN_CLI_BOOT=1)
Rattrapage additif, refus d’inventer, colonne en trop ignorée schema-reconcile.test.ts
Rattrapage sur serveurs réels (types, catalogue, index) schema-reconcile-dialects.e2e.test.ts
Verdict divergent, son absence quand un geste est déjà dû, et le DÉTAIL qu’il nomme migrate-divergence.test.ts
Refus destructif : ce qui perd des données, ce qui n’en perd pas migrate-destructive.test.ts
Parité entre le schéma migré et le schéma dérivé, sur les 3 dialectes migrations-parity-*.test.ts
Chaque réglage sur son couple (refus sans lui, travail avec lui), 3 dialectes migrate-reglages.test.ts
Lecture et empreinte d’un fichier : marque d’ordre des octets, CRLF, chaînes littérales tests/unit/migrationFichiers.test.ts
Le nom d’une migration, et l’invariant « une suggestion est toujours acceptable » tests/unit/migrationName.test.ts
Le cycle complet dans une application générée : génération, barrière de déploiement, /readyz 503, divergence provoquée gabarit tests/migrations.e2e.test.ts de toute app à ORM
Le découpage en instructions : séparateur dans un commentaire, séparateur collé en fin de ligne tests/unit/migratorContracts.test.ts

Les bancs PostgreSQL et MySQL/MariaDB exigent leurs serveurs :

docker compose -f docker/docker-compose.yml --profile postgres up -d postgres
docker compose -f docker/docker-compose.yml --profile mariadb up -d mariadb
NF_PG_URL=postgres://nodefony:nodefony-dev@127.0.0.1:5432/nodefony \
NF_MYSQL_URL=mysql://nodefony:nodefony-dev@127.0.0.1:3306/nodefony npm test

🛑 Prudence

Sans ces variables, les bancs se skippent — et un test skippé compte comme vert. La suite du module affiche en fin de passe ce qu’elle n’a PAS exercé : lisez ce bloc avant de conclure.

🔗 Pour aller plus loin#