Every account-based app eventually grows a notification bell: a persistent,
per-user feed with read/unread state and an unread count. Autumn ships that
composition as a first-class primitive — the Notifications service — so you
add a working feed with one generate command and one notify(...) call, with
zero hand-written SQL.
This is the in-app/database channel only: it is not flash (transient, single-request) and not the mailer (email is a different delivery channel).
Quick start
Scaffold the table, a minimal feed API, and a smoke test:
autumn generate notifications
autumn migrate
The generator emits a backend-aware migration (Postgres or SQLite, matching
your app's configured backend), a src/notifications.rs module with feed
routes, registers those routes in main.rs, and writes an in-process smoke
test (tests/notifications_feed.rs) that exercises notify → list → mark-read
through TestClient — run it with cargo test.
Then notify from anywhere a handler runs:
use autumn_web::notifications::Notifications;
use autumn_web::prelude::*;
#[post("/comments")]
async fn create_comment(notifications: Notifications) -> AutumnResult<&'static str> {
// ... create the comment ...
notifications
.notify(recipient_id, "comment.created", serde_json::json!({ "post": 42 }))
.await?;
Ok("ok")
}
The API
Notifications is an extractor, surfaced the same way as Session and Db:
declare it as a handler parameter. It provides:
| Method | Behavior |
|---|---|
notify(recipient_id, kind, payload) | Persist a new unread notification; immediately visible to reads |
list(recipient_id, &ListQuery, &PageRequest) | One Page of the feed (shipped pagination) |
unread_count(recipient_id) | Count of rows with read_at unset |
mark_read(id) / mark_read_for(recipient_id, id) | Mark one read — idempotent; the _for variant refuses to touch other recipients' rows |
mark_all_read(recipient_id) | Mark everything read; returns how many transitioned; idempotent |
Reading the feed reuses Page/ListQuery/PageRequest, so the usual query
parameters work out of the box: ?page=2&size=20, ?filter[unread]=true,
?filter[kind]=comment.created, ?sort=created_at&dir=asc. Without an
explicit sort the feed returns newest-first. Unknown filters and sort keys are
ignored, matching the repository list() contract.
use autumn_web::notifications::{Notification, Notifications};
use autumn_web::prelude::*;
#[get("/notifications")]
async fn feed(
Auth(user): Auth<CurrentUser>,
query: ListQuery,
page: PageRequest,
notifications: Notifications,
) -> AutumnResult<Json<Page<Notification>>> {
Ok(Json(notifications.list(user.id, &query, &page).await?))
}
In user-facing handlers prefer mark_read_for(user.id, id) over
mark_read(id): it scopes the update to the signed-in recipient, so a user
cannot mark someone else's notification read by guessing ids.
Storage
Notifications delegates to a pluggable NotificationStore, resolved once
per app:
- A store you registered via
AppBuilder::with_notification_store(...). DbNotificationStorewhen a database pool is configured — the persistent default, backed by thenotificationstable the generator scaffolds.MemoryNotificationStoreotherwise — a process-local fallback for tests and DB-less development (contents are lost on restart, and the store grows without bound — do not ship it as the production store).
The notifications table stores id, recipient_id, kind, a JSON
payload (TEXT-serialized, so it is identical on Postgres and SQLite), a
nullable read_at, and created_at. Timestamps are TIMESTAMPTZ on
Postgres and RFC 3339 TEXT on SQLite. Each row also has a
tenant_id (see Multi-tenancy). Keep payloads small and
reference-shaped ({"post": 42}, not the post body): they are stored
verbatim per recipient and re-sent on every feed page.
To plug in a custom backend, implement NotificationStore and register it:
autumn_web::app()
.with_notification_store(MyStore::new())
.run()
.await;
Multi-tenancy
With [tenancy] enabled = true, all tenants share one notifications table.
Each row stores the tenant that wrote it. The store gets the tenant from the
resolved tenant of the request. It never reads it from the request body.
- Two tenants can use the same
recipient_id. Their feeds, unread counts andmark_*calls stay separate. - Without a tenant in scope, the tenant is
''. A row with a tenant is hidden when no tenant is in scope. - A job or script has no request. Wrap the call in
autumn_web::tenancy::with_tenant(tenant_id, async { ... }). - A custom
NotificationStoremust scope by tenant too.
Realtime push over channels (optional, best-effort)
With the ws feature enabled you can push each new notification to connected
clients over the existing channels transport. The conventional
per-recipient topic is Notifications::topic(recipient_id)
("notifications:{recipient_id}").
The built-in helper persists and then publishes the stored notification as JSON:
#[post("/likes")]
async fn like(notifications: Notifications) -> AutumnResult<&'static str> {
// Persists, then publishes on "notifications:{recipient}". The broadcast
// is best-effort: a channel failure never fails the notify.
notifications
.notify_with_push(recipient_id, "like", serde_json::json!({}))
.await?;
Ok("ok")
}
Or wire it manually — the important part is ignoring the publish result, so a dead channel (or simply "no subscribers right now") never fails the write:
let n = notifications.notify(user.id, "like", payload).await?;
let _ = state
.broadcast()
.publish(&Notifications::topic(user.id), serde_json::to_string(&n)?);
A WebSocket or SSE handler subscribes to the same topic to stream the feed:
// SECURITY: derive the topic from the *authenticated* user (Auth/session),
// never from a client-supplied id — topics are guessable and the pushed JSON
// contains the full notification payload. For channel-level enforcement use
// the authorized subscription helpers (`subscribe_authorized` /
// `sse::stream_authorized`, see the realtime guide).
let mut rx = state.channels().subscribe(&Notifications::topic(user.id));
while let Ok(msg) = rx.recv().await {
// forward msg (the notification JSON) to the client
}
Inside a tenant scope the topic is notifications:{tenant}:{recipient_id}.
Subscribe from a request that runs in the same tenant.
Out of scope (by design)
The bell/dropdown widget itself, email/SMS delivery, per-user notification preferences, digests, and cross-recipient fan-out are deliberately not part of this primitive — it ships the data + API layer that those can build on.