@basaltkit/comments adds threaded comments to any resource — a note, a project, a task — with @mentions and resolve/reopen, scoped per tenant. It emits events that bridge cleanly to realtime (live discussion) and notifications (alert the mentioned).
Register commentsPlugin and mount the ready-made REST routes through your adapter. In dev the store is in-memory; in production pass a store backed by @basaltkit/comments-prisma or -sqlite:
add extracts mentions with @([\w-]+) by default. Pass mentionPattern (a regex whose first capture group is the user id) to commentsPlugin to match your own id scheme.
add extracts @mentions from the body (configurable pattern), stores them on the comment, and emits comment:created plus one comment:mentioned per mentioned user. Also: edit, remove, resolve(id, by), reopen(id).
Bounded bodies and mentions
A body longer than maxBodyLength (default 10 000 characters) throws CommentTooLongError (400 COMMENT_TOO_LONG), and one carrying more than maxMentions distinct mentions (default 50) throws CommentMentionLimitError (400 COMMENT_TOO_MANY_MENTIONS) — on add and on edit, before anything is stored or emitted. Every @id is otherwise taken at face value, so when comment:mentioned reaches a real notification channel, pass resolveMentions(ids, tenantId) to keep only users who may be mentioned — typically the tenant's members:
Inside a tenant context an explicit tenantId argument must name that tenant; any other value throws CommentTenantMismatchError (403 COMMENT_TENANT_MISMATCH). It selects a tenant only outside one (jobs, CLI).
In a single-tenant app — no tenancyPlugin — calls need no tenantId, and comments are filed under one internal store key, SINGLE_TENANT_SCOPE ('@single' — outside the tenant-id grammar, so no tenant can ever be handed those comments). A tenant id equal to it is refused with CommentTenantReservedError (400 COMMENT_TENANT_RESERVED).
Upgrading single-tenant data
Before @basaltkit/comments 4.0 the single-tenant key was 'default' — a valid tenant id, so a tenant named default read, edited and deleted the single-tenant comments. A single-tenant app with persisted comments re-keys them once: UPDATE comments SET "tenantId" = '@single' WHERE "tenantId" = 'default' (@basaltkit/comments-prisma; the @basaltkit/comments-sqlite column is tenant_id). Skip it if default was ever a real tenant in that database.
Every mutation emits a hook (comment:created, comment:mentioned, comment:updated, comment:deleted, comment:resolved, comment:reopened), so live updates and notifications wire up with no coupling. Subscribe on app.hooks:
ts
import { REALTIME } from '@basaltkit/realtime'import { NOTIFIER, defineNotification } from '@basaltkit/notifications'import { z } from 'zod'import { app } from './app.js'const realtime = app.container.get(REALTIME)const notifier = app.container.get(NOTIFIER)const CommentMention = defineNotification({ name: 'comment.mention', schema: z.object({ by: z.string() }), channels: ['inApp'], via: { inApp: ({ by }) => ({ title: 'You were mentioned', data: { by } }) },})// push new comments to everyone viewing the resourceapp.hooks.on('comment:created', ({ comment }) => realtime .to(comment.tenantId) .channel(`${comment.resourceType}:${comment.resourceId}`) .emit('comment', comment))// notify the mentioned — one hook fires per mentioned userapp.hooks.on('comment:mentioned', ({ comment, userId }) => notifier.notify({ id: userId }, CommentMention, { by: comment.authorId }))
commentRoutes() (require a logged-in user; author taken from ctx().user): GET /comments?resourceType=&resourceId=, POST /comments, PATCH /comments/:id, DELETE /comments/:id, POST /comments/:id/resolve and /reopen. By default any user of the tenant may read a thread and post to it, and editing, deleting, resolving and reopening are restricted to the comment's author (403 COMMENT_FORBIDDEN). Everything is tenant-scoped.
Pass authorize to tie a thread to the access rules of the resource it discusses. It replaces the default policy; compose with defaultCommentPolicy to keep it:
Comments
@basaltkit/commentsadds threaded comments to any resource — a note, a project, a task — with @mentions and resolve/reopen, scoped per tenant. It emits events that bridge cleanly to realtime (live discussion) and notifications (alert the mentioned).Setup
Register
commentsPluginand mount the ready-made REST routes through your adapter. In dev the store is in-memory; in production pass astorebacked by@basaltkit/comments-prismaor-sqlite:Custom @mention pattern
addextracts mentions with@([\w-]+)by default. PassmentionPattern(a regex whose first capture group is the user id) tocommentsPluginto match your own id scheme.Add and read
addextracts @mentions from the body (configurable pattern), stores them on the comment, and emitscomment:createdplus onecomment:mentionedper mentioned user. Also:edit,remove,resolve(id, by),reopen(id).Bounded bodies and mentions
A body longer than
maxBodyLength(default 10 000 characters) throwsCommentTooLongError(400 COMMENT_TOO_LONG), and one carrying more thanmaxMentionsdistinct mentions (default 50) throwsCommentMentionLimitError(400 COMMENT_TOO_MANY_MENTIONS) — onaddand onedit, before anything is stored or emitted. Every@idis otherwise taken at face value, so whencomment:mentionedreaches a real notification channel, passresolveMentions(ids, tenantId)to keep only users who may be mentioned — typically the tenant's members:Inside a tenant context an explicit
tenantIdargument must name that tenant; any other value throwsCommentTenantMismatchError(403 COMMENT_TENANT_MISMATCH). It selects a tenant only outside one (jobs, CLI).In a single-tenant app — no
tenancyPlugin— calls need notenantId, and comments are filed under one internal store key,SINGLE_TENANT_SCOPE('@single'— outside the tenant-id grammar, so no tenant can ever be handed those comments). A tenant id equal to it is refused withCommentTenantReservedError(400 COMMENT_TENANT_RESERVED).Upgrading single-tenant data
Before
@basaltkit/comments4.0 the single-tenant key was'default'— a valid tenant id, so a tenant nameddefaultread, edited and deleted the single-tenant comments. A single-tenant app with persisted comments re-keys them once:UPDATE comments SET "tenantId" = '@single' WHERE "tenantId" = 'default'(@basaltkit/comments-prisma; the@basaltkit/comments-sqlitecolumn istenant_id). Skip it ifdefaultwas ever a real tenant in that database.Live discussion + mention notifications
Every mutation emits a hook (
comment:created,comment:mentioned,comment:updated,comment:deleted,comment:resolved,comment:reopened), so live updates and notifications wire up with no coupling. Subscribe onapp.hooks:Routes
commentRoutes()(require a logged-in user; author taken fromctx().user):GET /comments?resourceType=&resourceId=,POST /comments,PATCH /comments/:id,DELETE /comments/:id,POST /comments/:id/resolveand/reopen. By default any user of the tenant may read a thread and post to it, and editing, deleting, resolving and reopening are restricted to the comment's author (403 COMMENT_FORBIDDEN). Everything is tenant-scoped.Pass
authorizeto tie a thread to the access rules of the resource it discusses. It replaces the default policy; compose withdefaultCommentPolicyto keep it:Ready-made UI is not needed here — comments render inline in your app — but the same self-contained pattern powers the audit viewer.