@molecule/api-resource-message
v1.0.4
Published
1:1 messaging threads + messages with read-tracking and realtime broadcast
Maintainers
Readme
@molecule/api-resource-message
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
1:1 messaging resource for molecule.dev.
Direct-message threads between two participants with read-tracking,
unread counters, optional attachments, and realtime broadcast over
@molecule/api-realtime when bonded.
Quick Start
import { routes, requestHandlerMap } from '@molecule/api-resource-message'
// Wire routes into your Express app via mlcl inject
// POST /message-threads
// GET /message-threads
// GET /message-threads/unread-count
// GET /message-threads/:threadId
// GET /message-threads/:threadId/messages
// POST /message-threads/:threadId/messages
// POST /message-threads/:threadId/read
// PATCH /message-threads/messages/:messageId
// DELETE /message-threads/messages/:messageIdType
resource
Installation
npm install @molecule/api-resource-message @molecule/api-database @molecule/api-i18n @molecule/api-locales-resource-message @molecule/api-logger @molecule/api-realtime @molecule/api-resource zodAPI
Interfaces
ListMessagesOptions
Options for paginating messages within a thread.
interface ListMessagesOptions {
/** Return messages strictly older than this timestamp (ISO 8601). */
before?: string
/** Maximum number of messages to return. Defaults to 50, max 200. */
limit?: number
}Message
A single message within a {@link Thread}.
editedAt and deletedAt are null until the message is mutated. A
soft-deleted message (deletedAt !== null) is preserved for audit /
thread continuity but should not be rendered as content.
interface Message {
/** Unique message identifier. */
id: string
/** Thread this message belongs to. */
threadId: string
/** ID of the user who sent the message. */
senderId: string
/** Message body text. */
body: string
/** Optional file/image attachments. */
attachments: MessageAttachment[]
/** When the message was created (ISO 8601). */
createdAt: string
/** When the message was last edited, or `null`. */
editedAt: string | null
/** When the message was soft-deleted, or `null`. */
deletedAt: string | null
}MessageAttachment
A message attachment reference.
interface MessageAttachment {
/** Public or signed URL to the attachment payload. */
url: string
/** MIME type of the attachment (e.g. `image/png`). */
mime: string
}Thread
A 1:1 messaging thread between two participants.
participantAId and participantBId are stored canonically — the
smaller value (lexicographically) is always participantAId. Use
{@link getOrCreateThread} to look up or create a thread between two
users without worrying about ordering.
interface Thread {
/** Unique thread identifier. */
id: string
/** Lexicographically-smaller participant ID. */
participantAId: string
/** Lexicographically-larger participant ID. */
participantBId: string
/** Timestamp of the most recent message, or `null` if no messages exist. */
lastMessageAt: string | null
/** Number of messages participant A has not yet read. */
unreadCountA: number
/** Number of messages participant B has not yet read. */
unreadCountB: number
/** When the thread was created (ISO 8601). */
createdAt: string
/** When the thread was last updated (ISO 8601). */
updatedAt: string
}Types
MessageRealtimeEvent
Union of realtime event names.
type MessageRealtimeEvent = (typeof MESSAGE_REALTIME_EVENTS)[keyof typeof MESSAGE_REALTIME_EVENTS]Functions
canonicaliseParticipants(a, b)
Canonicalises a (a, b) participant pair so that the lexicographically
smaller value is always returned first. This guarantees (x, y) and
(y, x) resolve to the same {@link Thread} row.
function canonicaliseParticipants(a: string, b: string): [string, string]a— First participant ID.b— Second participant ID.
Returns: A tuple [participantAId, participantBId] in canonical order.
countMessages(threadId)
Counts the messages persisted in a thread (including soft-deleted).
function countMessages(threadId: string): Promise<number>threadId— The thread ID.
Returns: Total stored message count.
createThread(req, res)
Resolves (or lazily creates) the canonical thread between the authenticated user and a counter-participant.
function createThread(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withparticipantIdin body.res— The response object.
deleteMessage(messageId, senderId)
Soft-deletes a message. Only the original sender may delete.
function deleteMessage(messageId: string, senderId: string): Promise<boolean>messageId— The message ID.senderId— The user ID claiming to be the message author.
Returns: true if the message was soft-deleted, false if not found or not authorised.
deleteMessageHandler(req, res)
Soft-deletes a message. Only the original sender may delete.
function deleteMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withmessageIdURL param.res— The response object.
editMessage(messageId, senderId, body)
Edits an existing message body. Only the original sender may edit.
function editMessage(messageId: string, senderId: string, body: string): Promise<Message | null>messageId— The message ID.senderId— The user ID claiming to be the message author.body— New non-empty message body.
Returns: The updated message, or null if not found / not authorised.
editMessageHandler(req, res)
Edits a message body. Only the original sender may edit.
function editMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withmessageIdURL param andbodyin body.res— The response object.
getOrCreateThread(a, b)
Looks up the canonical thread between two participants, creating it lazily if none exists.
function getOrCreateThread(a: string, b: string): Promise<Thread>a— First participant user ID.b— Second participant user ID.
Returns: The existing or newly-created thread.
getThreadById(threadId)
Retrieves a thread by ID.
function getThreadById(threadId: string): Promise<Thread | null>threadId— The thread ID.
Returns: The thread, or null if not found.
getTotalUnreadCount(participantId)
Counts unread messages for a participant across all of their threads.
function getTotalUnreadCount(participantId: string): Promise<number>participantId— The participant user ID.
Returns: Total unread message count across every thread.
listMessages(threadId, options)
Lists messages in a thread, newest first. Soft-deleted messages are
still returned (with deletedAt set) so clients can render
"this message was deleted" placeholders.
function listMessages(threadId: string, options?: ListMessagesOptions): Promise<Message[]>threadId— The thread ID.options— Pagination options.beforereturns messages strictly older than the given ISO timestamp;limitis clamped to 200.
Returns: Array of messages, newest first.
listMessagesHandler(req, res)
Lists messages in a thread, newest first.
function listMessagesHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withthreadIdURL param and optionalbefore/limitquery params.res— The response object.
listThreads(req, res)
Returns paginated threads for the authenticated user, newest first.
function listThreads(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request with optionallimit/offsetquery params.res— The response object.
listThreadsForParticipant(participantId, options)
Lists threads a participant is part of, ordered by most recent activity.
function listThreadsForParticipant(
participantId: string,
options?: { limit?: number; offset?: number },
): Promise<Thread[]>participantId— The user ID to list threads for.options— Pagination options.
Returns: Array of threads in descending lastMessageAt order.
markRead(threadId, readerId)
Marks a thread as read for the given participant and resets their
unread counter to zero. Broadcasts a message:read event.
function markRead(threadId: string, readerId: string): Promise<void>threadId— The thread ID.readerId— The participant user ID marking the thread as read.
markReadHandler(req, res)
Marks a thread as read and zeroes the authenticated user's unread
counter for that thread. Broadcasts a message:read event over the
bonded realtime provider.
function markReadHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withthreadIdURL param.res— The response object.
readThread(req, res)
Returns the thread identified by :threadId. The authenticated user
must be a participant.
function readThread(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withthreadIdURL param.res— The response object.
sendMessage(threadId, senderId, body, attachments)
Sends a message in a thread. Updates lastMessageAt and bumps the
recipient's unreadCount* counter, then broadcasts the new message
over the bonded realtime provider (if any).
function sendMessage(
threadId: string,
senderId: string,
body: string,
attachments?: MessageAttachment[],
): Promise<Message>threadId— The target thread ID.senderId— The sending participant's user ID.body— Message body. Must be non-empty after trimming.attachments— Optional attachment list.
Returns: The persisted message.
sendMessageHandler(req, res)
Sends a new message in a thread. Authenticated user must be a
participant. Broadcasts a message:sent event when realtime is bonded.
function sendMessageHandler(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request withthreadIdURL param and message body.res— The response object.
threadRoomId(threadId)
Returns the realtime room ID for a given thread.
function threadRoomId(threadId: string): stringthreadId— The thread ID.
Returns: The room ID consumers should subscribe to for thread events.
unreadCount(req, res)
Returns the authenticated user's total unread message count.
function unreadCount(req: MoleculeRequest, res: MoleculeResponse): Promise<void>req— The request.res— The response object.
Constants
editMessageSchema
Schema for editMessage request input.
const editMessageSchema: z.ZodObject<{ body: z.ZodString }, z.core.$strip>getOrCreateThreadSchema
Schema for getOrCreateThread request input.
const getOrCreateThreadSchema: z.ZodObject<{ participantId: z.ZodString }, z.core.$strip>i18nRegistered
The i18n registered.
const i18nRegistered: trueMESSAGE_REALTIME_EVENTS
Realtime event names emitted by this package.
Subscribers should listen on the room ID returned from {@link threadRoomId} for the thread they care about.
const MESSAGE_REALTIME_EVENTS: {
readonly messageSent: 'message:sent'
readonly messageRead: 'message:read'
}messageAttachmentSchema
Schema for an individual message attachment.
const messageAttachmentSchema: z.ZodObject<{ url: z.ZodString; mime: z.ZodString }, z.core.$strip>requestHandlerMap
Handler map for message resource routes.
const requestHandlerMap: {
readonly createThread: typeof createThread
readonly listThreads: typeof listThreads
readonly readThread: typeof readThread
readonly listMessages: typeof listMessagesHandler
readonly sendMessage: typeof sendMessageHandler
readonly markRead: typeof markReadHandler
readonly editMessage: typeof editMessageHandler
readonly deleteMessage: typeof deleteMessageHandler
readonly unreadCount: typeof unreadCount
}routes
Routes for 1:1 message threads, messages, and read-tracking.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/message-threads'
readonly handler: 'createThread'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/message-threads'
readonly handler: 'listThreads'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/message-threads/unread-count'
readonly handler: 'unreadCount'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/message-threads/:threadId'
readonly handler: 'readThread'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/message-threads/:threadId/messages'
readonly handler: 'listMessages'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/message-threads/:threadId/messages'
readonly handler: 'sendMessage'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/message-threads/:threadId/read'
readonly handler: 'markRead'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'patch'
readonly path: '/message-threads/messages/:messageId'
readonly handler: 'editMessage'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'delete'
readonly path: '/message-threads/messages/:messageId'
readonly handler: 'deleteMessage'
readonly middlewares: readonly ['authenticate']
},
]sendMessageSchema
Schema for sendMessage request input.
const sendMessageSchema: z.ZodObject<
{
body: z.ZodString
attachments: z.ZodOptional<
z.ZodArray<z.ZodObject<{ url: z.ZodString; mime: z.ZodString }, z.core.$strip>>
>
},
z.core.$strip
>Injection Notes
Requirements
Peer dependencies:
@molecule/api-database^1.0.1@molecule/api-i18n^1.0.1@molecule/api-locales-resource-message^1.0.1@molecule/api-logger^1.0.1@molecule/api-realtime^1.0.1@molecule/api-resource^1.0.1zod^4.0.0
Runtime Dependencies
@molecule/api-database@molecule/api-i18n@molecule/api-locales-resource-message@molecule/api-logger@molecule/api-realtime@molecule/api-resourcezod
Tables: src/__setup__/messages.sql creates message_threads + messages.
An mlcl-scaffolded API replays __setup__/*.sql automatically on migrate;
anywhere else run it once — nothing at runtime creates them.
Threads are strictly 1:1 — a canonicalised participant pair (creating a
thread with yourself throws) — and PARTICIPANT-SCOPED: every handler reads
the authenticated user from res.locals.session (401 without a session;
mount behind your global auth middleware) and rejects non-participants with
403/404. The sender is always the session user — never accept a sender id
from the request body.
Realtime delivery is best-effort — messages are persisted before any
broadcast attempt and a missing realtime bond is silently a no-op.
Subscribers should listen on threadRoomId(threadId) for
{@link MESSAGE_REALTIME_EVENTS} payloads.
User-facing strings go through t(key, …, { defaultValue }); translations
ship in the companion @molecule/api-locales-resource-message bond.
E2E Tests
Integration checklist — drive the real messaging UI (live preview, no mocks), adapt each item to this app's actual threads/screens, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. Messaging PRIVACY is the point here, so the last item is not optional:
- [ ] Sending a message in a thread persists it and it appears in that
thread for BOTH participants in chronological (
createdAt) order, stamped with the sender and a timestamp. Send from each side and confirm both see the same ordered transcript. - [ ] Read / unread works: a message you send is unread for the recipient —
their thread badge and
GET /message-threads/unread-countincrement; opening the thread and marking it read (POST /message-threads/:threadId/read) clears that side's unread count to zero and the total badge drops to match. - [ ] Editing a message shows an edited state (an "edited" marker /
editedAt) and deleting it removes it or renders a "message was deleted" tombstone (deletedAt) — and ONLY the author can edit or delete their OWN message: the other participant gets no edit/delete affordance and a forged PATCH/DELETE on someone else's message is rejected, never applied. - [ ] Delivery to the other participant: with
@molecule/api-realtimebonded, a new message appears in their already-open thread WITHOUT a reload; with no realtime bond it appears on their next load/refresh. - [ ] PRIVACY / AUTHORIZATION — a thread and its messages are visible ONLY to its two participants. Sign in as a THIRD user and confirm they cannot read the thread or any message by guessing its id (403/404), cannot post into a thread they are not part of (403), and cannot spoof the sender — the sender is always the session user, never a request-body field.
Translations
Translation strings are provided by @molecule/api-locales-resource-message.
