Les faits
Tout autre appel nomme un destinataire : remotes une adresse par Frond,
Facade<T> une porte. Emit<T>
nomme un sujet, et le nombre de lecteurs ne regarde pas l'émetteur.
Annoncer un fait
Un fait est une entité, le plus souvent dérivée de celle dont il parle :
// fronds/blog/entities/PostPublished.ts
export default class PostPublished extends Post.pick('id', 'title').extend({ at: created() }) {}
// fronds/blog/handlers/PostHandler.ts — l'émetteur
constructor(private published: Emit<PostPublished>) {}
async publish(id: string, title: string) {
await this.published({ id, title, at: new Date() });
}
// fronds/search/handlers/IndexHandler.ts — une tout autre Frond
async reindex(fact: Fact<PostPublished>): Promise<void> { … }
Pas de topic, pas d'appel d'abonnement, pas de liste d'auditeurs. Accepter un Fact<T>
EST l'abonnement — le scan lit la signature, et c'est tout le mécanisme. Un quatrième
auditeur ne rouvre pas PostHandler.
Rien ne marque non plus PostPublished comme un fait : il le devient parce que quelqu'un
écrit Emit<PostPublished> ou Fact<PostPublished> à son sujet. Un seul des deux bouts
suffit, donc supprimer l'émetteur ne désabonne personne en douce.
Ce que Fact<T> promet
Pas seulement « je m'abonne ». Le push est le mode strict et le pull en est un cas particulier : une op écrite pour le push est correcte quand on l'appelle directement, l'inverse est faux. L'enveloppe engage donc l'opération sur trois choses qu'aucun type ne vérifie :
| pull | push | |
|---|---|---|
| son retour | quelqu'un le lit | personne |
| une exception | remonte au client | part dans un log |
| combien de fois | une par requête | au moins une — rejouable |
Un abonné est une opération ordinaire. Il garde sa porte, son juge et ses middlewares, parce
qu'une émission et un appel direct sont le même appel — c'est aussi pourquoi il apparaît
dans votre surface publique. Placez-le sous handlers/<audience>/ pour le tenir hors d'une
audience cliente.
C'est un résolveur, pas un canal
Un bus déplace des messages : une file, une enveloppe, une sémantique de livraison. Ici,
rien de tout ça. Il répond à qui, puis rend la main à la porte qui existe déjà — un
resolve qui rend N choses au lieu d'une. Conséquences, énoncées plutôt que découvertes :
- Dispatcher n'est pas livrer.
await this.published(...)rend la main quand chaque abonné a reçu le fait, jamais quand l'un d'eux a fini. L'échec d'un abonné part dans un log ; une publication n'est jamais otage de son indexeur. - Un fait ne peut pas se causer lui-même. Un anneau (
A → B → A) est refusé et le message le nomme ; un losange (A → B → D,A → C → D) reste légal. - Rien n'est durable, et rien ici ne le sera. Tuez le process de l'auditeur et le fait
est perdu. L'at-least-once se paie en glissant un vrai canal sous le dispatch — un
journal, un curseur par abonné, un acquittement — et rien de tout ça n'appartient à un
résolveur.
Ce que Fougere doit à un tel canal, c'est de pouvoir répondre est-ce que c'est arrivé ?, et c'estapp.deliver(): il attend chaque auditeur local et rejette si l'un a refusé, pour qu'un porteur puisse acquitter, réessayer ou mettre de côté. C'est l'exact inverse d'annoncer, exprès — « dispatcher n'est pas délivrer » protège l'émetteur, et un porteur n'est pas l'émetteur. Voir emit-multirepo, où le broker de quatre-vingts lignes tient la file et la Frond n'en tient rien.
Entre process
remotes: est la seule ligne qui change. La Frond de l'auditeur reste sur le disque dans les
deux cas : elle est scannée, donc l'émetteur connaît sa signature ; la déclarer distante dit
seulement qu'elle n'est pas hébergée ici, et sa porte résout vers une doublure qui envoie
l'appel.
Voir emit-split — le même
PostHandler, publié deux fois, et le pid dans la sortie dit quel process a indexé.
Entre dépôts
Deux choses s'arrêtent à la frontière d'un dépôt, et remotes: n'en couvre qu'une.
Trouver les auditeurs. Le dispatch lit leur code, et la Frond d'une autre équipe n'est
pas sur ce disque. Un porteur se glisse alors sous l'émission — onEmit confie le fait à un
nom, et l'autre côté s'abonne à ce même nom depuis son propre code. Aucun des deux ne lit
l'autre ; ils se rejoignent sur une chaîne dérivée des deux côtés.
const app = await createApp({
root: import.meta.dirname,
createContainer,
onEmit: (fact, payload) => publierVersVotreBroker(fact, payload), // sortie
});
// entrée — le même dispatch local, juge et middlewares compris
await app.deliver(fact, payload);
// et ce à quoi s'abonner, lu sur ses propres signatures
app.listensTo(); // ['postPublished']
Connaître la forme. La carte la publie : la liste facts d'une frond porte chaque
Emit<T> que ses handlers injectent, donc fougere sync écrit la classe au lieu qu'un
abonné recopie les champs à la main. Réexportez-la dans une de vos fronds et un fait qui
arrive est jugé contre la déclaration de l'émetteur lui-même.
Voir emit-multirepo — deux projets, un fait, et la première publication perdue exprès pour montrer ce qu'apporte un porteur.
Faire évoluer un fait
Un fait rencontre le même juge que n'importe quelle entrée : une clé hors du contrat
vaut Unknown field, et c'est voulu. La tolérer signifierait qu'un lecteur ignore en
silence un champ qu'il devait traiter — la panne qu'on découvre six mois plus tard.
Donc ajouter un champ à un fait casse tous les lecteurs restés sur l'ancienne copie. Le refus atteint le log du lecteur et jamais l'émetteur, puisque dispatcher n'est pas délivrer — cette ligne est donc toute la trace disponible, et elle dit quoi faire :
ERR [blog] postPublished → indexHandler.reindex refused the shape — author: Unknown field.
If 'postPublished' gained a field, this copy is older than the sender's:
re-run `fougere sync`.
L'ordre fait donc partie du changement :
1. l'émetteur déclare le nouveau champ
2. chaque lecteur : `fougere sync`, puis déploie ← d'abord
3. l'émetteur déploie ← en dernier
Retirer un champ casse de la même façon, et aucune tolérance n'y aurait rien changé : la donnée dont le lecteur a besoin n'est simplement pas là.
Quand l'étape 2 ne vous appartient pas — une autre équipe, une flotte, tout ce que vous ne
déployez pas dans l'ordre —, annoncez un second fait plutôt que de modifier le premier.
Ça coûte une classe et un Emit<T>, les deux faits cohabitent, et les lecteurs migrent
quand ils migrent :
constructor(
private published: Emit<PostPublished>, // les anciens lecteurs, intacts
private publishedV2: Emit<PostPublishedFull>, // la nouvelle forme
) {}
Rien n'enregistre un fait : un second n'ajoute donc rien à désenregistrer plus tard.
Supprimez le premier Emit quand son dernier lecteur a disparu.
Un EventBus occupait cette place et a été retiré plutôt que publié. Il résolvait par
chaîne de caractères — seul endroit qui ne résolvait pas par type, si bien qu'un nom mal
orthographié ne produisait aucune erreur —, portait une charge unknown, et attendait chaque
auditeur en lui renvoyant ses échecs.
Suite : Presenters — les champs calculés ajoutés à la sortie d'une entité.