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 :
| Helper | Type | Options |
|---|---|---|
text(opts?) | string | min, max, pattern, format, default |
email(opts?) | string | options de text sans format |
url(opts?) | string | options de text sans format |
number(opts?) | number | min, max, integer, default |
bool(opts?) | boolean | default |
date() | Date | — |
oneOf(...valeurs, opts?) | union de littéraux | default |
list(item, opts?) | T[] | item = n'importe quel champ |
Champs de rôle :
| Helper | Sens |
|---|---|
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 :
| Helper | Sens |
|---|---|
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) :
| Wrapper | Axe | Effet |
|---|---|---|
optional(f) | shape | peut être absent ; T | null |
nullable(f) | shape | peut être null, doit être présent |
immutable(f) | lifecycle | écrivable à la création, interdit en mise à jour |
readOnly(f) | boundary | ne traverse jamais vers l'intérieur — sortie seule |
writeOnly(f) | boundary | ne 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 champprimary()est déjà unique.
Les deux écritures, deux propriétaires
unique(slug) déclare une contrainte sur un champ ; unique: [['listId','docId']] en
déclare une sur plusieurs. Ce ne sont pas le même énoncé, donc ils ne vivent pas au même
endroit : une contrainte sur un champ appartient à ce champ, une contrainte couvrant
plusieurs champs appartient au schéma — elle n'est à aucun d'eux.
Au runtime, un champ porte role.unique, un booléen, lu par Role.of(field).isUnique. Un
groupe couvrant plusieurs champs est tenu une fois par le schéma, et getUnique() le rend.
La carte aplatit les deux sur les membres, parce qu'un lecteur dans un autre langage voit un champ à la fois et doit pouvoir reconstruire la contrainte depuis ce que ce champ porte :
// describe(ListBook) — le `role.unique` de chaque membre
"listId": { "role": { "unique": [["listId", "docId"]] } },
"docId": { "role": { "unique": [["listId", "docId"]] } }
Un champ appartenant à deux contraintes porte deux entrées. Chaque membre porte le groupe entier, ce qui permet de reconstruire une contrainte composée à partir de la seule carte.
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 :
| Axe | Question | Lu par |
|---|---|---|
| shape | quelles valeurs sont valides ? (le shape est du JSON Schema) | validation, formulaires, colonne SQL, scalar GraphQL |
| role | quel rôle joue-t-il ? (primary, ref, many, unique) | DDL (PK/FK, contraintes), relations, carte |
| lifecycle | qui l'écrit, quand ? (create: 'now' | 'optional' | {value} | {generate}, update: 'now' | 'forbidden') | réalisation storage, détente du constructeur |
| boundary | dans quel sens traverse-t-il l'API ? (readOnly, writeOnly) | Visibility.input / Visibility.output |
La façade valide l'entrée client avec les axes shape et boundary : écrire un champ
readOnly comme status est refusé. Le storage 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 fieldau 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.