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'huiborgneuniquement, 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 viacreateMany),measurementsUnchangedCount(lignes ré-vues idempotentes — cf. D0),measurementsSkippedCount,errorMessage?,httpStatus?,payloadBytes?,payloadHash?,durationMs. Lu par/api/v1/status.Threshold— seuilsVIGILANCE/ALERTpar 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 paralerts-service.ts(ouvre / met à jour en place / ferme).metadataporte 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 parGET /alerts; leCOUNTdes alertes ouvertes par station alimenteactiveAlertsCountsur/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).LlmCallRun— couche IA, extension D (ADR-012). Un enregistrement par appel LLM (succès/échec), écrit par le décorateurObservingLlmClient:provider,model,operation,status(SUCCESS|ERROR),errorKind?,promptTokens?,completionTokens?,costUsd?,latencyMs,createdAt. Index@@index([createdAt desc]). Agrégé parGET /ai/status.Insight— couche 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éfautfr),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ématiques —
createdAt: DateTime @default(now())etupdatedAt: DateTime @updatedAtsur 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. SurIngestionRun(startedAt DESC)pour/status. SurInsight(stationId, parameter, generatedAt DESC)pour la dernière narration, + unique composite faisant office de clé de cache. - Pas de soft delete —
isActive: booleansurStationsi besoin de masquer sans supprimer. Pas dedeletedAtpartout.
5.P.3 Migrations¶
- Strategy additive uniquement en prod — jamais de
DROP COLUMN,DROP TABLE,ALTER TYPEdestructif en production. Les évolutions se font par migrations additives (ADD COLUMN NOT NULL DEFAULT) puis backfill puis nettoyage différé si nécessaire. - Nommage —
YYYYMMDDHHMMSS_description_snake_casegénéré parprisma 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 viaprisma migrate deploydansentrypoint.shau boot. - Pas de
prisma migrate reseten 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 :
Catchmentupserted surid.Stationupserted surofevCode(unique).Sensorupserted sur(stationId, parameter).Thresholdupserted sur(stationId, parameter).Glacierupserted surname.StationGlacierupserted sur(stationId, glacierId).Withdrawalupserted surname.
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/healthexécute unSELECT 1via Prisma. Retourdatabase: 'ok'ou'error'./api/v1/statuslit le dernierIngestionRun(tristartedAt DESC LIMIT 1) et calculelastSuccessAt(tristatus = 'SUCCESS' ORDER BY startedAt DESC LIMIT 1). Compteurs journée via agrégatCOUNT+SUMsurIngestionRundepuis minuit UTC.- Pas de métrique Prometheus exposée — reportée post-candidature. Pino JSON stdout suffit pour agréger à la main si besoin.