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évaleursce que ça énonce
role.primarytrueidentité — 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.indextruecontrainte 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. reconstructSet peut 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 kindquery 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 :

  1. le filPOST /_fougere/call, JSON-RPC 2.0, method = "entity.op" ; voir Le gradient.
  2. la carterpc.discover rend l'enveloppe ci-dessus, x-fougere-version: 1.
  3. le vocabulaire d'erreur — un FougereError entier dans error.data, dont le code est un code sémantique ; l'entier -32000 reste 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.

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