Migrations — Flyway, forward-only, index manuels¶
La décision¶
Le schéma PostgreSQL est versionné en SQL pur avec Flyway (migration/flyway/V<YYYYMMDDHHMM>__description.sql), en forward-only : pas de down-migration. Les gros index sur tables chargées se créent à la main (CREATE INDEX CONCURRENTLY), hors Flyway.
Le contexte¶
Flyway a été choisi par le passé : il correspondait aux contraintes, il marche bien et il est fiable — les raisons précises du choix initial se sont perdues, mais rien ne motive d'en changer. L'historique pré-Flyway (199 fichiers numérotés) sert de baseline dans migration/legacy-before-flyway/. TypeORM est à synchronize: false et ne touche jamais au schéma ; les outils de migration Kysely ne sont pas utilisés — la migration est l'affaire du SQL, pas du runtime applicatif.
Pourquoi forward-only¶
Pas de down-migrations : ça simplifie énormément le process, et un rollback de schéma est de toute façon très pénible — et rarement testé honnêtement. En cas de problème, on corrige en avant avec une nouvelle migration. Flyway n'est invoqué qu'avec info / migrate / validate (flyway.sh).
Le runbook des index manuels¶
Le problème. CREATE INDEX bloque les écritures pendant la construction ; CREATE INDEX CONCURRENTLY ne bloque pas, mais PostgreSQL l'interdit dans une transaction — et Flyway exécute chaque migration en transaction. Sur les tables volumineuses (souvent les tables invest-facing : customers ~700k rows, wallet_transactions, bricks…), l'index passe donc hors Flyway.
Le process.
- Écrire le script dans
migration/manual/:CREATE INDEX CONCURRENTLY IF NOT EXISTS …, suivi d'unANALYZE <table>. Le fichier documente le contexte : requêtes ciblées et métriques avant/après — ex.portfolio-home-metrics-indexes-concurrent.sql(30 590 ms → 246 ms). - L'exécuter manuellement en production avant le déploiement du code qui s'appuie dessus.
IF NOT EXISTSrend le script idempotent : un environnement reconstruit peut le rejouer sans douleur.
Tables partitionnées. CREATE INDEX CONCURRENTLY ne s'applique pas à la table parente — il faut créer l'index sur chaque partition séparément. Exemple : bricks est partitionnée en bricks_not_primary_available, bricks_primary_available, etc. Le script portfolio-home-metrics-indexes-concurrent.sql montre le pattern (un CREATE INDEX CONCURRENTLY par partition, puis ANALYZE sur la table parente).
Enfiler un job au déploiement¶
Le problème. Un changement de schéma peut laisser des données vides ou obsolètes jusqu'au prochain cron planifié (souvent la nuit). Exemple : nouvelles colonnes sur leaderboard_investor_live à 0 jusqu'au refresh de 3h UTC.
La solution. Une migration Flyway peut enfiler un run unique via graphile_worker.add_job, avec un run_at proche du déploiement :
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'graphile_worker') THEN
PERFORM graphile_worker.add_job(
identifier := 'refresh-leaderboard',
payload := '{}'::json,
queue_name := 'refresh-leaderboard-queue',
run_at := now() + interval '5 minutes',
job_key := 'refresh-leaderboard-initial-deploy'
);
END IF;
END $$;
Les invariants.
| Point | Pourquoi |
|---|---|
Garde graphile_worker |
Le schéma n'existe pas sur une DB fraîche (CI, nouvel env) — le cron planifié rattrapera |
run_at décalé (5–15 min) |
Laisse le worker finir son rollout et enregistrer la task avant exécution |
job_key unique |
Déduplication — un seul run par déploiement |
| Migration après le DDL concerné | La table/colonnes doivent exister avant que le job ne tourne |
Quand l'utiliser : backfill déclenché par un changement de schéma, dépendant d'un worker déjà déployé — pas pour remplacer un cron récurrent.
Quand ne pas l'utiliser : logique métier complexe (→ service ou cron dédié) ; sur un env sans worker, la garde sur le schéma suffit — le cron planifié rattrapera.
Exemples en prod :
V202606181500__schedule_initial_leaderboard_refresh.sql— premier peuplement deleaderboard_investor_liveV202606221300__schedule_leaderboard_refresh_contract_split.sql— backfill après ajout de colonnes splitV202606221401__schedule_leaderboard_refresh_per_investor.sql— repeuplement après changement de schéma de métriques
sequenceDiagram
participant CI as Deploy_Prod
participant Flyway
participant DB as PostgreSQL
participant Worker as Graphile_Worker
CI->>Flyway: migrate
Flyway->>DB: DDL + add_job run_at=now+5min
CI->>Worker: rollout nouvelle version
Worker->>DB: execute job refresh-leaderboard
Où et quand Flyway s'exécute¶
| Contexte | Mécanisme |
|---|---|
| Production | api-deployToProd.yml : check-flyway-migrations (info) → db-migration-flyway (migrate, seulement si pending) → validate-migrations. Le check fail si Flyway marque une versioned migration Ignored (out-of-order) — sinon migrate/validate sont skippés et le code part sans le schéma |
| Dev | api-deployToDev.yml : migrate toujours — échoue déjà sur out-of-order (Detected resolved migration not applied) |
| Tests d'intégration | Service Flyway du docker-compose : snapshot baseline + migrations delta (guide) |
| Dev local | Branches Neon personnelles via Doppler — pas de Postgres local |
Out-of-order. Un timestamp de migration plus ancien que le max déjà appliqué en DB → Flyway ne l'applique pas. Fix : renommer le fichier avec une version strictement après le max de __flyway_schema_history__ (contenu inchangé), uniquement si l'ancienne version n'a jamais été appliquée. Jamais outOfOrder=true en CI permanente.
Migrate before traffic. En prod, Flyway tourne avant le deploy du code : le nouvel app ne sert jamais de traffic contre un schéma pré-migration. Conséquence : pas de dual-read / dual-write « pour la race de deploy » sur un rename ou une migration JSON — le code peut assumer que la migration a déjà run.
Les types suivent à la main¶
Une migration qui touche une table de la stack Kysely implique, dans la même PR : le SQL Flyway, le schema zod (__new/lib/kysely/schemas/) et le type Database. Pas de codegen (pourquoi) — la discipline est le mécanisme de sync, et ky_parse* attrape le drift au runtime.