Comment un schéma bouge

Une entité se décrit telle qu'elle est aujourd'hui. fougere freeze enregistre ce qu'elle était, si bien que la différence entre deux moments devient une valeur sur laquelle quelque chose peut agir.

fougere freeze v1        # fronds/blog/versions/v1/shape.json
fougere migrate          # ce que la base n'a pas rattrapé
fougere migrate --apply  # et on y va

Un artefact, plusieurs lecteurs

fronds/blog/versions/
  v1/  shape.json                  ce que les formes ÉTAIENT
  v2/  shape.json  from.json       …et le pas qui mène ici

Les deux sont écrits, et aucun ne remplace l'autre. Un instantané seul perd l'intention — un champ parti plus un champ apparu ne se distingue pas d'un renommage. Un pas seul se corrompt en silence — rien ne le confronte à quoi que ce soit. Ensemble ils portent un invariant : rejouer le pas sur l'instantané précédent doit redonner celui-ci.

Une migration lit le pas une fois, contre les lignes. Le second lecteur est la raison pour laquelle le pas est un artefact et non un appel de fonction : une ancienne version d'API encore servie rejouerait le même pas à chaque appel, à la porte. Ce lecteur n'est pas encore écrit — et c'est pourquoi la comparaison vit à un seul endroit, car l'écrire deux fois laisserait les deux diverger sur les deux mêmes formes.

Un renommage se déclare, il ne se devine jamais

Fougere ne décidera pas que body est devenu content, même quand c'est la seule possibilité :

◆  post.body — what happened to it?
   ❯ renamed to content    la donnée le suit
     dropped               la colonne part, et ce qu'elle tenait avec elle

Les deux lectures produisent des DDL opposées — l'une garde une colonne de données vivantes, l'autre la jette. L'intention vit dans le moment où le changement a été fait, et aucune paire d'instantanés ne la contient. C'est le seul endroit où freeze pose une question, et rien n'atteint le disque tant qu'elle n'est pas réglée.

Ce qu'une migration réalise

Une passe automatique est additive à dessein : elle crée une table manquante, ajoute une colonne manquante, et rien d'autre n'arrive jamais. fougere migrate est l'autre moitié — et seulement ce que le pas dit :

Le pas ditLa base fait
renamedALTER TABLE … RENAME COLUMN — la donnée suit
removedDROP COLUMN
added, optionnelrien ; la passe additive s'en charge déjà
un index qui apparaîtrien ; chaque démarrage propose tous les index déclarés
une boundary qui bougerien — qui peut lire un champ n'est pas dans une colonne

La comparaison lit les quatre axes, pas seulement la forme : un index, un groupe unique, une relation, un lifecycle et une boundary sont des différences aussi, et un pas qui ne rapportait que la forme répondait au quart de la question.

Tout le reste est refusé en le nommant, et en une fois plutôt qu'un à la fois :

  • un champ requis sans défaut — les lignes existantes n'ont rien à mettre dedans. Déclarez text({ default: … }), la même réponse qu'une migration donne quand on ajoute une colonne NOT NULL à une table qui a déjà des lignes.
  • un champ devenu requis — même raison, dans l'autre sens.
  • un type qui bouge — aucune conversion ne se dérive de deux formes.
  • une borne resserrée — la porte la fait respecter ; la table garde son ancien CHECK jusqu'à ce que vous la migriez vous-même.
  • un groupe unique qui bouge — les lignes déjà stockées peuvent le contredire, donc ajouter la contrainte est une décision sur les lignes, pas sur le DDL.
  • une relation qui bouge — une clé étrangère est une contrainte, et rien ici n'en altère.
  • un défaut qui bouge — la table garde l'ancien ; rien ici n'altère un DEFAULT.
  • un index qui disparaît — rien ne supprime un index aujourd'hui, la table le garde.

Un plan qui porte un seul refus n'exécute rien. Un renommage à moitié fait est pire que pas fait : la donnée finit dans une colonne dont plus rien ne connaît le nom.

Rejouer ne coûte rien

Lancez migrate deux fois et le second passage répond up to date. Il n'y a aucun registre des migrations appliquées — un changement est sauté parce que les colonnes elles-mêmes disent qu'il a eu lieu : un renommage dont l'ancien nom a disparu et dont le nouveau est là a été fait.

C'est pour ça que la chaîne est lue entière et composée en un seul pas plutôt que rejouée pas à pas : un champ renommé deux fois est suivi jusqu'au nom sur lequel il finit, le seul dont les tables puissent répondre. Un nom intermédiaire qu'elles n'ont jamais porté n'est pas une question qu'on peut leur poser. Rien n'a besoin de savoir à quelle version la base est assise, et une colonne que quelqu'un a renommée à la main est vue plutôt que contredite. Un registre serait un second enregistrement d'un fait que les colonnes portent déjà, et les deux divergeraient le jour où ils ont divergé.

Ce que ceci ne fait pas

Rien ne tourne au démarrage. Supprimer et renommer touchent des données vivantes : c'est une commande que vous lancez, après avoir lu le plan. La passe additive reste où elle était.

Un gel n'est pas un artefact de build. Il est committé, parce qu'il enregistre un passé qu'aucun code présent ne sait régénérer — contrairement à la carte d'identité, dérivée des entités d'aujourd'hui et redérivable à tout moment.

Ensuite : La carte d'identité — la même idée à travers les process plutôt qu'à travers le temps.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.