npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sfp-group/notify

v0.9.3

Published

Help to easily send notifications between multiple services like SMS,Mail and Push

Readme

@sfp-group/notify

Envoi de notifications multi-canaux (SMS / mail / push) en différé pour AdonisJS 5, adossé à une file pg-boss sur PostgreSQL.

typescript-image npm-image license-image

Sommaire

  1. Présentation
  2. Démarrage rapide
  3. Concepts
  4. Guides
  5. Référence de configuration
  6. Écrire un driver
  7. Exploitation
  8. Dépannage
  9. Migration & changelog

1. Présentation

Le problème résolu : envoyer la même notification métier sur plusieurs canaux, sans bloquer la requête HTTP, avec des destinataires qui se décident en base plutôt qu'en dur, et un fournisseur qu'un administrateur peut changer à chaud.

Notify.send() n'envoie rien : il empile un job par canal. Tout le travail se fait dans un process worker séparé.

App ──Notify.send()──▶ pg-boss (1 job / canal) ──▶ notify:cluster-listener
                                                          │
                                    destinataires ◀───────┤   (notification_recipients
                                                          │    + NotifiableProvider)
                                    template Edge  ◀──────┤   (config.events[e].templates)
                                                          │
                                    service actif  ◀──────┤   (notification_services.default)
                                                          ▼
                                                    driver ──▶ fournisseur

⚠️ Le worker est obligatoire. Sans un process node ace notify:cluster-listener en cours d'exécution, aucune notification n'est jamais envoyée.

Matrice de compatibilité

| Composant | Version supportée | Note | |---|---|---| | AdonisJS | 5.x | | | Node.js | ≥ 16 | imposé par pg-boss 8 | | PostgreSQL | ≥ 12 | voir ci-dessous | | pg-boss | 8.x | |

CREATE EXTENSION pgcrypto; est obligatoire en PostgreSQL 12 et facultatif à partir de PostgreSQL 13. pg-boss appelle gen_random_uuid(), qui n'est native qu'à partir de PG 13 ; avant, elle vient de l'extension. En cas de doute, node ace notify:doctor tranche pour votre serveur.


2. Démarrage rapide

npm i @sfp-group/notify
node ace configure @sfp-group/notify
CREATE EXTENSION pgcrypto;   -- requis en PostgreSQL 12
node ace migration:run

1. Déclarez l'événement et son payloadcontracts/notify.ts :

declare module "@ioc:Adonis/Addons/Notify" {
  interface NotifyEventList {
    "notify:contract-rib-changed": {
      contract_id: number;
      transporter_name: string;
      new_rib: string;
    };
  }
}

2. Décrivez-leconfig/notify.ts :

events: {
  "notify:contract-rib-changed": {
    description: "Alerte lors du changement de RIB d'un contrat",
    subject: "[CDRS] Changement de RIB",
    templates: { mail: "emails/contract-rib-changed" },
  },
},

3. Synchronisez, activez un service, ajoutez un destinataire :

node ace notify:actualize-store
update notification_services set "default" = true where slug = 'mail';
insert into notification_recipients (notification_event_id, way, notifiable)
values (1, 'mail_to', '[{"table":"users","id":1}]');

4. Lancez le worker, puis émettez :

node ace notify:cluster-listener
import Notify from "@ioc:Adonis/Addons/Notify";

await Notify.send(
  "notify:contract-rib-changed",
  { contract_id: 1, transporter_name: "John Doe", new_rib: "892786289287" },
  ["normal"]
);

Si rien n'arrive : node ace notify:doctor.


3. Concepts

Ces quatre mots cohabitent dans la configuration et sont la première source de confusion. Ils ne désignent pas la même chose.

