import pg from 'pg'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_DIR = path.join(__dirname, '../migrations'); async function connectWithRetry(connStr, maxAttempts = 12) { for (let i = 1; i <= maxAttempts; i++) { const client = new pg.Client({ connectionString: connStr }); try { await client.connect(); return client; } catch (err) { await client.end().catch(() => {}); if (i === maxAttempts) throw err; const delay = Math.min(2000 * i, 30000); console.log(`[migrate] DB not ready (attempt ${i}/${maxAttempts}), retry in ${delay}ms — ${err.message}`); await new Promise(r => setTimeout(r, delay)); } } } // Identifiant arbitraire mais STABLE du verrou consultatif : deux processus qui // migrent la même base doivent choisir le même nombre. const MIGRATION_LOCK_ID = 4_073_219_001; async function run() { const client = await connectWithRetry(process.env.DATABASE_URL); // Verrou consultatif : le conteneur API exécute ce script à CHAQUE démarrage. // Deux réplicas qui démarrent ensemble lisaient tous deux schema_migrations // vide et appliquaient le même fichier en parallèle — au mieux une erreur au // second (clé primaire dupliquée) qui faisait échouer le démarrage, au pire // deux ALTER concurrents. Le verrou sérialise ; il est relâché à la // fermeture de la connexion, y compris si le process est tué. console.log('[migrate] acquisition du verrou…'); await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_ID]); await client.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `); const { rows } = await client.query( 'SELECT version FROM schema_migrations ORDER BY version' ); const applied = new Set(rows.map(r => r.version)); const files = fs.readdirSync(MIGRATIONS_DIR) .filter(f => f.endsWith('.sql')) .sort(); let count = 0; for (const file of files) { if (applied.has(file)) continue; console.log(`[migrate] applying ${file}`); const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); await client.query('BEGIN'); try { await client.query(sql); await client.query( 'INSERT INTO schema_migrations (version) VALUES ($1)', [file] ); await client.query('COMMIT'); console.log(`[migrate] ok ${file}`); count++; } catch (err) { await client.query('ROLLBACK'); console.error(`[migrate] FAILED ${file}: ${err.message}`); throw err; } } if (count === 0) console.log('[migrate] nothing to apply'); // Relâché explicitement : la fermeture suffirait, mais l'expliciter rend le // verrou visible dans les logs et évite de le garder si end() traîne. await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]); await client.end(); } export { MIGRATION_LOCK_ID, run }; // N'exécute la migration que si le fichier est lancé directement : importé par // un test, il ne doit pas migrer la base au chargement. const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); if (invokedDirectly) { run().catch(err => { console.error('[migrate] fatal:', err.message); process.exit(1); }); }