Files
team-tryouts/docs/database-schema.md
T
GGThed 7b9eee4805 feat(db): mesurer la derive du schema, au lieu de la supposer
DB-001. create_all() cree les tables manquantes et ne fait jamais d ALTER.
Une colonne ajoutee a un modele il y a six mois est donc absente de toute
base qui possedait deja la table, et rien ne le dit : l application demarre,
et la premiere requete qui touche cette colonne echoue a l execution. C est
la raison d etre de migrations/add_tryout_coaches.py, ecrit a la main pour
rattraper un cas. Personne ne sait combien il y en a d autres.

app/supporting_scripts/schema_report.py compare le catalogue d une base
vivante aux modeles : tables, colonnes, types, nullabilite, contraintes
d unicite, cles etrangeres, index. En **lecture seule** — il ouvre une
connexion, lit, imprime, sort. Aucun DDL, aucun DML.

Les constats sont classes par ce qu ils coutent, pas par ce qu ils sont :

- BLOCKING : les modeles l attendent, la base ne l a pas. C est la derive ;
- RISK : la base l a, aucun modele ne le decrit. Inoffensif tant que rien
  ne bouge — et **un alembic --autogenerate proposera de le supprimer**,
  avec ses donnees. C est la classe qu on lit en entier ;
- DIFFERENCE : types, nullabilite, contraintes. Chacune demande un humain.

Les types sont compares apres compilation vers le meme dialecte : opposer
String(200) a VARCHAR(200) en chaines aurait signale chaque colonne comme
differente, et un rapport qui crie partout ne se lit plus.

--check-seed-accounts repond a la question de SEC-003 a laquelle le depot
ne peut pas repondre : le compte admin/password seme par clear_db.py
existe-t-il encore, et son mot de passe est-il toujours celui-la.

13 tests le pilotent contre des bases SQLite fabriquees pour diverger d une
facon connue. Le cas qui compte le plus est la base propre : un rapport qui
crie sur une base saine ne sera pas lu, et un rapport qui dit « aucun
ecart » sur une base derivee est pire que pas de rapport — c est un feu
vert pour laisser autogenerate ecrire la difference en DROP.

docs/database-schema.md donne la suite, etape par etape, avec le piege de
DB-002 en toutes lettres : la migration initiale doit decrire la base telle
qu elle est, pas telle que les modeles la decrivent. Generer depuis les
modeles puis estampiller revient a declarer que la derive n existe pas.

Alembic n est pas ajoute aux dependances : rien ne l utilise encore, et une
dependance que rien n utilise est exactement ce que l audit reprochait
ailleurs. Le document dit a quelle etape l ajouter.

530 tests.
2026-08-11 15:00:18 -04:00

6.0 KiB

Le schéma réel, et comment sortir de create_all()

État au 2026-08-11 : l'outil de relevé existe et est testé. Le relevé lui-même n'a pas été exécuté — il demande un accès à la base de production, qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution.

Pourquoi c'est le nœud

db.create_all() crée les tables manquantes et ne fait jamais d'ALTER.

Une colonne ajoutée à un modèle il y a six mois est donc absente de toute base qui possédait déjà la table, et rien ne le dit : l'application démarre normalement, et la première requête qui touche cette colonne échoue à l'exécution. C'est la raison d'être de migrations/add_tryout_coaches.py, un script écrit à la main pour rattraper un cas.

Personne ne sait combien il y en a d'autres. C'est ce que DB-001 mesure, et c'est pourquoi huit tâches en dépendent : DB-002 à DB-009, plus ARCH-001 (fusion coach/équipe) et SEC-012 (identité Discord avec unique=True).

Étape 1 — sauvegarder, et vérifier la sauvegarde

Rien de ce qui suit ne se fait avant qu'une sauvegarde ait été restaurée avec succès. Pas « prise » : restaurée. Une sauvegarde qu'on n'a jamais restaurée est une hypothèse.

Procédure dans docs/database-restore.md. La copie restaurée sert aussi de terrain pour les étapes 2 et 3.

Étape 2 — relever l'écart

# D'abord sur la copie restaurée, jamais directement sur la production
python app/supporting_scripts/schema_report.py \
    --url postgresql://user:pass@host:5432/copie_restauree