| Terme | Sens | Valeurs | |---|---|---| | type | Canal technique de livraison | sms, mail, push | | way | Rôle du destinataire à l'intérieur d'un type | phone, mail_to, mail_cc, mail_bcc, push | | channel | Alias métier que vous définissez, résolu en un ou plusieurs type | normal, high, critical… | | event | Clé typée déclarée dans NotifyEventList | notify:contract-rib-changed | | service | Instance configurée d'un fournisseur, identifiée par un slug, activable en base | kopen_sms, local_mail | | driver | Classe qui réalise l'envoi (NotifyServiceContract) | MailSender | | notifiable | Entité applicative destinataire, référencée par {table, id} | un User, un Role |

Deux résolutions distinctes cohabitent volontairement :

  • le driver (la classe) vient de config/notify.ts ;
  • le service actif pour un type vient de la base (notification_services.default), ce qui permet à un administrateur de changer de fournisseur à chaud, sans déploiement.

4. Guides

4.1 Déclarer un événement typé

La clé de NotifyEventList type le payload de Notify.send(). Un événement absent de config/notify.ts > events est refusé à l'appel.

4.2 Écrire ses templates

Forme courte — le nom de la vue Edge suffit :

templates: { mail: "emails/alerte", sms: "sms/alerte" },

Forme longue, quand il faut préciser :

templates: {
  mail: { view: "emails/alerte", format: "html", textView: "emails/alerte.txt" },
  sms:  { view: "sms/alerte", format: "text" },
},

format vaut html pour mail, text pour sms et push. En text, le rendu Edge est converti en texte brut — c'est ce qui empêche d'expédier du HTML dans un SMS quand la vue hérite d'un layout partagé.

textView produit la variante text/plain d'un mail ; elle pèse dans les scores anti-spam.

Un canal sans template déclaré n'est pas envoyé (avertissement au log).

4.3 Implémenter un NotifiableProvider

Le plus simple est de composer votre modèle avec le mixin Notifiable :

import { compose } from "@poppinss/utils/build/helpers";
import { BaseModel, column } from "@ioc:Adonis/Lucid/Orm";
import { Notifiable } from "@ioc:Adonis/Addons/Notify/Models";

export default class User extends compose(BaseModel, Notifiable) {
  @column() public firstname: string;
  @column() public lastname: string;

  // Getter obligatoire : alimente `Recipient.name`.
  public get _name() {
    return `${this.firstname} ${this.lastname}`;
  }
}

Puis la classe qui traduit {table, id} en destinataires — c'est là qu'un rôle peut se déployer en N utilisateurs :

export default class NotifiableProvider implements NotifiableProviderInterface {
  public async execute(notices: Array<NotifyTable>): Promise<NotifiableInterface[]> {
    const result: NotifiableInterface[] = [];
    for (const notice of notices) {
      if (notice.table === User.table) {
        const user = await User.find(notice.id);
        if (user) result.push(user);
      }
      if (notice.table === Role.table) {
        const role = await Role.find(notice.id);
        if (role) {
          await role.load("users");
          result.push(...(role.users as unknown as NotifiableInterface[]));
        }
      }
    }
    return result;
  }
}

Déclarez son binding dans config/notify.ts > provider.

4.4 Configurer les destinataires côté admin

notification_recipients porte (notification_event_id, way, notifiable). Le way détermine quel type le consommera :

| type | ways interrogés | |---|---| | sms | phone | | mail | mail_to, mail_cc, mail_bcc | | push | push |

Surchargeable via waysByType.

4.5 Cibler des destinataires à l'envoi

Les destinataires configurés en base conviennent aux cas stables (« le superviseur reçoit toujours une copie »). Pour ceux connus seulement à l'exécution — notifier celui qui vient de valider, ou celui à qui on demande une validation — désignez-les à l'appel :

await Notify.send("notify:validation-demandee", { dossier_id: 12 }, ["normal"], {
  recipients: { mail_to: [Notify.ref(valideur)] },
  recipientsMode: "merge",
});

Trois formes acceptées, mélangeables dans un même way :

