@octabits-io/activity
The in-app notification feed over @octabits-io/events: the bell, its badge, read
state, and the rule that makes both honest. One row per fact, written by the same call that
emits the event, in the same transaction. Read state per user in a side table. The audience is
evaluated at read time against the reader’s live grants, never snapshotted. Resolution marks
the work done for everyone. Per-user mutes, a capped unread count, pruning. A store contract
with an in-memory reference, a plain-SQL Postgres adapter over any executor, a Drizzle adapter,
and a framework-free feed store for the browser with Vue and React bindings.
pnpm add @octabits-io/activity @octabits-io/events
| Subpath | Deps | Contents |
|---|---|---|
. |
@octabits-io/events (vocabulary + the permission predicate), @octabits-io/result |
the ActivityStore contract, the in-memory store, createActivityFeed (read side), createActivityPublisher (write side) |
./postgres |
none | plain SQL over a structural SqlExecutor — pg Pool/Client, PGlite, a wrapper over postgres.js — plus activityDdl() |
./drizzle |
drizzle-orm (optional peer), @octabits-io/postgres (peer) |
activityColumns, activityReadColumns, activityMuteColumns to spread into your tables (timestamps read back as ISO-8601 in UTC); createDrizzleActivityStore (tables.mute optional — without it there are no per-user mutes) |
./client |
none | createActivityFeedStore — the bell, headless: pages, badge, mark read, live frames in |
./vue, ./react |
vue / react (optional peers) |
useActivityFeed(store) |
What is generic, and what stays yours
The generic half is the shape every bell has: a row per fact, who has read it, who may see it, when the work behind it was done, what a person muted, and how old is too old. That is this package.
The half that stays in your app is the vocabulary: the event types, the i18n keys, the deep
links, which events deserve action_required, and which permission each type needs. Rows
store a title key and params, never a rendered sentence — a person can switch locale after
the row was written — and never personal data: the table is plaintext.
When not to use it
- Without the event lane. The write side is
emiton an events publisher: the row and the durable event are one transaction, and the bell is live because the frame carries the row. A feed written without@octabits-io/eventsis a table you can build without this package. - An audit trail. Rows are pruned, muted, resolved and hidden by audience;
they carry a title key and params, never the change. What an agent did and how
to undo it is
@octabits-io/agent-ledger; what a person did is your own audit table. - Reaching people who are not in the app. In-app only: no e-mail, no push, no digest, no device. A notification that must leave the browser is a mail sent from your own trigger — the same emit, a second sink.
- Messages. A row is a fact about a record, not a message from a person: no body, no thread, no reply. An inbox is a different table.
- Work with owners and deadlines.
resolvemarks a fact handled for everyone; there is no assignment, no due date, no state beyond open and resolved. A task is a record of yours; the feed can point at it. - Anything personal in
params. The table is plaintext and read by everyone the audience admits. Put ids in the row and resolve them at render, or encrypt with@octabits-io/piibefore it gets here.
Writing
import { createActivityPublisher } from '@octabits-io/activity';
import { createDrizzleActivityStore } from '@octabits-io/activity/drizzle';
const store = createDrizzleActivityStore({ db, tables: { activity, read, mute }, scope: { column: 'orgId', value: orgId } });
const activity = createActivityPublisher({ store, publisher: eventPublisher, scopeKey: orgId });
await db.transaction(async (tx) => {
const contact = await contacts.create(input, tx);
// The feed row and the durable outbox event, on the contact's own transaction.
await activity.emit(
{
type: 'contact.created',
data: { contactId: contact.id },
actor: { type: 'user', id: userId },
resources: [`contact:${contact.id}`],
audience: { permission: { contacts: ['read'] } },
activity: { titleKey: 'activity.contact.created', params: { contactId: contact.id }, link: `/contacts/${contact.id}`, severity: 'info' },
},
tx,
);
});
// Later, once the work behind some activities was handled — after the commit, never inside it:
await activity.resolve({ resources: [`draft:${draftId}`], types: ['draft.created'] });
emit throws on failure: inside the transaction the throw is the rollback, and a state
change whose activity cannot be recorded must not half-succeed. The event carries the row
inline as data.activity, so a connected client renders it without a refetch.
resolve is best-effort by design: it stamps resolvedAt on the unresolved rows whose
resources overlap, tells connected clients which ids settled (an ephemeral
activity.resolved event — resolvedEvent: { type, audience } names your own type when your
publisher validates types against a registry), logs failures and never throws — bell hygiene
must not fail real work.
Reading
import { createActivityFeed } from '@octabits-io/activity';
const feed = createActivityFeed({
store,
scopeKey: orgId,
reader: { userId, can: (permission) => rbac.allows(role, permission) },
badgeSeverities: ['action_required'], // optional: the badge counts open work only; `info` rows light the list
});
const page = await feed.list({ limit: 20 }); // { items, nextBefore }
const needed = await feed.list({ since, severities: ['action_required'], types: ['order.requested'] }); // predicates, for an assistant
const history = await feed.list({ resources: ['order:42'] }); // one record's history: rows whose resources overlap, still gated per reader
const unread = await feed.unreadCount(); // { count, capped } — stops at 99
await feed.markRead([page.value.items[0].id]); // { marked: 1 }
await feed.markAllRead(); // { marked: n }
await feed.setMutedTypes(['import.progress']);
Two rules, applied everywhere:
audienceAllows— a row withaudience.usersis for those users; a row withaudience.permissionis for readers whosecan(permission)says so, and a reader with nocansees none of those (fail closed, as the hub does). Evaluated per row, per read, so a grant revoked yesterday hides yesterday’s rows too.isReadFor— a row is read for a reader when they marked it, when it was resolved, or when they caused it. Nobody needs a badge for their own doing.
list over-fetches: the audience gate runs in the application, so a page of limit visible
rows may need more raw rows. The unread count asks the store for cap + 1 candidates, gates
them, and stops at the cap. markAllRead walks the reader’s unread rows the same way and marks
only those the gate lets them see (muted types included): a row for someone else keeps no marker
of theirs, so a grant given later shows it unread.
The bell
import { createActivityFeedStore } from '@octabits-io/activity/client';
import { useActivityFeed } from '@octabits-io/activity/vue'; // or /react
const store = createActivityFeedStore({
userId,
api: {
list: (params) => fetchJson('/api/activity', params),
unreadCount: () => fetchJson('/api/activity/unread-count'),
markRead: (ids) => postJson('/api/activity/read', { ids }),
markAllRead: () => postJson('/api/activity/read-all'),
},
});
eventStream.onEvent = (event) => store.applyEvent(event); // the live half
const feed = useActivityFeed(store); // entries, unread, loading, loaded, exhausted, error + the actions
applyEvent takes what the event stream delivers: a durable frame carrying data.activity
is prepended (and counted, unless this user caused it); an activity.resolved frame settles
the rows it names and refetches the count. Marking read is optimistic; a failed call restores
the count from the server, and so does any read while the badge is capped.
Give the store the same badgeSeverities as the server feed. With ['action_required'] the
server’s count leaves info rows out, and a store left on the default would count them as they
arrive and subtract them as they are read, so the badge would drift from the server’s until the
next refetch.
Scoping
scope: { column, value } binds an adapter to one scope: every row stamped, every read
filtered. scope: { column } stamps each row with its own scopeKey and reads by it — for a
process serving many scopes. No scope at all is the single-scope app, and the adapters
refuse a second scopeKey rather than mix. activityDdl({ scope: { column: 'org_id' } }) emits
the column on all three tables; row-level security is yours to add — the column is there for
it.
Conformance
src/conformance.test.ts runs the in-memory, plain-SQL and Drizzle stores through one
scenario on an embedded PGlite. A fourth adapter passes the same suite or is not an adapter.
Wire format
The feed’s shapes are published as JSON Schema (draft 2020-12):
| Document | What |
|---|---|
schema/activity-entry.schema.json |
ActivityEntry — a row as the reader sees it; ActivityFeedItem (the slice that rides inline on the event as data.activity) is its $defs entry |
schema/activity-page.schema.json |
ActivityPage — items newest first, nextBefore as the cursor |
schema/unread-count.schema.json |
UnreadCount — the badge, with capped |
The runtime is plain interfaces and takes no Zod; the documents come from a
Zod mirror in scripts/wire.ts, and src/wire.test.ts proves the mirror
equals the interfaces at the type level, so the published schema cannot drift
from what the store returns. Checked in pnpm lint; in the package
(@octabits-io/activity/schema/activity-entry.schema.json) and at their
$id, the raw GitHub URL.
License
MIT
From @octabits-io/activity@0.7.2 in the platform repository.