Repository navigation
feat: support scheduled messages State Layer - #1915
Open
nijeesh-stream wants to merge 20 commits into
Open
nijeesh-stream wants to merge 20 commits into
nijeesh-stream wants to merge 20 commits into
Conversation
Add scheduled_at to sendMessage options, the update/delete/send_now/query scheduled message endpoints, the scheduled_message.* events and a ScheduledMessageManager (client.scheduledMessages) that keeps the current user's scheduled messages in sync from the query, channel queries with include_scheduled_messages, and events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nijeesh-stream
requested review from
MartinCupela,
isekovanic,
oliverlaz and
szuperaz
as code owners
October 14, 2026 22:16
Messages awaiting a send retry report status sending, pending: false is accepted with scheduled_at, and the scheduled send response hydrates mentioned_users. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Ignore an upsert older (by updated_at) than the stored version, so the send-now response or its updated event cannot revert a failed message to scheduled. - load() and channel query hydration no longer undo changes that arrived while they were in flight. - Drop responses to requests made before disconnectUser(), so a new user never sees the previous user's scheduled messages. - getForChannel accepts parentId: null for messages outside threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass include_scheduled_messages through queryChannels and replace each returned channel's entries in the scheduled messages store from its scheduled_messages field. An absent field leaves the store untouched so the app can fall back to queryScheduledMessages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NikolaGoranNaumovski
left a comment
Contributor
There was a problem hiding this comment.
Looks good with some minor changes.
One thing i would add is that i believe there are a lot of comments with large descriptions. I believe this is unnecessary as the code is self explanatory.
Add getScheduledMessageErrorReason(), which reads exception_fields.reason
and falls back to the error code, and register codes 123
(ScheduledMessageAlreadySentError) and 124 (ScheduledMessageCanceledError).
Type the scheduled_messages_* channel config fields and add
getScheduledMessageLimits() with DEFAULT_SCHEDULED_MESSAGE_LIMITS, so a
missing field falls back to the backend default.
Address review feedback:
- load and loadMore share one query helper and snapshot token; loadMore
updates the store once per page
- one subscribeToEvents helper serves both event subscriptions
- order by Date.parse, tie-break with localeCompare
- ScheduledMessageSort is { scheduled_at: 1 }
- drop the ScheduledMessageId alias and the comments this PR added
Add getScheduledMessageErrorReason(), which reads exception_fields.reason
from an ErrorFromResponse and falls back to the error code. Register
codes 123 (ScheduledMessageAlreadySentError) and 124
(ScheduledMessageCanceledError), and make 122 non-retryable.
Type the scheduled_messages_* channel config fields and add
getScheduledMessageLimits() with DEFAULT_SCHEDULED_MESSAGE_LIMITS, so a
missing field falls back to the backend default.
Export ScheduledMessageStatus, ScheduledMessageFailureReason,
ScheduledMessageErrorReason and ScheduledMessageEventType as const
objects with same-named types.
Rework ScheduledMessageManager:
- set isDisabled when load fails with code 114; a successful load
clears it
- getForChannel returns channel-level messages only; add getForThread
and getCountForChannel
- drop the snapshot token (epoch/seq), hasMore and
upsertResponseToState; keep the updated_at check and tombstones
- load and loadMore share one query helper; loadMore updates the store
once per page
- one subscribeToEvents helper for both event groups
- sort by Date.parse, tie-break with localeCompare
- ScheduledMessageSort is { scheduled_at: 1 }
- name every inline type, order members private-first and drop the
comments this PR added
…ypes.ts Collect the client-side types (manager state, options, event, handlers, ScheduledMessageAPIError and ScheduledMessageLimits) in the folder's own types.ts, like notifications/ and search/. src/types.ts keeps the API shapes. Every type is still exported from the package root.
2 tasks done
…4-scheduled-messages Update the scheduled message manager and API tests to the merged API layer.
NikolaGoranNaumovski
changed the base branch from
master
to
feat/cha-5934-scheduled-messages-api
October 27, 2026 15:38
2 tasks done
… a predicate applySnapshot only ever received two predicates: one matching every scheduled message (load) and one matching a single channel's cid (hydrateChannelState). Replace the predicate parameter with an optional cid and move the check into a module-level isInSnapshotScope helper.
…manager applySnapshot only ever received two predicates: one matching every scheduled message (load) and one matching a single channel's cid (hydrateChannelState). Replace the predicate parameter with an optional cid and move the check into a module-level isInSnapshotScope helper. Subscribe to scheduled message events with the SDK's EventHandler type through dedicated handleUpsertEvent and handleRemoveEvent handlers. Remove the ScheduledMessagePredicate and ScheduledMessageHandler types.
…4-scheduled-messages
…r has it updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow remove the item from the store when the request fails with a 404, then rethrow. This happens when the message was sent or deleted elsewhere and the matching event never reached this client; the item would otherwise stay listed until the next reload.
…4-scheduled-messages
This was referenced Nov 2, 2026
…4-scheduled-messages # Conflicts: # src/scheduledMessages/types.ts
load(), channel.query and queryChannels replace the store (or a channel's part of it) with the server's list. A message scheduled, or received from an event, while that request was in flight was missing from the response and got dropped until its next event or a reload. The manager now numbers every message it stores (version). Each snapshot records the version from before its request, and only replaces messages stored before it; newer ones are kept. hydrateChannelState takes that version as an optional third argument, passed by channel.query and queryChannels.
load() had no concurrency guard: a reload on connection.recovered and a load started by the app could resolve out of order, and the first one to finish set isLoading to false while the other was still running. load() now runs through withCancellation with a tag per manager. A newer load aborts the older one: the older result is not applied, its state changes are skipped, and it resolves to undefined. Loads run one after the other, so the latest snapshot always wins.
- queryChannels no longer hydrates scheduled messages for channels listed in stateOptions.skipInitialization, matching how their other state is left alone - a snapshot that leaves the store as it was no longer emits a new scheduledMessages map, so subscribers are not notified for nothing
SCHEDULED_MESSAGES_LOAD_LIMIT, ScheduledMessageMap and ScheduledMessageQueryResultHandler are only used inside ScheduledMessageManager, so they are no longer exported. ScheduledMessageManagerState.scheduledMessages is typed as Map<string, ScheduledMessageResponse> directly.
NikolaGoranNaumovski
force-pushed
the
feat/cha-5934-scheduled-messages-api
branch
from
November 3, 2026 09:07
1679755 to
b4a398e
Compare
NikolaGoranNaumovski
force-pushed
the
feat/cha-5934-scheduled-messages
branch
from
November 3, 2026 09:07
62d7d9c to
070e050
Compare
2 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CLA
Description of the changes, What, Why and How?
Note
Stacked on #1951 (scheduled messages API layer): merge that first. The backend (CHA-5924) shipped in chat
v239.72.0, behind the per-app flagchat_scheduled_messages_enabled(off by default).TLDR
client.scheduledMessages(ScheduledMessageManager), built likeclient.reminders. It loads the user's scheduled messages with one query, stays in sync fromscheduled_message.*events, is filled from channel responses, and updates itself after its own API calls. UIs read it throughstateand the selectors.registerSubscriptions(), and the store is only filled when the app callsload()or passesinclude_scheduled_messages. Server-side clients (_cacheEnabled() === false) never store anything.Ticket
Store:
client.scheduledMessagesState (
StateStore<ScheduledMessageManagerState>)scheduledMessagesMap<id, ScheduledMessageResponse>of the user's active messages (scheduled,sending,failed)isLoadedload()has succeeded at least onceisLoadingisDisabledscheduled_messages_disabled; a later successful load clears itlastQueryErrornextloadMore(); undefined when there is nothing moreSelectors
getFromState(id)getForChannel(cid): messages outside threads, sorted byscheduled_at(ties byid)getForThread(parentId): the thread's replies, same ordergetCountForChannel(cid): count ofgetForChannel(cid)without building the listLoading
load(): queries up to 100 and replaces the store.connection.recoveredand one started by the app) run throughwithCancellation: a newer load aborts the older one, the older result is not applied and resolves toundefined, and only the latest load clearsisLoading.loadMore(): adds the next page in one store update; does nothing withoutnextor while loading.loadMore(). Pending ones always fit in the first page.Events (
registerSubscriptions()/unregisterSubscriptions(), the same opt-in asReminderManager)scheduled_message.created,.updated,.failed: upsertscheduled_message.sent,.deleted: removeconnection.recovered:load()again if the store was loadedActions:
updateScheduledMessage,deleteScheduledMessageandsendScheduledMessageNowcall the client methods and then update the store. If one fails with a 404 (isScheduledMessageNotFoundError, from #1951), the message was already sent or deleted elsewhere and its event never reached this client: the item is removed from the store (and remembered, so a late event cannot bring it back) and the error is rethrown. Any other error leaves the store as it is.Filled from elsewhere
channel.sendMessage(message, { scheduled_at }): the response'sscheduled_messageis upserted. The message is not added to the channel; it arrives later throughmessage.new.channel.query({ include_scheduled_messages: true })andclient.queryChannels(..., { include_scheduled_messages: true }): each channel'sscheduled_messagesreplaces that channel's entries.[]empties them. If the field is absent (not requested, feature off, more than 100 across the page), the store is left as it is.queryChannelsskips channels listed instateOptions.skipInitialization, and a snapshot that changes nothing does not notify subscribers.disconnectUser()resets the store.Ordering rules
Responses and events can arrive in any order. Two per-message rules keep the store correct:
Newest
updated_atwins. An older response or event is ignored, so a send-now response arriving afterscheduled_message.failedcannot turn the message back intoscheduled. Duringload()and hydration, a stored version newer than the snapshot's is kept.Sent and canceled are final. A message with status
sentorcanceled, or one removed by an event ordeleteScheduledMessage, is remembered untildisconnectUser(). A late response or a snapshot cannot bring it back. Scheduling again always uses a newmessage.id, so a remembered id never comes back legitimately.Messages stored during a snapshot request are kept. The manager numbers every message it stores (
version).load(),channel.queryandqueryChannelsread the version before their request, and the snapshot only replaces messages stored before it, so a message scheduled or received from an event while the request was in flight stays.hydrateChannelState(cid, scheduledMessages, requestedAtVersion?)takes that version.Known limitations:
load()drops older failed pages that were fetched withloadMore(); itsnextcursor lets the UI fetch them again.disconnectUser()is applied to the next session. This needs a request in flight at that exact moment.Backend contract notes
status: "sending"; 409scheduled_message_sendinghappens exactly then, so UIs can disable actions from the status alone.scheduled_message.message.mentioned_usersholds user IDs; the SendMessage response'smessage.mentioned_usersholds user objects.message.idis the scheduled message ID and the message is not in the channel. UIs must not insert an optimistic message for a scheduled send.scheduled_atthat is already past sends the message now: the response hasmessageand noscheduled_message, and nothing is stored.Public types
ScheduledMessageManager,ScheduledMessageManagerState,ScheduledMessageManagerOptions,ScheduledMessageEvent. The load limit and the map and query-handler types stay internal.Tests
[], absent, other channels kept), reset on disconnect.sending, registration on and off, reload onconnection.recovered.load(),loadMore()paging in one update, finished messages skipped, error kept,isDisabledon 114 and cleared on success..failed, stale.updated, equalupdated_at,.sentand.failedwhileload()is in flight, a message stored whileload()/channel.query/ a hydration request is in flight is kept, and only the latest of two overlapping loads is applied.skipInitializationare left alone, and an unchanged snapshot does not notify subscribers.sendMessagewithscheduled_atupserts,channel.queryandqueryChannelshydrate.Changelog
client.scheduledMessages(ScheduledMessageManager): a store of the current user's scheduled messages, kept in sync fromscheduled_message.*events, withload()/loadMore(),getForChannel/getForThread/getCountForChannel,isDisabled, andupdateScheduledMessage/deleteScheduledMessage/sendScheduledMessageNow(a 404 removes the item from the store).channel.sendMessagewithscheduled_at,channel.queryandclient.queryChannelswithinclude_scheduled_messagesnow fillclient.scheduledMessages.