recipients: {
  // référence à un modèle : résolue par votre NotifiableProvider,
  // donc nom, email, téléphone et jeton push sont récupérés
  mail_to: [Notify.ref(valideur)],          // ou { table: "users", id: 42 }

  // adresse brute : pour notifier quelqu'un sans compte
  mail_cc: ["[email protected]"],

  // Recipient complet, si vous l'avez déjà sous la main
  mail_bcc: [{ id: 7, name: "Archive", email: "[email protected]" }],
}

Le champ alimenté par une chaîne dépend du way, sans ambiguïté : mail_* → email, phone → téléphone, push → jeton.

recipientsMode est obligatoire. Il n'y a volontairement pas de défaut :

| Mode | Effet | |---|---| | "merge" | les destinataires désignés s'ajoutent à ceux configurés en base | | "replace" | ils remplacent entièrement ceux configurés, pour cet envoi |

Omettre la clé lève E_INVALID_RECIPIENT à l'appel, pas dans le worker.

Notify.ref() accepte aussi un tableau, et fonctionne sur n'importe quel modèle Lucid — pas seulement ceux composés avec Notifiable :

recipients: { mail_to: Notify.ref(await User.query().where("role", "valideur")) }

Deux garde-fous :

  • un mail_to désigné à l'appel n'apparaît jamais dans un envoi sms : les way non pertinents pour le canal sont écartés ;
  • un destinataire désigné qui est déjà configuré en base n'est pas dupliqué — le dédoublonnage se fait par identifiant.

La clé d'idempotence intègre les destinataires désignés. Deux demandes de validation adressées à deux personnes différentes, avec le même payload, restent donc bien deux envois distincts.

4.6 Lancer et superviser le worker

node ace notify:cluster-listener              # tous les canaux
node ace notify:cluster-listener --only=sms   # un process dédié par canal

Le worker enregistre un heartbeat toutes les 30 s dans notification_workers. Notify.health() expose l'état, à brancher sur un endpoint applicatif :

const health = await Notify.health();
// { queueConnected, pendingJobs, failedJobs24h, workers, staleAfterSeconds }

4.7 Modèles et services applicatifs

Suivez les instructions de l'implémentation


5. Référence de configuration

Niveau : 1 = démarrage, 2 = courant, 3 = expert. Un lecteur débutant peut s'arrêter au niveau 1 : toutes les autres clés ont un défaut fonctionnel.

Racine

| Clé | Type | Défaut | Portée | Niveau | |---|---|---|---|---| | events | Record<event, {…}> | — (requis) | global | 1 | | provider | string (binding IoC) | — (requis) | global | 1 | | services | Record<slug, {…}> | — (requis) | global | 1 | | channels | Record<alias, type \| type[]> | {} | global | 1 | | validateOnBoot | boolean | true | global | 2 | | retry | NotifyRetryPolicy | {limit:3, delay:30, backoff:true} | global | 2 | | retryByType | Record<type, NotifyRetryPolicy> | {} | canal | 2 | | fallback | Record<type, type> | {} | canal | 3 | | rateLimit | Record<type, {max, per}> | {} | canal | 3 | | window | {from, to, timezone?} | aucune | global | 3 | | windowByType | Record<type, {from,to,timezone?}> | {} | canal | 3 | | hooks | NotifyHooks \| string | aucun | global | 3 | | audit | NotifyAuditConfig | voir plus bas | global | 2 | | idempotency | NotifyIdempotencyConfig | voir plus bas | global | 2 | | webhooks | NotifyWebhookConfig | voir plus bas | global | 3 | | waysByType | Record<type, way[]> | mapping standard | global | 3 | | queue | NotifyQueueOptions | voir plus bas | global | 2 |

events[<slug>]

