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 |
|---|---|---|
| 1 | un type qui a un Collector (user: User | null) | le collect(ctx) du collector |
| 2 | ctx: InvocationContext | l'invocation entière |
| 3 | un primitif (id: string, page: number) | params[nom], puis query[nom] — coercé en number/boolean |
| 4 | tout 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 | nullse lie ;user: CurrentUser(un alias) est invisible et ne lie rien. - La porte est ce que vous déclarez public. Une méthode
privateouprotectedn'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.#nomfonctionne 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' });
}
oneOflimite les valeurs. Le formulaire peut produire un<select>, le DDL émetCHECK 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.readOnlyinterdit l'écriture du champ par les clients :boundary.invautclosed, doncstatusest 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 Test exactement la bonne liste. Le scan sauteprivateetprotected, donc les méthodes publiques d'un handler sont ses opérations — etkeyofexclut 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
stockdansremotes: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é.