Écrire une migration

Faire évoluer le schéma d'un module ou du core avec une migration versionnée, et les pièges à connaître avant d'en écrire une.

Une migration fait évoluer un schéma déjà déployé. Elle s'applique une fois, elle est enregistrée, et elle ne repasse jamais.

Cette page s'adresse à qui modifie le schéma de @lrvo/core ou d'un module @lrvo/module-*.

Pourquoi ce n'est pas createTables()

createTables() court-circuite sur tableExists() : une table absente est créée, une table présente est laissée telle quelle. Tant qu'une évolution consiste à créer une table entière, ça suffit.

Dès qu'il s'agit d'ajouter une colonne, de poser une contrainte ou de convertir des données, il n'y a plus aucun moyen d'atteindre une base déjà déployée, sauf à réexécuter l'instruction à chaque démarrage pour l'éternité. C'est ce qu'a produit l'accumulation de correctifs ensure* marqués LEGACY dans le core et dans les modules.

Ne rien ajouter à ces correctifs. Ils restent en place pour les bases qui ne les ont pas encore subis, et rien de plus.

Où elles vivent

PaquetFichier
@lrvo/coresrc/core/database/migrations/index.js
Un modulesrc/migrations.js, réexporté depuis src/index.js

Un module les expose comme le reste de son contrat :

export { createTables, dropTables } from './db.server.js';
export { migrations } from './migrations.js';

Forme

export const migrations = [
  {
    id: '001-add-excerpt',
    description: 'Ajoute la colonne excerpt aux posts.',
    up: async (ctx) => {
      if (!(await ctx.tableExists('lrvo_posts_post'))) return;
      await ctx.sql(`ALTER TABLE lrvo_posts_post ADD COLUMN excerpt TEXT`);
    },
  },
];

L'identifiant est numéroté et stable. Il ne change plus une fois publié : c'est lui qui est enregistré en base.

Il n'y a pas de down. Un retour arrière automatique sur une base de production donne une fausse assurance : la moitié des migrations ne sont pas réversibles sans perte, et celles qui le sont demandent une décision humaine.

Le contexte

ctx est lié au client de la transaction en cours.

MembreRôle
ctx.sql(text, params)Exécute une instruction.
ctx.clientLe client pg verrouillé, pour les cas non couverts.
ctx.tableExists(nom)Table présente.
ctx.columnExists(table, colonne)Colonne présente.
ctx.indexExists(nom)Index présent.
ctx.constraintExists(table, nom)Contrainte présente.

Les trois pièges

Toujours ctx, jamais l'import

Le tableExists exporté par @lrvo/core/database interroge le pool. Il ne verra donc pas le schéma créé plus tôt dans la même transaction, qui n'est pas encore validée, et il répondra faux sur une table qui vient d'être créée.

import { tableExists } from '@lrvo/core/database';   // ❌ jamais dans un `up`
await ctx.tableExists('lrvo_posts_post');            // ✓

Aucun système de types n'attrapera cette erreur.

Se garder sur les tables conditionnelles

Le schéma de @lrvo/module-posts dépend de sa configuration : lrvo_posts_taxonomy_term, lrvo_posts_post_taxonomy et lrvo_posts_relation n'existent que si un type déclare des taxonomies ou des relations.

Une migration qui les concerne doit sortir sans rien faire quand elles sont absentes. Elle est tout de même enregistrée comme appliquée : si la table apparaît plus tard, c'est createTables() qui la crée, déjà au schéma courant.

Pas de CREATE INDEX CONCURRENTLY

PostgreSQL l'interdit dans une transaction, et chaque migration en est une. Sur une grosse table, découper le travail ou l'assumer comme une opération de maintenance manuelle.

Ce que garantit le runner

  • Une transaction par migration. Un échec sur la septième laisse les six premières durablement appliquées, et la reprise repart de là.
  • L'enregistrement a lieu dans la même transaction que la migration. Une migration qui échoue ne laisse jamais de trace.
  • Un verrou consultatif couvre toute l'exécution. Deux répliques de déploiement qui lancent lrvo-db init en même temps se sérialisent, avec un message clair pour la seconde.
  • Le message SQL réel remonte. Le nom de la contrainte violée est affiché, pas un « une erreur est survenue ».

Bases déjà déployées

Aucune base existante n'est retouchée rétroactivement. À la première exécution sur une base donnée, chaque paquet reçoit une ligne 000-baseline posée après son createTables().

Dans les deux cas de figure, base neuve et base déployée depuis des mois, createTables() vient de garantir le schéma courant : c'est donc bien la ligne de base. Aucun CREATE TABLE n'est jamais rejoué sur des tables pleines.

Concrètement, une migration écrite aujourd'hui ne s'applique qu'aux bases qui passent la version où elle est publiée. Le schéma antérieur est figé, pas rejoué.

Migration ou seed

Les deux sont distincts, et le rester est important.

MigrationSeed
FréquenceUne fois, puis jamais.À chaque lrvo-db init.
EnregistréeOui, dans lrvo_migrations.Non.
ExempleAjouter une colonne.Attacher au rôle admin les permissions d'un module fraîchement installé.

seedDefaultRolesAndPermissions() doit tourner à chaque init : c'est ainsi qu'un module ajouté après coup voit ses permissions apparaître dans /admin/roles. Ce n'est pas une migration, et le transformer en migration casserait cette mécanique.

Vérifier

npx lrvo-db status     # appliquées, adoptées, en attente
npx lrvo-db migrate    # applique ce qui manque

Relancer migrate deux fois de suite doit être sans effet la seconde fois. Si ce n'est pas le cas, la migration n'est pas correctement enregistrée.

Sur cette page