@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-bosssur PostgreSQL.
Sommaire
- Présentation
- Démarrage rapide
- Concepts
- Guides
- Référence de configuration
- Écrire un driver
- Exploitation
- Dépannage
- 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-listeneren 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/notifyCREATE EXTENSION pgcrypto; -- requis en PostgreSQL 12node ace migration:run1. Déclarez l'événement et son payload — contracts/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-le — config/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-storeupdate 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-listenerimport 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_todésigné à l'appel n'apparaît jamais dans un envoisms: leswaynon 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 canalLe 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].retry → retryByType[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.tsn'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.notifiableest unstring[]poursms/push, et unRecord<MailNotifyWay, {name, email}[]>pourmail.- Il n'est jamais vide : le worker court-circuite l'envoi et journalise un avertissement quand la liste résolue est vide.
variantsest 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 OPTIONNELLEservices: {
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éfautsUne 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.
windowSecondsborne aussi les réservations abandonnées : une lignependingplus 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 (request→notify: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:00 → 06:00) sont gérées.
⚠️
maxn'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-deadLa 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⚠️
--prunesupprime 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_messagescontient 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'iddu destinataire même après masquage, ce qui rend une demande d'effacement traitable.
audit: { storeContent: false, redact: ["phone", "email"], retentionDays: 90 },storeContent: falseremplace le corps par son empreintesha256:…: deux envois identiques restent rapprochables, le message n'est plus conservé.redactmasque 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=30d8. 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 :
- Le worker
notify:cluster-listenern'est pas lancé. node ace notify:actualize-storen'a pas été exécuté après modification deconfig/notify.ts.- Aucun service n'est actif pour ce type (
notification_services.default). - Aucun destinataire n'est configuré pour ce couple événement / canal.
- Le template déclaré dans
events[…].templatesn'existe pas — le canal est alors ignoré avec un avertissement. - L'extension
pgcrypton'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é.
