Handlers

Un handler regroupe les opérations d'une entité. Crud(Entity) fournit les cinq opérations CRUD. Chaque méthode publique supplémentaire devient une opération.

import { Crud, FougereError, ErrorCode } from '@fougere/core';
import Post from '../entities/Post.js';
import User from '../../user/entities/User.js';

export default class PostHandler extends Crud(Post) {
  /** Lecture publique : publiés seulement. */
  async list(): Promise<PostCard[]> {
    const all = await this.storage.list();
    return all.filter((p) => p.status === 'published') /* … */;
  }

  /** La transition draft→published — une opération, pas une écriture de champ. */
  async publish(id: string, user?: User): Promise<Post> {
    if (!user) throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Connectez-vous pour publier', entity: 'post', operation: 'publish' });
    const post = await this.storage.findById(id);
    if (!post) throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' introuvable`, entity: 'post', operation: 'publish' });
    if (post.authorId !== user.id) throw new FougereError({ code: ErrorCode.FORBIDDEN, message: 'Seul l’auteur peut publier', entity: 'post', operation: 'publish' });
    if (post.status === 'published') throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Déjà publié', entity: 'post', operation: 'publish' });
    return this.storage.update(id, { status: 'published', publishedAt: new Date().toISOString() });
  }
}

Fougere n'impose pas de couche service. Un handler peut contenir directement la logique d'une opération ou déléguer à un service selon les besoins du domaine.

Dans un handler Crud(Entity), le this.storage hérité est alimenté par le repository de l'entité ; n'injectez pas vous-même Storage. Lectures : list(options?), findById(id), findBy(criteria), findAllBy(criteria). Écritures : create(input), update(id, input), delete(id). output(vue) rend un storage scopé aux champs d'une vue.

await this.storage.findBy({ slug });                    // la ligne qui correspond
await this.storage.findAllBy({ authorId: user.id });    // toutes celles qui correspondent
await this.storage.list({ where: { status: 'published' }, limit: 20, orderBy: 'createdAt' });

La façade valide l'entrée avant d'appeler la méthode. Le storage applique ensuite les règles de cycle de vie, comme les valeurs automatiques et les défauts. La valeur renvoyée par le handler est projetée et validée avant de quitter la façade.

Où vit une requête nommée

Storage est un port : cinq gestes génériques, aucun parfum de domaine. « Les relevés bruyants » n'en est pas un, donc cette requête finit épelée sur place, au milieu du calcul qu'elle alimente. Repository(Entity) lui donne un endroit :

// repositories/ReadingRepository.ts
export default class ReadingRepository extends Repository(Reading) {
  loud(): Promise<Reading[]> {
    return this.findAllBy({ loud: true });
  }
}
// handlers/ReadingHandler.ts — pose la question, n'épelle jamais le stockage
export default class ReadingHandler {
  constructor(private readings: ReadingRepository) {}
  async loud() { return this.readings.loud(); }
}

Chaque entité en a un, que vous ayez écrit le fichier ou non, et ce n'est pas une porte — donc le juge reste ici, dans le handler. Voir Les repositories.

Les quatre règles de binding

Fougere lit votre signature dans le programme TypeScript du projet au boot et lie chaque paramètre depuis l'invocation — dans cet ordre :

#Le paramètre ressemble àLié depuis
1un type qui a un Collector déclaré dans cette Frond (user?: User)le collect(ctx) du collector
2ctx: InvocationContextl'invocation entière
3un primitif (id: string, page: number)params[nom], puis query[nom] — coercé en number/boolean
4tout le reste (input: PostDraft)le body de la requête

Le scanner garde l'AST pour savoir ce que vous avez déclaré et utilise le type checker pour comprendre ce que cette déclaration signifie. En particulier :

  • Un collector ne se lie qu'à l'intérieur de sa propre Frond. La règle 1 lit les collectors de cette Frond, donc un type qu'elle ne sait pas résoudre tombe en règle 4 et le paramètre porte le corps de la requête — voir Un collector ne franchit pas la frontière d'une Frond.
  • Les alias sont résolus. user?: User se lie directement, et type CurrentUser = User | undefined; user: CurrentUser se lie au même collector.
  • La porte est ce que vous déclarez public. Une méthode private ou protected n'est pas une opération : le scan la saute, parce que TypeScript a déjà le mot pour ça. Un helper que le handler nomme par intention (mustOwn, refuse) reste donc un helper, à l'intérieur de la classe. #nom fonctionne aussi.

Absence canonique

La façade normalise chaque porte avant d'appeler le handler. L'absence suit TypeScript, pas le transport :

foo?: T          // omis → undefined
foo: T | null    // null est une valeur métier explicite et reste null
foo?: T | null   // omis → undefined ; null explicite → null

JSON ne sait pas transporter une propriété undefined, donc un appel distant ou navigateur l'omet. Le chemin local applique la même règle : { foo: undefined } est canonisé en {} et lire foo renvoie toujours undefined. Aucun adapter ne peut transformer un null explicite en undefined. La règle vaut pour les bindings primitifs comme pour les champs optionnels d'un body.

Validation des entrées

