Skip to content

feat: store-backed scheduled message lists owned by channels (CHA-5934) - #2013

Open
MartinCupela wants to merge 4 commits into
feat/cha-5934-scheduled-messages-v10from
feat/scheduled-messages-store-redesign
Open

MartinCupela wants to merge 4 commits into
feat/cha-5934-scheduled-messages-v10from
feat/scheduled-messages-store-redesign

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

Ticket: https://linear.app/stream/issue/REACT-1385

Stacked on #1990 (feat/cha-5934-scheduled-messages-v10): this PR changes how the scheduled-message lists are owned and kept consistent, and simplifies the composer's scheduling API. The React side that uses it is GetStream/stream-chat-react#3426.

The full design, with the reasoning, is in specs/scheduled-message-lists/spec.md.

How it differs from #1990

In #1990, one ScheduledMessageManager (client.scheduledMessages) owned every list: a global one, a per-channel map, their shared store, its own consistency layer (resolveLatest, receive versions, hydratedCids, a version read around every channel query) and every action. That made it the owner of data that belongs elsewhere:

Problem in #1990 This PR
A channel's list belonged to the manager; Channel read the manager's version before each query and handed scheduled_messages back to it. The channel owns its list: channel.scheduledMessagesPaginator, filled by the channel query like pinned messages.
The per-channel map only grew: a channel's paginator lived until logout, even after the channel was disposed. The list lives and goes with its channel.
The manager decided which lists exist: no way to add a "failed only" list, and the global list existed whether the UI showed it or not. client.scheduledMessagePaginators holds only the lists the integrator registers (insertPaginator), like ChannelManager, with an EventHandlerPipeline per event type.
Consistency rules lived outside the store; every writer had to go through the manager. The rules live in the store: EntityStore gained shouldReplace and tombstones, so a stale copy can never overwrite a newer one, whoever writes.

Other improvements

  • One publish per response. BasePaginator.batch(fn, { coalesce: true }) collects store notifications and publishes the list once, so hydrating a page doesn't re-render once per message.
  • Filters follow ReminderPaginator's pattern. The default statuses (scheduled, sending, failed) are applied only when matching. Setting filter resets the list.
  • Channel and thread scopes come from the SDK. The server can't filter scheduled messages by thread: ScheduledMessagesFilter has only cid and status (the Swift SDK has the same two keys). So a channel's list holds its own messages and its thread replies together, and the UI had to split them.
    • channel.scheduledMessages (the channel's own messages) and thread.scheduledMessages (one thread's replies) are ScheduledMessagesViews of the channel's list. Nothing is copied into threads, and a thread doesn't need its data loaded first.
    • They're built on a new generic PaginatorView<T>: a read-only view of any paginator's loaded items, narrowed by a predicate. It follows the paginator while registered (ref-counted), keeps the same array when nothing it shows changed, and offers the paginator's own toHead / toTail and their debounced versions.
    • An app that registers a cross-channel list shows it with new ScheduledMessagesView({ paginator }).
  • Empty complete lists: BasePaginator.placePage now starts an interval in a list that's loaded, complete and empty. Before, a message arriving there was lost.
  • Composer, one field instead of two: failedScheduledMessage is merged into editedScheduledMessage.
    • The message's status decides what submitting does: a pending message is saved as an edit (updateScheduledMessage()), a failed one is sent or scheduled again as a new message.
    • rescheduleFailedMessage and cancelRescheduleFailedMessage are gone; editScheduledMessage and cancelEditScheduledMessage cover both cases.
  • Polls can be scheduled. The backend accepts them, so ScheduledMessageBlockReason.Poll is removed. Shared locations stay blocked: a static one would be out of date when sent, and a live one's sharing window would start counting before the message is sent.
  • schedule() releases the composition like send(). With the default poll-only step, the composer keeps the text after a poll goes out, instead of clearing everything.

Breaking changes (to APIs only on the feature branch, unreleased)

client.scheduledMessages → client.scheduledMessagePaginators. Its actions moved to the channel (channel.scheduleMessage, updateScheduledMessage, deleteScheduledMessage, sendScheduledMessageNow). The global list is no longer built in: register your own. The full table is in the spec under "Breaking changes".

Review against the Figma designs (SDK side)

