Help us shape the future of Go! Take the 2026 Go Developer Survey

notify

package
v1.17.0 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 6 Imported by: 0

README

Shared inbox

helpers/notify describes general notices and actionable items without importing an application, router, identity provider or database. helpers/notify/postgres stores them on the host's PostgreSQL pool. AuthKit, OpenRails, ContentKit and their hosts can use the same types; adopting this package does not require any of them to invent notifications.

General notices, such as a comment reply or a receipt, have an empty Status. Tracked actionable items, such as an operator finding, have open, resolved or dismissed status. Read and archive state are independent: reading a failed-payment notice does not fix the payment. Transition only records workflow state; the application authorizes and executes the real action. TransitionTx can commit that action and its resolution atomically. Revision checks reject stale actions.

ActionRequested preserves classification separately from workflow. A historical "payment failed" notice can record that action was requested, with an empty Status, without falsely claiming that the payment still fails today. Imports of such history retain the flag and can display "Action requested"; only an authoritative workflow should display "Open" or "Needs attention". Publishing a tracked StatusOpen item sets the flag automatically. Reading never changes either value.

The host controls which categories produce notices. Routine charge reminders/receipts can be off by default without suppressing failed-payment notices. Preferences and rendering are domain policy, so there is no hard-coded event taxonomy, email template or automatic sender in this package.

// The host creates the schema and runs migrations before accepting requests.
err := postgres.Migrate(ctx, pool, "app")
inbox, err := postgres.New(pool, postgres.Config{
    Schema: "app", Scope: "project:example",
})
item, err := inbox.Publish(ctx, notify.Input{
    Recipient: notify.Recipient{Audience: notify.AudienceUser, ID: userID},
    Origin: notify.Origin{System: "contentkit", Scope: projectID, ID: replyID},
    Kind: "comment.reply", Data: replyReferences, CreatedAt: createdAt,
})

Use PublishTx inside a domain mutation's pgx.Tx when both share the database. A rolled-back comment creates no inbox item. This legacy insert-once interface retains the first payload and local state on retry. Deduplication includes the local scope, audience, recipient and source system/scope/ID, permitting deliberate fan-out while isolating independent merchants and deployments. The host must explicitly map a verified source recipient to a local recipient. Matching email addresses grants no authority.

Revisioned producers

notify.Sink.Apply(ctx, []notify.Mutation) accepts the current state of up to 100 exact notification identities atomically. postgres.TxSink.ApplyTx joins that acceptance to the producer's pgx.Tx. The PostgreSQL store implements both. An adapter for a remote inbox must durably enqueue delivery in the supplied transaction; a successful enqueue does not claim the remote inbox already contains the message. No event-log polling or automatic sender is built into this library.

notice := notify.Input{
    Recipient: notify.Recipient{Audience: notify.AudienceUser, ID: userID},
    Origin: notify.Origin{System: "contentkit", Scope: appID, ID: "comment.reply:" + replyID},
    Kind: "comment.reply", Data: replyReferences, CreatedAt: createdAt,
    Visibility: notify.VisibilityApp, AppID: appID,
}
err := inbox.ApplyTx(ctx, tx, []notify.Mutation{{Input: notice, Revision: 1}})

// When the reply is deleted, send a newer state for the same exact identity.
err = inbox.ApplyTx(ctx, deleteTx, []notify.Mutation{{
    Input: notify.Input{Recipient: notice.Recipient, Origin: notice.Origin,
        Visibility: notice.Visibility, AppID: notice.AppID},
    Revision: 2, Withdrawn: true,
}})

The producer assigns positive revisions from its durable issue state. Delivery retries reuse the revision; allocating a new revision for each retry can incorrectly reopen a resolved issue. Lower revisions are successful no-ops. Equal revisions with the same normalized mutation are successful no-ops; equal revisions with different payload or withdrawal state return ErrConflict. JSON object-key ordering and whitespace do not affect retry identity, and numeric values are never converted through floating point.

Item.ProducerRevision is separate from Item.Revision, which still tracks local receipt and workflow changes. A higher-revision update preserves read/archive state. A higher-revision publication after withdrawal explicitly reopens the item unread and unarchived. Publishing cannot change an existing identity's visibility or app ID. Hosts authorize producer namespaces and recipient accounts before calling a sink.

Withdrawal immediately hides the receipt from active and archived lists, details, and reader mutations, regardless of whether it was read. A withdrawal arriving first stores a hidden marker; a delayed older publication cannot recreate the message. Legacy Publish retries also cannot resurrect it. Markers have no automatic expiration because deleting them without a proven retry/restore horizon would permit resurrection. Whole-account/app erasure removes the rows and retains its existing permanent recipient fence.

Batch withdrawal is a batch of exact mutations. Producers determine the affected IDs from their own domain data; there are no wildcard prefixes, ranges, dependency graphs, or destination-inbox lookups. ApplyTx uses a savepoint, so a failed batch leaves no partial mutations even if the caller handles its error and commits other work.

