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, 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 :
| 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 ? (auto, updated, immutable) | réalisation ORM, détente du constructeur |
| boundary | dans 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 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.