# Bascule SQLite → MySQL

Runbook de la fenêtre de maintenance, et référence des pièges que la bascule révèle.

Les valeurs entre chevrons sont à remplacer une bonne fois pour toutes après l'audit
serveur (cf. « Prérequis » ci-dessous) :

| Placeholder | Ce que c'est | Valeur / comment le trouver |
|---|---|---|
| `<CHEMIN_APP>` | racine de l'application en production | **`/var/www/mostiglass`** — confirmé le 01/08/2026 |
| `<BINAIRE_PHP>` | binaire PHP CLI utilisé par cron | `command -v php` (chemin absolu, pas l'alias). PHP **8.2.32** |
| `<SQLITE_PROD>` | fichier SQLite historique | `grep DB_DATABASE <CHEMIN_APP>/.env` |
| `<DB_PASS>` | mot de passe du compte applicatif MySQL | à générer, cf. étape 2 |

Contexte serveur relevé le 01/08/2026 : `gocap-mostiglass-2`, PHP **8.2.32** (une seule
version), **PHP-FPM 8.2** derrière nginx, application possédée par **`www-data`**,
OPcache actif. `intl` est présent côté CLI **et** côté FPM
(`/etc/php/8.2/fpm/conf.d/20-intl.ini`).

---

## Prérequis (à relever avant la fenêtre)

```bash
cd <CHEMIN_APP>

php -v                       # version et chemin du binaire CLI
php -m | grep -E 'pdo_mysql|mbstring|intl|gd|exif|fileinfo'

# ⚠️ La ligne ci-dessus ne renseigne que le CLI. Les pages sont servies par un AUTRE
# SAPI (PHP-FPM ou mod_php), avec son propre php.ini et son propre conf.d : une
# extension peut être active d'un côté et absente de l'autre. Pour Filament (lot 3),
# c'est le SAPI web qui décide.
ls /etc/php/                                  # plusieurs versions installées ?
systemctl list-units 'php*fpm*' --all         # quelle version sert le site ?
diff <(php -m) <(php-fpm -m)                  # divergence CLI / FPM ?

mysql --version              # client présent ? (nécessaire à db:backup)
which mysqldump              # doit répondre : db:backup en dépend
systemctl status mysql       # serveur démarré ?
crontab -l                   # une ligne schedule:run existe-t-elle déjà ?
ls -l "$(grep DB_DATABASE .env | cut -d= -f2)"   # taille de la base SQLite
```

`pdo_mysql` et `mysqldump` sont bloquants pour cette bascule. `intl` ne l'est pas ici,
mais l'est pour le back-office Filament (Lot 3) : autant le traiter dans la même
intervention.

Volumétrie et montants non entiers, pour dimensionner la fenêtre :

```bash
<BINAIRE_PHP> artisan tinker --execute="\
  echo DB::table('product_helper_results')->count(), ' lignes, ', \
       DB::table('product_helper_results')->whereRaw('total != cast(total as integer)')->count(), \
       ' montants non entiers', PHP_EOL;"
```

---

## 0. Quel moteur demander à l'hébergeur — à trancher AVANT l'installation

Sur Debian 12, « installer MySQL » depuis les dépôts officiels donne **MariaDB** :
`default-mysql-server` pointe sur `mariadb-server`, et il n'existe pas de paquet
`mysql-server` officiel. Ce n'est pas un détail de nommage, les deux moteurs divergent.

**Recommandation : Oracle MySQL 8.** C'est ce contre quoi tout le chantier a été
développé et testé (186 tests verts). MariaDB fonctionne, mais avec les écarts mesurés
ci-dessous.

```bash
# ---- Oracle MySQL 8 (recommandé) : dépôt officiel MySQL, absent de Debian
curl -fsSLO https://dev.mysql.com/get/mysql-apt-config_0.8.34-1_all.deb
sudo dpkg -i mysql-apt-config_0.8.34-1_all.deb    # choisir MySQL 8.0 (ou 8.4 LTS)
sudo apt-get update && sudo apt-get install mysql-server mysql-client

# ---- MariaDB (défaut Debian), si l'hébergeur ne peut pas faire autrement
sudo apt-get install mariadb-server mariadb-client
```

Le **client doit être de la même famille que le serveur**. Un `mysqldump` de MySQL 8
pointé sur un serveur MariaDB échoue en code 2 (`Unknown table 'COLUMN_STATISTICS'`),
et inversement `mariadb-dump` refuse `--set-gtid-purged` en code 7. `db:backup` détecte
désormais la variante et adapte ses options, mais l'incohérence reste une source
d'ennuis à éviter.

### Écarts mesurés — MySQL 8.0.32 contre MariaDB 11.8.6

Suite `tests/Database/` (43 cas) rejouée à l'identique sur les deux moteurs :

| | MySQL 8.0 | MariaDB 11.8 |
|---|---|---|
| Résultat | **43/43** | **36/43** |

Ce qui passe des deux côtés, et qui constitue l'essentiel de la bascule : `DECIMAL(12,2)`
et l'arrondi du total (bug D1), le refus en `sql_mode` strict d'un montant hors capacité
et d'une chaîne trop longue, `utf8mb4` et les émoji, les contraintes d'unicité et les
clés étrangères, le rollback des migrations, le transfert `db:migrate-legacy-sqlite` avec
sa vérification, le parcours de devis complet, **et la sauvegarde avec restauration
réelle**.

Les 7 écarts tiennent tous au **type JSON**, que MariaDB n'implémente pas nativement —
`JSON` y est un alias de `LONGTEXT` assorti d'une contrainte `CHECK (json_valid(…))` :

| Écart | Portée |
|---|---|
| `information_schema` annonce `longtext` et non `json` (4 cas) | assertions de schéma seulement |
| MariaDB ne réécrit pas le JSON stocké (2 cas) | assertions seulement — et c'est plutôt une bonne nouvelle : l'empreinte `ksort` de `--verify` devient superflue, sans nuire |
| `whereJsonContains()` ne remonte rien | **écart fonctionnel réel** |

Le seul point à conséquence est le dernier. Précision utile : `where('user->lastname', …)`
**fonctionne** sur MariaDB ; c'est `JSON_CONTAINS` avec un objet candidat dans un tableau
qui ne correspond pas. Aucun chemin applicatif actuel ne l'utilise — seule la recherche
de devis du futur back-office s'appuierait dessus, et devrait alors être écrite
autrement.

Le rejet d'un JSON invalide à l'écriture, lui, fonctionne des deux côtés : sur MariaDB
c'est la contrainte `CHECK` que Laravel pose avec `$table->json()` qui s'en charge.

**Si le choix se porte sur MariaDB**, à faire avant la bascule : rendre les 6 assertions
de schéma conditionnelles au moteur, et rejouer la suite. Compter une demi-journée.

---

## 1. Sauvegarder la base actuelle (SQLite)

Rien ne commence avant ceci. Une copie de fichier suffit, à condition que l'application
n'écrive pas pendant la copie — d'où le mode maintenance.

```bash
cd <CHEMIN_APP>
<BINAIRE_PHP> artisan down

mkdir -p storage/app/backups/manual
cp "<SQLITE_PROD>" "storage/app/backups/manual/sqlite-avant-bascule-$(date +%F_%H%M%S).sqlite"
ls -lh storage/app/backups/manual/
```

Le sous-dossier `manual/` n'est **jamais** purgé par la rétention de `db:backup`
(cf. `doc/backups.md`). C'est fait pour : cette copie doit survivre à tout.

## 2. Créer la base et l'utilisateur MySQL

```bash
sudo mysql <<'SQL'
CREATE DATABASE mostiglass CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'mostiglass'@'localhost' IDENTIFIED BY '<DB_PASS>';
GRANT ALL PRIVILEGES ON mostiglass.* TO 'mostiglass'@'localhost';
FLUSH PRIVILEGES;
SQL
```

`utf8mb4` et non `utf8` : `utf8` est un alias d'`utf8mb3`, qui ne code pas les émoji sur
4 octets. Les messages clients en contiennent (le jeu de tests le vérifie).

`GRANT ALL … ON mostiglass.*` et non `ALL PRIVILEGES ON *.*` : `db:backup` est
volontairement construite pour ne pas exiger de privilège global (`--no-tablespaces`
évite `PROCESS`, et il n'y a ni routine ni trigger à sauvegarder).

Vérifier le `sql_mode` du serveur :

```bash
mysql -u mostiglass -p -e "SELECT @@GLOBAL.sql_mode;"
```

`STRICT_TRANS_TABLES` doit y figurer. Laravel le force de toute façon au niveau de la
session (`'strict' => true` dans `config/database.php`), mais un serveur laxiste
laisserait passer un import fait à la main.

## 3. Configurer `.env`

```dotenv
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mostiglass
DB_USERNAME=mostiglass
DB_PASSWORD=<DB_PASS>

# Chemin ABSOLU de l'ancienne base, lu uniquement par db:migrate-legacy-sqlite.
DB_LEGACY_SQLITE_DATABASE=<SQLITE_PROD>
```

`DB_LEGACY_SQLITE_DATABASE` a sa propre variable, volontairement : la connexion
`sqlite` lit `DB_DATABASE`, qui vaut maintenant `mostiglass` — elle chercherait donc un
*fichier* nommé « mostiglass ».

```bash
<BINAIRE_PHP> artisan config:clear
<BINAIRE_PHP> artisan tinker --execute="echo DB::connection()->getPdo() ? 'MySQL OK' : '', PHP_EOL;"
```

## 4. Créer le schéma

```bash
<BINAIRE_PHP> artisan migrate --force
<BINAIRE_PHP> artisan migrate:status
```

## 5. Transférer les données

Jamais `sqlite3 .dump | mysql` : les cinq pièges sont détaillés en tête de
`app/Console/Commands/MigrateLegacySqlite.php`, et l'un d'eux (les apostrophes dans les
colonnes JSON) suffit à interrompre l'import au milieu.

```bash
# 1) Diagnostic — n'écrit rien. Doit afficher « JSON source : 100 % décodable ».
<BINAIRE_PHP> artisan db:migrate-legacy-sqlite --dry-run

# 2) Transfert réel, en transaction, id et horodatages préservés.
<BINAIRE_PHP> artisan db:migrate-legacy-sqlite

# 3) Contre-vérification indépendante.
<BINAIRE_PHP> artisan db:migrate-legacy-sqlite --verify
```

L'étape 3 doit afficher :

```
VÉRIFICATION OK : comptages, ids, structures JSON, montants et horodatages identiques.
```

Si le dry-run signale un JSON illisible, **rien n'a été écrit** : corriger la ligne en
source (ou l'accepter comme perdue) avant de relancer. Pour repartir de zéro après un
transfert partiel : `--truncate`.

## 6. Recette applicative

```bash
<BINAIRE_PHP> artisan config:cache && <BINAIRE_PHP> artisan route:cache && <BINAIRE_PHP> artisan view:cache
<BINAIRE_PHP> artisan up
```

À vérifier à la main, dans cet ordre :

1. Page d'accueil et une page produit — 200.
2. Une URL inexistante — vraie page 404, **pas** une redirection 301 vers l'accueil.
3. Configurateur : ajouter un produit, demander un devis, envoyer.
   - message de succès affiché ;
   - `SELECT id, total, created_at FROM product_helper_results ORDER BY id DESC LIMIT 1;`
     → le montant doit avoir **deux décimales exactes** ;
   - mail de devis reçu ;
   - devis présent dans Dolibarr.
4. Un devis avec accents et émoji dans le message → relire la ligne, tout doit être intact.
5. `<BINAIRE_PHP> artisan db:backup` → doit finir sur « Sauvegarde OK ».

## 7. Retour arrière

Tant que `.env` n'est pas re-basculé, la base SQLite est intacte : le rollback consiste
à remettre les anciennes valeurs et à vider les caches.

```bash
cd <CHEMIN_APP>
<BINAIRE_PHP> artisan down
# .env : DB_CONNECTION=sqlite et DB_DATABASE=<SQLITE_PROD>
<BINAIRE_PHP> artisan config:clear && <BINAIRE_PHP> artisan config:cache
<BINAIRE_PHP> artisan up
```

Si des devis ont été saisis après la bascule, ils sont dans MySQL et **pas** dans
SQLite : les récupérer avant de revenir en arrière.

---

## Ce que MySQL refuse et que SQLite acceptait

Cette section n'est pas théorique : chaque point correspond à un test de
`tests/Database/`, exécutable sur un vrai serveur (cf. « Suite de tests » ci-dessous).

| Sujet | SQLite | MySQL en `sql_mode` strict | Test |
|---|---|---|---|
| `total` float dans `DECIMAL(12,2)` | stocke `1234.5599999999999` | erreur ou troncature | `StrictModeTest` |
| `total` hors capacité de la colonne | accepté | erreur 1264 | `StrictModeTest` |
| chaîne plus longue que le `varchar` | acceptée entière | erreur 1406 | `StrictModeTest` |
| JSON invalide | accepté (c'est du TEXT) | erreur 3140 à l'écriture | `JsonColumnTest` |
| ordre des clés JSON | conservé | **réécrit** par le serveur | `JsonColumnTest` |
| émoji (4 octets) | OK | OK en `utf8mb4`, perdu en `utf8mb3` | `JsonColumnTest` |
| clés étrangères | selon `foreign_key_constraints` | toujours appliquées (InnoDB) | `BlogConstraintTest` |
| DDL dans une transaction | transactionnel | **commit implicite** | cf. ci-dessous |

Le réordonnancement des clés JSON est le piège le plus coûteux : il rend toute
comparaison octet à octet entre source et cible fausse. C'est pourquoi `--verify`
compare des empreintes `ksort` récursives et non les chaînes.

Le commit implicite sur DDL a mordu la suite de tests elle-même : un `Schema::drop()`
dans un test refermait la transaction de `RefreshDatabase`, faisant échouer le rollback
(« 1305 SAVEPOINT trans2 does not exist ») et emportant les tests suivants. Invisible
sous SQLite, dont le DDL est transactionnel. À garder en tête pour toute migration qui
mélangerait DDL et écritures de données.

---

## Suite de tests sur MySQL

SQLite en mémoire reste la suite de développement : 2 secondes, aucun service à
démarrer. Mais elle ne peut rien dire des lignes du tableau ci-dessus.
`phpunit.mysql.xml` rejoue donc **toute** la suite sur un vrai MySQL, plus la suite
`Database` qui ne teste que des comportements propres à MySQL.

```bash
# En local : le conteneur mysql du docker-compose.yml, vu depuis l'hôte (port 3308)
docker compose up -d mysql
php artisan test -c phpunit.mysql.xml

# Depuis le conteneur applicatif
DB_HOST=mysql DB_PORT=3306 sail test -c phpunit.mysql.xml

# Sur le serveur, avant la bascule (base de test dédiée, jamais la base applicative)
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS mostiglass_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
DB_DATABASE=mostiglass_test <BINAIRE_PHP> artisan test -c phpunit.mysql.xml
```

Les variables `DB_*` de `phpunit.mysql.xml` ne sont pas forcées : une variable déjà
présente dans l'environnement l'emporte, ce qui permet de viser une autre instance sans
modifier le fichier.

**Garde-fou.** La suite utilise `RefreshDatabase`, qui vide la base ciblée.
`tests/TestCase.php` refuse donc de démarrer si le nom de la base ne contient pas
« test ». Un `DB_DATABASE` mal placé ne peut pas détruire la base applicative.

Les trois tests de `DatabaseBackupRoundTripTest` s'ignorent d'eux-mêmes si `mysqldump`
est absent de la machine. Ils doivent être exécutés au moins une fois sur le serveur,
où il est présent : ce sont eux qui prouvent qu'une archive se restaure vraiment
(cf. `doc/backups.md`).

---

Voir aussi : `doc/backups.md`, `doc/env.md`.
