npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@molecule/api-resource-message

v1.0.4

Published

1:1 messaging threads + messages with read-tracking and realtime broadcast

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.ts JSDoc, 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/:messageId

Type

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 zod

API

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

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: true

MESSAGE_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.1
  • zod ^4.0.0

Runtime Dependencies

  • @molecule/api-database
  • @molecule/api-i18n
  • @molecule/api-locales-resource-message
  • @molecule/api-logger
  • @molecule/api-realtime
  • @molecule/api-resource
  • zod

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-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.
  • [ ] 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-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.
  • [ ] 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.