A per-subject directed inbox — notifications with read/unread state and fan-out, as a Convex component.
const inbox = new Notifications(components.notifications);
await inbox.deliver(ctx, recipients, "mention", { actor }); // one row per recipient
await inbox.list(ctx, subjectRef, paginationOpts, { unreadOnly: true }); // reactive inbox
await inbox.unreadCount(ctx, subjectRef);
await inbox.markRead(ctx, notificationId);- Deliver + fan-out —
deliver(subjectRef | subjectRefs[], type, payload?)writes one notification per recipient and mints anotificationIdfor each. - Read state — every notification arrives unread;
markRead(idempotent) andmarkAllRead(bounded, self-rescheduling) clear it. - Inbox queries —
listpages newest-first (all or unread-only),unreadCounttotals the unread,getfetches one. Reactive in a Convex query. - Subject-bounded reads — a
list/unreadCountis keyed to one subject and never spans another's inbox. - Server-sourced time —
createdAt/readAtare stamped from the server clock; a caller can't supply a timestamp. - Typed, opaque payload —
Notifications<TPayload>types the storedpayload;payloadValidatornarrows it at the boundary. - Bounded purge + cron — a daily cron sweeps read notifications past retention in batches; unread are never purged.
- Mount-safe — correct under multiple named
app.usemounts; each instance is an isolated sandbox.
pnpm add @vllnt/convex-notificationsPeer dependency: convex@^1.41.0.
// convex/convex.config.ts
import { defineApp } from "convex/server";
import notifications from "@vllnt/convex-notifications/convex.config";
const app = defineApp();
app.use(notifications);
export default app;// convex/notify.ts — host owns auth; pass opaque subjectRefs in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Notifications } from "@vllnt/convex-notifications";
const inbox = new Notifications<{ actor: string }>(components.notifications, {
payloadValidator: v.object({ actor: v.string() }).parse, // narrow at the boundary
});
export const notifyMention = mutation({
args: { recipients: v.array(v.string()), actor: v.string() },
handler: (ctx, { recipients, actor }) =>
inbox.deliver(ctx, recipients, "mention", { actor }),
});
export const myUnread = query({
args: { subjectRef: v.string(), paginationOpts: v.any() },
handler: (ctx, { subjectRef, paginationOpts }) =>
inbox.list(ctx, subjectRef, paginationOpts, { unreadOnly: true }),
});| Method | Kind | Result |
|---|---|---|
deliver(ctx, subjects, type, payload?) |
mutation | { notificationIds } (subjects: one ref or an array) |
markRead(ctx, notificationId) |
mutation | null |
markAllRead(ctx, subjectRef, opts?) |
mutation | number (marked in the first bounded pass) |
get(ctx, notificationId) |
query | NotificationView | null |
list(ctx, subjectRef, paginationOpts, opts?) |
query | PaginationResult<NotificationView> (opts: { unreadOnly? }) |
unreadCount(ctx, subjectRef) |
query | number |
purge(ctx, opts?) |
mutation | number (read notifications removed in the first bounded pass) |
Full reference: docs/API.md.
Backend-only — no ./react entry. An inbox, unread count, and unread list are ordinary reactive useQuery calls over the host's own re-exported list / unreadCount / get refs.
- Auth-agnostic — the host resolves identity and decides who may deliver to or read a
subjectRef. - Tables sandboxed — reached only through the exported functions; never touches host or sibling tables.
- Subject-bounded reads + server-sourced time;
subjectRef/payloadstay opaque to the component.
See docs/API.md.
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
See CONTRIBUTING.md.
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
MIT — see LICENSE.