Inbox and realtime
How VitNode's notification inbox tracks read state, groups events, keeps the unread count exact and pushes it live over the WebSocket - plus the REST endpoints and frontend components.
Every recipient gets inbox items in core_notifications and one stored unread
count in core_notification_user_state. Each change to a user's inbox updates
that count in the same transaction and pushes the new absolute value to their
open tabs.
Read and unread
An item is unread when it is not archived and readSeq < activitySeq.
activitySeqstarts at 1 and goes up each time a grouped event joins the item.readSeqrecords how far the user has read.
Listing the inbox never marks anything read. Opening an item, marking it read, or "mark all as read" does.
The read boundary
The client sends the activitySeq it rendered as throughSeq:
POST /api/@vitnode/core/notifications/42/read
Content-Type: application/json
{ "throughSeq": 3 }If a fourth reply joined the item after the bell rendered it, activitySeq is
now 4. The item stays unread, because the user has not seen reply four yet.
Without throughSeq, the item is read up to its current activity. Repeating
the call changes nothing.
Mark all as read
POST /read-all takes the user's state lock and marks every unread item read in
one statement. A fan-out that committed before the lock is included. One still
running waits for the lock and then adds its item as unread, so nothing that
arrives during the click is lost. Pass { "category": "social" } to limit it
to one category.
Grouping
Types with grouping
merge events into one item per user, type, group key and time window:
- Window buckets. Windows are fixed slots counted in UTC, not sliding.
With
windowMinutes: 60, events between 14:00 and 14:59 UTC share an item, and an event at 15:00 starts a new one. - Reopening. A new event in a read item makes it unread again.
- Archive revival. A new event in an archived item brings it back to the inbox.
- Ordering. Items sort by last activity, so a grouped item jumps to the top when an event joins it.
- The bell counts items, not events. Ten replies in one grouped item add 1 to the unread count, not 10.
Unavailable items
Before returning a page, core re-checks every item: the type is still
registered, its stored data still parses, access still allows the user and
present still renders. If any check fails, the item comes back as a
placeholder:
{
"id": 42,
"available": false,
"title": "This notification is no longer available.",
"target": null,
"actors": []
}The placeholder keeps its read state, so users can still read or archive it and the count never includes a ghost they cannot clear. This covers deleted content, revoked access and uninstalled plugins.
The unread count
The count reaches the browser from three sources. Each one carries the same pair:
interface NotificationState {
unread: number // absolute, never a +1 or -1
revision: number // goes up with every committed change for this user
}- Session payload.
GET /api/@vitnode/core/users/sessionincludesuser.notifications. It is read fresh on every request, never cached with the session user. - WebSocket. Every committed inbox change sends
notificationsStateChannelto that user's connections, with areasonsuch ascreated,read,read_all,archived,removedorreconciled. - State endpoint.
GET /api/@vitnode/core/notifications/statereturns the current pair.
The client keeps whichever state has the highest revision and ignores anything older. A slow session response can never undo a newer realtime update. Messages are sent only after commit, so the browser never sees a count that was rolled back.
Reconnects and background tabs
WebSocket messages sent while a tab was offline are not replayed. Instead,
NotificationStateSync re-reads /state every time the socket connects, and
when a tab returns after more than 30 seconds in the background.
No socket at all? That happens when the API is mounted inside the web app on
the same origin (the default apps/web setup opens no WebSocket), or while
the socket is down. The count is then re-read every 60 seconds while the tab is
visible - one primary-key read - so the bell is at most a minute behind.
When the count changes, open notification lists refresh once per burst - 1 second after the last change, and at most 5 seconds after the first - instead of once per message.
Multiple API instances
Realtime across instances needs Redis
With REDIS_URL set, realtime messages are relayed between API instances over
Redis pub/sub, so a fan-out on instance A reaches a tab connected to instance
B. Without Redis, realtime only reaches clients connected to the same
instance. Other users still catch up from the session payload or the /state
endpoint on their next reconnect or page load.
REST endpoints
All routes act on the signed-in user and return 401 for guests. Paths are
relative to /api/@vitnode/core/notifications.
| Method | Path | Does |
|---|---|---|
GET | / | One page, newest activity first. Query: limit (1-50), cursor, unread, category, type |
GET | /state | { unread, revision } |
POST | /{id}/read | Mark read, optional body { throughSeq } |
POST | /{id}/unread | Mark unread again |
POST | /{id}/archive | Hide from the inbox |
POST | /read-all | Mark all read, optional body { category } |
GET | /preferences | Types and their channels |
PUT | /preferences | Save them |
Mark routes return the new { unread, revision }. Another user's item id
answers 404, exactly like a missing one.
Call them from a page with the universal fetcher:
const response = await fetcher({
plugin: '@vitnode/core',
method: 'get',
module: 'notifications',
path: '/state',
})Frontend pieces
Core ships the whole UI. Nothing needs wiring in a default theme.
| Piece | Where |
|---|---|
| Bell with unread badge | Site header, every screen size. Loads the recent list only when opened. |
| Notifications Center | /notifications - all or unread, category filter, infinite scroll |
| Preferences | /settings/notifications - channels and per type choices |
NotificationStateSync | Mounted with the realtime listeners. Keeps the count in step |
Related
Publish notifications
Publish a notification from a VitNode plugin with c.get("notifications").publish() - inside your transaction, with an idempotency key, to the users your plugin names.
Preferences
How VitNode decides which notifications each user receives in the notification list, as push and by email - installation policy, mandatory types, user choices and time zone.