Notifications + SSE
In-app notifications persist as rows in the notifications table and stream to the user's personal channel in realtime via SSE. Facteur (@facteurjs/adonisjs) is the framework: one Notification<User, Params> class per type, with a deliverBy: { database, transmit } config and per-channel methods (asDatabaseMessage, asTransmitMessage). Transmit (@adonisjs/transmit) is the SSE transport — in-memory / single-node by default (transport: null in config/transmit.ts); swap to a distributed transport (e.g. Redis) when scaling out to multiple instances. Emission always goes through domain events: actions emit, a listener calls facteur.notification(...).params(...).to([user]).send().
Rules
- A dedicated
notifications module owns the model, transformer, controller and routes for the bell:
GET /notifications → list + unseen count.
POST /notifications/:id/read → mark one read.
POST /notifications/seen → mark all as seen (the dot disappears; row stays "unread").
POST /notifications/read → mark all read.
- Notification classes live at
app/<mod>/notifications/<name>_notification.ts, extending Notification<User, Params>:static options = { name: 'user-welcome', deliverBy: { database: true, transmit: true } }
asDatabaseMessage() { return DatabaseMessage.create().setType(...).setContent({...}).setTags([...]) }
asTransmitMessage() { return TransmitMessage.create().setContent({ kind: '...' }) }
- User routing via
User.notificationTargets():{
database: { notifiableId: String(this.id) },
transmit: { channel: `notifications/user-${this.id}` },
}
Facteur maps each channel's target automatically.
- Emission goes through events — see [[actions-events]]. The listener calls:
await facteur.notification(SomeNotification).params({...}).to([user]).send()
- Shared prop
unseenNotifications (count) is computed in the Inertia middleware when a user is authenticated. It powers the bell badge on first render — the SSE stream keeps it in sync afterward.
- Bell UI subscribes to the per-user channel via
useNotificationsChannel(user.id, onIncoming), hits the endpoint to fetch the list when opened, marks-all-seen on open, marks-read on click.
- Frontend SSE client is a lazy singleton: one
EventSource on /__transmit/events, all channels multiplex through it.
- SSE transport —
apps/web/config/transmit.ts defaults to transport: null (in-memory, single-node). The @adonisjs/transmit provider auto-registers the /__transmit/* routes, so the bell works out of the box. Swap transport for a distributed driver (Redis) when horizontally scaling.
Doc refs
Workflow
Add a new notification type
- Class at
app/<mod>/notifications/<name>_notification.ts:import { DatabaseMessage } from '@facteurjs/adonisjs/channels/database'
import { TransmitMessage } from '@facteurjs/adonisjs/channels/transmit'
import { Notification } from '@facteurjs/adonisjs/types'
export interface OrderShippedParams { orderId: number; trackingUrl: string }
export default class OrderShippedNotification extends Notification<User, OrderShippedParams> {
static options = { name: 'order-shipped', deliverBy: { database: true, transmit: true } }
asDatabaseMessage() {
const { orderId, trackingUrl } = this.params
return DatabaseMessage.create()
.setType('order-shipped')
.setContent({ title: 'Your order shipped', body: `Order #${orderId}`, link: trackingUrl })
.setTags(['order', 'shipping'])
}
asTransmitMessage() {
return TransmitMessage.create().setContent({ kind: 'order-shipped' })
}
}
- Emit via event. The action does:
emitter.emit('order:shipped', { user, orderId, trackingUrl })
- Listener in
<mod>/start/events.ts:emitter.on('order:shipped', async ({ user, orderId, trackingUrl }) => {
await facteur.notification(OrderShippedNotification)
.params({ orderId, trackingUrl })
.to([user])
.send()
})
- Frontend rendering reads
content.title / content.body / content.link from the persisted row — the bell renders every notification uniformly. kind on the transmit message is available for client-side filtering by type if needed.
- Verify: a functional spec that emits the event and asserts a notification row persisted for the user. Don't fake the emitter in a listener spec — see [[testing]].
Custom SSE channel (not a Facteur notification)
For resource-scoped realtime (job progress, live status) that isn't a per-user Facteur notification — hardcoded channel pattern, transmit.authorize, broadcast from a listener → references/custom-sse-channel.md.
Testing
- Endpoint test: insert rows directly via
Notification.create({...}) and hit the 4 endpoints.
- End-to-end listener test:
mail.fake() first (to keep any co-fired mail from actually sending), then await emitter.emit('event:name', payload), then assert the Notification row exists.
See [[testing]].
Anti-patterns
- ❌ Calling
facteur.notification(...).send() directly from an action — emit an event; the listener sends.
- ❌ Hardcoding
notifications/user-<id> in components — use the per-user subscription hook.
- ❌ Storing render logic in the transmit message — the DB row is the durable copy; transmit is just a nudge (
{ kind: '...' } is enough).
- ❌ Sending massive payloads over transmit — the browser refetches the list on notification; keep transmit small.
Related skills
[[actions-events]] · [[routes]] · [[mail]] · [[layout-shells]] · [[testing]] · [[authorization]]
1---2name: notifications3description: In-app notifications + realtime SSE in an AdonisJS + Inertia app. Stack: `@facteurjs/adonisjs` for the notification classes and delivery channels + `@adonisjs/transmit` for the SSE transport. Notifications persist to Postgres and push to a per-user SSE channel. Bell + unread badge in the logged-in shell. Emission goes through domain events. Use when adding a new notification type, wiring a listener, adding a custom SSE channel, or debugging the bell. Trigger on: "add notification", "notify user", "SSE", "bell", "facteur", "transmit".4license: MIT5---67# Notifications + SSE89In-app notifications persist as rows in the `notifications` table and stream to the user's personal channel in realtime via SSE. Facteur (`@facteurjs/adonisjs`) is the framework: one `Notification<User, Params>` class per type, with a `deliverBy: { database, transmit }` config and per-channel methods (`asDatabaseMessage`, `asTransmitMessage`). Transmit (`@adonisjs/transmit`) is the SSE transport — in-memory / single-node by default (`transport: null` in `config/transmit.ts`); swap to a distributed transport (e.g. Redis) when scaling out to multiple instances. Emission always goes through domain events: actions emit, a listener calls `facteur.notification(...).params(...).to([user]).send()`.1011## Rules1213- **A dedicated `notifications` module** owns the model, transformer, controller and routes for the bell:14 - `GET /notifications` → list + unseen count.15 - `POST /notifications/:id/read` → mark one read.16 - `POST /notifications/seen` → mark all as seen (the dot disappears; row stays "unread").17 - `POST /notifications/read` → mark all read.18- **Notification classes** live at `app/<mod>/notifications/<name>_notification.ts`, extending `Notification<User, Params>`:19 ```ts20 static options = { name: 'user-welcome', deliverBy: { database: true, transmit: true } }21 asDatabaseMessage() { return DatabaseMessage.create().setType(...).setContent({...}).setTags([...]) }22 asTransmitMessage() { return TransmitMessage.create().setContent({ kind: '...' }) }23 ```24- **User routing** via `User.notificationTargets()`:25 ```ts26 {27 database: { notifiableId: String(this.id) },28 transmit: { channel: `notifications/user-${this.id}` },29 }30 ```31 Facteur maps each channel's target automatically.32- **Emission goes through events** — see [[actions-events]]. The listener calls:33 ```ts34 await facteur.notification(SomeNotification).params({...}).to([user]).send()35 ```36- **Shared prop `unseenNotifications`** (count) is computed in the Inertia middleware when a user is authenticated. It powers the bell badge on first render — the SSE stream keeps it in sync afterward.37- **Bell UI** subscribes to the per-user channel via `useNotificationsChannel(user.id, onIncoming)`, hits the endpoint to fetch the list when opened, marks-all-seen on open, marks-read on click.38- **Frontend SSE client** is a lazy singleton: one `EventSource` on `/__transmit/events`, all channels multiplex through it.39- **SSE transport** — `apps/web/config/transmit.ts` defaults to `transport: null` (in-memory, single-node). The `@adonisjs/transmit` provider auto-registers the `/__transmit/*` routes, so the bell works out of the box. Swap `transport` for a distributed driver (Redis) when horizontally scaling.4041## Doc refs4243- Facteur — https://github.com/facteurjs/facteur44- AdonisJS Transmit — https://docs.adonisjs.com/guides/digging-deeper/transmit45- SSE spec (MDN) — https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events4647## Workflow4849### Add a new notification type50511. **Class** at `app/<mod>/notifications/<name>_notification.ts`:52 ```ts53 import { DatabaseMessage } from '@facteurjs/adonisjs/channels/database'54 import { TransmitMessage } from '@facteurjs/adonisjs/channels/transmit'55 import { Notification } from '@facteurjs/adonisjs/types'5657 export interface OrderShippedParams { orderId: number; trackingUrl: string }5859 export default class OrderShippedNotification extends Notification<User, OrderShippedParams> {60 static options = { name: 'order-shipped', deliverBy: { database: true, transmit: true } }6162 asDatabaseMessage() {63 const { orderId, trackingUrl } = this.params64 return DatabaseMessage.create()65 .setType('order-shipped')66 .setContent({ title: 'Your order shipped', body: `Order #${orderId}`, link: trackingUrl })67 .setTags(['order', 'shipping'])68 }6970 asTransmitMessage() {71 return TransmitMessage.create().setContent({ kind: 'order-shipped' })72 }73 }74 ```752. **Emit via event**. The action does:76 ```ts77 emitter.emit('order:shipped', { user, orderId, trackingUrl })78 ```793. **Listener** in `<mod>/start/events.ts`:80 ```ts81 emitter.on('order:shipped', async ({ user, orderId, trackingUrl }) => {82 await facteur.notification(OrderShippedNotification)83 .params({ orderId, trackingUrl })84 .to([user])85 .send()86 })87 ```884. Frontend rendering reads `content.title` / `content.body` / `content.link` from the persisted row — the bell renders every notification uniformly. `kind` on the transmit message is available for client-side filtering by type if needed.895. Verify: a functional spec that emits the event and asserts a notification row persisted for the user. Don't fake the emitter in a listener spec — see [[testing]].9091### Custom SSE channel (not a Facteur notification)9293For resource-scoped realtime (job progress, live status) that isn't a per-user Facteur notification — hardcoded channel pattern, `transmit.authorize`, broadcast from a listener → [references/custom-sse-channel.md](references/custom-sse-channel.md).9495### Testing9697- Endpoint test: insert rows directly via `Notification.create({...})` and hit the 4 endpoints.98- End-to-end listener test: `mail.fake()` first (to keep any co-fired mail from actually sending), then `await emitter.emit('event:name', payload)`, then assert the `Notification` row exists.99100See [[testing]].101102## Anti-patterns103104- ❌ Calling `facteur.notification(...).send()` directly from an action — emit an event; the listener sends.105- ❌ Hardcoding `notifications/user-<id>` in components — use the per-user subscription hook.106- ❌ Storing render logic in the transmit message — the DB row is the durable copy; transmit is just a nudge (`{ kind: '...' }` is enough).107- ❌ Sending massive payloads over transmit — the browser refetches the list on notification; keep transmit small.108109## Related skills110111[[actions-events]] · [[routes]] · [[mail]] · [[layout-shells]] · [[testing]] · [[authorization]]