Skip to content

refactor: replace legacy ChannelState message/thread/pinned storage with paginators#1806

Merged
isekovanic merged 26 commits into
feat/message-paginator-master-mergefrom
refactor/remove-legacy-channelstate-storage
Jul 22, 2026
Merged

refactor: replace legacy ChannelState message/thread/pinned storage with paginators#1806
isekovanic merged 26 commits into
feat/message-paginator-master-mergefrom
refactor/remove-legacy-channelstate-storage

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Completes the message-paginator initiative on the LLC side: the channel's messages, thread replies, and pinned messages are no longer stored on channel.state. Each list now lives in a paginator that is the single source of truth (interval storage + a canonical ItemIndex):

  • Main message listchannel.messagePaginator
  • Thread repliesthread.messagePaginator (owned by the Thread object)
  • Pinned messageschannel.pinnedMessagesPaginator (new PinnedMessagePaginator)

What changed

  • ChannelState storage removed: messages, latestMessages, messageSets, messagePagination, threads, pinnedMessages, and their mutators (addMessageSorted/addMessagesSorted, removeMessage, findMessage, findMessageByTimestamp, filterErrorMessages, loadMessageIntoState, clearMessages, initMessages, pruneOldest, addReaction/removeReaction, updateUserMessages, deleteUserMessages, addPinnedMessages/addPinnedMessage/removePinnedMessage, removeQuotedMessageReferences, + internal helpers).
  • PinnedMessagePaginator added and populated from channel events; pin/unpin falls out of ingestItem + a { pinned: true } filter (no bespoke branching).
  • MessageIntervalPaginator extracted as the unread-free base of MessagePaginator; it tracks the latest message (latestMessageId + latestMessage), advanced on ingest and mirrored into reactive state.
  • channel.state.last_message_at is now a read-only getter derived from messagePaginator.latestMessage (setter + backing field removed).
  • channel.state.isUpToDate / setIsUpToDate removed — live-message routing (don't disrupt a scrolled-away view) is handled structurally by the paginator's interval model.
  • Channel._trackLatestMessage removed. user.updated / user.deleted propagation now scans active channels (reflectUserUpdate / applyMessageDeletionForUser self-filter by author id). A targeted user-reference index is planned (specs/user-reference-index).
  • utils cleanup: removed addToMessageList, messageSetPagination, binarySearchByDateEqualOrNearestGreater, deleteUserMessages, and the MessageSet / message-set pagination types.
  • Reactive headItems added to PaginatorState (newest-loaded window).
  • Removed the unused MessageReplyPaginator (dead sibling; the thread reply list uses MessagePaginator).

Breaking changes

Full list + before → after migration table in docs/breaking-changes-v14-v15.md. Highlights:

Before (v14) After (v15)
channel.state.messages channel.messagePaginator.state.items / .items
channel.state.threads[parentId] thread.messagePaginator.state.items
channel.state.pinnedMessages channel.pinnedMessagesPaginator.state.items
channel.state.addMessageSorted(m) channel.messagePaginator.ingestItem(m)
channel.state.removeMessage({ id }) channel.messagePaginator.removeItem({ id })
channel.state.findMessage(id) channel.messagePaginator.getItem(id)
channel.state.isUpToDate / setIsUpToDate messagePaginator.isActiveIntervalAtHead / hasMoreHead / jumpToTheLatestMessage()
channel.state.last_message_at = … read-only (derived); ingest a message on the paginator

Behavioral note: last_message_at now advances on every incoming message regardless of the viewer's scroll position (the old isUpToDate suppression is gone) — it's a channel-level fact.

Follow-ups (not in this PR)

  • specs/user-reference-index — replace the active-channel scan on user.updated/user.deleted with a userId → message-reference index (design-gated).
  • Unifying the remaining flat paginators on interval storage
  • Offline migration

MartinCupela and others added 22 commits July 20, 2026 12:33
Checkpoint before deleting legacy ChannelState message-list + threads storage.

Covers Tasks 8-10 and Task 11 steps 1-2; Task 11 step 3+ follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the test surgery for the channel_state main message-list removal (caba066):
delete message-set machinery specs (pruning, merges, latestMessages, messagePagination,
main-list addMessagesSorted/findMessage/reactions), and re-scope the surviving deleteUser/
updateUser and #1736 self-quote coverage to threads + pinned. Migrate channel/client event
and query/queryChannels tests to assert against channel.messagePaginator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the remaining ChannelState message-storage removal into 3a (main message list,
threads retained — DONE, both suites green) and 3b (threads + full method delete — next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…state to Thread

Delete the ChannelState thread shadow and its thread-only methods (removeMessage,
findMessage, _updateQuotedMessageReferences, _addReactionToState/_removeReactionFromState);
scope _updateMessage, updateUserMessages, deleteUserMessages and addReaction/removeReaction to
the pinned-message cache. addMessagesSorted stays as a slim channel-meta path (last_message_at
plus the user-reference map).

Thread reply state is now owned entirely by the Thread object: reply own_reactions are
preserved on message.updated and applied on reaction events via reflectReaction, and a new
user.messages.deleted/user.deleted subscription applies bans via applyMessageDeletionForUser.
channel.ts no longer handles thread-reply events and never reaches into a Thread instance
(getReplies, _extendEventWithOwnReactions and the reaction/delete handlers are main-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
channel.state.threads removed, thread-only methods deleted, reply own_reactions and
user-deletion re-homed onto the Thread object; both suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fault and search util

Resolve the ChannelState.formatMessage type anchor to LocalMessage and remove the leftover
message-set machinery now that the channel message list lives in the paginators: the MessageSet
object type (public), the internal DEFAULT_MESSAGE_SET_PAGINATION constant, and the now-unused
binarySearchByDateEqualOrNearestGreater util (public). MessageSetType stays as the type of
channel.query's messageSetToAddToIfDoesNotExist param.

BREAKING CHANGE: ChannelState no longer stores the channel message list or thread replies.
channel.state.messages / latestMessages / messageSets, the MessageSet type, and
binarySearchByDateEqualOrNearestGreater are removed. Read messages from channel.messagePaginator
and thread replies from the Thread object. addMessageSorted/addMessagesSorted no longer maintain a
message list (they record channel-meta only: last_message_at and the user-reference map).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the types/exports cleanup (MessageSet, DEFAULT_MESSAGE_SET_PAGINATION,
binarySearchByDateEqualOrNearestGreater removed; MessageSetType/isLatestMessageSet kept) and add
Task 19 for a reactive headItems property on PaginatorState at the BasePaginator level.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inator

headItems is the newest-loaded window (T[] | undefined, undefined until first load), materialized
on every state emission via a StateStore preprocessor so UI can subscribe to latest-item /
latest-messages changes. Interval mode derives it from latestItems (the head-most loaded interval,
so it tracks the newest window even when the active window is a jumped-to older interval); flat mode
uses the emitted items. Reference-stable via per-index identity so a headItems selector does not
re-fire on unrelated state changes. latestItems/latestItem getters are unchanged and stay
consistent with the field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…up note

Task 19 (reactive headItems) done. Add Task 20 (unify ChannelPaginator/ReminderPaginator/
UserGroupPaginator on itemIndex + interval storage) and note that Task 18 unblocks removing the
utils.addToMessageList helper (its last caller is ChannelState._addToMessageList).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…agePaginator

Split the message-interval mechanics (interval store, ingestItem, reflect*, jump navigation,
truncate, focus signals, query/filters/sort/cursor scaffolding) into a new MessageIntervalPaginator
base. MessagePaginator now extends it and adds only the unread/live-view concern
(unreadStateSnapshot, liveViewState, seed/set/clearUnreadSnapshot, isViewingLive/setViewingLive,
jumpToTheFirstUnreadMessage, and the postQueryReconcile/clearStateAndCache overrides).

Pure refactor: MessagePaginator's public surface is unchanged (shared types re-exported), so no
consumer changes. This gives PinnedMessagePaginator an unread-free base to extend by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New PinnedMessagePaginator extends the unread-free MessageIntervalPaginator base, so it never
carries the read/unread surface (pinned messages are a subset of the channel and must not
participate in read/delivery tracking). It fetches from the /pinned_messages endpoint via
config.doRequest, orders by pinned_at ascending, filters on { cid, pinned: true } (so ingestItem
auto-adds on pin and auto-removes on unpin), and includes only pinned, non-shadowed messages on the
query path. jumpToMessage/reflect*/truncate/applyMessageDeletionForUser are inherited.

Not yet wired into Channel — purely additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…parity)

Create channel.pinnedMessagesPaginator, seed it from state.pinned_messages in _initializeState, and
feed it alongside the legacy channel.state.pinnedMessages at every mutation site: message.new/
message.updated (ingestItem auto-adds on pin / auto-removes on unpin), message.deleted, channel.
truncated, channel.hidden clear_history, the reaction events (reflectReaction), and the channel- and
client-level user.messages.deleted/user.deleted (applyMessageDeletionForUser).

Parity-only: both stores run in parallel; nothing reads the paginator yet. Readers migrate and the
legacy store is removed in the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed-only methods

The pinned-message list is now owned by channel.pinnedMessagesPaginator. Delete channel.state
.pinnedMessages plus addPinnedMessage(s)/removePinnedMessage and the now-orphaned pinned-only
methods (addReaction/removeReaction, _updateMessage, updateUserMessages, deleteUserMessages,
_addToMessageList, removeMessageFromArray, clearMessages), and the orphaned utils.addToMessageList /
utils.deleteUserMessages helpers. ChannelState now holds no message/thread/pinned storage.

Per-user pinned updates re-home to pinnedMessagesPaginator.reflectUserUpdate; user deletions to
applyMessageDeletionForUser (channel + client level). channel.ts/client.ts drop the legacy calls;
the paginator feeds added earlier are the sole handling.

Tests: reorganize channel.ts _handleChannelEvent tests into one describe per WS event, each
asserting the effect on messagePaginator, pinnedMessagesPaginator and channel state (test bodies
moved verbatim, no assertion lost). Add parity coverage for the pinned paginator on channel WS
events (truncate/delete/user-deletion soft+hard/reaction) and client-level cross-channel user
deletion (cid-guard, soft, hard, main + pinned) and user.updated propagation.

BREAKING CHANGE: channel.state.pinnedMessages and ChannelState.addPinnedMessage(s)/
removePinnedMessage/addReaction/removeReaction/updateUserMessages/deleteUserMessages are removed.
Read pinned messages from channel.pinnedMessagesPaginator. utils.addToMessageList and
utils.deleteUserMessages are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
channel.state.pinnedMessages removed; PinnedMessagePaginator (unread-free MessageIntervalPaginator
subclass) is the source of truth, fed by channel/client WS-event handlers. React reads it via
usePinnedMessagesCount. Both suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3 audit)

The test-types message reply generator read channel.state.messages (removed) for the thread parent —
use response.message.id directly (the reply's parent_id). A channel last_message_at test passed a
stale 'new' positional arg (old message-set type) to addMessagesSorted — removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 13 (test close-out audit) complete — only two stale channel.state references remained, now
fixed; parity suites confirmed. Correct Task 11/12 statuses to committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Date and _trackLatestMessage

Continue the ChannelState message-storage removal: the message paginator is the single source of
truth for the channel's latest-message metadata, and several legacy ChannelState surfaces are gone.

- MessageIntervalPaginator now tracks the latest message (latestMessageId + a latestMessage getter),
  advanced monotonically on ingest and mirrored into paginator state via a preprocessor. All message
  paginators auto-track on ingest, so it is robust to the active window and to interval/item sort
  orientation (the reply list backing ThreadListItemUI included).
- channel.state.last_message_at is now a read-only getter derived from
  messagePaginator.latestMessage; the writable setter and its backing field are removed.
- Remove channel.state.isUpToDate / setIsUpToDate. Not disrupting a scrolled-away view on a live
  message is handled structurally by the paginator's interval routing, not a flag.
- Remove Channel._trackLatestMessage. user.updated / user.deleted propagation now scans active
  channels (reflectUserUpdate / applyMessageDeletionForUser already self-filter by author id); a
  targeted user-reference index is planned in specs/user-reference-index.
- Remove ChannelState.addMessagesSorted / addMessageSorted and their last remaining callers.
- Offline pending-message replay tracks the latest via messagePaginator.trackLatestMessage.
- Docs: docs/breaking-changes-v14-v15.md.

BREAKING CHANGE: channel.state.last_message_at is read-only (derived from the message paginator).
channel.state.isUpToDate / setIsUpToDate are removed (use messagePaginator.isActiveIntervalAtHead,
hasMoreHead, jumpToTheLatestMessage). ChannelState.addMessagesSorted / addMessageSorted are removed.
See docs/breaking-changes-v14-v15.md for migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Channel.on / StreamChat.on JSDoc examples logged the removed channel.state.messages; use
  channel.messagePaginator.state.items instead.
- CLAUDE.md: note that messages / thread replies / pinned messages live in the paginators
  (channel.messagePaginator / thread.messagePaginator / channel.pinnedMessagesPaginator) as the
  single source of truth, not channel.state; how to read/mutate; the read-only derived
  last_message_at; pointer to docs/breaking-changes-v14-v15.md.
- Mark Task 14 done (JS) in the remove-legacy-channelstate-messages spec. The React CLAUDE.md stale
  guidance remains and is tracked separately on the React branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Size Change: -10.9 kB (-2.11%)

Total Size: 505 kB

📦 View Changed
Filename Size Change
dist/cjs/index.browser.js 168 kB -3.64 kB (-2.12%)
dist/cjs/index.node.js 170 kB -3.59 kB (-2.07%)
dist/esm/index.mjs 167 kB -3.65 kB (-2.14%)

compressed-size-action

@MartinCupela MartinCupela changed the title refactor: remove legacy ChannelState message/thread/pinned storage — paginators are the single source of truth refactor: replace legacy ChannelState message/thread/pinned storage with paginators Jul 21, 2026
Comment thread src/pagination/paginators/MessageIntervalPaginator.ts Outdated
Comment thread src/pagination/paginators/MessageIntervalPaginator.ts Outdated
Comment thread src/pagination/paginators/BasePaginator.ts Outdated

@isekovanic isekovanic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @MartinCupela !

@isekovanic
isekovanic merged commit 3d2a56a into feat/message-paginator-master-merge Jul 22, 2026
4 checks passed
@isekovanic
isekovanic deleted the refactor/remove-legacy-channelstate-storage branch July 22, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants