Skip to content

Comments ​

@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).

Setup ​

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:

ts
// src/app.ts
import { createApp } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'
import { COMMENTS, commentsPlugin, commentRoutes } from '@basaltkit/comments'

export const app = await createApp({
  plugins: [
    fastifyPlugin({ routes: [...commentRoutes()] }), // create/list/edit/delete/resolve/reopen
    commentsPlugin(),
  ],
}).boot()

await app.container.get(FASTIFY).listen({ port: 3000 })

Custom @mention pattern

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 and read ​

ts
import { COMMENTS } from '@basaltkit/comments'
const comments = app.container.get(COMMENTS)

const root = await comments.on('note', 'note-1').add({ authorId: 'u1', body: 'Nice work @u2!' })
await comments.on('note', 'note-1').add({ authorId: 'u2', body: 'Thanks!', parentId: root.id })

const tree = await comments.on('note', 'note-1').tree() // nested replies

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:

ts
commentsPlugin({
  resolveMentions: async (ids, tenantId) => (await members.of(tenantId, ids)).map((m) => m.userId),
})

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.

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 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 resource
app.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 user
app.hooks.on('comment:mentioned', ({ comment, userId }) =>
  notifier.notify({ id: userId }, CommentMention, { by: comment.authorId }))

Routes ​

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:

ts
import { commentRoutes, defaultCommentPolicy } from '@basaltkit/comments'

commentRoutes({
  // action: 'list' | 'create' | 'edit' | 'delete' | 'resolve' | 'reopen'
  // target: { resourceType, resourceId, comment? }
  authorize: async (action, target, user) =>
    (await canSeeMatter(user.id, target.resourceId)) &&
    (defaultCommentPolicy(action, target, user) || (action === 'resolve' && user.role === 'admin')),
})

Ready-made UI is not needed here — comments render inline in your app — but the same self-contained pattern powers the audit viewer.

Released under the MIT License.