La carte d'identité
Une classe TypeScript ne peut pas être sérialisée directement. Sa carte est un document JSON qui décrit le schéma et permet à un consommateur de reconstruire une classe de validation.
Les deux sont duales. ~standard remet le juge
vivant à une bibliothèque du même processus ; la carte est la description
sérialisable de la même déclaration, pour les échanges entre processus ou langages.
import { describe, reconstruct } from '@fougere/schema';
const card = describe(Post); // schéma → document JSON
const Rebuilt = reconstruct(card); // document → schéma vivant (validate + from)
describe produit le document canonique utilisé par les adapters. reconstruct rebâtit
le schéma chez le consommateur, qui peut ensuite valider les données localement.
Énoncez la forme d'une ligne et le schéma rebâti est une classe au sens plein — un seul
nom porte le juge et le type de ce qu'il juge, exactement comme class Post extends entity({…}) {}. C'est ce qu'écrit fougere sync :
class Post extends reconstruct<{ id: string; title: string }>(card) {}
La structure
La carte est un document JSON Schema valide. L'axe shape est ce vocabulaire, donc
ses mots-clés vivent au niveau supérieur du champ ; les trois autres axes vivent sous la
clé d'extension x-fougere.
{
"title": "post",
"type": "object",
"properties": {
"id": {
"type": "string",
"x-fougere": {
"role": { "primary": true },
"lifecycle": { "create": { "generate": "cuid2" } }
}
},
"title": { "type": "string", "minLength": 3, "maxLength": 120 },
"status": { "type": "string", "enum": ["draft", "published"] },
"publishedAt": {
"type": ["string", "null"],
"format": "date-time",
"x-fougere": { "lifecycle": { "update": "forbidden" } }
},
"secret": { "type": "string", "x-fougere": { "boundary": { "out": "closed" } } }
},
"required": ["title", "status"],
"x-fougere-version": 1,
"x-fougere-vendor": "fougere"
}
Un champ nullable se replie dans l'union type: ["T", "null"] — la nullité est du shape,
donc du JSON Schema standard. Un objet embarqué (json(Entity)) porte ses properties et
son required imbriqués, eux aussi purement shape.
Les trois axes sous x-fougere
Les valeurs de x-fougere sont des noms et des structures JSON. Aucun descripteur séparé
n'est nécessaire.
| clé | valeurs | ce que ça énonce |
|---|---|---|
role.primary | true | identité — le système la possède |
role.unique | [["slug"], ["listId","docId"]] | les contraintes d'unicité dont ce champ est membre — une liste de membres par contrainte, nommés. Un champ seul est le cas dégénéré : une contrainte d'un membre. Le DDL les émet |
role.index | true | contrainte de stockage — le DDL l'émet |
role.relation | { to, kind: 'one'|'many', onDelete? } | to est un nom, pas un schéma inliné |
lifecycle.create | { value } | 'now' | { generate } | 'optional' | qui écrit à la création, et quand |
lifecycle.update | 'now' | 'forbidden' | qui écrit à la mise à jour |
boundary.in | 'closed' | { decode } | lecture seule, ou conversion entrante nommée |
boundary.out | 'closed' | { encode } | écriture seule, ou conversion sortante nommée |
Une clé absente vaut « ouvert » : pas de lifecycle.create signifie que l'appelant doit
fournir la valeur ; pas de boundary signifie identité dans les deux sens.
Seul un boundary explicitement déclaré est émis. Le comportement déduit du shape, par
exemple le décodage de format: date-time en Date, est recalculé à la reconstruction.
role.unique nomme toujours ses membres dans le document, y compris pour une contrainte
d'un seul champ ([["slug"]]). En mémoire, un unique() seul ne connaît pas encore sa clé ;
describe la résout depuis l'objet passé à entity({...}). Une contrainte composée comme
(listId, docId) apparaît en entier sur chacun de ses membres.
Sémantique de required
Dans une carte Fougere, required a une portée plus précise que dans JSON Schema seul.
required liste les noms qu'un appelant doit fournir à la création — ceux dont aucune
règle lifecycle.create ne répond de l'absence, et qui ne sont pas une relation many
(absente → []). C'est plus étroit que le required de JSON Schema, qui est
context-free et répond « qu'est-ce qui est toujours présent en lecture ? ».
Un champ nullable reste donc requis : l'appelant peut fournir null, mais ne peut pas
omettre la clé. Présence et nullité restent deux notions distinctes.
Le bundle — plusieurs entités, relations comprises
describeSet produit un document $defs autonome. Chaque entité utilise son nom en
minuscules, qui correspond à la valeur de relation.to.
const bundle = describeSet({ post: Post, author: Author });
const { post, author } = reconstructSet(bundle);
Une relation est un pointeur et non un sous-schéma intégré : Post → Author → Post produit
deux références plutôt qu'une imbrication récursive. reconstruct conserve les noms ;
reconstructSet les résout vers les entités cibles attendues par les adapters.
Limites de sérialisation
Une carte ne peut contenir que des données sérialisables :
- une relation devient un nom.
reconstructSetpeut résoudre le thunk() => Authorà l'intérieur d'un bundle. - un générateur ou une conversion custom est référencé par son nom, puis résolu dans le
registre du consommateur. Un nom inconnu produit une erreur locale
unknown generator.
Les axes n'acceptent donc pas de fermeture JavaScript, qui ne pourrait pas être sérialisée.
rpc.discover — la carte d'une app entière
Un hôte répond à l'opération réservée rpc.discover par ce qu'il héberge. Chaque frond
publie deux listes, et elles sont duales : ce qu'on peut appeler, et ce qui sort tout
seul.
{
"fronds": [{
"name": "blog",
"doors": [{
"name": "post",
"ops": [
{ "name": "list", "kind": "query", "output": { /* une carte */ } },
{ "name": "findById", "kind": "query", "output": { /* une carte */ } },
{ "name": "create", "kind": "command", "input": { /* une carte */ },
"output": { /* une carte */ } },
{ "name": "delete", "kind": "command" },
{ "name": "publish", "kind": "command", "description": "Rendre le post public." }
],
"schema": { /* une carte */ }
}],
"facts": [
{ "name": "postPublished", "schema": { /* une carte */ } }
]
}]
}
Une opération donne toujours son name et son kind — query lit, command écrit, la
même distinction que REST traduit en GET contre POST. Le reste apparaît quand il y a
quelque chose à dire : input et output quand le contrat nomme une vue, description
quand la méthode porte une phrase de doc. delete ci-dessus ne nomme aucune vue parce
qu'un booléen n'est pas une forme.
schema est optionnel dans les deux listes, et son absence ne dit pas la même chose. Une
porte sans schéma ne stocke rien — un contrôle de santé, un calcul, une recherche à
travers plusieurs formes. Un fait sans schéma annonce un type que l'hôte ne stocke pas.
Pourquoi les faits sont une liste à part
Héberger, c'est répondre : une entité sans façade est volontairement absente de doors,
sinon la carte publierait la forme de tables que personne ne peut atteindre — celles de
l'auth, typiquement.
Un fait est le cas inverse. Il ne porte pas d'opération non plus, mais quelqu'un a écrit
qu'il sort (Emit<PostPublished>) : publier sa forme honore une déclaration au lieu d'en
fuiter une. Sans cette liste, un fait s'arrêtait à la frontière du dépôt : la colocalisation
donne le contrat, remotes: ne donne que l'adresse, et entre deux dépôts il n'y a pas de
colocalisation. L'abonné devait recopier à la main la déclaration de l'émetteur.
facts est identique sur toutes les surfaces — une porte a un
public, un fait n'en a pas.
Le routeur de doublures ne lit que doors, et s'en sert pour associer les entités aux
adresses déclarées dans remotes. Un fait n'est pas routable : personne ne l'appelle, il
arrive.
Écrire un Frond ailleurs
Rien de tout cela ne suppose TypeScript. Un frond écrit dans un autre langage entre dans la topologie s'il honore trois choses :
- le fil —
POST /_fougere/call, JSON-RPC 2.0,method = "entity.op"; voir Le gradient. - la carte —
rpc.discoverrend l'enveloppe ci-dessus,x-fougere-version: 1. - le vocabulaire d'erreur — un
FougereErrorentier danserror.data, dont lecodeest un code sémantique ; l'entier-32000reste celui de la spec JSON-RPC.
Le consommateur reconstruit alors le schéma et valide les données localement avec les mêmes règles.
demos/rust-frond/ en est un, en Rust — une entité, trois opérations, aucune classe
entity({...}) nulle part.
Suite : Handlers — opérations, règles de binding, scoping de sortie.