Quand le type d'un paramètre est une classe de schéma (entité ou vue), la façade valide le body avant d'appeler la méthode. Une entrée invalide produit VALIDATION_FAILED avec les détails par champ. Une vue partial() utilise le mode patch.

Les clés inconnues sont refusées

Une clé hors contrat produit une erreur au lieu d'être retirée silencieusement : un body { …, status: 'published' } contre une vue qui ne déclare pas status ressort en VALIDATION_FAILED (status: Unknown field) avant votre méthode. Une entrée acceptée peut donc être transmise à le storage sans projection préalable. useFormFor applique la même validation dans le navigateur avant l'appel réseau.

Un état change par une opération, jamais par une écriture de champ

publish() illustre une transition d'état explicite pour une commande, un abonnement ou un ticket. Elle combine les règles suivantes :

// l'entité — le jeu de valeurs, et celle avec laquelle elle naît
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
// le handler — le passage, et le refus
async publish(id: string): Promise<Post> {
  const post = await this.storage.findById(id);
  if (post.status === 'published') {
    throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published',
      entity: 'post', operation: 'publish' });
  }
  return this.storage.update(id, { status: 'published' });
}
  • oneOf limite les valeurs. Le formulaire peut produire un <select>, le DDL émet CHECK status in (…) et GraphQL déclare un type enum.
  • { default: 'draft' } est l'état initial — une règle de création sur l'axe lifecycle, donc personne ne le fournit.
  • readOnly interdit l'écriture du champ par les clients : boundary.in vaut closed, donc status est absent de toute vue d'entrée. Un corps qui le porte est refusé en clé inconnue.
  • L'opération nommée porte la transition et renvoie CONFLICT, projeté en 409, lorsque l'état courant l'interdit.

La méthode est seule responsable de l'ordre des transitions. Une mise à jour SQL directe, ou un autre handler qui appelle this.storage.update(id, { status }), peut la contourner tant que la valeur respecte le CHECK. Fougere ne fournit pas de graphe d'états : le champ déclare les valeurs possibles et l'opération contrôle le passage de l'une à l'autre.

Vue de sortie

Le deuxième argument de Crud sélectionne la vue de sortie. Deux formes sont disponibles :

Crud(Post, { list: PostCard })   // list rend des cartes, le reste rend Post
Crud(Post, PostPublic)           // tout le handler rend PostPublic

Avec une configuration par opération, le storage renvoie la ligne entière et la façade applique la vue à la sortie. Les autres opérations conservent leur propre vue.

Avec une vue pour tout le handler, le storage injecté est lui-même restreint. Un second fichier de handler peut ainsi définir une autre audience : handlers/PostHandler.ts (complet) et handlers/public/PostHandler.ts (restreint).

Atteindre une autre Frond

Une Frond n'importe pas les fichiers d'une autre : elle passe par sa porte. Facade<T> est le deuxième port du framework, résolu par type comme un repository (RepositoryOf<Post>) :

import type { Facade } from '@fougere/core';
import type ArticleHandler from '@fronds/stock/handlers/ArticleHandler';

export default class CommandeHandler {
  constructor(private articleFacade: Facade<ArticleHandler>) {}

  /** Cette commande est-elle servable depuis l'étagère ? */
  async servable(): Promise<boolean> {
    const onHand = await this.articleFacade.onHand();
    return onHand > 0;
  }
}

Trois choses à voir :

  • C'est la porte, pas le handler. Personne n'injecte un handler — ses méthodes prennent des arguments positionnels. La façade prend l'invocation : chaque opération de Facade<T> a la signature (invocation?) => Promise<…>.
  • keyof T est exactement la bonne liste. Le scan saute private et protected, donc les méthodes publiques d'un handler sont ses opérations — et keyof exclut le reste pour la même raison.
  • La signature ne dit pas où l'autre Frond tourne. Le même type résout la façade locale ou une doublure. Déclarer stock dans remotes: ne change pas cette ligne.

Ce qui ne voyage pas tout seul, c'est l'identité. L'opération appelée reçoit l'invocation que vous lui passez, et rien d'autre : pour qu'un collector d'en face voie le même utilisateur, déclarez ctx: InvocationContext (règle de binding 2) et transmettez-le.

Annoncer un fait

Un appel nomme un destinataire ; une émission nomme un sujet. Emit<T> est une dépendance de constructeur comme une autre, et accepter un Fact<T> EST l'abonnement — pas de topic, pas d'appel d'inscription.

Voir Les faits — le résolveur, ce qui traverse un processus, et quoi faire quand la forme d'un fait bouge.

Écrire plusieurs entités comme une seule

L'unité d'Storage<T> est une instruction. Quand deux écritures doivent toutes deux aboutir ou aucune, Together<[Account, Ledger]> est le quatrième port, lu comme les trois autres — son unité est un bloc :

constructor(private together: Together<[Account, Ledger]>) {}

await this.together.run(async ([accounts, ledger]) => {});

Voir Des écritures qui tiennent ou tombent ensemble — les deux réalisations, et celle que vous obtenez.

Suite : Presenters — les champs calculés ajoutés à la sortie d'une entité.

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