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 #

A missing channel warns, it doesn't crash

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.