Notifications
One event. Every channel.
defineNotification declares which channels an event goes to and how it renders for
each; createNotifier fans it out. A built-in database channel persists in-app
notifications with unread tracking, while mail and custom channels ride alongside — an
unregistered channel is warned about, never thrown.
The database channel #
databaseChannel persists notifications to a table you migrate once. Give it a
NotificationDbHandle (exec/query over ?
placeholders); the clock and id factory are injectable for tests.
import { createNotifier, defineNotification, databaseChannel } from '@apex-stack/core/server'
const db = databaseChannel({ handle })
// Columns: id, notifiable_id, type, data, read_at, created_at
await handle.exec(db.migrationSql())
Define a notification #
defineNotification<Payload> declares via() — the channels this event
targets — plus one to<Channel> render per channel. A channel with no matching
render receives the raw payload.
const invoicePaid = defineNotification<{ invoiceId: number; amount: number }>({
via: () => ['database', 'mail'],
toDatabase: ({ payload }) => ({ type: 'invoice.paid', invoiceId: payload.invoiceId }),
toMail: ({ payload }) => ({ subject: `Invoice #${payload.invoiceId} paid`, amount: payload.amount }),
})
Dispatch #
Register your channels with createNotifier, then send to a notifiable
(anything with an id). Each registered channel gets its own render — the database
channel persists toDatabase(); a mail channel is any function receiving
toMail().
const notifier = createNotifier({
channels: {
database: db,
mail: async (rendered) => { await mailer.send(toEmail(rendered)) },
},
})
await notifier.send({ id: 42 }, invoicePaid({ invoiceId: 7, amount: 250 }))
Unread & mark read #
The database channel scopes reads to one recipient, oldest-first. markRead drops a
notification from the unread set.
const unread = await db.unread(42) // oldest-first, scoped to recipient 42
unread[0].type // → 'invoice.paid'
unread[0].data // → { type: 'invoice.paid', invoiceId: 7 }
await db.markRead(unread[0].id) // no longer returned by unread()
Missing channels #
If a notification's via() selects a channel you didn't register (say
sms), the notifier delivers the channels it does have and warns about the
rest — send() resolves rather than throwing. Injection-safe table names apply to the
database channel exactly as to the queue.
The server barrel also exposes buildNotificationsMigrationSql if you'd rather emit the
table DDL without constructing a channel.