Repository navigation
feat: store-backed scheduled message lists owned by channels (CHA-5934) - #2013
MartinCupela wants to merge 4 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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>(); |
There was a problem hiding this comment.
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 = ( |
There was a problem hiding this comment.
The rule the whole feature relies on:
- A
sentcopy wins, whether it's the one stored or the one arriving. - Otherwise the newer
updated_atwins. - 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.
| channel: this, | ||
| paginatorOptions: { declarativeConfig: declarativeConfig?.pinnedMessagesPaginator }, | ||
| }); | ||
| this.scheduledMessagesPaginator = new ScheduledMessagePaginator({ |
There was a problem hiding this comment.
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.
| const storedUnder = this.cid; | ||
| this.cid = serverCid; | ||
| if (storedUnder !== serverCid) | ||
| this.scheduledMessagesPaginator.filter = { cid: serverCid }; |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
Two bugs fixed when a list is updated inside a batch:
- This used to read
items, which lag behind insidebatch()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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
|
How to review this PR. About 36 files change, but most of the code is in six places. Suggested order:
You can skim the tests (they mirror the points above) and the deleted |
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>
CLA
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, aversionread around every channel query) and every action. That made it the owner of data that belongs elsewhere:Channelread the manager'sversionbefore each query and handedscheduled_messagesback to it.channel.scheduledMessagesPaginator, filled by the channel query like pinned messages.client.scheduledMessagePaginatorsholds only the lists the integrator registers (insertPaginator), likeChannelManager, with anEventHandlerPipelineper event type.EntityStoregainedshouldReplaceand tombstones, so a stale copy can never overwrite a newer one, whoever writes.Other improvements
BasePaginator.batch(fn, { coalesce: true })collects store notifications and publishes the list once, so hydrating a page doesn't re-render once per message.ReminderPaginator's pattern. The default statuses (scheduled, sending, failed) are applied only when matching. Settingfilterresets the list.ScheduledMessagesFilterhas onlycidandstatus(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) andthread.scheduledMessages(one thread's replies) areScheduledMessagesViews of the channel's list. Nothing is copied into threads, and a thread doesn't need its data loaded first.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 owntoHead/toTailand their debounced versions.new ScheduledMessagesView({ paginator }).BasePaginator.placePagenow starts an interval in a list that's loaded, complete and empty. Before, a message arriving there was lost.failedScheduledMessageis merged intoeditedScheduledMessage.updateScheduledMessage()), a failed one is sent or scheduled again as a new message.rescheduleFailedMessageandcancelRescheduleFailedMessageare gone;editScheduledMessageandcancelEditScheduledMessagecover both cases.ScheduledMessageBlockReason.Pollis 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 likesend(). 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)
scheduled_message.scheduled_atfrom the server, not the picked timescheduleMessage; this needs an SDK decision.Testing
EntityStore:shouldReplaceand tombstones.BasePaginator: empty complete pages and batched publishing.query(),watch()andqueryChannels.PaginatorViewand the channel and thread scheduled-message views.yarn typesand ESLint are clean; 5,302 tests pass.Changelog
channel.scheduledMessagesPaginator) and byclient.scheduledMessagePaginators, and backed by a store that keeps the newest copy.editedScheduledMessagefor both edits and retries of failed messages.channel.scheduledMessagesandthread.scheduledMessageslist a channel's own scheduled messages and a thread's scheduled replies, as views built on the newPaginatorView.🤖 Generated with Claude Code