← All @molecule/* packages · App templates
@molecule/api-resource-messageAPI resource · resource-message · API (Node) · v1.0.1 · Apache-2.0
1:1 messaging threads with read-tracking and realtime broadcast.
npm install @molecule/api-resource-message@molecule/api-resource-message is an API resource: the routes, validation and storage for resource-message, built on the database and auth cores so it runs on whichever providers your app has bonded.
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/:messageIdWorks with: @molecule/api-locales-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.
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/:messageId
resource
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 zod
ListMessagesOptionsOptions 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
}
MessageA 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
}
MessageAttachmentA 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
}
ThreadA 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
}
MessageRealtimeEventUnion of realtime event names.
type MessageRealtimeEvent = (typeof MESSAGE_REALTIME_EVENTS)[keyof typeof MESSAGE_REALTIME_EVENTS]
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 with participantId in 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 with messageId URL 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 with messageId URL param and body in 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. before returns messages strictly older than the given ISO timestamp; limit is 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 with threadId URL param and optional before / limit query 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 optional limit / offset query 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 with threadId URL 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 with threadId URL 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 with threadId URL param and message body.res — The response object.threadRoomId(threadId)Returns the realtime room ID for a given thread.
function threadRoomId(threadId: string): string
threadId — 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.editMessageSchemaSchema for editMessage request input.
const editMessageSchema: z.ZodObject<{ body: z.ZodString }, z.core.$strip>
getOrCreateThreadSchemaSchema for getOrCreateThread request input.
const getOrCreateThreadSchema: z.ZodObject<{ participantId: z.ZodString }, z.core.$strip>
i18nRegisteredThe i18n registered.
const i18nRegistered: true
MESSAGE_REALTIME_EVENTSRealtime 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'
}
messageAttachmentSchemaSchema for an individual message attachment.
const messageAttachmentSchema: z.ZodObject<{ url: z.ZodString; mime: z.ZodString }, z.core.$strip>
requestHandlerMapHandler 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
}
routesRoutes 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']
},
]
sendMessageSchemaSchema 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
>
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@molecule/api-database@molecule/api-i18n@molecule/api-locales-resource-message@molecule/api-logger@molecule/api-realtime@molecule/api-resourcezodTables: 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.
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:
createdAt) order, stamped
with the sender and a timestamp. Send from each side and confirm both see
the same ordered transcript.GET /message-threads/unread-count increment;
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.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.@molecule/api-realtime
bonded, a new message appears in their already-open thread WITHOUT a
reload; with no realtime bond it appears on their next load/refresh.Translation strings are provided by @molecule/api-locales-resource-message.