Skip to content

ADR 0001 — raw_data JSONB source de vérité + colonnes générées

Date : initial Statut : Accepté

Contexte

L'amont (Horizon) expose un endpoint /api/travels qui retourne des records voyage richement typés (~30-50 champs imbriqués, dont des collections comme occurrences[], travelRanges[], travelPictures[], minPrice{}). Le contrat de better-search est d'être drop-in compatible avec cet endpoint : le front consomme déjà la même forme JSON.

Deux options de modélisation côté Postgres :

  1. Schéma relationnel normalisé : une table par concept (travel, occurrence, travel_range, …) avec des FK. Reconstruction de la réponse JSON via aggregation JSON côté SQL ou côté Python.
  2. JSON document store : une table travels avec une colonne raw_data JSONB qui contient le record complet tel quel.

Décision

Option 2 — raw_data JSONB comme source de vérité unique, complétée par des colonnes générées (GENERATED ALWAYS AS … STORED) pour les champs indexables ou filtrables :

sql
CREATE TABLE travels (
    raw_data JSONB NOT NULL,
    embedding vector(3584) NOT NULL,
    id TEXT GENERATED ALWAYS AS (raw_data->>'id') STORED PRIMARY KEY,
    destination VARCHAR(2) GENERATED ALWAYS AS (raw_data->'country'->>'code') STORED,
    travel_ranges TEXT[] GENERATED ALWAYS AS (jsonb_to_text_array(raw_data, '$.travelRanges[*].slug')) STORED,
    departure_dates TIMESTAMP[] GENERATED ALWAYS AS (jsonb_to_timestamp_array(raw_data, '$.occurrences[*].start')) STORED,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('french', COALESCE(raw_data->>'name', '')), 'A') ||
        setweight(to_tsvector('french', COALESCE(raw_data->>'subtitle', '')), 'B') ||
        setweight(to_tsvector('french', COALESCE(raw_data->>'description', '')), 'C')
    ) STORED,
    -- + is_valid, discount_club, is_seaside, name
    updated_at TIMESTAMP DEFAULT NOW()
);

Conséquences

Positives

  • Réponse JSON triviale. SELECT raw_data FROM travels renvoie déjà la forme attendue par le front. Pas de mapping ORM, pas d'AutoMapper.
  • Une seule source de vérité. Pas de désynchro possible entre raw_data et les colonnes : Postgres recalcule les colonnes générées à chaque INSERT/UPDATE.
  • Schéma évolutif côté amont sans migration. Si Horizon ajoute un champ raw_data.foo, on l'a immédiatement dans le payload renvoyé au front. Pas besoin de toucher au schéma.
  • UPSERT idempotent. INSERT ... ON CONFLICT (id) DO UPDATE SET raw_data = EXCLUDED.raw_data recalcule tout. Pas de gestion fastidieuse d'updates partiels.
  • Filtres rapides. Les colonnes générées sont indexables (BTREE, GIN). Les filtres ?destination=, ?category=, ?dates= ne touchent pas le JSONB à l'exécution.
  • FTS et embeddings sur les bons champs. search_vector extrait pondéré nom/sous-titre/description directement depuis JSONB.

Négatives

  • Ajouter un filtre = migration SQL. Pas juste un changement applicatif. Voir modules/migrations.md pour le workflow.
  • Colonnes générées immutables. Modifier la formule (ex: changer la pondération FTS, changer la JSONPath de travel_ranges) demande un DROP COLUMN + recreation + reindex complet. Pas trivial.
  • Pas de validation typée par Postgres. Si Horizon retourne un bookingState en string au lieu d'int, la colonne générée is_valid ressort false silencieusement. La validation reste de la responsabilité de l'amont.
  • JSONB lourd en stockage. Chaque record voyage = quelques Ko, dupliqué entre raw_data et chaque colonne générée. Acceptable à l'échelle actuelle (< 1000 records).
  • jsonb_to_*_array doivent rester IMMUTABLE. Toute modification de ces helpers casse les colonnes générées qui s'en servent.

Alternatives écartées

  • Schéma relationnel : on aurait du gain en validation et lisibilité, mais le coût en mapping JSON↔relations et en migrations est élevé pour un service de pure recherche dont la persistance est un cache de l'amont.
  • Champs duplicate-but-not-generated (colonne destination TEXT remplie par l'application) : abandonné, source de désynchro. Le GENERATED ALWAYS AS … STORED est une vraie killer feature de Postgres pour ce cas.
  • MongoDB / Elasticsearch : auraient résolu le problème document-oriented + recherche, mais (a) ajouterait un service à opérer, (b) on perdrait pgvector, (c) FTS français costaud est natif en Postgres.

Implications opérationnelles

  • Toute évolution du modèle Horizon qui change la JSONPath d'un champ filtré (country.code, travelRanges[*].slug, occurrences[*].start, occurrences[*].bookingState, minPrice.pricePerPersonWith…) casse silencieusement les filtres. Tests d'intégration sur tests/test_db.json détectent les régressions sur la forme.
  • Les colonnes générées sont visibles dans \d travels côté psql — utile au debug.
  • Le seul writer dans travels est src/reindex.py. Aucun autre code ne doit faire d'INSERT/UPDATE direct.

Contributors

No contributors

Changelog

No recent changes