@aria-framework/notify
v0.3.1
Published
Aria App Framework — notify module. In-app notifications: ownership-scoped rows, a per-process SSE hub for live delivery, and a fire-and-forget emitter that needs no request and no actor, so a scheduler can raise one.
Readme
@aria-framework/notify
In-app notifications: durable rows, live delivery to open browsers, and an emitter that a scheduler can call.
Three pieces, in three places, because that is where they have to run:
| | thread | what it is |
|---|---|---|
| createStore | worker | the SQL, ownership-scoped by user_id |
| createHub | main | open SSE responses, in memory |
| createEmitter | main | create then push, fire-and-forget |
// worker
const store = notify.createStore({ getDb }); // expose as a model the main thread invokes
// main
const hub = notify.createHub();
const { emit, touch } = notify.createEmitter({ invoke: dbClient.invoke, hub, logger });
emit([userId], { type: 'invoice.overdue', title: 'Invoice #412 is overdue',
link: '/invoices/412', entity_type: 'invoice', entity_id: 412 });No request, no actor
Every emitter in the app this came from took a req and derived the actor from
req.session.user. That is right for a reply, an assignment, a mention — a person did
something. It is wrong for a scheduler deciding an invoice is overdue, or a webhook recording a
payment: no session, no name, nobody to put in front of the title.
So the core takes user ids and a payload; an actor is an optional string. A request-shaped convenience belongs in the app as a thin wrapper. A system-raised notification is then the ordinary case rather than a special one — had this required a request, the scheduler would have grown a second emitter beside it, which is the duplication the extraction exists to prevent.
Fire and forget
emit() returns a promise for tests and for callers that genuinely want to wait, and it never
rejects. A notification is never more important than the action that caused it: a bell that
fails must not fail a reply or a payment. Failures are logged and that is all. A synchronously
throwing invoke is caught too, which the obvious .catch() version misses.
Ownership scoping IS the authorisation
Every read and every write carries WHERE user_id = ?. An id you do not own matches zero rows —
the same answer as an id that does not exist, so it leaks nothing either. There is no separate
permission check to forget, and moving that scope to a caller-side if would be a silent
privilege escalation rather than a visible break. Read it as a constraint on future edits.
Two channels
- users — "tell these RECIPIENTS something happened". The bell.
- entities — "tell whoever is LOOKING at this record that it changed".
touch(type, id).
The second is not a nicety. Pushes on the first go to recipients, so someone reading a record they do not watch is told nothing — which is the ordinary state of an unassigned ticket or an unowned invoice, and exactly the one most likely to be open when it changes.
Both events are contentless: a type and an id. The client re-fetches through its own
permission-checked route, so neither channel can leak a title or a body even if a subscription
were wrong, and the app's own view permission stays the single place access is decided. One
consumer briefly included an actorUserId "so a viewer can ignore its own action"; no client
ever read it, so its only effect was disclosing a staffer's user id to every other viewer.
A push reaches the page that CAUSED it — including mid-navigation
Since there is no actorUserId, a client that must not react to its own change has to know when
it made one. That is easy to get wrong in one specific way, and a consumer shipped the mistake:
A form POST is a navigation, but the browser keeps the old page on screen — and its stream open — until the response arrives. So a push written while handling that POST is delivered to the very page that caused it, which then re-fetches, sees its own change, and announces it. The redirect lands a moment later and replaces everything. The consumer's code carried a comment asserting the opposite: that a page which redirects "never sees an event for its own action".
The client-side fix is small — set a flag on submit and ignore events while it is set — but it
has to be deliberate, and beforeunload is too late (it fires once the response is already
arriving). This is not something the hub can do for you: it writes to open responses and has no
idea which one issued the request.
There is also no replay. pushEntity writes to whoever is connected at that instant, so an
event a page misses while navigating is simply gone — which is why suppressing it costs nothing.
THE LIMIT: one process
The hub is in memory and per-process. Two app processes behind a load balancer each hold half the connections, so a push from one reaches only its own half — silently, with no error anywhere. Correct for a single process, which is what the current consumers run. A multi-process deployment needs a shared bus and this module is not it.
Stated here rather than discovered, because nothing about the failure looks like a failure.
What stays the app's
Who receives a notification, what the types are called, what the copy says, the migration, the routes, and whether any of it needs a permission. "Who cares about this thing" is domain knowledge — the answer is "the ticket's watchers" in one app and "whoever owns the invoice" in another.
SCHEMA is exported as the column list this module reads and writes. Your migration owns the
table and may add to it; nothing here reads a column it was not given.
Wire hub.closeAll() into graceful shutdown. An SSE response is a request that never finishes,
so server.close() otherwise waits for a drain that never comes.
