# Séquence base de la bascule de production

Livrable **B14** de l'issue **#156**, prérequis de **#136** / **#157**.
Runbook de référence : [`docs/roadmap/14_RUNBOOK_BASCULE_PROD.md`](../../docs/roadmap/14_RUNBOOK_BASCULE_PROD.md)
§0.7 étape 2, §3 ter, §4 phase 5.

> Avant ce dossier, la partie qui bloque tout — la conversion MyISAM → InnoDB et la
> création des clés étrangères — était la seule qui ne soit ni versionnée, ni testée,
> ni rejouable. Elle se serait donc faite **à la main, sur une base de production**.

## Ce que la séquence fait

```
dump de e-learning-prod
   ↓  1  restauration dans une base MySQL 8 neuve
   ↓  2  photo de l'état initial (moteurs, comptages, table migrations)
   ↓  3  ALTER TABLE … ENGINE=InnoDB      ← en SQL, hors migration (choix acté)
   ↓  4  schéma de référence construit par migrate:fresh sur une base jetable
   ↓  5  alignement des types portant une clé étrangère (couvre D2/D3)
   ↓  6  contrôle BLOQUANT des orphelins de courses  ← avant les migrations
   ↓  7  php artisan migrate --force      ← purges ea136_* + rattrapages ea137/141-144/152/160
   ↓  8  contrôle BLOQUANT des orphelins, toutes relations  ← après les purges
   ↓  9  création des clés étrangères manquantes
   ↓ 10  contrôles de sortie : migrate --pretend, diff de schéma outillé, comptages
```

**L'ordre 7 → 8 → 9 est l'invariant de la séquence.** Les 16 supports orphelins de cours
ne sont purgés que **par ricochet**, parce qu'ils font partie des 89 orphelins de section.
Une clé étrangère posée avant la purge les rend bloquants en **errno 1452**. C'est la
précision 3 du runbook §3 ter, et trois tests la gardent
(`tests/Unit/Back156SequenceBasculeTest.php`).

**Rien n'est écrit en dur.** Ni la liste des clés étrangères (elle est passée de 64 à 69
en seize jours), ni les divergences de type : tout est dérivé de la référence au moment
de l'exécution. Un sprint qui ajoute une table ne demande aucune retouche ici.

## Prérequis

| | |
|---|---|
| MySQL | **8.0** (la production est en 8.0.46). Une base **neuve**, jamais une base partagée. |
| PHP | **8.2** — `/opt/homebrew/opt/php@8.2/bin/php` en local, `php8.2` sur le serveur. |
| Python | `python3` (aucune dépendance : seule la bibliothèque standard sert). |
| Laravel | un clone de la branche qui partira en production, avec `composer install` **propre** (jamais de `vendor` symlinké) et son `.env`. |
| Droits MySQL | `CREATE`, `ALTER`, `DROP`, `REFERENCES` sur la base cible **et** sur la base de référence jetable. |

Le mot de passe MySQL se passe par **`MYSQL_PWD`**, jamais en argument de ligne de
commande (il serait lisible dans la table des processus).

⚠️ **Composer 2.2.6.** Le serveur de production porte encore cette version de février
2022 (runbook §1.6, précision 2 du §3 ter). La séquence ci-dessous ne dépend pas de
Composer, mais le `composer install` qui la précède, si : le jouer avec le Composer récent
d'un poste de développement ne prouve rien sur la machine cible.

## Invocation

### La répétition — c'est le critère de #156

```bash
export PATH="/opt/homebrew/opt/mysql@8.0/bin:$PATH"     # MySQL 8.0, pas celui du poste

# 1. une copie de la production. À défaut de dump réel : la fixture (voir plus bas).
DUMP=/chemin/vers/e-learning-prod.sql

# 2. les deux passes, enchaînées, sans intervention
DUMP="$DUMP" BASE=efektiv_testing_156 JOURNAL=/tmp/repetition \
  ./scripts/bascule/repetition.sh --port 3310 --php /opt/homebrew/opt/php@8.2/bin/php
```

`repetition.sh` sort en 0 **uniquement si** les deux passes vont au bout, que la seconde
est un no-op complet, et que l'**empreinte SHA-256 du schéma est identique** entre les
deux. Deux journaux qui se ressemblent ne prouvent rien ; deux empreintes identiques, si.

### Une passe seule

```bash
./scripts/bascule/bascule_base.sh \
    --base e-learning-prod \
    --dump ~/backups/prod-db-20260830.sql \
    --forcer-restauration \
    --php /usr/bin/php8.2
```

`--help` donne les options. Les deux qui comptent :

- **`--dump`** accepte indifféremment un `mysqldump` réel de la production et la fixture :
  c'est un fichier SQL, la séquence ne fait aucune hypothèse sur son origine.
- **`--forcer-restauration`** vide la base et recharge le dump. **C'est le mode du jour J** :
  on repart du dump frais, comme le veut le §0.7 étape 4 (« PAS de merge des écarts »).

## Ce que « no-op en 2e passe » veut dire

