Événements
Bus d'événements du core, un module annonce ce qui vient de se passer, le projet et les autres modules y réagissent, dans la transaction ou après elle avec relances.
core/events laisse un module annoncer ce qui vient de se passer (« une soumission est arrivée », « une facture est payée ») sans connaître ceux qui y réagissent. Le projet et les autres modules s'abonnent par le nom de l'événement. Un module qui annonce un événement le déclare dans son manifeste.
Règle absolue : un événement s'annonce dans la transaction qui enregistre l'opération, avec son client. Un événement annoncé ailleurs peut décrire une opération annulée.
Import
import {
emit, on, off, listEvents, listSubscribers, getEventDeclaration,
listEventDeliveries, getEventDelivery, countEventDeliveries, retryEventDelivery,
processEventDeliveries, purgeEvents,
MAX_EVENT_DEPTH, MAX_PAYLOAD_BYTES, ACTOR_TYPES, DEFAULT_MAX_ATTEMPTS, DELIVERY_STATUSES,
} from '@fraza/core/events';Module serveur : il importe la base, node:crypto et node:async_hooks. Un abonné est du code serveur enregistré au démarrage, jamais depuis le navigateur ni depuis une requête.
API
| Fonction | Retour | Rôle |
|---|---|---|
emit(name, payload?, { client, actor }?) | Promise<object | null> | Annonce un événement. |
on(names, handler, { source, durable, required, maxAttempts }) | () => void | Abonne une fonction à un événement ou à une liste. Retourne la fonction qui défait l'abonnement. |
off(name, handler) | boolean | Retire les abonnements de handler à name. |
listEvents() | object[] | Les événements déclarés par le core et les modules, triés par nom. |
getEventDeclaration(name) | object | undefined | La déclaration d'un événement. |
listSubscribers(name?) | object[] | Qui écoute quoi, sans les fonctions. |
listEventDeliveries({ status, name, limit }?) | Promise<object[]> | Les livraisons durables, les plus récentes d'abord. status : un statut ou une liste. |
getEventDelivery(id) | Promise<object | null> | Une livraison et son événement. |
countEventDeliveries() | Promise<object> | Nombre de livraisons par statut. |
retryEventDelivery(id) | Promise<boolean> | Remet en file une livraison abandonnée. |
processEventDeliveries({ limit, signal }?) | Promise<object> | Livre ce qui est dû. Le travail de la tâche fraza:events. |
purgeEvents({ olderThanDays, signal }?) | Promise<number> | Supprime les événements terminés plus vieux que la rétention. |
MAX_EVENT_DEPTH | number | Longueur maximale d'une chaîne d'événements, 10. |
MAX_PAYLOAD_BYTES | number | Taille maximale d'une charge en JSON, 64 Ko. |
ACTOR_TYPES | string[] | user, public, system. |
DEFAULT_MAX_ATTEMPTS | number | Essais d'une livraison durable par défaut, 12, environ une journée. |
DELIVERY_STATUSES | string[] | pending, running, done, failed. |
emit(name, payload?, { client, actor }?)
Annonce un événement et retourne son enveloppe.
import { emit } from '@fraza/core/events';
import { transaction } from '@fraza/core/database';
await transaction(async (client) => {
const submission = await insertSubmission(client, data);
await emit('forms.submission.created', { formId, submissionId: submission.id }, {
client,
actor: { type: 'public' },
});
});Avec client, l'événement appartient à la transaction de l'appelant. Les abonnés immédiats y tournent, les livraisons durables y sont écrites, et une erreur est levée pour que la transaction soit annulée. Une transaction annulée n'annonce donc rien.
Sans client, emit tient l'opération pour déjà enregistrée. Les abonnés immédiats reçoivent client: null. Une livraison durable qui ne peut pas être écrite est journalisée, pas levée : l'opération qu'elle annonce reste faite.
payload porte des identifiants et des faits d'état (un statut, une date), en JSON, 64 Ko au plus. Jamais de données personnelles : la charge des abonnés durables est stockée en base jusqu'à la purge. L'abonné relit l'objet par son identifiant, avec ses propres règles d'accès, et n'a jamais une copie périmée.
actor dit qui a déclenché l'opération : { type: 'user', id }, { type: 'public' } ou { type: 'system' }, le défaut.
Un événement que personne ne déclare, ou une charge qui ne correspond pas à sa déclaration, lève une erreur en développement. En production, l'écart est journalisé et l'événement part quand même.
L'enveloppe que reçoivent les abonnés :
{
id: '5c1f…', // UUID, pour l'idempotence
name: 'forms.submission.created',
payload: { formId: 3, submissionId: 812 },
actor: { type: 'public' },
occurredAt: '2026-11-06T14:02:11.000Z',
source: '@fraza/module-forms', // le paquet qui le déclare
depth: 1,
}L'enveloppe et sa charge sont figées.
on(names, handler, options)
Abonne une fonction à un événement, ou à chaque nom d'une liste.
import { on } from '@fraza/core/events';
// Immédiat : dans la transaction de l'émetteur.
on('forms.submission.created', async (event, { client }) => {
await client.query('INSERT INTO ai_jobs (submission_id) VALUES ($1)', [event.payload.submissionId]);
}, { source: '@fraza/module-ai' });
// Durable : après la validation, relancé s'il échoue.
on('booking.appointment.created', async (event, { attempt, signal }) => {
await sendSms(event.payload.appointmentId, { signal });
}, { source: '@acme/module-sms', durable: true });| Option | Défaut | Effet |
|---|---|---|
source | requis | Le paquet qui s'abonne, 'project' depuis events.js. Une source a un seul abonné par événement : s'abonner de nouveau remplace le précédent. |
durable | false | Appelé après la validation de la transaction, par une file qui relance les échecs. |
required | false | Abonné immédiat seulement : son erreur annule l'opération de l'émetteur. |
maxAttempts | 12 | Abonné durable seulement : essais avant l'abandon, de 1 à 20. |
Les deux façons d'écouter :
| Immédiat (défaut) | Durable (durable: true) | |
|---|---|---|
| Quand | Pendant emit, dans la transaction de l'émetteur. | Après la validation de la transaction. |
| Reçoit | (event, { client }) | (event, { attempt, signal }) |
| Si l'abonné échoue | Ce qu'il a écrit est annulé, l'erreur est journalisée, l'émetteur et les autres abonnés continuent. Avec required: true, toute l'opération est annulée. | Relancé avec un délai qui double, puis abandonné et visible dans l'admin. |
| Si l'émetteur est annulé | Ce que l'abonné a écrit l'est aussi. | L'événement n'est jamais livré. |
| Pour | Écrire dans la même base : une file, un compteur, un état qui doit suivre l'opération. | Appeler l'extérieur : courriel, texto, API, webhook. |
Un abonné immédiat ne doit jamais appeler un service externe : il bloquerait l'opération de l'émetteur, une soumission de formulaire ou un paiement, le temps de sa réponse.
Les abonnés d'un événement tournent l'un après l'autre, dans l'ordre de leur premier enregistrement : les modules dans l'ordre de leurs requiresModules, puis le projet.
off(name, handler)
Retire les abonnements de handler à name, toutes sources confondues. Sert surtout aux tests. La fonction retournée par on() fait la même chose pour les noms de cet appel.
listEvents() et listSubscribers(name?)
listEvents();
// [{ name: 'forms.submission.created', description: '…', payload: { formId: 'integer', … }, source: '@fraza/module-forms' }, …]
listSubscribers('forms.submission.created');
// [{ name: 'forms.submission.created', source: '@fraza/module-ai', durable: false, required: false, maxAttempts: 12 }]La page Événements de l'admin affiche les deux.
listEventDeliveries, getEventDelivery, countEventDeliveries, retryEventDelivery
Les livraisons durables, pour un écran d'administration.
const failed = await listEventDeliveries({ status: 'failed', limit: 50 });
// [{ id, subscriber, status, attempts, maxAttempts, nextAttemptAt, lastError, createdAt, updatedAt,
// event: { id, name, source, payload, actor, depth, occurredAt } }]
await retryEventDelivery(failed[0].id); // true : remise en file, nouveaux essaisretryEventDelivery() ne touche qu'une livraison abandonnée (failed). Elle repart avec tous ses essais, et une première tentative suit la réponse.
processEventDeliveries({ limit, signal }?)
Livre les livraisons dues, une à la fois, les plus anciennes d'abord (limit par défaut : 50). La tâche système fraza:events l'appelle chaque minute. Retourne { attempted, done, retrying, failed }.
purgeEvents({ olderThanDays, signal }?)
Supprime les événements dont toutes les livraisons sont terminées ou abandonnées, plus vieux que olderThanDays (par défaut FRAZA_EVENTS_RETENTION_DAYS, 7 jours), avec leurs livraisons. La tâche fraza:maintenance l'appelle chaque heure, par lots.
Déclarer ses événements
Un module liste ce qu'il annonce dans manifest.events :
export const manifest = {
name: '@fraza/module-forms',
// …
events: [
{
name: 'forms.submission.created',
description: "Une soumission a passé l'anti-robots et a été enregistrée.",
payload: { formId: 'integer', submissionId: 'integer' },
},
],
};Le nom suit la forme module.objet.verbe, le verbe au passé, en anglais comme le code. Le premier segment est le nom court du module : forms pour @fraza/module-forms, billing pour @fraza-pro/module-billing. users est réservé au core.
payload décrit chaque champ : string, number, integer, boolean, object ou array, suivi de ? quand le champ peut manquer ou valoir null. En développement, emit refuse une charge qui s'en écarte, champ en trop compris : une charge qui dérive casse les abonnés sans bruit.
fraza-module check valide la déclaration avant la publication. Au démarrage, un abonnement à un nom que personne ne déclare produit un avertissement : une faute de frappe, ou un module absent. Ce n'est pas une erreur, un module peut écouter un module facultatif.
Un événement est un contrat. Son nom et sa charge ne changent qu'avec une version majeure du module, et les notes de version annoncent les événements ajoutés ou retirés.
Événements du core
| Événement | Charge | Quand |
|---|---|---|
users.user.created | userId, roleIds | Un compte est créé par le site : inscription, premier administrateur depuis /auth, invitation par l'équipe. |
Un compte créé par le CLI n'est remis à aucun abonné. fraza-db reset --admin et le seed() d'un module, lancé par fraza-db seed, passent aussi par createUser(), qui émet l'événement. Le CLI déclare les événements des modules, pour qu'un seed puisse aussi passer par un service qui annonce le sien, mais il ne charge ni le events.js du projet ni le register() des modules : emit ne trouve aucun abonné, n'appelle personne et n'écrit rien dans fraza_events_outbox. Une synchronisation abonnée à users.user.created, vers un CRM par exemple, ne voit donc pas ces comptes de développement.
Les événements de chaque module officiel sont listés dans sa page de référence, sous Modules.
Livraison durable
Un abonné durable n'est pas appelé pendant emit. L'événement est écrit dans fraza_events_outbox, et une ligne par abonné durable dans fraza_events_delivery, dans la transaction de l'émetteur. C'est le modèle outbox : aucun événement perdu si le serveur s'arrête entre l'enregistrement et la livraison, aucun livré pour une transaction annulée.
- Une première tentative suit la réponse de la requête, par
runAfterResponse(). Hors d'une requête (une tâche planifiée, un script), elle part tout de suite : si la transaction de l'émetteur n'est pas encore validée, elle ne trouve rien, et la tâchefraza:eventslivre l'événement environ une minute plus tard. - La tâche
fraza:events, chaque minute, avec son verrou, reprend ce qui n'a pas été livré : une tentative qui n'a pas eu lieu, ou un échec dont le délai est écoulé. - Après un échec, le délai double : 1 min, 2 min, 4 min…, plafonné à 6 h. Après
maxAttemptsessais (12 par défaut, environ une journée), la livraison est abandonnée (failed) avec sa dernière erreur. - Une livraison abandonnée reste visible dans la page Événements de l'admin, qui la relance, jusqu'à la purge.
Chaque abonné a sa ligne : un abonné en échec n'en retarde aucun autre. Un abonné a 5 minutes par essai ; au-delà, l'essai compte comme un échec.
La livraison est au moins une fois. Un processus qui s'arrête entre la réussite de l'abonné et l'écriture de son état rappelle l'abonné plus tard. L'abonné se protège des doublons avec event.id, par exemple en l'enregistrant avec ce qu'il fait.
Un abonné durable qui n'existe plus au moment de la livraison (module désactivé, abonnement retiré) fait abandonner la livraison avec un message qui le dit.
Chaînes d'événements
Un abonné peut annoncer un événement à son tour. Chaque maillon de la chaîne est un niveau plus profond (event.depth), et un événement au-delà de MAX_EVENT_DEPTH (10) est refusé et journalisé : emit retourne null. Deux abonnés qui se répondent l'un l'autre s'arrêtent là au lieu de tourner sans fin.
Variables d'environnement
| Variable | Défaut | Effet |
|---|---|---|
FRAZA_EVENTS_RETENTION_DAYS | 7 | Jours de conservation d'un événement terminé, avec ses livraisons. |
Notes
- Un abonné immédiat tourne sous un
SAVEPOINT. PostgreSQL annule toute la transaction à la première requête en échec : sans point de reprise, l'erreur d'un abonné, même rattrapée, transformerait leCOMMITde l'émetteur enROLLBACK. Leclientpassé àemitdoit donc être celui d'une transaction ouverte partransaction(). Si le retour au point de reprise échoue lui-même, la transaction est inutilisable : l'erreur remonte à l'émetteur même sansrequired, pour qu'il ne croie pas avoir validé. - Pas de permission dans le bus. L'émetteur n'annonce qu'après ses propres vérifications, et l'abonné relit l'objet avec ses propres règles.
- Les tables appartiennent au core. Elles sont créées par la migration
015-create-eventset conservées parfraza-db drop, comme la file d'envoi des courriels. realtimeest un autre outil. Temps réel pousse des signaux au navigateur, sans garantie de réception. Les événements restent sur le serveur, entre modules. Un abonné peut publier surrealtime.