Files
team-tryouts/docs/database-schema.md
T

6.6 KiB

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

État au 2026-08-16 : l'outil de relevé existe et est testé, y compris pour les collisions d'identité Discord. 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 changement de schéma ci-dessous 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

L'identité Discord est désormais conservée côté serveur et toute nouvelle collision est refusée par l'application. Les doublons historiques restent à identifier avant d'ajouter la contrainte UNIQUE de SEC-012 :

python app/supporting_scripts/schema_report.py --check-discord-identities

É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) Les deux routes verrouillent désormais la ligne Tryout avant le contrôle de doublon, le count() et l'add() : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes
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 Ajouter unique=True sur l'identité Discord La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées 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.