L'outil est en lecture seule : il ouvre une connexion, lit le catalogue, imprime et sort. Aucun DDL, aucun DML.

Codes de sortie : 0 aucun écart, 1 écart trouvé, 2 connexion impossible.

Le rapport classe ce qu'il trouve par ce que ça coûte :

Classe Ce que c'est Ce que ça coûte
BLOCKING Les modèles l'attendent, la base ne l'a pas L'application échoue à l'exécution. C'est la dérive de create_all()
RISK La base l'a, aucun modèle ne le décrit Inoffensif tant que rien ne bouge. Un alembic --autogenerate proposera de le supprimer, avec ses données
DIFFERENCE Types, nullabilité, contraintes qui divergent Chacune demande un humain : certaines sont de l'orthographe de dialecte, d'autres sont réelles

La classe RISK est celle qu'on lit en entier. C'est par là qu'une migration corrective détruit une colonne dont quelqu'un se servait encore.

Pendant qu'on y est, la question que l'audit posait et à laquelle le dépôt ne peut pas répondre — le compte admin/password semé par clear_db.py existe-t-il encore ? (SEC-003) :

python app/supporting_scripts/schema_report.py --check-seed-accounts

Étape 3 — Alembic, décrivant le schéma réel (DB-002)

Le piège de cette étape tient en une phrase : la migration initiale doit décrire la base telle qu'elle est, pas telle que les modèles la décrivent.

Générer la migration initiale depuis les modèles puis estampiller la production revient à déclarer que la dérive n'existe pas. Elle reste là, invisible, et la première migration suivante s'appuie sur un état faux.

  1. Ajouter alembic à requirements.txt — et seulement à ce moment : une dépendance que rien n'utilise est exactement ce que l'audit reprochait ailleurs.
  2. alembic init migrations/alembic, en pointant sqlalchemy.url sur DATABASE_URL plutôt qu'en le codant en dur.
  3. Générer la révision initiale contre la copie restaurée : alembic revision --autogenerate -m "schéma existant".
  4. Relire la révision ligne par ligne contre le rapport de l'étape 2. Tout op.drop_* correspond à une ligne RISK : ou bien on l'assume, ou bien on le retire de la migration.
  5. Estampiller : alembic stamp head. La révision initiale ne s'exécute jamais ; elle décrit le point de départ.

Étape 4 — la migration corrective (DB-003)

Une seconde révision qui rattrape les écarts relevés. À exécuter d'abord sur la copie restaurée, et à vérifier avec :

alembic upgrade head && alembic downgrade -1 && alembic upgrade head
python app/supporting_scripts/schema_report.py --url <copie>   # doit sortir 0

Le critère d'acceptation est celui-là : le relevé ne trouve plus rien.

Attention aux colonnes BLOCKING déclarées NOT NULL : les ajouter à une table peuplée échoue sans valeur par défaut ni remplissage. Le rapport le signale dans la conséquence.

Étape 5 — ce que la migration débloque

Dans cet ordre, parce qu'ils dépendent tous de DB-002 :

Tâche Ce qu'elle fait Pourquoi ça attendait
DB-004 Retirer create_all() de create_app() Tant qu'il est là, deux mécanismes décrivent le schéma
DB-005 Cascades de suppression au niveau base Les cascades ORM sont en place ; PostgreSQL ne les connaît pas
DB-006 Unicité sur TryoutRegistration(tryout_id, player_id) Le plafond d'inscriptions est aujourd'hui un count() suivi d'un add() : deux requêtes simultanées passent toutes les deux
DB-007 Index, CheckConstraint sur les statuts, server_default
DB-008 Trancher attendance_confirmed côté tryout discord_bot.py écrit un attribut fantôme ; aujourd'hui journalisé en avertissement
DB-009 Horodatages avec fuseau datetime.utcnow partout, déprécié en 3.12
ARCH-001 Fusionner coach/équipe sur la relation m2m Migration de données ; app/permissions.py rend la duplication inoffensive en lecture seulement, l'écriture crée toujours les deux
SEC-012 Identité Discord côté serveur, unique=True La colonne doit être unique, donc dédoublonnée d'abord

Ce qu'on ne fait pas

Générer la migration initiale depuis les modèles « pour avancer en attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une production estampillée sur un état qu'elle n'a pas, et la dérive préservée sous une couche de plus.