| Clé | Type | Défaut | Niveau | |---|---|---|---| | description | string | — (requis) | 1 | | subject | string (rendu par Edge) | — (requis) | 1 | | templates | Partial<Record<type, NotifyTemplateDefinition>> | — (requis) | 1 | | retry | NotifyRetryPolicy | hérite | 3 |

services[<slug>]

| Clé | Type | Défaut | Niveau | |---|---|---|---| | type | "sms" \| "mail" \| "push" | — (requis) | 1 | | name | string | — (requis) | 1 | | driver | string (binding IoC) | — (requis) | 1 | | settings | Record<string, string> | undefined | 2 |

retry / retryByType / events[e].retry

| Clé | Type | Défaut | Niveau | |---|---|---|---| | limit | number | 3 | 2 | | delay | number (secondes) | 30 | 2 | | backoff | boolean | true | 2 |

Cascade, du plus spécifique au plus général : events[e].retryretryByType[type]retry → défaut librairie. Chaque clé se résout indépendamment : déclarer retry: { limit: 5 } ne remet pas delay et backoff à zéro.

queue

| Clé | Type | Défaut | Niveau | |---|---|---|---| | schema | string | "notify" | 2 | | autoStart | "always" \| "worker-only" \| "never" | "always" | 3 | | partitionBy | "none" \| "type" | "none" | 3 | | concurrency | Record<type, number> | 1 partout | 3 | | deadLetter | string | "notify:dead" | 3 | | expireInSeconds | number | 900 | 3 | | archiveCompletedAfterSeconds | number | 604800 | 3 |

audit

| Clé | Type | Défaut | Niveau | |---|---|---|---| | enabled | boolean | true | 2 | | storeContent | boolean | true | 2 | | redact | ("phone"\|"email"\|"push"\|"name")[] | [] | 2 | | retentionDays | number | 90 | 2 |

idempotency

| Clé | Type | Défaut | Niveau | |---|---|---|---| | enabled | boolean | true | 2 | | windowSeconds | number | 3600 | 2 |

webhooks

| Clé | Type | Défaut | Niveau | |---|---|---|---| | requireSignature | boolean | true | 3 |

rateLimit[<type>] et window / windowByType

| Clé | Type | Défaut | Niveau | |---|---|---|---| | max | number | — (requis) | 3 | | per | "second" \| "minute" \| "hour" | — (requis) | 3 | | from | string HH:mm | — (requis) | 3 | | to | string HH:mm | — (requis) | 3 | | timezone | string (IANA) | fuseau du serveur | 3 |

Options par appel — Notify.send(event, data, channels, options)

| Clé | Type | Défaut | Niveau | |---|---|---|---| | correlationId | string | généré | 3 | | idempotencyKey | string | calculée | 2 | | recipients | Partial<Record<way, DynamicRecipient[]>> | aucun | 2 | | recipientsMode | "merge" \| "replace" | — (requis avec recipients) | 2 |

DynamicRecipient vaut une chaîne (adresse brute), une référence { table, id }, ou un Recipient complet. Voir §4.5.

hooks

| Hook | Peut muter | Une erreur y … | |---|---|---| | beforeSend(ctx) | recipients, subject, content | fait échouer l'envoi (et donc rejouer) | | afterSend(ctx) | rien | est journalisée et ignorée | | onFailure(ctx, error) | rien | est journalisée et ignorée |

Forme recommandée — une référence de binding IoC :

hooks: "App/Notify/Hooks",

Ou en inline, si vous préférez :

hooks: {
  async beforeSend(ctx) {
    if (await estDesinscrit(ctx.recipients)) {
      ctx.abort("destinataire désinscrit");   // ni reprise, ni repli
    }
  },
},

afterSend et onFailure sont isolés volontairement : ils s'exécutent après un envoi déjà parti, et y lever une erreur ferait rejouer un message pourtant délivré.

ctx.abort(raison) écrit status = 'cancelled' et cancelled_reason. Une annulation n'est pas un échec : elle ne consomme aucune tentative.

