Skip to main content

@happyvertical/smrt-comments

Tenant-scoped polymorphic record comments, mention notification composition, and a browser-safe RecordComments Svelte component.

Use CommentService on the server with a trusted { tenantId, userId } actor from the authenticated session and an authorizeRecord callback. The callback must verify that the record belongs to that tenant and that the actor may read or comment on it. Both create and listForRecord require that decision; request bodies cannot override the actor. CreateCommentInput contains only tenant, author, record type/id, body, mentions and an optional idempotencyKey. The service ignores extra runtime properties, generates its own UUID/slug and inserts a new row without natural-key adoption. Caller-provided identity, context, database and constructor options never reach persistence. CommentOptions remains the separate trusted model hydration type. Direct model/collection APIs are persistence primitives for trusted code, not authorization boundaries. Generated API, MCP, and CLI access is disabled.

const comments = new CommentService({
db,
actor: { tenantId: session.tenantId, userId: session.userId },
authorizeRecord: access => records.authorize(access),
participation: {
// Supplies trusted membership and entitlement facts.
authorize: request => entitlements.authorize(request),
},
mentionNotifications: createCommentMentionNotifications({
notifications: new UserNotificationService({ db }), // smrt-messages
canNotify: mention => records.recipientMayRead(mention),
}),
});

participation is an optional server-side entitlement adapter. It receives trusted identity and record/action inputs and returns { allowed, member?, snapshot? }. Only literal allowed: true permits participation; members_only also requires member: true. The immutable JSON snapshot is retained on each new comment.

ParticipationService owns reusable tenant/target policy selection, bans and atomic fixed-window rate reservations. All CommentService creates invoke it, even without an entitlement adapter. Configure ParticipationPolicyCollection and ParticipationBanCollection on trusted administrative server paths under withTenant; these models expose no automatic API/CLI/MCP operations. Migrate all comments package models before deploying. A missing policy defaults to open; an exact (recordType, recordId, action) policy replaces the tenant default (blank record pair). Modes are open, pre_moderated, members_only, closed. A duplicate applicable policy fails closed. Ban targets may be blank (tenant-wide) or exact; a null expiry means indefinite. Ban decisions do not expose reasons.

maxActions: 0 disables quota; otherwise set a positive integer windowSeconds. Quota is per tenant, actor, action and selected policy scope in fixed UTC epoch windows. A tenant policy shares quota across records. The persisted counter uses one bounded atomic upsert; reservation and protected mutation commit or roll back together. Never delete active rate buckets. Policy edits affect subsequent checks, not transactions already authorized; changing a window length starts a new quota namespace. Trusted administrators own policy and bucket retention.

Reactions/polls import ParticipationService and neutral types from @happyvertical/smrt-comments/participation. Use execute(request, (tx, decision) => mutationUsing(tx)) for a new mutation. Within an existing transaction use executeInTransaction(tx, request, callback) and propagate errors so the owner rolls back. authorize checks current permission without spending quota. The consumer owns its durable idempotency binding: replay retained mutations after reauthorization, and call execute only for a genuinely new mutation. Comments already implement this composition. No subscription or journalism rules live here.

CommentService.edit retains prior bodies and rechecks record/participation permission, bans and the comment-action quota in the same transaction. Edits spend quota and retain the original post-time snapshot. report appends a moderation report, and moderate checks the host's record-level moderation permission before changing visibility. Readers receive authorized projections; hosts filter non-visible rows for their audience. ModerationQueue accepts pending/reported projections and an onmoderate callback; the browser cannot grant moderation authority.

The messages service is composed through a structural interface; there is no runtime dependency on messages. canNotify must check recipient membership and record access before exposing the comment. Notifications use a stable per-comment source reference so adapter replays deduplicate. Mentions are UUIDs, deduplicated, and limited to 50; self-mentions do not notify. No adapter means no notifications. Mention validation finishes before the single comment insert; invalid or over-limit mentions leave no saved row. The comment is saved before notification delivery. A delivery rejection leaves the comment saved. With idempotencyKey, retry create with the same key and content to recover delivery without creating another comment. Without a key, reload and retry delivery rather than creating another comment. The component warns users to refresh and check the discussion before posting again when submission rejects. Delivery and persistence are separate unless the host composes both with the same outer transaction. See idempotent creation for key binding, typed conflicts, retention and transaction requirements.

Import RecordComments from @happyvertical/smrt-comments/svelte. Pass authorized comment projections, an onsubmit(body) callback, and a contextKey containing the current tenant, actor and record identity. Change that key whenever any of those identities changes: it clears drafts/errors and ignores stale submission completion. The host owns loading, authentication, mention selection and refresh. The component renders plain text. RecordCommentsRecipe declares the comments.records model recipe in the stable comments catalog group. It intentionally has no global navigation or section: a discussion needs an already-authorized parent record, and a generic comment list would lose that context. Hosts embed it on a record detail page. Catalog help explains posting and mentions without promising a mention picker or delivery configuration. The recipe declares runtime: both: the record component runs in the browser, while persistence and authorization run on the server. Its playground surface exports RecordCommentsPlayground, a standalone sample-record preview with local-only posting. recordCommentsDemo supplies the same sample projections through demoSeed; remounting resets the preview. No notifications are sent. There is no route, settings panel or shell widget: none can supply the authorized parent-record context. No provider is declared because the host's record access and mention policies are callbacks, not a selectable external provider.

Validation: pnpm --filter @happyvertical/smrt-comments test covers SQLite and DuckDB. pnpm --filter @happyvertical/smrt-comments test:postgres adds PostgreSQL using the repository's disposable-database wrapper and ordinary CI role.

Keyed retries use a separate canonical tenant binding while preserving stored tenant and author references. UUID casing changes across trusted actor contexts reuse the same comment, including after generic receipt retention. Unkeyed legacy SQLite listing remains scoped to its original tenant spelling; keyed rows are merged by equivalent UUID tenant without folding arbitrary textual tenant IDs.

Use lowercase UUIDs for administrative policy/ban tenant, actor and target identities; request UUIDs are canonicalized before enforcement. Text identifiers retain case. Thread projections may include parentCommentId; supply onreply(parentId, body) to enable the reply composer. Host callbacks persist replies with that parent ID.

Edits to visible comments return to pending when current policy or entitlement requires pre-moderation; edits never restore hidden or deleted comments.

Reports use the separate report participation action, so hosts can configure report-specific limits without coupling them to comment quotas. Bans and entitlement checks apply to every report attempt. Only visible comments are reportable. Each actor/comment pair retains one report, including after generic receipt cleanup; retries return that report without consuming quota or replacing its original reason.

Parentless keyed-create digests preserve the original v1 shape across upgrades. After an authorized edit, replay verifies the original creation body against retained revision history and returns the current comment; edited content is not a new create payload. Revision history must be retained while the keyed comment is retained.

Mention delivery is limited to visible comments, including keyed retries. Pending, hidden and deleted bodies never reach the mention adapter. Approval does not send notifications automatically; hosts may explicitly retry the original keyed create after approval to recover delivery. Moderation uses the save lifecycle on a locked and freshly reloaded row, preserving change-feed and cache invalidation. Parent comment UUIDs are canonicalized before lookup, persistence and retry hashing. Reply-only embeds show a composer only while a reply target is selected.