notify

package module
v0.0.0-...-0b4d345 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 13 Imported by: 0

README

notify

A Laravel-notifications-style abstraction: a Notification declares which channels it goes out on (Via) and, per channel, its content via a small interface the channel type-asserts for (Mailable, Databasable, Broadcastable). A Hub fans a notification out to its registered channels and attempts every one named, even if an earlier channel failed.

Two subpackages provide notify's own channels beyond mail: broadcast publishes over a centrifuge-backed Valkey backplane for live, in-app pushes, and database (with database/bunx for projects using bun) persists notifications into a notifications table for a durable inbox.

The root package depends on github.com/gp-system/mail for the mail channel and on errs for wrapped errors; database depends on github.com/gp-system/dbx's pg (and bunx) driver subpackages for persistence; broadcast depends only on centrifuge, deliberately not on github.com/gp-system/queue: the two packages share a Valkey instance, not a dependency, so broadcast takes its own ValkeyConfig rather than reusing queue.Config. notify is developed as part of the gp-system tooling and used by the gpsystem kit, but has no dependency on the kit itself.

The problem it solves

Notifying a user usually means duplicating the same "who gets this and how" logic across a mail sender, a push/broadcast call, and an inbox insert, each wired ad hoc per feature. notify centralizes that into one Notification type per event, with per-channel content methods the Hub calls only when a channel is actually wired and the notification actually implements it.

Sending stays synchronous. For asynchronous delivery, dispatch an event (see github.com/gp-system/events) and call Hub.Send from a listener: events already provides at-least-once delivery and retries, so notify does not duplicate that.

Install

go get github.com/gp-system/notify@v0.1.0   # or @latest for the newest tag
go get github.com/gp-system/notify/broadcast@v0.1.0        # live, in-app push channel
go get github.com/gp-system/notify/database@v0.1.0         # persisted inbox channel (pgx-native)
go get github.com/gp-system/notify/database/bunx@v0.1.0    # persisted inbox channel (bun)

Usage

Defining and sending a notification
type OrderShipped struct {
	OrderID string
}

func (OrderShipped) NotificationName() string { return "orders.shipped" }

func (n OrderShipped) Via(ctx context.Context, r notify.Recipient) []string {
	return []string{notify.ChannelMail, notify.ChannelDatabase, notify.ChannelBroadcast}
}

func (n OrderShipped) ToMail(ctx context.Context, r notify.Recipient) (*mail.Message, error) {
	return mail.NewMessage().WithSubject("Your order shipped").WithText("Order " + n.OrderID), nil
}

func (n OrderShipped) ToDatabase(ctx context.Context, r notify.Recipient) (any, error) {
	return n, nil
}

hub := notify.NewHub(
	notify.NewMailChannel(mailer),
	notifydatabase.NewChannel(notifydatabase.NewStore(pg.NewDB(pool))),
	notify.NewBroadcastChannel(publisher, "rt"),
)

hub.Send(ctx, notify.Recipient{ID: "u1", Email: "u1@example.com"}, OrderShipped{OrderID: "42"})

Hub.Send stamps a delivery ID into ctx (notify.IDFromContext), so the database and broadcast channels for the same send reference the same ID: a client can reconcile a live push against the inbox row it backfills on reconnect, without a second round trip.

broadcast: live push over centrifuge
pub := broadcast.MustNewPublisher(ctx, broadcast.ValkeyConfig{
	Addr:     cfg.Valkey.Addr,
	Password: cfg.Valkey.Password,
	DB:       cfg.Valkey.DB,
}, cfg.Broadcast)

channel := notify.NewBroadcastChannel(pub, cfg.Broadcast.ChannelPrefix)

A Publisher is a Broadcaster that publishes through an embedded, publish-only centrifuge.Node: it never holds client connections, so a worker or module process can depend on it without running a gateway. NewNode builds the same kind of node for a full gateway server that additionally accepts client connections; the gpsystem kit's realtime package uses this to build a WebSocket gateway on top of broadcast.

database: persisted inbox
store := database.NewStore(pg.NewDB(pool))       // github.com/gp-system/dbx/pg
// or: store := bunx.NewStore(db)                // github.com/gp-system/dbx/bunx

channel := database.NewChannel(store)

database.MigrationSQL is the embedded goose migration that creates the notifications table; write it into a project's migrations directory once. A project's own HTTP layer wraps Store.List/CountUnread/MarkRead/ MarkAllRead with its DTOs and rbac guard.