Préférez le binding IoC à une fonction inline : une fonction dans config/notify.ts n'est pas sérialisable et se teste mal.


6. Écrire un driver

Un driver implémente NotifyServiceContract :

export default class KOpenSmsSender implements NotifyServiceContract {
  constructor(public settings) {}

  public async execute(
    notifiable: string[],          // numéros, pour un SMS
    subject: string,
    content: string,
    variants?: NotifyContentVariants   // { html?, text? }
  ) {
    await this.client.post("/messages", {
      to: notifiable,
      body: content,
      sender: this.settings?.sender,
    });
  }
}

Points de vigilance :

  • execute() prend trois paramètres obligatoires, dans cet ordre : destinataires, sujet, contenu. En omettre un décale les suivants et fait envoyer le sujet à la place du message.
  • notifiable est un string[] pour sms/push, et un Record<MailNotifyWay, {name, email}[]> pour mail.
  • Il n'est jamais vide : le worker court-circuite l'envoi et journalise un avertissement quand la liste résolue est vide.
  • variants est optionnel : les drivers antérieurs continuent de fonctionner.
  • Lever une erreur en cas d'échec fournisseur : c'est ce qui déclenche la reprise, la trace en base et, le cas échéant, le repli.

Driver push fourni

Adonis/Addons/Notify/Driver/FirebasePushSender couvre FCM HTTP v1 (Android, iOS, web).

npm i firebase-admin       # peer dependency OPTIONNELLE
services: {
  fcm: {
    type: "push",
    name: "Firebase Cloud Messaging",
    driver: "Adonis/Addons/Notify/Driver/FirebasePushSender",
    settings: { credential: "/chemin/service-account.json" },
  },
},

Il découpe les envois en lots de 500 jetons (limite FCM) et détecte les jetons devenus invalides. Leur cycle de vie reste à la charge de votre application — la librairie ne connaît pas votre stockage :

settings: {
  credential: "/chemin/service-account.json",
  async onInvalidTokens(tokens) {
    await DeviceToken.query().whereIn("token", tokens).delete();
  },
},

Sans ce rappel, les jetons morts restent en base et coûtent de la latence à chaque envoi.

Enregistrer votre propre driver

Enregistrez le binding dans un provider applicatif, puis référencez-le :

services: {
  kopen_sms: { type: "sms", name: "KOPENSMS", driver: "App/Notify/KOpenSmsSender" },
},

7. Exploitation

Journal des envois

Chaque envoi laisse une ligne dans notification_messages, écrite avant l'appel au fournisseur puis clôturée selon l'issue :

| status | Sens | |---|---| | pending | ligne créée, appel fournisseur en cours | | send | fournisseur acquitté, sent_at renseigné | | failed | échec, error contient le message du fournisseur | | cancelled | annulé par beforeSend, cancelled_reason renseigné | | delivered | réservé — nécessite les webhooks fournisseur | | read | réservé — idem |

attempts est incrémenté à chaque rejeu d'un même job_id : une ligne failed avec attempts = retry.limit + 1 signale une reprise épuisée. fallback_from distingue un rattrapage d'un envoi direct.

Ne pas envoyer deux fois

Si execute() réussit mais que le process meurt avant d'écrire le statut, pg-boss rejoue le job. Sans garde-fou, le SMS repart — et il est facturé.

idempotency: { enabled: true, windowSeconds: 3600 },   // défauts

Une clé déterministe est calculée à l'émission à partir de (événement, canal, payload), transportée par le job, et réservée en base avant l'appel au fournisseur. La réservation s'appuie sur un index unique partiel, pas sur un select préalable : deux workers concurrents ne peuvent pas gagner la course tous les deux.

Pour les envois répétitifs légitimes — un rappel quotidien au même payload — passez une clé explicite :

await Notify.send(event, data, ["mail"], { idempotencyKey: `rappel-${jour}` });

