Conversations & Messages
The data model - pair keys, message ordering with seq, read-state, pagination, and soft deletes.
The data model
Three entities, all stored through the adapter:
Conversation
Prop
Type
Participant
Prop
Type
Message
Prop
Type
One conversation per pair
"Find-or-create a direct conversation" is the entry point of the whole API.
Core computes a deterministic pair key - the two user ids sorted
lexicographically and joined with ":" - so getOrCreateConversation(A→B)
and (B→A) always converge on the same conversation, even under concurrent
calls. Duplicate DMs are prevented by data shape, not by racy
"check-then-insert" logic.
Conversation ids are server-generated opaque strings. Get one from POST /conversations or
chat.api.getOrCreateConversation with {otherUserId}. Never construct ids like "alice-bob" -
the sorted pair key is internal to Chatpack.
Message ordering: seq, not timestamps
Every message gets a strictly increasing integer seq, scoped per
conversation, assigned atomically by the storage adapter at insert time.
Timestamps collide (two messages in the same millisecond have no defined
order) and clocks skew across processes - so seq is the ordering contract:
listMessagesreturns newest-first by descendingseq.- Pagination cursors point at positions in
seqorder. - SSE gap-fill replays "everything after seq X" on reconnect.
createdAt remains for display purposes only. Gaps in seq are allowed;
reuse or decrease never happens.
Pagination vs gap-fill - don't mix them up
Infinite scroll ("load older messages") is listMessages with the
nextCursor from the previous page passed back as cursor:
const page1 = await chat.api.listMessages({ userId, conversationId, limit: 50 });
const page2 = await chat.api.listMessages({
userId,
conversationId,
limit: 50,
cursor: page1.nextCursor!, // null when there are no more results
});listMessagesAfter is not pagination - it fetches messages after a
known seq (oldest-first) and exists for real-time catch-up. The /stream
endpoint already calls it automatically on reconnect, so most apps never use
it directly.
Message lists are newest-first - reverse the page for a chronological top-to-bottom transcript.
Read-state and unread counts
Durable read-state is one field per participant: lastReadMessageId, updated
via chat.api.markRead or POST /conversations/:id/read:
await chat.api.markRead({ userId: "bob", conversationId, messageId: "msg_42" });markRead is monotonic: marking a message older than the current read-state is a silent no-op, so out-of-order replays after a reconnect can never move the read line backwards.
This is the source of truth for unread badges and ✓✓ indicators. The
receipts() plugin adds instant, ephemeral
ticks on top - but lastReadMessageId is what survives restarts.
unreadCount is computed from this field on every conversation object
the API returns: the number of messages with seq newer than the viewer's
lastReadMessageId (null = everything), excluding the viewer's own
messages - sending doesn't mark your own message read, but it's never
"unread" for you either. Soft-deleted messages count (they render as
tombstones in lists); so do assistant/system roles. It's viewer-relative
and never stored - the same conversation shows a different count to each
participant.
For a live badge without re-fetching: increment locally on message.created
events where senderId !== me, reset when you call markRead.
Edit & soft delete
Only the sender can edit or delete their own message (403 NOT_MESSAGE_SENDER otherwise):
await chat.api.editMessage({ userId, messageId, body: "fixed the typo" });
await chat.api.deleteMessage({ userId, messageId });Deletes are soft: the message keeps its id and seq, its body becomes
"", and deletedAt is set - clients render a tombstone ("message deleted").
Editing a deleted message fails with 409 MESSAGE_DELETED.
Metadata
Both conversations and messages carry a metadata JSON object that Chatpack
stores and returns losslessly, without interpreting it. Use it for anything
your app needs - labels, client-side ids, attachment pointers:
await chat.api.sendMessage({
userId,
conversationId,
body: "check this out",
metadata: { clientId: "tmp-123", kind: "link-share" },
});Timestamps on the wire
The exported types declare createdAt / editedAt / deletedAt as Date -
correct for server-side chat.api.* calls. Over HTTP, JSON serialization
means clients receive ISO 8601 strings - type them as string in frontend
code.