Chatpack
Core Concepts

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:

  • listMessages returns newest-first by descending seq.
  • Pagination cursors point at positions in seq order.
  • 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.

On this page