Threaded comments / annotations on any resource, as a Convex component.
const comments = new Comments(components.comments);
await comments.post(ctx, resourceRef, authorRef, body, parentId); // parentId threads a reply
await comments.list(ctx, resourceRef, paginationOpts); // reactive thread
await comments.resolve(ctx, commentId, authorRef); // author-gatedA host attaches a comment to an opaque resourceRef (an article, a doc, a clip — anything); replies
thread under a parent; the author edits, resolves, and soft-deletes their own comments; clients page a
resource's thread or subscribe reactively.
- Post on any resource —
post(resourceRef, authorRef, body, parentId?)inserts anopencomment;parentIdthreads it as a reply. - Author-gated edits —
edit,remove(soft-delete), andresolverequire the originalauthorRef, elseNOT_AUTHOR. - Threaded — a reply links to a parent on the same resource (cross-resource or deleted parent rejected);
list(..., { parentId })pages direct replies. - Soft-delete + retention —
removekeeps the row and replies but marks itdeletedand clears the body; a daily cron prunes deleted rows past retention. - Page or subscribe —
listpages oldest-first (deleted excluded by default),counttallies visible ones. Reactive in a Convex query. - Server-sourced time —
createdAt/updatedAt/editedAtare stamped from the server clock; a caller can't supply a timestamp. - Typed, opaque body —
Comments<TBody>types the storedbody;bodyValidatornarrows plain text vs rich blocks at the boundary. - Mount-safe — correct under multiple named
app.usemounts (e.g. acommentsmount + anannotationsmount); each is an isolated sandbox.
pnpm add @vllnt/convex-commentsPeer dependency: convex@^1.41.0.
// convex/convex.config.ts
import { defineApp } from "convex/server";
import comments from "@vllnt/convex-comments/convex.config";
const app = defineApp();
app.use(comments);
export default app;// convex/comments.ts — host owns auth; resolve identity, pass opaque refs in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { Comments } from "@vllnt/convex-comments";
const comments = new Comments<string>(components.comments, {
bodyValidator: v.string().parse, // narrow at the boundary (plain text here)
});
export const postComment = mutation({
args: { resourceRef: v.string(), body: v.string(), parentId: v.optional(v.string()) },
handler: async (ctx, { resourceRef, body, parentId }) => {
const authorRef = await requireUser(ctx); // host auth
return comments.post(ctx, resourceRef, authorRef, body, parentId);
},
});
export const listComments = query({
args: { resourceRef: v.string(), paginationOpts: paginationOptsValidator },
handler: (ctx, { resourceRef, paginationOpts }) =>
comments.list(ctx, resourceRef, paginationOpts),
});| Method | Kind | Result |
|---|---|---|
post(ctx, resourceRef, authorRef, body, parentId?) |
mutation | { commentId } |
edit(ctx, commentId, authorRef, body) |
mutation | null (author-gated) |
remove(ctx, commentId, authorRef) |
mutation | null (soft-delete, author-gated) |
resolve(ctx, commentId, authorRef, resolved?) |
mutation | null (toggle, author-gated; resolved defaults true) |
get(ctx, commentId) |
query | CommentView | null |
list(ctx, resourceRef, paginationOpts, opts?) |
query | PaginationResult<CommentView> (opts: { parentId?; includeDeleted? }) |
count(ctx, resourceRef) |
query | number (visible comments) |
prune(ctx, opts?) |
mutation | number (deleted comments removed in the first bounded pass) |
Full reference: docs/API.md.
Backend-only — no ./react entry. A comment thread is an ordinary reactive useQuery / usePaginatedQuery over the host's own re-exported list / count refs.
- Auth-agnostic for access; the component enforces only authorship (
edit/remove/resolverequire the originalauthorRef). The host owns who may post or moderate. - Tables sandboxed — reached only through the exported functions; never touches host or sibling tables.
- Server-sourced time;
resourceRef/authorRef/bodystay 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.