Migration 6 adds producer state to the existing inbox table; old rows have producer revision zero. MergeFrom preserves producer revisions, withdrawn markers, reader state, and recipient erasure. ClassifyLegacyOrigins only classifies unversioned legacy rows: versioned unclassified rows require exact source-state adoption, because relabeling them would invalidate their producer replay digest. Run explicit migrations before serving; stop old inbox readers/writers during the switch to revisioned publication because older binaries do not understand withdrawn markers. General platform event logs and their retention/cursors are independent of notification delivery.

List returns active receipts by default; ListOptions.Dismissed selects archived receipts. SetRead and SetDismissed return the canonical item and revision. Neither operation acknowledges the producer or changes workflow state. Get includes archived items. EraseRecipient takes the same transactional fence as publishing, deletes the inbox and rejects later publications for that recipient. It is permanent account erasure, not dismissal or unsubscribe. Hosts own retention policy and must never purge open work merely because it was read.

Hosts that combine several verified identities use ListRecipients, GetRecipients, SetReadRecipients and SetDismissedRecipients. Pass the freshly authorized recipients on every request. SQL filters this set before pagination and mutation; unlinking an identity removes its items from access immediately on the next authorization check. Cursors bind the normalized recipient set, scope and filters. Restart pagination after access changes. Inbox cursors are not durable source-delivery watermarks.

Origin.Subject optionally preserves the producer's recipient when the host maps it to a different local identity. It is provenance, not permission, and does not change deduplication identity. A producer retry preserves the original payload, source subject, read/archive state and workflow transitions.

General platform event logs expose facts for optional host integrations. A host may deliberately turn a selected fact into a notice, but the shared inbox does not require an event consumer. Producers can instead publish explicit notifications through a configured sink. Transport delivery, recipient read/archive state and domain resolution are separate operations.

Existing domain records can also project into the common model without duplicating authoritative workflow storage. Operator findings retain their evidence, severity, reopen rules and resolution executor. Revision: 0 denotes an unversioned external projection: call the source's authorized domain API to act on it. A historical event's ActionRequested flag does not imply that its problem remains unresolved.

The package creates no routes, workers or automatic delivery channels. AuthKit, OpenRails, ContentKit and application-specific producers can publish stable notification IDs through the same sink when configured. Applications own authentication, preferences, rendering and their existing job-runner lifecycle.

Run the real persistence checks against an owned PostgreSQL database:

NOTIFY_TEST_DATABASE_URL='postgres://…' go test -race ./notify/...

The tests also accept RIVER_TEST_DATABASE_URL, used by the repository's ordinary check script. Each test creates and removes only its own schema. No application or external service dependencies are added.

Documentation

Overview

Package notify defines notices and actionable inbox items shared by host applications. Authentication, rendering, delivery channels and the action that remedies a problem belong to the application. Reading never resolves an item. Existing domain records can be projected as Items without copying their authoritative state into another queue.

Index

Constants

View Source
const MaxMutations = 100

MaxMutations bounds one atomic publication/withdrawal batch.

Variables

View Source
var (
	ErrInvalid         = errors.New("notify: invalid input")
	ErrNotFound        = errors.New("notify: item not found")
	ErrConflict        = errors.New("notify: item changed")
	ErrRecipientErased = errors.New("notify: recipient erased")
)

Errors are stable outcomes of validation, scoped lookup and concurrent edits.

Functions

func ValidateRecipient

func ValidateRecipient(r Recipient) error

ValidateRecipient checks the scope-local identity; it grants no authority.

Types

type Adoption added in v1.17.0

type Adoption struct {
	Previous []Origin `json:"previous"`
	Current  Mutation `json:"current"`
}

Adoption maps exact legacy origins for Current's recipient to one current producer identity during an explicit, stopped-writer migration. The host authorizes the mapping; it never means a prefix, range or inbox search.

type AppView added in v1.13.0

type AppView struct {
	AppID         string
	LegacyOrigins []OriginScope
}

AppView presents tenant-wide receipts and one exact application's receipts. LegacyOrigins is an explicit compatibility allowance for unclassified rows. A nil or empty allowance admits no legacy origins.

type Audience

type Audience string

Audience selects an authorization boundary, not a delivery channel.

const (
	AudienceUser     Audience = "user"
	AudienceOperator Audience = "operator"
)

type Input

type Input struct {
	Recipient  Recipient       `json:"recipient"`
	Origin     Origin          `json:"origin"`
	Kind       string          `json:"kind"`
	Data       json.RawMessage `json:"data"`
	CreatedAt  time.Time       `json:"created_at"`
	Visibility Visibility      `json:"visibility,omitempty"`
	AppID      string          `json:"app_id,omitempty"`
	Status     Status          `json:"status,omitempty"`
	// ActionRequested records the item's category at creation. With empty Status
	// it is historical information, not a claim that the issue is still open.
	ActionRequested bool   `json:"action_requested,omitempty"`
	Severity        string `json:"severity,omitempty"`
}

