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.orm.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 | null): Promise<Post> {
    if (!user) throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Connectez-vous pour publier', entity: 'post', operation: 'publish' });
    const post = await this.orm.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.orm.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.

this.orm est l'accès aux données limité à l'entité (EntityOrm). Lectures : list(options?), findById(id), findBy(criteria), findAllBy(criteria). Écritures : create(input), update(id, input), delete(id). output(vue) rend un ORM scopé aux champs d'une vue.

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

La façade valide l'entrée avant d'appeler la méthode. L'ORM 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

EntityOrm 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.orm.findAllBy({ loud: true });
  }
}
// handlers/ReadingHandler.ts — pose la question, n'épelle jamais le stockage
export default class ReadingHandler {
  constructor(private readingRepository: ReadingRepository) {}
  async loud() { return this.readingRepository.loud(); }
}

N'en écrivez aucun et vous ne perdez rien. Le bootstrap enregistre un repository par défaut pour chaque entité — l'ORM gardé lui-même — donc ReadingRepository se résout que le fichier existe ou non. En déclarer un l'emporte, exactement comme une opération Crud redéfinie dans la sous-classe l'emporte sur celle du prefab.

Ce n'est pas une porte : un repository n'a pas de façade, donc rien de ce qu'il porte n'est joignable depuis le fil. Le juge reste dans le handler, seul endroit où un refus ne se contourne pas.

Les quatre règles de binding

Fougere lit votre signature (parse AST 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 (user: User | null)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

Deux conséquences du parser AST-only (pas de type checker) :

  • Épelez les types. user: User | null se lie ; user: CurrentUser (un alias) est invisible et ne lie rien.
  • 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.

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 à l'ORM 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.orm.findById(id);
  if (post.status === 'published') {
    throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published',
      entity: 'post', operation: 'publish' });
  }
  return this.orm.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.orm.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, l'ORM 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, l'ORM 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, lu comme le premier (EntityOrm<Post>) :

import type { Facade } from '@fougere/core';
import type ArticleHandler from '@frond/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.

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.