Design rules

  • A channel is attempted only if it is both named by Via and registered in the Hub. An unregistered channel name is silently skipped, mirroring an unconfigured mail transport.
  • Every named channel is attempted regardless of earlier failures. Errors are joined, each wrapped with the channel and notification name.
  • Content interfaces are optional and independent. Broadcastable falls back to Databasable's payload when absent, so a notification wired for both channels does not need to duplicate its payload logic.
  • broadcast does not depend on queue. The two share a Valkey instance by convention, not by import; callers convert their own Valkey settings into broadcast.ValkeyConfig.

Documentation

Overview

Package notify is a Laravel-notifications-style abstraction: a Notification declares which channels it goes out on (Via) and, per channel, its content via a small interface the channel type-asserts for (Mailable, Databasable, Broadcastable in notify/broadcast). A Hub fans a notification out to its channels.

This is a deliberate exception to "centralize, don't abstract": it is a first-party system (not a wrapper around a single third-party API), meant to make building products faster, with room to add drivers for third-party notification services later. Keep it thin — no reflection in the hot path, one type assertion per channel per send.

Sending stays synchronous. For asynchronous delivery, dispatch an event and call Hub.Send from a listener — the events package already provides at-least-once delivery and retries; notify does not duplicate that.

Index

Constants

View Source
const (
	ChannelMail      = "mail"
	ChannelDatabase  = "database"
	ChannelBroadcast = "broadcast"
)

Channel names recognized by the notifications generated by the CLI and the channels the kit ships. Third-party drivers may define their own.

Variables

View Source
var ErrNoContent = errors.New("notify: notification has no content for channel")

ErrNoContent is returned by a Channel when a notification's Via names it but the notification does not implement that channel's content interface (e.g. Via returns ChannelMail but the type has no ToMail method).

Functions

func ContextWithID

func ContextWithID(ctx context.Context, id uuid.UUID) context.Context

ContextWithID attaches a delivery ID to ctx. Hub.Send calls this once per Send; channels read it with IDFromContext to correlate a database row with its broadcast envelope.

func IDFromContext

func IDFromContext(ctx context.Context) (uuid.UUID, bool)

IDFromContext returns the delivery ID stamped by Hub.Send, if any.

Types

type BroadcastChannel

type BroadcastChannel struct {
	// contains filtered or unexported fields
}

BroadcastChannel delivers notifications through a broadcast.Broadcaster to the recipient's own channel (broadcast.UserChannel). Its envelope carries the delivery ID Hub.Send stamped into ctx — the same ID the database channel uses as its row's primary key, when both channels are wired for a send — so a client can reconcile a live push with markAsRead against the database channel's REST surface without refetching.

func NewBroadcastChannel

func NewBroadcastChannel(pub broadcast.Broadcaster, channelPrefix string) *BroadcastChannel

NewBroadcastChannel returns a Channel named ChannelBroadcast that publishes through pub, using channelPrefix to namespace per-user channels (the same prefix the realtime gateway is configured with).

func (*BroadcastChannel) Name

func (c *BroadcastChannel) Name() string

Name returns ChannelBroadcast.

func (*BroadcastChannel) Send

Send requires r.ID (there is no on-demand broadcast target), builds the payload from Broadcastable or, failing that, Databasable, and publishes it to the recipient's user channel.

type Broadcastable

type Broadcastable interface {
	ToBroadcast(ctx context.Context, r Recipient) (any, error)
}

Broadcastable is implemented by notifications that build a distinct payload for the broadcast channel (notify.BroadcastChannel). It is optional: a notification with no ToBroadcast falls back to Databasable's payload, so a notification wired for database+broadcast does not need to duplicate its payload logic.

type Channel

type Channel interface {
	// Name identifies the channel; it must match the values Via returns.
	Name() string
	Send(ctx context.Context, r Recipient, n Notification) error
}

Channel delivers one notification to one recipient. Implementations type-assert n against their own content interface and return ErrNoContent when it is absent.

type Databasable

type Databasable interface {
	ToDatabase(ctx context.Context, r Recipient) (any, error)
}

Databasable is implemented by notifications deliverable on the database channel (notify/database.Channel). The returned value is JSON-marshaled into the stored row's payload; returning the notification itself is the common case.

Databasable lives in the root package, not notify/database, so a notification's content package only needs to import notify and mail, not the database channel's driver.

type Delivery

type Delivery struct {
	Recipient    Recipient
	Notification Notification
}

Delivery is one captured send, recorded by Memory.

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub fans a notification out to its Via channels. Build it once at startup with the channels the project has wired; it is read-only and safe for concurrent use afterwards.

func NewHub

func NewHub(channels ...Channel) *Hub

NewHub registers channels by Name. NewHub panics if two channels share a name — that is a boot-time wiring mistake, not a runtime condition.

func (*Hub) Send

