Skip to content

feat: support scheduled messages State Layer - #1915

Open
nijeesh-stream wants to merge 20 commits into
feat/cha-5934-scheduled-messages-apifrom
feat/cha-5934-scheduled-messages
Open

nijeesh-stream wants to merge 20 commits into
feat/cha-5934-scheduled-messages-apifrom
feat/cha-5934-scheduled-messages

Conversation

@nijeesh-stream

@nijeesh-stream nijeesh-stream commented Oct 14, 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?

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 flag chat_scheduled_messages_enabled (off by default).

TLDR

  • Problem: feat: add scheduled messages API #1951 adds the scheduled message endpoints, types and events, but nothing keeps the current user's scheduled messages in memory. Every UI would have to query, track events and handle out-of-order responses itself.
  • What/how: A new client.scheduledMessages (ScheduledMessageManager), built like client.reminders. It loads the user's scheduled messages with one query, stays in sync from scheduled_message.* events, is filled from channel responses, and updates itself after its own API calls. UIs read it through state and the selectors.
  • Risks: Low. Everything is additive. Event handling only starts when the app calls registerSubscriptions(), and the store is only filled when the app calls load() or passes include_scheduled_messages. Server-side clients (_cacheEnabled() === false) never store anything.

Ticket

Store: client.scheduledMessages

State (StateStore<ScheduledMessageManagerState>)

Field Meaning
scheduledMessages Map<id, ScheduledMessageResponse> of the user's active messages (scheduled, sending, failed)
isLoaded load() has succeeded at least once
isLoading A query is running
isDisabled The last query failed with 114 scheduled_messages_disabled; a later successful load clears it
lastQueryError Error of the last failed query
next Cursor for loadMore(); undefined when there is nothing more

Selectors

  • getFromState(id)
  • getForChannel(cid): messages outside threads, sorted by scheduled_at (ties by id)
  • getForThread(parentId): the thread's replies, same order
  • getCountForChannel(cid): count of getForChannel(cid) without building the list

Loading

  • load(): queries up to 100 and replaces the store.
  • Overlapping loads (for example a reload on connection.recovered and one started by the app) run through withCancellation: a newer load aborts the older one, the older result is not applied and resolves to undefined, and only the latest load clears isLoading.
  • loadMore(): adds the next page in one store update; does nothing without next or while loading.
  • A user can have 100 pending messages plus 7 days of failed ones, so failed messages can need loadMore(). Pending ones always fit in the first page.

Events (registerSubscriptions() / unregisterSubscriptions(), the same opt-in as ReminderManager)

  • scheduled_message.created, .updated, .failed: upsert
  • scheduled_message.sent, .deleted: remove
  • connection.recovered: load() again if the store was loaded

Actions: updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow call 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's scheduled_message is upserted. The message is not added to the channel; it arrives later through message.new.
  • channel.query({ include_scheduled_messages: true }) and client.queryChannels(..., { include_scheduled_messages: true }): each channel's scheduled_messages replaces 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. queryChannels skips channels listed in stateOptions.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:

  1. Newest updated_at wins. An older response or event is ignored, so a send-now response arriving after scheduled_message.failed cannot turn the message back into scheduled. During load() and hydration, a stored version newer than the snapshot's is kept.

  2. Sent and canceled are final. A message with status sent or canceled, or one removed by an event or deleteScheduledMessage, is remembered until disconnectUser(). A late response or a snapshot cannot bring it back. Scheduling again always uses a new message.id, so a remembered id never comes back legitimately.

  3. Messages stored during a snapshot request are kept. The manager numbers every message it stores (version). load(), channel.query and queryChannels read 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 with loadMore(); its next cursor lets the UI fetch them again.
  • A response that resolves after disconnectUser() is applied to the next session. This needs a request in flight at that exact moment.

Backend contract notes

  • A message waiting for a send retry reports status: "sending"; 409 scheduled_message_sending happens exactly then, so UIs can disable actions from the status alone.
  • scheduled_message.message.mentioned_users holds user IDs; the SendMessage response's message.mentioned_users holds user objects.
  • In a scheduled SendMessage response, message.id is the scheduled message ID and the message is not in the channel. UIs must not insert an optimistic message for a scheduled send.
  • A scheduled_at that is already past sends the message now: the response has message and no scheduled_message, and nothing is stored.

Public types

ScheduledMessageManager, ScheduledMessageManagerState, ScheduledMessageManagerOptions, ScheduledMessageEvent. The load limit and the map and query-handler types stay internal.

Tests

  • Manager: upsert/remove, finished statuses, remembered removals, cache disabled, selectors (channel, thread, count, tie-break), hydration (present, [], absent, other channels kept), reset on disconnect.
  • Events: each event type, retry sending, registration on and off, reload on connection.recovered.
  • Loading: replace on load(), loadMore() paging in one update, finished messages skipped, error kept, isDisabled on 114 and cleared on success.
  • Ordering: send-now response after .failed, stale .updated, equal updated_at, .sent and .failed while load() is in flight, a message stored while load() / channel.query / a hydration request is in flight is kept, and only the latest of two overlapping loads is applied.
  • Hydration: channels in skipInitialization are left alone, and an unchanged snapshot does not notify subscribers.
  • Not found: a 404 from each action removes the item, a later event does not bring it back, and other errors keep it.
  • Integration: sendMessage with scheduled_at upserts, channel.query and queryChannels hydrate.

Changelog

  • Add client.scheduledMessages (ScheduledMessageManager): a store of the current user's scheduled messages, kept in sync from scheduled_message.* events, with load() / loadMore(), getForChannel / getForThread / getCountForChannel, isDisabled, and updateScheduledMessage / deleteScheduledMessage / sendScheduledMessageNow (a 404 removes the item from the store).
  • channel.sendMessage with scheduled_at, channel.query and client.queryChannels with include_scheduled_messages now fill client.scheduledMessages.

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 and others added 3 commits October 15, 2026 04:34
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 NikolaGoranNaumovski 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.

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.

Comment thread src/channel.ts Resolved
Comment thread src/channel.ts Resolved
Comment thread src/client.ts Resolved
Comment thread src/types.ts Resolved
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.
…4-scheduled-messages

Update the scheduled message manager and API tests to the merged API layer.
@NikolaGoranNaumovski
NikolaGoranNaumovski changed the base branch from master to feat/cha-5934-scheduled-messages-api October 27, 2026 15:38
… 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.
…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

# 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
NikolaGoranNaumovski force-pushed the feat/cha-5934-scheduled-messages-api branch from 1679755 to b4a398e Compare November 3, 2026 09:07
@NikolaGoranNaumovski
NikolaGoranNaumovski force-pushed the feat/cha-5934-scheduled-messages branch from 62d7d9c to 070e050 Compare November 3, 2026 09:07
@NikolaGoranNaumovski NikolaGoranNaumovski changed the title feat: support scheduled messages (CHA-5934) feat: support scheduled messages API Layer Nov 3, 2026
@NikolaGoranNaumovski NikolaGoranNaumovski changed the title feat: support scheduled messages API Layer feat: support scheduled messages State Layer Nov 3, 2026
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