Aller au contenu

Persistence (PostgreSQL via Prisma)

Schéma de données + conventions Prisma. Référence autoritative : apps/api/prisma/schema.prisma.

5.P.1 Schéma (vue d'ensemble)

erDiagram
    CATCHMENT ||--o{ STATION : contains
    STATION ||--o{ SENSOR : has
    SENSOR ||--o{ MEASUREMENT : produces
    STATION ||--o{ THRESHOLD : defines
    STATION ||--o{ ALERT : triggers
    STATION }o--o{ GLACIER : fed_by
    WITHDRAWAL }o--|| STATION : impacts
    INGESTION_RUN ||--o{ MEASUREMENT : traces

Tables du schéma :

  • Catchment — bassin versant (aujourd'hui borgne uniquement, extensible v2).
  • Station — 7 stations seedées : 4 LIVE (BAFU via LINDAS) + 3 RESEARCH (CREALP Borgne). Champs clés : id, ofevCode (unique), name, riverName, latitude, longitude, altitudeM, flowType (NATURAL | RESIDUAL | DOTATION), operatorName, dataSource (LIVE | RESEARCH | SEED), sourcingStatus (CONFIRMED | ILLUSTRATIVE, cf. ADR-008), isActive.
  • Sensor — un par combinaison {stationId, parameter}. Paramètres : DISCHARGE, WATER_LEVEL, TEMPERATURE (non-ingested v1), TURBIDITY (non-ingested v1).
  • Measurement — séries temporelles. Contrainte unique (sensorId, recordedAt) — socle de l'idempotence de l'upsert. Alimenté exclusivement par le cron LINDAS.
  • IngestionRun — trace chaque tick du cron. Champs : source, status (SUCCESS | FAILURE | PARTIAL), startedAt, completedAt, stationsSeenCount, measurementsCreatedCount (lignes réellement insérées via createMany), measurementsUnchangedCount (lignes ré-vues idempotentes — cf. D0), measurementsSkippedCount, errorMessage?, httpStatus?, payloadBytes?, payloadHash?, durationMs. Lu par /api/v1/status.
  • Threshold — seuils VIGILANCE / ALERT par station + paramètre (seedés en dur v1, pas d'admin UI).
  • Alert — épisodes d'anomalie. Schéma en place depuis l'init (2026-04-20), réactivé sans migration par la couche IA, extension B (ADR-012). Champs : stationId, type (STATISTICAL_ANOMALY | THRESHOLD_EXCEEDED | STATION_OFFLINE), level (INFO | VIGILANCE | ALERT), parameter, triggerValue, thresholdValue?, openedAt, closedAt? (null tant que l'épisode est ouvert), metadata (JSON). Invariant une seule alerte ouverte par {stationId, parameter} maintenu par alerts-service.ts (ouvre / met à jour en place / ferme). metadata porte les stats groundées du verdict : mean, std, z, sampleSize, hourBucket (heure UTC 0–23, clé du bucket déseasonnalisé B1-bis), bucketSampleSize (points même-heure ayant servi au μ/σ), windowFrom/windowTo. Lu par GET /alerts ; le COUNT des alertes ouvertes par station alimente activeAlertsCount sur /stations.
  • Glacier, StationGlacier — glaciers du bassin (Ferpècle, Mont Miné), jonction station ↔ glacier.
  • Withdrawal — captages Grande Dixence (Ferpècle 1896 m, Arolla 2009 m).
  • User, ThresholdAudit — schéma présent mais non utilisé v1 (admin JWT hors scope).
  • LlmCallRuncouche IA, extension D (ADR-012). Un enregistrement par appel LLM (succès/échec), écrit par le décorateur ObservingLlmClient : provider, model, operation, status (SUCCESS|ERROR), errorKind?, promptTokens?, completionTokens?, costUsd?, latencyMs, createdAt. Index @@index([createdAt desc]). Agrégé par GET /ai/status.
  • Insightcouche IA, extension A (ADR-012). Un enregistrement par narration LLM générée pour un couple {stationId, parameter, fenêtre, langue}. Champs : parameter, windowFrom, windowTo, language (défaut fr), text, provenance (provider, model, inputHash), métriques d'appel minimales (promptTokens?, completionTokens?, costUsd?, latencyMs?), generatedAt. Sert de cache idempotent via l'unique (stationId, parameter, windowFrom, windowTo, language, inputHash) : mêmes features groundées + version de prompt + langue ⇒ aucun rappel LLM. Calque la discipline de traçabilité d'IngestionRun.

5.P.2 Conventions

  • Cuids comme PK (id: String @id @default(cuid())). Pas d'UUID, pas d'auto-increment.
  • Timestamps systématiquescreatedAt: DateTime @default(now()) et updatedAt: DateTime @updatedAt sur les entités à cycle de vie.
  • Enums en SCREAMING_SNAKE_CASE — verbatim DTO front/back, pas de conversion casing. dataSource: 'LIVE', sourcingStatus: 'CONFIRMED', Parameter: 'DISCHARGE' cohabitent sans singularité à défendre.
  • Index composites — sur Measurement(sensorId, recordedAt DESC) pour les requêtes de série temporelle. Sur IngestionRun(startedAt DESC) pour /status. Sur Insight(stationId, parameter, generatedAt DESC) pour la dernière narration, + unique composite faisant office de clé de cache.
  • Pas de soft deleteisActive: boolean sur Station si besoin de masquer sans supprimer. Pas de deletedAt partout.

5.P.3 Migrations

  • Strategy additive uniquement en prod — jamais de DROP COLUMN, DROP TABLE, ALTER TYPE destructif en production. Les évolutions se font par migrations additives (ADD COLUMN NOT NULL DEFAULT) puis backfill puis nettoyage différé si nécessaire.
  • NommageYYYYMMDDHHMMSS_description_snake_case généré par prisma migrate dev --name description-kebab-case. Exemple : 20260422141207_add_station_sourcing_status.
  • Historique versionné — toutes les migrations vivent dans apps/api/prisma/migrations/ et sont committées. Prisma les replay via prisma migrate deploy dans entrypoint.sh au boot.
  • Pas de prisma migrate reset en prod — destructeur, effacerait toutes les données. Interdit par convention d'ops (cf. post-mortem 2026-04-21 pour le contexte).

5.P.4 Seed idempotent

apps/api/prisma/seed.ts — alimenté via upsert sur clés naturelles stables :

  • Catchment upserted sur id.
  • Station upserted sur ofevCode (unique).
  • Sensor upserted sur (stationId, parameter).
  • Threshold upserted sur (stationId, parameter).
  • Glacier upserted sur name.
  • StationGlacier upserted sur (stationId, glacierId).
  • Withdrawal upserted sur name.

pruneStaleStations(currentOfevCodes) supprime les Station dont ofevCode n'est pas dans la liste seed actuelle — cascade Measurement, Alert, Threshold, Sensor, StationGlacier. C'est l'opération qui aurait pu causer l'incident 2026-04-21 si un seed stale avait été lancé contre prod (non prouvé, cf. post-mortem).

Le seed ne touche jamais Measurement, Alert, IngestionRun, Insight, LlmCallRun — tables opérationnelles, écrites exclusivement par le cron (Measurement / IngestionRun) ou la couche IA (Insight / LlmCallRun ; Alert via le scan d'anomalie post-ingestion, ADR-012 B).

5.P.5 Observabilité côté DB

  • /api/v1/health exécute un SELECT 1 via Prisma. Retour database: 'ok' ou 'error'.
  • /api/v1/status lit le dernier IngestionRun (tri startedAt DESC LIMIT 1) et calcule lastSuccessAt (tri status = 'SUCCESS' ORDER BY startedAt DESC LIMIT 1). Compteurs journée via agrégat COUNT + SUM sur IngestionRun depuis minuit UTC.
  • Pas de métrique Prometheus exposée — reportée post-candidature. Pino JSON stdout suffit pour agréger à la main si besoin.