6.4 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.
- Ajouter
alembicàrequirements.txt— et seulement à ce moment : une dépendance que rien n'utilise est exactement ce que l'audit reprochait ailleurs. alembic init migrations/alembic, en pointantsqlalchemy.urlsurDATABASE_URLplutôt qu'en le codant en dur.- Générer la révision initiale contre la copie restaurée :
alembic revision --autogenerate -m "schéma existant". - Relire la révision ligne par ligne contre le rapport de l'étape 2. Tout
op.drop_*correspond à une ligneRISK: ou bien on l'assume, ou bien on le retire de la migration. - 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 |
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.