@samveys/notifications

Version
1.0.2Nov 5, 2026
Install
pnpm add @samveys/notifications

From packages/notifications/README.md in the framework repository.

One notification, several channels: mail and a database inbox are built in, and extend adds more.

Define and send

ts
import { database, defineDatabaseConfig, sqlite } from '@samveys/database'
import { mail, smtp } from '@samveys/mail'
import { defineNotification, notifications } from '@samveys/notifications'
import type { Notifiable } from '@samveys/notifications'

interface User extends Notifiable {
  readonly name: string
}

export const invoicePaid = defineNotification<User>({
  type: 'invoice.paid',
  via: (user) => (user.email === undefined ? ['database'] : ['mail', 'database']),
  toMail: (user) => ({ subject: `Thanks ${user.name}`, text: 'Your invoice was paid.' }),
  toDatabase: (user) => ({ message: `Invoice paid by ${user.name}` }),
})

export const plugins = [
  database(defineDatabaseConfig({ default: 'main', connections: { main: sqlite({ file: 'app.sqlite' }) } })),
  mail({ from: 'noreply@sadata.id', transport: smtp({ host: 'smtp.example.com', port: 587, requireTLS: true }) }),
  notifications({ notifications: [invoicePaid as never] }),
]

await notifications.notify(user, invoicePaid) mails user.email (the draft's to defaults to it; a user without an email and a mail channel is SVS_E103) and stores an inbox row in samveys_notifications. An unknown channel name is refused before anything is sent.

Inbox

ts
import type { Notifications } from '@samveys/notifications'

export async function readInbox(service: Notifications, user: { id: number }): Promise<number> {
  const unread = await service.unread(user)
  for (const entry of unread) await service.markAsRead(entry.id)
  return unread.length
}

all(user), unread(user), markAsRead(id) and markAllAsRead(user) only touch the rows of that user.

Queue a notification

Set queue: true and install queue() from @samveys/queue: notify then dispatches one job and returns; a worker (svs queue:work) delivers it on every channel. List the notification in notifications({ notifications: [...] }) so a worker can resolve it by type.

More channels

ts
notifications.extend('broadcast', {
  async send(user, notification) {
    publish(`user.${user.id}`, notification.payloadFor('broadcast', user))
  },
})

A notification supplies the payload for a custom channel with toChannel: { broadcast: (user) => ({ … }) }.