func (h *Hub) Send(ctx context.Context, r Recipient, n Notification) error

Send stamps a delivery ID into ctx (retrievable with IDFromContext, e.g. by the database channel as its row's primary key and by the broadcast channel for its envelope), then attempts every channel n.Via names, in order. A channel name with no registered Channel is silently skipped (discard semantics, mirroring an unconfigured mail transport). Every named channel is attempted regardless of earlier failures; errors are joined, each wrapped with the channel and notification name for diagnosis.

type MailChannel

type MailChannel struct {
	// contains filtered or unexported fields
}

MailChannel delivers Mailable notifications through a kit mail.Mailer — the same transport add mail wires, so a project's mail-sending configuration (SMTP, discard, memory) is shared between direct mail.Mailer use and notifications.

func NewMailChannel

func NewMailChannel(m mail.Mailer) *MailChannel

NewMailChannel returns a Channel named ChannelMail that sends through m.

func (*MailChannel) Name

func (c *MailChannel) Name() string

Name returns ChannelMail.

func (*MailChannel) Send

Send type-asserts n against Mailable, builds the message, and routes it to r when the notification did not set an explicit recipient.

type Mailable

type Mailable interface {
	ToMail(ctx context.Context, r Recipient) (*mail.Message, error)
}

Mailable is implemented by notifications deliverable on the mail channel. If the returned message has no To recipient set, MailChannel routes it to r.Email/r.Name automatically, so ToMail only needs to build subject/body.

type Memory

type Memory struct {
	// contains filtered or unexported fields
}

Memory is a Channel that captures every delivery instead of sending it — for tests. Register it under whatever channel name you want to observe, e.g. NewMemory(notify.ChannelDatabase) to assert on ToDatabase-eligible sends without a real store.

func NewMemory

func NewMemory(name string) *Memory

NewMemory returns an empty Memory channel named name.

func (*Memory) Deliveries

func (m *Memory) Deliveries() []Delivery

Deliveries returns everything captured so far.

func (*Memory) Name

func (m *Memory) Name() string

Name returns the name the channel was constructed with.

func (*Memory) Reset

func (m *Memory) Reset()

Reset clears the captured deliveries.

func (*Memory) Send

func (m *Memory) Send(_ context.Context, r Recipient, n Notification) error

Send captures the delivery. It never fails and never inspects n's content interfaces — pair it with the mail/database channel's own tests for content-rendering coverage.

type Notification

type Notification interface {
	// NotificationName is a stable, dotted identifier (e.g.
	// "contact.admin_notification"); the database channel persists it and
	// spans/logs carry it. Must work on the zero value, like events.Event.
	NotificationName() string
	// Via lists the channel names this notification goes out on for r.
	Via(ctx context.Context, r Recipient) []string
}

Notification declares content deliverable on one or more channels. Via is evaluated per send, so the same notification type may go out on different channels for different recipients.

type Recipient

type Recipient struct {
	ID    string
	Email string
	Name  string
}

Recipient is who a notification goes to. ID is the recipient's stable identifier (an rbac.Identity.Subject in kit-issued JWTs); it is required by the database and broadcast channels. Email/Name route the mail channel. An on-demand send (no ID, e.g. a contact-form submitter) should only name ChannelMail in Via.

type Sender

type Sender interface {
	Send(ctx context.Context, r Recipient, n Notification) error
}

Sender is what services and listeners depend on to send notifications. *Hub implements it.

Directories

Path Synopsis
Package broadcast is the publish-side of the kit's realtime channel: a small Broadcaster interface plus the per-user/topic channel naming convention, backed by an embedded centrifuge.Node publishing through a Valkey broker.
Package broadcast is the publish-side of the kit's realtime channel: a small Broadcaster interface plus the per-user/topic channel naming convention, backed by an embedded centrifuge.Node publishing through a Valkey broker.
Package database implements the notify database channel: it persists Databasable notifications into a notifications table and exposes the minimal read side (list, unread count, mark read) a project's own HTTP domain wraps with its own DTOs and rbac guard.
Package database implements the notify database channel: it persists Databasable notifications into a notifications table and exposes the minimal read side (list, unread count, mark read) a project's own HTTP domain wraps with its own DTOs and rbac guard.
bunx
Package bunx is the bun adapter for the notify database channel, mirroring the dbx/bunx split and events/outbox/bunx: projects that wire the bun transactor use it so notification inserts/updates join the bun transaction opened by WithinTransaction.
Package bunx is the bun adapter for the notify database channel, mirroring the dbx/bunx split and events/outbox/bunx: projects that wire the bun transactor use it so notification inserts/updates join the bun transaction opened by WithinTransaction.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL