Modèle de données
Les quatre tables du module, les migrations idempotentes, les règles de slug et de visibilité.
Le module crée jusqu'à quatre tables, toutes préfixées lrvo_posts_. Elles sont créées et migrées par npx lrvo-db init, qui appelle le createTables exporté par le module. Voir lrvo-db.
Création sensible à la configuration
Toutes les tables ne sont pas créées systématiquement :
| Table | Créée quand |
|---|---|
lrvo_posts_post | Toujours. |
lrvo_posts_taxonomy_term | Au moins un type déclare un champ taxonomy. |
lrvo_posts_post_taxonomy | Idem. |
lrvo_posts_relation | Au moins un type déclare un champ relation. |
Ajouter une taxonomie ou une relation à un type existant impose donc de relancer npx lrvo-db init. La commande est idempotente : elle saute les tables déjà présentes et n'altère aucune donnée.
lrvo_posts_post
Une seule table pour tous les types de contenu. Ce qui les distingue est la valeur de post_type et la forme du JSON de data.
| Colonne | Type | Rôle |
|---|---|---|
id | SERIAL PRIMARY KEY | Identifiant. |
post_type | VARCHAR(100) NOT NULL | Clé du type déclaré. |
slug | VARCHAR(500) NOT NULL | Segment d'URL, unique par type. |
data | JSONB NOT NULL DEFAULT '{}' | Valeurs des champs déclarés, plus template et seo. |
visibility | VARCHAR(20) NOT NULL DEFAULT 'public' | public ou private. |
parent_id | INTEGER REFERENCES lrvo_posts_post(id) ON DELETE SET NULL | Parent hiérarchique. |
sort_order | INTEGER NOT NULL DEFAULT 0 | Position dans la fratrie. |
created_at | TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP | Modifiable par un champ created. |
updated_at | TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP | Écrit à chaque mise à jour. |
Contrainte d'unicité : UNIQUE(post_type, slug).
Index : post_type, (post_type, slug), parent_id, (post_type, parent_id, sort_order), et un index GIN sur data qui rend interrogeables les valeurs des champs déclarés.
Ce qui vit dans data et ce qui n'y vit pas
Les champs de type slug, taxonomy, relation, parent, created, updated et visibility pilotent une colonne ou une table dédiée. Ils ne sont jamais écrits dans data.
Tous les autres champs déclarés y sont écrits sous leur nom, avec null quand la valeur est absente. Deux clés s'y ajoutent sans être déclarables :
template, l'identifiant du template de rendu,defaulten l'absence de choix ;seo, l'objet de métadonnées, forcé à un objet vide si la valeur soumise n'en est pas un.
Seules les valeurs saisies sont stockées dans seo. La méta description et l'image de partage dérivées du contenu n'y sont jamais écrites : elles sont recalculées à chaque rendu depuis les champs du post. Voir Repli SEO automatique.
À la lecture, le module aplatit data au premier niveau de l'objet post tout en conservant l'objet brut sous _data. Un post lu porte donc post.title et post._data.title.
Les champs média dans data
Un champ image ou thumbnail est écrit comme l'identifiant du média, ou null. Un champ gallery est écrit comme un tableau ordonné d'identifiants, vide plutôt que null quand la galerie ne contient rien. C'est ce tableau qui porte l'ordre des images : la table de références du module Médias n'en garde pas trace.
Jamais d'URL ni de nom de fichier en base. À la lecture, chaque champ média est enrichi en { id, filename, alt_text, caption, mime_type } (un tableau de ces objets pour une galerie, dans le même ordre), en une seule requête pour tous les médias de tous les posts lus. Le texte alternatif et la légende sont donc toujours relus depuis la fiche du média, et une correction s'y propage partout.
Une référence dont le média a disparu apparaît comme { id, filename: null } au lieu de faire tomber la lecture.
lrvo_posts_taxonomy_term
| Colonne | Type | Rôle |
|---|---|---|
id | SERIAL PRIMARY KEY | Identifiant. |
post_type | VARCHAR(100) NOT NULL | Type auquel le terme appartient. |
taxonomy | VARCHAR(100) NOT NULL | Clé de la taxonomie, soit le nom du champ. |
title | VARCHAR(255) NOT NULL | Libellé du terme. |
description | TEXT | Facultative. |
is_active | BOOLEAN DEFAULT true | Un terme inactif disparaît des sélecteurs et de la route publique. |
created_at | TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP | |
updated_at | TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP |
Index sur (post_type, taxonomy) et sur is_active.
Aucune contrainte d'unicité sur le titre. Deux termes homonymes dans la même taxonomie sont acceptés par la base.
lrvo_posts_post_taxonomy
Table de liaison, plusieurs à plusieurs.
| Colonne | Type | Rôle |
|---|---|---|
id | SERIAL PRIMARY KEY | |
post_id | INTEGER NOT NULL REFERENCES lrvo_posts_post(id) ON DELETE CASCADE | |
taxonomy | VARCHAR(100) NOT NULL | Redondant avec le terme, sert au filtrage direct. |
term_id | INTEGER NOT NULL REFERENCES lrvo_posts_taxonomy_term(id) ON DELETE CASCADE |
Contrainte : UNIQUE(post_id, taxonomy, term_id).
Supprimer un post ou un terme retire les liaisons correspondantes par cascade. La suppression d'un terme encore utilisé est cependant refusée en amont, par le module. Voir Taxonomies et relations.
lrvo_posts_relation
| Colonne | Type | Rôle |
|---|---|---|
id | SERIAL PRIMARY KEY | |
post_id | INTEGER NOT NULL REFERENCES lrvo_posts_post(id) ON DELETE CASCADE | Post source. |
field_name | VARCHAR(100) NOT NULL | Nom du champ relation qui porte le lien. |
related_post_id | INTEGER NOT NULL REFERENCES lrvo_posts_post(id) ON DELETE CASCADE | Post cible. |
sort_order | INTEGER DEFAULT 0 | Position dans la sélection. |
Contrainte : UNIQUE(post_id, field_name, related_post_id). Un même post ne peut donc pas être lié deux fois par le même champ.
sort_order reçoit la position dans le tableau soumis, ce qui rend l'ordre de sélection significatif et durable.
Règles de slug
Le slug est dérivé du champ slug s'il est renseigné, du champ title sinon, et de l'identifiant du post en dernier recours, sous la forme post-<id>.
Ce dernier recours sert surtout aux types qui ne déclarent pas de champ title : ils n'ont aucune source textuelle dont tirer une URL. L'identifiant est réservé sur la séquence de la table avant l'insertion, ce qui permet de construire le slug définitif en une seule écriture. Le résultat reste modifiable à la main dans le formulaire.
La normalisation met en minuscules, retire les accents, remplace les espaces par des tirets, puis supprime purement tout caractère hors [a-z0-9-]. Les barres obliques, les points et les soulignés disparaissent donc au lieu d'être convertis. Une suite de tirets est ensuite réduite à un seul, et les tirets de tête et de queue sont retirés. Un titre entièrement composé de caractères non latins produit un slug vide, ramené lui aussi à post-<id>.
L'unicité est vérifiée sur le couple (type de post, slug). En cas de collision, le module suffixe -2, puis -3, jusqu'à trouver une valeur libre.
Il n'existe aucune liste de slugs réservés. Contrairement à @lrvo/module-pages, rien n'empêche un slug admin ou api, parce que l'URL publique d'un post est toujours préfixée par son type et ne peut donc pas entrer en collision avec une route du projet.
À la mise à jour, le slug n'est recalculé que si le champ est explicitement soumis. Modifier le titre d'un post publié ne change donc jamais son URL.
Visibilité
La colonne accepte public ou private. Toute autre valeur est rejetée avec Invalid visibility.
Le filtrage est appliqué à trois endroits, indépendamment :
- les routes publiques de liste et de détail ne renvoient que les posts publics ;
- le résolveur de rendu public refuse un post non public ;
- la résolution d'une URL imbriquée exige que chaque maillon de la chaîne soit public.
Un parent privé masque donc toute sa descendance, même publiée.
Migrations
Trois migrations idempotentes sont exécutées à chaque lrvo-db init, y compris sur une base déjà à jour.
| Migration | Effet |
|---|---|
Colonne visibility | ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'public'. |
Colonne parent_id | Ajoute la clé étrangère auto-référencée et son index. |
Colonne sort_order | Ajoute la colonne et l'index (post_type, parent_id, sort_order). |
Les deux dernières sont neutres pour un projet qui n'utilise ni hiérarchie ni ordre manuel : la colonne reste vide ou à zéro.
Migration de l'ancien schéma de catégories
Une quatrième migration se déclenche uniquement si la table lrvo_posts_category est encore présente. Elle transpose chaque catégorie en terme de taxonomie, reporte les classements des posts dans la table de liaison, supprime la colonne category_id, puis supprime l'ancienne table.
La taxonomie de destination est la première taxonomie déclarée sur le type concerné, ou categories si le type n'en déclare aucune. Un projet issu de cet ancien schéma a donc intérêt à déclarer ses taxonomies dans le bon ordre avant de lancer la migration.
Notes
dropTablessupprime les quatre tables plus l'ancienne table de catégories, dans un ordre qui respecte les clés étrangères.- Supprimer un post détache d'abord ses références média, parce que la table de références du module Médias interdit la suppression d'un média encore lié. Les fichiers eux-mêmes sont conservés.
- Retirer un type de la configuration ne supprime aucune ligne. Les posts de ce type deviennent simplement invisibles de l'admin et du rendu public.