⚠️ La garantie est « at-least-once + déduplication applicative », pas « exactly-once ». Si le fournisseur a réellement expédié le message mais n'a pas répondu (timeout réseau), nous le comptons comme un échec : le doublon reste possible de son côté, et aucune librairie ne peut le savoir. windowSeconds borne aussi les réservations abandonnées : une ligne pending plus ancienne que la fenêtre est reprise, en supposant que le worker qui la détenait est mort avant d'appeler le fournisseur.

Où démarrer la file

| queue.autoStart | Effet | Compromis | |---|---|---| | "always" (défaut) | démarre dans tous les process | aucun risque | | "worker-only" | seulement dans le worker | moins de connexions, mais la file doit avoir été initialisée une fois, et Notify.send() depuis le web lève E_NOTIFY_QUEUE_NOT_STARTED | | "never" | jamais automatiquement | à vous d'appeler start() |

Débit

Par défaut, tous les canaux partagent une file traitée un job à la fois : un appel SMS lent bloque les mails.

queue: { partitionBy: "type", concurrency: { mail: 10, sms: 2, push: 20 } },

⚠️ Procédure de bascule. partitionBy: "type" change le nom des files (requestnotify:mail, …). Les jobs déjà enfilés sous l'ancien nom ne seraient plus consommés. Drainez d'abord : laissez tourner le worker en "none" jusqu'à vider la file, arrêtez-le, puis basculez.

Limiter le débit et les horaires

rateLimit: { sms: { max: 100, per: "minute" } },
window: { from: "08:00", to: "20:00", timezone: "Africa/Porto-Novo" },

Au dépassement, l'envoi est reporté, jamais rejeté. Hors fenêtre, le job est daté à la prochaine ouverture ; les fenêtres franchissant minuit (22:0006:00) sont gérées.

⚠️ max n'est pas un débit garanti à l'échelle d'une flotte : la répartition s'appuie sur un compteur local au process. Avec plusieurs émetteurs, la limite est atteinte plus tôt que prévu — l'erreur va du côté sûr, mais ne dimensionnez pas un contrat fournisseur dessus.

Repli inter-canaux

fallback: { sms: "mail" },

Déclenché à l'épuisement des reprises, jamais au premier échec. Profondeur plafonnée à un saut ; les cycles sont refusés au démarrage.

Reprise épuisée : la file morte

node ace notify:retry-failed --type=sms --since=24h --dry-run
node ace notify:retry-failed --type=sms --since=24h
node ace notify:purge-dead

La file morte sert au rejeu. La traçabilité est déjà dans notification_messages : la purger ne perd jamais l'historique.

pg-boss 8.4.2 n'a pas de file morte native — elle est apparue dans une majeure ultérieure dont le plancher PostgreSQL dépasse le nôtre. Celle-ci est construite à la main sur onComplete.

Synchroniser la configuration

node ace notify:actualize-store             # crée / met à jour, ne supprime rien
node ace notify:actualize-store --dry-run
node ace notify:actualize-store --prune     # supprime aussi les orphelins

⚠️ --prune supprime en cascade les destinataires des événements retirés de la configuration. Sans le flag, les orphelins sont seulement signalés.

Observabilité

Métriques, en JSON ou au format Prometheus :

Route.get("/metrics/notify", async ({ response }) => {
  const body = await Notify.metrics({ format: "prometheus" });
  return response.header("Content-Type", "text/plain").send(body);
});

Séries exposées : notify_messages_sent|failed|pending par canal, notify_send_latency_seconds (p50/p95), notify_workers (vivants / morts). Elles sont agrégées depuis la base, pas depuis un compteur en mémoire : un compteur de process repartirait de zéro à chaque redémarrage et serait faux dès qu'il y a plusieurs workers.