Design point Status
Polls can be scheduled Done
Locations are not offered for scheduling Kept blocked
The banner shows scheduled_message.scheduled_at from the server, not the picked time The server's copy is what the lists hold
Scheduling offline is queued and sent with the same ID on reconnect Open. Web has no offline queue for scheduleMessage; this needs an SDK decision.
Rows sorted earliest first; messages scheduled for the same time sort alphabetically Done. Failed messages come first by time alone, since they failed at their send time, so the pending-before-failed rule from #1990 is gone.
Deleting a message already canceled elsewhere (code 124) drops it quietly Open. Handled for 404 only; the status code 124 comes back with is unconfirmed.

Testing

  • EntityStore: shouldReplace and tombstones.
  • The scheduled-message state machine, including stale copies after cancel and delete.
  • BasePaginator: empty complete pages and batched publishing.
  • Channel hydration from query(), watch() and queryChannels.
  • The manager's pipelines, recovery and reset.
  • The composer: schedule, edit, retry, polls, the merged state, and draft pausing.
  • PaginatorView and the channel and thread scheduled-message views.
  • Results: yarn types and ESLint are clean; 5,302 tests pass.

Changelog

  • Scheduled-message lists are owned by the channel (channel.scheduledMessagesPaginator) and by client.scheduledMessagePaginators, and backed by a store that keeps the newest copy.
  • The composer holds one editedScheduledMessage for both edits and retries of failed messages.
  • Polls can be scheduled.
  • channel.scheduledMessages and thread.scheduledMessages list a channel's own scheduled messages and a thread's scheduled replies, as views built on the new PaginatorView.

🤖 Generated with Claude Code

…ginators manager

Replaces ScheduledMessageManager with channel.scheduledMessagesPaginator and
client.scheduledMessagePaginators, backs the lists with a shared EntityStore,
merges failedScheduledMessage into editedScheduledMessage and lets polls be
scheduled. See specs/scheduled-message-lists/spec.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@MartinCupela MartinCupela left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Notes on the changes with the most impact, for reviewers.

* Whether an incoming copy replaces the stored one (or its tombstone), on `upsert` and `resolveCanonicalEntity`.
* `false` skips the write. Defaults to always replacing.
*/
shouldReplace?: (incoming: T, stored: T) => boolean;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Key change: the store now owns consistency. shouldReplace lets the store refuse a stale copy whoever writes it (the channel query, an event, an action response, a registered list). #1990 needed the manager's resolveLatest, receive versions and version checks around every channel query to get this. Stores that don't pass the option behave exactly as before.

* The copy that removed each removed entity, kept after its entry is gone, so `shouldReplace` can
* refuse an older copy arriving late and let a newer one bring the entity back. Cleared by `clear`.
*/
private tombstones = new Map<string, T>();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tombstones remember an entity that was removed (sent, or canceled), so a late copy that arrives afterwards, such as an updated event racing a deleted one, cannot bring it back into the lists. clear() drops them on logout.

* `updated_at` (server clock) wins. A tie replaces, so `sending` ↔ `scheduled` from queries goes
* through, except over a removal: a deleted failed message keeps its failure's `updated_at`.
*/
export const shouldReplaceScheduledMessage = (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The rule the whole feature relies on:

  • A sent copy wins, whether it's the one stored or the one arriving.
  • Otherwise the newer updated_at wins.
  • On a tie, the incoming copy wins, unless the stored one was already removed.

Every list sees the same answer, because they all share this store.

Comment thread src/channel.ts
channel: this,
paginatorOptions: { declarativeConfig: declarativeConfig?.pinnedMessagesPaginator },
});
this.scheduledMessagesPaginator = new ScheduledMessagePaginator({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ownership moves to the channel. The channel creates its own list and fills it from the channel query's scheduled_messages, as pinned messages already work. It lives and is disposed with the channel. In #1990 the per-channel paginators sat in a manager map that only grew until logout.

Comment thread src/channel.ts
const storedUnder = this.cid;
this.cid = serverCid;
if (storedUnder !== serverCid)
this.scheduledMessagesPaginator.filter = { cid: serverCid };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Watch out: a distinct channel created with a provisional cid gets its real cid from the server. Re-setting the filter here keeps the list matching that cid. Without it, events for the channel's messages would stop reaching its list.

*/
private get isLoadedEmptyAndComplete() {
const { hasMoreHead, hasMoreTail } = this.state.getLatestValue();
return (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Two bugs fixed when a list is updated inside a batch:

  • This used to read items, which lag behind inside batch() and the throttle. While a list was being emptied and refilled in one batch, it looked non-empty, and incoming messages were lost: 18 of 20 in a test.
  • Reading the intervals fixes that. Together with setActiveInterval(..., { updateState: false }) below, the list now publishes once per response instead of twice.

* Whether an item ingested into a loaded, empty, complete list starts its only interval. Off by
* default (the item waits in the logical head; `ChannelManager` re-queries such a list instead).
*/
protected get startsIntervalInEmptyCompleteList(): boolean {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Bug fixed for every paginator that opts in. A message arriving in a list that is loaded, complete and empty used to be dropped: there was no interval to put it in. Opting in starts one. The scheduled-message lists opt in, because their first page is often empty.

get failedScheduledMessage(): ScheduledMessageResponse | undefined {
return this.state.getLatestValue().failedScheduledMessage ?? undefined;
/** The open scheduled message when it failed: it is sent or scheduled again, not edited. */
private get failedScheduledMessage(): ScheduledMessageResponse | undefined {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

API simplification: failedScheduledMessage is no longer state. The composer holds one editedScheduledMessage, and its status decides what submitting does: pending means save the edit, failed means send or schedule a new message. This works because the open message's status can't change under it: an edit ends when its message is sent, fails or is deleted elsewhere, and a failed message is never updated.

} else {
this.clear();
// as `send()` does: a retained composition only detaches the poll that just went out
this.releaseSubmittedComposition();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

schedule() now releases the composition the way send() does. Under the default poll-only step, scheduling a poll keeps the user's text in the composer instead of clearing it.


if (pollId) return ScheduledMessageBlockReason.Poll;

// A poll can be scheduled. A location cannot: a static one would be stale by the time it is sent,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behaviour change: polls can be scheduled; the backend accepts them, with the same rules as a live send. Shared locations stay blocked: a static one would be out of date when sent, and a live one's sharing window would start counting before the message goes out.

@MartinCupela

Copy link
Copy Markdown
Contributor Author

How to review this PR. About 36 files change, but most of the code is in six places. Suggested order:

  1. specs/scheduled-message-lists/spec.md. Read "Why" and "Design" for the reasoning, about 5 minutes.
  2. src/scheduledMessages/store.ts and src/entityStore/EntityStore.ts. The rule that keeps the newest copy, and the store options it uses. Everything else depends on these.
  3. src/channel.ts. The channel creates and fills its own list.
  4. src/scheduledMessages/ScheduledMessagePaginatorsManager.ts and eventHandlers.ts. The lists an integrator registers, and how events reach them.
  5. src/pagination/paginators/BasePaginator.ts and ScheduledMessagePaginator.ts. Empty complete lists, and publishing once per response.
  6. src/messageComposer/messageComposer.ts and middleware/messageComposer/scheduledMessage.ts. The single editedScheduledMessage, and polls.

You can skim the tests (they mirror the points above) and the deleted ScheduledMessageManager with its tests.

MartinCupela and others added 3 commits November 11, 2026 15:17
Failed messages failed at their send time, so ordering by scheduled_at alone
already lists them before the pending ones; the status rule is dropped. Ties
sort by text, as the design asks, then by id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The earliest-first comparator no longer reads SCHEDULED_MESSAGE_STATUS, and the
leftover import fails the zero-warnings ESLint run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server cannot filter scheduled messages by thread, so a channel's paginator
holds its own messages and thread replies alike, and UIs had to split them.

- PaginatorView<T>: a read-only, predicate-filtered view of any paginator's
  active interval. It follows the paginator while registered (ref-counted),
  keeps the same array when nothing in its scope changed, and exposes the
  paginator's toHead/toTail and their debounced versions.
- ScheduledMessagesView: a PaginatorView with an `all`, `channel` or `thread`
  scope.
- channel.scheduledMessages: the channel's own scheduled messages, without
  thread replies. Registered with the channel, released on disconnect().
- thread.scheduledMessages: the thread's scheduled replies, read from the
  channel's paginator. Follows it while the thread's subscriptions are
  registered. Nothing is copied into threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant