diff --git a/apps/api/migrations/019_status_constraints_and_retention.sql b/apps/api/migrations/019_status_constraints_and_retention.sql new file mode 100644 index 0000000..2a1b108 --- /dev/null +++ b/apps/api/migrations/019_status_constraints_and_retention.sql @@ -0,0 +1,93 @@ +-- 019_status_constraints_and_retention.sql +-- +-- A) Contraintes de domaine sur les statuts +-- +-- Dix-huit migrations, zéro CHECK. crawl_run.status, job_triggers.job_type et +-- band_locations.geocode_status sont du texte libre dont les valeurs admises ne +-- vivaient que dans des commentaires. C'est le dénominateur commun de plusieurs +-- bugs de ce dépôt : un statut 'error' que plus personne n'écrivait mais qu'un +-- bouton d'admin ciblait encore, des lignes 'processing' qu'aucune requête ne +-- reprenait, un job_type réclamé par le mauvais daemon. Une faute de frappe +-- écrivait une ligne durablement invisible, sans jamais rien faire échouer. +-- +-- Les valeurs sont relevées sur le code qui écrit réellement ces colonnes +-- (crawler/src/db.py, geocoder/src/*.py, api/src/adminRoutes.js). +-- +-- NOT VALID est délibéré et DÉFINITIF ici : la contrainte s'applique à toutes +-- les écritures futures, mais l'historique n'est pas relu. Cette migration +-- s'exécute au démarrage de chaque conteneur API ; valider exigerait un scan +-- complet, et une seule ligne héritée hors domaine ferait échouer la migration, +-- donc le démarrage de l'API. Le but est d'arrêter la dérive, pas de réécrire +-- le passé. Pour valider un jour, à froid : +-- ALTER TABLE crawl_run VALIDATE CONSTRAINT crawl_run_status_check; + +ALTER TABLE crawl_run DROP CONSTRAINT IF EXISTS crawl_run_status_check; +ALTER TABLE crawl_run ADD CONSTRAINT crawl_run_status_check + CHECK (status IN ('running', 'done', 'error', 'cancelled')) NOT VALID; + +ALTER TABLE job_triggers DROP CONSTRAINT IF EXISTS job_triggers_status_check; +ALTER TABLE job_triggers ADD CONSTRAINT job_triggers_status_check + CHECK (status IN ('pending', 'running', 'done', 'error', 'cancelled')) NOT VALID; + +ALTER TABLE job_triggers DROP CONSTRAINT IF EXISTS job_triggers_type_check; +ALTER TABLE job_triggers ADD CONSTRAINT job_triggers_type_check + CHECK (job_type IN ('enrich', 'incremental', 'full_crawl', 'geocoder_enqueue')) NOT VALID; + +ALTER TABLE band_locations DROP CONSTRAINT IF EXISTS band_locations_geocode_status_check; +ALTER TABLE band_locations ADD CONSTRAINT band_locations_geocode_status_check + CHECK (geocode_status IN ( + 'queued', 'processing', 'done', 'country_only', 'error', 'llm_needed', 'manual' + )) NOT VALID; + +-- ------------------------------------------------------------------ +-- B) Table morte +-- +-- geocode_queue date du pipeline Nominatim, remplacé par band_locations. Plus +-- aucune ligne de code ne la référence ; elle ne subsistait que dans le schéma. +-- ------------------------------------------------------------------ +DROP TABLE IF EXISTS geocode_queue; + +-- ------------------------------------------------------------------ +-- C) Rétention +-- +-- crawl_log, admin_audit_log et admin_login_attempts grossissent sans fin. La +-- migration 015 avait pourtant explicitement conçu service_health pour éviter +-- « une table qui grossit » — la leçon n'avait pas été appliquée aux autres. +-- crawl_log prend une ligne par échec d'enrichissement, et GET /admin/api/activity +-- relit admin_audit_log en entier à chaque affichage. +-- +-- La purge n'est pas automatique (pas de pg_cron ici) : elle est exposée par +-- l'API, qui l'appelle au démarrage. Les durées sont volontairement longues, +-- l'objectif est de borner la croissance, pas d'effacer l'historique utile. +-- ------------------------------------------------------------------ +CREATE OR REPLACE FUNCTION purge_historique( + jours_logs INT DEFAULT 90, + jours_audit INT DEFAULT 365, + jours_tentatives INT DEFAULT 30 +) RETURNS TABLE (table_purgee TEXT, lignes BIGINT) AS $$ +BEGIN + RETURN QUERY + WITH d AS ( + DELETE FROM crawl_log WHERE created_at < now() - make_interval(days => jours_logs) + RETURNING 1 + ) SELECT 'crawl_log'::TEXT, count(*) FROM d; + + RETURN QUERY + WITH d AS ( + DELETE FROM admin_audit_log WHERE created_at < now() - make_interval(days => jours_audit) + RETURNING 1 + ) SELECT 'admin_audit_log'::TEXT, count(*) FROM d; + + RETURN QUERY + WITH d AS ( + DELETE FROM admin_login_attempts WHERE created_at < now() - make_interval(days => jours_tentatives) + RETURNING 1 + ) SELECT 'admin_login_attempts'::TEXT, count(*) FROM d; +END; +$$ LANGUAGE plpgsql; + +-- Index manquant : la purge et le filtre `since` de /admin/api/logs balayaient +-- crawl_log en entier faute d'index sur created_at seul (celui de la 006 est +-- DESC, utilisable, mais on garde l'intention explicite ici). +CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_created ON admin_login_attempts (created_at); +CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log (created_at); diff --git a/apps/api/src/migrate.js b/apps/api/src/migrate.js index ed54764..6833f98 100644 --- a/apps/api/src/migrate.js +++ b/apps/api/src/migrate.js @@ -78,6 +78,20 @@ async function run() { } if (count === 0) console.log('[migrate] nothing to apply'); + + // Purge de rétention (migration 019). Bornée par des index sur created_at, et + // volontairement tolérante : ce nettoyage ne doit jamais empêcher l'API de + // démarrer. Il n'y a pas d'ordonnanceur ici, et ce script tourne au démarrage + // de chaque conteneur — c'est le seul point d'accroche périodique disponible. + try { + const { rows: purged } = await client.query('SELECT * FROM purge_historique()'); + const total = purged.reduce((n, r) => n + Number(r.lignes), 0); + if (total > 0) { + console.log('[migrate] purge: ' + purged.map(r => `${r.table_purgee}=${r.lignes}`).join(' ')); + } + } catch (err) { + console.warn(`[migrate] purge ignorée: ${err.message}`); + } // 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]);