Traçage. Un correlationId est généré à Notify.send() et propagé jusqu'au driver ; il apparaît dans tous les logs [notify]. C'est lui qui permet de reconstituer le parcours d'une notification à partir d'une réclamation utilisateur. Vous pouvez fournir le vôtre pour le raccrocher à votre traçage applicatif :

await Notify.send(event, data, ["mail"], { correlationId: request.id() });

Événements applicatifs — pour réagir sans passer par les hooks de configuration :

Event.on("notify:sent", ({ event, type, service, correlationId }) => {
  // vos métriques, votre traçage…
});
Event.on("notify:failed", ({ event, type, error }) => {
  // votre alerting…
});

Données personnelles (RGPD)

⚠️ notification_messages contient des données personnelles : coordonnées et corps des messages. C'est un traitement au sens du RGPD — à documenter dans votre registre. Les lignes conservent l'id du destinataire même après masquage, ce qui rend une demande d'effacement traitable.

audit: { storeContent: false, redact: ["phone", "email"], retentionDays: 90 },
  • storeContent: false remplace le corps par son empreinte sha256:… : deux envois identiques restent rapprochables, le message n'est plus conservé.
  • redact masque partiellement (+229******12, j***@example.com). Le fournisseur reçoit toujours les vraies coordonnées — seule la trace en base est réduite.
node ace notify:purge --dry-run
node ace notify:purge                  # utilise audit.retentionDays
node ace notify:purge --older-than=30d

8. Dépannage — « rien n'est envoyé »

Commencez par node ace notify:doctor : il exécute les dix contrôles ci-dessous et affiche la remédiation. Code de sortie non nul si un contrôle échoue, --json pour la supervision.

Par ordre de fréquence réelle :

  1. Le worker notify:cluster-listener n'est pas lancé.
  2. node ace notify:actualize-store n'a pas été exécuté après modification de config/notify.ts.
  3. Aucun service n'est actif pour ce type (notification_services.default).
  4. Aucun destinataire n'est configuré pour ce couple événement / canal.
  5. Le template déclaré dans events[…].templates n'existe pas — le canal est alors ignoré avec un avertissement.
  6. L'extension pgcrypto n'est pas installée (PostgreSQL 12), donc pg-boss ne démarre pas.

Cas particulier : « les mails partent, mais la file morte et le repli non »

Symptôme : les envois aboutissent, mais rien n'arrive jamais en file morte et le repli inter-canaux ne se déclenche pas. Dans les logs PostgreSQL : null value in column "id" violates not-null constraint.

Cause : la table notify.job de pg-boss n'a pas de valeur par défaut sur sa colonne id. Cela arrive sur un schéma créé par une version ancienne de pg-boss, où les identifiants venaient de JavaScript — aucune migration de la 8.x ne rattrape ce défaut. Les envois continuent de passer, car nous fournissons un identifiant explicite à l'insertion ; mais les jobs d'état __state__completed__*, que pg-boss insère sans identifiant, échouent — et ce sont eux qui portent la file morte et le repli.

node ace notify:doctor le détecte et affiche la réparation :

ALTER TABLE notify.job ALTER COLUMN id SET DEFAULT gen_random_uuid();

Cette table appartient à pg-boss, pas à nos migrations : la librairie signale le problème, elle ne modifie pas le schéma d'une dépendance. Un schéma recréé de zéro par pg-boss 8.4.2 a le défaut correctement posé.


9. Migration & changelog

  • CHANGELOG.md — chaque version, avec ses ruptures.
  • MIGRATION.md — procédure 0.x → 1.0, dans l'ordre. Drainer les files est la première étape et la seule qui perd des notifications si on la saute.
  • COOKBOOK.md — recettes : notification transactionnelle, multi-tenant, fournisseur SMS local, rejeu des échecs.

La version reste en 0.9.x volontairement. Une 1.0 est une promesse de stabilité d'API : elle ne sera taguée qu'après au moins deux semaines de production réelle, métriques actives et zéro doublon constaté.