Input is trusted application-authored content. Kind is extensible. Data must contain only fields the recipient may see. CreatedAt is the source time. Publish accepts StatusNotice or StatusOpen; workflow transitions are separate.

func Validate

func Validate(in Input) (Input, error)

Validate checks a publish input and supplies an empty JSON object when omitted.

type Item

type Item struct {
	ID string `json:"id"`
	Input
	ReadAt *time.Time `json:"read_at"`
	// DismissedAt archives this recipient's receipt without resolving work.
	DismissedAt *time.Time `json:"dismissed_at"`
	ResolvedAt  *time.Time `json:"resolved_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	Revision    int64      `json:"revision"`
	// ProducerRevision orders source mutations independently of receipt changes.
	ProducerRevision int64 `json:"producer_revision,omitempty"`
	// Withdrawn items are retained only to reject delayed producer mutations.
	Withdrawn bool `json:"withdrawn,omitempty"`
}

Item is an inbox record or a projection of an existing domain record. ReadAt and ResolvedAt describe different operations; neither implies the other. Revision is positive in the durable store and zero for unversioned projections.

type ListOptions

type ListOptions struct {
	Limit      int
	Cursor     string
	UnreadOnly bool
	Status     *Status
	// Dismissed selects archived receipts instead of the active inbox.
	Dismissed bool
}

ListOptions selects a bounded, newest-first page. Status nil includes notices and work items; a pointer to StatusNotice selects only general notices.

type Mutation added in v1.17.0

type Mutation struct {
	Input
	Revision  int64 `json:"revision"`
	Withdrawn bool  `json:"withdrawn,omitempty"`
}

Mutation is the current state of one exact producer identity. The producer durably assigns Revision before delivery; transport retries reuse it. A newer publication replaces the payload, while Withdrawn hides the item even if it has not arrived yet. Neither IDs nor withdrawals interpret wildcards.

func ValidateMutation added in v1.17.0

func ValidateMutation(in Mutation) (Mutation, error)

ValidateMutation validates and normalizes one producer mutation. Withdrawal needs only recipient, origin and visibility; payload fields are ignored.

type Origin

type Origin struct {
	System string `json:"system"`
	Scope  string `json:"scope"`
	ID     string `json:"id"`
	// Subject is the source recipient, when the destination maps it to a
	// different local identity. It grants no authority by itself.
	Subject string `json:"subject,omitempty"`
}

Origin preserves a producer's deployment, namespace and stable item ID. Scope can identify a merchant, project or account issuer. Each recipient receives at most one item for the same System, Scope and ID in one local store scope; Subject is optional provenance, not part of that identity.

type OriginScope added in v1.13.0

type OriginScope struct {
	System string `json:"system" yaml:"system"`
	Scope  string `json:"scope" yaml:"scope"`
}

OriginScope selects all items from one exact producer namespace. It is separate from recipient authorization and never grants access to an account.

type Page

type Page struct {
	Items      []Item `json:"items"`
	NextCursor string `json:"next_cursor,omitempty"`
}

Page is a bounded page; NextCursor is empty at the end of a current scan.

type Publisher

type Publisher interface {
	Publish(context.Context, Input) (Item, error)
}

Publisher is implemented by durable stores and application adapters. A retry with the same recipient and origin preserves the original content/read state.

type Reader

type Reader interface {
	List(context.Context, Recipient, ListOptions) (Page, error)
}

Reader is a recipient-scoped inbox. Callers supply an authorized recipient.

type Recipient

type Recipient struct {
	Audience Audience `json:"audience"`
	ID       string   `json:"id"`
}

Recipient names a user or an operator queue within the store's scope. Hosts authorize this pair; an email address is never an identity join.

type Sink added in v1.17.0

type Sink interface {
	Apply(context.Context, []Mutation) error
}

Sink durably accepts an atomic batch. Older revisions and identical retries are successful no-ops; conflicting mutations at one revision return ErrConflict. A nil error may mean durable queued delivery rather than a visible inbox item. Producers never need to list the destination inbox.

type Status

type Status string

Status is independent of read state. A general notice has no workflow; actionable items begin open and are explicitly resolved or dismissed.

const (
	StatusNotice    Status = ""
	StatusOpen      Status = "open"
	StatusResolved  Status = "resolved"
	StatusDismissed Status = "dismissed"
)

type Visibility added in v1.13.0

type Visibility string

Visibility identifies where a receipt may be presented; Origin remains provenance and replay identity. Empty visibility means an unclassified legacy item, never an implicit tenant-wide grant in an AppView.

const (
	VisibilityTenant Visibility = "tenant"
	VisibilityApp    Visibility = "app"
)

Directories

Path Synopsis
Package postgres implements notify's durable inbox on a host-owned PostgreSQL pool.
Package postgres implements notify's durable inbox on a host-owned PostgreSQL pool.

Jump to

Keyboard shortcuts

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