Entités

Une entité est une classe qui étend la factory entity(). Elle fournit le type TypeScript, la validation et les métadonnées lues par les adapters.

import { entity, primary, text, ref, created, oneOf, date, readOnly, optional } from '@fougere/schema';
import Author from './Author.js';

export default class Post extends entity({
  id: primary(),
  slug: text({ min: 1, max: 80 }),
  title: text({ min: 1, max: 160 }),
  body: optional(text()),
  authorId: ref(Author),
  createdAt: created(),
  status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
  publishedAt: readOnly(optional(date())),
}) {}

Le nom de classe sert d'identité : Post.name nomme la table SQLite, le type GraphQL, la clé d'enregistrement (post) et le match DI. entity() ne prend pas de nom séparé.

Le vocabulaire des champs

Champs de valeur :

HelperTypeOptions
text(opts?)stringmin, max, pattern, format, default
email(opts?)stringoptions de text sans format
url(opts?)stringoptions de text sans format
number(opts?)numbermin, max, integer, default
bool(opts?)booleandefault
date()Date
oneOf(...valeurs, opts?)union de littérauxdefault
list(item, opts?)T[]item = n'importe quel champ

Champs de rôle :

HelperSens
primary()clé primaire, générée — s'applique aussi à un champ : primary(text())
ref(Entity)clé étrangère (string) ; accepte () => Entity pour les cycles
many(Entity)one-to-many — rôle seul, pas de colonne
unique(f)deux lignes ne portent pas la même valeur — une contrainte que la base applique
indexed(f)les lectures filtrent souvent dessus — émet CREATE INDEX, ne change aucune réponse

Les règles oneOf, min et max sont aussi émises en contraintes CHECK. Elles s'appliquent donc aux écritures qui ne passent pas par la façade, par exemple une requête SQL directe ou un autre processus. pattern et format restent validés à la façade, car les dialectes d'expressions régulières diffèrent selon les bases.

Certains faits portent sur une paire, pas sur un champ. « Un livre apparaît une fois dans une liste » est vrai de (listId, docId) et d'aucun des deux seul, donc il se déclare sur l'entité :

class ListBook extends entity({
  id: primary(),
  listId: ref(List),
  docId: text(),
}, {
  unique: [['listId', 'docId']],
}) {}

La base applique cette contrainte afin de couvrir aussi les écritures concurrentes. Une dérivation qui retire un membre du groupe retire également le groupe : conserver seulement (listId) changerait la règle déclarée.

Champs de lifecycle :

HelperSens
created()tamponné à la création (createdAt) — jamais écrit par un client
updated()re-tamponné à chaque mise à jour (updatedAt)

Wrappers (se composent autour de n'importe quel champ) :

WrapperAxeEffet
optional(f)shapepeut être absent ; T | null
nullable(f)shapepeut être null, doit être présent
immutable(f)lifecycleécrivable à la création, interdit en mise à jour
readOnly(f)boundaryne traverse jamais vers l'intérieur — sortie seule
writeOnly(f)boundaryne traverse jamais vers l'extérieur — entrée seule (mots de passe)

Les wrappers s'emboîtent : readOnly(optional(date())) est une date possédée par le serveur, possiblement absente.

Post.validate() ne voit que l'entrée courante et ne peut donc pas vérifier l'unicité parmi les lignes existantes. La base applique cette règle et renvoie alors une erreur de driver plutôt qu'une erreur de champ. Un champ primary() est déjà unique.

Les deux écritures, une seule forme

unique(slug) déclare une contrainte sur un champ ; unique: [['listId','docId']] en déclare une sur plusieurs champs. Dans les deux cas, la représentation interne est une liste de groupes de champs.

L'axe role de chaque champ contient les contraintes dont il est membre :

slug.role.unique   // [['slug']]                — l'ensemble d'un membre
listId.role.unique // [['listId', 'docId']]     — la paire, portée par chaque membre
docId.role.unique  // [['listId', 'docId']]

Un champ appartenant à deux contraintes porte deux entrées. Chaque membre contient le groupe entier, ce qui permet à un consommateur dans un autre langage de reconstruire une contrainte composée.

Une dérivation qui retire un membre retire le groupe : Post.pick('listId') ne conserve pas la contrainte composée.

Les quatre axes

Chaque champ porte quatre axes orthogonaux — c'est le modèle que tous les adapters lisent :

AxeQuestionLu par
shapequelles valeurs sont valides ? (le shape est du JSON Schema)validation, formulaires, colonne SQL, scalar GraphQL
rolequel rôle joue-t-il ? (primary, ref, many, unique)DDL (PK/FK, contraintes), relations, carte
lifecyclequi l'écrit, quand ? (auto, updated, immutable)réalisation ORM, détente du constructeur
boundarydans quel sens traverse-t-il l'API ? (readOnly, writeOnly)projections io inputFields / outputFields

La façade valide l'entrée client avec les axes shape et boundary : écrire un champ readOnly comme status est refusé. L'ORM applique ensuite l'axe lifecycle, par exemple en renseignant createdAt et les valeurs par défaut.

La validation refuse les clés inconnues avec Unknown field au lieu de les retirer. Une entrée acceptée correspond donc exactement au contrat (voir Handlers).

Entity.validate

Le moteur de validation est edge-safe (@cfworker/json-schema). La même fonction peut être utilisée dans le navigateur et dans le handler :

Post.validate(input)
// → { success: true, data }                                    valide
// → { success: false, errors: [{ path: 'title', message }] }   invalide, par champ

new Post(data) construit une instance sans validation. Les champs générés ou automatiques sont optionnels pour le constructeur, mais restent présents dans le type résultant.

Suite : Vues — dériver les contrats depuis le schéma.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.