La seconde passe **s'exécute entièrement**, sans qu'on change quoi que ce soit à la
commande, et **ne modifie rien** :

| étape | 1re passe | 2e passe |
|---|---|---|
| restauration | dump chargé | base déjà peuplée → **ignorée** |
| conversion InnoDB | 58 tables converties | **0 table à convertir** |
| alignement des types | 1 colonne réalignée | **0 colonne** |
| migrations | 58 migrations jouées | **« Nothing to migrate »** |
| orphelins | 0 restant, contrôle passé | **plus aucune relation à contrôler** |
| clés étrangères | 50 créées | **0 à créer** |
| empreinte du schéma | `93b96b4f…` | **`93b96b4f…` — la même** |

La restauration est le seul geste qui ne peut pas être idempotent par nature : elle est
donc **conditionnelle**. Sans `--forcer-restauration`, une base déjà peuplée n'est pas
rechargée — c'est ce qui rend la seconde passe automatique. Avec, on repart du dump, et
c'est alors le schéma d'arrivée qui doit être identique, pas l'exécution.

⚠️ **« Nothing to migrate » ne prouve rien** : cette commande ne lit que la table
`migrations`, et c'est exactement ce qu'affichait la production le 06/08 alors que
`courses.category_id` manquait (#137). **Seul le diff de schéma fait preuve.** La séquence
le joue et refuse de sortir en 0 s'il rapporte un écart.

⛔ **La table `migrations` n'est jamais recalée.** Aucune ligne n'y est insérée ni
supprimée par cette séquence : le recalage est l'incident #137, pas un outil.

## Les écarts tolérés

[`ecarts_attendus.json`](./ecarts_attendus.json) liste ce que le diff rapportera encore à
l'arrivée et qu'on accepte **sciemment**. Une seule entrée à ce jour : l'index
`completed_courses_learner_id_foreign`, documenté « en trop, sans risque » au
[§4 du diff de schéma](../../docs/roadmap/19_DIFF_SCHEMA_PROD.md).

Toute entrée doit citer sa source — un test le vérifie. Un écart **non** documenté fait
échouer la séquence, et c'est le but.

## La fixture, à défaut de dump réel

Aucun dump de production n'existe hors du serveur, et la répétition ne pouvait pas
attendre. [`fixture/construire_fixture.sh`](./fixture/construire_fixture.sh) fabrique une
copie synthétique de `e-learning-prod` à partir de son état **documenté** :

```bash
./scripts/bascule/fixture/construire_fixture.sh \
    --sortie /tmp/fixture-prod.sql --port 3310 \
    --php /opt/homebrew/opt/php@8.2/bin/php
```

Le schéma n'est pas recopié à la main : les **62 migrations enregistrées en production**
sont rejouées telles quelles, puis [`deltas_prod.sql`](./fixture/deltas_prod.sql) applique
les écarts relevés les 08 et 11/08 et [`donnees_prod.sql`](./fixture/donnees_prod.sql)
charge la volumétrie et les références pendouillantes. Le constructeur **refuse de
produire** une fixture qui ne passerait pas ses 24 contrôles de conformité — 58 tables,
442 colonnes, MyISAM 58/58, 0 clé étrangère, 62 lignes de `migrations` en `MAX(batch) = 5`,
89 + 16 + 1 + 5 orphelins, 28 réponses de quiz de plus de 125 caractères.

**Ce que la fixture ne remplace pas.** Elle reproduit l'état *documenté*, donc elle ne
peut pas révéler ce que personne n'a mesuré : un orphelin sur une relation jamais
comptée, une colonne posée à la main dont aucun document ne parle. Le jour où un
`mysqldump` réel sera disponible, il faut **rejouer la répétition dessus** — la séquence
l'accepte sans modification, c'est tout l'intérêt du paramètre `--dump`.

## Contre-épreuve — ce que la séquence empêche

Sur la fixture, ordre inversé (clés étrangères posées **avant** les purges) :

```
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
  … CONSTRAINT `lessons_section_id_foreign` … REFERENCES `sections` (`id`)
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
  … CONSTRAINT `lessons_course_id_foreign`  … REFERENCES `courses`  (`id`)
```

89 orphelins de section, 16 de cours. Dans le bon ordre, la même base accepte les
**69 clés étrangères** sans une erreur. Le risque n'est pas théorique, il est mesuré.

## Fichiers

| | |
|---|---|
| `bascule_base.sh` | la séquence, une passe. |
| `repetition.sh` | les deux passes enchaînées + le contrôle du no-op. C'est le critère d'acceptation de #156. |
| `cles_etrangeres.py` | dérive du couple référence ↔ cible : les types à réaligner, les orphelins à compter, les clés étrangères à poser. |
| `ecarts_attendus.json` | la seule tolérance de la porte de sortie, sources à l'appui. |
| `fixture/` | construction de la copie synthétique de `e-learning-prod`. |

L'outillage de comparaison de schéma (`scripts/schema/`) n'est pas dupliqué : il est
appelé tel quel.
