diff --git a/CLAUDE.md b/CLAUDE.md index f74a5b5282..8b73c49cc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,8 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, config?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. -- **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `v9-to-v10-migration-guide-methods.md`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers and ordering comes from a comparator compiled from `sort`. The manager is instantiated by the `StreamChat` constructor and lives as long as the client (`client.channelManager`) — it is not configurable through the client options; register lists with `insertPaginator({ paginator, index? })`, detach them with `removePaginator(paginatorOrId)` and set cross-list ownership with `setOwnershipResolver(resolverOrPriorityIds?)`. `setPaginators(paginators)` is the primitive the other two build on — use it (or `clearPaginators()`) for batches, since it publishes one state update instead of one per paginator, and skips the update entirely when the set is unchanged. Registration and loaded data have different owners: `disconnectUser` calls `resetPaginatorStates()`, which discards each list's channels (they belong to the user going away) while leaving the lists themselves registered, since which lists exist is the integrator's configuration. Event handling stays customizable: `ChannelManagerOptions.eventHandlers` replaces the default map wholesale at construction (start from `getDefaultHandlers()` to enrich it instead), and `addEventHandler` / `setEventHandlers` / `removeEventHandlers` adjust the pipelines afterwards — which is the only route for `client.channelManager`, since the client constructs it without options. The exported `ignoreEventsForUnknownChannels` handler, inserted at `index: 0`, is how a list opts out of pulling in channels it has not loaded. +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `ai-docs/v10-breaking-changes.md#channel-state`. +- **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `ai-docs/v10-breaking-changes.md#channel-manager`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers and ordering comes from a comparator compiled from `sort`. The manager is instantiated by the `StreamChat` constructor and lives as long as the client (`client.channelManager`) — it is not configurable through the client options; register lists with `insertPaginator({ paginator, index? })`, detach them with `removePaginator(paginatorOrId)` and set cross-list ownership with `setOwnershipResolver(resolverOrPriorityIds?)`. `setPaginators(paginators)` is the primitive the other two build on — use it (or `clearPaginators()`) for batches, since it publishes one state update instead of one per paginator, and skips the update entirely when the set is unchanged. Registration and loaded data have different owners: `disconnectUser` calls `resetPaginatorStates()`, which discards each list's channels (they belong to the user going away) while leaving the lists themselves registered, since which lists exist is the integrator's configuration. Event handling stays customizable: `ChannelManagerOptions.eventHandlers` replaces the default map wholesale at construction (start from `getDefaultHandlers()` to enrich it instead), and `addEventHandler` / `setEventHandlers` / `removeEventHandlers` adjust the pipelines afterwards — which is the only route for `client.channelManager`, since the client constructs it without options. The exported `ignoreEventsForUnknownChannels` handler, inserted at `index: 0`, is how a list opts out of pulling in channels it has not loaded. - **`connection/wsConnection/StableWSConnection.ts` (`StableWSConnection`)** — the realtime socket. With `client.wsConnection.config.enableWSFallback` (a `wsConnection` config key; a `StreamChatOptions` entry in v9), `client.wsConnection.connect()` switches to v9's `WSConnectionFallback` (`src/connection/wsConnection/WSConnectionFallback.ts`, long-poll on `/api/v2/longpoll`, held on `client.wsConnection.fallback`) when the socket fails with a network error — which it reports after the full `connectTimeoutMs`, where v9 cut it to 6s — dispatches `connection.fallback_activated`, and stays on it, across `disconnectUser()` too. That class is v9's, adapted only where v10 changed what it relied on: it writes `client.wsConnection.state`, drives `ConnectionIdManager`, takes network status from `WSConnection`'s one subscription through `_applyNetworkStatus` (ignored while disconnected, where v9 stopped listening for good at its first disconnect), exposes `isConnecting`, and sets `connection_id` on its own polls and close. It calls the generated `client.longPoll()`, with per-request `timeout`s through `StreamRequestOptions` and an `AbortController` for cancellation; `ApiClient.resolveUrl` maps a local API's `:3030` to `:8900` for that path. The socket connects to `/api/v2/connect`, which authenticates off the **first frame the client sends** (`client._buildWSAuthMessage()`) rather than the query string, and answers with a `connection.ok` hello event instead of v1's `health.check`. It runs a 25s ping and a 35s connection check, and reconnects on close/error, publishing every status transition into `client.wsConnection.state`. `connection.ok` (`ConnectedEvent`) and `connection.error` (`ConnectionErrorEvent`) come from the generated `WSEvent` union like every other wire event. **It no longer registers `window` listeners.** The offline/online edge now arrives from `client.networkConnection` (see below), which inverts the old dependency: the socket is a _consumer_ of network status rather than the thing that detects it. Its own settings — connect timeout, `enableWSFallback`, ping interval, connection-check grace period, the UI's offline-notification delay, `webSocketImpl`, `urlParams` and an injectable socket — live in `client.wsConnection.config`, not in `StreamChatOptions`; `client.defaultWSTimeout` is gone. Every instance is created by `WSConnection.connect()`, which also disconnects the one it replaces, and read live from that config so an `updateConfig` reaches an already-open socket. It holds its `WSConnection` parent rather than the client, and reaches the client through it. - **`networkConnection/` — `NetworkConnectionObserver` (`client.networkConnection`).** The device's own network status, as a `StateStore`, written by a `NetworkStatusReporter` — a function that installs a platform listener and returns an unsubscribe, supplied as `statusReporter` config or through `setStatusReporter()`. `isOnline` is `boolean | undefined`, and `undefined` means _unknown_ rather than offline, so guards must test `=== false`. It is an accelerator, never a precondition — nothing in the SDK requires it. @@ -81,10 +81,10 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`connection/ConnectionIdManager.ts` (`client.connectionIdManager`).** The connection id — the WebSocket's, or the long-poll's after an `enableWSFallback` switch — and the only place the client reads it from: `client._getConnectionID()` / `_hasConnectionID()` read through it and keep no copy. The long-poll keeps no copy either: `WSConnection.disconnect()` reads the id before the socket's `disconnect()` invalidates it, and passes it to `WSConnectionFallback.disconnect()` for the close. The server keys watches and presence by it and answers `200` while registering nothing when it is missing, so `ApiClient._doRequest` holds any request `requiresConnectionId` recognises until one exists — the `watch` / `presence` flags, and nothing else. The flagless operations that declare `connection_id` set it themselves: `StreamChat.stopWatchingChannel` (overridden to always wait for the connection id as the gate would — through a handshake or reconnect, honouring the abort signal, and rejecting when there is no connection and none is being established — then send it) and the long-poll fallback; `test/unit/codegen/connectionIdEndpoints.test.ts` pins that generated endpoint set. `StableWSConnection` drives the lifecycle: `arm()` before a socket opens, `resolveConnectionId()` on the hello frame, `invalidate()` from `_applyHealth(false)` (every status transition, so the error paths are covered) and again from `disconnect()`, and a reject when a reconnect gives up. After an `enableWSFallback` switch `WSConnectionFallback` drives it instead, in the same shape: `arm()` in `connect()`, `resolveConnectionId()` before going healthy, `invalidate()` on going closed or disconnected, and a reject when a connect fails (unless cancelled) or a poll hits a non-retryable error. A deliberate close invalidates rather than rejects: `closeConnection()` exists for mobile backgrounding, so a request already waiting keeps waiting for the socket `openConnection()` will build. `reset()` is the harder variant that fails those waiters instead; nothing in the SDK calls it. A caller's abort signal reaches the wait, so an abandoned request does not hold it. - **`wsConnection/` — `WSConnection` (`client.wsConnection`).** A stable wrapper created with the client and never replaced, owning the socket's reactive status (`state`: `isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`; the connection id lives on `client.connectionIdManager`), its configuration, and the one network-status subscription. The live `StableWSConnection` hangs off `.connection` and _is_ replaced per connect, which is why the store cannot live there — it would be unreadable before `connectUser` and would strand subscribers on every `closeConnection()` → `openConnection()` cycle. The store is written on **every** transition, including `disconnect()` and the error paths. It is the only description of the socket's status: there is no companion event. It also performs the `enableWSFallback` switch and holds the long-poll on `.fallback`, which every later `connect()` reuses and `disconnect()` closes alongside the socket. After the switch, `WSConnectionFallback` writes the store through `_setStatus()` instead — so `isHealthy`, `ConnectionRecoveryManager` and the socket-mirroring network reporter all follow the long-poll — `isConnecting` and the network-status routing follow it too, and `.connection` keeps the disconnected socket. - **No `store.ts`** — `StateStore` comes from `@stream-io/state-store`, imported directly wherever it is used and **not** re-exported from the root barrel (see "State and subscription patterns" below). -- **`signing.ts` — `UserFromToken` and `DevToken`.** `UserFromToken` decodes a JWT payload with the global `atob` and returns `user_id`; `DevToken` (also `client.devToken`) builds the unsigned development token (fixed `devtoken` signature, accepted only while the app disables auth checks), so it holds no secret. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. +- **`signing.ts` — `UserFromToken` and `DevToken`.** `UserFromToken` decodes a JWT payload with the global `atob` and returns `user_id`; `DevToken` (also `client.devToken`) builds the unsigned development token (fixed `devtoken` signature, accepted only while the app disables auth checks), so it holds no secret. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `ai-docs/v10-breaking-changes.md#webhook-helpers-removed`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. - **`middleware.ts`** — `MiddlewareExecutor` (see "Middleware pipelines" below). Used by composer pipelines, not by client request lifecycle. - **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. The constructor takes no arguments: there is no `secret` and no local JWT signing — every token comes from the caller (a string or a `TokenProvider`). Anonymous users may have no token at all; anyone else without one now fails at `getToken()` rather than at `setTokenOrProvider()`. -- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection/wsConnection/StableWSConnection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `v9-to-v10-migration-guide-dates.md`. `src/types.ts` overlays one non-generated member onto the public `Event` union, `LocalEvent` — `channels.queried`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, `connection.fallback_activated` (the `enableWSFallback` switch to long-poll), all dispatched client-side only and never received over the wire — and declares `received_at` on every `WSEvent` member, because `dispatchEvent` stamps it on all of them while the spec omits it from some (`connection.ok`, `connection.error`). +- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection/wsConnection/StableWSConnection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `ai-docs/v10-breaking-changes.md#timestamp-ns`. `src/types.ts` overlays one non-generated member onto the public `Event` union, `LocalEvent` — `channels.queried`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, `connection.fallback_activated` (the `enableWSFallback` switch to long-poll), all dispatched client-side only and never received over the wire — and declares `received_at` on every `WSEvent` member, because `dispatchEvent` stamps it on all of them while the spec omits it from some (`connection.ok`, `connection.error`). **There is no `connection.changed`.** Connectivity is published as two stores — `client.wsConnection.state` and `client.networkConnection.state` — and nothing else. Publishing it twice meant two descriptions that disagreed: the event was silent on `closeConnection()` and two error paths, and held a drop for five seconds. `connection.recovered` survives, reports that a recovery pass finished rather than a status, and carries no payload. A UI that renders a "connection lost" banner holds a drop for `wsConnection.offlineNotificationDisplayDelayMs` itself, which the event used to do. - **`uploadManager.ts` / `LiveLocationManager.ts` / `CooldownTimer.ts`** — feature controllers, each owns its own `StateStore` slice. - **Domain subsystems** (each a folder with its own `index.ts` barrel): @@ -192,7 +192,7 @@ What core still owns, because each is Chat's to define: These deliberately keep a `message` alongside the `code` so an integrator with no i18n layer still gets readable text. -The consumer-facing delta is `v9-to-v10-migration-guide-i18n.md`; the initiative record is in +The consumer-facing delta is `ai-docs/v10-breaking-changes.md#notifications`; the initiative record is in `specs/i18n-to-core/`, which now also records this move. ## Conventions to preserve diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md new file mode 100644 index 0000000000..40ca8ea9cf --- /dev/null +++ b/ai-docs/v10-breaking-changes.md @@ -0,0 +1,3552 @@ +# stream-chat v9 -> v10 breaking changes + +**Scope:** every breaking change in the public API of the `stream-chat` npm package between v9 (`master` @ `24b7623b`, v9.54.x) and v10 (`release-v10` @ `53505e72`). "Public" means reachable from the package root (`src/index.ts`) or through a public instance member (for example `client.wsConnection`, `channel.cooldownTimer`); members tagged `@internal` or `_`-prefixed are collected in [Internal surface touched](#internal-surface). + +**How this list was produced:** a mechanical diff of the exported surface (names, members, signatures) between the two SHAs; a deep type-compatibility check of v9 types against v10 types in both directions; a compile check of the documentation snippets against v10; a diff of the unit tests; the downstream migrations of stream-chat-react and stream-chat-react-native to v10; the existing migration guides and the commit history. Every entry was then reconciled against the source of both versions, and a few behaviours were confirmed with probes (tsc runs, Node runtime checks). File and line references point to `src/` at the stated SHA. + +**Who it is for:** authors of migration guides and docs, SDK maintainers, and AI agents migrating code. It is the single source of truth; each entry states the v9 shape, the v10 shape and the migration. + +**Legend:** + +- **Kind:** `removed` (gone, possibly with a replacement), `renamed` (same role, new name or location), `signature` (parameters or return type changed), `type-shape` (a type's members changed), `behavior` (same API, different runtime effect), `packaging` (install, runtime, dependencies, shipped types). +- **Impact:** `high` (most integrations hit it), `medium` (common features or a silent behaviour change), `low` (niche APIs, custom subclasses, tests). +- **PR:** the stream-chat-js pull request(s) that introduced the change, when known. +- **Source:** `src/` paths with line numbers; "(v10)" refers to `53505e72`, "(v9)" to `24b7623b`. +- "(tracked in v10-followups.md)" marks current v10 behaviour that looks like a bug and has a follow-up; the entry describes what v10 does today. + +**Exclusions:** changes that were introduced and reverted (or renamed again) within the v10 release candidates are not listed. Only the net difference between the two SHAs counts; an API that existed only in an rc is not a v9 API. + + + +## At a glance + +The highest-impact changes. Most integrations need all of these. + +| # | Change | Entry | +| --- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `new StreamChat(key, config)`: no `secret` slot; options move under `config.client` | [streamchat-constructor](#streamchat-constructor), [streamchat-options](#streamchat-options) | +| 2 | Custom data lives under `custom` on every entity, request and event | [custom-data](#custom-data) | +| 3 | Server timestamps are `TimestampNS` (branded ns numbers), not strings or `Date` | [timestamp-ns](#timestamp-ns) | +| 4 | Sort is `SortParamRequest[]` (`[{ field, direction }]`); all sort aliases removed | [sort-shape](#sort-shape) | +| 5 | Types are generated from the OpenAPI spec; 384 type names renamed or removed | [generated-types](#generated-types), [type-rename-table](#type-rename-table) | +| 6 | `Role`, `User`, `Reaction`, `PollResponse`, `ThreadResponse`, ... keep their name but change meaning | [same-name-meaning-changes](#same-name-meaning-changes) | +| 7 | `Event` is a discriminated union; `EventTypes` -> `EventType`; `EVENT_MAP` removed | [event-union](#event-union), [event-type-names](#event-type-names) | +| 8 | `connection.changed` / `transport.changed` are never dispatched; use `client.wsConnection.state` | [connection-changed-removed](#connection-changed-removed) | +| 9 | `client.queryChannels` is a raw request; the v9 behaviour is `queryChannelsAndHydrate` | [query-channels-raw](#query-channels-raw), [query-channels-hydration](#query-channels-hydration) | +| 10 | `StreamChat` methods take path objects plus request objects; many renamed | [generated-client-signatures](#generated-client-signatures), [renamed-client-methods](#renamed-client-methods) | +| 11 | Watching and presence requests wait for a WS connection id (or fail without a socket) | [watch-waits-for-connection-id](#watch-waits-for-connection-id) | +| 12 | `ErrorFromResponse` -> `StreamAPIError`; non-Stream failures are raw axios errors | [stream-api-error](#stream-api-error) | +| 13 | Logging moves to `chatLoggerSystem`; the default now logs to the console | [logging](#logging) | +| 14 | `StateStore` is no longer exported; import it from `@stream-io/state-store` | [state-store-package](#state-store-package) | +| 15 | `client.activeChannels` -> `client.channelManager` store (`get`, `values`, `ensure`) | [active-channels](#active-channels) | +| 16 | `ChannelManager` rewritten: one per client, N `ChannelPaginator` lists | [channel-manager](#channel-manager), [channel-manager-list-methods](#channel-manager-list-methods) | +| 17 | Channel lists order by sort only; no promote-to-top on new messages | [channel-list-ordering](#channel-list-ordering) | +| 18 | `ChannelState` is a store; the message list moved to `channel.messagePaginator` | [channel-state-store](#channel-state-store), [message-paginator](#message-paginator) | +| 19 | `channel.sendMessage` / `sendReaction` are plain requests; optimistic + offline flow is `channel.messageOperations` | [channel-send-message](#channel-send-message), [channel-send-reaction](#channel-send-reaction) | +| 20 | `channel.markRead()` is the direct request; the v9 path is `markReadViaReporter()` | [channel-mark-read](#channel-mark-read) | +| 21 | `channel.getConfig()` -> `channel.serverConfig`; `ChannelConfig` now means the instance config | [channel-config](#channel-config) | +| 22 | Reconnect recovery reloads only channels you `activate()` | [channel-activate-recovery](#channel-activate-recovery) | +| 23 | Thread replies moved to `thread.messagePaginator`; thread list to `client.threads.paginator` | [thread-replies](#thread-replies), [thread-manager-list](#thread-manager-list) | +| 24 | Link previews are enabled by default in the composer | [link-previews-default](#link-previews-default) | +| 25 | Attachment `mime_type` / `file_size` / `duration` / `waveform_data` moved under `custom` | [attachment-metadata-custom](#attachment-metadata-custom), [composer-upload-metadata](#composer-upload-metadata) | +| 26 | Server-side features (secret, JWT signing, webhooks, admin methods) removed; use `@stream-io/node-sdk` | [server-side-auth-removed](#server-side-auth-removed), [server-only-methods-removed](#server-only-methods-removed), [webhook-helpers-removed](#webhook-helpers-removed) | +| 27 | `engines.node >=22.18.0` (Yarn 1 refuses to install); no bundled `ws` | [node-engine](#node-engine), [websocket-on-node](#websocket-on-node) | +| 28 | Uploads take `{ file }` objects (`File`, `Blob`, RN `{ uri, name, type }`); `sendFile` / `sendImage` gone | [client-uploads](#client-uploads), [channel-uploads](#channel-uploads) | + +## Contents + +1. [Installation, runtime and packaging](#installation-runtime-packaging) +2. [Client construction and configuration](#client-construction) +3. [`StreamChat` methods and properties](#streamchat-methods) +4. [Connection, events transport, errors, logging, tokens](#connection) +5. [Cross-cutting data model](#data-model) +6. [`Channel`](#channel) +7. [`ChannelState` and message paginators](#channel-state) +8. [`ChannelManager`, channel list and `activeChannels`](#channel-manager-section) +9. [Message operations, offline sending and reactions](#message-operations) +10. [Threads](#threads) +11. [Polls](#polls) +12. [Reminders, live location, message delivery and receipts, cooldown](#reminders-delivery) +13. [Search](#search) +14. [Pagination primitives](#pagination) +15. [Moderation](#moderation) +16. [Notifications](#notifications) +17. [Offline support](#offline-support) +18. [MessageComposer](#message-composer) +19. [Utilities](#utilities) +20. [Full type rename table](#type-rename-table) +21. [Behaviour changes that still compile](#behaviour-index) +22. [Internal surface touched](#internal-surface) +23. [Open questions / unverified](#open-questions) + + + +## 1. Installation, runtime and packaging + + + +### `package.json` `engines.node` + +**Kind:** packaging · **Impact:** medium · **PR:** #1830 + +- **v9:** `"node": ">=18"`. +- **v10:** `"node": ">=22.18.0"`. Yarn 1 (classic) refuses to install a dependency whose `engines` does not match (bypass with `--ignore-engines`); npm and pnpm print an EBADENGINE warning unless `engine-strict` is set; Yarn Berry does not check dependency engines. This hits React Native / Expo projects still on Yarn 1 with Node 20 even though Node is not their runtime. Git-ref installs run `prepare` -> `build`, whose `./scripts/bundle.mts` needs default type stripping (Node 22.18+). The node-cjs bundle itself loads and connects on Node 22.14.0 (verified); Node 18/20 fail at `connectUser` without a WebSocket implementation ([WebSocket on Node](#websocket-on-node)). +- **Migrate:** run Node 22.18+ (or 24.x) for installs and builds. +- **Source:** `package.json:107-108`, `scripts/bundle.mts:1` (v10); `package.json:114-115` (v9) + + + +### WebSocket on Node: no bundled implementation + +**Kind:** behavior · **Impact:** low · **PR:** #1819, #1859 + +- **v9:** `connection.ts` imported `isomorphic-ws` (the `ws` package on Node), so `connectUser` worked on any supported Node. +- **v10:** `StableWSConnection` uses `config.webSocketImpl ?? WebSocket` (global). Node 22+ has a global `WebSocket`; on Node 20 (verified 20.20.2) `connectUser` rejects with `WebSocket is not defined`. Browsers and React Native are unaffected. v9 had no `webSocketImpl` option (it was added and renamed during the v10 rcs). +- **Migrate:** use Node 22+, or inject an implementation: `new StreamChat(key, { client: { wsConnection: { webSocketImpl: WS as unknown as typeof WebSocket } } })` with `import WS from 'ws'`. The cast is required: the `ws` class is not assignable to `typeof WebSocket` (TS2322 with `@types/ws` 8). See also [WebSocket implementation and network status](#websocket-impl-network-status). +- **Source:** `src/connection/wsConnection/StableWSConnection.ts:363`, `src/connection/wsConnection/types.ts:81`, `src/configuration/types.ts:190` (v10); `src/connection.ts:1,324` (v9) + + + +### Removed runtime dependencies + +**Kind:** packaging · **Impact:** low · **PR:** #1819 + +- **v9 `dependencies`:** `@types/jsonwebtoken`, `@types/ws`, `axios`, `base64-js`, `form-data`, `isomorphic-ws`, `jsonwebtoken`, `linkifyjs`, `ws`. +- **v10 `dependencies`:** `@stream-io/logger ^2.0.0`, `@stream-io/state-store ^1.1.6`, `axios`, `linkifyjs`. No `peerDependencies` or `optionalDependencies` in either version (`@stream-io/state-store` has optional peers `react`, `use-sync-external-store`, only for its `/react-bindings` subpath). +- **Migrate:** if your code imports `ws`, `jsonwebtoken`, `form-data`, `base64-js`, `isomorphic-ws`, or relied on `@types/ws` / `@types/jsonwebtoken` (and through them `@types/node`) arriving transitively, add them to your own `package.json`. Server code (JWT, webhooks, `secret`) moves to `@stream-io/node-sdk` ([server-side removals](#server-side-auth-removed)); Node `Buffer` / stream uploads are gone ([client uploads](#client-uploads)); `encodeBase64` / `decodeBase64` are gone ([base64](#encode-decode-base64)). +- **Source:** `package.json:47-52` (v10); `package.json:52-62` (v9) + + + +### `package.json` `browser` field removed + +**Kind:** packaging · **Impact:** low · **PR:** #1819 + +- **v9:** `"browser": { "crypto": false, "https": false, "jsonwebtoken": false, "ws": false, "zlib": false }`. +- **v10:** field removed. v10 source imports none of these modules; bundle externals are only `axios`, `@stream-io/logger`, `@stream-io/state-store`, `linkifyjs`. +- **Migrate:** none required. Bundler shims added for v9 (webpack `resolve.fallback`, Metro `extraNodeModules`, Vite aliases, Jest `moduleNameMapper` for `crypto` / `https` / `zlib` / `ws` / `jsonwebtoken`) can be deleted. +- **Source:** `package.json` (v10, no `browser` key), `scripts/bundle.mts:28`; `package.json:31-37` (v9) + + + +### Consumer TypeScript requirements + +**Kind:** packaging · **Impact:** low + +- **v9:** d.ts compiled clean on TS 5.5.4 to 6.0.3 with `skipLibCheck: false` under node16 / node10; with `types: []` it also compiled because `@types/ws` (a runtime dependency) pulled in `@types/node`. Under `moduleResolution: bundler` v9 had 2 errors in `insights.d.ts` (TS1340 on `ws`). +- **v10:** 0 errors on TS 5.5.4, 5.6.3, 5.7.2, 5.8.3, 5.9.3, 6.0.3 under bundler / node16 / node10 with `types: ["node"]` and `lib: ["es2020","dom"]`. Two new requirements, visible only with `skipLibCheck: false`: without `@types/node` (e.g. `types: []` in a browser / RN project) TS2503 `Cannot find namespace 'NodeJS'` from `StableWSConnection.d.ts` (`healthCheckTimeoutRef?: NodeJS.Timeout`); without the DOM lib (Node-only `lib: ["es2022"]`) TS2304 for `CloseEvent` / `ErrorEvent`. `@stream-io/state-store`'s `index.d.ts` uses `export * from "./state-store.ts"`, accepted by TS 5.5+. +- **Migrate:** keep `skipLibCheck: true`, or add `@types/node` and the `dom` lib. TS older than 5.5 is untested. +- **Source:** `dist/types/connection/wsConnection/StableWSConnection.d.ts:30,36,148,181` (v10) + + + +### `StateStore` and friends moved to `@stream-io/state-store` + +**Kind:** removed · **Impact:** high · **PR:** #1861 + +- **v9:** `import { StateStore, MergedStateStore, isPatch, type Unsubscribe, type Handler, type Patch, type Preprocessor, type RemovePreprocessor, type ValueOrPatch } from 'stream-chat'` (`src/store.ts`). +- **v10:** not exported from `stream-chat`; `src/store.ts` deleted. The same API lives in the `@stream-io/state-store` dependency (`^1.1.6`), used internally but not re-exported. +- **Migrate:** `import { StateStore, type Unsubscribe } from '@stream-io/state-store';` and add `"@stream-io/state-store": "^1.1.6"` to your own `dependencies` (required under pnpm / Yarn PnP strict resolution). Same class shape and method names. See [class identity](#state-store-identity) and [edge semantics](#state-store-semantics). +- **Source:** `src/index.ts` (v10, no `./store`), `src/CooldownTimer.ts:1`; `src/index.ts:25`, `src/store.ts:1-15` (v9) + + + +### `StateStore` class identity (every public `StateStore<...>` member) + +**Kind:** type-shape · **Impact:** medium · **PR:** #1861 + +- **v9:** every `state` / `configState` field was typed with the local `StateStore` class. +- **v10:** typed with `@stream-io/state-store`'s `StateStore`, whose `protected` members (`value`, `handlers`, `preprocessors`) make TypeScript compare it nominally. A store from a second resolved copy of `@stream-io/state-store` (or from a v9 `stream-chat`) is not assignable (TS2345 "Types have separate declarations of a private property 'handlers'"). This is the root cause of compat failures through `state.handlers`, `*.merge(...).merged.value`, and "Type 'Record' is not assignable" on `BaseSearchSource`, `BaseSearchSourceSync`, `FilterBuilder`, `NotificationManager`, `ReminderManager`, `SearchControllerState`, `SearchSource`, `SearchSourceSync`, `Suggestions`, `UploadManager`. +- **Migrate:** ensure exactly one `@stream-io/state-store` resolves (`npm ls @stream-io/state-store` / `yarn why @stream-io/state-store`); align your range with `^1.1.6`, dedupe with `overrides` / `resolutions`. Hooks such as `useStateStore(client.threads.state, selector)` must come from bindings compiled against that same copy (the UI SDKs or `@stream-io/state-store/react-bindings`). +- **Source:** `node_modules/@stream-io/state-store/dist/esm/state-store.d.ts` (v10); `src/store.ts:15-19` (v9) + + + +### `StateStore.next` / `subscribeWithSelector` edge semantics + +**Kind:** behavior · **Impact:** low · **PR:** #1861 + +- **v9:** `next()` skipped notification on `newValue === this.value`; `subscribeWithSelector` compared selections by iterating the previous selection's keys with `===`. +- **v10:** `next()` uses `Object.is`; selections are compared by iterating the new selection's keys with `Object.is` (new static `StateStore.doSelectionsEqual`). Verified differences: + + | case | v9 | v10 | + | ------------------------------------------ | ------------------------ | --------------- | + | selector drops a key, remaining keys equal | notifies | does not notify | + | selector gains a key | does not notify | notifies | + | selected value is `NaN` and unchanged | notifies on every update | does not notify | + +- **Migrate:** none for flat, fixed-shape selectors. If a selector returns objects whose key set varies, return a fixed key set (use `undefined` values). +- **Source:** `node_modules/@stream-io/state-store/src/state-store.ts:45,76` (v10); `src/store.ts:47,86` (v9) + +### Global `DateConstructor` guard ships with the types + +**Kind:** packaging · **Impact:** medium · **PR:** #1884 + +Owned by [Global `DateConstructor` overload](#date-constructor-guard) in section 5. Packaging facts: `src/index.ts:87` (`export type {} from './gen/models/timestamp-guard'`) keeps the guard in `dist/types/index.d.ts`, so it applies program-wide in every TS project that loads `stream-chat` types, with or without `skipLibCheck`, under bundler / node16 / node10 resolution (verified TS 5.5.4 and 6.0.3). It is type-only: nothing in the JS bundles, and it cannot be opted out of per file. + +### Root barrel removals owned elsewhere + +| v9 root export path | v10 | entry | +| ---------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `./store` | removed | [state-store-package](#state-store-package) | +| `./signing` | still `export *`, only `UserFromToken`, `DevToken` remain | [webhook-helpers-removed](#webhook-helpers-removed) | +| `./base64`, `./insights` | removed | [encode-decode-base64](#encode-decode-base64), [insights-removed](#insights-removed) | +| `./events` (`EVENT_MAP`) | removed | [event-type-names](#event-type-names) | +| `./campaign`, `./segment` | removed | [campaign-segment-removed](#campaign-segment-removed) | +| `./permissions` | removed | [permissions-removed](#permissions-removed) | +| `./channel_manager`, `./channel_batch_updater` | replaced by `./ChannelManager` / removed | [channel-manager](#channel-manager), [channel-batch-updater](#channel-batch-updater) | +| `promoteChannel` (from `./utils`) | removed | [promote-channel](#promote-channel) | +| `ThreadRepliesPagination` (from `./thread`) | removed | [thread-replies](#thread-replies) | + +### Checked, not breaking + +- `exports`: same `"."` conditions and targets as v9 (`types`, `browser`, `react-native`, `node` -> `dist/cjs/index.node.js`, `default` -> esm). Only addition: `"./package.json"`. `main`, `types`, `files` unchanged. +- Bundles: same three outputs, same `process.env.CLIENT_BUNDLE` values (`node-cjs`, `browser-cjs`, `browser-esm`) and identical `getUserAgent()` logic; the header is now sent as `x-stream-client` (case-insensitive, no effect). Browser ESM minified grew from 303 KB to 431 KB (deps external). +- `sideEffects: false` is new in v10 (v9 had no `sideEffects` key) and safe; the only global effect is the type-only date guard. +- New additive root exports: `LANGUAGE_NAMES`, `LanguageNameCatalog`, `languageNameDefaults`, the configuration types, `EntityStore`, `EventHandlerPipeline`, time helpers. `stream-chat/i18n` never shipped in a v9 release (rc-only). + + + +## 2. Client construction and configuration + + + +### `new StreamChat` / `StreamChat.getInstance` + +**Kind:** signature · **Impact:** high · **PR:** #1911 + +- **v9:** `new StreamChat(key, options?)`, `new StreamChat(key, secret?, options?)`, same two overloads on `StreamChat.getInstance`; `options: StreamChatOptions` (which also extended `AxiosRequestConfig`). +- **v10:** `new StreamChat(key, config?: DeepPartial)` and `StreamChat.getInstance(key, config?)`. `StreamChat` now `extends ChatApi` (generated REST methods, see [generated signatures](#generated-client-signatures)). `config` is the same tree that `client.config.set()` takes; client options live under `config.client`. `StreamChatOptions` -> `InstanceConfigTree`. +- **Migrate:** drop the secret slot and any `undefined` / `''` placeholder; move options under `client` ([option table](#streamchat-options)). In plain JS a v9 call still runs but is silently wrong: `new StreamChat(key, 'secret', opts)` treats the string as the tree (one "unknown key" warning per character) and ignores `opts`; `new StreamChat(key, { timeout, enableWSFallback })` ignores both keys with a warning (axios timeout stays 3000, `enableWSFallback` stays false). +- **Source:** `src/client.ts:139,313-400,502-511`, `src/configuration/InstanceConfigurationRegistry.ts:183-215,377-388` (v10); `src/client.ts:436-597,615-640` (v9) + +```ts +// v9 +const client = StreamChat.getInstance(key, { timeout: 6000, enableWSFallback: true }); +// v10 +const client = StreamChat.getInstance(key, { + client: { + axiosRequestConfig: { timeout: 6000 }, + wsConnection: { enableWSFallback: true }, + }, +}); +``` + + + +### `StreamChatOptions` keys -> `config.client` + +**Kind:** renamed · **Impact:** high · **PR:** #1911 + +- **v9:** flat `StreamChatOptions` keys plus top-level axios fields. +- **v10:** `ClientDeclarativeConfig` under `config.client`: + + | v9 option | v10 location | notes | + | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | + | `allowServerSideConnect` | `client.allowServerSideConnect` | read on every connect; warning goes through the `client` logger scope | + | `axiosRequestConfig` | `client.axiosRequestConfig` | construction-only; see [axios instance](#axios-instance) | + | top-level axios fields (`timeout`, `httpsAgent`, `withCredentials`, `headers`, `adapter`, `proxy`, ...) | `client.axiosRequestConfig.` | v9 spread the whole options object into `axios.create` (a top-level `paramsSerializer` was already overwritten); `paramsSerializer` is now fixed | + | `baseURL` | `client.baseURL` | construction-only; `client.setBaseURL()` at runtime | + | `browser` | `client.browser` | read live by the `client.browser` / `client.node` getters | + | `notifications` (a `NotificationManager` instance) | `client.notificationManager` | construction-only; `client.notifications` is now the manager's config slice ([notifications](#notification-manager-config)) | + | `persistUserOnConnectionFailure` | `client.persistUserOnConnectionFailure` | behaviour change: [persist-user](#persist-user-on-connection-failure) | + | `recoverStateOnReconnect` | `client.connectionRecovery.enabled` (default `true`) | [connection recovery](#connection-recovery); `false` also stops `connection.recovered`, which v9 still dispatched | + | `enableWSFallback` | `client.wsConnection.enableWSFallback` | [long-poll fallback](#long-poll-fallback) | + | `wsUrlParams` | `client.wsConnection.urlParams` | default `undefined` (was empty `URLSearchParams`), URL unchanged | + | `wsConnection` (prebuilt `StableWSConnection`) | `client.wsConnection.connection` | test-only | + | `isLocalUnreadCountEnabled` | `channel.readEvents.localUnreadCountEnabled` | channel config ([markReadLocally](#channel-mark-read-locally)) | + | `device` | removed | call `client.createDevice(...)` after connecting | + | `logger` | removed | `chatLoggerSystem.configureLoggers(...)` ([logging](#logging)) | + | `disableCache` | removed, no replacement | caches always filled; `_cacheEnabled()` gone ([caches](#poll-reminder-cache)) | + | `enableInsights` | removed, no replacement | [insights](#insights-removed) | + | `warmUp` | removed, no replacement | `GET /hi` no longer sent | + +- **Migrate:** `new StreamChat(key, { client: { allowServerSideConnect: true, axiosRequestConfig: { timeout: 5000 }, wsConnection: { enableWSFallback: true }, connectionRecovery: { enabled: false } } })`, or `client.config.setConfig('client', {...})` later. Construction-only paths (`axiosRequestConfig`, `baseURL`, `notificationManager`) set after construction only warn. +- **Source:** `src/configuration/types.ts:144-210,245-277`, `src/configuration/keys.ts` (`CONSTRUCTION_ONLY_CONFIG_PATHS`), `src/client.ts:325-351,585-591,612` (v10); `src/types.ts:1599-1665`, `src/client.ts:466-497` (v9) + + + +### Option-mirror properties on `StreamChat` removed + +**Kind:** removed · **Impact:** medium + +- **v9:** `client.options`, `client.persistUserOnConnectionFailure`, `client.recoverStateOnReconnect`, `client.defaultWSTimeout` (15000), `client.defaultWSTimeoutWithFallback` (6000), `client.consecutiveFailures`. +- **v10:** removed. + + | v9 | v10 | + | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `client.options.` | `client.config.getConfig('client')?.`; axios values via `client.axiosInstance.defaults` | + | `client.persistUserOnConnectionFailure = v` | `client.config.setConfig('client', { persistUserOnConnectionFailure: v })` | + | `client.recoverStateOnReconnect` | `client.connectionRecovery.config.enabled` / `config.client.connectionRecovery.enabled` | + | `client.defaultWSTimeout` | `client.wsConnection.config.connectTimeoutMs` (default 15000), via `config.client.wsConnection.connectTimeoutMs` or `client.wsConnection.updateConfig(...)` | + | `client.defaultWSTimeoutWithFallback` | none: the WS always uses `connectTimeoutMs`; lower it to fall back sooner | + | `client.consecutiveFailures` | none (counter used only by the v9 token-expiry backoff) | + +- **Migrate:** per table. +- **Source:** `src/connection/wsConnection/config.ts:10-19`, `src/client.ts:436-447` (v10); `src/client.ts:354-428,463-529` (v9) + + + +### Axios instance and request headers + +**Kind:** behavior · **Impact:** medium · **PR:** #1911, #1909 + +- **v9:** the whole options object went to `axios.create`; `axiosRequestConfig` was spread per request after the SDK's own config, so its `headers` could override `Authorization` / `stream-auth-type` / `X-Stream-Client` and its rest (e.g. `timeout`) overrode per-call config such as the upload `timeout: 0`. In Node a keep-alive `https.Agent({ keepAlive: true, keepAliveMsecs: 3000 })` was created by default. `paramsSerializer` could be overridden only through `axiosRequestConfig` (a top-level one was overwritten right after `axios.create`). +- **v10:** `axios.create({ timeout: 3000, withCredentials: false, ...client.axiosRequestConfig, paramsSerializer: axiosParamsSerializer })` once. Per request only `axiosRequestConfig.headers` / `params` are re-read; integrator headers are spread first, so SDK `Authorization`, `stream-auth-type`, `x-stream-client`, `x-client-request-id` always win; per-request params override integrator params. No default Node agent. `paramsSerializer` is fixed. +- **Migrate:** pass `httpsAgent` explicitly in Node if you relied on keep-alive; stop overriding auth headers; per-call timeouts via `requestOptions.timeout`. +- **Source:** `src/client.ts:344-349`, `src/api-client.ts:146-171` (v10); `src/client.ts:483-498,3895-3949` (v9) + + + +### Read-only getters and id aliases on `StreamChat` + +**Kind:** signature · **Impact:** low + +- **v9:** writable fields `client.anonymous`, `client.browser`, `client.node`, `client.userID`, `client.clientID`. +- **v10:** getters: `anonymous` (from `tokenManager.isAnonymous`), `browser` (from `config.client.browser`, else `typeof window`), `node`, `userId` and deprecated `userID` (from `user?.id`); `clientId` field plus deprecated `clientID` getter/setter. Assigning a getter without a setter throws in strict-mode code. +- **Migrate:** use `userId` / `clientId`; set `browser` through config; stop assigning `anonymous` / `node` / `userID` (connect through `connectUser` / `connectAnonymousUser`). +- **Source:** `src/client.ts:211,251-277` (v10); `src/client.ts:353-391,711-713` (v9) + + + +### `setMessageComposerSetupFunction` deprecated; `MessageComposerSetupState` removed + +**Kind:** renamed · **Impact:** low · **PR:** #1831 + +- **v9:** `client.setMessageComposerSetupFunction(fn | null)` stored into `client._messageComposerSetupState: StateStore`; composers subscribed to that store in `registerSubscriptions`. `MessageComposerSetupState` was root-exported. +- **v10:** the method still works but is `@deprecated` and forwards to `client.config.setSetupFunction('messageComposer', fn)`. `_messageComposerSetupState` and `MessageComposerSetupState` are removed; writing to the old store no longer reaches composers. Composers subscribe through `client.config` (key `messageComposer`); the previous setup's teardown runs before the next one. Equivalent type: `InstanceSetupState<'messageComposer'>`, read via `client.config.getSetupFunction('messageComposer')`. +- **Migrate:** `client.config.setSetupFunction('messageComposer', ({ composer }) => teardown)`; replace `MessageComposerSetupState` with `InstanceSetupState<'messageComposer'>`. +- **Source:** `src/client.ts:545-552`, `src/configuration/types.ts:85-87`, `src/messageComposer/messageComposer.ts:898-910` (v10); `src/client.ts:319-335,410-416,663-670`, `src/messageComposer/messageComposer.ts:676-692` (v9) + + + +### API secret and server-side auth removed + +**Kind:** removed · **Impact:** medium · **PR:** #1819 + +- **v9:** `client.secret`, the `secret` constructor argument, `client.createToken(userID, exp?, iat?)`, `client._isUsingServerAuth()`, `client.validateServerSideAuth()`, `new TokenManager(secret)` signing server tokens (`JWTServerToken`) when no user token was set. +- **v10:** all removed. No server JWTs are produced; every request needs a user token (or anonymous). +- **Migrate:** move server code to `@stream-io/node-sdk` (token creation there). Delete branches gated on `_isUsingServerAuth()`. See also [server-only methods](#server-only-methods-removed) and [webhook helpers](#webhook-helpers-removed). +- **Source:** `src/token_manager.ts:21-26` (v10); `src/client.ts:451-454,1227-1245,3892,4297`, `src/token_manager.ts:24-34,58-61,140-142` (v9) + + + +## 3. `StreamChat` methods and properties + +Common rules for every generated or rewritten `StreamChat` method: path parameters are a first object argument (a stray key in it throws `"" is not a path parameter of `), then a request object sent as given, then `requestOptions?: StreamRequestOptions` (`{ signal?, onUploadProgress?, timeout? }`, replaces v9 `RequestOptions`). No-body endpoints keep an unused `_request?: Record` slot (pass `undefined` to reach `requestOptions`). All `user_id` / `userId` "on behalf of" overrides are dropped. Responses are `StreamResponse` = generated body plus `metadata` (rate limit, headers, status, client request id). Sort, filter and date shapes: [sort](#sort-shape), [filters](#filter-types), [request dates](#request-dates). + + + +### Renamed `StreamChat` methods + +**Kind:** renamed · **Impact:** high · **PR:** #1832, #1836 + +- **v9 -> v10:** + + | v9 | v10 | + | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | + | `queryUserGroups(options?)` | `listUserGroups({ limit?, id_gt?, created_at_gt?, team_id? }?)` | + | `addDevice(id, push_provider, userID?, push_provider_name?)` | `createDevice({ id, push_provider, push_provider_name?, hardware_id?, voip_token? })` | + | `getDevices(userID?)` | `listDevices()` | + | `removeDevice(id, userID?)` | `deleteDevice({ id })` | + | `setLocalDevice(device)` | none; call `createDevice(...)` after connecting (device no longer sent on WS connect) | + | `getUnreadCount(userID?)` | `unreadCounts()` (connected user only) | + | `setPushPreferences(preferences)` | `updatePushNotificationPreferences({ preferences })` | + | `partialUpdateUser(u)` / `partialUpdateUsers(us)` | `updateUsersPartial({ users: [{ id, set?, unset? }] })` | + | `upsertUser(u)` / `upsertUsers(us)` / `updateUser(u)` (alias) | `updateUsers({ users: { [id]: user } })` (record keyed by id) | + | `unBlockUser(id, userID?)` | `unblockUser(id, requestOptions?)` (wrapper over generated `unblockUsers`) | + | `getSharedLocations()` | `getUserLiveLocations()` | + | `updateLocation(payload)` | `updateLiveLocation({ message_id, latitude?, longitude?, end_at? })` | + | `markAllRead(data?)` (alias) | `markChannelsRead(request?)` | + | `partialUpdateMessage(id, { set, unset }, userId?, options?)` | `updateMessagePartial({ id }, { set?, unset?, skip_enrich_url?, skip_push? })` | + | `ephemeralUpdateMessage(id, partial, user?, options?)` | none (ephemeral endpoint gone); use `updateMessagePartial` | + | `partialUpdateThread(messageId, { set, unset })` | `updateThreadPartial({ message_id }, { set?, unset? })` (no reserved-field guard, no empty-id throw) | + | `enrichURL(url)` | `getOG({ url })` (response `GetOGResponse`) | + | `hydrateActiveChannels(channels, stateOptions?, options?)` | `hydrateChannels(channels, stateOptions?, queryOptions?)` | + | `setAnonymousUser()` (alias) | `connectAnonymousUser()` | + | poll methods (`partialUpdatePoll`, `closePoll`, `addPollAnswer`, `removePollVote`) | see [poll client methods](#poll-client-methods) | + +- **Migrate:** per table. User data passed to `updateUsers` / `createGuest` is `UserRequest` (custom fields under `custom`, see [custom data](#custom-data)). +- **Source:** `src/gen/chat/ChatApi.ts`, `src/client.ts:904,1672,1867` (v10); `src/client.ts:1189,1965,2286,2460-2560,2773-2800,3041,3253,3537,3575,3810,4605,5276` (v9) + + + +### Same-name methods with generated (`ChatApi`) signatures + +**Kind:** signature · **Impact:** high · **PR:** #1760, #1832, #1836, #1870, #1896 + +- **v9:** hand-written positional methods returning `APIResponse & ...`; many took `user_id` / `userId` overrides. +- **v10:** inherited from generated `ChatApi` (`StreamChat extends ChatApi`) or thin wrappers over it: + + | method | v9 | v10 | + | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `queryUsers` | `(filter, sort?, options?, requestOptions?)` | `({ payload: { filter_conditions, sort, presence?, limit?, ... } }?)` | + | `search` | `(filterConditions, query \| messageFilters, options?, requestOptions?)` | `({ payload: { filter_conditions, query? \| message_filter_conditions?, sort?, limit?, offset?, next? } }?)` | + | `queryBannedUsers` | `(filter?, sort?, options?)` | `({ payload }?)` | + | `queryFutureChannelBans` | `(options?)` | `({ payload }?)` | + | `queryMessageFlags` | `(filter?, options?)` | `({ payload }?)` | + | `createUserGroup` | `(options)` | `(request)` | + | `getUserGroup` / `deleteUserGroup` | `(id, options?)` | `({ id }, { team_id? }?)` | + | `updateUserGroup` | `(id, options)` | `({ id }, request?)` | + | `addUserGroupMembers` / `removeUserGroupMembers` | `(id, options)` | `({ id }, request)` | + | `searchUserGroups` / `searchRoles` | `(options, requestOptions?)` | `(request, requestOptions?)` (same keys) | + | `updateUsers` | `(users: UserResponse[])` | `({ users: Record })` | + | `blockUser` | `(blockedUserID, user_id?)` | `(blockedUserId, requestOptions?)` (wrapper over `blockUsers`) | + | `getBlockedUsers` | `(user_id?)` | `(_request?, requestOptions?)` | + | `markChannelsRead` | `(data?: { read_by_channel?, user?, user_id? })`, resolved `void` | `({ read_by_channel? }?)`, resolves the response | + | `markChannelsDelivered` | `(data: MarkDeliveredOptions)` | `(request?: MarkDeliveredRequest, requestOptions?)` (still a no-op when `latest_delivered_messages` is empty; generated `markDelivered` is the raw call) | + | `translateMessage` | `(messageId, language)` | `({ id }, { language })`; `language` is a closed union of 57 codes (`TranslateMessageRequest['language']`), so a `string` variable fails (TS2322) | + | `getMessage` | `(messageID, { show_deleted_message? }?)` | `({ id }, _request?, requestOptions?)` (option gone) | + | `sync` | `(channel_cids, last_sync_at: string, { watch?, with_inaccessible_cids? }?)` | `({ channel_cids, last_sync_at: Date, watch?, with_inaccessible_cids? })` | + | `createBlockList` | `(blockList)` | `(request)` | + | `listBlockLists` | `({ team? }?)` | `({ team?, cursor?, limit? }?)` | + | `updateBlockList` / `deleteBlockList` | `(name, data)` | `({ name }, request?)` | + | `deleteChannels` | `(cids, { hard_delete? }?)` | `({ cids, hard_delete? })` | + | `queryDrafts` | `({ filter?, sort?, user_id?, ...pager }?)` | `({ filter?, sort?, limit?, next?, prev? }?)` | + | `getAppSettings` | `()` -> `AppSettingsAPIResponse` | `(_request?, requestOptions?)` -> `StreamResponse` (wraps generated `getApp`); `appSettingsPromise` typed the same | + | `setGuestUser` | `(user: UserResponse)` | `(user: UserRequest, requestOptions?)` (calls `createGuest({ user })`) | + | poll methods (`createPoll`, `updatePoll`, `getPoll`, `deletePoll`, `createPollOption`, `updatePollOption`, `getPollOption`, `deletePollOption`, `castPollVote`, `queryPolls`, `queryPollVotes`, `queryPollAnswers`) | positional | see [poll client methods](#poll-client-methods) | + | reminder methods (`createReminder`, `updateReminder`, `deleteReminder`, `queryReminders`) | object with `messageId` | see [reminder methods](#reminder-methods) | + + Still present on `StreamChat` (generated, new signatures above): the user-group methods, `translateMessage`, `deleteChannels`, reminder create/update/delete, block-list create/update/delete/list (`getBlockList` removed, `importBlockList` added), `getAppSettings`. + +- **Migrate:** per table, e.g. `client.deleteMessage({ id }, { hard: true })`, `client.getAppSettings(undefined, { signal })`; narrow or cast user-supplied language strings to `TranslateMessageRequest['language']` for `translateMessage`. +- **Source:** `src/gen/chat/ChatApi.ts`, `src/api-client.ts:60-87,120-144`, `src/client.ts:814-819,956-968,1463-1469,1800-1807,1846-1878,2253-2262,2304-2310` (v10); `src/client.ts:1041,1198,1922,1976-2140,2418,2995-3030,3268,3335,3703,4117-4230,4630,4830-5245,5367` (v9) + + + +### `client.queryChannels` is raw; hydration is `queryChannelsAndHydrate` + +**Kind:** signature · **Impact:** high · **PR:** #1760 + +- **v9:** `queryChannels(filter, sort?, options?, stateOptions?)` -> `Channel[]` (hydrated, `channels.queried` dispatched, offline DB upserted; `withResponse` overload); raw variants `queryChannelsRequest` (-> channel array) and `queryChannelsRequestWithResponse` (-> full response). Abort via `stateOptions.signal`. +- **v10:** `queryChannels(request?, requestOptions?)` is the raw generated call returning `StreamResponse` (defaults `state: true, watch: true, presence: false` still applied). The v9 behaviour is `queryChannelsAndHydrate(request?, stateOptions?, requestOptions?)` (overload `{ withResponse: true }` -> `QueryChannelsResponseWithChannels`). `queryChannelsRequest*` removed. Filter and sort move into the request (`filter_conditions`, `sort: [{ field, direction }]`). Same name, different return type: `const channels = await client.queryChannels(...)` now gets an object. Channel-state consequences: [hydration](#query-channels-hydration). +- **Migrate:** `await client.queryChannelsAndHydrate({ filter_conditions: { members: { $in: [id] } }, sort: [{ field: 'last_message_at', direction: -1 }], limit: 20 })`; the signal moves from `stateOptions` to the third argument. For channel lists prefer a `ChannelPaginator` ([ChannelManager](#channel-manager)). Watching needs a connection ([connection id wait](#watch-waits-for-connection-id)). +- **Source:** `src/client.ts:1516-1631` (v10); `src/client.ts:2167-2297` (v9) + + + +### Threads and reactions: raw calls plus `*AndHydrate` + +**Kind:** signature · **Impact:** medium + +- **v9:** `queryThreads(options?)` -> `{ threads: Thread[], next }` (defaults `limit 10, participant_limit 10, reply_limit 3, watch true`, poll cache hydrated); `getThread(messageId, options?)` -> `Thread` (defaults `participant_limit 100, reply_limit 3, watch true`); `queryReactions(messageID, filter, sort?, options?)` read the offline DB first and dispatched `offline_reactions.queried`. +- **v10:** `queryThreads(request?)`, `getThread({ message_id }, request?)`, `queryReactions({ id }, request?)` are raw generated calls with no defaults and no state. Hydrating versions: `queryThreadsAndHydrate(options?, requestOptions?)`, `getThreadAndHydrate(messageId, options?, requestOptions?)` (thread instances resolved through `client.threads`), `queryReactionsAndHydrate({ id }, request?)`. +- **Migrate:** switch calls that need `Thread` instances or offline reactions to the `*AndHydrate` variants; when calling the raw ones pass `watch: true` yourself if needed. +- **Source:** `src/client.ts:1643-1670,2059-2142` (v10); `src/client.ts:2307-2349,3725-3808` (v9) + + + +### `client.updateMessage` / `client.deleteMessage` are raw requests + +**Kind:** signature · **Impact:** medium · **PR:** #1882, #1836 + +- **v9:** `updateMessage(message, userId?, options?)` queued an offline `update-message` task and normalized the payload (mentioned users to ids, reserved fields stripped, `user_id`) via `_updateMessage`; `deleteMessage(id, boolean | { hardDelete, deleteForMe })` applied the offline DB delete and queued `delete-message` via `_deleteMessage`. +- **v10:** `updateMessage({ id }, { message, skip_enrich_url?, skip_push? }, requestOptions?)` sends `message` as given (attachment sanitization only); `deleteMessage({ id }, { hard?, delete_for_me? }, requestOptions?)` sends only the request (still marks `deleted_for_me` / `type: 'deleted'` on the response when `delete_for_me`). No offline queue, no local state. `_updateMessage` / `_deleteMessage` removed. +- **Migrate:** UI-facing edits and deletes go through `channel.messageOperations.update/delete` or the composer ([message operations](#channel-send-message)); raw calls use the new shapes and option names `hard` / `delete_for_me`. +- **Source:** `src/client.ts:2016-2042` (v10); `src/client.ts:3459-3684` (v9) + + + +### Client uploads: `uploadFile` / `uploadImage` / `deleteFile` / `deleteImage`; `sendFile` removed + +**Kind:** signature · **Impact:** medium · **PR:** #1832 + +- **v9:** `uploadFile(uri, name?, contentType?, user?, axiosRequestConfig?)`, `uploadImage(...)` (inputs `string | ReadableStream | Buffer | File`, `form-data` in Node); `sendFile(url, uri, ...)`; `deleteFile(url)`, `deleteImage(url)`; routes `/uploads/file|image`; response `SendFileAPIResponse`. +- **v10:** `uploadFile({ file?, user? }, requestOptions?)`, `uploadImage({ file?, user?, upload_sizes? }, requestOptions?)` with `file: FileUploadInput` (`File | Blob | { uri, name?, type? } | string`), encoded with the global `FormData` (no Node `Buffer` or streams). Multipart requests get `timeout: 0` and unbounded body size; `requestOptions.timeout` / `onUploadProgress` / `signal` apply. `deleteFile({ url })`, `deleteImage({ url })`. Routes `/api/v2/uploads/file|image`. `sendFile` removed. Responses `FileUploadResponse` / `ImageUploadResponse`. There is no `contentType` parameter. +- **Migrate:** `client.uploadImage({ file: { uri, name: 'a.heic', type: 'image/heic' } }, { onUploadProgress })`; set `type` on the file reference instead of `contentType`. Node: pass a `Blob` / `File`. Channel uploads: [channel uploads](#channel-uploads). +- **Source:** `src/client.ts:2271-2296`, `src/upload-utils.ts:1-53`, `src/api-client.ts:36-40,70-84` (v10); `src/client.ts:1434-1455,5294-5358` (v9) + + + +### `pinMessage` / `unpinMessage` arguments + +**Kind:** signature · **Impact:** low + +- **v9:** `pinMessage(msg, timeoutOrExpirationDate?, pinnedBy?, pinnedAt?)`, `unpinMessage(msg, userId?)`. +- **v10:** `pinMessage(msg, timeoutOrExpirationDate?, pinnedAt?, requestOptions?)`, `unpinMessage(msg, requestOptions?)`. `pinnedBy` (3rd) removed, so `pinnedAt` moves to 3rd. A numeric argument is a relative offset in seconds; a server `TimestampNS` is rejected at compile time; non-representable offsets now throw. +- **Migrate:** drop the 3rd `pinnedBy` argument (`pinMessage(m, null, 'user', date)` -> `pinMessage(m, null, date)`); drop `unpinMessage`'s user argument. +- **Source:** `src/client.ts:1908-2010` (v10); `src/client.ts:3405-3445` (v9) + + + +### `revokeTokens` revokes only the connected user's tokens + +**Kind:** behavior · **Impact:** medium + +- **v9:** `revokeTokens(before: Date | string | null)` -> `updateAppSettings({ revoke_tokens_issued_before })`: app-wide revocation (server-side). +- **v10:** `revokeTokens(before?: Date | null, requestOptions?)` -> `updateUsersPartial({ users: [{ id: client.userId, set: { revoke_tokens_issued_before } }] })`: only the current user; `before` defaults to now; string form dropped; returns `StreamResponse`. Without a connected user the request carries no `id`. +- **Migrate:** app-wide revocation moves to `@stream-io/node-sdk`; for the own user pass a `Date`. +- **Source:** `src/client.ts:787-803` (v10); `src/client.ts:1002-1009` (v9) + + + +### Raw HTTP helpers moved to `client.api`; `createAbortControllerForNextRequest` removed + +**Kind:** renamed · **Impact:** low · **PR:** #1828 + +- **v9:** `client.get/put/post/patch/delete(url, ...)`, `client.doAxiosRequest(type, url, data?, options?)`, `post` / `get` taking an axios `config`; `client.createAbortControllerForNextRequest()`. +- **v10:** on `client.api` (`ApiClient`, not root-exported): `get(url, params?)`, `put(url, data?)`, `post(url, data?)`, `patch`, `delete(url, params?)`, `doAxiosRequest(type, url, data?, options?)`, plus `sendRequest(method, path, pathParams?, queryParams?, body?, contentType?, requestOptions?)`. No per-call config on the verbs. `createAbortControllerForNextRequest` removed: every method takes `requestOptions.signal`. There is no `errorFromResponse` anywhere in v10 ([internal helpers](#internal-surface)). +- **Migrate:** `client.api.post(client.baseURL + '/api/v2/...', body)` or the typed method; `const ac = new AbortController(); client.queryUsers({ payload }, { signal: ac.signal })`. +- **Source:** `src/client.ts:278-280`, `src/api-client.ts:60-116,343-355` (v10); `src/client.ts:1339-1432,4808-4810` (v9) + + + +### REST routes and default query params + +**Kind:** behavior · **Impact:** medium · **PR:** #1760 + +- **v9:** endpoints at `baseURL + '/channels'`, `'/users'`, `'/messages/{id}'`, `'/uploads/file'`, ...; every request carried `user_id`, `api_key` and `connection_id`. +- **v10:** generated endpoints are `baseURL + '/api/v2/...'` (e.g. `/api/v2/chat/channels`, `/api/v2/users`, `/api/v2/chat/messages/{id}`, `/api/v2/uploads/file`). `user_id` and `api_key` are still on every request; `connection_id` only on requests that watch or subscribe to presence and on `stopWatchingChannel`. Hand-written `unbanUser` still calls v1 `DELETE /moderation/ban`. +- **Migrate:** update reverse proxies, CSP allowlists, request mocks (msw / nock) and server-side request filters keyed on paths or on `connection_id`. +- **Source:** `src/gen/chat/ChatApi.ts`, `src/api-client.ts:146-171,206-217`, `src/client.ts:1840` (v10); `src/client.ts:3929-3936` (v9) + + + +### `client.channel()` + +**Kind:** signature · **Impact:** medium · **PR:** #1901 + +- **v9:** `channel(type, id?, custom?: ChannelData)` / `channel(type, custom)`, deduped via `activeChannels` (`getChannelById` / `getChannelByMembers`, both `@private` arrow properties). +- **v10:** `channel(type, id?, custom?: ChannelInput)` / `channel(type, custom?)` is a shorthand for `client.channelManager.ensure({ type, id, data })`. `getChannelById` / `getChannelByMembers` removed. `ChannelInput` puts custom fields under `custom` and members as `{ user_id }` objects. Identity, provisional channels and `supersededBy`: [channel store](#active-channels). +- **Migrate:** `client.channel('messaging', id, { custom: { name } })`; replace `getChannelById(type, id, data)` with `client.channel(type, id, data)`; spies on channel creation target `channelManager.ensure`. +- **Source:** `src/client.ts:1773-1789` (v10); `src/client.ts:2610-2770` (v9) + + + +### User, listener and mute fields + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `user?` / `_user?: UserResponse | OwnUserResponse`; `connectUser(user: UserResponse | OwnUserResponse, token)`; `listeners: Record`; `mutedUsers: Mute[]` plain field. +- **v10:** `user?` / `_user?: ClientUser` (`PartializeAllBut & { anon? }`, `_user` tagged `@private`); `connectUser(user: ClientUser, token)` (and deprecated `setUser`); `listeners: Map>` (a duplicate handler registers once; `client.listeners['message.new']` is `undefined`); `mutedUsers: UserMuteResponse[]` is a getter/setter over the new `mutedUsersStore: StateStore<{ mutedUsers }>` (assignment notifies, in-place mutation does not); `on(type, handler)` narrows the handler's event type ([EventHandler](#event-handler-generic)). +- **Migrate:** user custom fields under `custom`, timestamps as `TimestampNS`; `client.listeners.get(type)?.size`; subscribe to `client.mutedUsersStore` instead of listening for `notification.mutes_updated`. +- **Source:** `src/client.ts:122,149,213,221,237,327,449-455,561,987-1019` (v10); `src/client.ts:343,366,381,391,677-680,1256-1290` (v9) + + + +### `client.preventThreadCleanup` has no effect + +**Kind:** behavior · **Impact:** low + +- **v9:** `client.preventThreadCleanup = true` stopped `ChannelState` from cleaning up threads while a channel initialized (set by stream-chat-react-native). +- **v10:** the field is still declared but read nowhere; thread state lives in `client.threads` ([threads](#threads)). +- **Migrate:** remove the assignment. +- **Source:** `src/client.ts:214-218` (v10); `src/client.ts:374-378`, `src/channel_state.ts:253` (v9) + + + +### `disconnectUser` tears down more state + +**Kind:** behavior · **Impact:** low + +- **v9:** cleared the user, `activeChannels`, `ClientState`, threads, uploads, the composer cache; token manager reset after close. +- **v10:** additionally resets every channel list (paginators stay registered), clears the channel store, clears `mutedChannels`, runs the `'client'` setup function's teardown (re-armed on next connect), and rejects requests waiting for a connection id with "Connection was closed because disconnectUser() was called"; the token manager resets only if no new user connected meanwhile. +- **Migrate:** re-query lists after reconnecting as another user (they are empty, not stale). Clearing the channel store disposes every `Channel` instance, including ones the app still holds in its own state (an "active channel", a cached list): any later call on them throws "Channel is pending disposal and cannot be used" ([pendingDisposal](#channel-pending-disposal)). Drop held channel references on logout or user switch, or re-acquire them after the next `connectUser()` with `client.channelManager.ensure({ type, id })`. v9 also threw from `getClient()` on channels disconnected this way; a dev-mode hot reload that re-runs `connectUser` after the old cleanup's `disconnectUser()` hits it too. +- **Source:** `src/client.ts:828-889` (v10); `src/client.ts:1110-1148` (v9) + + + +### `ClientState` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `users: Record`, `updateUser(user?: UserResponse)`; writes skipped when `_cacheEnabled()` was false. +- **v10:** `users: Record`, `updateUser(user?: UserResponse | OwnUserResponse)`; always written. `UserResponse` is the generated type ([user types](#user-response-types)). +- **Migrate:** narrow `client.state.users[id]` if you relied on the plain `UserResponse` type. +- **Source:** `src/client_state.ts:9-11,28-32` (v10 and v9) + + + +### `QueryChannelsResponseWithChannels` and `UploadManager.upload` types + +**Kind:** type-shape · **Impact:** low + +- **v9:** `QueryChannelsResponseWithChannels = Omit & { channels: Channel[] }` (had `blocklist?`, `predefined_filter?: ParsedPredefinedFilterResponse>`); `UploadManager.upload(...)` (`@internal`) resolved `MinimumUploadRequestResult | { blocklist?, file, thumb_url? }`. +- **v10:** based on generated `QueryChannelsResponse` (no `blocklist`, non-generic `predefined_filter` with `sort?: SortParamRequest[]`); `UploadManager.upload` resolves `MinimumUploadRequestResult` (follows `AttachmentManager.doUploadRequest`, see [default upload request](#composer-default-upload)). +- **Migrate:** read `predefined_filter.sort` as `[{ field?, direction }]`; drop `blocklist` reads. +- **Source:** `src/client.ts:109-114`, `src/uploadManager.ts:190-262` (v10); `src/client.ts:309-314` (v9) + + + +### Moderation methods moved from `StreamChat` to `client.moderation` + +**Kind:** removed · **Impact:** medium · **PR:** #1833, #1836 + +- **v9:** v1 moderation methods on `StreamChat`. +- **v10:** removed from `StreamChat`; `client.moderation` (`class Moderation extends ModerationApi`, see [Moderation](#moderation)) carries the V2 replacements. + + | v9 | v10 | + | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `client.banUser(targetUserID, options?)` | `client.moderation.ban({ target_user_id, ...options })` (V2 `BanRequest`: `timeout`, `reason`, `shadow`, `ip_ban`, `delete_messages`, `delete_reactions`, `ban_from_future_channels`, `channel_cid`; no `banned_by_id` / `user_id`) | + | `client.shadowBan(id, options?)` | `client.moderation.ban({ target_user_id: id, shadow: true, ...options })` | + | `client.removeShadowBan(id, options?)` | `client.unbanUser(id, { shadow: true, ...options })` (still v1 `DELETE /moderation/ban`) | + | `client.muteUser(targetID, userID?, options?)` | `client.moderation.mute({ target_ids: [id], timeout? })`; response `MuteResponse` (`mutes`, no single `mute`) | + | `client.unmuteUser(targetID, currentUserID?)` | `client.moderation.unmuteUser(id)` or `client.moderation.unmute({ target_ids: [id] })` | + | `client.flagMessage(targetMessageID, { reason, custom, user_id }?)` | `client.moderation.flagMessage(messageId, reason?, { custom, ... }?)`; V2 `/api/v2/moderation/flag`, response `FlagItemResponse` | + | `client.flagUser(targetID, { reason, user_id }?)` | `client.moderation.flagUser(userId, reason?, options?)` | + | `client.unflagMessage`, `client.unflagUser`, `client.unblockMessage`, `client._unblockMessage` | removed, no replacement in this package (review via `client.moderation.submitAction` or the dashboard) | + +- **Migrate:** per table; `user_id` overrides are gone (the connected user acts). +- **Source:** `src/moderation.ts:10-93`, `src/gen/moderation/ModerationApi.ts`, `src/client.ts:1839-1844`, `src/types.ts:97,139-147` (v10); `src/client.ts:2950-3168,3240-3252` (v9) + + + +### Server-only / admin `StreamChat` methods removed + +**Kind:** removed · **Impact:** low (client apps), high for server code on `stream-chat` · **PR:** #1819, #1836 + +- **v9:** the methods below on `StreamChat`. +- **v10:** removed from this package; use `@stream-io/node-sdk`. + + | area | removed v9 methods | + | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | app / push | `updateAppSettings`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `getRateLimits`, `getHookEvents` | + | tokens | `revokeUserToken`, `revokeUsersToken` (see [revokeTokens](#revoke-tokens)) | + | user admin | `deleteUser`, `deleteUsers`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getUnreadCountBatch`, `sendUserCustomEvent` | + | flags admin | `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `updateFlags` | + | commands | `createCommand`, `getCommand`, `updateCommand`, `deleteCommand`, `listCommands` | + | channel types | `createChannelType`, `getChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes` | + | roles / permissions | `createRole`, `listRoles`, `deleteRole`, `getPermission`, `createPermission`, `updatePermission`, `deletePermission`, `listPermissions` (only `searchRoles` remains) | + | block lists | `getBlockList` (list/create/update/delete remain; `importBlockList` added) | + | exports / imports / tasks | `exportChannel`, `exportChannels`, `exportUsers`, `getExportChannelStatus`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `getTask` | + | messages | `undeleteMessage`, `commitMessage`, `queryMessageHistory`, `translate` (free-text; `translateMessage` remains) | + | campaigns | `campaign()`, `createCampaign`, `getCampaign`, `startCampaign`, `queryCampaigns`, `updateCampaign`, `deleteCampaign`, `stopCampaign` (classes: [Campaign and Segment](#campaign-segment-removed)) | + | segments | `segment()`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists` | + | batch / filters / retention / usage | `channelBatchUpdater()`, `updateChannelsBatch`, `createPredefinedFilter`, `getPredefinedFilter`, `updatePredefinedFilter`, `deletePredefinedFilter`, `listPredefinedFilters`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, `queryTeamUsageStats` | + +- **Migrate:** `@stream-io/node-sdk` (exact method names there are not verified here). Querying with an existing `predefined_filter` still works through `queryChannels` / `queryChannelsAndHydrate`. +- **Source:** `src/gen/chat/ChatApi.ts`, `src/client.ts` (v10, absent); `src/client.ts:971,1011,1061,2530,2572,2846,3169,3272,4045,4157,4204,4244,4272-4297,4316,4485,4616,4648,4711,4759,4818,5127,5150,5262,5400,5487` (v9) + + + +### Webhook, SQS/SNS and JWT helpers removed + +**Kind:** removed · **Impact:** low (client), high for webhook handlers on `stream-chat` · **PR:** #1819 + +- **v9:** root exports `verifySignature`, `CheckSignature` (deprecated), `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages`, `JWTUserToken`, `JWTServerToken`; client methods `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`. +- **v10:** removed. `signing.ts` exports only `UserFromToken` and `DevToken` (plus `client.devToken`). +- **Migrate:** `@stream-io/node-sdk` webhook helpers and token generation. +- **Source:** `src/signing.ts:1-35` (v10); `src/signing.ts:18-290`, `src/client.ts:3990-4030` (v9) + + + +## 4. Connection, events transport, errors, logging, tokens + +The removal of the `connection.changed` / `transport.changed` events is owned by [connection.changed removed](#connection-changed-removed); this section covers the replacements. + + + +### `client.wsConnection` is never null; `client.wsFallback` removed + +**Kind:** type-shape · **Impact:** medium · **PR:** #1859 + +- **v9:** `client.wsConnection: StableWSConnection | null` (null until connect, field `isHealthy`); `client.wsFallback?: WSConnectionFallback`; health changes surfaced as `connection.changed` events. +- **v10:** `client.wsConnection: WSConnection`, built in the constructor and never null; getters `isHealthy`, `isConnecting`; reactive `state: StateStore<{ isHealthy, lastHealthyAt, lastUnhealthyAt }>`; `config` / `updateConfig` (timing, `enableWSFallback`, `webSocketImpl`, `urlParams`, `connection`); the socket is at `wsConnection.connection` and the long-poll at `wsConnection.fallback` (both `@internal`). `client.wsFallback` removed. Device network status is the separate `client.networkConnection.state` (`isOnline`). `client.wsConnection` is no longer the socket: the v9 `StableWSConnection` members (`ws`, `wsID`, `connectionOpen`, `isDisconnected`, `isResolved`, `consecutiveFailures`, `totalFailures`, `lastEvent`, `requestID`, `pingInterval`, `connectionCheckTimeout`, `_reconnect()`, `_destroyCurrentWSConnection()`, `_setHealth()`, `_waitForHealthy()`, `scheduleNextPing()`, `scheduleConnectionCheck()`, `onopen` / `onmessage` / `onclose` / `onerror`, ...) read as `undefined` (or throw a `TypeError` when called) on it; they are on `client.wsConnection.connection` (`@internal`, `null` before the first connect, a new instance per connect). +- **Migrate:** `if (client.wsConnection)` is always true; use `client.wsConnection.isHealthy` or subscribe: `client.wsConnection.state.subscribeWithSelector(({ isHealthy }) => ({ isHealthy }), ...)`. A UI offline banner should wait `client.wsConnection.config.offlineNotificationDisplayDelayMs` (5000) itself; v9 delayed the event. +- **Source:** `src/client.ts:240,368-369`, `src/connection/wsConnection/WSConnection.ts:45-70,160-200,238-275,315-323`, `src/connection/wsConnection/types.ts:3-17` (v10); `src/client.ts:393-394,466` (v9) + + + +### `StableWSConnection` class + +**Kind:** signature · **Impact:** low · **PR:** #1859, #1833 + +- **v9:** `new StableWSConnection({ client })`; `connectionID`, `_log(msg, extra?, level?)`, `setClient(client)`; writable `pingInterval` (25000) and `connectionCheckTimeout` (35000); `onopen(wsID)`; window online/offline listeners registered by the socket; file `src/connection.ts`. +- **v10:** `new StableWSConnection({ wsConnection })` (constructor changed), `setWSConnection(wsConnection)`, `client` is a getter; `connectionID`, `_log`, `setClient` removed; `pingInterval` / `connectionCheckTimeout` are read-only getters over `config.pingIntervalMs` (default and max 25000, min 1000) and `pingIntervalMs + healthCheckGracePeriodMs` (default 10000, min 1000); `onopen(wsId, authMessage)`; `onlineStatusChanged` deprecated (network status arrives through `_applyNetworkStatus` from `client.networkConnection`). The `wsID` field name is unchanged. File `src/connection/wsConnection/StableWSConnection.ts`. +- **Migrate:** configure timing through `config.client.wsConnection`; logging via `chatLoggerSystem.getLogger('connection')`; a prebuilt socket goes in `config.client.wsConnection.connection`. +- **Source:** `src/connection/wsConnection/StableWSConnection.ts:59-135`, `src/connection/wsConnection/config.ts:10-19,58-80` (v10); `src/connection.ts:47-110` (v9) + + + +### WS handshake: `/api/v2/connect`, auth in the first frame + +**Kind:** behavior · **Impact:** medium · **PR:** #1833 + +- **v9:** socket URL `wsBaseURL/connect?json=<{ user_id, user_details, device, client_request_id }>&api_key&authorization=&stream-auth-type`; first event `health.check` carrying `me`; payload built by `client._buildWSPayload(client_request_id?)`. +- **v10:** URL `wsBaseURL/api/v2/connect?api_key&stream-auth-type&X-Stream-Client` (plus `urlParams`); the first frame after open is the auth message `{ token, user_details, products: ['chat'] }` (anonymous uses a placeholder token); no `device`, `client_request_id` or `user_id`; hello event `connection.ok` (with `connection_id`, `me`); periodic `health.check` unchanged. `_buildWSPayload` replaced by `_buildWSAuthMessage()` / `_buildWSAuthPayload(token?)`. +- **Migrate:** proxies and firewalls must allow `/api/v2/connect` and must not require the token in the URL; seed own-user data from `connection.ok` or the `connectUser` result ([ConnectionOpen](#connection-open)). +- **Source:** `src/connection/wsConnection/StableWSConnection.ts:238-247,353-370,543-560`, `src/client.ts:2223-2243` (v10); `src/connection.ts:205-220`, `src/client.ts:3976-3982` (v9) + + + +### Watching requests wait for a connection id + +**Kind:** behavior · **Impact:** high · **PR:** #1859, #1889 + +- **v9:** `queryChannels`, `queryUsers`, `search`, `queryReactions` awaited `client.wsPromise`; without a connection id `queryChannels` defaulted `watch` to `false` (an explicit `watch: true` was still sent) and `queryUsers` already defaulted `presence: false`; `queryThreads` / `getThread` / `sync` had no gate. +- **v10:** requests no longer await `wsPromise`. Any request whose params, `payload` or body carries `watch` or `presence` waits for `client.connectionIdManager` to have an id (across reconnects and `closeConnection()` -> `openConnection()`), honouring `requestOptions.signal`. It throws at once "No connection id is available: ..." when no socket is open or opening, and rejects with "Connection was closed because disconnectUser() was called" on `disconnectUser()`. Applies to `queryChannels` / `queryChannelsAndHydrate` (default `watch: true`), `groupedQueryChannels`, `queryUsers` with `presence`, `queryThreads` / `getThread` / `sync` with `watch`, and channel `query` / `watch` ([channel watch](#channel-watch)). `stopWatchingChannel` always waits. The wait is not bounded by the axios timeout. +- **Migrate:** call `connectUser()` first (awaiting it is not required); pass `watch: false` / `presence: false` for read-only loads from a client without a socket (server-side or before connecting); pass a `signal` to bound the wait. +- **Source:** `src/api-client.ts:206-217,310-327`, `src/connection/ConnectionIdManager.ts:113-135`, `src/client.ts:835-845,1484-1499,1516-1549` (v10); `src/client.ts:1930-1937,2163-2169,2342,2443` (v9) + + + +### Connection id lifecycle + +**Kind:** behavior · **Impact:** low + +- **v9:** `StableWSConnection.connectionID` (and `wsFallback.connectionID`) was set on connect and never cleared, so `client._getConnectionID()` could return a stale id after a drop or `closeConnection()`. +- **v10:** the id lives in `client.connectionIdManager.connectionId`; cleared when the socket turns unhealthy, on `closeConnection()` and `disconnectUser()`. `_getConnectionID()` / `_hasConnectionID()` still exist and read it. `StableWSConnection.connectionID` removed. +- **Migrate:** read `client.connectionIdManager.connectionId` (or `_getConnectionID()`); expect `undefined` between a drop and the next handshake. +- **Source:** `src/connection/ConnectionIdManager.ts:30-100`, `src/connection/wsConnection/StableWSConnection.ts:681-694`, `src/client.ts:541-543` (v10); `src/connection.ts:51`, `src/client.ts:658-660` (v9) + + + +### `recoverState` -> `ConnectionRecoveryManager` + +**Kind:** behavior · **Impact:** medium · **PR:** #1841 + +- **v9:** on reconnect `client.recoverState()` re-queried all `activeChannels` cids in one `queryChannels({ cid: { $in } }, { last_message_at: -1 }, { limit: 30 })`, then dispatched `connection.recovered`; gated by `recoverStateOnReconnect`, which skipped only the query: `connection.recovered` was dispatched after every reconnect either way; public method. +- **v10:** `client.connectionRecovery` re-runs each registered channel list's first page (`channelManager.recover()`) and, independently, `channel.reload()` only for activated channels and `thread.reload()` for displayed threads; no bulk `$in` query; channels nobody activated are not refreshed; `connection.recovered` is withheld if the socket drops mid-recovery; runs on every reconnect path including `closeConnection()` -> `openConnection()`. Toggle `config.client.connectionRecovery.enabled`; manual trigger `client.connectionRecovery.recover()`. `recoverState` removed. Channel side: [activate for recovery](#channel-activate-recovery). With `connectionRecovery.enabled: false` nothing is recovered and `connection.recovered` is never dispatched (v9 dispatched it after every reconnect even with `recoverStateOnReconnect: false`); `client.connectionRecovery.recover()` is then a no-op, and threads are not reloaded either (`ThreadManager` reloads on `connection.recovered`). With an initialized offline DB, active channels are reloaded and `connection.recovered` is dispatched only after the offline sync finishes (pending-task replay), not as soon as the socket is healthy again. +- **Migrate:** remove UI-side reconnect re-queries that existed because `recoverStateOnReconnect` was off; call `channel.activate()` for channels on screen. If you disabled recovery to run your own, listen to `client.wsConnection.state` (an `isHealthy` false -> true edge with `lastUnhealthyAt` set) instead of `connection.recovered`. +- **Source:** `src/connection/ConnectionRecoveryManager.ts:160-215,221-371` (sync-edge path `:196-215`, gate `:273-279`, dispatch `:353`, `recover()` `:362-370`), `src/thread_manager.ts:468`, `src/client.ts:173,383-384` (v10); `src/client.ts:1782-1826` (dispatch in both branches `:1811,1815`) (v9) + + + +### Long-poll fallback + +**Kind:** behavior · **Impact:** low · **PR:** #1889 + +- **v9:** with `enableWSFallback`, the WS connect used a 6 s timeout; on WS failure it dispatched `transport.changed` and stored the fallback on `client.wsFallback`; skipped when `navigator.onLine` was false; the fallback registered its own window listeners; long-poll at `/longpoll` authenticated via query params. +- **v10:** the WS uses `connectTimeoutMs` (15 s); local event `connection.fallback_activated` (`mode: 'longpoll'`); fallback at `client.wsConnection.fallback`; skipped when a real network reporter says offline (ignores the WS-mirroring default reporter); network changes reach it through `client.networkConnection`; `/api/v2/longpoll` with an `Authorization` header; the client never returns to WebSocket. +- **Migrate:** enable via `config.client.wsConnection.enableWSFallback`; lower `connectTimeoutMs` to keep the faster switch. +- **Source:** `src/connection/wsConnection/WSConnection.ts:237-310`, `src/connection/wsConnection/WSConnectionFallback.ts`, `src/api-client.ts:23` (v10); `src/client.ts:1840-1895`, `src/connection_fallback.ts:33-60` (v9) + + + +### `persistUserOnConnectionFailure` keeps retrying after WS failures + +**Kind:** behavior · **Impact:** low + +- **v9:** with the flag, any initial `connectUser` failure called `closeConnection()`, stopping reconnection. +- **v10:** `closeConnection()` only for non-WS failures (bad token, token provider throw); after a WS / network failure the socket keeps retrying and reconnects when the network returns. Without the flag `disconnectUser()` runs as before. +- **Migrate:** if you relied on the socket stopping, call `closeConnection()` yourself. +- **Source:** `src/client.ts:603-623` (v10); `src/client.ts:727-735` (v9) + + + +### WebSocket implementation and network status source + +**Kind:** behavior · **Impact:** medium · **PR:** #1859, #1833 + +- **v9:** `isomorphic-ws` (the `ws` package in Node); online/offline from window listeners registered by the socket. +- **v10:** global `WebSocket` or `config.client.wsConnection.webSocketImpl`; device network status from `client.networkConnection` (browser reporter installed automatically; elsewhere a stand-in that mirrors the socket until `config.client.networkConnection.statusReporter` or `client.networkConnection.setStatusReporter(...)` supplies one, e.g. NetInfo on RN). Dependency removal: [WebSocket on Node](#websocket-on-node). +- **Migrate:** Node without a global `WebSocket` (below 22) must pass `webSocketImpl`; RN apps should install a NetInfo-based `statusReporter` to get fast reconnects when the network returns. +- **Source:** `src/connection/wsConnection/StableWSConnection.ts:363`, `src/connection/networkConnection/reporters.ts:1-85`, `src/connection/networkConnection/NetworkConnectionObserver.ts:92-281` (v10); `src/connection.ts:1,99,324` (v9) + + + +### `ErrorFromResponse` -> `StreamAPIError`; `APIErrorResponse` -> `APIError` + +**Kind:** behavior · **Impact:** high · **PR:** #1909, #1760 + +- **v9:** any non-2xx response became `ErrorFromResponse` (exported runtime class: `code: number | null`, `status: number`, `response: AxiosResponse`, `name = 'ErrorFromResponse'`) with message `StreamChat error code : ` (or `StreamChat error HTTP code: `). Body type `APIErrorResponse { duration; message; more_info; StatusCode; code?; details?: ErrorResponseDetails }`. +- **v10:** only a response whose body has a numeric Stream `code` becomes `StreamAPIError(message, { code, status, response })` (`code?: number`, `status?: number`, `response?: AxiosResponse`); the message is the raw server message (no prefix); `name` is a getter such as `StreamAPIError(status: 400, code: 4)`. Anything else (network error, 502 HTML page, cancellation) is rethrown as the raw axios error. `ErrorFromResponse`, `client.errorFromResponse`, `client.handleResponse` removed. Errors no longer carry `client_request_id` (v9 attached it to every rejected request, Stream and network errors alike); read `err.response?.config.headers['x-client-request-id']` (or `err.config?.headers['x-client-request-id']` for a raw axios error), or `metadata.client_request_id` on a successful response. Body type is the generated `APIError { code; duration; message; more_info; status_code; details: number[]; cooldown_remaining_ms?; unrecoverable?; exception_fields? }`. `LocalMessage.error?: StreamAPIError`. +- **Migrate:** `err instanceof StreamAPIError`; match on `err.code` / `err.status`, not on the message text; read `err.response?.data.status_code` instead of `StatusCode`; handle non-`StreamAPIError` failures separately. +- **Source:** `src/api-client.ts:220-224,284-294,363-369`, `src/types.ts:269-333`, `src/gen/models/index.ts:281-315` (v10); `src/client.ts:1382-1383,1456-1474`, `src/types.ts:3595-3620` (v9) + + + +### Automatic retries on 429 and `TOKEN_EXPIRED` + +**Kind:** behavior · **Impact:** medium · **PR:** #1828 + +- **v9:** only `TOKEN_EXPIRED` with a token provider was retried (reload token, back off after the first failure); 429 was thrown at once; static tokens were not retried. +- **v10:** every request runs through `runWithRetry` with up to 10 retries (11 requests) and `retryInterval` backoff (delays of 0.25 to 20.5 s, about 100 s in total) on HTTP 429 and on `TOKEN_EXPIRED` (static tokens included). When retries run out the rejection is `RetryError('Reached maximum amount of retry attempts')` (`name: 'RetryError'`, not root-exported), not `StreamAPIError`. The static-token retry and the error class are tracked in v10-followups.md. +- **Migrate:** expect long waits on persistent 429s; set `requestOptions.signal` / `timeout` to bound them; handle `RetryError` (no `code` / `status`; match on `error.name`). +- **Source:** `src/api-client.ts:226-278`, `src/utils/retryable.ts:64-135`, `src/utils.ts:128-133` (v10); `src/client.ts:1384-1404` (v9) + + + +### Logging: `options.logger` / `client.logger` -> `chatLoggerSystem` + +**Kind:** behavior · **Impact:** medium · **PR:** #1760, #1831 + +- **v9:** `options.logger` (`Logger = (level, message, extraData?) => void`, `LogLevel = 'info' | 'error' | 'warn'`), `client.logger`, default `noopLogger` (silent); some warnings via `console.warn`. +- **v10:** process-wide `chatLoggerSystem` (`@stream-io/logger`): `configureLoggers({ default | : { level, sink } })`, `getLogger(scope)`; `Sink = (level, message, ...data) => void`; `LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'` (plus `LogLevelEnum`); `ChatLoggerScope` has 15 scopes: `api-client`, `channel`, `channel-manager`, `client`, `connection`, `instance-configuration`, `message-composer`, `offline-db`, `state-store`, `text-composer`, `thread`, `thread-manager`, `token-manager`, `upload-manager`, `utils`. `client.logger` and `Logger` removed. The default is a console sink at `info`, so apps that never set a logger now see console output (`info` / `warn` / `error`; RN maps warn/error to `console.info`); v9 was silent. Listener exceptions are logged on the scoped logger. +- **Migrate:** `chatLoggerSystem.configureLoggers({ default: { level: 'warn', sink: mySink } })` before creating the client; to keep v9 silence configure a no-op sink (`sink: () => {}`); `level: 'error'` keeps errors only. Handle `trace` / `debug` in exhaustive switches over `LogLevel`. Composer and utils log messages: [logChatPromiseExecution](#log-chat-promise-execution). +- **Source:** `src/logger.ts:1-28`, `node_modules/@stream-io/logger/dist/esm/index.js:42-45`, `src/utils.ts:37-40` (v10); `src/client.ts:584`, `src/types.ts:3019-3025` (v9) + + + +### `TokenManager` + +**Kind:** signature · **Impact:** low + +- **v9:** `new TokenManager(secret?)`, `secret`, `user?: UserResponse`, `setTokenOrProvider(token, user: UserResponse)`, `validateToken(token, user: UserResponse)` threw "User token can not be empty" for an empty non-anonymous token; `getToken()` returned `undefined` for anonymous. +- **v10:** `new TokenManager()` (constructor has no `secret`); `user?: TokenManagerMinimalUser` (`{ id; anon? }`); `setTokenOrProvider` / `validateToken` take `TokenManagerMinimalUser`; new `isAnonymous` getter; `getToken(): string` returns `''` for anonymous; an `undefined` token is no longer rejected up front (the connect fails later with "User token is not set..."). `client._setToken` / `_setUser` take `TokenManagerMinimalUser`. +- **Migrate:** subclasses and mocks implement `isAnonymous`; do not rely on the early empty-token error. +- **Source:** `src/token_manager.ts:8-150`, `src/client.ts:637-651` (v10); `src/token_manager.ts:15-147` (v9) + + + +### `UserFromToken` / `DevToken` encoding + +**Kind:** behavior · **Impact:** low · **PR:** #1819 + +- **v9:** base64 via `base64-js` / custom decoder, Latin-1 char codes; `connectUser({ id: 'müller' }, client.devToken('müller'))` passed the user-id check. +- **v10:** `DevToken` encodes UTF-8 bytes with `btoa`; `UserFromToken` decodes with global `atob` without UTF-8 decoding. Any non-ASCII id now fails `connectUser` with a dev token ("userToken does not have a user_id or is not matching with user.id") (tracked in v10-followups.md). Needs global `atob` / `btoa` (old Hermes: polyfill). +- **Migrate:** ASCII ids for dev tokens; polyfill `atob` on runtimes without it. +- **Source:** `src/signing.ts:7-35`, `src/token_manager.ts:92-99` (v10); `src/signing.ts:63-86`, `src/base64.ts:47-80` (v9) + + + +### Insights telemetry removed + +**Kind:** removed · **Impact:** low · **PR:** #1870 + +- **v9:** root exports `InsightMetrics`, `InsightTypes`, `postInsights`, `buildWsFatalInsight`, `buildWsSuccessAfterFailureInsight`; `client.insightMetrics`; the `enableInsights` option posted WS failure telemetry to chat-insights. +- **v10:** all removed; nothing is posted. +- **Migrate:** delete references; no replacement. +- **Source:** `src/insights.ts:6-80`, `src/client.ts:401,529` (v9) + + + +## 5. Cross-cutting data model + +Root causes referenced from every other section: custom data under `custom`, `TimestampNS` dates, the sort shape, generated types and the `Event` union. The complete v9 -> v10 type-name mapping is the [rename table](#type-rename-table). + + + +### Generated types replace hand-written types + +**Kind:** type-shape · **Impact:** high · **PR:** #1760, #1836, #1896 + +- **v9:** about 5.6k lines of hand-written `type` aliases in `src/types.ts`; responses were `APIResponse & {...}` where `APIResponse = { duration: string; blocklist?: BlockListResponse }`; most fields optional. +- **v10:** `src/types.ts` keeps about 40 hand-written helpers and ends with `export * from './gen/models'`; models are generated `interface`s from the OpenAPI spec. Consequences: + - required/optional flips in both directions: e.g. `MessageResponse.text/html/attachments/pinned/user/cid` are now required, while `ChannelStateResponseFields.channel`, `*UserGroupResponse.user_group` (`CreateUserGroupResponse`, `GetUserGroupResponse`, `UpdateUserGroupResponse`, `AddUserGroupMembersResponse`, `RemoveUserGroupMembersResponse`), `PollVoteResponse.vote`, `FileUploadResponse.file`, `DeleteChannelResponse.channel`, `MuteChannelResponse.channel_mute` are now optional. + - `blocklist` is gone from every response (`APIResponse` is now `{ duration: string }`, kept as a legacy alias). + - request methods resolve `StreamResponse = T & { metadata: RequestMetadata }`. + - nullable unions become optional (`T | null` -> `T | undefined`), e.g. `membership`, `created_by`, `pinned_by`, `reaction_counts`. + - literal unions follow the spec (`MessageResponse.type: string`, `ChannelMemberResponse.status: string`, `Action.name/text/type` required). + - `type` aliases became `interface`s (declaration merging now possible). + - `SendMessageAPIResponse` / `UpdateMessageAPIResponse` are now `StreamResponse` / `StreamResponse` (no `pending_message_metadata`). + - Other response types whose members changed this way: `ChatPreferences`, `CreateDraftResponse`, `DeleteChannelsResponse`, `DraftResponse`, `Field`, `GetDraftResponse`, `ParsedPredefinedFilterResponse`, `PushPreferencesResponse`, `QueryDraftsResponse`, `QueryRemindersResponse`, `SearchUserGroupsResponse`, `SearchWarning`, `SharedLiveLocationResponse`, `UpsertPushPreferencesResponse`, `UserGroupResponse`. Members touched include `call_level`, `chat_level`, `feeds_level`, `feeds_preferences`, `default_preference`, `channel_context`, `channel_mentions`, `direct_mentions`, `group_mentions`, `here_mentions`, `role_mentions`, `thread_replies`, `distinct_channel_messages`, `channel_search_cids`, `channel_search_count`, `mentioned_members`, `parent_message`, `quoted_message`, `scheduled_message`, `schedule`, `short`, `title`, `value`, `metadata`, `filter`, `sort`. +- **Migrate:** guard now-optional response fields (`res.user_group?.id`); remove reads of `res.blocklist`; rename imports with the [rename table](#type-rename-table). +- **Source:** `src/types.ts:66-68,434-436,450-452,489`, `src/gen/models/index.ts` (v10); `src/types.ts:73-76` (v9) + + + +### Custom data moves under `custom` + +**Kind:** type-shape · **Impact:** high · **PR:** #1836 + +- **v9:** custom fields were spread at the top level: `MessageBase = CustomMessageData & {...}`, `UserResponse = CustomUserData & {...}`, `ChannelResponse = CustomChannelData & {...}`, `ChannelMemberResponse = CustomMemberData & {...}`, `Attachment = CustomAttachmentData & {...}`, `Reaction = CustomReactionData & {...}`, `PollResponse = CustomPollData & ...`, `Event = CustomEventData & {...}`. Reads were `message.priority`; writes were `channel.sendMessage({ text, priority })`. +- **v10:** every entity has a `custom` object typed by the matching `Custom*Data` interface. On responses it is required (`MessageResponse.custom: CustomMessageData`, `UserResponse.custom`, `ChannelResponse.custom`, `ChannelMemberResponse.custom`, `ReactionResponse.custom`, `ThreadResponse.custom`, `Attachment.custom`, `OGAttachment.custom`, events `custom: CustomEventData`); on requests it is optional (`MessageRequest.custom?`, `ChannelInput.custom?`, `ChannelMemberRequest.custom?`, `ReactionRequest.custom?`, `PollOptionInput.custom?`, `CreatePollRequest.custom?`) except `Attachment.custom`, which is required in requests too. The SDK talks to `/api/v2/...` (REST and `/api/v2/connect`), whose payloads nest custom fields; there is no client-side flattening. `Attachment` members whose types follow the generated model: `actions`, `author_icon`, `color`, `fallback`, `fields`, `footer`, `footer_icon`, `giphy` (`Images`), `original_height`, `original_width`, `pretext`. +- **Migrate:** read `message.custom.priority`; write `channel.sendMessage({ message: { text, custom: { priority } } })`; `client.channel('messaging', id, { custom: { name } })` via `ChannelInput`; outgoing attachments need at least `custom: {}`. +- **Channel `name` and `image`:** the most common read to fix. `ChannelResponse` has no top-level `name` or `image`, so `channel.data?.name` / `channel.data?.image` (channel headers, list previews, search results) become `channel.data?.custom.name` / `channel.data?.custom.image`; grep for `data?.name` and `data.name`. Filtering is the exception: `name` is still a top-level key of the channel `filter_conditions` (`{ name: { $autocomplete: q } }` keeps working), while any other custom field filters as `'custom.'` ([filters](#filter-types)). +- **Source:** `src/gen/models/index.ts:598-599,4889,4820,11555,1511,1501,1398,2663-2668`, `src/connection/wsConnection/StableWSConnection.ts:246` (v10); `src/types.ts:338,419,1056,2579,3038,3179,1712`, `src/connection.ts:219` (v9) + +```ts +// v9 +await channel.sendMessage({ text: 'hi', priority: 'high' }); +const p = message.priority; +// v10 +await channel.sendMessage({ message: { text: 'hi', custom: { priority: 'high' } } }); +const p = message.custom.priority; +``` + + + +### `Custom*Data` module-augmentation contract + +**Kind:** type-shape · **Impact:** high + +- **v9:** augmenting `CustomMessageData` (etc.) added top-level properties to `MessageResponse`, `Message`, filters and sort keys; `CustomCommandData` keys extended `CommandVariants`; `CustomEventData` extended every `Event`. +- **v10:** same 13 interfaces, same names, same root re-export. They now type the `custom` object only. Differences: + - `CustomAttachmentData` ships two declared members, `mime_type?: string` and `file_size?: number` ([attachment metadata](#attachment-metadata-custom)); augmentations merge with them. + - `CustomCommandData` is no longer referenced anywhere (`Command.name` / `set` are plain `string`), so augmenting it has no effect. + - `CustomEventData` types `event.custom`; event names come from `CustomEventTypes` keys, and undeclared custom names are typed `never` ([custom events](#custom-events)). + - poll options use `CustomPollOptionData` (v9 `PollOptionResponse` used `CustomPollData`). + - filters reach custom fields through `custom.` keys derived from the augmentation ([filters](#filter-types)); sort keys are untyped strings ([sort](#sort-shape)). + - required members declared in an augmentation become required in every `custom` object, including test fixtures of response types. +- **Migrate:** keep the `declare module 'stream-chat'` blocks; move every read/write of augmented fields under `.custom`; drop `CustomCommandData` augmentations. +- **Source:** `src/custom_types.ts:1-16`, `src/gen/models/index.ts:1-12`, `src/index.ts:93-107` (v10); `src/custom_types.ts:1-13` (v9) + + + +### Filter types generated per endpoint; custom fields as `custom.` + +**Kind:** type-shape · **Impact:** medium + +- **v9:** permissive hand-written filters built from `QueryFilters<...>`, `QueryFilter`, `PrimitiveFilter`, `ContainsOperator`, `ArrayOneOrMore` / `ArrayTwoOrMore`, `QueryLogicalOperators`; custom fields as top-level keys (`{ color: 'green' }`); `$and` / `$nor` needed one element, `$or` two. +- **v10:** `ChannelFilters`, `UserFilters`, `MemberFilters`, `MessageFilters`, `ReactionFilters`, `ThreadFilters`, `ReminderFilters`, `QueryPollsFilters`, `QueryVotesFilters` are derived from the generated request's `filter` / `filter_conditions` (`Filters<{ key: { type; operators } }>`). Only declared operators per key are allowed, one operator per key (`RequireOnlyOne`), `$eq` / `$ne` accept `null`, `$in` takes `T[]`. Custom fields use `` `custom.${field}` `` keys plus a `` `custom.${string}` `` index. Logical operators (`$and`, `$or`, `$nor`) are plain arrays. Keys removed from the typed filters include `ChannelFilters.deleted_at/config/cooldown/created_by/invites/truncated_*/auto_translation_language/own_capabilities`, all `MemberFilters` keys except via the generated payload, `UserFilters.privacy_settings/push_notifications/online/invisible/...`, `QueryPollsFilters.user_id`, `QueryVotesFilters.created_by_id`, `ReminderFilters.user_id`; added `ChannelFilters.channel_role`, `QueryVotesFilters.poll_id`, `ReminderFilters.expires_at`, `ThreadFilters.has_unread/reply_count/...`. Other filter keys whose typing changed (added, removed or retyped): `ChannelFilters.app_banned`, `auto_translation_enabled`, `distinct`, `filter_tags`, `hide_messages_before`, `invite`, `message_count`, `mute_expires_at`, `truncated_by`; `MemberFilters.invite`, `is_moderator`, `user.email`; `MessageFilters.mentioned_group_ids`, `mentioned_groups`, `mentioned_roles`, `mml`, `quoted_message_id`, `thread_participants`; `ThreadFilters.created_by_user_id`; `UserFilters.avg_response_time`, `bypass_moderation`, `deactivated_at`, `email`; `SearchPayload.force_default_search`, `force_sql_v2_backend`. `QueryFilters` now requires its type argument. `SearchPayload` drops `client_id` / `connection_id` (and takes `filter_conditions`, `message_filter_conditions`, `query`). +- **Migrate:** `{ color: 'green' }` -> `{ 'custom.color': 'green' }`; split two operators on one key into `$and`; replace removed building blocks with `Filters<...>` or the derived alias (`NonNullable`). Whether the API still accepts old top-level custom keys is unverified. +- **Source:** `src/gen/models/index.ts:39-125`, `src/types.ts:215-231` (v10); `src/types.ts:31-40,1994,2182-2260` (v9) + + + +### User response types + +**Kind:** type-shape · **Impact:** high + +- **v9:** `UserResponse = CustomUserData & { id; anon?; banned?; blocked_user_ids?; channel_last_read_at?; invisible?; language?: '' | TranslationLanguages; notifications_muted?; privacy_settings?; push_notifications?; revoke_tokens_issued_before?; shadow_banned?; username?; teams_role?: TeamsRole | null; ... }`; `OwnUserBase` hand-listed (`roles`, `devices: Device[]`, `mutes: Mute[]`, `total_unread_count_by_team`, `unread_count`). +- **v10:** generated `UserResponse` without `anon`, `channel_last_read_at`, `invisible`, `notifications_muted`, `privacy_settings`, `push_notifications`, `revoke_tokens_issued_before`, `shadow_banned`, `username`; now required `banned`, `blocked_user_ids`, `created_at`, `language: string`, `online`, `role`, `teams`, `updated_at`, `custom`; `teams_role` is `Record`. `OwnUserResponse` keeps `invisible`, `privacy_settings: PrivacySettingsResponse`, `push_preferences`, `devices: DeviceResponse[]`, `mutes: UserMuteResponse[]`, adds `latest_hidden_channels`, drops `roles`. `OwnUserBase = Pick>`. User input for `connectUser` is `ClientUser` ([user fields](#client-user-listeners-mutes)). +- **Migrate:** read privacy/push settings from the own user only; build user input as `ClientUser` / `UserRequest`, not `UserResponse`; custom user fields under `custom`. +- **Source:** `src/gen/models/index.ts:11555,6238`, `src/types.ts:91-94` (v10); `src/types.ts:910-925,1056` (v9) + + + +### Message response types (`MessageResponse`, `LocalMessage`) + +**Kind:** type-shape · **Impact:** high · **PR:** #1849 + +- **v9:** `MessageResponse = MessageResponseBase & { quoted_message? }` with `type: MessageLabel`, `user_id?`, `status?`, `args?`, `command_info?`, `before_message_send_failed?`, `channel?`, `moderation_details?`, `i18n?: TranslatedField`; `LocalMessage` converted `created_at` / `updated_at` / `deleted_at` / `pinned_at` to `Date` and allowed `null`. +- **v10:** generated `MessageResponse` without `user_id`, `status`, `args`, `command_info`, `before_message_send_failed`, `channel`, `moderation_details`; `type: string`; `member?: ChannelMemberPartialResponse`; `moderation?: ModerationV2Response`; `poll?: PollResponseData`; `reminder?: ReminderResponseData`; `shared_location?: SharedLocationResponseData`; `i18n?: Record`; now required: `created_at`, `updated_at`, `attachments`, `latest_reactions`, `own_reactions`, `reaction_counts`, `reaction_scores`, `mentioned_users`, `reply_count`, `deleted_reply_count`, `shadowed`, `silent`, `pinned`, `html`, `text`, `user`, `cid`, `restricted_visibility`, `mentioned_here`, `mentioned_channel`; changed: `pinned_by`, `quoted_message`; adds `custom`, `draft`, `image_labels`, `mentioned_channel_members`. `LocalMessage = MessageResponse & { status: string; error?: StreamAPIError; user_id?: string }`: dates stay `TimestampNS`, and `deleted_at` / `pinned_at` / `quoted_message` / `error` are `undefined` instead of `null`; `formatMessage` no longer converts dates ([formatMessage](#format-message)). +- **Migrate:** compare dates numerically or convert with `nsToDate`; replace `=== null` checks with `== null`; narrow `type` against string literals yourself (`MessageLabel` is still exported); read `moderation` instead of `moderation_details`. +- **Source:** `src/gen/models/index.ts:4889`, `src/types.ts:74-78,253-259`, `src/utils.ts:220-257` (v10); `src/types.ts:553-575,795-799` (v9) + + + +### Message input types collapse into `MessageRequest` + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `Message` (send), `UpdatedMessage` (update), `DraftMessagePayload` (draft), `MessageBase`; `DraftMessage` hand-written (`custom?: {}`, `mentioned_users?: string[]`, `shared_location?: StaticLocationPayload | LiveLocationPayload`). +- **v10:** all inputs are `MessageRequest` (`type?: 'regular' | 'system'`, no `html` / `user` / `user_id` / `status`, date fields `Date`, `custom?`, `shared_location?: SharedLocation`) wrapped by the endpoint request (`SendMessageRequest { message }`, `UpdateMessageRequest { message }`, `CreateDraftRequest { message }`). `DraftMessage = DraftPayloadResponse & Partial>` with `mentioned_users: UserResponse[]` and `custom: CustomMessageData`. +- **Migrate:** `channel.sendMessage({ message: { text, custom } }, requestOptions?)`; drop `user` / `user_id` (connected user only); convert response timestamps with `nsToDate` before putting them in a request. +- **Source:** `src/gen/models/index.ts:4820`, `src/types.ts:395-406` (v10); `src/types.ts:3038,4643` (v9) + + + +### Channel, member and channel-state response shapes + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `ChannelResponse` had `created_by_id`, `invites`, `joined`, `truncated_by_id`, `created_by: UserResponse | null`, `auto_translation_language: TranslationLanguages`; `ChannelMemberResponse` fields optional, `channel_role: Role`, `status: InviteStatus`; `ChannelAPIResponse.membership: ChannelMemberResponse | null`, `channel` required; `ChannelData` spread custom keys and accepted `members: string[] | NewMemberPayload[]`. +- **v10:** `ChannelResponse` drops those four fields, `own_capabilities: ChannelOwnCapability[]`, `auto_translation_language: string`; `ChannelMemberResponse` requires `banned`, `notifications_muted`, `shadow_banned`, `user_id`, `created_at`, `updated_at`, `custom`, uses `channel_role: string`, `status?: string`, `user?`, adds `ban_from_future_channels`, `deleted_at`, `future_channel_ban_expires`, `deleted_messages`; `ChannelStateResponseFields.channel` optional, `threads` required, `membership` not nullable; channel input is `ChannelInput` (`members?: ChannelMemberRequest[]`, `custom?`, no `blocked` / `automod` / `blocklist_behavior`); `ChannelMute.user` optional. +- **Migrate:** guard `state.channel`; pass member objects `{ user_id, channel_role?, custom? }`; custom channel data under `custom`. +- **Source:** `src/gen/models/index.ts:1398,1501-1560,1679` (v10); `src/types.ts:338,419,2681` (v9) + + + +### Poll types + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `PollResponse` was the poll data (`CustomPollData & PollEnrichData & {...}`); `PollOption` had `vote_count`, `votes`, `poll_id`, `created_at`, `updated_at`; votes were `PollVote` / `PollAnswer`; `VotingVisibility` was a runtime `enum`. +- **v10:** poll data is `PollResponseData`; `PollResponse` is the `{ duration, poll }` envelope ([same-name changes](#same-name-meaning-changes)); `PollOptionResponseData` is `{ id, text, custom, text_i18n? }` only; votes and answers are one `PollVoteResponseData` (`option_id` required, `answer_text` / `is_answer` optional); `VotingVisibility` is a type-only union ([VotingVisibility](#voting-visibility)); `PartialPollUpdate` keys (`set`, `unset`) are `keyof UpdatePollRequest`; `QueryPollsResponse.polls: PollResponseData[]` (with `next` / `prev`, `duration`). +- **Migrate:** rename `PollResponse` -> `PollResponseData` for data; read option vote counts from `PollResponseData.vote_counts_by_option[option.id]`; use string literals `'anonymous'` / `'public'` instead of the enum. +- **Source:** `src/gen/models/index.ts:6407-6440`, `src/types.ts:338-343` (v10); `src/types.ts:3662-3700,3757` (v9) + + + +### Reaction types + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `Reaction = CustomReactionData & { type; message_id?; score?; user?; user_id?; emoji_code? }` (send payload); `ReactionResponse = Reaction & { created_at; message_id; updated_at }`. +- **v10:** the send payload is `ReactionRequest` (`custom?`); `ReactionResponse` drops `emoji_code`, requires `score`, `user`, `user_id`, `custom`; `ReactionGroupResponse.first_reaction_at` / `last_reaction_at` are required `TimestampNS`, `latest_reactions_by` (`ReactionGroupUserResponse[]`) required. The v10 name `Reaction` denotes an unrelated generated activity reaction ([same-name changes](#same-name-meaning-changes)). +- **Migrate:** `import type { ReactionRequest }` for sending; custom reaction fields under `custom`. +- **Source:** `src/gen/models/index.ts:8354,8518,8538` (v10); `src/types.ts:945,3179` (v9) + + + +### Option types derived from generated requests + +**Kind:** type-shape · **Impact:** medium + +- **v9:** hand-written option bags. +- **v10:** aliases over generated requests: + + | type | v9 | v10 | what breaks | + | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + | `BanUserOptions` | `UnBanUserOptions & { ban_from_future_channels?, banned_by?, banned_by_id?, ... }` | `Omit` | drops `client_id`, `connection_id`, `id`, `type`, `target_user_id`, `remove_future_channels_ban`, `banned_by`, `banned_by_id`; adds `channel_cid` (`delete_messages` follows `BanRequest`) | + | `ChannelOptions` | hand-written | `Omit` | drops `user_id`; adds `member_custom_include` | + | `ChannelStateOptions` | `RequestOptions & {...}` | `{ offlineMode?, skipInitialization?, skipHydration?, withResponse? }` | `signal` moved to the trailing `requestOptions` | + | `DeleteMessageOptions` | `{ deleteForMe?, hardDelete? }` | `NonNullable[1]>` | keys renamed to `delete_for_me`, `hard` | + | `GetThreadOptions` | hand-written | derived from `ChatApi.getThread` | adds `connection_id` (non-breaking) | + | `MessageOptions` | `{ include_thread_participants? }` | generated | adds `member_custom_include` (non-breaking) | + | `ModerationFlagOptions` | `{ custom?, moderation_payload?, user_id? }` | `Omit` | drops `user_id`; adds `entity_creator_id` | + | `QueryPollsOptions` / `QueryVotesOptions` | `Pager` | `Omit` | same keys (`limit`, `next`, `prev`) | + | `SearchUserGroupsOptions` | hand-written | `Parameters[0]` | same keys | + | `SendMessageOptions` | `{ force_moderation?, is_pending_message?, keep_channel_hidden?, pending?, pending_message_metadata?, skip_enrich_url?, skip_push? }` | `Omit` | drops `force_moderation`, `is_pending_message`, `pending`, `pending_message_metadata`; adds `scheduled_at`, `include_channel_context`, `include_mentioned_members` | + | `UpdateMessageOptions` | `{ skip_enrich_url?, skip_push? }` | `Omit` | same keys | + | `UserOptions` | hand-written | `Omit` | adds `id_gt` / `id_gte` / `id_lt` / `id_lte` (non-breaking) | + +- **Migrate:** rename keys as in the table; pass `signal` through `requestOptions`. Pending-message send flags are no longer accepted by the type (server support unverified). +- **Source:** `src/types.ts:80,97,110-125,150,219-221,261,349-352,419,449-451` (v10); `src/types.ts:3924` (v9) + + + +### Moderation and app-config generated types + +**Kind:** type-shape · **Impact:** low + +- **v9 -> v10** (all became generated interfaces; mostly used by moderation dashboards): + - rule actions: `ModerationActionType` removed; `AutomodRule.action`, `BlockListRule.action` (adds `mask`, `mask_flag`), `LLMRule.action` (adds `keep`) are inline literal unions. + - renamed members: `AIImageConfig.rules: AWSRekognitionRule[]` (plus `ocr_rules` required), `AITextConfig.rules: BodyguardRule[]`, `severity_rules: BodyguardSeverityRule[]`, `profile` now required; `ModerationConfig` / `ModerationConfigResponse` -> `UpsertConfigRequest` / `ConfigResponse` (`GetConfigResponse.config`, `UpsertConfigResponse.config` now optional `ConfigResponse`). + - optional -> required: `BlockListResponse.type/is_*_enabled` (`is_confusable_folding_enabled`, `is_leet_check_enabled`, `is_plural_check_enabled`, `is_substring_matching_enabled`), `BlockListRule.name`, `VelocityFilterConfigRule.*`, `Field.*`, `Action.name/text/type`, `FileUploadConfig.*` (`allowed_file_extensions`, `allowed_mime_types`, `blocked_file_extensions`, `blocked_mime_types`, `size_limit`; no `null`), `ChannelConfigWithInfo` booleans and `name` / `max_message_length` / `message_retention`. + - required -> optional: `BanOptions.*`, `ContentCountRuleParameters.*`, `ImageRuleParameters.*`, `TextRuleParameters.threshold/time_window`, `VideoRuleParameters.*`, `ImageContentParameters.harm_labels`, `VideoContentParameters.harm_labels`, `UserRuleParameters.max_age`, `UserCreatedWithinParameters.max_age`, `RuleBuilderRule.id/action`, `RuleBuilderConditionGroup.*`, `RuleBuilderCondition.type`, `FlagUserOptions.reason`, `SearchWarning.channel_search_*`. + - `Automod` / `AutomodBehavior` lose the `| (string & {})` tail; `ChannelConfigWithInfo.automod_thresholds` no longer `null`; `Thresholds` values are `LabelThresholds`; `RuleBuilderAction.type` is a closed union; `UserCustomPropertyParameters.expected_value` removed; `ModerationPayload.created_at` removed; `ModerationResponse` drops `original_text`, `action: string`; `ChatPreferences` levels are `string`; `Configs` value type is the generated `ChannelConfigWithInfo`. + - Other interfaces in this group whose members changed: `AutomodPlatformCircumventionConfig`, `AutomodSemanticFiltersConfig`, `AutomodSemanticFiltersRule`, `AutomodToxicityConfig`, `BlockListConfig`, `BlockListOptions`, `FilterConfigResponse`, `LLMConfig`, `SubmitActionResponse`, `TextContentParameters`, `VelocityFilterConfig`. + - Further members added, removed or retyped across these interfaces: `action_sequences`, `advanced_filters`, `ai_image_labels`, `ai_image_ocr_labels`, `ai_image_taxonomy`, `allowed_flag_reasons`, `appeal_item`, `audios`, `auto_restore_warning`, `ban_duration`, `blocklists`, `call_custom_property_params`, `call_options`, `call_type_rule_params`, `call_violation_count_params`, `cascading_action`, `cascading_threshold`, `channel_message_count_rule_params`, `chat_preferences`, `check_message_context`, `cids_per_user`, `closed_caption_rule_params`, `config_keys`, `content_country_code_params`, `content_custom_property_count_params`, `content_custom_property_params`, `content_flag_count_rule_params`, `country_code`, `fast_spam_threshold`, `fast_spam_ttl`, `filterable_custom_keys`, `flood_identical_params`, `flood_similar_params`, `grants`, `image_ids`, `image_ordered_keys`, `intent_rule_params`, `ip_content_count_rule_params`, `ip_flag_count_rule_params`, `keyframe_ocr_rule_params`, `keyframe_rule_params`, `label_operator`, `match_substring`, `ocr_content_params`, `other_media`, `partition_size`, `partition_ttl`, `probation_period`, `property_key`, `rule_names`, `rule_type`, `semantic_filter_min_threshold`, `semantic_filter_names`, `severity_descriptions`, `shadow_ban`, `skip_inbox`, `slow_spam_threshold`, `slow_spam_ttl`, `spam`, `text_ids`, `text_length`, `text_length_operator`, `text_ordered_keys`, `toxic`, `url_only`, `user_channel_ban_count_params`, `user_channel_count_params`, `user_flag_count_rule_params`, `user_identical_content_count_params`, `user_identical_image_count_params`, `user_reaction_count_params`, `user_role_params`. +- **Migrate:** retype configs from the generated interfaces; supply now-required fields. +- **Source:** `src/gen/models/index.ts`, `src/types.ts:128-130,242` (v10) + + + +### Server timestamps are `TimestampNS` (unix nanoseconds) + +**Kind:** type-shape · **Impact:** high · **PR:** #1849, #1884 + +- **v9:** response and event date fields were ISO `string` (`created_at`, `updated_at`, `deleted_at`, `last_read`, `last_active`, `last_message_at`, `remind_at`, `pinned_at`, `expires`, ...); `formatMessage` / `LocalMessage` converted message dates to `Date`; `Event.received_at: string | Date`. +- **v10:** `type TimestampNS = number & { readonly [timestampNsBrand]: true }`; every server-sent date on generated responses and events (391 fields) and the hand-written state types carry it; a plain `number` is not assignable. Root-exported helpers: `nsToDate`, `convertTimestampToDate`, `nsToMs`, `msToNs`, `dateToNs`, `nowNs`, `nsToRfc3339`, `asTimestampNS`, `NS_PER_MS`. `received_at?: TimestampNS` on every event. Silent failure modes (no type error): `dayjs(ns)`, `ts > Date.now()`, `if (!created_at)` (0 is valid), persisted `Date` / ISO values. Slice-specific state fields: [state timestamps](#state-timestamps). +- **Migrate:** compare and sort timestamps numerically; convert at the UI edge with `nsToDate(ts)` / `convertTimestampToDate(ts)`; mint values with `nowNs()` / `msToNs()` / `dateToNs()`; brand DB rows with `asTimestampNS()`; migrate persisted state. +- **Source:** `src/gen/models/index.ts:13-37`, `src/utils/time.ts:1-80`, `src/index.ts:115-125`, `src/types.ts:193,197` (v10); `src/types.ts:1712-1776`, `src/utils.ts:351` (v9) + + + +### Global `DateConstructor` overload for `new Date(timestampNs)` + +**Kind:** type-shape · **Impact:** medium · **PR:** #1884 + +- **v9:** none. +- **v10:** `src/index.ts` re-exports `./gen/models/timestamp-guard` type-only, which declares a global `DateConstructor` overload: `new (t)` returns `{ readonly ERROR_use_SDKs_nsToDate_helper_instead: never }` and is `@deprecated`. `const d = new Date(message.created_at)` still compiles; the error appears only where the result is used as a `Date` (`new Date(ts).getTime()` -> TS2339, `const x: Date = new Date(ts)` -> TS2740), plus a deprecation strike-through in editors. Code that only passes the value along (dayjs, string interpolation, `JSON.stringify`), `new Date(x ?? 0)` and `any` values are not caught. The augmentation is global for every consumer whose type graph imports `stream-chat` (packaging details in [section 1](#installation-runtime-packaging)). +- **Migrate:** `nsToDate(ts)`. The guard cannot be excluded by tsconfig for package consumers. +- **Source:** `src/gen/models/timestamp-guard.ts:13-27`, `src/index.ts:83-87`, `dist/types/index.d.ts:35` (v10) + + + +### Request date fields are `Date`; responses must be converted before reuse + +**Kind:** type-shape · **Impact:** medium + +- **v9:** request dates accepted `string | Date` in many places (`hide_history_before`, `pinned_at_*`, `created_at_*` pagination, `message_timestamp`, `remind_at: string | null`, `end_at: string`, `disabled_until: string`). +- **v10:** generated request fields are `Date` only (22 fields, e.g. `MessageRequest.pin_expires/pinned_at`, `MessagePaginationParams.created_at_*`, `MarkUnreadRequest.message_timestamp`, `CreateReminderRequest.remind_at`, `UpdateLiveLocationRequest.end_at`, `SharedLocation.end_at`, `PushPreferenceInput.disabled_until`, `UpdateChannelRequest.hide_history_before`). `ChannelUpdateOptions.hide_history_before` and `PinnedMessagePaginationOptions.pinned_at_*` (`pinned_at_after`, `pinned_at_after_or_equal`, `pinned_at_around`, `pinned_at_before`, `pinned_at_before_or_equal`) drop `string`. `ChannelUpdateOptions` follows `UpdateChannelRequest` (`accept_invite`, `add_filter_tags`, `add_members`, `cooldown`, `data`, `invites`, `reject_invite`, `remove_filter_tags`, `remove_members`). A `TimestampNS` from a response is not assignable (TS2322). Exceptions: `QueryMembersPayload`, `QueryBannedUsersPayload`, `QueryFutureChannelBansPayload` type `created_at_*` as `TimestampNS` (so `QueryMembersOptions.created_at_*` went `string` -> `TimestampNS`, and `QueryMembersOptions` gains `type`, `id`, `members`, `sort`), and `listUserGroups` takes `created_at_gt: string`. +- **Migrate:** `new Date(iso)` for literals, `nsToDate(ts)` for server values; for `queryMembers` / `queryBannedUsers` / `queryFutureChannelBans` date bounds pass `msToNs(ms)` (server acceptance of ns there is unverified). +- **Source:** `src/gen/models/index.ts:4725,4820,6815-6818,7180-7183,7223-7226`, `src/types.ts:70,132-135,137`, `src/utils/time.ts:19-21` (v10) + + + +### Sort is `SortParamRequest[]`; all sort aliases removed + +**Kind:** type-shape · **Impact:** high · **PR:** #1836 + +- **v9:** `{ field: 1 | -1 }` objects or arrays of them, typed per endpoint (`ChannelSort`, `UserSort`, `MemberSort`, `ReactionSort`, `SearchMessageSort`, `ThreadSort`, `PollSort`, `VoteSort`, `DraftSort`, `PinnedMessagesSort`, `BannedUsersSort`, `ReminderSort`, `ReviewQueueSort`, `QueryModerationConfigsSort`, `QuerySort`, `PredefinedFilterSort`, with `*SortBase`, `Sort`, `SortParam`, `PredefinedFilterSortParam`, `AscDesc = 1 | -1`); a runtime normalizer converted objects to `[{ field, direction }]`. +- **v10:** every sort is `SortParamRequest[]` with `{ field?: string; direction?: number; type?: string }` (`type: 'number' | 'boolean'` for non-string custom fields). All 29 sort aliases are removed with no replacement alias (also `CampaignSort`, `QueryMessageHistorySort(Base)`, `QueryModerationRulesSort` together with their APIs). `UserSortBase` never existed. Field names are not type-checked; `direction` is any number. Sort sits inside the request (`request.sort` or `payload.sort`). Exception: the moderation `CreateQueueRequest`-style queue sort stays `Array>`. +- **Migrate:** `{ last_message_at: -1, created_at: 1 }` -> `[{ field: 'last_message_at', direction: -1 }, { field: 'created_at', direction: 1 }]`, keeping key order; type variables as `SortParamRequest[]`. Full list in the [rename table](#type-rename-table). +- **Source:** `src/gen/models/index.ts:9951-9964` (v10); `src/types.ts:1789,2396-2450,3398` (v9) + + + +### `Event` is a discriminated union + +**Kind:** type-shape · **Impact:** high + +- **v9:** `Event = CustomEventData & { type: EventTypes; ...~55 optional fields }`, so `e.user?.id`, `e.cid`, `e.total_unread_count` compiled on any event. +- **v10:** `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`. `WSEvent` is the generated union of 83 `{ type: '' } & Event` members (one is `{ type: '*' } & CustomEvent`); `LocalEvent` (not exported) covers `live_location_sharing.started` / `stopped`, `channels.queried` (`queriedChannels`), `connection.recovered`, `connection.fallback_activated` (`mode`), `offline_reactions.queried` (`offlineReactions`), `capabilities.changed`, `message.read_locally`. Fields exist only on the members that declare them; `EventPayload<'message.new'>` extracts one member. `SyncResponse.events` is `WSEvent[]`; `TypingStartEvent` is the generated `typing.start` payload. v9 flat fields now present only on specific members include `ai_message`, `ai_state`, `app`, `channel_custom`, `channel_id`, `channel_last_message_at`, `channel_member_count`, `channel_type`, `clear_history`, `connection_id`, `deleted_for_me`, `draft`, `first_unread_message_id`, `hard_delete`, `last_delivered_at`, `last_delivered_message_id`, `last_read_at`, `last_read_message_id`, `live_location`, `mark_messages_deleted`, `me`, `member`, `online`, `own_capabilities`, `parent_id`, `poll`, `poll_vote`, `reaction`, `reminder`, `shadow`, `team`, `thread`, `thread_id`, `total_unread_count`, `unread_channels`, `unread_count`, `unread_messages`, `unread_thread_messages`, `unread_threads`, `watcher_count`. +- **Migrate:** narrow on `event.type` (or `'field' in event`), or type handlers as `EventPayload<'...'>`; `client.on('message.new', (e) => ...)` infers the member. +- **Source:** `src/types.ts:163-209,438-441`, `src/gen/models/index.ts:11938-12023` (v10); `src/types.ts:1712-1776` (v9) + + + +### `EventTypes` -> `EventType`; `EVENT_MAP` removed; event-name set changed + +**Kind:** renamed · **Impact:** medium · **PR:** #1836 + +- **v9:** `EventTypes = 'all' | keyof typeof EVENT_MAP | keyof CustomEventTypes`; `Event['type']` was `EventTypes`, so it included `'all'`. `EVENT_MAP` runtime object exported from `src/events.ts` (70 names). +- **v10:** `EventType = Event['type'] | 'all'`; `EVENT_MAP` and `src/events.ts` deleted (no runtime list of names). `'all'` is no longer an `Event['type']` literal but remains in `EventType`, and `client.on(cb)` / `channel.on(cb)` without a name still register under `'all'` and receive every event. `'*'` is a new `Event['type']` literal (spec placeholder member `{ type: '*' } & CustomEvent`). Names no longer in the union: `connection.changed`, `transport.changed` ([removed](#connection-changed-removed)), `channel.muted`, `channel.unmuted`, `user.unread_message_reminder` (not in the v2 event spec; the v9 SDK never handled them). Added: `app.updated`, `channel.frozen`, `channel.unfrozen`, `channel.max_streak_changed`, `connection.ok`, `connection.error`, `connection.fallback_activated`, `message.pending`, `moderation.custom_action`, `moderation.flagged`, `moderation.mark_reviewed`, `poll.deleted`, `scheduled_message.created` / `deleted` / `failed` / `sent` / `updated`, `user.deactivated`, `user.reactivated`, `user.muted`, `user_group.created` / `deleted` / `updated` / `member_added` / `member_removed`. +- **Current v10 typing quirks (tracked in v10-followups.md):** `client.on('all', cb)` / `channel.on('all', cb)` type the event as `never` (`EventHandler<'all'>` extracts no member, TS2339 on any property) even though the listener receives every event at runtime; `client.on('*', cb)` type-checks with a `CustomEvent` payload but never fires, because dispatch keys listeners by the real `event.type` and nothing is dispatched under `'*'`. +- **Migrate:** `import type { EventType }`; replace `Object.keys(EVENT_MAP)` with your own list; use `client.on(cb)` (no name) for all events; listen to `notification.channel_mutes_updated` instead of `channel.muted` / `unmuted`. +- **Source:** `src/types.ts:196-209`, `src/client.ts:987-1040,1386-1396`, `src/channel.ts:2234-2297,2877`, `src/gen/models/index.ts:11854,11939` (v10); `src/events.ts:1-75`, `src/types.ts:1783`, `src/index.ts:10` (v9) + + + +### `connection.changed` and `transport.changed` are no longer dispatched + +**Kind:** behavior · **Impact:** high + +- **v9:** `StableWSConnection` / `WSConnectionFallback` dispatched `connection.changed` (`{ online }`) on health changes (drop announcement delayed about 5 s); switching to long-poll dispatched `transport.changed` (`{ mode: 'longpoll' }`); `ConnectionChangeEvent` type exported. +- **v10:** neither is emitted and `ConnectionChangeEvent` is removed. `client.on('connection.changed', fn)` still compiles through the string overload but never fires and `e` is `never`. Socket health: `client.wsConnection.state` (`isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`, see [wsConnection](#ws-connection)); device network: `client.networkConnection.state` (`isOnline`, ...); fallback switch: local `connection.fallback_activated` (`{ mode }`, see [long-poll](#long-poll-fallback)); recovery: `connection.recovered`. The stores publish every transition immediately (no 5 s debounce). Offline sync no longer listens to it ([offline sync](#offline-sync-manager)); threads no longer read it ([thread manager](#thread-manager-behaviour)). +- **Migrate:** `client.wsConnection.state.subscribeWithSelector(({ isHealthy }) => ({ isHealthy }), ({ isHealthy }) => ...)` (fires once immediately); delay banners yourself. +- **Source:** `src/connection/wsConnection/WSConnection.ts:289`, `src/connection/ConnectionRecoveryManager.ts:353` (v10); `src/connection.ts:569,576`, `src/connection_fallback.ts:51-55`, `src/client.ts:1878`, `src/types.ts:1707` (v9) + + + +### Custom events: payload under `event.custom`; names must be declared + +**Kind:** type-shape · **Impact:** medium + +- **v9:** custom events were `Event` with `CustomEventData` spread at the top level (`e.text`); any string was a valid listener name; `UserCustomEvent = CustomEventData & { type: string }` for `client.sendUserCustomEvent`. +- **v10:** custom events arrive as `CustomEvent { type; custom: CustomEventData; created_at: TimestampNS; received_at? }`; `CustomEventTypes` keys become union members `{ type: K } & Omit`. An undeclared name yields `never`: `client.on('my_custom', (e) => e.type)` fails with TS2339. `UserCustomEvent` is removed with `sendUserCustomEvent`. Sending: `channel.sendEvent({ event: { type, custom: {...} } })` ([sendEvent](#channel-send-event)). +- **Migrate:** declare names in `interface CustomEventTypes { my_custom: true }` via module augmentation; read `e.custom.text`. +- **Source:** `src/types.ts:196-206`, `src/gen/models/index.ts:2663-2668` (v10); `src/types.ts:1712,1777-1783` (v9) + + + +### `EventHandler` is generic + +**Kind:** signature · **Impact:** low + +- **v9:** `EventHandler = (event: Event) => void`. +- **v10:** `EventHandler = (event: Extract) => void`. Unparameterised `EventHandler` still receives the full union. With `T = 'all'` the event is typed `never` ([quirk](#event-type-names)). +- **Migrate:** use `client.on(cb)` without a name for all events; annotate handlers as `EventHandler<'message.new'>` or `(e: EventPayload<'message.new'>) => void`. +- **Source:** `src/types.ts:209`, `src/client.ts:987-1016`, `src/channel.ts:2234-2243` (v10); `src/types.ts:1781` (v9) + + + +### `ConnectionOpen` / `ConnectAPIResponse` are event payload types + +**Kind:** type-shape · **Impact:** low + +- **v9:** `ConnectionOpen = { connection_id; cid?; created_at?; received_at?; me?; type }` (string dates). +- **v10:** `ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>`; `ConnectAPIResponse = Promise` (the `connectUser` result). `connection_id` is on both members; `me` is required on `connection.ok` and optional on `health.check`; dates are `TimestampNS`. +- **Migrate:** narrow on `type === 'connection.ok'` to get a non-optional `me`. +- **Source:** `src/types.ts:72,244` (v10); `src/types.ts:2785` (v9) + + + +### Exported names that still compile but mean something else + +**Kind:** type-shape · **Impact:** high + +These imports keep compiling and fail only at use sites. + +| name | v9 meaning | v10 meaning | use instead | +| ------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `Role` | role-name union `'admin' \| 'user' \| ... \| (string & {})` | generated role object `{ name, custom, scopes, created_at, updated_at }` | `string` for role names (`ChannelMemberResponse.channel_role` is `string`); `Role` for `searchRoles` results. There is no `RoleName` type | +| `User` | alias of `UserResponse` (with `deactivated_at`, `avg_response_time`, ...) | generated `{ id: string; data?: Record }` (unrelated) | `ClientUser` (connectUser input) or `UserResponse` | +| `Reaction` | send-reaction payload | generated activity reaction (`activity_id`, `kind`, `children_counts`, `latest_children`, `own_children`, `target_feeds`, `target_feeds_extra_data`, ...) | `ReactionRequest` | +| `PollResponse` | poll data | `{ duration; poll: PollResponseData }` envelope | `PollResponseData` | +| `PollOptionResponse` | poll option data | `{ duration; poll_option: PollOptionResponseData }` envelope | `PollOptionResponseData` | +| `MuteResponse` | a user mute (`{ user, target, created_at, ... }`) | `/moderation/mute` response `{ duration, mutes?, non_existing_users?, own_user? }` | `UserMuteResponse` | +| `SharedLocationResponse` | location data | endpoint response (data fields plus required `duration`, optional `channel`, `message`) | `SharedLocationResponseData` | +| `ThreadResponse` | full thread incl. `read`, `latest_replies`, `draft` | minimal generated thread (no `read` / `latest_replies` / `draft`) | `ThreadStateResponse` | +| `TypingStartEvent` | alias of `Event` | generated `typing.start` payload | `EventPayload<'typing.start'>` | +| `UpdateChannelResponse` | channel-type update response (config fields, `grants`, ...) | channel update response `{ channel?, members, message?, duration }` | channel-type admin removed (node-sdk) | +| `PendingMessageResponse` | `{ message; pending_message_metadata? }` | `{ message?; channel?; user? }` | read `message?` | +| `ChannelConfig` | server channel config | per-channel instance config | `ChannelConfigWithInfo` ([channel config](#channel-config)) | +| `Event` | flat object | discriminated union | [event union](#event-union) | +| `ConnectionOpen` | flat object | event payload union | [ConnectionOpen](#connection-open) | +| `VotingVisibility` | runtime enum | type-only literal union | string literals ([VotingVisibility](#voting-visibility)) | +| `Command` | hand-written, string dates | generated, `TimestampNS` dates (required fields unchanged) | same name ([commands](#composer-commands)) | + +- **Migrate:** audit `import type { Role, User, Reaction, PollResponse, PollOptionResponse, MuteResponse, SharedLocationResponse, ThreadResponse, TypingStartEvent, UpdateChannelResponse }`. +- **Source:** `src/gen/models/index.ts:8926,11170,8354,6422,6407,5586,9841,10171,10444,10719,6335,2257` (v10); `src/permissions.ts:60`, `src/types.ts:3332,3179,3662,3757,873,4669,578,3313,1029,3131,1319,3693` (v9) + + + +### `AIState` literals changed + +**Kind:** type-shape · **Impact:** low + +- **v9:** `'AI_STATE_ERROR' | 'AI_STATE_CHECKING_SOURCES' | 'AI_STATE_THINKING' | 'AI_STATE_GENERATING' | (string & {})`. +- **v10:** `'AI_STATE_IDLE' | 'AI_STATE_ERROR' | 'AI_STATE_EXTERNAL_SOURCES' | 'AI_STATE_THINKING' | 'AI_STATE_GENERATING' | 'AI_STATE_STOP' | (string & {})`, plus a new `AIStates` constant. Still open-ended, so no compile error; `AI_STATE_CHECKING_SOURCES` is now `AI_STATE_EXTERNAL_SOURCES`. +- **Migrate:** switch statements on `'AI_STATE_CHECKING_SOURCES'` should handle `'AI_STATE_EXTERNAL_SOURCES'`; prefer `AIStates.*`. +- **Source:** `src/types.ts:354-370` (v10); `src/types.ts:4438` (v9) + + + +### Attachment metadata fields moved into `attachment.custom` + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `Attachment` had top-level `mime_type`, `file_size`, `duration`, `waveform_data`, `latitude`, `longitude`. +- **v10:** none of these are on `Attachment`. `CustomAttachmentData` declares `mime_type?`, `file_size?`; `VoiceRecordingAttachment.custom` adds `duration?`, `waveform_data?`; location moved to `MessageResponse.shared_location`. SDK helpers read and write `custom.*` only (composer side: [upload metadata](#composer-upload-metadata)). +- **Migrate:** `attachment.custom.mime_type`, `attachment.custom.file_size`, `attachment.custom.duration`, `attachment.custom.waveform_data`. +- **Source:** `src/custom_types.ts:1-4`, `src/messageComposer/types.ts:59-65`, `src/messageComposer/attachmentIdentity.ts:75-77` (v10); `src/types.ts:2579` (v9) + + + +### Utility types removed + +**Kind:** removed · **Impact:** low + +- **v9:** `Readable`, `KnownKeys`, `PartializeKeys`, `UnknownType`, `UR`, `LiteralStringForUnion`, `CreatedAtUpdatedAt`, `Unpacked`, `ArrayOneOrMore`, `ArrayTwoOrMore` exported from `src/types.ts`; `src/types.utility.ts` had `DeepPartial` (not root-exported). +- **v10:** all removed. `RequireOnlyOne`, `RequireAtLeastOne` kept; new `RequireLiteral`, `PartializeAllBut`; `DeepPartial` is now root-exported. +- **Migrate:** inline the built-ins (`Record` for `UR` / `UnknownType`, `T[]` for the array helpers, `Partial> & Omit` for `PartializeKeys`). +- **Source:** `src/types.ts:46-53,443-447`, `src/types.utility.ts:1-17` (v10); `src/types.ts:31-40` (v9) + + + +## 6. `Channel` + +Common pattern for every generated or overridden `Channel` method: the trailing `requestOptions?: StreamRequestOptions` (`{ signal, onUploadProgress, timeout }`) replaces v9 `RequestOptions` / `axiosRequestConfig`; responses are `StreamResponse<...>` generated types. Server-side `user_id` overrides are gone from every channel method (`MarkReadRequest`, `HideChannelRequest`, `MuteChannelRequest`, `UpdateMemberPartialRequest`, `TruncateChannelRequest` carry no `user_id`); server-side code moves to `@stream-io/node-sdk`. Sending messages and reactions: [section 9](#message-operations). + + + +### `Channel` class and constructor + +**Kind:** signature · **Impact:** medium · **PR:** #1901 + +- **v9:** `new Channel(client, type, id, data: ChannelData)`; plain class; `type` / `id` / `cid` writable fields; a members-only channel had `cid === 'type:undefined'` until queried. +- **v10:** `new Channel(client, type, id, data: ChannelInput)` (ctor input changed); `class Channel extends ChannelApi` (generated); `type` is `readonly`; a members-only channel's `cid` is the temporary `type:!members-` until the server answers, and `isProvisional` is true while it has no id. A constructed instance is not stored anywhere (no events reach it) unless created through `client.channelManager.ensure()` / `client.channel()`. +- **Migrate:** never `new Channel(...)`; use `client.channelManager.ensure({ type, id, data })` or `client.channel()`. `data.members` must be `[{ user_id }]` objects and custom fields go under `data.custom`. Do not assign `channel.type`. +- **Source:** `src/channel.ts:227,286-316`, `src/gen/chat/ChannelApi.ts:41-46`, `src/ChannelManager.ts:532-551` (v10); `src/channel.ts:90-187` (v9) + + + +### `channel.data` / `channel._data` are store-backed + +**Kind:** behavior · **Impact:** medium + +- **v9:** `data: Partial` plain field holding the very object assigned (the constructor stored the caller's `data` object); `channel.data.x = y` mutated it in place. +- **v10:** `data` is a getter/setter over `channel.state` (`Partial | undefined`). Assigning publishes `data`, `memberCount`, `ownCapabilities` in one update; when the new object omits `member_count` or `own_capabilities`, the previous values are carried onto a copy (so `channel.data !== assigned`, and capabilities can no longer be cleared by omission). In-place edits (`channel.data.member_count = n`) are not published to subscribers. `_data` (internal) is `ChannelInput`. +- **Migrate:** replace in-place edits with `channel.data = { ...channel.data, field }`; subscribe with `channel.state.subscribeWithSelector((s) => ({ data: s.data }), ...)`. +- **Source:** `src/channel.ts:229,1376-1408` (v10); `src/channel.ts:94-95,154-158` (v9) + + + +### `channel.disconnected` -> `pendingDisposal`; `_disconnect()` -> `disconnect()` + +**Kind:** renamed · **Impact:** medium · **PR:** #1901 + +- **v9:** writable `disconnected: boolean`; `_disconnect()` set it, cleared the cooldown timer and called `state.setIsUpToDate(false)`; `getClient()` then threw "You can't use a channel after client.disconnect() was called". +- **v10:** read-only reactive getter `pendingDisposal` (slice of `channel.state`), set only by public `disconnect()`. `disconnect()` also unsubscribes configuration, stops the receipts tracker and cooldown timer, disposes `messagePaginator` / `pinnedMessagesPaginator`, and sets `watchStatus: NotWatching`; it is idempotent and sends no request. Afterwards `getClient()` throws "Channel is pending disposal ... use client.channelManager.ensure()", `query()` / `watch()` reject, `activate()` warns and no-ops. The instance stays in the channel store until `releaseUnusedChannels()`, but `ensure()` returns a fresh instance for its cid. +- **Migrate:** `channel.disconnected` -> `channel.pendingDisposal`; `channel._disconnect()` -> `channel.disconnect()`; never assign it. Re-acquire with `client.channelManager.ensure({ type, id })`. +- **Source:** `src/channel.ts:505-515,1423-1425,1477-1481,1916,3074-3102` (v10); `src/channel.ts:118,187-192,2546-2559` (v9) + + + +### `initialized` / `offlineMode` are store-backed; set by `query()` and `create()` + +**Kind:** behavior · **Impact:** low · **PR:** #1901 + +- **v9:** plain fields; `initialized` set only by `watch()` and `queryChannels` hydration. +- **v10:** accessors over `channel.state` (reactive); any applied live response sets `initialized: true` (`query()`, `create()`, `watch()`, hydration). `create()` therefore now unlocks `markRead()` / `markUnread()`. +- **Migrate:** read as before; subscribe via `channel.state` if needed. Do not rely on `create()` leaving the channel uninitialized. +- **Source:** `src/channel.ts:1320-1326,1360-1366,2057` (v10); `src/channel.ts:109,114,1339,1677` (v9) + + + +### Reconnect recovery reloads only activated channels + +**Kind:** behavior · **Impact:** high · **PR:** #1841 + +- **v9:** when `recoverStateOnReconnect` was on, `client.recoverState()` re-queried the cids in `client.activeChannels` with one `queryChannels({ cid: { $in } }, { last_message_at: -1 }, { limit: 30 })` (so at most 30), refreshing and re-watching the channels the app had touched. +- **v10:** `client.connectionRecovery` re-runs each loaded `ChannelPaginator`'s first page (`channelManager.recover()`) and, independently (after the offline sync when an offline DB is initialized), calls `channel.reload()` only for channels with `channel.active === true` (set by `channel.activate()`, ref-counted) and not `pendingDisposal`. Other channels stay `watchStatus: WasWatching` and are re-watched lazily (`ensureWatched()`) when an event names them. `reload()` re-watches with `messages.limit` = max(loaded head window, pageSize), reconciles offline hard deletes and re-ingests failed messages; it is a no-op on a channel that is neither initialized nor offline. Client side: [connection recovery](#connection-recovery). +- **Migrate:** call `const release = channel.activate()` while a channel is on screen and `release()` on unmount. A channel opened with `watch()` but never activated is not refreshed on reconnect and misses messages sent while offline until something re-queries it. Do not re-query on `connection.recovered` for active channels; it is dispatched after the reload. +- **Source:** `src/connection/ConnectionRecoveryManager.ts:229-233,282-353`, `src/channel.ts:1455-1496,1680-1721`, `src/ChannelManager.ts:142-150,1175-1182` (v10); `src/client.ts:1782-1826` (v9) + + + +### `watch()` / `stopWatching()` / `watchStatus` + +**Kind:** behavior · **Impact:** medium + +- **v9:** `watch(options?: ChannelQueryOptions)` awaited `wsPromise`, silently downgraded to `watch: false` without a connection id, and resolved; `stopWatching()` took no arguments. No watch bookkeeping. +- **v10:** `watch(options?: ChannelGetOrCreateRequest, requestOptions?)`: the request waits until a connection id exists ([connection id wait](#watch-waits-for-connection-id)), rejects with "No connection id is available..." when no socket is open or opening, honours `requestOptions.signal` while waiting, and rejects for a `pendingDisposal` instance. New reactive `watchStatus` (`ChannelWatchStatus.NotWatching | WasWatching | Watching`); `stopWatching(request?, requestOptions?)` sets `NotWatching`. `ensureWatched(options?)` dedupes in-flight watches and resolves to the superseding instance (replaces the v9 internal `getAndWatchChannel`). +- **Migrate:** server-side or socket-less callers pass `{ watch: false }` or use `query()`. Use `ensureWatched()` where several call sites may watch the same channel. Read `channel.watchStatus` instead of guessing from `initialized`. +- **Source:** `src/channel.ts:1432-1438,1595-1657,1731-1740`, `src/channel_state.ts:41-70` (v10); `src/channel.ts:1324-1374` (v9) + + + +### `query()` / `create()`: only latest-page queries update the message list + +**Kind:** behavior · **Impact:** medium + +- **v9:** `query(options?: ChannelQueryOptions, messageSetToAddToIfDoesNotExist = 'current')` always added the returned messages to channel state (`'current'` extended the active set, `'new'` created a disjoint set, `'latest'` the latest set) and computed pagination flags. +- **v10:** `query(options?: ChannelGetOrCreateRequest, messageSetToAddToIfDoesNotExist = 'current', requestOptions?)` (wraps generated `getOrCreate`): returned messages are seeded into `channel.messagePaginator` only for `'latest'` (what `watch()` / `create()` pass). A direct `channel.query({ messages: { id_lt } })` returns the page but leaves the message list untouched; `'current'` and `'new'` behave identically. A latest-page query without `messages` now sends `messages: { limit: messagePaginator.pageSize }` (default 100). Queries on a `pendingDisposal` instance reject. A members-only channel answered with a cid another stored instance holds is marked `supersededBy` ([channel store](#active-channels)). The option type changed, so the signature is not unchanged. `channels.queried` and `offlineDb.upsertChannels` now get `isLatestMessageSet: true` only for `'latest'` queries without `id_around` / `created_at_around` (`watch()` / `create()`, not jump/around); v9 passed the target set's `isLatest`, which was `true` for a default `'current'` query on a fresh channel or on one that extended the latest set (e.g. `query({ messages: { id_lt } })`). Offline DBs that persist messages only for the latest set no longer persist pages fetched through a direct `channel.query()`. +- **Migrate:** paginate with `channel.messagePaginator.toTail()` (older) / `toHead()` (newer) / `jumpToMessage(id)`; use `channel.query()` only for one-off reads or channel fields. +- **Source:** `src/channel.ts:1909-2085` (`isLatestMessageSet` `:2034-2038,2067-2081`) (v10); `src/channel.ts:1568-1709,2410-2440` (`isLatest` `:1679-1696`), `src/channel_state.ts:851-852` (v9) + + + +### Id / initialization guards and synchronous throws + +**Kind:** behavior · **Impact:** low + +- **v9:** `_checkInitialized()` was skipped for server-side auth (`_isUsingServerAuth()`) and guarded `sendEvent`, `sendAction`, `hide`, `show`, `banUser`, `unbanUser`, `muteStatus`, `deleteReaction`, `markAsReadRequest`, `markUnread`, `shadowBan`, `removeShadowBan`, `_deleteReaction`; most methods were `async`, so a missing id surfaced as a rejected promise (`sendAction` and `muteStatus` threw synchronously). +- **v10:** `_checkInitialized()` has no server-auth bypass and is used only by `markRead()` / `markUnread()` (server-side callers on an unqueried channel now get "hasn't been initialized"). New `_checkHasId()` ("Channel isn't yet created, call getOrCreateDistinctChannel() before this operation") guards `sendMessage`, `queryMembers`, `mute`, `unmute`, `muteStatus`, `sendAction`, `banUser`, `unbanUser`, `vote`, `removeVote`, `createDraft`, `deleteDraft`, `sendSharedLocation`, `stopLiveLocationSharing`. Inherited non-async `ChannelApi` methods (`sendEvent`, `delete`, `truncate`, `getDraft`, `getPinnedMessages`, `getMessagesById`, `deleteFile`, `deleteImage`, `updateMemberPartial`, `get`, `getOrCreate`) throw synchronously for an id-less channel, so `.catch()` chains miss it. `sendEvent`, `sendAction`, `hide`, `show` no longer require a prior query. +- **Migrate:** wrap calls on members-only channels in `try` (or `await` inside `try`); create the channel (`create()` / `watch()`) first. +- **Source:** `src/channel.ts:2892-2909`, `src/gen/chat/ChannelApi.ts:48-62,194-209` (v10); `src/channel.ts:2398-2408` (v9) + + + +### `channel.sendEvent` and AI indicator methods + +**Kind:** signature · **Impact:** medium + +- **v9:** `sendEvent(event: Event)` (required initialization; arbitrary top-level fields); `updateAIState(messageId, state, options?)` sent `ai_state` / `message_id` / `ai_message` top-level; `clearAIIndicator()`, `stopAIResponse()`. +- **v10:** `sendEvent({ event: { type, parent_id?, custom? } }, requestOptions?)`: non-`type` / `parent_id` fields must go under `event.custom` (custom event types need `CustomEventTypes` augmentation, see [custom events](#custom-events)); no initialization required; synchronous throw for id-less channels. `updateAIState(messageId, state, options?, requestOptions?)` sends the AI fields under `event.custom`. `clearAIIndicator(_request?, requestOptions?)` / `stopAIResponse(_request?, requestOptions?)` take a placeholder first argument. Incoming `ai_indicator.*` events now also drive `channel.state.aiState` (reset to `AIStates.Idle` while offline). +- **Migrate:** `channel.sendEvent({ type: 'x', text })` -> `channel.sendEvent({ event: { type: 'x', custom: { text } } })`; pass `undefined` first to reach `requestOptions` on the AI methods. +- **Source:** `src/channel.ts:1173-1240,2405-2415`, `src/gen/chat/ChannelApi.ts:194-209` (v10); `src/channel.ts:325-330,1135-1170` (v9) + + + +### `sendFile` / `sendImage` -> `uploadFile` / `uploadImage`; `deleteFile` / `deleteImage` + +**Kind:** renamed · **Impact:** medium · **PR:** #1832 + +- **v9:** `sendFile(uri, name?, contentType?, user?, axiosRequestConfig?)`, `sendImage(...)` (accepted `Buffer` / streams); `deleteFile(url)`, `deleteImage(url)`. +- **v10:** `uploadFile({ file, user? }, requestOptions?)` / `uploadImage({ file, upload_sizes?, user? }, requestOptions?)` (aliases of `uploadChannelFile` / `uploadChannelImage`; `file` is `File` / `Blob` / RN `{ uri, name?, type }` / URI `string`, no Node streams or Buffers); routes `/api/v2/chat/channels/{type}/{id}/file|image`. `deleteFile(url, requestOptions?)` / `deleteImage(url, requestOptions?)` (also `deleteChannelFile({ url })` / `deleteChannelImage({ url })`). +- **Migrate:** `channel.sendImage(file, name, type, user, { onUploadProgress, signal })` -> `channel.uploadImage({ file, user }, { onUploadProgress, signal })`. +- **Source:** `src/channel.ts:597-640` (v10); `src/channel.ts:266-316` (v9) + + + +### `channel.search` removed + +**Kind:** removed · **Impact:** medium · **PR:** #1836 + +- **v9:** `search(query: string | MessageFilters, options?)` scoped results to the channel (`filter_conditions: { cid }`), normalized sort, rejected offset plus next. +- **v10:** removed, no channel-scoped replacement. +- **Migrate:** `client.search({ payload: { filter_conditions: { cid: channel.cid }, query | message_filter_conditions, sort: [{ field, direction }] } })`. +- **Source:** `src/channel.ts:340-387` (v9) + + + +### `channel.getReplies` / `channel.getReactions` removed + +**Kind:** removed · **Impact:** medium + +- **v9:** `getReplies(parent_id, options, sort?)` fetched replies and added them to `channel.state.threads`; `getReactions(message_id, { limit, offset })`. +- **v10:** removed. `client.getReplies({ parent_id }, { limit, id_lt, sort: [...] })` and `client.getReactions({ id }, { limit, offset })` are raw requests with no state side effect. +- **Migrate:** for thread UIs use `client.threads.ensure(...)` / `thread.messagePaginator` ([threads](#threads)); for raw data use the client methods above. +- **Source:** `src/gen/chat/ChatApi.ts` (v10); `src/channel.ts:1387-1454` (v9) + + + +### `channel.lastMessage()` removed + +**Kind:** removed · **Impact:** medium + +- **v9:** `lastMessage()` returned the newest of the last 5 `latestMessages`. +- **v10:** removed. `channel.messagePaginator.lastMessage` (newest message that counts for previews, skips filtered/system per config) or `messagePaginator.headmostItem` (newest loaded, unfiltered); reactive via `messagePaginator.aggregateState`. +- **Migrate:** `channel.lastMessage()` -> `channel.messagePaginator.lastMessage ?? undefined`. +- **Source:** `src/pagination/paginators/MessagePaginator.ts:188-201`, `src/pagination/paginators/BasePaginator.ts:770-773` (v10); `src/channel.ts:1198-1211` (v9) + + + +### `getPinnedMessages` / `getMessagesById` + +**Kind:** signature · **Impact:** low + +- **v9:** `getPinnedMessages(options, sort?: PinnedMessagesSort)`, `getMessagesById(messageIds)`. +- **v10:** `getPinnedMessages({ limit?, id_lt?, ..., sort?: [{ field, direction }] }, requestOptions?)` (sort inside the request; second argument is `requestOptions`); `getMessagesById(messageIds, requestOptions?)` (wraps `getManyMessages({ ids })`). +- **Migrate:** `channel.getPinnedMessages({ limit }, { pinned_at: -1 })` -> `channel.getPinnedMessages({ limit, sort: [{ field: 'pinned_at', direction: -1 }] })`. For a reactive pinned list use `channel.pinnedMessagesPaginator` ([pinned messages](#pinned-messages-paginator)). +- **Source:** `src/gen/chat/ChannelApi.ts:347-378`, `src/channel.ts:1750-1752` (v10); `src/channel.ts:1417-1466` (v9) + + + +### `sendSharedLocation` / `stopLiveLocationSharing` + +**Kind:** signature · **Impact:** low + +- **v9:** `sendSharedLocation(location, userId?)` injected `user: { id: userId }`; `stopLiveLocationSharing(payload: UpdateLocationPayload)` spread the whole payload into `client.updateLocation` with `end_at` as ISO string. +- **v10:** `sendSharedLocation(location: SharedLocation & { message_id? })` (no user injection; queued offline only when `message_id` is set); `stopLiveLocationSharing(payload: UpdateLiveLocationRequest, requestOptions?)` sends only `{ latitude, longitude, message_id, end_at: new Date() }` via `client.updateLiveLocation`; both throw for id-less channels. +- **Migrate:** drop the `userId` argument (server-side only). +- **Source:** `src/channel.ts:762-806` (v10); `src/channel.ts:724-760` (v9) + + + +### `keystroke` / `stopTyping` / `sendAction` and typing gating + +**Kind:** signature · **Impact:** low + +- **v9:** `keystroke(parent_id?, { user_id }?)`, `stopTyping(parent_id?, { user_id }?)`; typing gated on `getConfig()?.typing_events` (no typing events before the channel was queried); `sendAction(messageID, formData)` required initialization and threw on empty id. +- **v10:** `keystroke(parentId?, requestOptions?)`, `stopTyping(parentId?, requestOptions?)`; gated on resolved `channel.config.typingEvents.enabled` (server flag ANDed with client config; unknown server config counts as enabled), `channel.isProvisional` and socket health. `sendAction(messageId, formData, requestOptions?)` requires only an id and calls `client.runMessageAction`. +- **Migrate:** drop `{ user_id }`. To disable typing client-side: `client.config.setConfig('channel', { typingEvents: { enabled: false } })`. +- **Source:** `src/channel.ts:1116-1160,1251-1279` (v10); `src/channel.ts:1086-1196` (v9) + + + +### Draft methods + +**Kind:** signature · **Impact:** medium + +- **v9:** `createDraft(message: DraftMessagePayload)`, `deleteDraft({ parent_id }?)`, `getDraft({ parent_id }?)`, internal `_createDraft(message)`, `_deleteDraft({ parent_id })`. +- **v10:** `createDraft({ message: MessageRequest }, requestOptions?)`, `deleteDraft({ parent_id }?, requestOptions?)`, `getDraft({ parent_id }?, requestOptions?)` (inherited); `_createDraft` / `_deleteDraft` take the same request shapes; `createDraft` / `deleteDraft` require an id; the offline queue payload is the argument list `[{ message }, requestOptions?]` (a passed `requestOptions`, including an `AbortSignal`, is persisted into the pending task). The composer side skips provisional channels ([composer drafts](#composer-drafts)). +- **Migrate:** `channel.createDraft({ text })` -> `channel.createDraft({ message: { text } })`. +- **Source:** `src/channel.ts:2165-2215`, `src/gen/chat/ChannelApi.ts:143-193` (v10); `src/channel.ts:1817-1930` (v9) + + + +### `vote` / `removeVote` + +**Kind:** signature · **Impact:** low + +- **v9:** `vote(messageId, pollId, vote)`, `removeVote(messageId, pollId, voteId)`. +- **v10:** `vote({ message_id, poll_id }, { vote: { option_id?, answer_text? } }, requestOptions?)`, `removeVote({ message_id, poll_id, vote_id }, _request?, requestOptions?)`; both require an id. +- **Migrate:** wrap positional arguments into the path object and `{ vote }`. +- **Source:** `src/channel.ts:2155-2163` (v10); `src/channel.ts:1802-1815` (v9) + + + +### `update` / `_update` / slow mode / `updatePartial` + +**Kind:** signature · **Impact:** medium + +- **v9:** `update(channelData?, updateMessage?, options?)` stripped reserved keys (`cid`, `id`, `config`, `created_at`, `member_count`, `own_capabilities`, ...) then called `_update(payload)`; `enableSlowMode(n)`, `disableSlowMode()`; `updatePartial(update: PartialUpdateChannel)`. +- **v10:** `update({ data?, message?, skip_push?, hide_history?, hide_history_before?: Date, cooldown?, add_members?, ... }, requestOptions?)` (no reserved-key stripping; `data` is `ChannelInputRequest`); sets `channel.data` from the response. `_update` removed. `enableSlowMode(n, requestOptions?)`, `disableSlowMode(_request?, requestOptions?)`. `updatePartial({ set?, unset? }, requestOptions?)` (wraps generated `updateChannelPartial`) returns `StreamResponse` and dispatches `capabilities.changed` only when the response carries `own_capabilities`. +- **Migrate:** `channel.update({ name }, { text })` -> `channel.update({ data: { custom: { name } }, message: { text } })`; `_update(payload)` -> `update(payload)`. +- **Careful:** `update` is a full update, so `data.custom` replaces the channel's entire custom data; the mapping above drops every other custom field (as v9's `update({ name })` did). To change one field, spread the current values (`update({ data: { custom: { ...channel.data?.custom, name } } })`) or use `channel.updatePartial({ set: { name } })`. +- **Source:** `src/channel.ts:693-760` (v10); `src/channel.ts:623-722,936-950` (v9) + + + +### `delete` / `truncate` + +**Kind:** signature · **Impact:** low + +- **v9:** `delete({ hard_delete? })`, `truncate(options?: TruncateOptions)` (`truncated_at?: Date`, `user?`, `user_id?`). +- **v10:** `delete({ hard_delete? }?, requestOptions?)` (inherited, same flag); `truncate({ hard_delete?, skip_push?, truncated_at?: Date, member_ids?, message? }?, requestOptions?)`; `TruncateChannelRequest` has no `user_id`; both throw synchronously for id-less channels. +- **Migrate:** drop `user_id` / `user`. +- **Source:** `src/gen/chat/ChannelApi.ts:48-62,447-462`, `src/gen/models/index.ts:10402` (v10); `src/channel.ts:762-785` (v9) + + + +### Membership helpers; `addModerators` / `demoteModerators` / `assignRoles` removed + +**Kind:** removed · **Impact:** medium · **PR:** #1849 + +- **v9:** `addMembers(members, message?, options?)`, `addFilterTags`, `removeFilterTags`, `inviteMembers`, `removeMembers`, `acceptInvite(options?)`, `rejectInvite(options?)`, `addModerators(members, message?, options?)`, `demoteModerators(...)`, `assignRoles(roles, message?, options?)`; `message: Message`, `options: { hide_history?, hide_history_before?, skip_push? }`. +- **v10:** helpers keep their positional shape plus `requestOptions?`; `message` is `MessageRequest`, `options` is `ChannelUpdateOptions = Omit`; `addMembers` / `inviteMembers` map string ids to `{ user_id }`. `acceptInvite` / `rejectInvite(options?: UpdateChannelRequest, requestOptions?)`. `addModerators`, `demoteModerators`, `assignRoles` removed (the client-side `UpdateChannelRequest` has no `add_moderators` / `demote_moderators` / `assign_roles`). +- **Migrate:** moderator promotion and role assignment move to the server-side node SDK; at insert time use `channel.addMembers([{ user_id, channel_role: 'channel_moderator' }])`. +- **Source:** `src/channel.ts:816-958` (v10); `src/channel.ts:787-935` (v9) + + + +### `updateMemberPartial`; `partialUpdateMember` removed + +**Kind:** signature · **Impact:** low + +- **v9:** `updateMemberPartial(updates, { userId? })` (could target another user via the `user_id` query param); `partialUpdateMember(user_id, updates)` (deprecated) targeted any member. +- **v10:** inherited `updateMemberPartial({ set?, unset? }?, requestOptions?)` updates the connected user's own membership only (`UpdateMemberPartialRequest` has only `set` / `unset`); `partialUpdateMember` removed. +- **Migrate:** `channel.updateMemberPartial({ set }, { userId })` / `partialUpdateMember(id, { set })` -> `channel.updateMemberPartial({ set })` for the current user; other users' memberships are server-side only. +- **Source:** `src/gen/chat/ChannelApi.ts:296-312`, `src/gen/models/index.ts:10757` (v10); `src/channel.ts:429-471` (v9) + + + +### `archive` / `unarchive` / `pin` / `unpin` + +**Kind:** signature · **Impact:** medium + +- **v9:** `archive({ user_id? })` etc.; resolved to `resp.channel_member`. +- **v10:** `archive(_request?, requestOptions?)` etc.; act on the connected user only; resolve to the full `updateMemberPartial` response (`{ channel_member?, duration }`). +- **Migrate:** `const member = await channel.archive()` -> `const { channel_member } = await channel.archive()`; pass `undefined` first to reach `requestOptions`. +- **Source:** `src/channel.ts:1029-1082` (v10); `src/channel.ts:997-1075` (v9) + + + +### `mute` / `unmute` / `muteStatus` + +**Kind:** signature · **Impact:** low + +- **v9:** `mute({ expiration?, user_id? })`, `unmute({ user_id? })` (sent `channel_cid`); `muteStatus()` required initialization and returned `{ muted, createdAt: Date | null, expiresAt: Date | null }`. +- **v10:** `mute({ expiration?, channel_cids? }?, requestOptions?)`, `unmute(...)` (send `channel_cids: [cid]`, require an id; `MuteChannelRequest` has no `user`); `muteStatus()` requires only an id and returns `ChannelMuteStatus` with `TimestampNS | null` dates. Reactive copy at the `channel.state` slice `muteStatus`, kept in sync on `notification.channel_mutes_updated` / `health.check` (internal `_syncMuteStatus`). +- **Migrate:** drop `user_id`; convert timestamps with `nsToDate`; replace `notification.channel_mutes_updated` listeners with a `channel.state` selector on `muteStatus`. +- **Source:** `src/channel.ts:977-1015,1090-1114`, `src/channel_state.ts:111-127` (v10); `src/channel.ts:957-995,1077-1084` (v9) + + + +### `hide` / `show` + +**Kind:** signature · **Impact:** low + +- **v9:** `hide(userId = null, clearHistory = false)`, `show(userId = null)`; both required initialization. +- **v10:** `hide({ clear_history? }?, requestOptions?)`, `show({}?, requestOptions?)`; no initialization requirement; the requests have no `user_id`. +- **Migrate:** `channel.hide(null, true)` -> `channel.hide({ clear_history: true })`. +- **Source:** `src/channel.ts:2113-2127`, `src/gen/models/index.ts:3960,9942` (v10); `src/channel.ts:1728-1755` (v9) + + + +### `banUser` / `unbanUser`; `shadowBan` / `removeShadowBan` removed + +**Kind:** signature · **Impact:** medium + +- **v9:** `banUser(targetUserID, options: BanUserOptions)` (v1 `/moderation/ban` with `type` / `id`), `unbanUser(targetUserID, options?)`, `shadowBan(targetUserID, options)`, `removeShadowBan(targetUserID)`; all required initialization. +- **v10:** `banUser(targetUserId, options: Omit)` goes through `client.moderation.ban({ ...options, target_user_id, channel_cid })` (V2; returns `StreamResponse`; `delete_reactions`, `ban_from_future_channels` follow `BanRequest`); `unbanUser(targetUserId, options?)` keeps the v1 call. Both require only an id. `shadowBan` / `removeShadowBan` removed. +- **Migrate:** `channel.shadowBan(id, o)` -> `channel.banUser(id, { ...o, shadow: true })`; `channel.removeShadowBan(id)` -> `channel.unbanUser(id, { shadow: true })`. +- **Source:** `src/channel.ts:2094-2144`, `src/types.ts:97,139-147` (v10); `src/channel.ts:1711-1800` (v9) + + + +### `queryMembers` + +**Kind:** signature · **Impact:** medium + +- **v9:** `queryMembers(filterConditions, sort?, options?, requestOptions?)` (sort normalized from `{ field: dir }`). +- **v10:** `queryMembers({ payload?: Partial }?, requestOptions?)`; `type` / `id` filled in; an id-less (members-only) channel now rejects with `Channel isn't yet created...` because `_checkHasId()` runs first (v9 sent `data.members` instead; the v10 `members` branch is dead code, tracked in v10-followups.md); sort is `[{ field, direction }]`; date bounds are `TimestampNS` ([request dates](#request-dates)). +- **Migrate:** `channel.queryMembers(f, s, o)` -> `channel.queryMembers({ payload: { filter_conditions: f, sort: [...], ...o } })`. To query members of a distinct channel before it is created, call `watch()` / `create()` first (or `client.queryMembers({ payload: { type, members, ... } })`). +- **Source:** `src/channel.ts:652-682` (guard `:656`, `members` branch `:665`) (v10); `src/channel.ts:390-427` (v9) + + + +### `markRead` semantic swap; `markAsReadRequest` removed; `markReadViaReporter` + +**Kind:** behavior · **Impact:** high + +- **v9:** `markRead(data?)` called `client.messageDeliveryReporter.markRead(channel)` (neither batched nor throttled; only `throttledMarkRead` throttles), which called `markAsReadRequest` and on success dropped the channel's delivery candidate; `markAsReadRequest(data?)` was the direct POST. Both returned `Promise`. +- **v10:** `markRead(request?: MarkReadRequest, requestOptions?)` is the direct request (no reporter, no delivery-candidate cleanup, no `requestHandlers.markReadRequest`, no local-unread fallback); returns `StreamResponse` (`event?` optional). `markReadViaReporter(data?)` replaces the v9 `markRead` path (reporter, drops the delivery candidate) and adds two v10-only behaviours: the `requestHandlers.markReadRequest` hook and the `markReadLocally()` fallback when read events are off and `localUnreadCountEnabled`; returns `Partial> | null`. `markAsReadRequest` removed. Same name, different behaviour: code keeping `markRead()` silently loses candidate cleanup (and bypasses the new hook and fallback). Reporter details: [delivery reporter](#delivery-reporter-mark-read). +- **Migrate:** v9 `markRead()` -> `markReadViaReporter()`; v9 `markAsReadRequest()` -> `markRead()`. Narrow `response?.event`. `MarkReadOptions` -> `MarkReadRequest` (`{ message_id?, thread_id? }`). +- **Source:** `src/channel.ts:1290-1313`, `src/messageDelivery/MessageDeliveryReporter.ts:398-436` (v10); `src/channel.ts:1220-1254`, `src/messageDelivery/MessageDeliveryReporter.ts:281-298` (v9) + + + +### `markRead` / `markUnread` throw when read events are off + +**Kind:** behavior · **Impact:** medium + +- **v9:** `markAsReadRequest` / `markUnread(data: MarkUnreadOptions)` resolved `null` without a request when the server config lacked `read_events`, when the config was not loaded yet, or when `own_capabilities` lacked `read-events`; server-side auth bypassed all gates. +- **v10:** both `throw new Error("Read events are disabled ...")` when the resolved `channel.config.readEvents.enabled` is false (server `read_events` ANDed with client `channel.readEvents.enabled`); an unknown server config counts as enabled; the `read-events` own-capability check is gone (the request is sent; the remaining capability checks elsewhere are tracked in v10-followups.md); no server-auth bypass; `_checkInitialized` still applies. `markUnread(request?: MarkUnreadRequest, requestOptions?)` (argument optional; `message_timestamp` is `Date`). Through the reporter, `markReadViaReporter()` therefore rejects for read-events-disabled channels unless a handler or `localUnreadCountEnabled` is configured. +- **Migrate:** replace `if (res === null)` checks with `try/catch`, or check `channel.config.readEvents.enabled` first. +- **Source:** `src/channel.ts:1303-1313,1506-1516,439-449` (v10); `src/channel.ts:1231-1281` (v9) + + + +### `markReadLocally` / local unread count + +**Kind:** behavior · **Impact:** low · **PR:** #1886 + +- **v9:** enabled by client option `isLocalUnreadCountEnabled`; dispatched `message.read_locally` with `created_at` ISO string and `last_read_message_id` from `lastMessage()`. +- **v10:** enabled per channel config `client.config.setConfig('channel', { readEvents: { localUnreadCountEnabled: true } })` (applies only when `readEvents.enabled` is false); event `created_at` is `nowNs()` (`TimestampNS`), `last_read_message_id` from `messagePaginator.headmostItem`; returns the typed event. +- **Migrate:** move the option to the channel config; treat `created_at` as `TimestampNS`. +- **Source:** `src/channel.ts:1529-1546,143` (v10); `src/channel.ts:1283-1303` (v9) + + + +### `lastRead` / `countUnread` / `countUnreadMentions` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `lastRead(): Date | null | undefined`; `countUnread(lastRead?: Date | null)` iterated `state.latestMessages`. +- **v10:** `lastRead(): TimestampNS | null | undefined`; `countUnread(lastRead?: TimestampNS | null)` (nullish check, so `0` is a valid timestamp) and `countUnreadMentions()` iterate `messagePaginator.headItems`; `countUnread()` without an argument returns the derived `state.unreadCount` ([ChannelState](#channel-state-store)). +- **Migrate:** see [TimestampNS](#timestamp-ns). +- **Source:** `src/channel.ts:1760-1831` (v10); `src/channel.ts:1469-1548` (v9) + + + +### `getConfig()` removed; `ChannelConfig` changes meaning + +**Kind:** renamed · **Impact:** high · **PR:** #1831 + +- **v9:** `channel.getConfig()` returned `client.configs[cid]` (`ChannelConfigWithInfo`). Root type `ChannelConfig = ChannelConfigFields & CreatedAtUpdatedAt & { commands? }` was the server channel-type config (`read_events`, `typing_events`, `uploads`, `replies`, `reactions`, `search`, `mutes`, `quotes`, `polls`, `reminders`, `shared_locations`, `url_enrichment`, `custom_events`, `connect_events`, `delivery_events`, `user_message_reminders`, `count_messages`, `mark_messages_pending`, `skip_last_msg_update_for_system_msgs`, `push_level`, `push_notifications`, `max_message_length`, `message_retention`, `automod`, `automod_behavior`, `automod_thresholds`, `blocklist_behavior`, `name`, `created_at`, `updated_at`). +- **v10:** `channel.serverConfig` getter (same server value, `undefined` until queried; cannot be mocked with `vi.fn()`). New `channel.config` (resolved, frozen) / `channel.configState` (store) of type `ChannelConfig`, which now means the resolved instance config `{ requestHandlers?, typingEvents: { enabled }, readEvents: { enabled, localUnreadCountEnabled }, replies: { enabled }, userMessageReminders: { enabled }, deliveryEvents: { enabled }, availableCommands }`; `initializeConfig` resets it. Default `DEFAULT_CHANNEL_CONFIG`. Code typed with v9 `ChannelConfig` still compiles where only shared keys are read, but `config.read_events`, `config.commands` etc. no longer exist on it. +- **Migrate:** `channel.getConfig()?.uploads` -> `channel.serverConfig?.uploads`; v9 `ChannelConfig` type -> `ChannelConfigWithInfo`; feature gating -> `channel.config.readEvents.enabled` etc.; `commands` -> `channel.config.availableCommands`. +- **Source:** `src/channel.ts:116-175,215-222,526-561` (v10); `src/channel.ts:199-202`, `src/types.ts:2634-2679` (v9) + + + +### `channel.listeners` is a Map of Sets; `on` / `off` + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `listeners: { [key: string]: (string | EventHandler)[] }`; `on(eventType: EventTypes, cb)`; registering the same callback twice called it twice. +- **v10:** `listeners: Map>`; `on(eventType: T, callback: EventHandler)`, `off(eventType, callback)`; duplicate registrations dedupe and one `unsubscribe` removes them; empty sets are deleted. +- **Migrate:** `channel.listeners = {}` -> `channel.listeners.clear()`; `channel.listeners['x'].length` -> `channel.listeners.get('x')?.size`; `EventTypes` -> `EventType` ([event names](#event-type-names)). +- **Source:** `src/channel.ts:232,2234-2297` (v10); `src/channel.ts:98,1932-1995` (v9) + + + +### Channel event handling changes + +**Kind:** behavior · **Impact:** medium + +- **v9:** `_handleChannelEvent` mutated `channel.state` fields in place and wrote messages to message sets. +- **v10:** state is written through `channel.state` (store), the paginators and `client.messageStore`. Observable differences: + + | Event | v9 | v10 | + | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `message.new` / `notification.message_new` | `data.hidden` untouched | sets `channel.data.hidden = false` | + | `message.new` (channel message) | pushed to the list only when `isUpToDate`; own unread bumped for every countable message | always ingested (lands in the head interval when an older window is shown); own unread and unread snapshot not bumped while `messagePaginator.isViewingLive`; own read row seeded (`last_read: 0`) when missing | + | `message.new` / `message.deleted` thread-only reply | stored in `state.threads` | ignored by the channel (owned by `Thread` via `client.threads`) | + | `message.updated` / `message.undeleted` with `parent_id` | applied to `threads` and (if shown) the channel list | ignored by the channel, including `show_in_channel` replies (tracked in v10-followups.md) | + | `message.deleted` soft | message replaced in sets | written to `client.messageStore` only if held | + | `message.read`, `message.read_locally` | read row reset, delivery untouched | also advances `last_delivered_at` / `last_delivered_message_id`, clears `first_unread_message_id` | + | `notification.mark_read` | not handled | handled like `message.read` unless it carries `thread_id` | + | `message.delivered` | overwrote delivery (could move back) | monotonic; new rows get `last_read = created_at` | + | `reaction.*` | replaced `event.message` with the state-merged message (own_reactions recomputed) before listeners | `event.message` left as received; the store copy is upserted only if held | + | `typing.*` | `state.typing[id] = event` | `state.setTypingEvent` / `removeTypingEvent` (mirrored to `messageComposer.textComposer.typing`) | + | `ai_indicator.*` | none | `state.aiState` | + | `channel.updated` | carried over `hidden`, `own_capabilities` | also carries over `member_count` | + | `channel.truncated` | `channel.data` untouched | `event.channel` merged into `channel.data` (so `truncated_at`, `truncated_by`, `updated_at`, ... land on `channel.data`, `channel.state.data` and `memberCount`) | + | `user.presence.changed` (client-level) | replaced the user in `members`, `watchers` and `read` (mutated in place) | replaces it in `members`, `watchers` and own `membership` (new objects published); `read[id].user` is refreshed only on `user.updated`, so read rows keep the old `online` / `last_active` | + +- **Migrate:** listeners that relied on `event.message.own_reactions` being recomputed must read the message from `channel.messagePaginator.getItem(id)`; thread UIs must hold a `Thread`. +- **Source:** `src/channel.ts:2377-2870` (`channel.truncated` `:2689-2690`), `src/client.ts:1096-1126,1244-1247` (v10); `src/channel.ts:1997-2367` (`channel.truncated` `:2183-2214`), `src/client.ts:1508-1525,1631-1632` (v9) + + + +### `user.messages.deleted` with `hard_delete` removes messages + +**Kind:** behavior · **Impact:** medium + +- **v9:** hard and soft delete both kept the user's messages, converted to `type: 'deleted'` (hard also stripped content), across all message sets, `state.threads` and `pinnedMessages`. +- **v10:** hard delete removes the messages from `messagePaginator` and `pinnedMessagesPaginator`; soft delete tombstones; only the loaded active window (`paginator.items`) is processed; thread replies are handled by `Thread.handleUserMessagesDeleted`. `message.deleted` with `hard_delete` keeps removing (as in v9). `messageOperations.delete({ localMessage, options: { hard: true } })` removes optimistically. `channel.delete({ hard_delete })` is unchanged. +- **Migrate:** UIs must not expect a "deleted" placeholder after a hard user-messages delete. +- **Source:** `src/channel.ts:2547-2577`, `src/pagination/paginators/MessageIntervalPaginator.ts:1405-1452` (v10); `src/channel.ts:2096-2120`, `src/channel_state.ts:776-800` (v9) + + + +## 7. `ChannelState` and message paginators + + + +### `ChannelState` is a `StateStore` + +**Kind:** type-shape · **Impact:** high · **PR:** #1834 + +- **v9:** plain class with writable fields `watcher_count`, `typing`, `read`, `watchers`, `members`, `membership`, `unreadCount` (own counter, assignable, separate from `read[me].unread_messages`). +- **v10:** `class ChannelState extends StateStore` (from `@stream-io/state-store`: `getLatestValue`, `subscribe`, `subscribeWithSelector`, `next`, `partialNext`, `addPreprocessor`, `merge`; protected `value`, `handlers`, `preprocessors`). Flat slices: `watcherCount`, `watchers`, `watchStatus`, `typing`, `read`, `members`, `memberCount`, `membership`, `ownCapabilities`, `data`, `muteStatus`, `initialized`, `offlineMode`, `pendingDisposal`, `supersededBy`, `active`, `aiState`. `members` / `read` / `watchers` / `typing` / `membership` / `watcher_count` / new `member_count` are getter/setter proxies: assigning a new object publishes, in-place mutation (`state.read[uid] = ...`, `state.members[uid].x = ...`) does not notify subscribers. `unreadCount` is a getter only (`read[ownUserId].unread_messages ?? 0`): assigning throws in strict mode. `read[ownUserId].unread_messages` (and so `unreadCount`) is no longer bumped by muted/silent/shadowed messages (v9 bumped the read row for all of them; only its separate `unreadCount` skipped them) or by messages arriving while `isViewingLive`. The `typing` getter reads `messageComposer.textComposer.typing` (still `Record`); `membership` initializes to `{}`. New `setTypingEvent`, `removeTypingEvent`, `resetAIState`. Read timestamps are `TimestampNS`. `ChannelState.formatMessage` remains, but now returns the v10 `LocalMessage` (dates stay `TimestampNS`, see [formatMessage](#format-message)) and accepts only `MessageResponse | LocalMessage` (v9 also accepted `MessageResponseBase`). +- **Migrate:** subscribe with `channel.state.subscribeWithSelector((s) => ({ read: s.read }), ...)` (React: `useStateStore(channel.state, selector)`); replace in-place writes with whole-object assignment or `partialNext`; never assign `unreadCount` (use `markRead` / `markReadLocally`). +- **Source:** `src/channel_state.ts:201-374` (`formatMessage` `:338-344`) (v10); `src/channel_state.ts:70-122` (v9) + + + +### Message storage moved from `ChannelState` to `channel.messagePaginator` + +**Kind:** removed · **Impact:** high + +- **v9:** `ChannelState` owned the message list: `messages`, `latestMessages`, `messageSets`, `messagePagination`, `last_message_at`, `mutedUsers` (never populated) and `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `removeMessageFromArray`, `findMessage`, `findMessageByTimestamp`, `loadMessageIntoState`, `pruneOldest`, `initMessages`, `clearMessages`, `filterErrorMessages`, `addReaction`, `removeReaction`, `_addReactionToState`, `_addOwnReactionToMessage`, `_removeOwnReactionFromMessage`, `_removeReactionFromState`, `_updateQuotedMessageReferences`, `removeQuotedMessageReferences`, `_updateMessage`, `_addToMessageList`, `updateUserMessages`, `deleteUserMessages`. +- **v10:** all removed. `channel.messagePaginator` (`MessagePaginator`, interval-based, backed by the shared `client.messageStore`; window publishes are throttled in production): + + | v9 | v10 | + | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | + | `state.messages` | `messagePaginator.items` / `.state` `items` | + | `state.latestMessages` | `messagePaginator.headItems` | + | `state.messagePagination.hasPrev` / `hasNext` | `messagePaginator.state` `hasMoreTail` (older) / `hasMoreHead` (newer) | + | `state.last_message_at` | `messagePaginator.lastMessageAt` (`TimestampNS`, derived; reactive via `aggregateState`) | + | `addMessageSorted(m)` / `addMessagesSorted(ms)` | `messagePaginator.ingestItem(m)` / `ingestPage(...)` | + | `removeMessage({ id })` | `messagePaginator.removeItem({ id })` | + | `findMessage(id)` / `findMessageByTimestamp(ts)` | `messagePaginator.getItem(id)` / `findItemByTimestamp(ts)` | + | `loadMessageIntoState(id)` | `messagePaginator.jumpToMessage(id)`; `jumpToTheLatestMessage()`, `jumpToTheFirstUnreadMessage()` | + | `pruneOldest(n)` | `client.config.setConfig('messagePaginator', { maxLoadedItems })` | + | `clearMessages()` / `initMessages()` | `messagePaginator.clearStateAndCache()` | + | `addReaction` / `removeReaction` | `channel.messageOperations.addReaction` / `deleteReaction`, or WS events | + | `filterErrorMessages`, `updateUserMessages`, `deleteUserMessages`, quoted-reference helpers | handled internally (`reflectUserUpdate`, `applyMessageDeletionForUser`, `reflectQuotedMessageUpdate`) | + | `mutedUsers` | `client.mutedUsersStore` ([mutes](#client-user-listeners-mutes)) | + +- **Migrate:** read and write the message list only through `channel.messagePaginator`; paginate with `toTail()` / `toHead()`. `MessageSet` type removed ([rename table](#type-rename-table)). +- **Source:** `src/pagination/paginators/MessagePaginator.ts:109-563`, `src/pagination/paginators/MessageIntervalPaginator.ts:521-1543` (v10); `src/channel_state.ts:75-1008` (v9) + + + +### `pinnedMessages` -> `channel.pinnedMessagesPaginator` + +**Kind:** removed · **Impact:** medium + +- **v9:** `state.pinnedMessages` array maintained by `addPinnedMessages` / `addPinnedMessage` / `removePinnedMessage`. +- **v10:** `channel.pinnedMessagesPaginator` (`PinnedMessagePaginator`): seeded from `pinned_messages` on every query, auto-adds/removes on message events by `pinned`, pruned on `channel.truncated`, cleared on full truncate / `channel.hidden` with `clear_history`. +- **Migrate:** `channel.state.pinnedMessages` -> `channel.pinnedMessagesPaginator.items`. +- **Source:** `src/pagination/paginators/PinnedMessagePaginator.ts:54-154`, `src/channel.ts:2935-2938` (v10); `src/channel_state.ts:75,322-352` (v9) + + + +### `channel.state.threads` removed + +**Kind:** removed · **Impact:** medium + +- **v9:** `channel.state.threads[parent_id]` held replies, fed by `message.new` / `updated` / `deleted`, reactions, `getReplies()`. +- **v10:** removed; replies live only in `Thread` instances held by `client.threads` (`client.threads.ensure({ channel, parentMessage })`, `thread.messagePaginator`). +- **Migrate:** `channel.state.threads[id]` -> `client.threads.get(id)?.messagePaginator.items`. +- **Source:** `src/channel.ts:2543-2546,2584-2587,2680` (v10); `src/channel_state.ts:77` (v9) + + + +### `isUpToDate` / `setIsUpToDate` removed + +**Kind:** removed · **Impact:** medium + +- **v9:** the UI set `state.setIsUpToDate(false)` while showing an older window so `message.new` was not pushed; `_disconnect()` and reconnect set it false. +- **v10:** removed. New messages always land in the head interval without disturbing the active window; use `messagePaginator.isActiveIntervalAtHead`, `hasMoreHead`, `jumpToTheLatestMessage()`; tell the SDK the user sees the latest messages with `messagePaginator.setViewingLive(true)` (suppresses the own unread bump). Reconnect state is `watchStatus: WasWatching`. +- **Migrate:** delete `setIsUpToDate` calls; drive the "jump to latest" affordance from `isActiveIntervalAtHead`. +- **Source:** `src/pagination/paginators/MessagePaginator.ts:540-546`, `src/channel.ts:2588-2611` (v10); `src/channel_state.ts:90,666-677`, `src/channel.ts:2128-2133` (v9) + + + +## 8. `ChannelManager`, channel list and `activeChannels` + + + +### `client.activeChannels` / `client.configs` -> the channel store + +**Kind:** removed · **Impact:** high · **PR:** #1901, #1813 + +- **v9:** `client.activeChannels: Record`, writable; `client.configs: Record`; `client.createChannelManager({ eventHandlerOverrides, options, queryChannelsOverride })`. `client.channel()` deduped through `activeChannels` (a stored channel got the passed `data` merged into `data` / `_data`); members-only channels were keyed by temp cid until queried; `_disconnect()`ed channels were skipped and replaced. +- **v10:** `client.channelManager` is instantiated with the client and is the store (an `EntityStore`; `channelStore` is `@internal`): `get(cid)`, `values()`, `ensure({ type, id?, data? })`, `releaseUnusedChannels()`, paginators. `createChannelManager` removed. `client.channelServerConfigs[cid]` (same cid key space, `@internal`) and reactive `client.channelServerConfigsStore` replace `configs`; read per channel via `channel.serverConfig`. Assigning into a plain object has no effect. For a stored channel `ensure` / `client.channel()` apply only `data.custom`. A `pendingDisposal` channel stays stored until `releaseUnusedChannels()`, but `ensure()` replaces it with a fresh instance. Channels are never evicted automatically: `releaseUnusedChannels()` removes and disconnects disposed channels and any channel that is not watched / `WasWatching`, not active, not loading, and not held by a list, thread, composer cache or active channel search (`getChannelUsage` reports holders). A members-only channel whose server cid is already stored is superseded (`supersedeChannel`): the stored instance stays, takes the response, local messages and (when the new one is active) its composition; the new one gets `supersededBy` and `watchStatus: NotWatching`, and receives no events. Store internals touched: `changeChannelId`, `clearChannels`, `getOrCreateChannel`, `removeChannel`, `resolveListedChannel`. +- **Migrate:** `client.activeChannels[cid]` -> `client.channelManager.get(cid)`; `Object.values(client.activeChannels)` -> `client.channelManager.values()`; `client.configs[cid]` -> `channel.serverConfig`; channel lists -> `ChannelPaginator` + `client.channelManager.insertPaginator(...)`. Call `releaseUnusedChannels()` after closing screens that held channels you no longer need; `channel.activate()` channels you keep using; switch UIs to `channel.supersededBy` when set. +- **Source:** `src/ChannelManager.ts:370-750`, `src/channel.ts:1857-1895`, `src/client.ts:164,232,380,476-483,1822-1830` (v10); `src/client.ts:345,364,817-835,2600-2604,2660-2762` (v9) + + + +### `ChannelManager` rewritten and owned by the client + +**Kind:** type-shape · **Impact:** high · **PR:** #1813 + +- **v9:** one list per manager; `new ChannelManager({ client, eventHandlerOverrides?, options?, queryChannelsOverride? })` or `client.createChannelManager(...)`; any number per client; `readonly state: StateStore<{ channels, initialized, pagination, error }>` (`ChannelManagerState`). The class carried the JSDoc `tags` `@internal` but was exported and used by the UI SDKs (v10 drops the tag). +- **v10:** a different class (former rc `ChannelPaginatorsOrchestrator`) holding N `ChannelPaginator` lists plus the client's channel store. The client builds exactly one, `client.channelManager`; ctor `({ client, paginators?, eventHandlers?, ownershipResolver? })`; `state: StateStore<{ paginators: ChannelPaginator[] }>`; `getPaginatorById`, `insertPaginator`, `removePaginator`, `setPaginators`, `clearPaginators`. List state lives on each `paginator.state`. Statics `getDefaultHandlers()`, `defaultEventHandlers` (protected). +- **Migrate:** use `client.channelManager`; create one `new ChannelPaginator({ client, filters, sort, id, paginatorOptions })` per list and `client.channelManager.insertPaginator({ paginator })` / `removePaginator(id)` / `setPaginators([...])`. Read list state from `paginator.state`: + + | v9 `manager.state` | v10 | + | --------------------------------- | ----------------------------------------------------------------- | + | `channels` | `paginator.state.items` (`undefined` = never queried) | + | `initialized` | `paginator.state.items !== undefined` / `paginator.isInitialized` | + | `pagination.hasNext` | `paginator.state.hasMoreTail` | + | `pagination.isLoading` | `paginator.state.isLoading` | + | `pagination.isLoadingNext` | none (`isLoading && items.length > 0`) | + | `pagination.filters/sort/options` | `paginator.staticFilters` / `sort` / `options` | + | `error` | `paginator.state.lastQueryError` | + +- **Source:** `src/ChannelManager.ts:322-457`, `src/client.ts:164,380` (v10); `src/channel_manager.ts:32-54,215-271` (v9) + +```ts +// v10 +const paginator = new ChannelPaginator({ + client, + id: 'main', + filters: { members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], +}); +client.channelManager.insertPaginator({ paginator }); +const unsubscribe = client.channelManager.registerSubscriptions(); +await paginator.toTail(); +``` + + + +### `ChannelManager` list methods removed + +**Kind:** removed · **Impact:** high + +- **v9:** `queryChannels(filters, sort?, options?, stateOptions?)`, `loadNext()`, `setChannels(valueOrFactory)`, `setQueryChannelsRequest(fn)`, `setOptions(options)`, `setEventHandlerOverrides(overrides)`. +- **v10:** removed from the manager. + + | v9 | v10 | + | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `manager.queryChannels(f, s, o)` | `paginator.toTail({ reset: 'yes' })` or `paginator.reload()` | + | `manager.loadNext()` | `paginator.toTail()` / `toTailDebounced()` | + | `manager.setChannels(v)` | `paginator.setItems({ valueOrFactory: v })` | + | `manager.setQueryChannelsRequest(fn)` | `paginatorOptions.doRequest` (`paginator.updateConfig({ doRequest })`) | + | `manager.setOptions(o)` | per-paginator config (`updateConfig({ lockItemOrder })`) | + | `manager.setEventHandlerOverrides(o)` | `addEventHandler` / `setEventHandlers` / `removeEventHandlers` ([event handlers](#channel-manager-event-handlers)) | + | `queryChannels` `options.limit` | `new ChannelPaginator({ paginatorOptions: { pageSize } })` (default `10`); `ChannelPaginatorRequestOptions` omits `limit` and `offset`, and the remaining request options (`state`, `watch`, `presence`, `message_limit`, `member_limit`, ...) go to `requestOptions` | + +- **Migrate:** see table. +- **Source:** `src/ChannelManager.ts:968-1131`, `src/pagination/paginators/BasePaginator.ts:343,385,2848,3483-3531` (v10); `src/channel_manager.ts:273-544` (v9) + + + +### `ChannelManagerOptions` replaced + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `ChannelManagerOptions = { abortInFlightQuery?, allowNotLoadedChannelPromotionForEvent?, lockChannelOrder? }`. +- **v10:** `ChannelManagerOptions = { client, paginators?, eventHandlers?, ownershipResolver? }` (constructor options). `lockChannelOrder` -> per-paginator `lockItemOrder` (keeps existing items in place for every event, not just `message.new` / `member.updated`); `abortInFlightQuery` removed (a paginator never starts a query while loading; `paginator.cancelScheduledQuery()`); `allowNotLoadedChannelPromotionForEvent` removed (the default still admits unknown channels; opt out per event with `addEventHandler({ eventType, handle: ignoreEventsForUnknownChannels, id, index: 0 })`). +- **Migrate:** see mapping. +- **Source:** `src/ChannelManager.ts:120-125,330-348`, `src/pagination/paginators/BasePaginator.ts:409,469` (v10); `src/channel_manager.ts:115-157` (v9) + + + +### Event handler overrides -> `EventHandlerPipeline`; manager types and constants removed + +**Kind:** removed · **Impact:** medium + +- **v9:** overrides keyed by handler name (`newMessageHandler`, `channelDeletedHandler`, ... 10 names), each `(setChannels, event)`; an override fully replaced the default; only the 10 mapped events were listened to. Exported: `ChannelManagerEventHandlerOverrides`, `ChannelManagerEventHandlerNames`, `ChannelManagerEventTypes`, `ChannelManagerPagination`, `ChannelSetterParameterType`, `ChannelSetterType`, `EventHandlerType`, `EventHandlerOverrideType`, `GenericEventHandlerType`, `ExecuteChannelsQueryPayload`, `QueryChannelsRequestType`, `QueryChannelsRequestOutput`, `channelManagerEventToHandlerMapping`, `DEFAULT_CHANNEL_MANAGER_OPTIONS`, `DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS`. +- **v10:** one `EventHandlerPipeline` per event type (any event string; internal `pipelines` / `_pipelines`, `ensurePipeline`); handlers `{ id, handle({ event, ctx: { channelManager } }) }` may return `{ action: 'stop' }`; default ids `ChannelManager:default-handler:` (read-state events share `ChannelManager:default-handler:read-state-changed`); passing `eventHandlers` to the constructor replaces the whole default map. All listed types and constants removed. + + | v9 | v10 | + | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | + | `ChannelManagerEventHandlerOverrides` | `ChannelManagerEventHandlers` (event type -> `LabeledEventHandler[]`) | + | `EventHandlerType` / `EventHandlerOverrideType` / `GenericEventHandlerType` | `EventHandlerPipelineHandler` | + | `ChannelSetterParameterType` / `ChannelSetterType` | `SetPaginatorItemsParams['valueOrFactory']` | + | `QueryChannelsRequestType` / `QueryChannelsRequestOutput` | `PaginatorOptions['doRequest']` | + | `channelManagerEventToHandlerMapping` | `ChannelManager.getDefaultHandlers()` | + | `ChannelManagerPagination`, `ChannelManagerEventTypes`, `ChannelManagerEventHandlerNames`, `ExecuteChannelsQueryPayload`, `DEFAULT_CHANNEL_MANAGER_OPTIONS`, `DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS` | none | + +- **Migrate:** `eventHandlerOverrides: { newMessageHandler: (setChannels, e) => ... }` -> `client.channelManager.setEventHandlers({ eventType: 'message.new', handlers: [{ id: 'my', handle: ({ event, ctx }) => ... }] })`. +- **Source:** `src/ChannelManager.ts:24-30,200-320,326-328,419-436,810-818,1084-1144` (v10); `src/channel_manager.ts:56-113,148-161,304-318` (v9) + + + +### `ChannelManager.registerSubscriptions` is ref-counted and returns an unsubscribe + +**Kind:** behavior · **Impact:** medium + +- **v9:** `registerSubscriptions` was an arrow property returning `void`; one `unregisterSubscriptions()` always tore everything down. +- **v10:** a method returning `Unsubscribe`; each call increments the ref count and listeners are removed only after every registration is released; uses one wildcard `client.on(listener)`. +- **Migrate:** pair every `registerSubscriptions()` with its returned unsubscribe (or `unregisterSubscriptions()`), e.g. in a `useEffect` cleanup. +- **Source:** `src/ChannelManager.ts:1116-1131` (v10); `src/channel_manager.ts:818-829` (v9) + + + +### List ordering: no promotion, sort-derived position + +**Kind:** behavior · **Impact:** high + +- **v9:** `message.new`, `notification.message_new`, `channel.visible`, `notification.added_to_channel` called `promoteChannel`: the channel jumped to index 0 (or just below the pinned block when `pinned_at` led the sort) regardless of the list's sort; `message.new` on a pinned channel left the list unchanged; unpinning placed the channel at the top of the unpinned section. +- **v10:** handlers call `channelManager.ingestChannel(channel)` -> `paginator.ingestItem`, which inserts the channel where the compiled sort comparator puts it; no event boosts. A channel whose sort key did not change does not move; pinned channels relocate inside the pinned block by the next sort key; an unpinned channel returns to its natural position. An empty `sort` means the backend default. +- **Migrate:** declare the order as sort, e.g. `[{ field: 'pinned_at', direction: -1 }, { field: 'last_message_at', direction: -1 }]`; force a top position with `paginator.boost(cid, { ttlMs })`. +- **Source:** `src/ChannelManager.ts:152-198`, `src/pagination/paginators/BasePaginator.ts:1539` (v10); `src/channel_manager.ts:578-801`, `src/utils.ts:1407-1453` (v9) + + + +### Event routing: full filter matching, more events, unknown channels watched + +**Kind:** behavior · **Impact:** medium + +- **v9:** handlers inspected only `filters.archived` and a leading `pinned_at`; not-loaded channels were added even if other filters did not match; channels were never removed for no longer matching (except archive on `member.updated`). `member.updated` ignored other users and needed pinned/archived sort or filters. `channel.hidden` removed the channel unconditionally. `channel.updated` / `channel.truncated` were no-ops. `message.new` for an unknown channel used `client.channel()` (no request); other events used `getAndWatchChannel`. +- **v10:** every routed event evaluates `paginator.matchesFilter(channel)` (full effective filter; hidden channels excluded unless the filter constrains `hidden`) and ingests into matching/owning lists and removes from the rest (`dropFromUnmatchedLists`), so channels can now leave a list on `message.new`, `member.updated`, `channel.updated`, etc. `member.updated` for any member re-routes. `channel.hidden` re-evaluates filters. `channel.updated`, `channel.truncated`, hard `message.deleted` re-insert loaded channels. New defaults: `message.read`, `message.read_locally`, `notification.mark_read`, `notification.mark_unread` (only when a list filters or sorts on `has_unread` / `unread_count`), `notification.channel_mutes_updated` (re-routes all loaded channels). Events for unknown channels (`message.new`, `member.updated`, `channel.visible`, `channel.hidden`, `notification.added_to_channel`, `notification.message_new`) call `ensure().ensureWatched()` (a watch request); stored `WasWatching` channels get a detached `ensureWatched()`. With `ownershipResolver` (`setOwnershipResolver`, `resolveOwnership`, `filterItemsByOwnership`), a channel matching several lists is kept only by the owners, also for server query pages (`wrapPaginatorFiltering` / `unwrapPaginatorFiltering`, `filterQueryResultsBeforeWrapping`). +- **Migrate:** review lists whose server filter is not expressible client-side; to keep v9 "add without matching", use a catch-all paginator with low ownership priority. +- **Source:** `src/ChannelManager.ts:104-320,865-966`, `src/pagination/paginators/ChannelPaginator.ts:515-523` (v10); `src/channel_manager.ts:546-801` (v9) + + + +### `promoteChannel` and pinned/archived helpers removed + +**Kind:** removed · **Impact:** low · **PR:** #1813 + +- **v9:** root exports `promoteChannel({ channels, channelToMove, sort, channelToMoveIndexWithinChannels? }): Channel[]` (pin-aware move-to-top) and type `PromoteChannelParams`; internal (deep-import only) `findLastPinnedChannelIndex`, `findPinnedAtSortOrder`, `shouldConsiderPinnedChannels`, `shouldConsiderArchivedChannels`, `extractSortValue`, `isChannelPinned`, `isChannelArchived`, `getAndWatchChannel`. +- **v10:** all removed; list position derives from the `ChannelPaginator` sort. +- **Migrate:** ordering via sort ([list ordering](#channel-list-ordering)); `client.channelManager.ingestChannel(channel)` to surface a channel; `paginator.boost(cid, { ttlMs })` for temporary promotion; pinned/archived checks via `paginator.matchesFilter(channel)`; `getAndWatchChannel` -> `client.channelManager.ensure({ type, id }).ensureWatched()`. +- **Source:** `src/index.ts` (v10, absent); `src/utils.ts:1216-1453`, `src/index.ts:57-63`, `src/types.ts:4580` (v9) + + + +### Channel list query errors, offline cache, logout + +**Kind:** behavior · **Impact:** medium + +- **v9:** after 3 failed retries `state.error = new Error('Maximum number of retries reached in queryChannels. Last error message is: ...')` plus `console.warn`; `setChannels` upserted every loaded cid into the offline DB; `disconnectUser` left an integrator-created manager's channels intact. +- **v10:** `paginator.state.lastQueryError` holds the raw error, set on each failed attempt (not set when offline support is enabled and the list already holds items) and cleared on success (no wrapping, no console warn); the offline cid cache stores only the first `pageSize` cids; `client.disconnectUser()` calls `channelManager.resetPaginatorStates()` (every list back to `items: undefined`, registrations kept) and `clearChannels()`. +- **Migrate:** read `lastQueryError` (check `error.message` of the raw error); re-query whenever `items` is `undefined`, not only on mount. +- **Source:** `src/ChannelManager.ts:1077-1082`, `src/client.ts:851-856`, `src/pagination/paginators/ChannelPaginator.ts:610-646`, `src/pagination/paginators/BasePaginator.ts:3098-3124` (v10); `src/channel_manager.ts:288-412` (v9) + + + +### `ChannelBatchUpdater` removed + +**Kind:** removed · **Impact:** low + +- **v9:** root-exported class (`addMembers`, `removeMembers`, `inviteMembers`, `addModerators`, `demoteModerators`, `assignRoles`, `hide`, `show`, `archive`, `unarchive`, `updateData`) obtained from `client.channelBatchUpdater()`, wrapping `client.updateChannelsBatch`. +- **v10:** removed; `src/channel_batch_updater.ts` is a one-line comment stub. +- **Migrate:** server-side node SDK `client.chat.channelBatchUpdate(...)`; client-side, loop over `channel.*` methods. +- **Source:** `src/channel_batch_updater.ts:1` (v10); `src/channel_batch_updater.ts:19-237`, `src/index.ts:3` (v9) + + + +### Channel-state consequences of `queryChannels` -> `queryChannelsAndHydrate` + +**Kind:** behavior · **Impact:** high + +- **v9:** `client.queryChannels(...)` created and stored `Channel` instances, set `initialized`, merged each response's messages into the latest message set (always), dispatched `channels.queried`, upserted the offline DB. +- **v10:** `client.queryChannels(request)` is the raw request ([raw queryChannels](#query-channels-raw)): no `Channel` instance is created or updated, nothing is stored, no `channels.queried`, no offline upsert, so `client.channel(type, id)` afterwards returns an empty, uninitialized channel. `client.queryChannelsAndHydrate(request, stateOptions?, requestOptions?)` (or `client.hydrateChannels(response.channels)`) restores hydration with these differences: sets `initialized: true` / `offlineMode: false` (offline: the reverse) and `watchStatus: Watching` when `watch` was requested or the socket is healthy; seeds `messagePaginator` with `reconcile` (offline hard deletes pruned) but skips the re-seed for an `active` channel and for a channel whose paginator has jumped away from the latest window (v9 always merged); `skipInitialization` also skips the message seed; `stateOptions.skipHydration` is still a no-op. +- **Migrate:** replace `await client.queryChannels(f, s, o)` with `await client.queryChannelsAndHydrate({ filter_conditions: f, sort: [...], ...o })`, or better a `ChannelPaginator` registered on `client.channelManager`. +- **Source:** `src/client.ts:1516-1633,1680-1750` (v10); `src/client.ts:2245-2400` (v9) + + + +## 9. Message operations, offline sending and reactions + +Raw client-level edit/delete: [updateMessage / deleteMessage](#update-delete-message-raw). Offline queue contract: [offline support](#offline-support). + + + +### `channel.sendMessage` is a request wrapper; offline queue moved to `messageOperations` + +**Kind:** signature · **Impact:** high · **PR:** #1897, #1882 + +- **v9:** `sendMessage(message: Message, options?: SendMessageOptions)` queued a `send-message` offline task when an offline DB existed and `message.id` was set, otherwise called `_sendMessage` (sanitized attachments, POST). Options included `pending`, `pending_message_metadata`, `force_moderation`, `is_pending_message`, `keep_channel_hidden`, `skip_enrich_url`, `skip_push`. +- **v10:** `sendMessage({ message, skip_push?, skip_enrich_url?, keep_channel_hidden?, include_channel_context?, include_mentioned_members?, scheduled_at? }, requestOptions?)`: HTTP only (attachments sanitized, `_checkHasId`), no offline queueing, no local state. `pending`, `pending_message_metadata`, `force_moderation`, `is_pending_message` are not in `SendMessageRequest` (whether the API still honours them when sent untyped is unverified). `_sendMessage` removed. `channel.messageOperations` is the stateful flow. +- **Migrate:** + + | v9 | v10 | + | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `channel.sendMessage(msg, opts)` | `channel.sendMessage({ message: msg, ...opts })` (HTTP only) | + | send with optimistic state and offline replay | `channel.messageComposer.send()` or `channel.messageOperations.send({ localMessage, message?, options? })` | + | retry a failed message | `channel.messageOperations.retry({ localMessage })` | + | edit / delete with local state | `channel.messageOperations.update({ localMessage, options? })` / `.delete({ localMessage, options: { hard?, delete_for_me? } })` | + | per-SDK request override props | `client.config.setConfig('channel', { requestHandlers: { sendMessageRequest, retrySendMessageRequest, updateMessageRequest, deleteMessageRequest } })`; a handler gets `(params, defaultRequest)` | + + `messageOperations.*` resolve `void` and reject after marking the message `failed`; a send without `localMessage.id` is not queued. + +- **Source:** `src/channel.ts:571-580`, `src/gen/models/index.ts:9742-9750`, `src/messageOperations/MessageOperations.ts:230-332`, `src/messageOperations/createMessageOperations.ts:142-200` (v10); `src/channel.ts:217-253`, `src/types.ts:3066-3075` (v9) + + + +### `channel.sendReaction` / `deleteReaction` are pass-throughs + +**Kind:** signature · **Impact:** high · **PR:** #1906, #1897 + +- **v9:** `sendReaction(messageID, reaction, options?)` validated input and queued `send-reaction` when an offline DB existed; `deleteReaction(messageID, type, user_id?)` required `_checkInitialized`, wrote `offlineDb.deleteReaction` optimistically and queued `delete-reaction` (internal `_sendReaction` / `_deleteReaction`). Reaction WS events then updated state. +- **v10:** `sendReaction({ id }, { reaction, enforce_unique?, skip_push? }, requestOptions?)`, `deleteReaction({ id, type }, _request?, requestOptions?)`: forward to `client.sendReaction` / `client.deleteReaction`; no validation, no offline queue, no DB write, no id or initialization check. `_sendReaction` / `_deleteReaction` removed. The optimistic + queued + rollback flow is `channel.messageOperations.addReaction({ messageId, reaction, options? })` / `deleteReaction({ messageId, type })`. `user_id` override dropped. +- **Migrate:** `channel.sendReaction(id, r, o)` -> `channel.sendReaction({ id }, { reaction: r, ...o })` (custom reaction fields under `reaction.custom`); `channel.deleteReaction(id, type)` -> `channel.deleteReaction({ id, type })`. For offline support use `messageOperations`. +- **Source:** `src/channel.ts:582-588`, `src/messageOperations/MessageOperations.ts:334-396` (v10); `src/channel.ts:473-621` (v9) + + + +## 10. Threads + +Client-level `queryThreads` / `getThread` becoming raw: [threads and reactions](#threads-reactions-hydrate). Thread state timestamps: [state timestamps](#state-timestamps). + + + +### Thread reply list moved to `thread.messagePaginator` + +**Kind:** removed · **Impact:** high · **PR:** #1795 + +- **v9:** `thread.state.getLatestValue().replies`, `state.pagination` (`ThreadRepliesPagination`: `isLoadingNext`, `isLoadingPrev`, `nextCursor`, `prevCursor`), `thread.loadNextPage({ limit })`, `thread.loadPrevPage({ limit })`, `thread.queryReplies(options)` (default limit 50). +- **v10:** removed from `Thread` / `ThreadState`. Replies live in `thread.messagePaginator` (a `MessagePaginator`, page size 50, seeded from `latest_replies`). + + | v9 | v10 | + | -------------------------------------------- | --------------------------------------------------------------- | + | `state.replies` | `thread.messagePaginator.state.getLatestValue().items ?? []` | + | `loadPrevPage()` (older, `id_lt`) | `thread.messagePaginator.toTail()` | + | `loadNextPage()` (newer, `id_gt`) | `thread.messagePaginator.toHead()` | + | `pagination.prevCursor !== null` | `messagePaginator.state.hasMoreTail` | + | `pagination.nextCursor !== null` | `messagePaginator.state.hasMoreHead` | + | `pagination.isLoadingPrev` / `isLoadingNext` | `messagePaginator.state.isLoading` (one flag) | + | `queryReplies(opts)` | `client.getReplies({ parent_id }, { limit, id_lt, ... })` (raw) | + | type `ThreadRepliesPagination` | `PaginatorState` ([paginator state](#paginator-state)) | + +- **Migrate:** per table. +- **Source:** `src/thread.ts:51-76,126,245-278`, `src/gen/chat/ChatApi.ts:1044`, `src/pagination/paginators/MessageIntervalPaginator.ts:483` (v10); `src/thread.ts:30-62,582-648`, `src/index.ts:27-32` (v9) + + + +### `Thread.activate()` returns a release function; `deactivate()` removed + +**Kind:** signature · **Impact:** medium · **PR:** #1901 + +- **v9:** `thread.activate(): void`, `thread.deactivate(): void`; both only toggled `state.active`. +- **v10:** `thread.activate(): () => void`, ref-counted (active until every activation is released; a release is idempotent). The first activation calls `client.threads.register(thread)`, which keeps the thread in `client.threads` (subscribed, resolvable with `client.threads.get(id)`) for the rest of the session, also after release. A second instance with the same id is refused with a warning and stays unmanaged. `thread.deactivate()` does not exist. +- **Migrate:** `const release = thread.activate(); /* on unmount */ release();` +- **Source:** `src/thread.ts:380-396`, `src/thread_manager.ts:222-241` (v10); `src/thread.ts:205-211` (v9) + + + +### Threads no longer subscribe to client events; `client.threads` routes them + +**Kind:** behavior · **Impact:** medium · **PR:** #1888 + +- **v9:** `thread.registerSubscriptions()` added its own `client.on` listeners (`thread.updated`, `message.new`, `message.read`, `notification.mark_unread`, `message.deleted`, `message.updated`, `reaction.*`, `user.watching.stop`). Any registered `Thread` got events. +- **v10:** `thread.registerSubscriptions()` only wires state subscriptions (config key, parent message from `client.messageStore`, auto mark-read, stale reload, channel `watchStatus`). `ThreadManager.registerSubscriptions()` routes client events by id to threads in its store through the new public handlers `handleNewReply`, `handleRepliesRead`, `handleRepliesUnread`, `handleThreadUpdated`, `handleMessageDeleted`, `handleMessageUpdated`, `handleReactionChanged`, `handleUserMessagesDeleted`. A thread gets events only when `client.threads` holds it (listed, `ensure()`d or `activate()`d) and `client.threads.registerSubscriptions()` is active; the client does not call it itself. New: `message.undeleted` and `user.messages.deleted` / `user.deleted` now reach threads. +- **Migrate:** keep `client.threads.registerSubscriptions()` mounted while threads are shown; open threads with `client.threads.ensure(...)` or `activate()`, never a bare `new Thread(...)`. +- **Source:** `src/thread.ts:542-553,592-811`, `src/thread_manager.ts:300-374` (v10); `src/thread.ts:271-285,288-484` (v9) + + + +### Thread construction: `threadData` optional; open threads with `client.threads.ensure()` + +**Kind:** signature · **Impact:** medium + +- **v9:** `new Thread({ client, threadData: ThreadResponse })` (required); apps used `client.threads.threadsById[id] ?? new Thread(...)`. +- **v10:** constructor `new Thread({ client, threadData?: ThreadStateResponse, channel?, parentMessage?, draft? })`. Without `threadData`, `channel` and `parentMessage` (with an `id`) are required, else it throws. A thread built from a parent message starts with `isStateStale: true` when the parent has replies or the channel is not watched, and loads once when activated. With `threadData`, `threadData.channel` and `threadData.parent_message` are required (throws otherwise) and the channel comes from `client.channelManager.ensure` (seeded with `channel.custom`, not `{ name }`). `ThreadResponse` -> `ThreadStateResponse` ([same-name changes](#same-name-meaning-changes)). +- **Migrate:** `const thread = client.threads.ensure({ channel, parentMessage })`. Use `client.getThreadAndHydrate(id)` / `client.queryThreadsAndHydrate()` for fetched threads; both return the already-registered instance when one exists. +- **Source:** `src/thread.ts:133-231`, `src/thread_manager.ts:174-204`, `src/client.ts:2059-2142` (v10); `src/thread.ts:123-196` (v9) + + + +### Thread state derivation: `replyCount`, `custom`, parent message via message store + +**Kind:** behavior · **Impact:** low · **PR:** #1888, #1849 + +- **v9:** `replyCount = threadData.reply_count` (excludes soft-deleted replies); `state.custom` = every non-reserved top-level key of the thread payload; `updateParentMessageLocally` wrote `state.parentMessage` / `deletedAt` / `replyCount` directly; `upsertReplyLocally({ message, timestampChanged })` repositioned on `timestampChanged`. +- **v10:** `replyCount = threadData.parent_message.reply_count` (includes soft-deleted, matches the channel badge; a local `message.new` never bumps it). `state.custom = threadData.custom ?? {}` on construct and `thread.updated`. The parent message lives in `client.messageStore`; `updateParentMessageLocally` upserts there only if the store already holds the id, and `state.parentMessage` / `deletedAt` / `replyCount` / `participants` follow through the store subscription that `registerSubscriptions()` creates, so on an unsubscribed thread the call changes nothing. `upsertReplyLocally` accepts `timestampChanged` and ignores it. Once subscribed, `state.participants` is rebuilt from `parentMessage.thread_participants` (users only) on subscribe and on every parent update: each row's `created_at` / `last_read_at` is set to the current time and its `custom` / `left_thread_at` / `last_thread_message_at` are dropped (tracked in v10-followups.md). v9 kept the thread response's participants unchanged. +- **Migrate:** read thread custom fields from `thread.state.custom` after moving them under `custom` server-side; call `registerSubscriptions()` (or let `client.threads` do it) before relying on local parent updates; do not read per-participant timestamps or `custom` from `thread.state.participants`. +- **Source:** `src/thread.ts:181-189,592-605,819-836,858-903` (`normalizeThreadParticipants` `:940-958`) (v10); `src/thread.ts:77-110,177-180,288-302,515-556` (v9) + + + +### Thread staleness and recovery triggers + +**Kind:** behavior · **Impact:** low + +- **v9:** a `user.watching.stop` event for the thread's channel and own user set `isStateStale: true`; an active stale thread reloaded. +- **v10:** only a channel `watchStatus` transition `Watching -> NotWatching` marks the thread stale. A dropped connection is handled by `ConnectionRecoveryManager`: it reloads active registered threads and marks inactive registered ones stale. `getThreadAndHydrate` / `queryThreadsAndHydrate` with `watch: true` set the channel's `watchStatus` to `Watching`. +- **Migrate:** do not drive thread freshness from `user.watching.stop`. +- **Source:** `src/thread.ts:633-644`, `src/connection/ConnectionRecoveryManager.ts:249-271`, `src/client.ts:135-137` (v10); `src/thread.ts:327-340` (v9) + + + +### `Thread.reload()` request sizing and error handling + +**Kind:** behavior · **Impact:** low + +- **v9:** `client.getThread(id, { watch: true })` (server default reply limit), replaced `state.replies` (plus failed replies), rethrew every error. +- **v10:** `client.getThreadAndHydrate(id, { watch: true, reply_limit: max(loaded replies, pageSize) })`, merges into the reply paginator in place and re-ingests failed replies. A not-found error (Stream code or HTTP 404) resolves silently when `replyCount === 0`; other errors rethrow. +- **Migrate:** none. +- **Source:** `src/thread.ts:413-466` (v10); `src/thread.ts:213-226` (v9) + + + +### `thread.markAsRead` deprecated; `thread.markRead` returns the generated response + +**Kind:** signature · **Impact:** low + +- **v9:** `markAsRead({ force }): Promise`; auto mark-read via a per-thread 1000 ms throttle. +- **v10:** `markRead({ force }): Promise> | null>`; `markAsRead` is a deprecated alias. Auto mark-read goes through the shared `client.messageDeliveryReporter.throttledMarkRead` ([throttle](#throttled-mark-read)). With read events disabled the request throws ([delivery reporter](#delivery-reporter-mark-read)). A `thread.requestHandlers.markReadRequest` config handler replaces the request. +- **Migrate:** rename to `markRead`; read `response?.event` (optional). +- **Source:** `src/thread.ts:99-108,916-933` (v10); `src/thread.ts:568-580` (v9) + + + +### `Thread.unregisterSubscriptions()` disposes the reply paginator + +**Kind:** behavior · **Impact:** low + +- **v9:** removed listeners and set `isStateStale: true`; replies kept. +- **v10:** also calls `messagePaginator.dispose()` (clears its item index and store links, cancels publish throttles; `state.items` is left as is). The instance is meant to be discarded. (`client.threads.unregisterSubscriptions()` disposing every stored thread's paginator while keeping the threads is tracked in v10-followups.md.) +- **Migrate:** do not keep using a thread after unregistering it yourself; resolve it again through `client.threads`. +- **Source:** `src/thread.ts:838-848`, `src/pagination/paginators/BasePaginator.ts:3476-3481` (v10); `src/thread.ts:486-490` (v9) + + + +### `ThreadManager` list state moved to `client.threads.paginator` + +**Kind:** removed · **Impact:** high · **PR:** #1888, #1859 + +- **v9:** `ThreadManagerState.threads`, `.pagination` (`ThreadManagerPagination`: `isLoading`, `isLoadingNext`, `nextCursor`), `.ready`, `.lastConnectionDropAt`; `threadManager.threadsById`, `queryThreads(options)`, `loadNextPage(options)`; `THREAD_MANAGER_INITIAL_STATE` carried those fields. +- **v10:** `ThreadManagerState` = `active`, `wasActivatedAtLeastOnce`, `isThreadOrderStale`, `unreadThreadCount`, `unseenThreadIds`. The list is `client.threads.paginator` (`ThreadPaginator`, state `items | undefined`, `isLoading`, `hasMoreTail`, `lastQueryError`), server order, no client sort. `client.threads.get(id)` / `ensure(...)` resolve instances. + + | v9 | v10 | + | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- | + | `state.threads` | `client.threads.paginator.state.getLatestValue().items ?? []` | + | `state.ready` | `paginator.state.items !== undefined` (not `isInitialized`) | + | `pagination.isLoading` (first load) | `paginator.isLoading && !items?.length` | + | `pagination.isLoadingNext` | `paginator.isLoading && items.length > 0` | + | `pagination.isLoading` during a reload of a loaded list | no flag; await `client.threads.reload()` | + | `pagination.nextCursor` | `paginator.state.hasMoreTail` | + | `loadNextPage()` | `client.threads.paginator.toTail()` (no-op before first load and at the end) | + | `queryThreads(opts)` | `client.queryThreadsAndHydrate(opts)` | + | `threadsById[id]` | `client.threads.get(id)` (listed or opened); `paginator.getItem(id)` for list membership | + | `threadsById[id] ?? new Thread(...)` | `client.threads.ensure({ channel, parentMessage })` | + | `lastConnectionDropAt` | `client.wsConnection.state.getLatestValue().lastUnhealthyAt` | + | type `ThreadManagerPagination` | `PaginatorState` | + +- **Migrate:** per table. +- **Source:** `src/thread_manager.ts:41-66,75-158`, `src/pagination/paginators/ThreadPaginator.ts:16-181` (v10); `src/thread_manager.ts:11-52,74-93,294-337` (v9) + + + +### `ThreadManager` behaviour: reconnect reload, channel disposal, routing + +**Kind:** behavior · **Impact:** low + +- **v9:** reload after reconnect needed a recorded `connection.changed { online: false }` plus `wasActivatedAtLeastOnce`; only `notification.channel_deleted` dropped a channel's threads from the list; reload errors were logged through `client.logger`. +- **v10:** any `connection.recovered` (throttled by `config.connectionRecoveryThrottleMs`, default 1000) reloads with `force: true` if `wasActivatedAtLeastOnce`, which also covers `closeConnection()` / `openConnection()`. Threads (listed and opened) are released when their channel's `pendingDisposal` flips (`channel.deleted`, `notification.channel_deleted`, `notification.removed_from_channel`, channel disposal); `client.threads.get(id)` then returns `undefined`. Reload failures are recorded on `paginator.lastQueryError`; `unseenThreadIds` / `isThreadOrderStale` are cleared only on success. `resetState()` (run by `disconnectUser()`) releases every thread and returns `paginator.items` to `undefined`. `unread_threads` is also read from `connection.ok`. +- **Migrate:** do not reset the manager state on mount; read reload errors from `paginator.state.lastQueryError`. +- **Source:** `src/thread_manager.ts:252-290,376-405,458-510`, `src/client.ts:865` (v10); `src/thread_manager.ts:143-150,191-225,234-292` (v9) + + + +## 11. Polls + +Poll data types and `PollResponse` renaming: [poll types](#poll-types). State timestamps: [state timestamps](#state-timestamps). + + + +### `StreamChat` poll methods renamed, removed, path-param signatures + +**Kind:** renamed · **Impact:** medium · **PR:** #1896 + +- **v9 -> v10:** + + | v9 | v10 | + | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | + | `createPoll(poll: CreatePollData, userId?)` | `createPoll(request: CreatePollRequest, requestOptions?)` | + | `getPoll(id, userId?)` | `getPoll({ poll_id }, _request?, requestOptions?)` | + | `updatePoll(poll: PollData, userId?)` | `updatePoll(request: UpdatePollRequest, requestOptions?)` | + | `partialUpdatePoll(id, { set, unset }, userId?)` | `updatePollPartial({ poll_id }, { set, unset }, requestOptions?)` | + | `closePoll(id, userId?)` | removed: `updatePollPartial({ poll_id }, { set: { is_closed: true } })` or `poll.close()` | + | `deletePoll(id, userId?)` | `deletePoll({ poll_id })` | + | `createPollOption(pollId, option, userId?)` | `createPollOption({ poll_id }, { text, custom? })` | + | `getPollOption(pollId, optionId, userId?)` | `getPollOption({ poll_id, option_id })` | + | `updatePollOption(pollId, option, userId?)` | `updatePollOption({ poll_id }, { id, text, custom? })` | + | `deletePollOption(pollId, optionId, userId?)` | `deletePollOption({ poll_id, option_id })` | + | `castPollVote(messageId, pollId, vote, userId?)` | `castPollVote({ message_id, poll_id }, { vote: { option_id \| answer_text } })` | + | `addPollAnswer(messageId, pollId, answerText, userId?)` | removed: `castPollVote({ message_id, poll_id }, { vote: { answer_text } })` or `poll.addAnswer()` | + | `removePollVote(messageId, pollId, voteId, userId?)` | `deletePollVote({ message_id, poll_id, vote_id })` | + | `queryPolls(filter, sort, options, userId?)` | `queryPolls({ filter, sort, limit?, next?, prev? })` | + | `queryPollVotes(pollId, filter, sort, options, userId?)` | `queryPollVotes({ poll_id }, { filter, sort, ...options })` | + | `queryPollAnswers(pollId, filter, sort, options, userId?)` | `queryPollAnswers({ poll_id }, { filter, sort, ...options })` (adds `is_answer: true`) | + +- **Migrate:** move path ids into the first object; wrap votes in `{ vote }`; drop the `userId` argument; sort becomes `SortParamRequest[]`. Responses are `StreamResponse<...>` generated types (`PollResponse` is now the `{ duration, poll }` envelope). +- **Source:** `src/gen/chat/ChatApi.ts:964-995,1420-1590`, `src/client.ts:2253-2262` (v10); `src/client.ts:4830-5120` (v9) + + + +### `Poll` method parameter and return types + +**Kind:** signature · **Impact:** low + +- **v9:** `poll.createOption(option: PollOptionData)`, `poll.updateOption(option: PollOptionData)`, `poll.update(data: Exclude)`, `poll.partialUpdate(...)`; hand-written `APIResponse & ...` returns; `handlePollUpdated` / `handlePollClosed` / `handleVoteCasted` / `handleVoteChanged` / `handleVoteRemoved` took `Event`; `reinitializeState(poll: PollResponse)`; `query()` returned `PollResponse`. +- **v10:** `createOption(option: CreatePollOptionRequest)` (`{ text, custom? }`), `updateOption(option: UpdatePollOptionRequest)` (`{ id, text, custom? }`), `update(data: UpdatePollRequest)` (still requires `id`, tracked in v10-followups.md); all return `StreamResponse`; handlers take `EventPayload<'poll.updated' | ...>`; `reinitializeState(poll: PollResponseData)`; `query()` returns `PollResponseData`. Public method names and positional arguments (`castVote(optionId, messageId)`, `removeVote(voteId, messageId)`, `addAnswer`, `removeAnswer`, `queryAnswers`, `queryOptionVotes`, `close`, `delete`, `deleteOption`) are unchanged. New `handleUserUpdated(user)`. +- **Migrate:** nest option custom fields under `custom`; pass narrowed event payloads to the handlers (`client.on('poll.vote_casted', poll.handleVoteCasted)` still compiles); read response fields from the generated types. +- **Source:** `src/poll.ts:154-284,330-445` (v10); `src/poll.ts:181-405` (v9) + + + +### `PollState`, `PollAnswersQueryParams`, `PollOptionVotesQueryParams` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `PollState = Omit & { lastActivityAt: Date; ownVotesByOptionId: Record; ownAnswer?: PollAnswer; ... }`; query params `{ filter?: QueryVotesFilters; options?: Pager; sort?: VoteSort }`. +- **v10:** `PollState` is built on generated `PollResponseData`: `custom` required, `description` / `allow_answers` / `allow_user_suggested_options` required, `max_votes_allowed` optional, `created_by` `UserResponse | undefined` (was `| null`), `voting_visibility` `'anonymous' | 'public'`, `description_i18n` / `name_i18n` `Record`, `options` / `latest_answers` / `latest_votes_by_option` use `PollVoteResponseData`, `ownVotesByOptionId` / `ownAnswer` follow; timestamps per [state timestamps](#state-timestamps). Query params `sort?: SortParamRequest[]`, `options?: QueryVotesOptions`, `filter` derived from `QueryPollVotesRequest['filter']`. +- **Migrate:** read custom poll fields from `poll.data.custom`; sort as `[{ field, direction }]`. +- **Source:** `src/poll.ts:43-62` (v10); `src/poll.ts:64-84` (v9) + + + +### Poll helper exports retyped + +**Kind:** type-shape · **Impact:** low + +- **v9:** `isVoteAnswer(vote: PollVote | PollAnswer): vote is PollAnswer`; `extractPollData(PollResponse): PollData`; `extractPollEnrichedData(PollResponse): Omit`; `mapPollStateToResponse(poll, state?): PollResponse`. +- **v10:** `isVoteAnswer(vote: any): vote is RequireLiteral`; `extractPollData(PollResponseData): UpdatePollRequest`; `extractPollEnrichedData(PollResponseData): Pick`; `mapPollStateToResponse(poll, state?): PollResponseData` (own votes sorted numerically). +- **Migrate:** update the surrounding type names ([rename table](#type-rename-table)). +- **Source:** `src/poll.ts:38-41,457-510` (v10); `src/poll.ts:61-62,417-460` (v9) + + + +### `VotingVisibility` enum -> string union type + +**Kind:** type-shape · **Impact:** low · **PR:** #1904 + +- **v9:** `export enum VotingVisibility { anonymous = 'anonymous', public = 'public' }` (runtime value). +- **v10:** `export type VotingVisibility = NonNullable` (type only). `VotingVisibility.anonymous` fails to compile and is `undefined` at runtime in JS. The poll composer default is the string `'public'`. +- **Migrate:** use the literals `'anonymous'` / `'public'`. +- **Source:** `src/types.ts:338` (v10); `src/types.ts:3693-3696` (v9) + + + +### `PollManager` signatures and `user.updated` handling + +**Kind:** signature · **Impact:** low + +- **v9:** `createPoll(poll: CreatePollData)`; `queryPolls(filter, sort: PollSort = [], options: Pager = {})`. +- **v10:** `createPoll(poll: CreatePollRequest)`; `queryPolls(filter, sort: SortParamRequest[] = [], options: QueryPollsOptions = {})`, still positional (unlike `client.queryPolls`). New: `registerSubscriptions()` also listens to `user.updated` and replaces that user in every cached poll's votes, answers and `created_by` (and writes the offline DB). +- **Migrate:** rename the type; sort as `SortParamRequest[]`. +- **Source:** `src/poll_manager.ts:40-60,80-100,165-170` (v10); `src/poll_manager.ts:49,77-90` (v9) + + + +### Poll and reminder caches no longer gated by `disableCache` + +**Kind:** behavior · **Impact:** low + +- **v9:** `PollManager.setOrOverwriteInCache` and `ReminderManager.upsertToState` returned early when `client._cacheEnabled()` was false (server-side client with `disableCache: true`). +- **v10:** no gate; server-side clients always populate `client.polls` / `client.reminders` (the option is gone). +- **Migrate:** none; server-side code should use `@stream-io/node-sdk`. +- **Source:** `src/poll_manager.ts:116-127`, `src/reminders/ReminderManager.ts:139-160` (v10); `src/poll_manager.ts:113`, `src/reminders/ReminderManager.ts:122`, `src/client.ts:3894` (v9) + + + +## 12. Reminders, live location, message delivery and receipts, cooldown + + + +### State timestamps: `Date` / ISO `string` / ms -> `TimestampNS` + +**Kind:** type-shape · **Impact:** medium · **PR:** #1884, #1849 + +- **v9 / v10** (root cause: [TimestampNS](#timestamp-ns)): + + | member | v9 | v10 | + | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------- | + | `ThreadState.createdAt` | `Date` | `TimestampNS` | + | `ThreadState.deletedAt`, `.updatedAt` | `Date \| null` | `TimestampNS \| null` | + | `ThreadState.participants[]` `created_at`, `last_read_at`, `last_thread_message_at`, `left_thread_at` | `string` | `TimestampNS` | + | `ThreadUserReadState.lastReadAt` (also via `ThreadReadState`) | `Date` | `TimestampNS` | + | `ReminderState.created_at`, `.updated_at` | `Date` | `TimestampNS` | + | `ReminderState.remind_at`, `Reminder.remindAt` | `Date \| null` | `TimestampNS \| null` | + | `timeLeftMs(remindAt)` | epoch ms `number` | `TimestampNS` (still returns ms) | + | `PollState.lastActivityAt` | `Date` | `TimestampNS` | + | `PollState.created_at`, `.updated_at`, vote/answer `created_at` | `string` | `TimestampNS` | + | `ScheduledLiveLocationSharing.created_at`, `.updated_at`, `.end_at` | `string` | `TimestampNS` | + | `MessageReceiptsTracker.onMessageRead({ readAt })`, `onMessageDelivered({ deliveredAt })`, `onNotificationMarkUnread({ lastReadAt })` | ISO `string` | `TimestampNS` | + | `OwnMessageReceiptsTrackerMessageLocator(t)` | ms `number` | `TimestampNS` | + + (`MessageReceiptsTracker.reconcileUserRead` was not a v9 API.) + +- **Migrate:** compare numerically; convert at the UI boundary with `nsToDate` / `convertTimestampToDate` / `nsToMs`. `reminder.remindAt.getTime()` -> `nsToMs(reminder.remindAt)`. +- **Source:** `src/thread.ts:58-80`, `src/reminders/Reminder.ts:18-33`, `src/poll.ts:57-62`, `src/LiveLocationManager.ts:34-36`, `src/messageDelivery/MessageReceiptsTracker.ts:9-12,331-430` (v10); `src/thread.ts:37-64`, `src/reminders/Reminder.ts:6-22`, `src/poll.ts:78-79`, `src/messageDelivery/MessageReceiptsTracker.ts:5-8,179-270` (v9) + + + +### Reminder write methods take `({ message_id }, request)`; `remind_at` is a `Date`, `null` is gone + +**Kind:** signature · **Impact:** medium · **PR:** #1896 + +- **v9:** `client.reminders.createReminder({ messageId, remind_at?: string | null, user_id? })`, same for `updateReminder` / `upsertReminder` (`CreateReminderOptions`); `client.createReminder(...)` / `client.updateReminder(...)` same object; `client.deleteReminder(messageId, userId?)`; `client.queryReminders({ filter, sort, ...pager })`. `remind_at: null` on update turned a timed reminder into "save for later". `ReminderFilters` already used `message_id`. +- **v10:** `client.reminders.createReminder({ message_id }, { remind_at?: Date, expires_at?: Date }?, requestOptions?)`, same for `updateReminder` / `upsertReminder`; `client.reminders.deleteReminder(messageId)`, `getFromState(messageId)`, `removeFromState(messageId)` still take a bare id. `client.createReminder` / `updateReminder` are the generated `({ message_id }, { remind_at?: Date, expires_at?: Date }?, requestOptions?)`; `client.deleteReminder({ message_id })` (no `user_id`); `client.queryReminders(request: QueryRemindersRequest)`. `UpdateReminderRequest.remind_at` has no `null`: omit it (`updateReminder({ message_id })` sends `{}`); the docs snippet test asserts the server then clears `remind_at` (live API, not run here). `queryNextReminders` / `queryPreviousReminders` call `paginator.toTail()` / `toHead()`. +- **Migrate:** `client.reminders.upsertReminder({ message_id: messageId }, { remind_at: new Date(...) })`; to clear the due date: `client.reminders.upsertReminder({ message_id })`. Pass `Date`, not ISO strings. +- **Source:** `src/reminders/ReminderManager.ts:276-320`, `src/gen/chat/ChatApi.ts:996-1042,1144-1157`, `src/gen/models/index.ts:2611-2614,10900-10903`, `test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts:136-147` (v10); `src/reminders/ReminderManager.ts:251-295`, `src/client.ts:5199-5245`, `src/types.ts:4807-4813` (v9) + + + +### Reminder data types follow `ReminderResponseData` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `ReminderResponseBaseOrResponse = ReminderResponseBase & { user?; message? }` (string dates); `ReminderOptions.data`, `Reminder.setState(data)`, `Reminder.toStateValue(data)`, `ReminderManager.upsertToState({ data })` took it. +- **v10:** `ReminderResponseBaseOrResponse = ReminderResponseData` (alias; `TimestampNS` dates for `created_at` / `updated_at` / `remind_at`, adds `expires_at?`, `channel?`); the same members take `ReminderResponseData`. +- **Migrate:** use `ReminderResponseData`. +- **Source:** `src/reminders/Reminder.ts:20,52-60,76-91`, `src/reminders/ReminderManager.ts:139` (v10); `src/reminders/Reminder.ts:8-11,39-46` (v9) + + + +### `ReminderEvent` is the generated event payload + +**Kind:** type-shape · **Impact:** low + +- **v9:** `ReminderEvent = { cid; created_at: string; message_id; reminder: ReminderResponse; type: EventTypes; user_id }`. +- **v10:** ``ReminderEvent = EventPayload<`reminder.${string}` | 'notification.reminder_due'>``; `reminder` is optional on the generated events, `created_at` is `TimestampNS`. +- **Migrate:** narrow with `ReminderManager.isReminderWsEventPayload(event)` and guard `event.reminder`. +- **Source:** `src/reminders/ReminderManager.ts:37-39,190-193` (v10); `src/reminders/ReminderManager.ts:41-48` (v9) + + + +### `ReminderManager.configState` is a read-only getter + +**Kind:** type-shape · **Impact:** low + +- **v9:** `configState: StateStore` (assignable field). +- **v10:** `get configState()` backed by `ConfigController` (cannot be reassigned); adds `config` and `initializeConfig(config?)`; `updateConfig` unchanged. Live timers get a new `stopTimerRefreshBoundaryMs` on any config write, not only through `updateConfig`. `StateStore` comes from `@stream-io/state-store`. `updateConfig` patches are not retained (v9 kept them for the client's lifetime): they are discarded whenever the client re-derives manager config, which happens on any `client.config` change under `client` (including sibling keys such as `client.threads`), on `client.config.reset()`, and on every `connectUser()` after `disconnectUser()` (tracked in v10-followups.md). `DEFAULT_REMINDER_MANAGER_CONFIG` (including `scheduledOffsetsMs`) is deep-frozen, and the resolved config shares those arrays, so mutating them throws a `TypeError` in strict mode. +- **Migrate:** set persistent values through `client.config.set({ client: { reminders: ... } })` or the `client` setup function; treat `updateConfig` as valid only until the next re-derivation; never assign `configState`. Copy before changing defaults (`[...config.scheduledOffsetsMs, x]`) and apply the copy through config. +- **Source:** `src/reminders/ReminderManager.ts:17-27,55-120`, `src/client.ts:415-446,649-650,872-873`, `src/configuration/ConfigController.ts:161,192-196` (v10); `src/reminders/ReminderManager.ts:21-31,66-96` (v9) + + + +### `ReminderPaginator` retyped on the new `BasePaginator` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `class ReminderPaginator extends BasePaginator`; `sort: ReminderSort`; `new ReminderPaginator(client, options?: PaginatorOptions)`; file `src/pagination/ReminderPaginator.ts`. +- **v10:** `extends BasePaginator`; constructor options `PaginatorOptions`; `sort: SortParamRequest[] | undefined` (setting it rebuilds a client-side comparator and resets; internal `_sort`); `filters: ReminderFilters` (internal `_filters`, derived from `QueryRemindersRequest`); `query` / `filterQueryResults` follow the new contract; items keyed by `message_id` (deduped); file moved to `src/pagination/paginators/` (still root-exported). Items are ordered client-side by the comparator built from `sort` (v9 appended pages in server order); with `sort` unset the order is `created_at` ascending, then `message_id`, not the server's response order. Inherited API changes: [paginator state](#paginator-state), [navigation](#paginator-navigation), [subclass contract](#paginator-subclass). +- **Migrate:** `client.reminders.paginator.sort = [{ field: 'remind_at', direction: 1 }]`, matching the order you query with; prefer `toTail()` over the deprecated `next()`. +- **Source:** `src/pagination/paginators/ReminderPaginator.ts:22-109` (v10); `src/pagination/ReminderPaginator.ts:10-56`, `src/pagination/BasePaginator.ts:121-129` (v9) + + + +### `LiveLocationManager.dispose()` required for teardown + +**Kind:** behavior · **Impact:** low + +- **v9:** no configuration; `unregisterSubscriptions()` was the whole teardown. +- **v10:** the constructor subscribes the instance to the `liveLocationManager` configuration key; only `dispose()` releases it (`unregisterSubscriptions()` is ref-counted and leaves it). Until then the client's config registry holds the manager. Adds `config`, `configState`, `updateConfig`, `initializeConfig` (`minUpdateThrottleMs`, default 3000). +- **Migrate:** call `liveLocationManager.dispose()` next to the last `unregisterSubscriptions()`. Nothing fails to compile if you don't; it leaks the instance. +- **Source:** `src/LiveLocationManager.ts:86-100,143-188,195-236` (v10); `src/LiveLocationManager.ts:105` (v9) + + + +### Live location requests and expiry handling + +**Kind:** behavior · **Impact:** low + +- **v9:** coordinate updates via `client.updateLocation({ created_by_device_id, message_id, latitude, longitude })`; init via `client.getSharedLocations()`; a location without `end_at` was kept; stop-sharing timers used one `setTimeout` (a share longer than about 24.9 days fired after 1 ms). +- **v10:** `client.updateLiveLocation({ message_id, latitude, longitude })` (no device id); init via `client.getUserLiveLocations()`; locations with absent or non-finite `end_at` are dropped; long shares re-arm their timer in steps. Throttle floor read from `config.minUpdateThrottleMs`. +- **Migrate:** none for `LiveLocationManager` users; direct callers see [renamed methods](#renamed-client-methods). +- **Source:** `src/LiveLocationManager.ts:43-66,254-266,300-330,377-407` (v10); `src/LiveLocationManager.ts:38-42,124-140,192-197` (v9) + + + +### `CooldownTimer.ownLatestMessageDate` -> `ownLatestMessageTimestamp` + +**Kind:** renamed · **Impact:** low + +- **v9:** `CooldownTimerState.ownLatestMessageDate?: Date`; `channel.cooldownTimer.ownLatestMessageDate`; `setOwnLatestMessageDate(date: Date | undefined)`. +- **v10:** `CooldownTimerState.ownLatestMessageTimestamp?: TimestampNS`; getter `ownLatestMessageTimestamp`; `setOwnLatestMessageTimestamp(ns: TimestampNS | undefined)`. `CooldownTimer` is reachable as the public `channel.cooldownTimer` in both versions. +- **Migrate:** rename; convert with `nsToDate` where a `Date` is needed. +- **Source:** `src/CooldownTimer.ts:21,57-59,144-147` (v10); `src/CooldownTimer.ts:18,63-65,135-137` (v9) + + + +### `CooldownTimer` derives from stores and is registered by `Channel` + +**Kind:** behavior · **Impact:** low + +- **v9:** `Channel` / the client refreshed the timer imperatively (4 call sites); the timer's own `registerSubscriptions` (`message.new`, `channel.updated`) existed but was never called. The refresh sites were `query()`, own `message.new`, `channel.updated` and `hydrateActiveChannels`; a capability-only `updatePartial` did not refresh it. +- **v10:** `Channel` calls `cooldownTimer.registerSubscriptions()` at construction and unregisters on disconnect; it refreshes on `channel.state` `data.cooldown` / `ownCapabilities` changes and on message-paginator item changes, and the countdown stops on unregister. +- **Migrate:** remove manual `cooldownTimer.refresh()` calls tied to WS events (harmless if kept). +- **Source:** `src/CooldownTimer.ts:61-95`, `src/channel.ts:358-359,3089` (v10); `src/CooldownTimer.ts:67-87`, `src/channel.ts:179,1674,2143,2298` (v9) + + + +### `MessageDeliveryReporter.markRead`: generated types; throws when read events are disabled + +**Kind:** behavior · **Impact:** medium · **PR:** #1886 + +- **v9:** `markRead(collection, options?: MarkReadOptions): Promise`; delegated to `channel.markAsReadRequest`, which returned `null` without a request when `read_events` was off or the user lacked the `read-events` capability (candidate kept). +- **v10:** `markRead(collection, options?: MarkReadRequest): Promise> | null>`. For channels: a `channel.requestHandlers.markReadRequest` config handler runs if set; else if `readEvents.enabled` is false and `readEvents.localUnreadCountEnabled` is true it calls `channel.markReadLocally()` and returns `null`; else it calls `channel.markRead()`, which throws `Read events are disabled ...` when `readEvents.enabled` is false. For threads: `thread.requestHandlers.markReadRequest` or `channel.markRead({ thread_id })` (throws the same way). Affects `channel.markReadViaReporter` and `thread.markRead` ([channel markRead](#channel-mark-read)). +- **Migrate:** catch the error on explicit calls, or gate on `channel.config.readEvents.enabled`; read `response?.event` (optional). +- **Source:** `src/messageDelivery/MessageDeliveryReporter.ts:398-440`, `src/channel.ts:1303-1313` (v10); `src/messageDelivery/MessageDeliveryReporter.ts:281-302`, `src/channel.ts:1231-1247` (v9) + + + +### `throttledMarkRead` fires on the leading edge and swallows errors + +**Kind:** behavior · **Impact:** low + +- **v9:** `throttle(markRead, 1000, { leading: false, trailing: true })` with the v9 `throttle`: a burst sent one request after 1 s with the last arguments, and a single isolated call was dropped. Rejections surfaced as unhandled. +- **v10:** `{ leading: true, trailing: true }` on the new throttle: the first call fires immediately, a burst adds one trailing call; rejections are caught and dropped. Interval from `config.markAsReadThrottleTimeoutMs` (rebuilt on change). One throttle is shared by every channel and thread (v9 threads had their own). +- **Migrate:** none; explicit `markRead()` still rejects. +- **Source:** `src/messageDelivery/MessageDeliveryReporter.ts:84-100,452-463`, `src/utils/throttling/throttle.ts:39-104`, `src/thread.ts:924-926` (v10); `src/messageDelivery/MessageDeliveryReporter.ts:304-307`, `src/utils.ts:874-904`, `src/thread.ts:576-580` (v9) + + + +### `AnnounceDeliveryOptions` and delivery gating + +**Kind:** type-shape · **Impact:** low + +- **v9:** `AnnounceDeliveryOptions = Omit` (included `user`, `user_id`); delivery reporting gated on `channel.getConfig()?.delivery_events`; requests via `client.markChannelsDelivered`. +- **v10:** `Omit` (no `user` / `user_id`); gated on `channel.config.deliveryEvents.enabled`; requests via generated `client.markDelivered` (empty batches are still skipped by `announceDelivery`). Buffer, batch size and back-off constants are now `config` fields. Protected `markDeliveredRequestPromise` is `Promise | null` (subclasses only). +- **Migrate:** drop `user` / `user_id`. +- **Source:** `src/messageDelivery/MessageDeliveryReporter.ts:23-56,165-168,326-380` (v10); `src/messageDelivery/MessageDeliveryReporter.ts:15-30,71-74,263` (v9) + + + +### `MessageReceiptsTracker` requires `channel`, self-subscribes, `MsgRef.timestamp` + +**Kind:** signature · **Impact:** low + +- **v9:** `new MessageReceiptsTracker({ locateMessage })` (`OwnMessageReceiptsTrackerOptions`, `locateMessage` required); fed only by `ingestInitial(ReadResponse[])` / `onMessageRead` / `onMessageDelivered` / `onNotificationMarkUnread` calls from `Channel`; `MsgRef = { timestampMs: number; msgId }`. +- **v10:** constructor `new MessageReceiptsTracker({ channel, locateMessage? })` (default locator `channel.messagePaginator.findItemByTimestamp`); `extends WithSubscriptions` (`addUnsubscribeFunction`, `hasSubscriptions`, `incrementRefCount`, `registerSubscriptions`, `unregisterSubscriptions`); `registerSubscriptions()` (called by `Channel`) reconciles from `channel.state.read` (`reconcileFromReadStore`, `setPendingReadStoreReconcileMeta`), and `Channel` no longer calls the `on*` methods; `ingestInitial(ReadStateResponse[])`; `MsgRef = { timestamp: TimestampNS; msgId }`. Adds `snapshotStore` (`MessageReceiptsSnapshot`), `isDeliveredToOthers`, `isReadByOthers`. +- **Migrate:** read receipts from `channel.messageReceiptsTracker.snapshotStore` or the query methods; rename `timestampMs` -> `timestamp` (ns). Only code constructing a tracker needs `channel`. +- **Source:** `src/messageDelivery/MessageReceiptsTracker.ts:9-17,128-131,191-245,295-330,483-498`, `src/channel.ts:355-356` (v10); `src/messageDelivery/MessageReceiptsTracker.ts:5-8,73-75,135-145`, `src/channel.ts:172,2041,2073,2276,2494` (v9) + + + +## 13. Search + + + +### `SearchController._internalState` / `InternalSearchControllerState` removed + +**Kind:** removed · **Impact:** medium · **Internal:** yes (`_`-prefixed member; the type was exported) · **PR:** #1866 + +- **v9:** `searchController._internalState: StateStore` with `{ focusedMessage?: MessageResponse }`; type `InternalSearchControllerState` exported. +- **v10:** removed, no replacement on `SearchController`. +- **Migrate:** to focus a search result, open its channel and call `channel.messagePaginator.jumpToMessage(messageId)`; subscribe to `messagePaginator.messageFocusSignal` (`MessageFocusSignalState`) to highlight it (stream-chat-react does this). +- **Source:** `src/search/SearchController.ts:9-33`, `src/pagination/paginators/MessageIntervalPaginator.ts:49-58,239,653` (v10); `src/search/SearchController.ts:11-16,32,42` (v9) + + + +### `SearchController.config` and configuration + +**Kind:** type-shape · **Impact:** low · **PR:** #1831, #1901 + +- **v9:** `config: SearchControllerConfig` plain writable field; `new SearchController({ config, sources })` (`SearchControllerOptions`). +- **v10:** `get config(): Readonly` over a `configState` store; `updateConfig(partial)`, `initializeConfig(slice?)`; new optional `client` option that wires the controller to `client.config` key `searchController`; new `registerSubscriptions()` / `dispose()`. +- **Migrate:** replace `controller.config = {...}` (now throws `Cannot set property config ... which has only a getter`) and `controller.config.keepSingleActiveSource = x` (silent, non-reactive) with `controller.updateConfig({ keepSingleActiveSource: x })`. Pass `client` to take part in `client.config`. Call `controller.dispose()` on unmount and `registerSubscriptions()` on (re)mount; this releases the channel-store claims of active channel/message sources. +- **Source:** `src/search/SearchController.ts:20-33,55-121` (v10); `src/search/SearchController.ts:34,37-43` (v9) + + + +### `ChannelSearchSource` results are not watched + +**Kind:** behavior · **Impact:** medium · **PR:** #1901 + +- **v9:** `client.queryChannels(filters, sort ?? {}, { ...searchOptions, limit, offset })`, inheriting the default `watch: true`; result channels received WS events. +- **v10:** `client.queryChannelsAndHydrate({ filter_conditions, sort, watch: false, ...searchOptions, limit, offset }, { withResponse: false })`. Results are unwatched previews; opening a channel watches it. While the source is active it holds a claim on its result channels in `client.channelManager.channelStore` (released on deactivate or `dispose()`); it has `registerSubscriptions()` / `dispose()`. +- **Migrate:** pass `searchOptions: { watch: true }` to keep v9 behaviour, or watch the channel when the user opens it (`channel.watch()`). Call `searchController.dispose()` when tearing down a UI that keeps a source active. +- **Source:** `src/search/ChannelSearchSource.ts:18-23,70-127` (v10); `src/search/ChannelSearchSource.ts:75-77` (v9) + + + +### `MessageSearchSource` channel hydration + +**Kind:** behavior · **Impact:** medium · **PR:** #1901, #1836 + +- **v9:** unknown result channels were hydrated with `client.queryChannels(channelQueryFilters, { last_message_at: -1, ...channelQuerySort }, channelQueryOptions)` (defaults `watch: true, state: true`), so they were watched; `channelQuerySort` could override the sort. +- **v10:** hydration uses `queryChannelsAndHydrate({ filter_conditions, sort: [{ field: 'last_message_at', direction: -1 }], watch: false, ...channelQueryOptions })`. Channels are unwatched unless `channelQueryOptions.watch` is true. `channelQuerySort` is stored but never sent (tracked in v10-followups.md). Every result whose channel is still not in the store afterwards is created via `channelManager.ensure({ id, type })` with `data = message.channel` (unwatched, not loaded). Results without a `message` are dropped. The source claims its result channels while active (`registerSubscriptions()` / `dispose()`). +- **Migrate:** pass `channelQueryOptions: { watch: true }` if result channels must receive events before being opened; do not rely on `channelQuerySort`. +- **Source:** `src/search/MessageSearchSource.ts:58-72,165-208,255-305` (v10); `src/search/MessageSearchSource.ts:202,221-229` (v9) + + + +### `MessageSearchSource.messageSearchSort` no longer overrides the default order + +**Kind:** behavior · **Impact:** low + +- **v9:** request sort `{ created_at: -1, ...messageSearchSort }`; `messageSearchSort: { created_at: 1 }` flipped the order. +- **v10:** request sort `[{ field: 'created_at', direction: -1 }, ...(messageSearchSort ?? [])]`; the caller's entries are secondary keys only. +- **Migrate:** the primary order cannot be changed through the source; subclass and override `query` if ascending order is needed. +- **Source:** `src/search/MessageSearchSource.ts:235-250` (v10); `src/search/MessageSearchSource.ts:185-195` (v9) + + + +### Search source sort options take `SortParamRequest[]` + +**Kind:** type-shape · **Impact:** medium · **PR:** #1836 + +- **v9:** `ChannelSearchSourceOptions.sort: ChannelSort`, `UserSearchSourceOptions.sort: UserSort`, `ChannelMemberSearchSourceOptions.sort: MemberSort`, `MessageSearchSourceOptions.messageSearchSort: SearchMessageSort`, `.channelQuerySort: ChannelSort` (object or array form); same-named instance fields. +- **v10:** all `SortParamRequest[]` (`[{ field, direction }]`), forwarded as-is. `UserSearchSource` appends `{ field: 'id', direction: 1 }` last unless an `id` entry exists (v9 object form made `id` the primary key). A non-array sort is no longer normalized: `UserSearchSource` fails inside `query` (`baseSort.some is not a function`), `MessageSearchSource` fails with `... is not iterable`, and `ChannelSearchSource` / `ChannelMemberSearchSource` send the object to the API; client-side failures land in `lastQueryError`. `searchOptions` / `channelQueryOptions` and `filters` types follow the generated request types ([filters](#filter-types)). +- **Migrate:** `{ created_at: -1 }` -> `[{ field: 'created_at', direction: -1 }]` ([sort](#sort-shape)). +- **Source:** `src/search/ChannelSearchSource.ts:18,32`, `src/search/UserSearchSource.ts:16,26,74-78`, `src/search/ChannelMemberSearchSource.ts:21,31`, `src/search/MessageSearchSource.ts:62,65,82,85,247`, `src/search/ChannelSearchSource.ts:112-123`, `src/search/ChannelMemberSearchSource.ts:82-88` (v10); `src/search/UserSearchSource.ts:74-80`, `src/search/MessageSearchSource.ts:62-65` (v9) + + + +## 14. Pagination primitives (`BasePaginator`, `FilterBuilder`) + + + +### `BasePaginator` state and cursor shape + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `PaginatorState = { hasNext, hasPrev, isLoading, items, lastQueryError?, cursor?: { next, prev }, offset? }`; `paginator.cursor.next/.prev`; getters `hasNext`, `hasPrev`; `initialState`. +- **v10:** `PaginatorState` (type argument now required) `= { hasMoreHead, hasMoreTail, isLoading, items, lastQueryError?, cursor?: PaginatorCursor, offset? }`; `PaginatorCursor = { tailward, headward }` (`undefined` = not requested yet, `null` = exhausted); getters `hasMoreTail`, `hasMoreHead`; `hasNext` / `hasPrev` kept as `@deprecated` getters on the class only; `initialState` follows the new shape. +- **Migrate:** `state.hasNext` -> `state.hasMoreTail`, `state.hasPrev` -> `state.hasMoreHead`, `cursor.next` -> `cursor.tailward`, `cursor.prev` -> `cursor.headward`; `PaginatorState` -> `PaginatorState`. Selectors reading `hasNext` from `state` now get `undefined`. +- **Source:** `src/pagination/paginators/BasePaginator.ts:169-176,256-264,685-709,732-742` (v10); `src/pagination/BasePaginator.ts:5,19-27,56-62,72-82` (v9) + + + +### `BasePaginator` navigation API + +**Kind:** signature · **Impact:** medium + +- **v9:** `next()`, `prev()`, `nextDebounced()`, `prevDebounced()` (no args); `executeQuery({ direction: 'next' | 'prev' })`; public `canExecuteQuery(direction)`. +- **v10:** `toTail(params?)`, `toHead(params?)`, `toTailDebounced(params?)`, `toHeadDebounced(params?)`, `reload()`; the old names are `@deprecated` aliases taking the same `params` (`reset`, `keepPreviousItems`, `silent`, `retryCount`, `updateState`, `requestOptions`). `executeQuery(params?: PaginationQueryParams)` (`{ direction, queryShape, reset, retryCount, requestOptions? }`) returns `ExecuteQueryReturnValue | void`. Direction values are `'tailward' | 'headward'`. `canExecuteQuery` is `protected` and takes `{ direction?, reset? }`; both directions are refused while `isLoading`. +- **Migrate:** `next` -> `toTail`, `prev` -> `toHead`, `executeQuery({ direction: 'next' })` -> `executeQuery({ direction: 'tailward' })`; drop external `canExecuteQuery` calls (check `isLoading` / `hasMoreTail`). +- **Source:** `src/pagination/paginators/BasePaginator.ts:126,185-216,3023-3032,3138-3148,3483-3533` (v10); `src/pagination/BasePaginator.ts:4,104-106,131,174-183` (v9) + + + +### `BasePaginator` subclass contract + +**Kind:** signature · **Impact:** low + +- **v9:** `abstract class BasePaginator`; `protected constructor(options?: PaginatorOptions)`; subclasses implemented `query({ direction })` and `filterQueryResults(items): T[] | Promise`; cursor mode auto-detected when the first response carried `next` / `prev` (`_isCursorPagination`); `_executeQueryDebounced`. +- **v10:** `BasePaginator` (query-shape type `Q`); `constructor(options?: PaginatorOptions, builtInDefaults?)`; subclasses must also implement `protected getNextQueryShape()` (the base throws `Paginator.getNextQueryShape() is not implemented`, so an unmigrated subclass's `next()` rejects); `query(params: PaginationQueryParams)` receives `{ direction, queryShape, reset, retryCount, requestOptions? }`; return `PaginationQueryReturnValue` `{ items, tailward?, headward? }` (`next` / `prev` deprecated, honored only when `initialCursor` is set); cursor mode is `isCursorPagination = !!cursor`, i.e. only with an `initialCursor` (e.g. `ZERO_PAGE_CURSOR`); `_isCursorPagination` removed; `filterQueryResults` must be synchronous; the first-page `query()` runs after an awaited offline-preload tick. `PaginatorOptions` and `PaginationQueryParams` now require type arguments. New `PaginatorOptions` / config members: `doRequest`, `initialCursor`, `initialOffset`, `itemOrderComparator`, `lockItemOrder`, `maxLoadedItems`, `hasPaginationQueryShapeChanged`, `retryCount`, `throwErrors`, `stateThrottleMs`, `declarativeConfig`; internal `itemIndex`, `createItemIndex`, `deriveCursor`, `getStateAfterQuery`. +- **Migrate:** add `Q` and `getNextQueryShape()`, return `tailward` / `headward`, pass `initialCursor: ZERO_PAGE_CURSOR` for cursor APIs, make `filterQueryResults` sync. See [UserGroupPaginator](#user-group-paginator) for a minimal port. +- **Source:** `src/pagination/paginators/BasePaginator.ts:185-249,351-430,477,621-670,728-730,987-1000,3260-3268` (v10); `src/pagination/BasePaginator.ts:6-10,29-33,39-50,96-98,142-158` (v9) + + + +### `BasePaginator` configuration and defaults + +**Kind:** type-shape · **Impact:** low + +- **v9:** `pageSize` plain field; debounce set via constructor `debounceMs` or `setDebounceOptions`; `DEFAULT_PAGINATION_OPTIONS: Required` = `{ debounceMs: 300, pageSize: 10 }` (mutable). +- **v10:** `pageSize` is an accessor over `config.pageSize`; `config` is a `Readonly>` getter over `configState`; change via `updateConfig(partial)` (`initializeConfig` resets); `DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig` is deep-frozen and adds `lockItemOrder`, `hasPaginationQueryShapeChanged`, `retryCount`, `throwErrors`. Defaults (300 ms, 10) unchanged. +- **Migrate:** `paginator.pageSize = n` still works; use `updateConfig({ debounceMs })` instead of mutating config; do not write into `DEFAULT_PAGINATION_OPTIONS` (throws in strict mode). +- **Source:** `src/pagination/paginators/BasePaginator.ts:338-349,412-475,791-817` (v10); `src/pagination/BasePaginator.ts:29-37,41,100-102` (v9) + + + +### `BasePaginator` item handling semantics + +**Kind:** behavior · **Impact:** low + +- **v9:** each page appended to `items` in server order, no dedup; `items` reset only by `resetState()` or when `undefined`; offset mode left `hasPrev` at `true`, `hasNext = items.length === pageSize`; `prev` could run while loading; errors only stored in `lastQueryError`. +- **v10:** pages are ingested into interval storage keyed by `getItemId` (default `item.id`): duplicates collapse, items are ordered by the paginator comparator, a changed query shape (deep-equal of `getNextQueryShape()`) is treated as a first page and resets state first. Offset mode: `hasMoreHead = initialOffset > 0`, `hasMoreTail = items.length >= pageSize`. Optional `retryCount` and `throwErrors`. +- **Migrate:** none for consumers of built-in paginators; custom subclasses must expose a stable id and a comparator matching server order. +- **Source:** `src/pagination/paginators/BasePaginator.ts:3034-3063,3098-3136,3278-3300` (v10); `src/pagination/BasePaginator.ts:104-129,150-156` (v9) + + + +### `UserGroupPaginator` + +**Kind:** type-shape · **Impact:** low + +- **v9:** `new UserGroupPaginator(client, options?: PaginatorOptions)`, `extends BasePaginator`; called `client.queryUserGroups`; initial `cursor` `undefined`; `hasPrev: false`; cursor `created_at_gt` was the raw string `created_at`. +- **v10:** `extends BasePaginator`; options `PaginatorOptions`; `query` calls `client.listUserGroups`; implements `getNextQueryShape`; initial cursor `{ tailward: undefined, headward: null }`; `hasMoreHead: false`; items ordered by `created_at` then `id`; cursor `created_at_gt` built with `nsToRfc3339(created_at)`. Inherits every `BasePaginator` change above. +- **Migrate:** read `state.hasMoreTail` / call `toTail()`; the `teamId` setter is unchanged. +- **Source:** `src/pagination/paginators/UserGroupPaginator.ts:40-131` (v10); `src/pagination/UserGroupPaginator.ts:37-90` (v9) + + + +### `ExtendedQueryFilter` / `ExtendedQueryFilters` / `ExtendedQueryLogicalOperators` removed + +**Kind:** removed · **Impact:** low · **PR:** #1870 + +- **v9:** exported from `pagination/FilterBuilder` for typing `FilterBuilder` filter generators. +- **v10:** removed. `FilterBuilder` runtime unchanged; `FilterBuilderOptions` gained a default for `TContext`. +- **Migrate:** type filters with the endpoint filter types (`ChannelFilters`, `UserFilters`, `MemberFilters`, `MessageFilters`) or `Filters<{ field: { type, operators } }>` ([filters](#filter-types)). +- **Source:** `src/pagination/FilterBuilder.ts:1-19` (v10); `src/pagination/FilterBuilder.ts:4-23` (v9) + + + +### Pagination source layout (deep imports) + +**Kind:** packaging · **Impact:** low + +- **v9:** `pagination/BasePaginator.ts`, `pagination/UserGroupPaginator.ts` (and `ReminderPaginator.ts`) re-exported by `pagination/index.ts`. +- **v10:** moved to `pagination/paginators/*`; `pagination/index.ts` re-exports `./paginators` and `./FilterBuilder`. Root exports keep the same names. +- **Migrate:** import from `stream-chat`, not from `stream-chat/dist/types/pagination/BasePaginator`. +- **Source:** `src/pagination/index.ts:1-2`, `src/pagination/paginators/index.ts:1-8` (v10); `src/pagination/index.ts:1-4` (v9) + + + +## 15. Moderation + +`StreamChat`-level moderation methods that moved to `client.moderation`: [client moderation methods](#client-moderation-methods). Generated moderation config types: [moderation config types](#moderation-config-types). + + + +### `Moderation` methods are generated request-object methods + +**Kind:** signature · **Impact:** medium · **PR:** #1836 + +- **v9:** hand-written positional methods on `class Moderation`. +- **v10:** `class Moderation extends ModerationApi`; the methods below are inherited from `gen/moderation/ModerationApi.ts` with `(request | pathParams, request?, requestOptions?)` and return `StreamResponse<...>` generated responses. Server-side `user_id` options are gone. Passing a positional string where `pathParams` is expected throws at runtime (`"0" is not a path parameter of ...`). New public field `apiClient`. + + | v9 | v10 | + | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | + | `queryReviewQueue(filter, sort, options)` | `queryReviewQueue({ filter, sort: SortParamRequest[], limit, next, ... })` | + | `upsertConfig(config)` | `upsertConfig(request: UpsertConfigRequest)` | + | `getConfig(key, { team }?)` | `getConfig({ key }, { team }?)` | + | `deleteConfig(key, { team }?)` | `deleteConfig({ key }, { team }?)` | + | `queryConfigs(filter, sort, options)` | `queryModerationConfigs({ filter, sort, limit, next, prev })` | + | `submitAction(actionType, itemID, options)` | `submitAction({ action_type, item_id, ...payload })` | + | `flag(entityType, entityId, entityCreatorID, reason, options)` | `flag({ entity_type, entity_id, entity_creator_id?, reason?, custom?, moderation_payload? })` | + | `muteUser(targetID, { timeout, user_id })` | `mute({ target_ids: [targetId], timeout })` | + | `unmuteUser(targetID, { user_id })` | `unmuteUser(targetId, requestOptions?)` or `unmute({ target_ids })` | + + Other generated members now on `Moderation` (additive): `ban`, `unban`, `appeal`, `getAppeal`, `queryAppeals`, `bulkActionAppeals`, `createQueue`, `getQueue`, `listQueues`, `updateQueue`, `deleteQueue`, `getActionConfig`, `upsertActionConfig`, `deleteActionConfig`, `bulkUpsertActionConfig`, `bulkDeleteActionConfig`. + +- **Migrate:** rewrite calls per the table; sort entries become `[{ field, direction }]`. +- **Source:** `src/moderation.ts:11-17,91-93`, `src/gen/moderation/ModerationApi.ts:205-282,375-404,423-436` (v10); `src/moderation.ts:43-48,91-255` (v9) + + + +### `Moderation` methods removed without client-side replacement + +**Kind:** removed · **Impact:** low + +- **v9:** `getUserModerationReport(userID, options)`, `check(...)`, `checkUserProfile(userId, profile)`, `addCustomFlags(...)`, `addCustomMessageFlags(messageID, flags)`, `upsertModerationRule(rule)`, `queryModerationRules(filter, sort, options)`, `getModerationRule(id)`, `deleteModerationRule(id)`. +- **v10:** removed, no replacement in this package. +- **Migrate:** call these endpoints from a server-side SDK (e.g. `@stream-io/node-sdk`; exact method names unverified). +- **Source:** `src/moderation.ts:1-94`, `src/gen/moderation/ModerationApi.ts` (v10, absent); `src/moderation.ts:158-168,270-298,326-353,368-406,414-463` (v9) + + + +### `Moderation.flagUser` / `flagMessage` + +**Kind:** signature · **Impact:** low + +- **v9:** `flagUser(flaggedUserID, reason: string, options?: ModerationFlagOptions)` and `flagMessage(messageID, reason, options?)`; options `{ custom?, moderation_payload?, user_id? }`; always sent `entity_creator_id: ''`; returned `{ item_id } & APIResponse`. +- **v10:** `flagUser(flaggedUserId, reason?, options?, requestOptions?)` / `flagMessage(messageId, reason?, options?, requestOptions?)`; `reason` is optional; options `Omit` (no `user_id`; `entity_creator_id` settable and omitted unless given); returns `StreamResponse`. +- **Migrate:** drop `user_id`; positional order is unchanged. +- **Source:** `src/moderation.ts:34-81`, `src/types.ts:349-352` (v10); `src/moderation.ts:60-76` (v9) + + + +### `MODERATION_ENTITY_TYPES.userprofile` removed + +**Kind:** removed · **Impact:** low + +- **v9:** `{ user: 'stream:user', message: 'stream:chat:v1:message', userprofile: 'stream:v1:user_profile' }`. +- **v10:** `{ user, message }`; `userprofile` removed with `checkUserProfile`. +- **Migrate:** inline `'stream:v1:user_profile'` if still needed. +- **Source:** `src/moderation.ts:5-8` (v10); `src/moderation.ts:36-40` (v9) + + + +## 16. Notifications + + + +### `NotificationManager.config` is read-only and follows `client.config` + +**Kind:** type-shape · **Impact:** low · **PR:** #1831, #1872 + +- **v9:** `config: NotificationManagerConfig` plain field, `mergeWith(DEFAULT_NOTIFICATION_MANAGER_CONFIG, config)` at construction; mutable. A custom instance was passed as `options.notifications`. +- **v10:** `get config(): Readonly` over `configState`; `updateConfig(partial)` (deep merge), `initializeConfig(slice)`; `DEFAULT_NOTIFICATION_MANAGER_CONFIG` is deep-frozen. The client re-derives the manager's config from `client.config` key `client.notifications` at construction, on every change of any `client.*` key (not only `client.notifications`), on `client.config.reset()` and on every `connectUser()` after `disconnectUser()`, replacing earlier `updateConfig` patches (constructor arguments survive; tracked in v10-followups.md). A custom instance is passed as `config.client.notificationManager`. Severities and every notification type string v9 emitted are unchanged; `Notification.type` is typed `CoreNotificationType | (string & {})`; `CORE_NOTIFICATION_TYPE` lists the 14 emitted ids (including `api:message:send:failed` and `api:message:update:failed`). +- **Migrate:** `manager.config.durations.error = 5000` (throws on the frozen default) -> `manager.updateConfig({ durations: { error: 5000 } })` or `client.config.set({ client: { notifications: { durations: { error: 5000 } } } })`. +- **Source:** `src/notifications/NotificationManager.ts:20-74`, `src/notifications/configuration.ts:6-14`, `src/notifications/types.ts:40-60,97`, `src/client.ts:341,415-446,649-650,872-873` (v10); `src/notifications/NotificationManager.ts:15-19` (v9) + + + +## 17. Offline support (`AbstractOfflineDB` implementers) + +`AbstractOfflineDB` is an injection point (`client.setOfflineDBApi(...)`); these entries matter mostly for custom implementations such as the React Native SQLite store. + + + +### `OfflineDBSyncManager.syncStatus` -> `isSynced`; sync trigger + +**Kind:** renamed · **Impact:** medium · **PR:** #1859 + +- **v9:** `public syncStatus: boolean`; `connectionChangedListener: { unsubscribe(): void } | null` from `client.on('connection.changed', ...)`; sync and pending-task replay ran on `connection.changed` (`event.online`), so a manually dispatched `connection.changed` triggered it. +- **v10:** `public isSynced: boolean`; `connectionChangedListener: Unsubscribe | null` (a bare function) from `client.wsConnection.state.subscribeWithSelector(({ isHealthy }) => ...)` (first emission ignored as baseline). `connection.changed` no longer triggers a sync. +- **Migrate:** `syncManager.syncStatus` -> `syncManager.isSynced`; `connectionChangedListener.unsubscribe()` -> `connectionChangedListener?.()`; listen with `syncManager.onSyncStatusChange(cb)` (unchanged). +- **Source:** `src/offline-support/offline_sync_manager.ts:20-25,62-104,145` (v10); `src/offline-support/offline_sync_manager.ts:16-17,54-87,126` (v9) + + + +### `OfflineDBApi` / `AbstractOfflineDB` implementer contract + +**Kind:** signature · **Impact:** medium (high for custom offline DB implementations) · **PR:** #1869, #1884, #1836 + +- **v9:** `getChannelsForQuery({ userId, filters?, options?: ChannelOptions, sort? })` -> `Omit[] | null`; `getChannels` -> `Omit[] | null`; `getAppSettings` -> `AppSettingsAPIResponse | null`; `upsertCidsForQuery({ cids, filters?, options?: ChannelOptions, sort?, execute? })`. +- **v10:** `getChannelsForQuery({ userId, options?: QueryChannelsRequest })` (filters and sort now live inside `options.filter_conditions` / `options.sort`, or `options.predefined_filter`) -> `DBGetChannelsForQueryResult | null` = `{ channels, predefinedFilter? }` (`DBGetChannelsForQueryType` changed); `getChannels` -> `Omit[] | null`; `getAppSettings` -> `GetApplicationResponse | null`; `upsertCidsForQuery` additionally receives `predefinedFilter?: ParsedPredefinedFilterResponse` (store it; clear it when absent) and `options` is the full request (`filters` / `sort` are still passed as `request.filter_conditions` / `request.sort`; `options` is typed `ChannelOptions` in `DBUpsertCidsForQueryType` but carries the whole `QueryChannelsRequest`, including `predefined_filter`); `getReactions.filters` widened to `ReactionFilters`. The set of abstract members is unchanged; `hardDeleteMessages`, `upsertMessageWithChannelGuard`, `getFailedMessages` are new concrete helpers. Handler `event` params are narrowed to `EventPayload<...>`. + + | type | field | v9 | v10 | + | ------------------------------------------------ | -------------------- | -------------------------------------- | ------------------------------------------------------------------ | + | `DBUpsertChannelsType` | `channels` | `ChannelAPIResponse[]` | `ChannelStateResponseFields[]` | + | `DBUpsertAppSettingsType` | `appSettings` | `AppSettingsAPIResponse` | `GetApplicationResponse` | + | `DBUpsertPollType` | `poll` | `PollResponse` | `PollResponseData` | + | `DBUpsertReadsType` | `reads` | `ReadResponse[]` (`last_read: string`) | `ReadStateResponse[]` (`last_read: TimestampNS`, `0` = never read) | + | `DBDeleteMessagesForChannelType` | `truncated_at` | `string` | `TimestampNS` | + | `DBGetReactionsType`, `DBUpsertCidsForQueryType` | `sort` | `ReactionSort` / `ChannelSort` | `SortParamRequest[]` | + | message/member/reaction/channel/draft rows | timestamps, `custom` | strings, top-level custom | `TimestampNS`, `custom` object | + +- **Migrate:** key the cid cache on `options.filter_conditions` + `options.sort` (or `options.predefined_filter`) on both write and read; return `{ channels, predefinedFilter }` from `getChannelsForQuery`; persist and parse timestamps as ns numbers (`asTimestampNS`). +- **Source:** `src/offline-support/types.ts:42-59,63-70,186-202,233-241,321-327,390-401`, `src/offline-support/offline_support_api.ts:276-287,830-960` (v10); `src/offline-support/types.ts:41-52,178-187,371-382` (v9) + + + +### `queriesWithChannelGuard` writes first and repairs the channel row on failure + +**Kind:** behavior · **Impact:** low · **PR:** #1865 + +- **v9:** always probed `channelExists({ cid })` before writing; when missing, built the channel row from the event or from `client.channel(type, id)` (creating and registering a `Channel` as a side effect). +- **v10:** with `execute: true` and no `forceUpdate`, runs `createQueries(true)` first; only if that throws and the channel row is missing does it prepend the channel upsert and retry. The fallback reads `client.channelManager.get(cid)` and never creates a `Channel`. +- **Migrate:** implementations must make a write for a missing channel fail (FK constraint); otherwise rows are written without their channel. +- **Source:** `src/offline-support/offline_support_api.ts:557-680` (v10); `src/offline-support/offline_support_api.ts:538-597` (v9) + + + +### Offline queueing policy: only ephemeral failures are queued or kept + +**Kind:** behavior · **Impact:** medium · **PR:** #1897 + +- **v9:** `shouldSkipQueueingTask` skipped only API codes 4 and 17; every other failure (5xx without a code, unknown codes, cancellations) was queued, and `executePendingTasks` kept such tasks. +- **v10:** `shouldSkipQueueingTask = !isEphemeral(error)`: queued/kept only when there was no response (network, `OfflineError`) or the Stream code is marked `retryable` in `APIErrorCodes`. Cancelled requests are never queued. Responses without a Stream code (e.g. a 502 HTML page) are dropped, and in `executePendingTasks` non-ephemeral failures delete the task. +- **Migrate:** none; expect fewer tasks to survive in the queue. Do not rely on aborting a request to keep it queued. +- **Source:** `src/offline-support/offline_support_api.ts:1421-1451,1563-1590`, `src/errors.ts:84-88` (v10); `src/offline-support/offline_support_api.ts:1131-1160,1332-1360` (v9) + + + +### `update-message` pending tasks fold into queued sends + +**Kind:** behavior · **Impact:** low · **PR:** #1873 + +- **v9:** non-replayable edits were always dropped. A replayable edit was folded into a queued `send-message` only when the socket was unhealthy and the edit had `status: 'failed'` (dropped if no send task was queued); in every other case it was queued on its own as an `update-message` task. +- **v10:** any edit is folded into a queued `send-message` task for the same message id (regardless of connection or status, non-replayable ones included), so a replayable edit on a healthy socket that v9 queued separately is now folded; without a send task a replayable edit is queued on its own, a non-replayable one dropped. +- **Migrate:** none. +- **Source:** `src/offline-support/offline_support_api.ts:1474-1515` (v10); `src/offline-support/offline_support_api.ts:1195-1255` (v9) + + + +### `PendingTask` payload shapes; v9-persisted tasks replay with wrong arguments + +**Kind:** type-shape · **Impact:** low (custom `AbstractOfflineDB` implementations only; the React Native SDK drops v9 pending tasks) · **PR:** #1843, #1897 + +- **v9:** `PendingTask.payload` (and `DBUpdatePendingTaskType`) = argument list of the v9 replay method: `send-message` `[message, options]` (`channel._sendMessage`), `update-message` `[message, userId?, options?]` (`client._updateMessage`), `delete-message` `[messageID, hardDelete | options]`, `send-reaction` `[messageID, reaction, options]` (`channel._sendReaction`), `delete-reaction` `[messageID, reactionType]`, `create-draft` `[draftMessage]`, `delete-draft` `[{ parent_id }]`. +- **v10:** `payload` = `Parameters`: `send-message` `[{ message, ... }, requestOptions?]` (`channel.sendMessage`), `update-message` `[{ id }, { message, ... }, requestOptions?]`, `delete-message` `[{ id }, { hard?, delete_for_me? }?, requestOptions?]`, `send-reaction` `[{ id }, { reaction, ... }, requestOptions?]` and `delete-reaction` `[{ id, type }, _, requestOptions?]` (now on `client`), `create-draft` `[{ message }, requestOptions?]`, `delete-draft` unchanged. No migration of stored rows exists. Replaying a v9 row: `delete-message`, `send-reaction`, `delete-reaction` throw locally in `resolveUrl` (`"0" is not a path parameter ...`) and `update-message` throws a `TypeError` in `client.updateMessage` (`Cannot read properties of undefined (reading 'message')`, or `(reading 'attachments')` when the v9 `userId` was set); none of these errors has a `response`, so `isEphemeral` treats them as transient and the task is kept and fails again on every sync (tracked in v10-followups.md); `send-message` and `create-draft` send the bare message as the request body (server outcome unverified, likely an InputError that deletes the task and loses the message); `delete-draft` replays correctly. This applies to every v9 upgrade of a custom `AbstractOfflineDB` that keeps its pending tasks, not only early rcs. The React Native SDK's SQLite store is not affected: RN V10 bumps `SqliteClient.dbVersion` from 16 to 18, and the version mismatch drops every table (`pendingTasks` included) before replay, so RN apps lose v9 pending tasks instead of replaying them. +- **Migrate:** before the first v10 init, clear the pending-tasks table in your `OfflineDB` implementation (or `resetDB()`), or migrate rows to the v10 shapes. +- **Source:** `src/offline-support/types.ts:457-506`, `src/offline-support/queueableOperations.ts:83-147,235-239`, `src/api-client.ts:119-131`, `src/client.ts:2016-2024`, `src/errors.ts:84-88`, `src/offline-support/offline_support_api.ts:1563-1590` (v10); `src/offline-support/types.ts:428-462`, `src/offline-support/offline_support_api.ts:1272-1318` (v9) + + + +## 18. MessageComposer + +`src/middleware.ts` (`MiddlewareExecutor`) is unchanged. The setup function moved to `client.config`: [setup function](#message-composer-setup-function). + + + +### Pending-upload sending: middleware removed, now a config flag + +**Kind:** removed · **Impact:** medium · **PR:** #1880, #1881 + +- **v9:** install `createSendWithPendingUploadsAttachmentsMiddleware(composer)` via `compositionMiddlewareExecutor.replace([...])`; it carried `MessageCompositionMiddleware.allowsPendingUploads: true`; `composer.allowsPendingUploads` scanned `compositionMiddlewareExecutor.installedMiddleware` (narrowed override on `MessageComposerMiddlewareExecutor`). The caller had to await uploads and patch URLs into the payload before sending. +- **v10:** the export, the `allowsPendingUploads` middleware field and the `installedMiddleware` narrowing are gone. The switch is `attachments.pendingUploadsEnabled` (default `false`), read per composition by the stock `createAttachmentsCompositionMiddleware`. `composer.allowsPendingUploads` returns `attachmentManager.config.pendingUploadsEnabled`. Settling uploads moved into `channel|thread.messageOperations.send` (`settlePendingAttachmentUploads`). In this mode `failed` uploads count for `hasSendableData` and ride along on `localMessage.attachments` (retried at send); only `blocked` stays behind. The "keep in composer" rule is `config.retainCompositionOnSubmit(composer)` (default `!!composer.pollId`, same as v9's hardcoded check). +- **Migrate:** delete the middleware install; `client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: true } } })` (or `composer.updateConfig(...)`). Drop any own await/patch-URLs step and send through `composer.send()` or `messageOperations.send`, otherwise uploads are handled twice. A custom middleware with `allowsPendingUploads: true` now does nothing. +- **Source:** `src/messageComposer/middleware/messageComposer/attachments.ts:54,62,124`, `src/messageComposer/messageComposer.ts:478,524`, `src/messageComposer/configuration/types.ts:109`, `src/messageOperations/createMessageOperations.ts:43` (v10); `src/messageComposer/middleware/messageComposer/attachments.ts:43,108`, `src/messageComposer/middleware/messageComposer/types.ts:55`, `src/messageComposer/messageComposer.ts:448`, `src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts:64` (v9) + + + +### `legacy_thread` composition context removed + +**Kind:** removed · **Impact:** low · **PR:** #1881 + +- **v9:** `CompositionContext = Channel | Thread | LocalMessageWithLegacyThreadId`; a context with `legacyThreadId` gave `contextType` / `evaluateContextType` = `'legacy_thread'`, `tag` / `constructTag` = `legacy_thread_`, `threadId = legacyThreadId`. +- **v10:** `LocalMessageWithLegacyThreadId` removed, `CompositionContext = Channel | Thread | LocalMessage`; such a context evaluates to `'message'` and `threadId` falls back to `parent_id`. The `contextType` / `tag` unions lose the `legacy_thread` member. +- **Migrate:** compose replies against a `Thread` (`client.threads.ensure(...)`) or a `LocalMessage` with `parent_id`. Remove `'legacy_thread'` branches from switches on `contextType`. +- **Source:** `src/messageComposer/messageComposer.ts:105,321` (v10); `src/messageComposer/messageComposer.ts:86,282,292` (v9) + + + +### Server channel flags resolve into `composer.config` + +**Kind:** behavior · **Impact:** medium · **PR:** #1831 + +- **v9:** only `shared_locations` was merged into `location.enabled`, once, at construction. `uploads` and `polls` were never consulted; `url_enrichment` was ANDed live inside `linkPreviewsManager.enabled` (so `composer.config.linkPreviews.enabled` showed the client value only); `max_message_length` was ignored. +- **v10:** `composer.config` is the server-restricted result: `attachments.enabled` AND `uploads` (skipped when `customCdn`), `polls.enabled` AND `polls`, `linkPreviews.enabled` AND `url_enrichment`, `location.enabled` AND `shared_locations`; `text.maxLengthOnSend` / `maxLengthOnEdit` are capped by, and default to, `max_message_length`. Re-resolved when `client.channelServerConfigsStore` changes for the cid (after `registerSubscriptions()`) or on `channel.query`. Consequences: a channel type with `uploads: false` now blocks uploads client-side (`isUploadEnabled` false); `insertText` truncates typed/pasted text at the server limit and over-limit compositions are discarded. `polls.enabled` is only a resolved flag: `composer.createPoll()` / `PollComposer` do not check it (tracked in v10-followups.md). +- **Migrate:** gate UI on the resolved values (`composer.attachmentManager.isUploadEnabled`, `composer.config.polls.enabled`, `composer.config.linkPreviews.enabled`), not `channel.serverConfig?.*`. +- **Source:** `src/messageComposer/messageComposer.ts:620-664,921`, `src/messageComposer/textComposer.ts:455`, `src/messageComposer/middleware/messageComposer/compositionValidation.ts:71`, `src/channel.ts:2000` (v10); `src/messageComposer/messageComposer.ts:245`, `src/messageComposer/linkPreviewsManager.ts:160` (v9) + + + +### Composer config is read-only and deep-frozen + +**Kind:** behavior · **Impact:** low + +- **v9:** `composer.config` returned a mutable object; `composer.config.drafts.enabled = true` mutated state silently, and nested subtrees were the shared `DEFAULT_*` objects, so the write leaked into every composer. The exported `DEFAULT_COMPOSER_CONFIG` / `DEFAULT_*_CONFIG` constants were mutable. +- **v10:** getter typed `Readonly`; every resolution is deep-frozen, and `DEFAULT_COMPOSER_CONFIG` is frozen in place, which also freezes `DEFAULT_ATTACHMENT_MANAGER_CONFIG`, `DEFAULT_COMMANDS_CONFIG`, `DEFAULT_LINK_PREVIEW_MANAGER_CONFIG`, `DEFAULT_LOCATION_COMPOSER_CONFIG`, `DEFAULT_TEXT_COMPOSER_CONFIG`. Writes throw `TypeError` in strict mode (ESM / TS output). +- **Migrate:** use `composer.updateConfig(...)` or `client.config.set({ messageComposer: ... })`; copy a default before changing it (`{ ...DEFAULT_LINK_PREVIEW_MANAGER_CONFIG, enabled: false }`). +- **Source:** `src/messageComposer/messageComposer.ts:283-290,349`, `src/messageComposer/configuration/configuration.ts:65`, `src/configuration/utils/deepFreezeConfig.ts` (v10); `src/messageComposer/messageComposer.ts:306`, `src/messageComposer/configuration/configuration.ts:48` (v9) + + + +### `updateConfig` patches are retained; config setters no longer short-circuit + +**Kind:** behavior · **Impact:** low · **PR:** #1831 + +- **v9:** `updateConfig(patch)` merged into the current resolved state (`configState.partialNext`); `configState` was a plain `readonly` store you could `next()` directly. Setters `linkPreviewsManager.enabled`, `textComposer.enabled`, `textComposer.maxLengthOnEdit`, `textComposer.maxLengthOnSend`, `attachmentManager.maxNumberOfFilesPerMessage` returned early when the value equalled the current effective value. +- **v10:** `updateConfig` records a retained patch layer that is re-applied on every re-resolution (server change, declarative `client.config` change), so the last request wins once a server restriction lifts. `initializeConfig()` (called by `client.config.reset()`) discards all patches, including those made through sub-composer setters. `configState` is a getter over the internal controller store; a direct `configState.next(...)` is overwritten by the next re-resolution. All five setters always write. New `requestedConfig` getter exposes the pre-server request. +- **Migrate:** change config only via `updateConfig` / `client.config`; after `client.config.reset()` re-apply imperative settings. +- **Source:** `src/messageComposer/messageComposer.ts:218,580-581,591,694`, `src/messageComposer/linkPreviewsManager.ts:162`, `src/messageComposer/textComposer.ts:166,188,200`, `src/messageComposer/attachmentManager.ts:162` (v10); `src/messageComposer/messageComposer.ts:177,512` (v9) + + + +### New required fields on composer config types + +**Kind:** type-shape · **Impact:** low · **PR:** #1831, #1881 + +- **v9:** `MessageComposerConfig { attachments, commands, drafts, linkPreviews, location, text }`; `AttachmentManagerConfig` without `enabled` / `customCdn` / `pendingUploadsEnabled`; `LocationComposerConfig { enabled, getDeviceId }` with a hardcoded 60 s live-location minimum. +- **v10:** required `MessageComposerConfig.polls: PollComposerConfig` (`{ enabled: true }`), `MessageComposerConfig.retainCompositionOnSubmit: (composer) => boolean` (default `!!composer.pollId`), `AttachmentManagerConfig.enabled` (`true`), `.customCdn` (`false`), `.pendingUploadsEnabled` (`false`), `LocationComposerConfig.minShareDurationMs` (`60000`, replaces the hardcoded constant; a shorter `durationMs` makes `validLocation` null). +- **Migrate:** partials (`updateConfig`, `client.config.set`, constructor `config`) need nothing. Values annotated as a complete config type must add the six fields. +- **Source:** `src/messageComposer/configuration/types.ts:66,103,109,136,149,152,160,172,178`, `src/messageComposer/configuration/configuration.ts:35-39,55,72` (v10); `src/messageComposer/configuration/types.ts:66,101,111`, `src/messageComposer/LocationComposer.ts:29` (v9) + + + +### `applyCommandValidatorOverride` removed + +**Kind:** removed · **Impact:** low · **PR:** #1831 + +- **v9:** exported helper `applyCommandValidatorOverride(targetConfig, sourceConfig?)` that copied `commands.sendValidator` from a partial onto a full config. +- **v10:** removed; config merging assigns function values directly, so `updateConfig({ commands: { sendValidator } })` sets it. +- **Migrate:** delete the call; pass `commands.sendValidator` through `updateConfig` / `client.config.set`. +- **Source:** `src/messageComposer/configuration/commands.configuration.ts:36`, `src/messageComposer/configuration/index.ts:3` (v9) + + + +### `compose()` output shape changed + +**Kind:** behavior · **Impact:** medium + +- **v9:** `localMessage` had `deleted_at: null`, `error: null`, `pinned_at: null`, `quoted_message: null`, `reaction_groups: null`, `Date` timestamps, no `cid`; `localMessage.user` = `client.user` minus `channel_mutes` / `devices` / `mutes`. The edit `message` (via `toUpdatedMessagePayload`) kept `status`, `reaction_scores`, `message_text_updated_at` and dropped `pinned_at`. +- **v10:** those fields are `undefined`; `localMessage.cid` is set; timestamps are ns numbers; `localMessage.user.blocked_user_ids` defaults to `[]`. `message` is a `MessageRequest`; for edits `status`, `reaction_scores`, `message_text_updated_at` are stripped and `pinned_at` / `pin_expires` are kept as `Date`. `localMessage.shared_location` timestamps and `end_at` are ns numbers; `message.shared_location.end_at` is a `Date`. +- **Migrate:** replace `=== null` checks with `== null`; do not deep-equal `localMessage.user` against `client.user`. +- **Source:** `src/messageComposer/messageComposer.ts:1122-1150`, `src/constants.ts:6-30`, `src/messageComposer/middleware/messageComposer/userDataInjection.ts:45`, `src/messageComposer/middleware/messageComposer/sharedLocation.ts:23-45` (v10); `src/messageComposer/messageComposer.ts:900-916` (v9) + + + +### Drafts: request-object `createDraft`, provisional channels skipped + +**Kind:** behavior · **Impact:** low + +- **v9:** `composer.createDraft()` called `channel.createDraft(draft)`; `createDraft` / `deleteDraft` ran for any channel. +- **v10:** calls `channel.createDraft({ message: draft })`; both no-op while `channel.isProvisional` (no id yet). Draft composition seeds `custom: {}`. The optimistic offline draft carries `quoted_message` as the `LocalMessage` (v9 ran `unformatMessage`). +- **Migrate:** overrides and spies of `channel.createDraft` must accept the request object ([channel drafts](#channel-drafts)). +- **Source:** `src/messageComposer/messageComposer.ts:1180,1203,1211` (v10); `src/messageComposer/messageComposer.ts:953-970` (v9) + + + +### `LastComposerChange` timestamps: epoch ms -> ns + +**Kind:** type-shape · **Impact:** low + +- **v9:** `{ draftUpdate: number | null; stateUpdate: number }` in epoch milliseconds (`Date.getTime()`). +- **v10:** `{ draftUpdate: TimestampNS | null; stateUpdate: TimestampNS }` in unix nanoseconds (`nowNs()` / message `updated_at`). Also on `EditingAuditState.lastChange`. +- **Migrate:** compare with `nowNs()` or convert with `nsToMs` / `nsToDate`. +- **Source:** `src/messageComposer/messageComposer.ts:71` (v10); `src/messageComposer/messageComposer.ts:55` (v9) + + + +### Commands: `CommandResponse` -> generated `Command` + +**Kind:** signature · **Impact:** medium + +- **v9:** composer command APIs took and returned `CommandResponse` (all fields optional, `name` / `set` typed `CommandVariants`, ISO string timestamps). +- **v10:** they use generated `Command` (`args`, `description`, `name`, `set` required `string`; `created_at` / `updated_at` `TimestampNS`). `CommandSearchSource.query` reads `channel.config.availableCommands` (was `channel.getConfig()?.commands`). + + | API | v9 | v10 | + | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- | + | `MessageComposer.getCommandDisabledReason` / `isCommandDisabled` / `validateCommandSendability` | `(command: CommandResponse, ...)` | `(command: Command, ...)` | + | `TextComposer.command`, `setCommand`; `TextComposerState.command` (plus snapshot / executor-state / `TextComposerCommandActivationStateToRestore`) | `CommandResponse \| null` | `Command \| null` | + | `CommandSuggestion` | `BaseSuggestion & CommandResponse` | `BaseSuggestion & Command` | + | `CommandSendability.command`, `CommandSendValidationContext.command` (`commands.sendValidator` input) | `CommandResponse` | `Command` | + | `TextComposerCommandActivationEffect.command` | `CommandResponse` | `Command` | + | `getCommandByName` return, `notifyCommandDisabled` param | `CommandResponse` | `Command` | + | `CommandSearchSource.query().items` | `CommandResponse`-like with `id: CommandVariants` | `Command` | + +- **Migrate:** retype to `Command`; custom `sendValidator`s and command UIs reading `created_at` must handle ns numbers; objects passed to `setCommand` need all four string fields. +- **Source:** `src/messageComposer/messageComposer.ts:427`, `src/messageComposer/textComposer.ts:401`, `src/messageComposer/middleware/textComposer/types.ts:18,133`, `src/messageComposer/configuration/types.ts:43-56`, `src/messageComposer/middleware/textComposer/commandUtils.ts:50`, `src/messageComposer/middleware/textComposer/commands.ts:40-41` (v10) + + + +### Composition middleware state retyped onto generated request types + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `MessageComposerMiddlewareState.message: Message | UpdatedMessage`; `MessageDraftComposerMiddlewareValueState.draft: DraftMessagePayload`; `PollComposerCompositionMiddlewareValueState.data: CreatePollData`. +- **v10:** `message: MessageRequest`; `draft: MessageRequest`; `data: CreatePollRequest`. Custom fields live under `custom`, request dates are `Date`, `mentioned_users` is `string[]`. +- **Migrate:** custom composition, draft and poll middleware must write custom data into `state.message.custom` and use `Date` for `pinned_at` / `pin_expires` / `shared_location.end_at`. +- **Source:** `src/messageComposer/middleware/messageComposer/types.ts:6,19`, `src/messageComposer/middleware/pollComposer/types.ts:52` (v10); `src/messageComposer/middleware/messageComposer/types.ts:12,25`, `src/messageComposer/middleware/pollComposer/types.ts:48` (v9) + + + +### Other composer members retyped + +**Kind:** signature · **Impact:** low + +- **v9:** `MessageComposerState.quotedMessage` / `composer.quotedMessage`: `LocalMessageBase | null`; `initStateFromChannelResponse(channelApiResponse: ChannelAPIResponse)`. +- **v10:** `LocalMessage | null`; `initStateFromChannelResponse(channelApiResponse: ChannelStateResponseFields)` (guards `channel?.cid`). +- **Migrate:** rename types ([rename table](#type-rename-table)). +- **Source:** `src/messageComposer/messageComposer.ts:127,744` (v10); `src/messageComposer/messageComposer.ts:93,543` (v9) + + + +### Composer classes gained members (hand-built fakes no longer type-check) + +**Kind:** type-shape · **Impact:** low + +- **v9:** class shapes without the members below. +- **v10:** added public members, so object literals and mocks typed as these classes, or v9-typed instances, fail structural checks (root of most `*Options` / `create*Middleware` compat failures that reference `MessageComposer`). + + | class | added | + | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `MessageComposer` | `defaultSubmitTarget`, `retainsCompositionOnSubmit`, `requestedConfig`, `initializeConfig`, `send`, `update`, `applyServerRestrictions` (@internal), `transferTo` (@internal) | + | `AttachmentManager` | `usesStreamStorage`, `takeOver` (@internal) | + | `TextComposer` | `typing`, `setTyping`, `setTypingEvent`, `removeTypingEvent`, `takeOver` (@internal) | + | `LinkPreviewsManager`, `LocationComposer`, `PollComposer`, `CustomDataManager` | `takeOver` (@internal) | + | `FixedSizeQueueCache` | `values()` | + +- **Migrate:** build fakes from real instances or cast; no change for code using real instances. +- **Source:** `src/messageComposer/messageComposer.ts:491,505,591,694,715,797,1350,1385`, `src/messageComposer/attachmentManager.ts:203,300`, `src/messageComposer/textComposer.ts:244-266,282`, `src/utils/FixedSizeQueueCache.ts:82` (v10) + + + +### Link previews on by default + +**Kind:** behavior · **Impact:** high · **PR:** #1831 + +- **v9:** `DEFAULT_LINK_PREVIEW_MANAGER_CONFIG.enabled = false`; `linkPreviewsManager.enabled` = `channel.getConfig()?.url_enrichment && config.linkPreviews.enabled`; enrichment via `client.enrichURL(url)`. +- **v10:** default `true`, ANDed with `url_enrichment` through config resolution ([server restrictions](#composer-server-restrictions)), so wherever the channel type enriches URLs the composer issues `client.getOG({ url })` requests while typing and attaches previews. Before the channel config is known, `url_enrichment` is undefined and does not restrict. +- **Migrate:** to keep v9 behaviour, `client.config.set({ messageComposer: { linkPreviews: { enabled: false } } })`. Spies or mocks on `client.enrichURL` no longer intercept. +- **Source:** `src/messageComposer/configuration/configuration.ts:15-17`, `src/messageComposer/linkPreviewsManager.ts:158,247` (v10); `src/messageComposer/configuration/configuration.ts:13-15`, `src/messageComposer/linkPreviewsManager.ts:160,242` (v9) + + + +### Local upload metadata moved under `custom`; identity guards read `custom.mime_type` + +**Kind:** type-shape · **Impact:** high · **PR:** #1760 + +- **v9:** `AttachmentManager.toLocalUploadAttachment` / `fileToLocalUploadAttachment` put `file_size`, `mime_type` at the attachment root and copied `FileReference.duration` to the root for any type (e.g. video). `isFileAttachment` / `isVideoAttachment` classified by root `mime_type`. Composer attachment types declared root `duration`, `file_size`, `mime_type`, `waveform_data`. +- **v10:** `file_size` / `mime_type` go to `attachment.custom`; `duration` goes to `custom.duration` only for `voiceRecording` (a video's duration is dropped). `isFileAttachment` / `isVideoAttachment` read `custom?.mime_type` only, so hand-built or legacy objects with root `mime_type` are no longer classified by mime. New helpers `resolveAttachmentFileSize` / `resolveAttachmentFullByteSize` fall back to the root field. Type side: [attachment metadata](#attachment-metadata-custom). +- **Migrate:** read `attachment.custom?.mime_type` / `.file_size` / `.duration` (or `resolveAttachmentFileSize(a)`); put `mime_type` under `custom` on attachments you build for the identity guards. +- **Source:** `src/messageComposer/attachmentManager.ts:523,558`, `src/messageComposer/attachmentIdentity.ts:69-80,115-125`, `src/messageComposer/attachmentUtils.ts:31` (v10); `src/messageComposer/attachmentManager.ts:463,494`, `src/messageComposer/attachmentIdentity.ts:57,101` (v9) + + + +### Attachment-shaped composer types derive from the generated `Attachment` + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `FileAttachment`, `AudioAttachment`, `VideoAttachment`, `ImageAttachment`, `GiphyAttachment`, `VoiceRecordingAttachment` (and `LocalFileAttachment`, `LocalAudioAttachment`, `LocalVideoAttachment`, `LocalImageAttachment`, `LocalVoiceRecordingAttachment`, `AnyLocalAttachment`, `LocalUploadAttachment`, `UploadedAttachment`) re-declared fields such as `asset_url`, `title`, `thumb_url`, `image_url`, `giphy: GiphyData`, `thumbnail_url`, `latitude`, `longitude`; `VoiceRecordingAttachment.asset_url` was required. `LinkPreview` was the hand-written OG shape. +- **v10:** each is `Attachment & { type: '' }` over the generated `Attachment` (`custom` required; `giphy` is `Images`; no `thumbnail_url`); `VoiceRecordingAttachment.asset_url` is optional and it adds `custom: CustomAttachmentData & { duration?; waveform_data? }`. `LinkPreview = OGAttachment & { status }`, so it requires `custom` (the manager stores `custom: {}`) and `LinkPreviewsManager.getPreviewData()` returns it. Middleware typed on `LocalUploadAttachment` (pre/post-upload) follows. +- **Migrate:** add `custom: {}` to attachment and preview literals; null-check `asset_url` on voice recordings; read `waveform_data` / `duration` from `custom`. +- **Source:** `src/messageComposer/types.ts:60-86`, `src/messageComposer/linkPreviewsManager.ts:9,310,338`, `src/types.ts:233` (v10); `src/messageComposer/types.ts:59-108` (v9) + + + +### Upload gating: `customCdn` replaces the custom-upload waiver; `enabled` and `uploads` enforced + +**Kind:** behavior · **Impact:** medium · **PR:** #1831 + +- **v9:** `isUploadEnabled = hasUploadPermission && hasAvailableUploadSlots`; `uploadFiles` skipped the `upload-file` capability check whenever a custom `doUploadRequest` was configured (`hasCustomDoUploadRequest`). +- **v10:** `isUploadEnabled = config.enabled && hasAvailableUploadSlots && (!usesStreamStorage || hasUploadPermission)`, with `usesStreamStorage = !config.customCdn`; `uploadFiles` and the deprecated `uploadAttachment` both return early on `!isUploadEnabled`. A custom `doUploadRequest` without `customCdn: true` now needs `upload-file` and is subject to the channel type's `uploads` flag. `attachments.enabled: false` blocks even with `customCdn`. +- **Migrate:** if files go to your own storage, `client.config.set({ messageComposer: { attachments: { customCdn: true, doUploadRequest } } })`. Gate the attach button on `attachmentManager.isUploadEnabled`. +- **Source:** `src/messageComposer/attachmentManager.ts:203,219-225,823`, `src/messageComposer/messageComposer.ts:642` (v10); `src/messageComposer/attachmentManager.ts:181,785` (v9) + + + +### Default upload request: `uploadImage` / `uploadFile`, throws without a file URL + +**Kind:** behavior · **Impact:** low + +- **v9:** `doDefaultUploadRequest` called `channel.sendImage/sendFile(uri|file, name, type, undefined, axiosConfig)` and returned the response minus `duration`, typed `{ file; thumb_url?; blocklist? }`, even without `file`. +- **v10:** calls `channel.uploadImage/uploadFile({ file }, requestOptions)` (FileReference passed whole), strips `duration` and `metadata`, throws `The upload succeeded but the response carried no file URL` when `file` is missing (surfaces as a failed upload). `doDefaultUploadRequest` / `doUploadRequest` return `MinimumUploadRequestResult`. +- **Migrate:** spies on `channel.sendImage/sendFile` no longer intercept composer uploads; stub `uploadImage/uploadFile` or set `doUploadRequest`. +- **Source:** `src/messageComposer/attachmentManager.ts:624-670` (v10); `src/messageComposer/attachmentManager.ts:552-601` (v9) + + + +### `isSharedLocationResponse` -> `isSharedLocationResponseData` + +**Kind:** renamed · **Impact:** low · **PR:** #1760 + +- **v9:** `isSharedLocationResponse(location): location is SharedLocationResponse`. +- **v10:** `isSharedLocationResponseData(location): location is SharedLocationResponseData`; same check (`latitude && longitude && channel_cid`). +- **Migrate:** rename the import. +- **Source:** `src/messageComposer/attachmentIdentity.ts:141` (v10); `src/messageComposer/attachmentIdentity.ts:122` (v9) + + + +### Location preview types, `validLocation` shape and hydration + +**Kind:** type-shape · **Impact:** medium + +- **v9:** `StaticLocationPreview = StaticLocationPayload`, `LiveLocationPreview = Omit & { durationMs? }` (`message_id`, `created_by_device_id` required); `validLocation: StaticLocationPayload | LiveLocationPayload | null`, spread from state with `end_at` an ISO string computed only from `durationMs`; `initState` stored `message.shared_location` verbatim; minimum duration hardcoded 60 s. +- **v10:** `StaticLocationPreview = SharedLocation & { message_id? }`, `LiveLocationPreview = Omit & { durationMs?; message_id? }`; `validLocation: StaticLocationPreview | null`, built field by field (`created_by_device_id`, `latitude`, `longitude`, `message_id`, `end_at` as `Date`), keeping a hydrated absolute `end_at` on edit instead of dropping it; `initState` keeps only those fields and converts ns `end_at` to `Date` (`LocationComposerState.location`, `LocationComposerSnapshot` follow); minimum is `location.minShareDurationMs`. +- **Migrate:** format `end_at` with `.toISOString()` at read sites; read `channel_cid` / `user_id` / timestamps off the message, not `locationComposer.location`. +- **Source:** `src/messageComposer/LocationComposer.ts:13-20,39-58,83-117`, `src/messageComposer/configuration/types.ts:149` (v10); `src/messageComposer/LocationComposer.ts:17-21,29-37,62-77` (v9) + + + +### `user_id` removed from the poll composer + +**Kind:** removed · **Impact:** low · **PR:** #1760 + +- **v9:** `pollComposer.user_id` getter; `state.data.user_id` seeded from `client.user.id` and sent in the create-poll payload; `user_id` was a key of `UpdateFieldsData`, `PollComposerState.data`, `handleFieldBlur`'s field union and the processor/validator records (`pollCompositionStateProcessors`, `PollComposerStateMiddlewareFactoryOptions` `processors` / `validators` / `targetFields`, `PollCompositionStateProcessorOutput`). +- **v10:** gone from the getter, state, payload and all those key unions. The `voting_visibility` default is the string `'public'` ([VotingVisibility](#voting-visibility)). +- **Migrate:** use `client.userId`; remove `user_id` processors and validators. +- **Source:** `src/messageComposer/pollComposer.ts:42-48`, `src/messageComposer/middleware/pollComposer/types.ts:35-47` (v10); `src/messageComposer/pollComposer.ts:48,79`, `src/messageComposer/middleware/pollComposer/types.ts:30-48` (v9) + + + +### Poll field errors: strings -> `PollComposerValidationError` objects + +**Kind:** type-shape · **Impact:** medium · **PR:** #1830 + +- **v9:** `PollComposerFieldErrors` values were English strings (`'Question is required'`, ...), keyed on `CreatePollData`; validators returned strings. +- **v10:** values are `{ code: PollComposerValidationCode; message: string; metadata? }` keyed on `CreatePollRequest` (`allow_answers`, `allow_user_suggested_options`, `description`, `enforce_unique_vote`, `id`, `is_closed`, `max_votes_allowed`, `name`, `options`, `voting_visibility`, plus new `custom`, `team`; no `user_id`). Codes in `POLL_COMPOSER_VALIDATION_CODE` (`validation:poll:maxVotes:notNumeric|outOfRange|uniqueVoteEnforced`, `validation:poll:name:required`, `validation:poll:option:duplicate|empty`). Applies to `PollComposerState.errors`, `PollComposerSnapshot`, `PollComposerStateChangeMiddlewareValue`, `PollComposerCompositionMiddlewareValueState`, `PollStateValidationOutput`, the default validator maps (`defaultPollFieldBlurEventValidators`, `defaultPollFieldChangeEventValidators`, `pollStateChangeValidators`), and `updateFields(data, injectedFieldErrors)`. +- **Migrate:** render `errors.name?.message` or translate by `.code`; custom validators and injected errors must return `pollComposerValidationError(code, metadata?)` objects. +- **Source:** `src/messageComposer/middleware/pollComposer/types.ts:29`, `src/messageComposer/middleware/pollComposer/state.ts:22-120`, `src/messageComposer/middleware/pollComposer/validation.ts:12-76` (v10); `src/messageComposer/middleware/pollComposer/types.ts:24`, `src/messageComposer/middleware/pollComposer/state.ts:16-90` (v9) + + + +### `TextComposerState.typing` added as required + +**Kind:** type-shape · **Impact:** low · **PR:** #1795 + +- **v9:** no `typing` in text composer state. +- **v10:** `typing: Record` is required on `TextComposerState`, its snapshots (`TextComposerSnapshot`, `TextComposerStateSnapshot`), `TextComposerMiddlewareExecutorState` and `TextComposerCommandActivationStateToRestore`; `channel.state.typing` reads it when the channel has a composer. New `setTyping`, `setTypingEvent`, `removeTypingEvent`. +- **Migrate:** include `typing: {}` in hand-built text composer states and snapshots. (A reset bug is tracked in v10-followups.md.) +- **Source:** `src/messageComposer/middleware/textComposer/types.ts:133`, `src/messageComposer/textComposer.ts:123,137,244`, `src/channel_state.ts:296-307` (v10) + + + +### `MentionsSearchSource` sort options and query shape + +**Kind:** signature · **Impact:** low + +- **v9:** `userSort?: UserSort`, `memberSort?: MemberSort` (object form `{ name: 1 }`; a multi-key `memberSort` object fell back to `[{ user_id: 1 }]`); queries `client.queryUsers(filters, sort, options)` / `channel.queryMembers(filters, sort, options)`. +- **v10:** both `SortParamRequest[]` (`[{ field, direction }]`); the member query uses the first entry only; local member sorting iterates the array; requests are sent as `{ payload: { filter_conditions, sort, ...options } }` (`prepareQueryUsersParams` / `prepareQueryMembersParams`); `queryUsers` returns `FullUserResponse[]`. Filter option types are the generated `Filters<...>`. +- **Migrate:** `memberSort: [{ field: 'name', direction: 1 }]`. +- **Source:** `src/messageComposer/middleware/textComposer/mentions.ts:100-103,309-310,535-538,556-575,585-622` (v10); `src/messageComposer/middleware/textComposer/mentions.ts:101-104,539,564-590` (v9) + + + +## 19. Utilities + +In both versions `src/index.ts` re-exports only `isOwnUser`, `chatCodes`, `logChatPromiseExecution`, `localMessageToNewMessagePayload`, `formatMessage` (plus `promoteChannel` in v9) from `./utils`, plus `FixedSizeQueueCache` and the four concurrency runners. Everything else in `src/utils.ts` / `src/utils/**` is internal. v10 adds root exports from `./utils/time` ([TimestampNS](#timestamp-ns)). `promoteChannel`: [promote-channel](#promote-channel). + + + +### `formatMessage`: no date conversion, `undefined` instead of `null`, memoized + +**Kind:** behavior · **Impact:** medium · **PR:** #1849, #1876 + +- **v9:** `formatMessage(message: MessageResponseBase | MessageResponse | LocalMessage)` always returned a new object; parsed `created_at` / `updated_at` / `deleted_at` / `pinned_at` to `Date`; missing `deleted_at` / `pinned_at` / `error` / `quoted_message` became `null`. +- **v10:** `formatMessage(message: MessageResponse | LocalMessage)`; timestamps stay ns numbers (missing `created_at` / `updated_at` default to `nowNs()`); those fields are `undefined`; the result is memoized in a `WeakMap` keyed on the source object, so a second call with the same (possibly mutated) object returns the identical first result. +- **Migrate:** drop `MessageResponseBase`; use `nsToDate` / `convertTimestampToDate` for `Date`s; do not mutate a message and re-format it, pass a fresh object (`formatMessage({ ...msg, text })`). +- **Source:** `src/utils.ts:220-257`, `src/formatMessageMemo.ts:14-24` (v10); `src/utils.ts:351-381` (v9) + + + +### `localMessageToNewMessagePayload` returns `MessageRequest` with `Date` fields + +**Kind:** signature · **Impact:** low + +- **v9:** returned `Message` with `pinned_at` as an ISO string; `pin_expires`, `message_text_updated_at` and the response-shaped `shared_location` passed through. +- **v10:** returns `MessageRequest`; `pinned_at` / `pin_expires` are `Date`; `shared_location` narrowed to `{ latitude, longitude, created_by_device_id, end_at?: Date }`; `message_text_updated_at` stripped. +- **Migrate:** stop calling `.toISOString()`-dependent code on the result; adjust any persisted pending-send payloads. +- **Source:** `src/utils.ts:397-419,487-525` (v10); `src/utils.ts:480-509` (v9) + + + +### `logChatPromiseExecution` and composer errors go through `chatLoggerSystem` + +**Kind:** behavior · **Impact:** low + +- **v9:** `logChatPromiseExecution` -> `console.warn('failed to do , ran into error: ', error)`; composer draft failures -> `client.logger('error', 'offlineDb:upsertDraft' | 'offlineDb:deleteDraft' | 'messageComposer:getDraft', ...)`; suggestion search errors -> `console.error`. +- **v10:** `chatLoggerSystem` scope `utils`, level `error`, message `Failed to execute "".`; composer failures via scopes `offline-db` / `message-composer` / `text-composer` with sentence messages; output depends on logger configuration ([logging](#logging)). +- **Migrate:** configure `chatLoggerSystem` sinks and levels instead of watching `console.warn` or the old tag strings. +- **Source:** `src/utils.ts:29-35`, `src/messageComposer/messageComposer.ts:1199,1225,1266`, `src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts:82` (v10); `src/utils.ts:38-42` (v9) + + + +### `encodeBase64` / `decodeBase64` removed + +**Kind:** removed · **Impact:** low · **PR:** #1819 + +- **v9:** root exports `encodeBase64(data)`, `decodeBase64(s)`. +- **v10:** removed, no replacement. +- **Migrate:** `btoa` / `atob` (with a UTF-8 aware wrapper if needed). +- **Source:** `src/base64.ts:44-80`, `src/index.ts:1` (v9) + + + +### `Campaign` and `Segment` classes removed + +**Kind:** removed · **Impact:** low · **PR:** #1760 + +- **v9:** `export class Campaign` (`create`, `start`, `update`, `delete`, `stop`, `get`) and `export class Segment` (`create`, `get`, `update`, `addTargets`, `removeTargets`, `delete`, `targetExists`, `queryTargets`), re-exported from the root. +- **v10:** not exported; `src/campaign.ts` and `src/segment.ts` are one-line comment stubs and `index.ts` does not re-export them at all. The `client.campaign()` / `client.segment()` factories and REST methods: [server-only methods](#server-only-methods-removed). +- **Migrate:** use a server-side SDK. +- **Source:** `src/campaign.ts:1`, `src/segment.ts:1`, `src/index.ts:63-89` (v10); `src/campaign.ts:4-77`, `src/segment.ts:17-95`, `src/index.ts:2,23` (v9) + + + +### `permissions` module removed + +**Kind:** removed · **Impact:** low · **PR:** #1836 + +- **v9:** `Permission` class, `AllowAll`, `DenyAll` (instances), `Allow`, `Deny`, `AnyResource`, `AnyRole`, `MaxPriority`, `MinPriority`, `BuiltinRoles`, `BuiltinPermissions` (values such as `'Create Message'`), and type `Role`. +- **v10:** `src/permissions.ts` deleted; all runtime values gone (imports are compile errors and `undefined` at runtime in untyped code). The name `Role` now refers to the generated role object ([same-name changes](#same-name-meaning-changes)). +- **Migrate:** inline the role and permission strings you used; manage permissions server-side. +- **Source:** `src/index.ts` (v10, no `./permissions`); `src/permissions.ts:5-103`, `src/index.ts:18` (v9) + + + +## 20. Full type rename table + +**Kind:** renamed · **Impact:** high · **PR:** #1760, #1836, #1896 + +All 384 hand-written v9 type exports from `src/types.ts` (383) and `src/events.ts` (1) that no longer exist under the same name, with their v10 mapping. Categories: renamed (same role), renamed with a shape change, derived expression, sort alias ([sort](#sort-shape)), removed with the API that used it (mostly server-side/admin methods removed from the client: use `@stream-io/node-sdk`), removed with no replacement. The "verified delta" column is computed member by member ("drops" = v9 members missing in v10; date fields that only changed to `TimestampNS` are counted, see [TimestampNS](#timestamp-ns)). Names that survive with a different meaning are in [same-name changes](#same-name-meaning-changes). Facts worth noting: `AppSettings` (the `updateAppSettings` input) has no client replacement (`AppResponseFields` is the read shape, not a replacement); `FlagMessageResponse`, `FlagUserResponse`, `MuteUserResponse`, `UnmuteUserResponse` are all removed; `ReminderAPIResponse` maps to `CreateReminderResponse` / `UpdateReminderResponse`, `PollAnswer` to `PollVoteResponseData`, `FlagMessageResponse` to `FlagItemResponse`; `UsersAPIResponse` (`users: UserResponse[]`) maps to `QueryUsersResponse`. + +- **Migrate:** rename imports per row; for derived rows write the expression or inline the type. +- **Source:** `src/types.ts`, `src/gen/models/index.ts` (v10); `src/types.ts`, `src/events.ts` (v9) + +### Renamed (same role, generated name) (92) + +| v9 | v10 | verified delta / note | +| --------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AIImageRule` | `AWSRekognitionRule` | now required min_confidence; 1 field type(s) changed | +| `AITextRule` | `BodyguardRule` | now required action, severity_rules; 2 field type(s) changed | +| `AITextSeverityRule` | `BodyguardSeverityRule` | 2 field type(s) changed | +| `AIVideoConfig` | `AIVideoConfigResponse` | read shape; AIVideoConfigRequest for input. 1 field type(s) changed | +| `AIVideoRule` | `AWSRekognitionRule` | now required min_confidence; 1 field type(s) changed | +| `AddUserGroupMembersOptions` | `AddUserGroupMembersRequest` | | +| `AppSettingsAPIResponse` | `GetApplicationResponse` | drops blocklist; now required app; 1 field type(s) changed | +| `AutomodDetails` | `AutomodDetailsResponse` | 1 field type(s) changed | +| `BannedUsersResponse` | `QueryBannedUsersResponse` | drops blocklist; now required bans; 1 field type(s) changed | +| `BlockUserAPIResponse` | `BlockUsersResponse` | drops blocklist, blocked_at; new required created_at | +| `BlockedUserDetails` | `BlockedUserResponse` | drops duration, blocklist; 1 date field(s) string -> TimestampNS | +| `CastVoteAPIResponse` | `PollVoteResponse` | new required duration; now optional vote; 1 field type(s) changed | +| `ChannelAPIResponse` | `ChannelStateResponseFields` | now required threads; now optional channel; 5 field type(s) changed | +| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | = ChannelConfigWithInfo['automod_behavior'] | +| `ChannelConfigAutomodThresholds` | `Thresholds` | null no longer allowed | +| `ChannelConfigAutomod` | `Automod` | = ChannelConfigWithInfo['automod'] | +| `ChannelMemberAPIResponse` | `MembersResponse` | drops blocklist | +| `ChannelPushPreference` | `ChannelPushPreferencesResponse` | 1 date field(s) string -> TimestampNS; 2 field type(s) changed | +| `ChannelQueryOptions` | `ChannelGetOrCreateRequest` | drops client_id, connection_id, created_by, created_by_id; 4 field type(s) changed | +| `CommandResponse` | `Command` | now required args, description, name, set; 2 date field(s) string -> TimestampNS; 2 field type(s) changed | +| `CreatePollData` | `CreatePollRequest` | custom poll fields under custom. drops user_id; 2 field type(s) changed | +| `CreatePollOptionAPIResponse` | `PollOptionResponse` | v10 PollOptionResponse is the { duration, poll_option } envelope (name reuse). new required duration; 1 field type(s) changed | +| `CreateUserGroupOptions` | `CreateUserGroupRequest` | | +| `DeleteChannelAPIResponse` | `DeleteChannelResponse` | drops blocklist; now optional channel | +| `DeliveredMessageConfirmation` | `DeliveredMessagePayload` | drops parent_id; now optional cid, id | +| `ErrorFromResponse` | `StreamAPIError` | runtime class. 2 field type(s) changed | +| `EventTypes` | `EventType` | | +| `FlagDetails` | `FlagDetailsResponse` | new required original_text; 1 field type(s) changed | +| `FormatMessageResponse` | `LocalMessage` | was alias of LocalMessage. drops args, before_message_send_failed, channel, command_info, moderation_details; new required custom; now required attachments, html, pinned, restricted_visibility (+14); now optional deleted_at, pinned_at; 6 date field(s) -> TimestampNS (created_at, updated_at, deleted_at, pinned_at were Date; pin_expires, message_text_updated_at were string); 15 field type(s) changed | +| `FutureChannelBan` | `FutureChannelBanResponse` | now optional user; 2 date field(s) string -> TimestampNS | +| `FutureChannelBansResponse` | `QueryFutureChannelBansResponse` | drops blocklist; 1 field type(s) changed | +| `GetBlockedUsersAPIResponse` | `GetBlockedUsersResponse` | drops blocklist; 1 field type(s) changed | +| `GetMessageAPIResponse` | `GetMessageResponse` | drops blocklist, pending_message_metadata; 1 field type(s) changed | +| `GetMultipleMessagesAPIResponse` | `GetManyMessagesResponse` | drops blocklist | +| `GetPollAPIResponse` | `PollResponse` | v10 PollResponse is the { duration, poll } envelope (name reuse). new required duration; 1 field type(s) changed | +| `GetPollOptionAPIResponse` | `PollOptionResponse` | envelope { duration, poll_option }. new required duration; 1 field type(s) changed | +| `GetReactionsAPIResponse` | `GetReactionsResponse` | channel.getReactions -> client.getReactions({ id }). drops blocklist | +| `GetRepliesAPIResponse` | `GetRepliesResponse` | drops blocklist | +| `GetThreadAPIResponse` | `GetThreadResponse` | partialUpdateThread -> updateThreadPartial returns UpdateThreadPartialResponse. drops blocklist; 1 field type(s) changed | +| `GiphyData` | `Images` | 7 field type(s) changed | +| `LLMSeverityRule` | `BodyguardSeverityRule` | 2 field type(s) changed | +| `MessageFlagsResponse` | `QueryMessageFlagsResponse` | drops blocklist; now required flags; 1 field type(s) changed | +| `MessagePaginationOptions` | `MessagePaginationParams` | no offset. drops offset; 5 field type(s) changed | +| `ModerationActionConfig` | `ModerationActionConfigResponse` | 1 field type(s) changed | +| `ModerationFlag` | `ModerationFlagResponse` | drops id, moderation_payload_hash; new required type, user_id; now optional custom, entity_creator_id, reason, review_queue_item_id (+1); 2 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `ModerationResult` | `MessageModerationResult` | 2 date field(s) string -> TimestampNS | +| `MuteChannelAPIResponse` | `MuteChannelResponse` | drops blocklist, mute; now optional channel_mute, own_user | +| `Mute` | `UserMuteResponse` | now optional target, user; 2 date field(s) string -> TimestampNS | +| `PartialMessageUpdate` | `UpdateMessagePartialRequest` | partialUpdateMessage -> updateMessagePartial({ id }, request). 2 field type(s) changed | +| `PartialThreadUpdate` | `UpdateThreadPartialRequest` | 1 field type(s) changed | +| `PartialUpdateChannelAPIResponse` | `UpdateChannelPartialResponse` | drops blocklist; now optional channel | +| `PartialUpdateChannel` | `UpdateChannelPartialRequest` | 2 field type(s) changed | +| `PartialUpdateMemberAPIResponse` | `UpdateMemberPartialResponse` | drops blocklist; now optional channel_member | +| `PartialUpdateMember` | `UpdateMemberPartialRequest` | 2 field type(s) changed | +| `PartialUserUpdate` | `UpdateUserPartialRequest` | partialUpdateUser(s) removed; updateUsersPartial({ users }). 2 field type(s) changed | +| `PollAnswer` | `PollVoteResponseData` | now required option_id; now optional answer_text, is_answer; 2 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `PollAnswersAPIResponse` | `PollVotesResponse` | new required duration; 1 field type(s) changed | +| `PollVote` | `PollVoteResponseData` | now required option_id; 2 date field(s) string -> TimestampNS | +| `PollVotesAPIResponse` | `PollVotesResponse` | new required duration; 1 field type(s) changed | +| `PrivacySettings` | `PrivacySettingsResponse` | UserResponse no longer has privacy_settings (OwnUserResponse only). 3 field type(s) changed | +| `PushPreference` | `PushPreferenceInput` | setPushPreferences -> updatePushNotificationPreferences({ preferences }). 4 field type(s) changed | +| `QueryChannelAPIResponse` | `ChannelStateResponse` | drops blocklist; now required threads; now optional channel; 5 field type(s) changed | +| `QueryChannelsAPIResponse` | `QueryChannelsResponse` | drops blocklist; 2 field type(s) changed | +| `QueryConfigsResponse` | `QueryModerationConfigsResponse` | moderation.queryConfigs -> moderation.queryModerationConfigs. new required duration; 1 field type(s) changed | +| `QueryReactionsAPIResponse` | `QueryReactionsResponse` | drops blocklist | +| `QueryRemindersOptions` | `QueryRemindersRequest` | 2 field type(s) changed | +| `QueryThreadsAPIResponse` | `QueryThreadsResponse` | drops blocklist; 1 field type(s) changed | +| `QueryThreadsOptions` | `QueryThreadsRequest` | 2 field type(s) changed | +| `QueryUserGroupsOptions` | `ListUserGroupsOptions` | queryUserGroups -> listUserGroups | +| `QueryUserGroupsResponse` | `ListUserGroupsResponse` | drops blocklist | +| `ReactionAPIResponse` | `SendReactionResponse` | DeleteReactionResponse for deleteReaction. drops blocklist | +| `ReadResponse` | `ReadStateResponse` | now required unread_messages; 2 date field(s) string -> TimestampNS | +| `ReminderAPIResponse` | `CreateReminderResponse` | UpdateReminderResponse for updateReminder. drops blocklist; 1 field type(s) changed | +| `ReminderResponseBase` | `ReminderResponseData` | 3 date field(s) string -> TimestampNS | +| `ReminderResponse` | `ReminderResponseData` | now optional user, message; 3 date field(s) string -> TimestampNS | +| `RemoveUserGroupMembersOptions` | `RemoveUserGroupMembersRequest` | | +| `RequestOptions` | `StreamRequestOptions` | adds onUploadProgress, timeout | +| `ReviewQueueResponse` | `QueryReviewQueueResponse` | new required duration; now required action_config, stats; 3 field type(s) changed | +| `RoleResponse` | `Role` | v10 Role is this object (name reuse; v9 Role was the role-name union). 2 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `SearchAPIResponse` | `SearchResponse` | drops blocklist; 2 field type(s) changed | +| `SearchRolesAPIResponse` | `SearchRolesResponse` | drops blocklist; 1 field type(s) changed | +| `SharedStaticLocationResponse` | `SharedLocationResponseData` | 2 date field(s) string -> TimestampNS | +| `TranslationLanguages` | `TranslationLanguage` | | +| `TruncateChannelAPIResponse` | `TruncateChannelResponse` | drops blocklist; now optional channel | +| `TruncateOptions` | `TruncateChannelRequest` | drops user, user_id; 1 field type(s) changed | +| `UnmuteUserResponse` | `UnmuteResponse` | drops blocklist | +| `UpdateChannelAPIResponse` | `UpdateChannelResponse` | v10 UpdateChannelResponse replaces the v9 hand-written channel-type one (name reuse). drops blocklist; now optional channel | +| `UpdateChannelOptions` | `UpdateChannelRequest` | drops add_moderators, client_id, connection_id, demote_moderators, user, user_id; 4 field type(s) changed | +| `UpdateLocationPayload` | `UpdateLiveLocationRequest` | end_at is Date. drops created_by_device_id, user, user_id; 1 field type(s) changed | +| `UpdateUserGroupOptions` | `UpdateUserGroupRequest` | | +| `UpdateUsersAPIResponse` | `UpdateUsersResponse` | drops blocklist; now required membership_deletion_task_id; 1 field type(s) changed | +| `UserGroupMemberResponse` | `UserGroupMember` | new required app_pk; 1 date field(s) string -> TimestampNS | + +### Renamed with a shape change that needs code edits (46) + +| v9 | v10 | verified delta / note | +| -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APIErrorResponse` | `APIError` | StatusCode -> status_code. drops StatusCode; new required status_code; now required code, details; 1 field type(s) changed | +| `ActiveLiveLocationsAPIResponse` | `SharedLocationsResponse` | getSharedLocations -> getUserLiveLocations. drops blocklist; 1 field type(s) changed | +| `BaseDeviceFields` | `DeviceResponse` | read shape; CreateDeviceRequest for client.createDevice input. new required created_at, user_id; 1 field type(s) changed | +| `BlockList` | `CreateBlockListRequest` | input of createBlockList; BlockListResponse for reads. drops validate; 1 field type(s) changed | +| `ChannelData` | `ChannelInput` | custom fields move under custom. drops blocked, blocklist_behavior, automod; 2 field type(s) changed | +| `CreatePollAPIResponse` | `PollResponse` | v10 PollResponse is the { duration, poll } envelope (name reuse). new required duration; 1 field type(s) changed | +| `CreateReminderOptions` | `CreateReminderRequest` | messageId -> pathParams { message_id }; remind_at Date. drops messageId, user_id; 1 field type(s) changed | +| `DeviceFields` | `DeviceResponse` | new required user_id; 1 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `Device` | `DeviceResponse` | created_at TimestampNS; no provider/user. drops provider, user; now required user_id; 1 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `DraftMessagePayload` | `MessageRequest` | createDraft({ message: MessageRequest }). drops html, user_id; now optional text; 3 field type(s) changed | +| `EventAPIResponse` | `EventResponse` | sendEvent; markRead -> MarkReadResponse; markChannelsDelivered -> MarkDeliveredResponse. drops blocklist; 1 field type(s) changed | +| `FlagMessageResponse` | `FlagItemResponse` | client.flagMessage -> client.moderation.flagMessage. drops blocklist, flag, review_queue_item_id; new required item_id | +| `FlagUserResponse` | `FlagItemResponse` | client.flagUser -> client.moderation.flagUser. drops blocklist, flag, review_queue_item_id; new required item_id | +| `GetUnreadCountAPIResponse` | `WrappedUnreadCountsResponse` | getUnreadCount(userID?) -> unreadCounts(). drops blocklist; 3 field type(s) changed | +| `LiveLocationPayload` | `SharedLocation` | sendSharedLocation(SharedLocation & { message_id? }); end_at Date. drops message_id; now optional created_by_device_id, end_at; 1 field type(s) changed | +| `LocalMessageBase` | `LocalMessage` | no Date conversion: created_at etc. stay TimestampNS. drops args, before_message_send_failed, channel, command_info, moderation_details; new required custom; now required attachments, html, pinned, restricted_visibility (+14); now optional deleted_at, pinned_at; 6 date field(s) -> TimestampNS (created_at, updated_at, deleted_at, pinned_at were Date; pin_expires, message_text_updated_at were string); 13 field type(s) changed | +| `MarkChannelsReadOptions` | `MarkChannelsReadRequest` | user/user_id overrides gone (connected user only). drops client_id, connection_id, user, user_id | +| `MarkDeliveredOptions` | `MarkDeliveredRequest` | user/user_id overrides gone. drops user, user_id; now optional latest_delivered_messages; 1 field type(s) changed | +| `MarkReadOptions` | `MarkReadRequest` | user/user_id overrides gone. drops client_id, connection_id, user, user_id | +| `MarkUnreadOptions` | `MarkUnreadRequest` | user/user_id overrides gone; message_timestamp Date only. drops client_id, connection_id, user, user_id; 1 field type(s) changed | +| `MessageBase` | `MessageRequest` | input; MessageResponse for reads. drops html, user, user_id; now optional id; 3 field type(s) changed | +| `MessageResponseBase` | `MessageResponse` | drops user_id, args, before_message_send_failed, channel, command_info, moderation_details (+1); new required custom; now required attachments, html, pinned, restricted_visibility (+16); 6 date field(s) string -> TimestampNS; 13 field type(s) changed | +| `Message` | `MessageRequest` | custom fields move under custom. drops html, user, user_id; 4 field type(s) changed | +| `ModerationConfigResponse` | `ConfigResponse` | new required async, supported_video_call_harm_types; now required team; 2 date field(s) string -> TimestampNS; 1 field type(s) changed | +| `ModerationConfig` | `UpsertConfigRequest` | moderation.upsertConfig input. 1 field type(s) changed | +| `ModerationDetailsResponse` | `ModerationV2Response` | MessageResponse.moderation_details removed; read MessageResponse.moderation. drops error_msg, harms; 1 field type(s) changed | +| `ModerationMuteOptions` | `MuteRequest` | moderation.muteUser(targetId, options) -> moderation.mute({ target_ids, timeout }). drops user_id; new required target_ids | +| `MuteUserOptions` | `MuteRequest` | client.muteUser removed; client.moderation.mute({ target_ids, timeout }). drops client_id, connection_id, id, reason, target_user_id, type (+2); new required target_ids | +| `MuteUserResponse` | `MuteResponse` | v10 MuteResponse is the /moderation/mute envelope (name reuse). drops blocklist, mute; 1 field type(s) changed | +| `NewMemberPayload` | `ChannelMemberRequest` | custom member fields under custom. 1 field type(s) changed | +| `PaginationOptions` | `PaginationParams` | channel.query members/watchers; MessagePaginationParams for messages. drops created_at_after, created_at_after_or_equal, created_at_before, created_at_before_or_equal; 4 field type(s) changed | +| `PollData` | `UpdatePollRequest` | custom poll fields under custom. drops user_id; 2 field type(s) changed | +| `PollOptionData` | `PollOptionInput` | createOption -> CreatePollOptionRequest; updateOption -> UpdatePollOptionRequest; position dropped. drops id, position; now optional text | +| `PollOption` | `PollOptionResponseData` | only id, text, custom; vote_count/votes/created_at/poll_id gone. drops created_at, poll_id, updated_at, vote_count, votes; new required custom; 1 field type(s) changed | +| `PollVoteData` | `VoteData` | castPollVote({ message_id, poll_id }, { vote }); is_answer dropped. drops is_answer | +| `QueryFutureChannelBansOptions` | `QueryFutureChannelBansPayload` | queryFutureChannelBans({ payload }); user_id dropped. drops user_id | +| `ReviewQueueItem` | `ReviewQueueItemResponse` | drops actions_taken, appealed_by, context, created_by, has_image, has_text (+4); new required ai_text_severity, escalated, flags_count, reviewed_by (+4); now required latest_moderator_action; now optional assigned_to, completed_at, config_key, moderation_payload (+1); 4 date field(s) string -> TimestampNS; 4 field type(s) changed | +| `SendFileAPIResponse` | `FileUploadResponse` | client.uploadFile; ImageUploadResponse (uploadImage); UploadChannelFileResponse / UploadChannelResponse (channel.uploadChannelFile / uploadChannelImage). drops blocklist; now optional file | +| `StaticLocationPayload` | `SharedLocation` | message_id moved to sendSharedLocation arg. drops message_id; now optional created_by_device_id | +| `StreamChatOptions` | `InstanceConfigTree` | see [StreamChatOptions keys](#streamchat-options). drops url, method, baseURL, allowAbsoluteUrls, transformRequest, transformResponse (+59); new required channel, client, liveLocationManager, messageComposer (+4) | +| `SubmitActionOptions` | `SubmitActionRequest` | drops end_call, kick_user, user_id; new required action_type; 16 field type(s) changed | +| `UpdatePollAPIResponse` | `PollResponse` | envelope { duration, poll } (name reuse). new required duration; 1 field type(s) changed | +| `UpdatePollOptionAPIResponse` | `PollOptionResponse` | envelope { duration, poll_option }. new required duration; 1 field type(s) changed | +| `UpdateReminderOptions` | `UpdateReminderRequest` | messageId -> pathParams { message_id }. drops messageId, user_id; 1 field type(s) changed | +| `UpdatedMessage` | `MessageRequest` | drops status, user_id, args, before_message_send_failed, channel, cid (+14); now optional id; 3 field type(s) changed | +| `UsersAPIResponse` | `QueryUsersResponse` | was unreferenced; UpdateUsersResponse for writes. drops blocklist, membership_deletion_task_id; 1 field type(s) changed | + +### Removed: write the derived type (26) + +| v9 | v10 | verified delta / note | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BannedUsersFilterOptions` | removed: use `QueryBannedUsersPayload['filter_conditions']` | | +| `BannedUsersFilters` | removed: use `QueryBannedUsersPayload['filter_conditions']` | | +| `BannedUsersPaginationOptions` | removed: use `Omit` | queryBannedUsers({ payload }) | +| `BlocklistBehavior` | removed: use `ChannelConfigWithInfo['blocklist_behavior']` | | +| `ChannelConfigFields` | removed: use `ChannelConfigWithInfo` | server channel config; was also the base of v9 ChannelConfig. new required created_at, updated_at, commands; now required automod, automod_behavior, connect_events, custom_events (+20); 5 field type(s) changed | +| `ChannelMemberUpdates` | removed: use `UpdateMemberPartialRequest['set']` | | +| `DeleteUserGroupOptions` | removed: use `NonNullable[1]>` | inline { team_id? } | +| `DraftFilters` | removed: use `NonNullable` | | +| `GetUserGroupOptions` | removed: use `NonNullable[1]>` | inline { team_id? } | +| `MessageDeletionStrategy` | removed: use `NonNullable` | | +| `MessageFlagsFiltersOptions` | removed: use `QueryMessageFlagsPayload['filter_conditions']` | | +| `MessageFlagsFilters` | removed: use `QueryMessageFlagsPayload['filter_conditions']` | | +| `MessageFlagsPaginationOptions` | removed: use `Omit` | queryMessageFlags({ payload }) | +| `MessageUpdatableFields` | removed: use `NonNullable` | | +| `Pager` | removed: use `Pick` | pagination fields live on each request | +| `PartialUpdateChannelFields` | removed: use `NonNullable` | | +| `PollEnrichData` | removed: use `Pick` | | +| `QueryModerationConfigsFilters` | removed: use `NonNullable` | | +| `QueryReactionsOptions` | removed: use `Omit` | | +| `ReviewQueueFilters` | removed: use `NonNullable` | | +| `ReviewQueuePaginationOptions` | removed: use `Pick` | | +| `SearchOptions` | removed: use `Pick` | search({ payload }) | +| `SearchRolesOptions` | removed: use `Parameters[0]` | | +| `SendReactionOptions` | removed: use `Omit` | | +| `SyncOptions` | removed: use `Parameters[0]` | watch / with_inaccessible_cids now inside the request | +| `VotesFiltersOptions` | removed: use `NonNullable` | | + +### Removed sort aliases (see [sort](#sort-shape)) (29) + +| v9 | v10 | verified delta / note | +| ---------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `AscDesc` | removed: use `SortParamRequest['direction']` | direction is number | +| `BannedUsersSortBase` | removed: use `SortParamRequest` | drops created_at | +| `BannedUsersSort` | removed: use `SortParamRequest[]` | | +| `ChannelSortBase` | removed: use `SortParamRequest` | drops created_at, has_unread, last_message_at, last_updated, member_count, pinned_at (+2) | +| `ChannelSort` | removed: use `SortParamRequest[]` | | +| `DraftSortBase` | removed: use `SortParamRequest` | drops created_at | +| `DraftSort` | removed: use `SortParamRequest[]` | | +| `MemberSort` | removed: use `SortParamRequest[]` | | +| `PinnedMessagesSortBase` | removed: use `SortParamRequest` | drops pinned_at | +| `PinnedMessagesSort` | removed: use `SortParamRequest[]` | getPinnedMessages({ ...options, sort }) | +| `PollSortBase` | removed: use `SortParamRequest` | drops created_at, id, is_closed, name, updated_at | +| `PollSort` | removed: use `SortParamRequest[]` | | +| `PredefinedFilterSortParam` | removed: use `SortParamRequest` | now optional field; 1 field type(s) changed | +| `PredefinedFilterSort` | removed: use `SortParamRequest[]` | | +| `QueryModerationConfigsSort` | removed: use `SortParamRequest[]` | | +| `QuerySort` | removed: use `SortParamRequest[]` | | +| `ReactionSortBase` | removed: use `SortParamRequest` | drops created_at | +| `ReactionSort` | removed: use `SortParamRequest[]` | | +| `ReminderSort` | removed: use `SortParamRequest[]` | | +| `ReviewQueueSort` | removed: use `SortParamRequest[]` | | +| `SearchMessageSortBase` | removed: use `SortParamRequest` | drops attachments, attachments.type, created_at, id, mentioned_users.id, parent_id (+6); 1 field type(s) changed | +| `SearchMessageSort` | removed: use `SortParamRequest[]` | | +| `SortParam` | removed: use `SortParamRequest` | now optional field; 1 field type(s) changed | +| `Sort` | removed: use `SortParamRequest` | | +| `ThreadSortBase` | removed: use `SortParamRequest` | drops active_participant_count, created_at, last_message_at, parent_message_id, participant_count, reply_count (+1) | +| `ThreadSort` | removed: use `SortParamRequest[]` | | +| `UserSort` | removed: use `SortParamRequest[]` | | +| `VoteSortBase` | removed: use `SortParamRequest` | drops created_at, id, is_closed, name, updated_at | +| `VoteSort` | removed: use `SortParamRequest[]` | | + +### Removed together with the only API that used it (152) + +| v9 | v10 | verified delta / note | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `APNConfig` | removed: no replacement (app-settings admin (updateAppSettings removed); node-sdk) | | +| `AppSettings` | removed: no replacement (was the updateAppSettings input; updateAppSettings removed (node-sdk). AppResponseFields is the read shape, not a replacement) | | +| `AsyncModerationOptions` | removed: no replacement (app-settings admin) | | +| `BatchChannelDataUpdate` | removed: no replacement (ChannelBatchUpdater / updateChannelsBatch removed) | | +| `BatchUpdateOperation` | removed: no replacement (ChannelBatchUpdater / updateChannelsBatch removed) | | +| `CampaignData` | removed: no replacement (campaigns removed (node-sdk)) | | +| `CampaignFilters` | removed: no replacement (campaigns removed) | | +| `CampaignQueryOptions` | removed: no replacement (campaigns removed) | | +| `CampaignResponse` | removed: no replacement (campaigns removed) | | +| `CampaignSort` | removed: no replacement (campaigns removed) | | +| `CampaignStats` | removed: no replacement (campaigns removed) | | +| `ChannelBatchDataUpdateOptions` | removed: no replacement (ChannelBatchUpdater removed) | | +| `CheckPushInput` | removed: no replacement (testPushSettings removed) | | +| `CheckPushResponse` | removed: no replacement (testPushSettings removed) | | +| `CheckResponse` | removed: no replacement (moderation.check / checkUserProfile removed) | | +| `CheckSNSResponse` | removed: no replacement (testSNSSettings removed) | | +| `CheckSQSResponse` | removed: no replacement (testSQSSettings removed) | | +| `CreateChannelOptions` | removed: no replacement (createChannelType removed) | | +| `CreateChannelResponse` | removed: no replacement (createChannelType removed) | | +| `CreateCommandOptions` | removed: no replacement (createCommand removed) | | +| `CreateCommandResponse` | removed: no replacement (createCommand removed) | | +| `CreateImportOptions` | removed: no replacement (imports removed) | | +| `CreateImportResponse` | removed: no replacement (imports removed) | | +| `CreateImportURLResponse` | removed: no replacement (imports removed) | | +| `CreatePredefinedFilterOptions` | removed: no replacement (predefined-filter CRUD removed) | | +| `CreateRoleAPIResponse` | removed: no replacement (createRole removed) | | +| `CustomCheckFlag` | removed: no replacement (moderation.addCustomFlags removed) | | +| `CustomCheckResponse` | removed: no replacement (moderation.addCustomFlags removed) | | +| `CustomPermissionOptions` | removed: no replacement (permission CRUD removed) | | +| `DailyValue` | removed: no replacement (team usage stats removed) | | +| `DeactivateUsersOptions` | removed: no replacement (deactivateUser(s) removed) | | +| `DeleteCampaignOptions` | removed: no replacement (campaigns removed) | | +| `DeleteCommandResponse` | removed: no replacement (deleteCommand removed) | | +| `DeleteRetentionPolicyResponse` | removed: no replacement (retention policies removed) | | +| `DeleteType` | removed: no replacement (deleteUsers removed) | | +| `DeleteUserOptions` | removed: no replacement (deleteUsers removed) | | +| `EndpointName` | removed: no replacement (getRateLimits removed) | | +| `EventHook` | removed: no replacement (app-settings admin) | | +| `ExportChannelOptions` | removed: no replacement (exportChannel(s) removed) | | +| `ExportChannelRequest` | removed: no replacement (exportChannel(s) removed) | | +| `ExportChannelResponse` | removed: no replacement (exportChannel(s) removed) | | +| `ExportChannelStatusResponse` | removed: no replacement (getExportChannelStatus removed) | | +| `ExportUsersRequest` | removed: no replacement (exportUsers removed) | | +| `ExportUsersResponse` | removed: no replacement (exportUsers removed) | | +| `FirebaseConfig` | removed: no replacement (app-settings admin) | | +| `FlagReport` | removed: no replacement (private \_queryFlagReports removed) | | +| `FlagReportsFiltersOptions` | removed: no replacement (private \_queryFlagReports removed) | | +| `FlagReportsFilters` | removed: no replacement (private \_queryFlagReports removed) | | +| `FlagReportsPaginationOptions` | removed: no replacement (private \_queryFlagReports removed) | | +| `FlagReportsResponse` | removed: no replacement (private \_queryFlagReports removed) | | +| `Flag` | removed: no replacement (private \_queryFlags removed; nearest MessageFlagResponse (queryMessageFlags)) | | +| `FlagsFiltersOptions` | removed: no replacement (private \_queryFlags removed) | | +| `FlagsFilters` | removed: no replacement (private \_queryFlags removed) | | +| `FlagsPaginationOptions` | removed: no replacement (private \_queryFlags removed) | | +| `FlagsResponse` | removed: no replacement (private \_queryFlags removed) | | +| `GetCampaignOptions` | removed: no replacement (campaigns removed) | | +| `GetChannelTypeResponse` | removed: no replacement (getChannelType removed) | | +| `GetCommandResponse` | removed: no replacement (getCommand removed) | | +| `GetHookEventsResponse` | removed: no replacement (getHookEvents removed) | | +| `GetImportResponse` | removed: no replacement (imports removed) | | +| `GetRateLimitsResponse` | removed: no replacement (getRateLimits removed) | | +| `GetRetentionPolicyResponse` | removed: no replacement (retention policies removed) | | +| `GetRetentionPolicyRunsOptions` | removed: no replacement (retention policies removed) | | +| `GetRetentionPolicyRunsResponse` | removed: no replacement (retention policies removed) | | +| `GetUnreadCountBatchAPIResponse` | removed: no replacement (getUnreadCountBatch removed) | | +| `GetUserModerationReportOptions` | removed: no replacement (moderation.getUserModerationReport removed) | | +| `GetUserModerationReportResponse` | removed: no replacement (moderation.getUserModerationReport removed) | | +| `HookEvent` | removed: no replacement (getHookEvents removed) | | +| `HookType` | removed: no replacement (app-settings admin) | | +| `HuaweiConfig` | removed: no replacement (app-settings admin) | | +| `ImportTaskHistory` | removed: no replacement (imports removed) | | +| `ImportTask` | removed: no replacement (imports removed) | | +| `ListChannelResponse` | removed: no replacement (listChannelTypes removed) | | +| `ListChannelTypesAPIResponse` | removed: no replacement (listChannelTypes removed) | | +| `ListCommandsResponse` | removed: no replacement (listCommands removed) | | +| `ListImportsPaginationOptions` | removed: no replacement (imports removed) | | +| `ListImportsResponse` | removed: no replacement (imports removed) | | +| `ListPredefinedFiltersOptions` | removed: no replacement (predefined-filter CRUD removed) | | +| `ListPredefinedFiltersResponse` | removed: no replacement (predefined-filter CRUD removed) | | +| `ListRolesAPIResponse` | removed: no replacement (listRoles removed) | | +| `MessageHistoryEntry` | removed: no replacement (queryMessageHistory removed) | | +| `MessageSet` | removed: no replacement (ChannelState.messageSets removed ([message paginator](#message-paginator))) | | +| `MetricStats` | removed: no replacement (queryTeamUsageStats removed) | | +| `ModerationRuleRequest` | removed: no replacement (moderation rules CRUD removed) | | +| `ModerationRule` | removed: no replacement (moderation rules CRUD removed) | | +| `PermissionAPIObject` | removed: no replacement (permission CRUD removed) | | +| `PermissionAPIResponse` | removed: no replacement (getPermission removed) | | +| `PermissionObject` | removed: no replacement (channel-type admin removed) | | +| `PermissionsAPIResponse` | removed: no replacement (listPermissions removed) | | +| `PolicyRequest` | removed: no replacement (channel-type admin removed) | | +| `Policy` | removed: no replacement (app-settings admin) | | +| `PredefinedFilterOperation` | removed: no replacement (predefined-filter CRUD removed) | | +| `PredefinedFilterResponse` | removed: no replacement (predefined-filter CRUD removed) | | +| `PredefinedFilter` | removed: no replacement (predefined-filter CRUD removed) | | +| `PromoteChannelParams` | removed: no replacement (promoteChannel removed) | | +| `PushProviderAPN` | removed: no replacement (push-provider admin removed) | | +| `PushProviderCommon` | removed: no replacement (push-provider admin removed) | | +| `PushProviderConfig` | removed: no replacement (push-provider admin removed) | | +| `PushProviderFirebase` | removed: no replacement (push-provider admin removed) | | +| `PushProviderHuawei` | removed: no replacement (push-provider admin removed) | | +| `PushProviderID` | removed: no replacement (push-provider admin removed) | | +| `PushProviderListResponse` | removed: no replacement (push-provider admin removed) | | +| `PushProviderUpsertResponse` | removed: no replacement (push-provider admin removed) | | +| `PushProviderXiaomi` | removed: no replacement (push-provider admin removed) | | +| `QueryMessageHistoryFilters` | removed: no replacement (queryMessageHistory removed) | | +| `QueryMessageHistoryOptions` | removed: no replacement (queryMessageHistory removed) | | +| `QueryMessageHistoryResponse` | removed: no replacement (queryMessageHistory removed) | | +| `QueryMessageHistorySortBase` | removed: no replacement (queryMessageHistory removed) | | +| `QueryMessageHistorySort` | removed: no replacement (queryMessageHistory removed) | | +| `QueryModerationRulesFilters` | removed: no replacement (moderation rules CRUD removed) | | +| `QueryModerationRulesResponse` | removed: no replacement (moderation rules CRUD removed) | | +| `QueryModerationRulesSort` | removed: no replacement (moderation rules CRUD removed) | | +| `QuerySegmentTargetsFilter` | removed: no replacement (segments removed) | | +| `QuerySegmentTargetsOptions` | removed: no replacement (segments removed) | | +| `QuerySegmentsOptions` | removed: no replacement (segments removed) | | +| `QueryTeamUsageStatsOptions` | removed: no replacement (queryTeamUsageStats removed) | | +| `QueryTeamUsageStatsResponse` | removed: no replacement (queryTeamUsageStats removed) | | +| `RateLimitsInfo` | removed: no replacement (getRateLimits removed) | | +| `RateLimitsMap` | removed: no replacement (getRateLimits removed) | | +| `ReactivateUserOptions` | removed: no replacement (reactivateUser removed) | | +| `ReactivateUsersOptions` | removed: no replacement (reactivateUsers removed) | | +| `Resource` | removed: no replacement (permission CRUD removed) | | +| `RetentionPolicyConfig` | removed: no replacement (retention policies removed) | | +| `RetentionPolicy` | removed: no replacement (retention policies removed) | | +| `RetentionRunResponse` | removed: no replacement (retention policies removed) | | +| `RetentionRunStats` | removed: no replacement (retention policies removed) | | +| `ReviewFlagReportOptions` | removed: no replacement (private \_reviewFlagReport removed) | | +| `ReviewFlagReportResponse` | removed: no replacement (private \_reviewFlagReport removed) | | +| `SegmentData` | removed: no replacement (segments removed) | | +| `SegmentQueryOptions` | removed: no replacement (segments removed) | | +| `SegmentResponse` | removed: no replacement (segments removed) | | +| `SegmentTargetsResponse` | removed: no replacement (segments removed) | | +| `SegmentType` | removed: no replacement (segments removed) | | +| `SetRetentionPolicyResponse` | removed: no replacement (retention policies removed) | | +| `TaskStatus` | removed: no replacement (getTask removed) | | +| `TeamUsageStats` | removed: no replacement (queryTeamUsageStats removed) | | +| `TestPushDataInput` | removed: no replacement (testPushSettings removed) | | +| `TestSNSDataInput` | removed: no replacement (testSNSSettings removed) | | +| `TestSQSDataInput` | removed: no replacement (testSQSSettings removed) | | +| `TranslateResponse` | removed: no replacement (translate removed (translateMessage remains: TranslateMessageResponse)) | | +| `UpdateChannelTypeRequest` | removed: no replacement (updateChannelType removed) | | +| `UpdateChannelTypeResponse` | removed: no replacement (updateChannelType removed) | | +| `UpdateChannelsBatchFilters` | removed: no replacement (updateChannelsBatch removed) | | +| `UpdateChannelsBatchOptions` | removed: no replacement (updateChannelsBatch removed) | | +| `UpdateChannelsBatchResponse` | removed: no replacement (updateChannelsBatch removed) | | +| `UpdateCommandOptions` | removed: no replacement (updateCommand removed) | | +| `UpdateCommandResponse` | removed: no replacement (updateCommand removed) | | +| `UpdatePredefinedFilterOptions` | removed: no replacement (predefined-filter CRUD removed) | | +| `UpdateSegmentData` | removed: no replacement (segments removed) | | +| `UpsertModerationRuleResponse` | removed: no replacement (moderation rules CRUD removed) | | +| `UserCustomEvent` | removed: no replacement (sendUserCustomEvent removed) | | +| `XiaomiConfig` | removed: no replacement (app-settings admin) | | + +### Removed: no replacement type (39) + +| v9 | v10 | verified delta / note | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `AnonUserType` | removed: no replacement; was `{}` | | +| `ArrayOneOrMore` | removed: no replacement; use T[] | | +| `ArrayTwoOrMore` | removed: no replacement; use T[] | | +| `CallLevelPushPreference` | removed: no replacement; string (PushPreferencesResponse.call_level) | | +| `ChannelRole` | removed: no replacement; unused in v9 signatures | | +| `ChatLevelPushPreference` | removed: no replacement; string (PushPreferencesResponse.chat_level / PushPreferenceInput.chat_level) | | +| `CommandVariants` | removed: no replacement; string (Command['name'] / Command['set']) | | +| `ConnectionChangeEvent` | removed: no replacement; connection.changed event removed; client.wsConnection.state / client.networkConnection.state | | +| `ContainsOperator` | removed: no replacement; filters generated per endpoint | | +| `CreatedAtUpdatedAt` | removed: no replacement | | +| `DeleteMessagesOptions` | removed: no replacement; was alias of MessageDeletionStrategy | | +| `EVENT_MAP` | removed: no replacement; event set is the Event union; EventType | | +| `GetMessageOptions` | removed: no replacement; getMessage no longer sends show_deleted_message | | +| `InviteStatus` | removed: no replacement; ChannelMemberResponse.status is string | | +| `KnownKeys` | removed: no replacement | | +| `LiteralStringForUnion` | removed: no replacement; use string & {} | | +| `Logger` | removed: no replacement; logger option removed; chatLoggerSystem / Sink (see [logging](#logging)) | | +| `ModV2ReviewStatus` | removed: no replacement | | +| `ModerationActionType` | removed: no replacement; inline per rule, e.g. AutomodRule['action'] | | +| `ModerationAction` | removed: no replacement; ModerationResponse.action is string | | +| `ModerationHarmResponse` | removed: no replacement; moderation_details removed; ModerationV2Response.text_harms is string[] | | +| `ModerationSeverity` | removed: no replacement; string | | +| `PartialPollOptionUpdate` | removed: no replacement; unused in v9 signatures | | +| `PartializeKeys` | removed: no replacement; Partial> & Omit | | +| `PollPaginationOptions` | removed: no replacement; unused in v9 signatures | | +| `PrimitiveFilter` | removed: no replacement; filters generated per endpoint | | +| `Product` | removed: no replacement; runtime enum; use string literals 'chat' etc. | | +| `PushNotificationSettings` | removed: no replacement; UserResponse.push_notifications removed; OwnUserResponse.push_preferences | | +| `PushPreferenceLevel` | removed: no replacement; string (ChatPreferences fields are string) | | +| `QueryFilter` | removed: no replacement; filters generated per endpoint (Filters / QueryFilters) | | +| `QueryPollsParams` | removed: no replacement; use QueryPollsRequest | | +| `Readable` | removed: no replacement | | +| `ReservedUpdatedMessageFields` | removed: no replacement | | +| `TaskResponse` | removed: no replacement; per-endpoint responses carry task_id (e.g. DeleteChannelsResponse) | | +| `TeamsRole` | removed: no replacement; Record | | +| `TranslatedField` | removed: no replacement; i18n fields are Record | | +| `UR` | removed: no replacement; Record | | +| `UnknownType` | removed: no replacement; Record | | +| `Unpacked` | removed: no replacement | | + + + +## 21. Behaviour changes that still compile + +Changes where code written for v9 can type-check (or run untyped) against v10 and behave differently. Each row links to the full entry above. + +| Area | What changes silently | Entry | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Packaging | `StateStore` selector notification edge cases (dropped/added keys, `NaN`) | [state-store-semantics](#state-store-semantics) | +| Packaging | Node 20 `connectUser` rejects without a `webSocketImpl` | [websocket-on-node](#websocket-on-node) | +| Client | JS calls with a `secret` or flat options run but ignore the options | [streamchat-constructor](#streamchat-constructor) | +| Client | axios config applied once; SDK auth headers always win; no default keep-alive agent | [axios-instance](#axios-instance) | +| Client | `revokeTokens` revokes only the connected user's tokens | [revoke-tokens](#revoke-tokens) | +| Client | REST paths under `/api/v2`; `connection_id` only on watching requests | [rest-routes](#rest-routes) | +| Client | `preventThreadCleanup` is ignored | [prevent-thread-cleanup](#prevent-thread-cleanup) | +| Client | `disconnectUser` resets channel lists and disposes every channel, including instances the app still holds | [disconnect-user-teardown](#disconnect-user-teardown) | +| Connection | watching / presence requests wait for (or fail without) a connection id | [watch-waits-for-connection-id](#watch-waits-for-connection-id) | +| Connection | connection id is cleared on drop / close | [connection-id-lifecycle](#connection-id-lifecycle) | +| Connection | reconnect recovery refreshes lists and activated channels only | [connection-recovery](#connection-recovery) | +| Connection | `connectionRecovery.enabled: false` also stops `connection.recovered` and makes `recover()` a no-op; with an offline DB it fires after the sync | [connection-recovery](#connection-recovery) | +| Connection | WS connect timeout is 15 s even with the long-poll fallback | [long-poll-fallback](#long-poll-fallback) | +| Connection | `persistUserOnConnectionFailure` keeps the socket retrying | [persist-user-on-connection-failure](#persist-user-on-connection-failure) | +| Connection | network status comes from `client.networkConnection` reporters | [websocket-impl-network-status](#websocket-impl-network-status) | +| Connection | WS handshake path and auth frame changed (proxies) | [ws-handshake](#ws-handshake) | +| Errors | non-Stream failures are raw axios errors; message text has no prefix | [stream-api-error](#stream-api-error) | +| Errors | `error.client_request_id` is `undefined` | [stream-api-error](#stream-api-error) | +| Errors | 429 and `TOKEN_EXPIRED` retried 10 times (about 100 s); `RetryError` at the end | [automatic-retries](#automatic-retries) | +| Logging | default console output at `info` (v9 was silent) | [logging](#logging) | +| Tokens | dev tokens with non-ASCII user ids fail | [dev-token-encoding](#dev-token-encoding) | +| Events | `connection.changed` / `transport.changed` listeners never fire | [connection-changed-removed](#connection-changed-removed) | +| Events | `on('all', cb)` typed `never`; `on('*', cb)` never fires | [event-type-names](#event-type-names) | +| Types | `Role`, `User`, `Reaction`, `PollResponse`, ... imports compile but mean something else | [same-name-meaning-changes](#same-name-meaning-changes) | +| Types | `new Date(timestampNs)` compiles unless used as a `Date` | [date-constructor-guard](#date-constructor-guard) | +| Types | `AI_STATE_CHECKING_SOURCES` is now `AI_STATE_EXTERNAL_SOURCES` | [ai-state](#ai-state) | +| Types | timestamps are ns numbers: `dayjs(ts)`, `ts > Date.now()`, `!ts` misbehave | [timestamp-ns](#timestamp-ns) | +| Channel | in-place edits of `channel.data` are not published | [channel-data](#channel-data) | +| Channel | `create()` / `query()` mark the channel initialized | [channel-initialized](#channel-initialized) | +| Channel | channels not `activate()`d are not reloaded on reconnect | [channel-activate-recovery](#channel-activate-recovery) | +| Channel | `watch()` waits for or rejects without a socket | [channel-watch](#channel-watch) | +| Channel | `channel.query()` for older pages no longer writes to the message list | [channel-query](#channel-query) | +| Channel | default `channel.query()` reports `isLatestMessageSet: false` to `channels.queried` and the offline DB | [channel-query](#channel-query) | +| Channel | id-less channels throw synchronously; server-auth bypass gone | [channel-id-guards](#channel-id-guards) | +| Channel | `queryMembers()` on an id-less (members-only) channel rejects instead of sending `members` | [channel-query-members](#channel-query-members) | +| Channel | `markRead()` is now the direct request (reporter path is `markReadViaReporter`) | [channel-mark-read](#channel-mark-read) | +| Channel | `markRead` / `markUnread` throw instead of resolving `null` | [channel-read-events-throw](#channel-read-events-throw) | +| Channel | local unread count needs the channel config flag | [channel-mark-read-locally](#channel-mark-read-locally) | +| Channel | v9 `ChannelConfig` shared keys compile against the new meaning | [channel-config](#channel-config) | +| Channel | event handling (hidden, unread, threads, reactions, delivery, truncate, presence) changed | [channel-event-handling](#channel-event-handling) | +| Channel | hard `user.messages.deleted` removes messages | [user-messages-deleted-hard](#user-messages-deleted-hard) | +| ChannelState | in-place mutation of `read` / `members` does not notify | [channel-state-store](#channel-state-store) | +| Channel list | `registerSubscriptions` is ref-counted | [channel-manager-register-subscriptions](#channel-manager-register-subscriptions) | +| Channel list | no promote-to-top; position follows sort | [channel-list-ordering](#channel-list-ordering) | +| Channel list | full filter matching; channels can leave lists; unknown channels watched | [channel-list-event-routing](#channel-list-event-routing) | +| Channel list | raw query errors; lists reset on logout | [channel-list-errors](#channel-list-errors) | +| Channel list | `queryChannels` no longer creates or hydrates channels | [query-channels-hydration](#query-channels-hydration) | +| Threads | threads get events only via `client.threads` | [thread-event-routing](#thread-event-routing) | +| Threads | `replyCount` includes soft-deleted replies; parent via message store | [thread-state-derivation](#thread-state-derivation) | +| Threads | `state.participants` rebuilt with current-time timestamps and no `custom` | [thread-state-derivation](#thread-state-derivation) | +| Threads | staleness driven by channel `watchStatus` | [thread-staleness](#thread-staleness) | +| Threads | `reload()` sizing and not-found handling | [thread-reload](#thread-reload) | +| Threads | `unregisterSubscriptions()` disposes the reply paginator | [thread-unregister](#thread-unregister) | +| Threads | reload on every `connection.recovered`; threads released with their channel | [thread-manager-behaviour](#thread-manager-behaviour) | +| Polls | `VotingVisibility.anonymous` is `undefined` in JS | [voting-visibility](#voting-visibility) | +| Polls / reminders | caches always filled on server-side clients | [poll-reminder-cache](#poll-reminder-cache) | +| Reminders | `updateConfig` patches dropped on any `client` config change and on reconnect; defaults frozen | [reminder-config-state](#reminder-config-state) | +| Reminders | paginator items ordered client-side (`created_at` ascending without `sort`) | [reminder-paginator](#reminder-paginator) | +| Live location | `dispose()` needed to release the manager | [live-location-dispose](#live-location-dispose) | +| Live location | locations without `end_at` dropped | [live-location-requests](#live-location-requests) | +| Cooldown | timer self-subscribes | [cooldown-subscriptions](#cooldown-subscriptions) | +| Delivery | reporter `markRead` throws when read events are off | [delivery-reporter-mark-read](#delivery-reporter-mark-read) | +| Delivery | `throttledMarkRead` leading edge, errors swallowed | [throttled-mark-read](#throttled-mark-read) | +| Search | channel search results are unwatched | [channel-search-source-unwatched](#channel-search-source-unwatched) | +| Search | message search channels unwatched; `channelQuerySort` ignored | [message-search-source-hydration](#message-search-source-hydration) | +| Search | `messageSearchSort` is secondary only | [message-search-sort](#message-search-sort) | +| Pagination | items deduped and comparator-ordered | [paginator-items](#paginator-items) | +| Notifications | client re-derives the manager config on any `client.*` change and on reconnect, replacing `updateConfig` patches | [notification-manager-config](#notification-manager-config) | +| Offline | channel guard writes first | [offline-channel-guard](#offline-channel-guard) | +| Offline | only ephemeral failures queued | [offline-queue-policy](#offline-queue-policy) | +| Offline | edits fold into queued sends | [offline-update-fold](#offline-update-fold) | +| Offline | v9 pending tasks replay with wrong arguments (custom `AbstractOfflineDB`; RN's schema bump drops them) | [pending-task-payloads](#pending-task-payloads) | +| Composer | server flags (`uploads`, `polls`, `url_enrichment`, `max_message_length`) restrict config | [composer-server-restrictions](#composer-server-restrictions) | +| Composer | config objects frozen; writes throw | [composer-config-frozen](#composer-config-frozen) | +| Composer | `updateConfig` patches retained; setters always write | [composer-update-config](#composer-update-config) | +| Composer | `compose()` uses `undefined`, ns timestamps, `cid` | [composer-compose-output](#composer-compose-output) | +| Composer | drafts skipped for provisional channels | [composer-drafts](#composer-drafts) | +| Composer | link previews enabled by default | [link-previews-default](#link-previews-default) | +| Composer | upload metadata under `custom`; identity guards read `custom.mime_type` | [composer-upload-metadata](#composer-upload-metadata) | +| Composer | custom `doUploadRequest` needs `customCdn: true` to skip permission checks | [composer-upload-gating](#composer-upload-gating) | +| Composer | default upload throws without a file URL | [composer-default-upload](#composer-default-upload) | +| Utilities | `formatMessage` memoized, no `Date` conversion | [format-message](#format-message) | +| Utilities | `logChatPromiseExecution` logs through `chatLoggerSystem` | [log-chat-promise-execution](#log-chat-promise-execution) | + + + +## 22. Internal surface touched + +Members that are `_`-prefixed, `@internal` or `@private`, or reachable only by deep import. Listed briefly for SDK maintainers; integrators should not depend on them. + +| v9 internal | v10 | Entry | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StreamChat._setupConnection`, `_sayHi` (warmUp `GET /hi`), `_logApiRequest`, `_logApiResponse`, `_logApiError`, `_enrichAxiosOptions`, `_getToken`, `_cacheEnabled`, `_normalizeDate`, `errorFromResponse`, `handleResponse` | removed. Nearest equivalents: `client.api.populateRequestConfigWithDefaults(config)`, `client.api._getToken()` / `client.tokenManager.getToken()`, logging via the `api-client` logger scope, `StreamAPIError` created inside `ApiClient` | [stream-api-error](#stream-api-error) | +| `Channel._channelURL()` (`{baseURL}/channels/{type}/{id}`) | removed, no replacement (routes are `/api/v2/chat/channels/{type}/{id}/...`) | [rest-routes](#rest-routes) | +| `Channel._callChannelListeners: (event: Event) => void` | `(event: WSEvent) => void` | [event-union](#event-union) | +| `Channel._initializeState(state: ChannelAPIResponse, messageSet?)` -> `{ messageSet, filteredMessageIds }` | `_initializeState(state: ChannelStateResponseFields): void` (no message seeding; membership replaced only when present). SDKs calling it must seed messages through `messagePaginator.seedFirstPageSync` (or call `client.hydrateChannels`) | [message-paginator](#message-paginator) | +| `Channel._extendEventWithOwnReactions(event: Event)` | takes `EventPayload<'message.deleted' \| 'message.updated' \| 'message.undeleted'>` | [channel-event-handling](#channel-event-handling) | +| `Channel.push_preferences?: ChannelPushPreference` | `ChannelPushPreferencesResponse` | [type-rename-table](#type-rename-table) | +| (none) | new internal `Channel._checkHasId`, `_setOwnUnreadCount`, `_syncMuteStatus`, `isQueryingChannel` | [channel-id-guards](#channel-id-guards) | +| `Channel._data` | `ChannelInput` | [channel-data](#channel-data) | +| `Channel._sendMessage`, `_sendReaction`, `_deleteReaction`, `_update` | removed | [channel-send-message](#channel-send-message), [channel-send-reaction](#channel-send-reaction), [channel-update](#channel-update) | +| `Channel._createDraft`, `_deleteDraft` | request-object shapes | [channel-drafts](#channel-drafts) | +| `client._updateMessage`, `_deleteMessage`, `_unblockMessage`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `_createImport*`, `_getImport`, `_listImports`, `_buildWSPayload`, `_isUsingServerAuth`, `_messageComposerSetupState` | removed | [update-delete-message-raw](#update-delete-message-raw), [server-only-methods-removed](#server-only-methods-removed), [ws-handshake](#ws-handshake), [server-side-auth-removed](#server-side-auth-removed), [message-composer-setup-function](#message-composer-setup-function) | +| `ChannelState` underscore message helpers | removed with the message list | [message-paginator](#message-paginator) | +| `SearchController._internalState` | removed | [search-controller-internal-state](#search-controller-internal-state) | +| `UploadManager.upload` result type | `MinimumUploadRequestResult` | [query-channels-response-with-channels](#query-channels-response-with-channels) | +| `client.channelServerConfigs`, `client.channelManager.channelStore`, `wsConnection.connection` / `.fallback` | new `@internal` members | [active-channels](#active-channels), [ws-connection](#ws-connection) | +| Composer `applyServerRestrictions`, `transferTo`, `takeOver` | new `@internal` members | [composer-class-members](#composer-class-members) | +| `utils.ts` helpers (`getAndWatchChannel`, pinned/archived helpers, `mergeWithCore` rewrite) | removed or rewritten (deep imports only) | [promote-channel](#promote-channel) | + + + +## 23. Open questions / unverified + +Claims the source cannot settle; verify against the live API or the target package before relying on them in docs. + +1. `@stream-io/node-sdk` method names for the removed server-only `StreamChat` methods and removed `Moderation` methods were not verified ([server-only methods](#server-only-methods-removed), [moderation removed](#moderation-removed-methods)). +2. Whether the API still honours `pending`, `pending_message_metadata`, `force_moderation`, `is_pending_message` when sent untyped on `sendMessage` ([sendMessage](#channel-send-message), [option types](#option-types)). +3. Server outcome of replaying v9-persisted `send-message` / `create-draft` pending tasks (bare message as request body; likely an InputError that deletes the task) ([pending tasks](#pending-task-payloads)). +4. TypeScript older than 5.5 was not tested against the v10 declarations ([TS requirements](#typescript-requirements)). +5. Whether the API still accepts old top-level custom filter keys (`{ color: 'green' }` instead of `{ 'custom.color': 'green' }`) ([filters](#filter-types)). +6. Whether the API accepts nanosecond `created_at_*` bounds on `QueryMembersPayload`, `QueryBannedUsersPayload`, `QueryFutureChannelBansPayload`, which are typed `TimestampNS` while every other request date is `Date` ([request dates](#request-dates)). +7. That `updateReminder({ message_id })` (no `remind_at`) clears the due date server-side is asserted by a docs snippet test against the live API, not run for this report ([reminder methods](#reminder-methods)). diff --git a/ai-docs/v10-followups.md b/ai-docs/v10-followups.md new file mode 100644 index 0000000000..3a47a8669f --- /dev/null +++ b/ai-docs/v10-followups.md @@ -0,0 +1,1171 @@ +# stream-chat v10 follow-ups + +Record-only list of dead code, bugs, simplifications and doc mismatches found while auditing the `stream-chat` v10 release against v9. Nothing here is fixed; each item is a pointer for the team to act on later. + +- **v10:** `release-v10` @ `53505e72`. **v9:** `master` @ `24b7623b`. Line numbers are v10 unless marked v9. +- **Downstream refs:** stream-chat-react `master` @ `557ffcb3c`, stream-chat-react-native `V10` @ `c994ffd00`. +- **Sources:** the per-slice reconciled follow-ups (verified against source), plus test, snippet, doc-claim, downstream and dead-code signals. Every bug below with confidence `high` was re-read against v10 source for this file; see [Dropped after re-check](#dropped) for what did not hold. + +**Legend** + +- Category: `bug` (wrong behaviour or types with a concrete scenario), `dead-code` (unused code or exports), `leftover` (migration residue: deprecated aliases, stale shims, stubs), `simplification` (works, but could be smaller or clearer), `doc-mismatch` (JSDoc, comments, README, docs or downstream guides disagree with code), `test-gap` (skipped, todo, broken or missing tests). +- Confidence: `high` (code path read, scenario holds), `medium` (mechanism confirmed, impact or server behaviour unverified), `low` (plausible, rare or unverified). +- Pre-release: `yes` when the fix is itself breaking (cheap only before 10.0.0) or the bug is a v10 regression that hits apps on upgrade; `no` when it can land in any v10 minor or patch. + +## Contents + +1. [Fix before 10.0.0 ships](#fix-before) +2. [Bugs](#bugs) +3. [Dead code and leftovers](#dead-code) +4. [Simplifications](#simplifications) +5. [Doc and comment mismatches](#docs) +6. [Test gaps](#test-gaps) +7. [Dropped after re-check](#dropped) + +--- + + + +## 1. Fix before 10.0.0 ships + +Ordered by severity. The breaking dead-code removals and leftover decisions are kept in [section 3](#dead-code) and listed at the end of this section. Items a review downgraded to `pre-release: no` (non-breaking fix, or not a v10 regression that hits apps) were moved to [Bugs](#bugs) and [Simplifications](#simplifications). + + + +### Typing indicators go stale after the composer resets + +`bug` · confidence: high · pre-release: yes · composer / channel state + +- **Where:** `src/messageComposer/textComposer.ts:115-140` (`initState` sets `typing: {}`), `:297-299` (`restoreSnapshot`), `:514-519` (`commitState`), `:524-541` (`handleChange`); `src/channel_state.ts:296-320` (`typing` getter/setter, `removeTypingEvent`), `:347-360` (`clean`) +- **What:** `channel.state.typing` reads the text composer's copy first. Every composer re-init (send, `clear()`, `restore()`, edit init) wipes that copy, and `restoreSnapshot` reverts it to the captured (stale) map, which can resurrect stopped typers, while the store slice keeps its own entries. Command activation is not affected (it commits `...getLatestValue()`, `textComposer.ts:413`). A second path: `handleChange` passes `...this.state.getLatestValue()` (typing included) into the async text middleware and `commitState` writes it back, so a `typing.start` / `typing.stop` that arrives while the middleware runs is overwritten in the composer copy. +- **Evidence / scenario:** B and C are typing; A sends a message, so `textComposer.initState()` sets `typing: {}` and the getter returns `{}`. C's `typing.stop` reaches `removeTypingEvent`, whose `if (!this.typing[userID]) return` exits early, so the store slice still holds C; `clean()` iterates the composer copy and never prunes it. Probe: after `clear()` the getter is `[]` and the slice `[B, C]`; after C's `typing.stop` and `clean()` the slice is still `[B, C]`. Impact differs per SDK: React Native reads the store slice (`TypingIndicatorContainer.tsx:23`, `useTypingUsers.ts:20`), so RN keeps showing "C is typing" until another typing event rewrites the slice; React reads the composer copy (`TypingIndicator.tsx:30`), so React drops B and C until their next `typing.start`. v9 had no composer typing (added in 62f05078), so this is new. +- **Suggested change:** Keep `typing` out of text-composer resets (carry the current map through `initState`, `restoreSnapshot` and `commitState` after async middleware), or make `channel.state` the single owner and have the composer read from it. + + + +### One mark-read throttle shared by every channel and thread + +`bug` · confidence: medium · pre-release: yes · message delivery / threads + +- **Where:** `src/messageDelivery/MessageDeliveryReporter.ts:452-463` (`buildThrottledMarkRead`), `src/thread.ts:924-926` +- **What:** `throttledMarkRead` is one lodash-style throttle (leading + trailing) for all collections; the trailing call keeps only the latest arguments. v9 had a throttle per thread. +- **Evidence / scenario:** A thread is open beside its channel (React). A reply arrives and the thread calls `throttledMarkRead(thread)`; within 1 s the UI calls `throttledMarkRead(channel)` (React `useMarkRead`). Whichever came first fires on the leading edge; the trailing slot is overwritten by the channel, so `markRead(thread)` is never sent. The thread's own unread count does not increment again, so the auto-read does not retry and the server keeps the thread unread until the next reply. Mechanism confirmed in code; frequency depends on UI timing. +- **Suggested change:** Key the throttle per collection (`Map`) or restore a per-thread throttle. + + + +### `on('all')` and undeclared custom events are typed `never` + +`bug` · confidence: high · pre-release: yes · types / events + +- **Where:** `src/types.ts:196-209` (`Event`, `EventType`, `EventHandler`), `src/client.ts:987-996`, `src/channel.ts:2234-2243` +- **What:** `EventHandler = (event: Extract) => void`. For `'all'` (a valid `EventType`, and the key runtime dispatch uses at `client.ts:1391`) and for any custom name not augmented into `CustomEventTypes`, `Extract` yields `never`. +- **Evidence / scenario:** `client.on('all', (e) => e.type)` and `client.on('my_custom', (e) => e.type)` fail with TS2339 "Property 'type' does not exist on type 'never'", while both listeners fire at runtime. v9 accepted any string with a usable `Event`. Removed v9 names such as `client.on('connection.changed', fn)` still type-check (string overload) but never fire, with no warning. +- **Suggested change:** `EventHandler = (event: T extends 'all' ? Event : [Extract] extends [never] ? { type: T } & Omit : Extract) => void`. Optionally log a dev warning when subscribing to a removed v9 event name. + + + +### `client.on('*')` type-checks but never fires + +`bug` · confidence: high · pre-release: yes · types / events + +- **Where:** `src/gen/models/index.ts:11854, :11939` (`{ type: '*' } & CustomEvent` in the WS event unions), `src/client.ts:1391-1392` (`_callClientListeners`) +- **What:** The spec's custom-event placeholder `'*'` leaks into `Event['type']` and `EventType`. Dispatch only looks up `'all'` and `event.type`, and no event carries type `'*'`. +- **Evidence / scenario:** `client.on('*', cb)` compiles and `cb` is never called. +- **Suggested change:** Exclude the `'*'` member from the exported `Event` union (custom events already come from `CustomEventTypeUnion`), or map `'*'` to a catch-all for custom events. Removing it from the union is breaking, so decide before 10.0.0. + + + +### Exhausted retries hide the API error; 429 now holds callers for about 100 s + +`bug` · confidence: high · pre-release: yes · client / HTTP + +- **Where:** `src/api-client.ts:226-294`, `src/utils/retryable.ts:64-135`, `src/utils.ts:128-133` (`retryInterval`), `src/errors.ts:84-88`; v9 `src/client.ts:1384-1402` +- **What:** When retries run out (persistent 429 or expired token), `runWithRetry` throws `RetryError('Reached maximum amount of retry attempts')`. The `StreamAPIError` wrapping in the `catch` only handles axios errors, so `code`, `status` and `response` are lost. v9 did not retry 429 at all (it rejected at once with the API error; only a non-static `TOKEN_EXPIRED` was retried), while v10 retries a 429 10 times (11 requests, delays of 0.25 to 20.5 s, about 100 s in total). +- **Evidence / scenario:** Probe: a request that keeps getting 429 makes 11 requests and then rejects with `RetryError`; `code` / `status` are `undefined` and `isEphemeral` is `true`, so offline-aware operations queue it. A rate-limited UI action (send, react, mark read) appears to hang for about 100 s before failing. +- **Suggested change:** Keep the last error in `runWithRetry` and rethrow it (or attach it as `cause`), so the final rejection is a `StreamAPIError`. Decide at the same time whether 429 is retried at all, or with a much smaller budget. The observable error class and latency are public behaviour, so settle both before 10.0.0. + + + +### Static expired token is retried 10 times + +`bug` · confidence: high · pre-release: yes · client / HTTP + +- **Where:** `src/api-client.ts:268-276` (`isRetryable`) +- **What:** `TOKEN_EXPIRED` is retryable regardless of token type. For a static token `loadToken()` returns the same token, so the request is doomed. v9 skipped static tokens (v9 `src/client.ts:1388-1399`, `!this.tokenManager.isStatic()`). +- **Evidence / scenario:** App connects with a static token that expires; every HTTP call is retried 10 times (11 requests) with delays of 0.25 to 20.5 s, about 100 s in total, before rejecting with `RetryError` (probe: 11 requests, then `RetryError`), instead of failing at once as in v9. +- **Suggested change:** Retry `TOKEN_EXPIRED` only when `!this.client.tokenManager.isStatic()`. + + + +### Edits to `show_in_channel` replies are not applied to the channel list + +`bug` · confidence: medium · pre-release: yes · channel + +- **Where:** `src/channel.ts:2680` vs `:2584` (`message.new`) and `:2543` (`message.deleted`) +- **What:** `message.updated` / `message.undeleted` skip every message with `parent_id`; `message.new` / `message.deleted` skip only thread-only replies (`parent_id && !show_in_channel`). v9 applied updates to every message (`addMessageSorted`). +- **Evidence / scenario:** A reply sent with `show_in_channel: true` is in the channel list; the author edits it. If no `Thread` for the parent is held by `client.threads` (so `ThreadManager` does not route the event, `src/thread_manager.ts:348-353`), the channel list keeps the old text. No test covers `show_in_channel` edits. +- **Suggested change:** Use the same predicate as `message.new`. + + + +### `user.messages.deleted` only processes the active window + +`bug` · confidence: medium · pre-release: yes · channel / pagination + +- **Where:** `src/pagination/paginators/MessageIntervalPaginator.ts:1414` (`applyMessageDeletionForUser` iterates `this.items`) +- **What:** Only the active interval is scanned. v9 processed every message set, threads and pinned messages. +- **Evidence / scenario:** The user jumped to an older message (active interval is not the head); `user.messages.deleted` arrives for a banned user; that user's newest messages in the head interval stay visible when the user jumps back to latest. +- **Suggested change:** Iterate all loaded intervals (or the item index) instead of `items`. + + + +### `connectionRecovery.enabled: false` also suppresses `connection.recovered` + +`bug` · confidence: high · pre-release: yes · connection recovery + +- **Where:** `src/connection/ConnectionRecoveryManager.ts:273-279` (`recoverActiveChannels` returns first when disabled), `:353` (the only dispatch site), `:362-370` (`recover()`), `:196-215` (offline sync-edge path); `src/thread_manager.ts:468`; v9 `src/client.ts:1782-1817` +- **What:** Behaviour change to confirm is intended. v9 dispatched `connection.recovered` after every reconnect, also with `recoverStateOnReconnect: false` (the `else` branch), and that flag existed so integrators could run their own recovery keyed on the event. In v10 the event is dispatched only from inside `recoverActiveChannels`, which returns on its first line when `connectionRecovery.enabled` is `false`, so the event never fires and the manual `client.connectionRecovery.recover()` is a no-op too. With an initialized offline DB the event is also delayed until the offline sync finishes. +- **Evidence / scenario:** An app sets `recoverStateOnReconnect: false` in v9 and re-queries its channels on `connection.recovered`. After migrating to `connectionRecovery.enabled: false` it never recovers after a drop, and `ThreadManager`, which reloads threads on `connection.recovered`, stops reloading as well. Documented in the breaking-changes doc (`#connection-recovery`) with a `wsConnection.state` workaround. +- **Suggested change:** Decide before 10.0.0: either dispatch `connection.recovered` on the reconnect edge when recovery is disabled (v9 parity) or keep the current behaviour and state it in the `connectionRecovery` JSDoc. Changing it after the release is a behaviour change again. + + + +### Reminder list blanks on every "load more" + +`bug` · confidence: high · pre-release: yes · pagination / reminders + +- **Where:** `src/pagination/paginators/ReminderPaginator.ts:84-96` (`getNextQueryShape` puts the cursor in the shape), `src/pagination/paginators/BasePaginator.ts:3034-3046` (`isFirstPageQuery`), `:3013-3021`, `:3196-3213` +- **What:** The default `hasPaginationQueryShapeChanged` sees a new cursor as a new query shape, so each next page is treated as a first page and publishes `items: undefined` while loading. +- **Evidence / scenario:** Probe emissions on load-more: `[m1, m2] -> undefined -> [m1, m2, m3]`, so a reminders list flashes empty each page. `UserGroupPaginator` avoids this by keeping the cursor out of the shape. +- **Suggested change:** Move the cursor out of `ReminderPaginator`'s query shape (as `UserGroupPaginator` does) or override the shape comparison to ignore `next` / `prev`. + + + +### `lastQueryError` flashes on every failed retry attempt + +`bug` · confidence: high · pre-release: yes · pagination / channel list + +- **Where:** `src/pagination/paginators/BasePaginator.ts:3098-3116` (`runQueryRetryable`), `src/pagination/paginators/ChannelPaginator.ts:344` (default `retryCount`); v9 `src/channel_manager.ts:386-411` +- **What:** The error is published on every failed attempt, before retries are exhausted. v9 `BasePaginator` had no retries at all (v9 `pagination/BasePaginator.ts:159-160`); the v9 behaviour this regresses is `ChannelManager`, which set `state.error` only once `retryCount >= DEFAULT_QUERY_CHANNELS_RETRY_COUNT`. `ChannelPaginator` replaces it and retries 3 times by default. +- **Evidence / scenario:** Probe: `ChannelPaginator` with the first attempt failing emits `lastQueryError` `[null, "503", null]`. React's `ChannelList.tsx:77-83` calls `client.notifications.addError` on every `lastQueryError` change, so a transient channel-query failure that recovers on retry still shows an error toast, and one query can show up to 4 (each a distinct `Error`). +- **Suggested change:** Publish only when no retries remain (non-breaking). + + + +### `messageSearchSort` cannot change the primary order + +`bug` · confidence: high · pre-release: yes · search + +- **Where:** `src/search/MessageSearchSource.ts:242-249`; pinned by `test/unit/search/MessageSearchSource.test.ts:272-276` +- **What:** The custom sort is appended after a fixed `created_at: -1`. +- **Evidence / scenario:** `messageSearchSort: [{ field: 'created_at', direction: 1 }]` sends `[created_at -1, created_at 1]`, a contradictory duplicate key. v9 let `{ created_at: 1 }` flip the order. +- **Suggested change:** Merge the way v9's `{ created_at: -1, ...messageSearchSort }` did: `created_at` stays the primary key and takes the caller's direction when the caller passes a `created_at` entry, and the caller's other entries follow as secondary keys. Replacing the sort outright would drop the `created_at` primary order for anyone passing only a secondary key. Update the test (non-breaking: it only removes the duplicate key). + + ```ts + const custom = this.messageSearchSort ?? []; + const createdAt = custom.find(({ field }) => field === 'created_at'); + const sort = [ + { field: 'created_at', direction: createdAt?.direction ?? -1 }, + ...custom.filter(({ field }) => field !== 'created_at'), + ]; + ``` + + + +### Request date filters typed as `TimestampNS` + +`bug` · confidence: medium · pre-release: yes · types / generated models + +- **Where:** `src/gen/models/index.ts:6815-6818` (`QueryBannedUsersPayload`), `:7180-7183` (`QueryFutureChannelBansPayload`), `:7223-7226` (`QueryMembersPayload`); `src/gen/chat/ChatApi.ts:1693` (`listUserGroups` `created_at_gt: string`) +- **What:** Every other request date field is `Date`, and `src/utils/time.ts` says request dates are `Date`. These filters take nanosecond numbers or a string. Whether the API accepts nanoseconds there is unverified. +- **Evidence / scenario:** Code passing a `Date` to `queryMembers({ created_at_after })` fails to compile; passing a ns number may be rejected by the server. v9 typed these `string` (RFC 3339, v9 `src/types.ts:1552-1560`), so a v9 string still compiles. +- **Suggested change:** Align the spec on `Date` for request filters, or document the exceptions. Narrowing the type later is breaking; a widening fix (`TimestampNS | Date | string`) is not, so the pre-release part is choosing the final type. + + + +### Positional `pinMessage` / `unpinMessage` wrappers + +`simplification` · confidence: low · pre-release: yes · client + +- **Where:** `src/client.ts:129` (`NotATimestampNS`), `:1908-1960` (`_normalizeExpiration`, `_validateAndGetMessageId`), `:1967` (`pinMessage`), `:2001` (`unpinMessage`) +- **What:** The helpers exist only for the positional `pinMessage(msg, expiration, pinnedAt)`; every other request method takes objects. Other mixed conventions: `client.getThreadAndHydrate(messageId, opts)` vs `getThread({ message_id })`, `channel.deleteImage(url)` vs `client.deleteImage({ url })`, positional `channel.addMembers`, `client.polls.queryPolls(filter, sort)` vs `client.queryPolls({ filter, sort })`, positional `client.moderation.flagMessage(id, reason, options)`. +- **Suggested change:** Consider `pinMessage({ id }, { pin_expires?, pinned_at? })` and aligning the other outliers while signatures are breaking anyway. + + + +### Placeholder `_request?: Record` parameters + +`simplification` · confidence: high · pre-release: yes · channel / generated API + +- **Where:** `src/channel.ts:1029, 1045, 1064, 1080` (`archive`, `unarchive`, `pin`, `unpin`); `src/gen/chat/ChatApi.ts:167, 822, 902, 982, 998, 1176, 1316, 1346, 1467, 1483` (e.g. `listDevices`) +- **What:** Callers must pass `undefined` first to reach `requestOptions`. +- **Suggested change:** Drop the placeholder for body-less endpoints (generator and the four hand-written channel methods), so `requestOptions` is the first argument. + + + +### `channel.query()` keeps v9 message-set semantics in its signature + +`simplification` · confidence: high · pre-release: yes · channel + +- **Where:** `src/channel.ts:1909-1913, 1931-1934`; JSDoc `:1901-1904` +- **What:** `messageSetToAddToIfDoesNotExist: MessageSetType` survives, but only `'latest'` has an effect (seeds the paginator); `'current'` and `'new'` are identical no-ops. The JSDoc still describes v9 message sets. +- **Suggested change:** Replace with `{ seedLatest?: boolean }` (or narrow to `'latest' | undefined`) and fix the JSDoc. + + + +### `PollComposer.updateFields` "change with the next major" FIXME + +`simplification` · confidence: high · pre-release: yes · composer + +- **Where:** `src/messageComposer/pollComposer.ts:129` +- **What:** `// FIXME: change method params to a single object with the next major release` on `updateFields(data, injectedFieldErrors)`; v10 is that release and `injectedFieldErrors` already changed type. +- **Suggested change:** Switch to `updateFields({ data, injectedFieldErrors })` now. + +### Also pre-release: breaking removals and leftover decisions in section 3 + +These are full items in [Dead code and leftovers](#dead-code); listed here so the pre-release set is complete: [preventThreadCleanup](#dc-prevent-thread-cleanup), [skipHydration](#dc-skip-hydration), [Moderation.unmuteUser](#dc-unmute-user), [setMarkAsReadThrottleOptions](#dc-throttle-options), [PaginatorPlugin](#dc-paginator-plugin), [EntityStore.isHeld](#dc-is-held), [MessageReceiptsTracker event methods](#dc-receipts-tracker), [unused v10 root exports](#dc-root-exports), [ChatLoggerScope](#dc-logger-scopes), [CustomCommandData](#keep-custom-command-data), [deprecated client aliases](#lo-client-aliases), [Thread.markAsRead](#lo-thread-mark-as-read), [Thread `*Locally` methods](#lo-thread-locally), [composer deprecations](#lo-composer-deprecations), [onlineStatusChanged shims](#lo-online-status), [types that restate generated types](#lo-restated-types), [@internal exports used downstream](#lo-internal-exports). + +--- + + + +## 2. Bugs + +### Client and auth + + + +#### Non-ASCII user ids fail with a dev token + +`bug` · confidence: high · pre-release: no · client / auth + +- **Where:** `src/signing.ts:13` (`UserFromToken`) vs `:26-33` (`DevToken`), check in `src/token_manager.ts:92-99` +- **What:** `DevToken` now base64-encodes the UTF-8 bytes, but `UserFromToken` decodes with bare `atob` (Latin-1), so the decoded `user_id` never matches a non-ASCII id. +- **Evidence / scenario:** `client.connectUser({ id: 'jöhn' }, client.devToken('jöhn'))` rejects with "userToken does not have a user_id or is not matching with user.id". v9 used `btoa` on the raw string, so Latin-1 ids like `jöhn` connected; ids outside Latin-1 (`日本`) fail in both (v9 truncated them to garbage). The token itself is now correct for the server; only the client-side check is wrong. `test/unit/signing.test.js:33-36` is named "round-trips ... non-Latin-1 ids" but only asserts no throw. Regression for Latin-1 ids, but dev tokens only and the fix is non-breaking, so it can land in any 10.0.x. +- **Suggested change:** Decode as UTF-8: `new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))`, and assert the round trip in the test. See also [base64url](#base64url). + + + +#### v9-style constructor calls are silently misconfigured + +`bug` · confidence: high · pre-release: no · client construction + +- **Where:** `src/client.ts:313-325`, `src/configuration/InstanceConfigurationRegistry.ts:183-193` +- **What:** The constructor takes `(key, config?)`, and `config.set` iterates whatever it receives. JavaScript callers (or `any`-typed TS) using v9 shapes get no error. +- **Evidence / scenario:** Probe: `new StreamChat(key, 'secret', opts)` iterates the string (one "unknown key" warning per character) and drops `opts`; `new StreamChat(key, { timeout, enableWSFallback })` drops both (axios timeout stays 3000, fallback off). A guard is non-breaking and can ship any time. +- **Suggested change:** Throw (or warn once with a migration hint) when `config` is a string, when a third argument is passed, or when top-level keys are not `InstanceConfigTree` keys. + + + +#### `UserFromToken` does not handle base64url + +`bug` · confidence: medium · pre-release: no · client / auth + +- **Where:** `src/signing.ts:9-17` +- **What:** JWT segments are base64url (`-`, `_`, no padding); `atob` throws `InvalidCharacterError` on `-` / `_`. +- **Evidence / scenario:** A real (server-signed) token whose payload encodes to those characters makes `connectUser` throw. v9 produced garbage and failed too, so not a regression. +- **Suggested change:** Map `-` to `+`, `_` to `/`, and pad before `atob`; combine with the [UTF-8 fix](#devtoken-non-ascii). + + + +#### `revokeTokens()` without a connected user sends no id + +`bug` · confidence: high · pre-release: no · client + +- **Where:** `src/client.ts:787-803` +- **What:** Uses `this.userId!` behind an eslint-disable. In v10 the method revokes for the connected user (`updateUsersPartial`), not app-wide as in v9. +- **Evidence / scenario:** Calling it before `connectUser` sends `{ users: [{ set: { revoke_tokens_issued_before } }] }` with no `id`. +- **Suggested change:** Throw early when `!this.userId`; drop the non-null assertion. + + + +#### Empty token is no longer rejected up front + +`bug` · confidence: high · pre-release: no · client / auth + +- **Where:** `src/token_manager.ts:78-101` +- **What:** v9 threw "User token can not be empty" for a non-anonymous user without a token (v9 `src/token_manager.ts:81-84`). v10 accepts it. +- **Evidence / scenario:** `connectUser(user, undefined)` fails later inside the WS auth frame with "User token is not set. Either client.connectUser wasn't called...", which points at the wrong cause. +- **Suggested change:** Restore `if (!user.anon && !tokenOrProvider) throw new Error('User token can not be empty')`. + + + +#### `queryThreadsAndHydrate` assumes `parent_message` + +`bug` · confidence: low · pre-release: no · client / threads + +- **Where:** `src/client.ts:2088-2091`; `src/poll_manager.ts:107-108` +- **What:** `response.threads.map((t) => t.parent_message!)`; the generated `ThreadStateResponse.parent_message` is optional (`src/gen/models/index.ts:10305`) and `hydratePollCache` reads `message.poll` unguarded. v9 typed the field required. Also violates the repo's no-non-null rule. +- **Evidence / scenario:** A thread returned without `parent_message` throws `TypeError` from the whole query. The server likely always returns it. +- **Suggested change:** `filter(Boolean)` before hydrating. + +### Channel + + + +#### Watchers and members are mutated in place on the store-backed state + +`bug` · confidence: high · pre-release: no · channel state + +- **Where:** `src/channel.ts:2530, 2536` (`user.watching.start/stop`, `user.updated`), `:2846, :2855` (`user.banned/unbanned`), `:2950` (`_initializeState` watchers); getters `src/channel_state.ts:322-328` +- **What:** `channelState.watchers[id] = user`, `delete channelState.watchers[id]` and `channelState.members[id] = {...}` write into the store's current object; no new reference is published. +- **Evidence / scenario:** `useStateStore(channel.state, (s) => ({ watchers: s.watchers }))` does not re-render when another user starts watching or is banned; the shallow selector compare sees the same object. +- **Suggested change:** Assign new objects (`channelState.watchers = { ...channelState.watchers, [id]: user }`, same for `members`) or use `partialNext`. + + + +#### Two notions of "read events off" disagree + +`bug` · confidence: medium · pre-release: no · channel / message delivery + +- **Where:** `src/channel.ts:1303-1313` (`markRead`/`markUnread`), `src/utils.ts:112-121` (`channelHasReadEvents`), `src/messageDelivery/MessageDeliveryReporter.ts:421-430` +- **What:** The `read-events` own-capability gate was dropped from `markRead` / `markUnread`, but `channelTracksReadLocally` and `_countMessageAsUnread` still use the capability, while the reporter's local fallback keys off `config.readEvents.enabled`. +- **Evidence / scenario:** Channel type has `read_events: true`, the user's `own_capabilities` lack `read-events`, `localUnreadCountEnabled: true`. The reporter sends `markRead` (v9 resolved `null` with no request) and does not reset the local count. Server response for a user without the capability is unverified. +- **Suggested change:** Pick one source of truth: restore the capability check in `markRead` and the reporter, or document that the request is sent. + + + +#### `_countMessageAsUnread` throws on an id-less channel + +`bug` · confidence: low · pre-release: no · channel + +- **Where:** `src/channel.ts:1783`, `:1090-1093` +- **What:** Calls `this.muteStatus()`, which now runs `_checkHasId()`; v9 read `client._muteStatus(cid)` directly to avoid such a guard (v9 `src/channel.ts:1501-1503`). +- **Evidence / scenario:** `countUnread(ts)` / `countUnreadMentions()` throws "Channel isn't yet created" only on a provisional (members-only, unqueried) channel that holds another user's message. No known path puts one there: messages arrive through queries and WS events, which carry the real cid, and offline DB rows are keyed by the real cid too. It is a latent guard mismatch rather than a reachable bug. +- **Suggested change:** `this.getClient()._muteStatus(this.cid).muted` or read `this.state.getLatestValue().muteStatus`. + + + +#### `queryMembers` rejects on an id-less (members-only) channel + +`bug` · confidence: high · pre-release: no · channel + +- **Where:** `src/channel.ts:656` (`_checkHasId()`), `:665-671` (unreachable `members` branch); pinned by `test/unit/channel.test.js:4922`; v9 `src/channel.ts:390-427` +- **What:** `queryMembers` calls `_checkHasId()` before building the payload, so on a channel created from members only (`client.channel('messaging', { members })`) it rejects with "Channel isn't yet created, call getOrCreateDistinctChannel() before this operation", and the `else if (Array.isArray(this.data?.members))` branch that fills `payload.members` can never run. v9 had no guard and sent `members` for an id-less channel. +- **Evidence / scenario:** Probe: `channelManager.ensure({ type, data: { members } })` then `queryMembers()` rejects. A UI that lists or searches the members of a distinct DM before it is created (e.g. a `ChannelMemberSearchSource` on that channel) fails where v9 worked. The composer's mention search does not hit it: on such a channel the local member map is empty, so it searches locally (`mentions.ts:640-641`). Documented in the breaking-changes doc (`#channel-query-members`, `#channel-id-guards`) with the `watch()` / `create()` workaround. +- **Suggested change:** Either skip the guard when `data.members` is set (restores v9; widening, non-breaking) and update the test, or keep the guard and delete the dead branch. Placed here, not in section 1: it is a v10 regression, but the fix is non-breaking in either direction, the SDK's own composer paths avoid it, and the rejection is documented with a workaround. + + + +#### `member.updated` re-routes every channel and may watch unknown ones + +`bug` · confidence: low · pre-release: no · channel manager + +- **Where:** `src/ChannelManager.ts:231-236` via `updateLists` `:152-175` +- **What:** No current-user guard (v9 ignored other members' updates, v9 `src/channel_manager.ts:737-744). For a cached channel every member's role or name change re-ingests it into every paginator; for an uncached cid it calls `ensure().ensureWatched()`. +- **Evidence / scenario:** Downgraded from medium: the watch request needs the server to deliver `member.updated` for a channel this connection does not watch, which is unverified. The re-routing cost for cached channels holds. +- **Suggested change:** Guard on own membership unless a paginator filters or sorts on member fields; never fetch for unknown channels on this event. + +### Threads, polls, reminders + + + +#### Manager unregister disposes thread paginators that are later reused + +`bug` · confidence: low · pre-release: no · threads + +- **Where:** `src/thread_manager.ts:311, 472-475`; `src/thread.ts:838-848`; `src/pagination/paginators/BasePaginator.ts:3476-3481` +- **What:** `client.threads.unregisterSubscriptions()` unregisters every stored thread, which disposes its reply paginator (item index cleared, `state.items` kept), but the threads stay in the store; a later `registerSubscriptions()` (remount, React StrictMode) re-subscribes the same instances. +- **Evidence / scenario:** After a remount, inactive threads still show replies while their item index and message-store links are empty, so edits and reactions no longer reach those replies until the thread reloads. Duplicate ingestion into the emptied index is unverified. Untested. +- **Suggested change:** Dispose only on release from the store, or clear `state.items` with the index so the next activation reseeds; add an unregister then register test. + + + +#### `poll.update` requires `id` and `name` + +`bug` · confidence: high · pre-release: no · polls + +- **Where:** `src/poll.ts:337` +- **What:** `data: Exclude`; `Exclude` on an object type is a no-op, so `id` (overwritten by the method) is still required. Carried from v9. +- **Evidence / scenario:** `poll.update({ name: 'x' })` fails to compile (`UpdatePollRequest.id: string`, `src/gen/models/index.ts:10850`). +- **Suggested change:** `Omit` (widening, not breaking). + + + +#### `ThreadState.participants` rows are rebuilt with fake timestamps + +`bug` · confidence: high · pre-release: no · threads + +- **Where:** `src/thread.ts:819-836` (`subscribeParentMessageFromStore`), `:940-958` (`normalizeThreadParticipants`); v9 `src/thread.ts:48,170` +- **What:** Data loss. On `registerSubscriptions()` (the store subscription fires immediately) and on every parent-message update, `state.participants` is replaced with rows built from `parentMessage.thread_participants` (`UserResponse[]`): `created_at` and `last_read_at` are set to `nowNs()`, and the `custom`, `left_thread_at` and `last_thread_message_at` that came from `threadData.thread_participants` are dropped. v9 kept the thread response's participants unchanged. +- **Evidence / scenario:** Probe: before `registerSubscriptions()` a participant had `created_at` 5e17, `last_read_at` 6e17 and `custom: { x: 1 }`; after it both timestamps were the current time and `custom` was gone. A UI showing "joined" / "last read" per participant shows the subscribe time. `test/unit/threads.test.ts:340-362` asserts only `user_id`. +- **Suggested change:** Merge by `user_id`: keep the existing row's fields and only refresh `user`; synthesize rows (with `undefined` timestamps rather than now) only for users not yet in `participants`. + +### Offline support + + + +#### `isEphemeral` treats local programming errors as transient + +`bug` · confidence: high · pre-release: no · offline support + +- **Where:** `src/errors.ts:84-88`; `src/offline-support/offline_support_api.ts:1451, 1579` +- **What:** Any error without `response` counts as ephemeral, including `TypeError`, `resolveUrl` validation errors and "Channel isn't yet created" from generated channel methods. +- **Evidence / scenario:** Such a task is queued by `queueTask` and never deleted by `executePendingTasks`; it fails on every sync. +- **Suggested change:** Treat as ephemeral only axios network errors (`isAxiosError(e) && !e.response`) and `OfflineError`; everything else is non-ephemeral. + +### Search, pagination, notifications + + + +#### `updateConfig()` patches are lost on re-derivation, including every reconnect + +`bug` · confidence: high · pre-release: no · client config / reminders / notifications / search + +- **Where:** `src/client.ts:415-446` (`wireClientConfiguration`, `initializeManagerConfig`), `:649-650` (`_setUser` re-arm), `:872-873` (`disconnectUser` release); `src/configuration/utils/applyInstanceConfiguration.ts` (`apply()` on subscribe); `src/configuration/ConfigController.ts:192-196` (`initialize` clears `patchLayer`), `:215-243` (unretained `patch`); `src/reminders/ReminderManager.ts:101-120`; `src/notifications/NotificationManager.ts:48-55, 73-75`; `src/search/SearchController.ts:113-121`; v9 `src/reminders/ReminderManager.ts:85-96` +- **What:** `StreamChat.initializeManagerConfig()` re-derives every client-owned manager (`reminders`, `threads`, `messageDeliveryReporter`, `notifications`, `wsConnection`, `connectionRecovery`, `networkConnection`) through `initializeConfig`, which calls `ConfigController.initialize` and drops every `updateConfig` patch (`initialize` clears even retained patches). It runs on any change under the `client` key (including a sibling such as `client.threads`), on `client.config.reset()`, and on every `connectUser()` after `disconnectUser()`, because `disconnectUser` releases the subscription and `_setUser` re-arms it, which applies the configuration immediately. `SearchController` and a pre-built `NotificationManager` lose patches the same way. For reminders this is a v10 regression: v9 `updateConfig` wrote into a plain `configState` that persisted for the client's lifetime. +- **Evidence / scenario:** Probe: `client.reminders.updateConfig({ stopTimerRefreshBoundaryMs: 5 })` reads 5, then 1209600000 again after `client.config.set({ client: { threads: {...} } })`; after a simulated disconnect and `_setUser`, reminders are back to 1209600000 and `notifications.config.durations.error` goes from 9999 to 3000. Also `const nm = new NotificationManager(); nm.updateConfig({ durations: { error: 10000 } }); new StreamChat(key, { client: { notificationManager: nm } })` leaves `nm.config.durations.error` at 3000. Not placed in section 1 because `docs/instance-configuration.md:527-536` documents drop-on-next-cycle as intended for everything but `MessageComposer` (retention deferred as FU-35); that doc does not say that a cycle includes sibling-key changes and every reconnect. Now documented in the breaking-changes doc (`#reminder-config-state`, `#notification-manager-config`). +- **Suggested change:** Enable `retainPatches` for the client-owned managers and `SearchController`, and make the client's re-derivation call `rederive` instead of `initialize` (keep `initialize` for `config.reset()`); or skip re-deriving managers whose slice did not change. At minimum, document that the reconnect re-applies configuration. + + + +#### `Readonly` config objects are mutable + +`bug` · confidence: medium · pre-release: no · search / notifications + +- **Where:** `src/search/SearchController.ts:108-110`; `src/notifications/NotificationManager.ts:43-45` +- **What:** `config` is typed `Readonly`, but the published object is a shallow spread of frozen defaults. +- **Evidence / scenario:** `controller.config.keepSingleActiveSource = false` mutates silently without notifying `configState`; `notificationManager.config.durations.error = x` throws when `durations` is the frozen default but mutates silently when the constructor supplied it. +- **Suggested change:** Freeze the published value in `ConfigController.write` (at least in dev builds). + + + +#### `MessageSearchSource.channelQuerySort` is ignored + +`bug` · confidence: high · pre-release: no · search + +- **Where:** `src/search/MessageSearchSource.ts:65, 85, 126` vs `:276-284` +- **What:** The option is accepted, stored and documented, but the hydration query always sends `sort: [{ field: 'last_message_at', direction: -1 }]`. v9 merged it. +- **Evidence / scenario:** Probe: `new MessageSearchSource(client, { channelQuerySort: [{ field: 'created_at', direction: 1 }] })` sends only `last_message_at`. The hydration order has no visible effect, so the impact is that the option is dead. +- **Suggested change:** Send `sort: this.channelQuerySort ?? [{ field: 'last_message_at', direction: -1 }]` (non-breaking, any time). Removing the option instead would be breaking, so if that is the choice, make it before 10.0.0. + +### Composer and utils + + + +#### `polls.enabled` is resolved but never enforced + +`bug` · confidence: high · pre-release: no · composer + +- **Where:** `src/messageComposer/configuration/types.ts:152-158`; `src/messageComposer/messageComposer.ts:646, 1270-1275` +- **What:** The JSDoc says client and server flags are ANDed "so either side can switch it off", but nothing reads `config.polls.enabled`. +- **Evidence / scenario:** With `polls: false` on the channel type, `composer.createPoll()` still calls `client.polls.createPoll`. +- **Suggested change:** Return early in `createPoll` (and optionally `PollComposer.compose`) when disabled, or document it as a UI-only flag. + + + +#### Over-length composition is discarded without feedback + +`bug` · confidence: medium · pre-release: no · composer + +- **Where:** `src/messageComposer/messageComposer.ts:107-118` (`MessageComposerSubmitResult` docs); `src/messageComposer/middleware/messageComposer/compositionValidation.ts:71-75`; `src/messageComposer/textComposer.ts:427-430` +- **What:** The docs say a `nothing-to-send` reason "has already been reported through `client.notifications`", but `text.length > maxLengthOnSend` is discarded silently. v10 defaults `maxLengthOnSend` to the server `max_message_length`, and `setText` does not truncate. +- **Evidence / scenario:** Pasting text longer than the server limit and pressing send returns `nothing-to-send` with no notification. +- **Suggested change:** Emit a validation notification (e.g. `validation:message:text:too-long`) or reword the doc. + + + +#### Mention filtering crashes on a mute without `target` + +`bug` · confidence: medium · pre-release: no · composer + +- **Where:** `src/messageComposer/middleware/textComposer/mentions.ts:791, 798` +- **What:** `mute.target!.id` with eslint-disable; `target` is optional on the generated `UserMuteResponse`. +- **Evidence / scenario:** A mute entry without `target` throws `TypeError` while filtering suggestions. +- **Suggested change:** `mute.target?.id === suggestion.id` / `!==`. + + + +#### `formatMessage` returns a stale result for a mutated source + +`bug` · confidence: medium · pre-release: no · utils (public export) + +- **Where:** `src/utils.ts:220-257`; `src/formatMessageMemo.ts:14-24` +- **What:** Memoized on source-object identity (`WeakMap`). Formatting, mutating and formatting again returns the first result. Intended for immutable event payloads, but `formatMessage` is public and used directly by stream-chat-react. +- **Suggested change:** Document the immutability contract on the export, or key the memo on an `updated_at` / version check. + +### Types and packaging + + + +#### `Attachment.custom` is required on requests + +`bug` · confidence: high · pre-release: no · generated models + +- **Where:** `src/gen/models/index.ts:598-599` +- **What:** The shared request/response `Attachment` interface requires `custom`, so every outgoing attachment needs `custom: {}`; the composer's `LinkPreview` / `OGAttachment` fail compatibility for the same reason. +- **Evidence / scenario:** Docs snippets and downstream compat checks: "Property 'custom' is missing in type 'LinkPreview'" (7x), attachment literals (15x). +- **Suggested change:** Make `custom` optional on the request side in the spec, or generate a separate request attachment type (widening, not breaking). + + + +#### `webSocketImpl: typeof WebSocket` rejects the `ws` package + +`bug` · confidence: high · pre-release: no · configuration / packaging + +- **Where:** `src/connection/wsConnection/types.ts:81` +- **What:** `ws` is the documented way to run on Node < 22, but its class lacks DOM `WebSocket` members such as `dispatchEvent`. +- **Evidence / scenario:** `{ client: { wsConnection: { webSocketImpl: WS } } }` with `import WS from 'ws'` fails with TS2322 (TS 6.0.3, `@types/ws` 8). Runtime works on Node 20. +- **Suggested change:** Type it as a minimal constructor (`new (url: string) => Pick`) or document the cast. + + + +#### `@stream-io/state-store` selector that drops a key never notifies + +`bug` · confidence: high · pre-release: no · external dependency + +- **Where:** `node_modules/@stream-io/state-store/src/state-store.ts:76` (`doSelectionsEqual`, `@stream-io/state-store` 1.1.6); v9 `src/store.ts:86-90` +- **What:** Equality iterates only the new selection's keys, so a selection that loses a key while the remaining keys are unchanged compares equal. Behaviour swap, not a pure regression: v9's in-repo `StateStore` iterated only the previous keys, so it missed a selection that gains a key; v10 misses one that drops a key. +- **Evidence / scenario:** `store.subscribeWithSelector((s) => (s.loaded ? { a: s.a } : { a: s.a, b: s.b }), h)` then `partialNext({ loaded: true })`: `h` is not called. Probe (handler calls including the initial one): drop-key v9 2 / v10 1, add-key v9 1 / v10 2. +- **Suggested change:** Compare key counts or iterate the union of keys; fix in js-toolkit and raise the `^1.1.6` floor. A dependency patch plus a floor bump is non-breaking, so any 10.0.x. + + + +#### Shipped `.d.ts` needs `NodeJS` and DOM ambient types + +`bug` · confidence: high · pre-release: no · packaging / types + +- **Where:** `src/connection/wsConnection/StableWSConnection.ts:60, 66` (`NodeJS.Timeout` fields), `:14-20, 286, 611, 725` (`CloseEvent`, `ErrorEvent` in public signatures); `dist/types/messageComposer/attachmentManager.d.ts`, `dist/types/messageComposer/fileUtils.d.ts` (`FileList`) +- **What:** v10 dropped every `@types/*` runtime dependency (v9 got `@types/node` via `@types/ws`), but the declarations still name `NodeJS` and DOM-only types. +- **Evidence / scenario:** Browser or RN project with `skipLibCheck: false` and no `@types/node`: TS2503 `Cannot find namespace 'NodeJS'` in `StableWSConnection.d.ts` (2x). Node-only project (`lib: ["es2022"]`, `types: ["node"]`): TS2304 on `CloseEvent` / `ErrorEvent`, plus TS2304 `FileList` twice (`FileList` failed the same way in v9). Verified with TS 5.5.4 to 6.0.3. Only `skipLibCheck: false` projects see it. +- **Suggested change:** Type timers as `ReturnType`; replace DOM types in public signatures with narrow structural types (`{ code?: number; reason?: string }`, a `FileList`-free union) or make those handlers private / `@internal`. Non-breaking, so 10.0.x is fine. + +### Downstream SDKs (found while auditing; fix in those repos) + + + +#### React: chat context memo key reads `client.listeners` as an object + +`bug` · confidence: high · pre-release: no · stream-chat-react + +- **Where:** stream-chat-react `src/components/Chat/hooks/useCreateChatContext.ts:19` +- **What:** `Object.keys(client.listeners).length`, but `client.listeners` is now a `Map`, so the term is always 0 and the memo key no longer reacts to listener changes. RN removed the equivalent. +- **Suggested change:** `client.listeners.size`, or drop the term. + + + +#### React: delete fallback passes request fields as path params + +`bug` · confidence: high · pre-release: no · stream-chat-react + +- **Where:** stream-chat-react `src/components/Message/hooks/useDeleteHandler.ts:42` +- **What:** `client.deleteMessage({ id: message.id, ...options })` spreads `hard` / `delete_for_me` into `pathParams`. +- **Evidence / scenario:** With any delete option set, `resolveUrl` throws `"hard" is not a path parameter ...`. Only reached when `entity.messageOperations` is absent. +- **Suggested change:** `client.deleteMessage({ id: message.id }, options)`. + + + +#### React: test helpers import the removed `ChannelAPIResponse` + +`bug` · confidence: medium · pre-release: no · stream-chat-react + +- **Where:** stream-chat-react `src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx:32`, `src/mock-builders/generator/channel.ts:3`, `src/mock-builders/api/getOrCreateChannel.ts:1`, `src/mock-builders/api/queryChannels.ts:1` +- **What:** The type no longer exists in v10, which suggests React's test files are not type-checked against the rc. +- **Suggested change:** Migrate to `ChannelStateResponseFields` and type-check tests in CI. + +--- + + + +## 3. Dead code and leftovers + +Spot-checked 25 `deadcode.md` rows against source and the downstream trees (React `master`, RN `V10`, including tests and examples). The token method reports in-file use and type-only use in public signatures as "unreferenced"; those false positives are listed in [Dropped after re-check](#dropped). + +### 3.1 Removal candidates before 10.0.0 + +Unused in `src` and not used downstream. Most are v10-only. The ones marked "v9 API" (`skipHydration`, `Moderation.unmuteUser`, the `MessageReceiptsTracker` event methods, and the [restated types](#lo-restated-types)) were v9 public surface: removing them is a breaking change vs v9, so decide before 10.0.0 and list any removal in v10-breaking-changes.md. + + + +#### `client.preventThreadCleanup` does nothing + +`dead-code` · confidence: high · pre-release: yes · client + +- **Where:** `src/client.ts:214-218` +- **What:** Public field read nowhere in `src` (v9 read it in `ChannelState`). RN V10 no longer sets it. +- **Suggested change:** Remove. + + + +#### `ChannelStateOptions.skipHydration` is ignored + +`dead-code` · confidence: high · pre-release: yes · client / types + +- **Where:** `src/types.ts:115`; documented at `src/client.ts:1566-1567` +- **What:** Never read (dead in v9 too), but `queryChannelsAndHydrate`'s JSDoc says it "returns the raw query response". v9 API (v9 `src/types.ts:1212`): removing is a breaking change vs v9; decide before 10.0.0. +- **Suggested change:** Remove the field and the JSDoc line (or implement it). + + + +#### `Moderation.unmuteUser` changed meaning under the v9 name + +`dead-code` · confidence: medium · pre-release: yes · moderation + +- **Where:** `src/moderation.ts:91-93` +- **What:** One-line alias of the inherited `unmute({ target_ids: [targetId] })`. v9's second parameter was `options` (with `user_id`); v10's is `requestOptions`, so v9 callers compile and silently lose their options. `muteUser` is gone, so the pair is asymmetric. No src, test or downstream use (React calls `moderation.unmute`). v9 API (v9 `src/moderation.ts:135`): removing is a breaking change vs v9; decide before 10.0.0. +- **Suggested change:** Remove. + + + +#### `MessageDeliveryReporter.setMarkAsReadThrottleOptions` + +`dead-code` · confidence: high · pre-release: yes · message delivery + +- **Where:** `src/messageDelivery/MessageDeliveryReporter.ts:136-148`; JSDoc on `markAsReadThrottleTimeoutMs` (`:30`) and `buildThrottledMarkRead` point to it +- **What:** Wrapper around `updateConfig({ markAsReadThrottleTimeoutMs })`; its comment says "Kept as released surface", but it did not exist in v9 (rc-only). Not downstream. +- **Suggested change:** Remove and fix the two JSDoc references. + + + +#### `PaginatorPlugin` and `PaginatorItemsChangeProcessor` + +`dead-code` · confidence: high · pre-release: yes · pagination + +- **Where:** `src/pagination/paginators/BasePaginator.ts:285-328` +- **What:** Exported, v10-only, only consumer is a commented-out `plugins?` option with "todo: think whether plugins are necessary". `isLiveHeadInterval` / `isLiveTailInterval` are runtime exports used only in this file. +- **Suggested change:** Remove the two types; drop `export` from the two helpers. Keep `LogicalInterval`, `IntervalMergePolicy`, `MakeIntervalParams` (they name protected members). + + + +#### `EntityStore.isHeld` + +`dead-code` · confidence: low · pre-release: yes · entity store + +- **Where:** `src/entityStore/EntityStore.ts:179` +- **What:** Only tests call it; `EntityStore` is a new v10 root export, so this is new public surface. +- **Suggested change:** Remove or mark `@internal`. Keeping it is harmless. + + + +#### `MessageReceiptsTracker` event-ingestion methods + +`dead-code` · confidence: medium · pre-release: yes · message delivery + +- **Where:** `src/messageDelivery/MessageReceiptsTracker.ts:331` (`onMessageDelivered`), `:367` (`onMessageRead`), `:417` (`onNotificationMarkUnread`) +- **What:** No src callers: the tracker reconciles from `channel.state.read` (`:217-241`). Calling them from app code makes the tracker diverge until the next reconcile overwrites it. `onNotificationMarkUnread` has no downstream use; `onMessageDelivered` / `onMessageRead` are used by RN tests (`MessageStatus.test.tsx:132`, `receiptUserHooks.test.tsx:91`); React mentions them only in `specs/*.md`. v9 API: v9 `messageDelivery/MessageReceiptsTracker.ts:179, 213, 260` defined all three and v9 `channel.ts:2041, 2073, 2276` called them, so removing any of them (or marking it `@internal`) is a breaking change vs v9; decide before 10.0.0. +- **Suggested change:** Remove `onNotificationMarkUnread`; mark the other two `@internal` (or move RN tests to driving `channel.state.read`). Keep `ingestInitial` and the query methods. + + + +#### New v10 root exports with no consumer + +`dead-code` · confidence: low · pre-release: yes · public surface + +- **Where:** `src/index.ts:34, 37, 39-40, 121` +- **What:** `mergeServerRestrictions`, `ServerRestrictions`, `ServerUpperBounds`, `ConfiguredInstance`, `NS_PER_MS` are new in v10, not used downstream (React defines its own `NS_PER_MS` in a test), and not needed to name a public signature. Removing them after 10.0.0 is breaking; adding later is not. +- **Suggested change:** Decide whether they are intended public API; otherwise drop them from the barrel. + + + +#### `ChatLoggerScope` declares scopes nothing logs under + +`dead-code` · confidence: high · pre-release: yes · logging + +- **Where:** `src/logger.ts:12, 14` +- **What:** `'state-store'` (moved to the external package) and `'thread'` have no `getLogger(...)` call in `src`; 13 scopes are actually used. `docs/logging.md` lists both. +- **Suggested change:** Remove both members (and from the doc), or start logging under them. + +### 3.2 Internal dead code (not exported; remove any time) + + + +#### Unused internal helpers and dead branches + +`dead-code` · confidence: high · pre-release: no · various + +- **Where / What:** + - `src/pagination/utility.search.ts:25, 62`: `locateOnPlateauAlternating`, `locateOnPlateauScanOneSide` referenced nowhere in `src` or `test`. + - `src/pagination/utility.normalization.ts:89`: `normalizeStringAccentInsensitive` never called. + - `src/channel.ts:2460, 2515`: `void userReadState;` after `_upsertReadState(...)`; the value is unused. + - `src/ChannelManager.ts:165`: `if (!channel) return;` after `await ...ensureWatched()`, typed `Promise` (`src/channel.ts:1595`). + - `src/messageDelivery/MessageDeliveryReporter.ts:248-262`: thread branch of `getNextDeliveryReportCandidate` computes `latestMessages`, `lastReadAt`, `lastDeliveredAt` (behind a `@ts-expect-error`) and `key`, then returns `undefined` (same in v9). + - `src/utils.ts:572`: `FIXME: we are returning an object that does not match LocalMessage` in `toDeletedMessage`'s hard-delete branch. +- **Suggested change:** Delete the helpers and dead statements; replace the delivery branch with an early `return` plus a short TODO; type the hard-deleted shape (`satisfies Partial`) and drop the FIXME. + +### 3.3 Keep (used downstream) + + + +#### `CustomCommandData` is augmented downstream but has no effect + +`dead-code` · confidence: high · pre-release: yes · types + +- **Where:** `src/custom_types.ts:6` +- **What:** Exported for augmentation but referenced nowhere in v10 `src` (v9 fed `CommandVariants`). React (`src/@types/stream-chat-custom-data.d.ts:23`) and RN (`package/src/types/stream-chat-common-custom-data.d.ts:24`, two examples) still augment it, so removal breaks their declaration files, yet the augmentation does nothing. +- **Suggested change:** Type `Command['name']` with it again, or remove it together with the downstream declarations. + + + +#### Members still used by downstream code, tests or examples + +`dead-code` · confidence: high · pre-release: no · various + +- **Where / What:** no src reader, but used downstream, so keep (or migrate the consumer first): + - `AttachmentManager.fileToLocalUploadAttachment` (deprecated): RN `examples/ExpoChatGPTSample/screens/ChatContent.tsx:121`. + - `AttachmentManager.uploadAttachment` (deprecated): runtime use in both UI SDKs. React `src/components/MediaRecorder/hooks/useMediaRecorder.ts:58`, `src/components/MessageComposer/AttachmentPreviewList/AttachmentPreviewList.tsx:99-141` (`handleRetry`), `VoiceRecordingPreviewSlot.tsx:39`; RN `package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx:353-503`, `package/src/components/MessageInput/hooks/useAudioRecorder.tsx:111`. + - `BasePaginator.next`, `hasNext`, `hasPrev` (deprecated shims): React `src/components/ChannelList/ChannelList.tsx:95` (`loadNextOnScrollToBottom={paginator.next}`), `src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx:66-67` (see [shims](#lo-paginator-shims)). + - `TextComposer.upsertMentionedUser` (deprecated): React `MessageInput.test.tsx:1750-1778`. + - `MessageComposer.allowsPendingUploads`: React `examples/vite/src/Debug/ComposerStateInspector.tsx:140`. Now only an alias of `attachmentManager.config.pendingUploadsEnabled` (`src/messageComposer/messageComposer.ts:474-480`), still read by `hasSendableData`. + - `MessageReceiptsTracker.onMessageDelivered` / `onMessageRead`: RN tests (see [above](#dc-receipts-tracker)). + - `OfflineDBSyncManager.connectionChangedListener`: RN tests (see [rename](#connection-changed-listener)). + - Opt-in middleware factories `createCommandInjectionMiddleware`, `createDraftCommandInjectionMiddleware`, `createActiveCommandGuardMiddleware`, `createCommandStringExtractionMiddleware`, and `replaceWordWithEntity`: used downstream. + - `BUILT_IN_INSTANCE_KEYS`, `CONSTRUCTION_ONLY_CONFIG_PATHS`: see [@internal exports](#lo-internal-exports). +- **Suggested change:** Keep; for the deprecated ones, move the downstream use first, then remove in a later major. + + + +#### v9 exports now unused in src and downstream + +`dead-code` · confidence: medium · pre-release: no · public surface + +- **Where / What:** v9 public API, not broken in v10, but with no src and no downstream use (React has its own copies of the file helpers): `isOwnUser` (`src/utils.ts:82`), `readFileAsArrayBuffer` (`src/messageComposer/fileUtils.ts:55`), `RequireAtLeastOne` (`src/types.ts:51-53`, its only v9 user `TranslatedField` is gone), `extractPollEnrichedData` (`src/poll.ts:496`), `MiddlewareExecutor.installedMiddleware` (`src/middleware.ts:68`). +- **Suggested change:** Keep for v10 (removal is a v9 break with no v10 reason); consider `@deprecated` and removal in v11. If the team prefers a clean break, drop them in this major instead. + +### 3.4 Migration leftovers + + + +#### Deprecated client aliases kept into the new major + +`leftover` · confidence: medium · pre-release: yes · client + +- **Where:** `src/client.ts:635` (`setUser`), `:897` (`disconnect`), `:1132` (`_updateUserReferences`), `:545-552` (`setMessageComposerSetupFunction`); error text `:581` +- **What:** Aliases of `connectUser`, `disconnectUser`, `_updateMemberWatcherReferences`, and `config.setSetupFunction('messageComposer', fn)`. None is used downstream (React migrated; RN only mentions the composer one in a comment). The `connectUser` error still says "Use client.disconnect()". Keep `userID`, `clientID`, `setUserAgent` (used downstream). +- **Suggested change:** Drop the four aliases before 10.0.0 or document them as kept for the v10 line; change the message to `disconnectUser()`. + + + +#### `Thread.markAsRead` + +`leftover` · confidence: high · pre-release: yes · threads + +- **Where:** `src/thread.ts:928-932` +- **What:** The v9 name, now a `@deprecated` alias of `markRead`. No src or downstream callers. +- **Suggested change:** Remove before 10.0.0 (and note the rename), or keep for the whole v10 line deliberately. + + + +#### Thread `*Locally` methods marked "remove with the next breaking change" + +`leftover` · confidence: high · pre-release: yes · threads + +- **Where:** `src/thread.ts:850-914` (`deleteReplyLocally`, `upsertReplyLocally`, `updateParentMessageLocally`, `updateParentMessageOrReplyLocally`); `:674-682, 858-865` +- **What:** Each carries "todo: can be removed with the next breaking change and use MessagePaginator only"; v10 is that change. Only the thread's own handlers (and offline `send-message` replay, `queueableOperations.ts`) call them; React and RN stopped. `upsertReplyLocally` ignores its `timestampChanged` parameter, yet `handleNewReply` still computes `isOwnMessage` to pass it. +- **Suggested change:** Make them private (or inline into the handlers); drop `timestampChanged` and the `isOwnMessage` computation. + + + +#### Paginator and notification compat shims + +`leftover` · confidence: high · pre-release: no · pagination / notifications + +- **Where:** `src/pagination/paginators/BasePaginator.ts:237-249, 700-709, 3492-3529` (`next`, `prev`, `nextDebounced`, `prevDebounced`, `hasNext`, `hasPrev`, `PaginationQueryReturnValue.next/prev`); `src/notifications/types.ts:122-133` +- **What:** Deprecated shims shipped in the new major. React `master` still calls them at runtime on `ChannelPaginator`: `loadNextOnScrollToBottom={paginator.next}` (`src/components/ChannelList/ChannelList.tsx:95`) and `paginator.hasPrev` / `paginator.hasNext` (`src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx:66-67`, through `PaginatorLike`). `NotificationState` is deprecated, yet `NotificationManagerState` is declared as an alias of it, so the deprecated name is the canonical definition. +- **Suggested change:** Keep them for the whole v10 line ([keep](#keep-downstream)); removing them first requires migrating React's ChannelList / InfiniteScrollWithComponents to `toTail` / `hasMoreTail` / `hasMoreHead`, so remove in v11. Invert the alias so `NotificationState` points at `NotificationManagerState` (non-breaking, any time). + + + +#### Composer deprecations and "next major" TODOs + +`leftover` · confidence: high · pre-release: yes · composer + +- **Where:** `src/messageComposer/attachmentManager.ts:144-156` (`fileUploadFilter` getter/setter), `:566` (`fileToLocalUploadAttachment`), `:689` (`uploadAttachment`), `:811` (`// todo: remove with the next major release`), `:187-189` (`hasCustomDoUploadRequest`); `src/messageComposer/textComposer.ts:331-356` (`upsertMentionedUser`, `getMentionedUser`, `removeMentionedUser`); `src/messageComposer/middleware/textComposer/types.ts:123` (`TextComposerState.mentionedUsers`); `src/messageComposer/types.ts:112` (`LocalImageAttachmentUploadMetadata.previewUri`) +- **What:** v9-era deprecations kept in the v10 major. `hasCustomDoUploadRequest` has no reader since `customCdn` replaced the custom-upload waiver and invites the v9 reading "custom fn = custom storage" (React mentions it only in an example comment). `previewUri` is widely used downstream; `fileToLocalUploadAttachment`, `uploadAttachment` (runtime use in React and RN) and `upsertMentionedUser` are used downstream (see [keep](#keep-downstream)). The deprecated `fileUploadFilter` getter is still read by the upload path (`:811`), so removing it removes the `config.fileUploadFilter` filter feature, not dead code. +- **Suggested change:** Decide per member: keep `uploadAttachment`; remove the unused `getMentionedUser`, `removeMentionedUser` and `hasCustomDoUploadRequest` (or deprecate it toward `usesStreamStorage`) now; remove `fileUploadFilter` only together with the `:811` filter path (filtering moves to middleware); re-target the TODOs on the used ones to v11 explicitly. + + + +#### `onlineStatusChanged` shims + +`leftover` · confidence: low · pre-release: yes · connection + +- **Where:** `src/connection/wsConnection/StableWSConnection.ts:502-530`, `src/connection/wsConnection/WSConnection.ts:339-345` +- **What:** `@deprecated` shims for the v9 window-listener path; network status flows through `client.networkConnection`. The JSDoc (`WSConnection.ts:339-341`, `StableWSConnection.ts:511`) says React Native still calls it, but RN V10 has no call left (it moved to a `statusReporter`). +- **Suggested change:** Remove both shims, or at least fix the JSDoc. + + + +#### Other "next major" notes and stale TODOs + +`leftover` · confidence: high · pre-release: no · various + +- **Where / What:** `src/offline-support/offline_support_api.ts:489-490` ("This will change in the next major release" in `init`); `src/LiveLocationManager.ts:84` (`UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT`, a v9 export now only the seed of `DEFAULT_LIVE_LOCATION_MANAGER_CONFIG.minUpdateThrottleMs`). See also [ChannelStateOptions.withResponse](#doc-next-major-comments) and [queryChannels JSDoc](#doc-next-major-comments). +- **Suggested change:** Resolve the `init` note now or re-target it to v11; mark the constant `@deprecated` in favour of the config. + + + +#### Comment-only stub modules + +`leftover` · confidence: high · pre-release: no · repo hygiene + +- **Where:** `src/campaign.ts:1`, `src/segment.ts:1`, `src/channel_batch_updater.ts:1` +- **What:** Each is a single comment ("... moved to the server-side SDK"); none is exported from `index.ts` or imported anywhere. They only emit empty `dist/types/*.d.ts` modules. `CLAUDE.md:75` still lists `channel_batch_updater.ts`. +- **Suggested change:** Delete the three files and the CLAUDE.md mention. + + + +#### `@internal` exports that are public in practice + +`leftover` · confidence: high · pre-release: yes · public surface + +- **Where:** `src/configuration/keys.ts` (`BUILT_IN_INSTANCE_KEYS`), `CONSTRUCTION_ONLY_CONFIG_PATHS`; `src/logger.ts:19-23` (`ScopedLogger`) +- **What:** `BUILT_IN_INSTANCE_KEYS` and `CONSTRUCTION_ONLY_CONFIG_PATHS` are `@internal` but root-exported and imported by React's vite example (`examples/vite/src/AppSettings/tabs/Configuration/configurationTree.ts`). `ScopedLogger` is `@internal` but root-exported (no downstream use). +- **Suggested change:** Either drop `@internal` (they are de facto public) or stop root-exporting them and move the React example off them. + + + +#### Types that only restate a generated type + +`leftover` · confidence: high · pre-release: yes · types + +- **Where / What:** + - `src/reminders/Reminder.ts:20`: `ReminderResponseBaseOrResponse = ReminderResponseData` (used in `Reminder.ts` / `ReminderManager.ts` only). + - `src/types.ts:128-130` (`Automod`, `AutomodBehavior`), `:413` (`ThreadFilters`): unused in src and downstream. + - `src/types.ts:46-49`: `RequireOnlyOne` duplicates the identical generated `RequireOnlyOne` (`src/gen/models/index.ts:120-123`) and shadows the star re-export. + - `src/types.ts:59-68`: `APIResponse`, see [unbanUser](#unban-user). +- **v9 API:** `Automod`, `AutomodBehavior`, `ThreadFilters`, `ReminderResponseBaseOrResponse`, `RequireOnlyOne` and `APIResponse` are all v9 exports: removing them is a breaking change vs v9; decide before 10.0.0 (except `RequireOnlyOne`, whose identical generated twin stays exported under the same name). +- **Suggested change:** Use the generated names and drop the aliases (the `RequireOnlyOne` copy can go any time since the generated one stays exported). + +--- + + + +## 4. Simplifications + +Non-breaking; land any time. Breaking simplifications are in [section 1](#fix-before). + + + +#### `engines.node >=22.18.0` blocks installs that would run + +`simplification` · confidence: medium · pre-release: no · packaging + +- **Where:** `package.json:107-108` +- **What:** The floor is the dev tooling's (type-stripped `.mts` build scripts, commit 40cf0629), but `engines` is consumer-facing. The runtime only needs a global `WebSocket`; the node-cjs bundle connects on Node 22.14.0. No consumer path needs 22.18: there is no `bin` and no `i18n` subpath in `exports`; only `prepare` (git-ref installs) runs the `.mts` scripts. +- **Evidence / scenario:** Yarn 1 refuses the install on Node 20 / 22.0-22.17; npm and pnpm warn. On Node 20, `connectUser` rejects with `WebSocket is not defined` unless `webSocketImpl` is injected (see [webSocketImpl typing](#websocketimpl-type)). +- **Suggested change:** Set `engines.node` to the runtime floor (`>=22`) and keep 22.18 in `.nvmrc` / `devEngines`. Loosening is non-breaking at any time. A `>=20` floor with a documented `webSocketImpl` is a weak option, since Node 20 is EOL (2026-04-30). + + + +#### `unbanUser` is the last hand-written v1 moderation call + +`simplification` · confidence: high · pre-release: no · client / moderation + +- **Where:** `src/client.ts:1839-1844`, `src/types.ts:59-68` (`APIResponse`) +- **What:** `unbanUser` still calls v1 `DELETE /moderation/ban` returning the legacy `APIResponse` envelope, while ban, mute and unmute moved to V2 `client.moderation.*`, which already has `unban`. `APIResponse`'s own comment says to delete it with these methods. RN V10 calls `client.unbanUser(id)` (`useMessageActionHandlers.ts:216`). +- **Suggested change:** Route through `this.moderation.unban({ target_user_id, ...options })` while keeping the `unbanUser(targetUserID, options?)` signature (non-breaking, any time). Deleting `APIResponse` is a v9 break, decided with the [restated types](#lo-restated-types). + + + +#### Offline cache-key option is typed narrower than what is passed + +`simplification` · confidence: medium · pre-release: no · offline support / types + +- **Where:** `src/offline-support/types.ts:42-59` vs `:186-191`; writer `src/pagination/paginators/ChannelPaginator.ts:610-625`, reader `:655-664`; JSDoc `:596-603` +- **What:** Type-only. `DBUpsertCidsForQueryType.options` is declared `ChannelOptions` but receives the full `QueryChannelsRequest`. At runtime writer and reader both pass the full request as `options`, so they agree; the separate `filters` / `sort` exist deliberately for RN's `convertFilterSortToQuery`, which derives the row key from them. +- **Suggested change:** Type `options?: QueryChannelsRequest` on upsert (widening, non-breaking). Dropping `filters` / `sort` would break implementers and needs RN to derive the key from `options` first (plus a schema bump), so only do that before 10.0.0 or in v11. + + + +#### `OfflineDBSyncManager.connectionChangedListener` name is stale + +`simplification` · confidence: low · pre-release: no · offline support + +- **Where:** `src/offline-support/offline_sync_manager.ts:21-25` +- **What:** The field now holds a `wsConnection.state` subscription, not a `connection.changed` listener. RN's tests read and replace it (`package/src/components/Chat/__tests__/Chat.test.tsx:31-35`). +- **Suggested change:** Fix the field's JSDoc to say what it holds. A rename is cosmetic and breaks RN's tests, so it is not worth a pre-release change; rename in v11 if at all. + + + +#### `Channel.hide()` / `show()` overrides only re-await `super` + +`simplification` · confidence: high · pre-release: no · channel + +- **Where:** `src/channel.ts:2113-2127` +- **What:** They only turn a sync throw into a rejection. `show`'s JSDoc says "Ensures the channel is initialized first", which it does not. +- **Suggested change:** Remove the overrides, or keep them deliberately and fix the doc. + + + +#### Message-list publish throttle depends on test-runner env vars + +`simplification` · confidence: medium · pre-release: no · pagination + +- **Where:** `src/pagination/paginators/stateThrottling.ts:11-25` +- **What:** Throttling (500 ms for message lists) is off whenever `VITEST`, `JEST_WORKER_ID` or `NODE_ENV === 'test'` is set, including in integrators' own test suites, so their tests see synchronous publishes that production never does. +- **Suggested change:** Default from configuration (`stateThrottleMs`) instead of environment sniffing, or document it. + + + +#### Non-null assertions in channel query paths + +`simplification` · confidence: medium · pre-release: no · channel + +- **Where:** `src/channel.ts:1862, 1982` (`response.channel!`, `state.channel!`), also `:2836` (`channel.data!`) +- **What:** eslint-disabled `!` against the repo rule. +- **Suggested change:** Guard and throw a descriptive error when the response has no `channel`. + + + +#### Orphaned JSDoc block above `ownDeclarativeConfig` + +`simplification` · confidence: high · pre-release: no · channel + +- **Where:** `src/channel.ts:177-190` +- **What:** The "Frozen for the same reason ... `DEFAULT_CHANNEL_CONFIG`" block sits above another member's doc instead of `DEFAULT_CHANNEL_CONFIG` (`:215`). +- **Suggested change:** Move it. + + + +#### Threads: duplicate resolve pass, assertions and a redundant map + +`simplification` · confidence: medium · pre-release: no · threads / reminders / delivery + +- **Where / What:** + - `src/pagination/paginators/ThreadPaginator.ts:113-125`: `toStoredInstance` repeats `ThreadManager.resolveQueried` (`src/thread_manager.ts:197-204`), which `queryThreadsAndHydrate` already applies (`src/client.ts:2093-2095`); only matters for a standalone paginator with a private store. + - `src/reminders/ReminderManager.ts:209-221`: `reminder!` assertions plus "TODO: OAPI discrepancy?" right after `isReminderWsEventPayload` already checked `!!event.reminder` (`:190-193`). + - `src/thread.ts:129, 495-498, 872-878`: `failedRepliesMap` ("todo: do we really need the failedRepliesMap?"); `reload()` already reads failed replies from the paginator, only `hydrateState` uses the map. + - `src/messageDelivery/MessageReceiptsTracker.ts:732`: `furthestRefByOthers` reads the deprecated `client.userID` (the test mock only provides `userID`). +- **Suggested change:** Delegate both resolve paths to one helper; make the guard a type predicate (`event is ReminderEvent & { reminder: ReminderResponseData }`); read pending replies from `messagePaginator.items` and drop the map; use `client.userId` and update the mock. + + + +#### Composer cleanups + +`simplification` · confidence: high · pre-release: no · composer + +- **Where / What:** + - `src/messageComposer/middleware/textComposer/mentions.ts:570-575`: `sort` initialised to the default then re-assigned the same default. + - `src/messageComposer/textComposer.ts:166, 188, 201`, `src/messageComposer/linkPreviewsManager.ts:163`, `src/messageComposer/attachmentManager.ts:165`: the same 5-line "No early return on same value" comment pasted five times. + - `src/messageComposer/messageComposer.ts:697-717`: `applyServerRestrictions` (`@internal`) is a one-line wrapper of the private `publishConfig`, kept for `Channel.query` (`src/channel.ts:2000`); its JSDoc admits the name is historical. +- **Suggested change:** `const sort = this.memberSort?.length ? this.memberSort.slice(0, 1) : [{ field: 'user_id', direction: 1 }]`; keep the comment once and reference it; expose one internal re-resolve method and drop the alias. + + + +#### Redundant `userId` branch in `MessageSearchSource` + +`simplification` · confidence: high · pre-release: no · search + +- **Where:** `src/search/MessageSearchSource.ts:209-214` +- **What:** `...(this.client.userId ? { members: ... } : {})` right after `if (!this.client.userId ...) return`. +- **Suggested change:** Use `{ members: { $in: [this.client.userId] } }` unconditionally. + + + +#### Build script leftovers + +`simplification` · confidence: high · pre-release: no · build + +- **Where:** `scripts/bundle.mts:25-31, 134-140` +- **What:** `browserIgnoreModules: string[] = []; // Object.keys(packageJson.browser)` and its "ignored in browser field" comment survive the removed `browser` field, so `browserExternal` always equals `modules`. The `CLIENT_BUNDLE` comment lists `index.js` / `index.browser.cjs` / `index.node.cjs`; outputs are `index.mjs` / `index.browser.js` / `index.node.js`. +- **Suggested change:** Drop the filter and use `modules`; fix the comment. + + + +#### `Channel.queryMembers` spec-gap TODOs + +`simplification` · confidence: low · pre-release: no · channel / spec + +- **Where:** `src/channel.ts:660-672` +- **What:** "these should be probably optional in the OAPI spec" and a `user_id` cast in the hand-written wrapper. +- **Suggested change:** Fix the spec and drop the cast. + +--- + + + +## 5. Doc and comment mismatches + +Only places that survive the guide cleanup: JSDoc and code comments, README, CHANGELOG, `docs/*.md`, `CLAUDE.md`, and the downstream React / RN migration guides. Mismatches that only concern the deleted root guides are dropped (see [Dropped](#dropped)). + +### JSDoc and code comments + + + +#### "Next major" comments in the major + +`doc-mismatch` · confidence: high · pre-release: no · client / types + +- **Where:** `src/client.ts:1501-1509` (`queryChannels`: "a compatibility bridge" for `queryChannelsRequest()`, consolidation "in the next major release"; that method is gone); `src/types.ts:116-123` (`ChannelStateOptions.withResponse`: "should be folded into a single full response API in the next major release") +- **Suggested change:** Rewrite both comments for v10, or do the folding now. + + + +#### Wrong `@returns` and behaviour descriptions on channel methods + +`doc-mismatch` · confidence: high · pre-release: no · channel + +- **Where / What:** + - `src/channel.ts:1301, 1504`: `markRead` / `markUnread` say "or `null` if the request was skipped"; both now throw. + - `src/channel.ts:1281-1289`: `markReadViaReporter` says the reporter "batches" the request; it neither batches nor throttles (only `throttledMarkRead` throttles). + - `src/channel.ts:2221-2223`: `on()` example logs `channel.state.messages` (removed). + - `src/channel.ts:1096-1100`: `_syncMuteStatus` says `muteStatus()` requires initialization; it now requires only an id. + - `src/client.ts:1156-1160`: `_deleteUserMessageReference` says hard delete "strips down" content; `applyMessageDeletionForUser` removes the messages (`MessageIntervalPaginator.ts:1419-1425`). +- **Suggested change:** Fix each; describe `markReadViaReporter` as "runs the reporter's mark-read path (custom handler, local fallback, delivery-candidate cleanup)" and use `channel.messagePaginator.items` in the example. + + + +#### ChannelManager stale TODOs and typo + +`doc-mismatch` · confidence: high · pre-release: no · channel manager + +- **Where:** `src/ChannelManager.ts:118` (todo names non-existent `allowNewMessagesFromUnfilteredChannels`; v9 had `allowNotLoadedChannelPromotionForEvent`, v10 ships `ignoreEventsForUnknownChannels`), `:313` ("ingestig"), `:86` and `:1119` (unresolved todos) +- **Suggested change:** Resolve or remove the todos; fix the typo. + + + +#### Offline support JSDoc drift + +`doc-mismatch` · confidence: high · pre-release: no · offline support / moderation + +- **Where / What:** + - `src/offline-support/offline_support_api.ts:285`: `getChannelsForQuery` `@returns` says `Omit[] | null`; it returns `DBGetChannelsForQueryResult | null` (`{ channels, predefinedFilter? }`). + - `src/offline-support/types.ts:189`: `DBGetChannelsForQueryType.options` is commented "Optional filters for channels."; it is the full `QueryChannelsRequest` used as the cache key. + - `src/offline-support/types.ts:31, 155, 167`, `src/moderation.ts:54`: bulk-rename artifacts ("MessageRequest to which the reaction is applied", "MessageRequest ID to be flagged", ...) where "message" is meant. +- **Suggested change:** Fix the `@returns` and describe `predefinedFilter`; reword the options comment; revert the artifacts to "message" and grep `MessageRequest ID\|MessageRequest to\|MessageRequest associated` across `src`. + + + +#### Paginator JSDoc + +`doc-mismatch` · confidence: high · pre-release: no · pagination + +- **Where / What:** + - `src/pagination/paginators/BasePaginator.ts:719-722`: `isInitialized` says "has not performed any query so far" but returns `true` once a query shape was recorded. + - `src/pagination/paginators/BasePaginator.ts:3260-3266`: the `next`/`prev` fallback "keeps non-migrated external subclasses working", but such subclasses reject in `getNextQueryShape()` (`:996-1000`) and, without `initialCursor`, their cursors are ignored (`:728-730`). + - `src/thread_manager.ts:85, 318, 480`, `src/pagination/paginators/ThreadPaginator.ts:77`: comments compare against "`master`" as the baseline, which goes stale once v10 is `master`. +- **Suggested change:** Fix the `isInitialized` doc; reword the fallback comment to "accepts legacy `next`/`prev` keys for subclasses that implement `getNextQueryShape` and set `initialCursor`" (or drop the fallback); say "v9" instead of `master`. + + + +#### Composer and utils comments + +`doc-mismatch` · confidence: high · pre-release: no · composer / utils + +- **Where / What:** + - `src/messageComposer/messageComposer.ts:138`: `MessageComposerOptions` still lists "legacy thread" as a composition context. + - `src/messageComposer/messageComposer.ts:711`: typo "what it does is\* re-assert". + - `src/utils/mergeWith/mergeWith.ts:10`: "merged into the ." (missing word); `src/utils/mergeWith/mergeWithCore.ts:59, 153`: comments read as pasted chat ("Keeps your existing semantics", "(your current rule)"). +- **Suggested change:** Fix wording. + + + +#### Timestamp guard: "compile error" and `exclude` claims + +`doc-mismatch` · confidence: high · pre-release: no · types / dates + +- **Where:** `src/gen/models/timestamp-guard.ts:4, 6`; `src/index.ts:85-87` +- **What:** (a) "Makes `new Date(ns)` ... a compile error": `const d = new Date(ts)` compiles; it fails only when the result is used as a `Date` (probe, TS 5.5.4 and 6.0.3). (b) "`exclude` it to defer": for package consumers the guard is pulled in through `dist/types/index.d.ts` (`export type {} from './gen/models/timestamp-guard'`), and `exclude` does not drop files reached by import. The guard adds a `DateConstructor` overload to every consumer's global scope, which deserves a note in user-facing docs. +- **Suggested change:** Reword to "the result is not a `Date`; using it as one fails to compile", and say the guard always loads with the root entry. + +### README, CHANGELOG, docs, CLAUDE.md + + + +#### README examples use removed v9 APIs + +`doc-mismatch` · confidence: high · pre-release: no · README + +- **Where:** `README.md:46-48`, `:51-54`, `:91-101` +- **What:** `new StreamChat('API_KEY', { // ...options like baseURL or axiosRequestConfig })` (these now go under `client: {...}`); `client.upsertUser(...)` and `client.partialUpdateUser(...)` (removed; server-side); `channel.sendMessage({ text, custom_property })` and `message.custom_property` (v10 takes `{ message: {...} }` and custom fields live under `custom`). +- **Suggested change:** Rewrite Getting Started and the augmentation example for v10 (`connectUser`, `updateUsersPartial`, `custom.*`). + + + +#### Surviving docs link to the deleted guides + +`doc-mismatch` · confidence: high · pre-release: no · docs + +- **Where:** `docs/fileUpload.md:146`, `docs/logging.md:31`, `docs/instance-configuration.md:1064`, `docs/webhooks.md:41`, `CLAUDE.md:195`; historical mentions in `CHANGELOG.md:21, 401` +- **What:** All point at `v9-to-v10-migration-guide-*.md` or `docs/breaking-changes-v14-v15.md`, which are being deleted. +- **Suggested change:** Repoint to the published migration docs (or inline the needed sentence); leave the CHANGELOG entries as history or add a note. + + + +#### `docs/logging.md` scope list + +`doc-mismatch` · confidence: high · pre-release: no · docs + +- **Where:** `docs/logging.md:23-26` +- **What:** Lists `state-store` and `thread`, under which nothing logs (see [ChatLoggerScope](#dc-logger-scopes)). +- **Suggested change:** Remove them from the list (with the type change). + + + +#### `docs/network-connection.md` "Before" column mixes rc-only names + +`doc-mismatch` · confidence: high · pre-release: no · docs + +- **Where:** `docs/network-connection.md:262-265` +- **What:** `NetworkStatusListenerRegistrar` / `statusListenerRegistrar` and `new StreamChat(key, { WebSocketImpl, wsUrlParams })` appear as "Before", but neither `WebSocketImpl` nor the registrar existed in v9 (rc-only). v9 readers have nothing to migrate from there. +- **Suggested change:** Mark those rows as rc-only, or drop them. + +### Downstream migration guides + + + +#### React `ai-docs/ai-migration-v14-v15.md` + +`doc-mismatch` · confidence: high · pre-release: no · stream-chat-react + +- **Where:** stream-chat-react `ai-docs/ai-migration-v14-v15.md:31`, `:917-935` +- **What:** (a) Recommends "`channel.state.messages` for the raw channel state"; `ChannelState.messages` is removed. (b) Says `` calls `channel.reload()` on `connection.recovered` and that "nothing in the client calls `reload()` for you"; React's `` only activates (`src/components/Channel/Channel.tsx:53-58`), and `client.connectionRecovery` reloads every active channel and thread (`src/connection/ConnectionRecoveryManager.ts:36-40`). The suggested manual `client.on('connection.recovered', ...)` would reload twice. +- **Suggested change:** Point raw state at `channel.messagePaginator`; describe recovery as owned by `client.connectionRecovery` for active channels. + + + +#### RN `ai-docs/ai-migration-v9-to-v10.md` + +`doc-mismatch` · confidence: high · pre-release: no · stream-chat-react-native + +- **Where:** stream-chat-react-native `ai-docs/ai-migration-v9-to-v10.md:161, 463, 1628` (`client.activeChannels`), `:189, 1474-1477, 1685` (`deactivate()`) +- **What:** Recipes sum unread counts over `client.activeChannels` (removed; use `client.channelManager.values()`), and describe `channel.deactivate()` / `thread.deactivate()`; neither exists in v10 (`activate()` returns the release function, which RN's code already uses). +- **Suggested change:** Update the recipes and the K.4 / L.4 sections. + +--- + + + +## 6. Test gaps + +`test-gap` items; all confidence high unless noted; none is pre-release. + +| Where | Gap | Suggested change | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `test/unit/utils.test.js:6` | `describe.skip('generateUUIDv4')` (skipped in v9 too); its TODO says it fails on newer Node, so the no-`crypto` fallback is never tested. | Stub `crypto` instead of skipping. | +| `test/unit/pagination/paginators/BasePaginator.test.ts:1200` | `it.todo('... whe updateState === false')`: the `updateState: false` path is untested ("whe" typo). | Write the test. | +| `test/unit/pagination/paginators/BasePaginator.test.ts:192` | Title "throws is the paginator does implement own getNextQueryShape" means "throws if ... does not implement". | Fix the title. | +| `test/unit/pagination/paginators/MessagePaginator.test.ts:1630` | `describe.todo('postQueryReconcile and deriveCursor for', () => {})`: neither path is tested. | Write the tests. | +| `test/unit/channel.test.js:2618` | Empty body: `it('does not override the delivery information in the read status', () => {})` (also empty in v9). | Implement or delete. | +| `test/unit/channel.test.js:3369-3373` | Comment says "`client.search()` was removed - it forwarded to `client.search()`"; the removed method was `channel.search()`, and it did scope by `cid`. | Fix the comment. | +| `test/unit/messageDelivery/MessageDeliveryReporter.test.ts:475-480` | "still requests when no local unread count was configured" mocks `markRead`, hiding that the real `channel.markRead()` throws for `read_events: false` and `markReadViaReporter()` rejects. | Assert the real rejection. | +| `test/unit/offline-support/offline_support_api.test.ts:2900` | Title "should call \_createDraft for create-reaction task" tests `create-draft` (carried from v9). | Fix the title. | +| `test/unit/MessageComposer/linkPreviewsManager.test.ts:23, 33` | `vi.mock('../../src/utils')` and `vi.mock('../../src/utils/mergeWith')` resolve to `test/src/...` (nonexistent), so the mocks never apply. | Use `../../../src/...`. | +| `test/unit/MessageComposer/attachmentManager.test.ts:18` | `import { beforeEach } from 'node:test'` instead of Vitest; such hooks never run. | Import from `vitest`. | +| `test/unit/MessageComposer/messageComposer.test.ts:1846, 1879` | "as not delivered" and "as not failed" have identical bodies (only the ms offset differs) and neither sets that status. Confidence medium. | Make each set the status it names. | +| `src/messageComposer/attachmentManager.ts:667` | New throw "The upload succeeded but the response carried no file URL" has no test. | Add one. | +| `src/search/MessageSearchSource.ts:259, 288` | Both abort guards are untested since v9's "skips the channel hydration request when the query was aborted" was deleted. | Restore the test. | +| `test/unit/search/MessageSearchSource.test.ts:272-276, 324-328` | Pins the contradictory sort from [messageSearchSort](#message-search-sort). | Update with the fix. | +| `test/unit/signing.test.js:33-36` | "round-trips ... non-Latin-1 ids" only asserts no throw. | Assert `UserFromToken(DevToken(id)) === id` for non-ASCII ids. | +| `src/channel.ts:2680` | No test covers `show_in_channel` reply edits ([item](#show-in-channel-edits)). | Add one. | +| `src/thread_manager.ts:472-475` | No test covers unregister then register of the manager with loaded threads ([item](#thread-manager-reregister)). | Add one. | +| `test/unit/messageDelivery/MessageReceiptsTracker.test.ts` (`createChannelMock`) | Mock provides only the deprecated `userID`, blocking the `userId` switch. | Provide `userId`. | + +--- + + + +## 7. Dropped after re-check + +- **RN imports `PollResponse` with the old meaning** (s5-types-events 14, RN half): RN V10's `upsertPoll.ts` already imports `PollResponseData`; the remaining `PollResponse` use (`usePollState.ts:40`) types `poll.close()`'s return, which is the `{ duration, poll }` envelope in v10, so it is correct. React's `ChannelAPIResponse` half is kept ([item](#react-channel-api-response)). +- **`channel.lastReloadError` announced but missing** (doc-claims-b 24): only in the 8ec9a9d3 commit message, not in CHANGELOG or any surviving doc; nothing to fix. +- **`member.updated` watch storm** (s2-channel 6): kept but downgraded to low, since the scenario depends on the server delivering `member.updated` for unwatched channels ([item](#member-updated)). +- **`client.deleteReminder` lost `user_id`** (tests followups): server-side capability, removed by design with the rest of the server-side surface. +- **Unexplained snippet rewrite for silent messages** (snippets 11): low confidence, no behaviour difference identified. +- **Endpoint paths moved to `/api/v2/...`** (snippets 12) and **two config entry points in the docs snippets** (snippets 7): not code issues; they concern getstream.io docs and proxy setups. +- **v9 flaky skipped test** (tests followups): already replaced by a working test in v10. +- **Release notes should treat the `ChannelManager` rewrite as public** (tests followups): concerns release notes only. +- **StateStore "semantics preserved" commit body** (s6 doc row): commit text, not a surviving doc; the behaviour gap is kept as a [bug](#state-store-drop-key). +- **Dead-code false positives from the token scan:** `ChannelState.doSelectionsEqual` (inherited static from `StateStore`); `extractPollData`, `mapPollStateToResponse`, `MODERATION_ENTITY_TYPES`, `LANGUAGE_NAMES`, `accentsMap`, `removeDiacritics`, `isBlobButNotFile`, `getExtensionFromMimeType` (used in their own files); `StreamProgressEvent`, `QueryChannelsResponseWithChannels`, `InsertPosition`, `MiddlewareStatus`, `MiddlewareHandlers`, `TextComposerPreValidationMiddleware`, `PollComposerMiddlewareExecutorOptions`, `PreCommandMiddleware`, `CommandStringExtractionMiddleware` (name public signatures); `ignoreEventsForUnknownChannels` (public opt-in handler); downstream hits for `readFileAsArrayBuffer`, `getExtensionFromMimeType`, `isBlobButNotFile`, `NS_PER_MS`, `setUser` (React's own same-named helpers), `unmuteUser` (i18n keys and a test variable), `hasCustomDoUploadRequest` and `fileUploadFilter` (comments only). +- **Guide-only doc mismatches** (dropped per scope, since the root guides and `docs/breaking-changes-v14-v15.md` are being deleted): s1-client 15-22 and 25; s2-channel 24-25; s3a 16-19; s3b 12; s4-composer-utils 7 guide rows; s5-types-events 13; s6-packaging Node floor, `WebSocketImpl`, i18n dependency table and the dates-guide half of the `new Date(ns)` row (the src JSDoc half is [kept](#doc-timestamp-guard)); doc-claims-a 1-29; doc-claims-b 1-23. Their underlying code issues, where any, are kept above (e.g. `revokeTokens` semantics in [revoke-tokens](#revoke-tokens), `polls.enabled` in [polls-enabled](#polls-enabled)). diff --git a/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md deleted file mode 100644 index ee80520816..0000000000 --- a/docs/breaking-changes-v14-v15.md +++ /dev/null @@ -1,164 +0,0 @@ -# Breaking Changes: v14 → v15 (stream-chat JS) - -Consumer-facing **breaking changes** on the v15 line of the JS SDK — the `message-paginator` -initiative (`feat/message-paginator-master-merge` and the follow-on -`refactor/remove-legacy-channelstate-storage`), beyond the master ⇄ PR merge itself. This is the -source for the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry -self-contained: what changed, before → after, how to migrate, why. - -> Scope note: this file tracks **public API / observable behavior** changes. Internal refactors with -> identical output belong in `decisions.md`, not here. - ---- - -## `ChannelState` message / thread / pinned storage removed — paginators are the source of truth - -**Area:** `ChannelState`, `Channel`, `utils` · **Status:** implemented - -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 list** → `channel.messagePaginator` -- **Thread replies** → `thread.messagePaginator` (via `client.threads` / the `Thread` object) -- **Pinned messages** → `channel.pinnedMessagesPaginator` - -### Removed from `ChannelState` - -Properties / getters: `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`, -`pinnedMessages`. (Also `isUpToDate` and the `last_message_at` setter — see the dedicated entries -below.) - -Methods: `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `findMessage`, -`findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`, -`initMessages`, `pruneOldest`, `addReaction`, `removeReaction`, `updateUserMessages`, -`deleteUserMessages`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, -`removeQuotedMessageReferences` (plus the internal `_updateMessage` / `_updateQuotedMessageReferences` -/ `_add*`/`_remove*` reaction helpers). - -### Removed from `utils` (re-exported through the package root) - -`addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`, -`deleteUserMessages`, and the `MessageSet` / message-set pagination types. - -### Migrate - -| Before (v14) | After (v15) | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `channel.state.messages` | `channel.messagePaginator.state.items` (reactive) / `channel.messagePaginator.items` | -| `channel.state.latestMessages` | `channel.messagePaginator.headItems` / `.lastMessage` | -| `channel.state.messagePagination` | `channel.messagePaginator.state` (`hasMoreHead` / `hasMoreTail` / `cursor`) | -| `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` (resolve the `Thread` via `client.threads`) | -| `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | -| `channel.state.addMessageSorted(m)` / `addMessagesSorted(ms)` | `channel.messagePaginator.ingestItem(m)` | -| `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` | -| `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` | - -Reactive reads use `useStateStore(channel.messagePaginator.state, …)` (or the paginator's `state` -store directly). Pin/unpin, reactions, user updates, and deletions are applied to the paginators by -the SDK's own event handlers — application code should not call the removed mutators. - -### Why - -Messages were previously stored twice — a flat `ChannelState` list plus message-set/pagination -bookkeeping — which had to be kept in sync with the paginators and could drift. Consolidating on the -paginators removes the dual-write, gives one API/behavior across the main list, threads, and pinned -messages (dedup-by-id, interval merge, `getItem`/`removeItem`, head-window semantics), and lets -`last_message_at` be derived rather than separately maintained. - -## `ChannelState.last_message_at` removed — use `channel.messagePaginator.lastMessageAt` - -**Area:** `ChannelState` · **Status:** implemented - -`channel.state.last_message_at` was **removed entirely** (it was briefly a read-only getter earlier -in v15; that getter is gone too). The channel's latest-message timestamp is now owned by the message -paginator as a whole-collection aggregate. - -- **Before:** `channel.state.last_message_at` (writable, then a derived getter). Internally - maintained by the now-removed `Channel._trackLatestMessage`. -- **After:** `channel.messagePaginator.lastMessageAt` — a `Date | null` **derived** getter over the - paginator's `aggregateState` store (`MessagePaginatorAggregateState = { lastMessage, -seededLastMessageAt }`). It returns `max(lastMessage?.created_at, seededLastMessageAt)`: - `lastMessage` is the newest loaded/received message (advanced on ingest), `seededLastMessageAt` is - the server floor **seeded from `ChannelResponse.last_message_at`** (for channels whose newest - message isn't loaded). Deriving the sort key from the two independent facts means it can never drift - from the display message. Subscribe to `channel.messagePaginator.aggregateState` for reactivity. -- **Migrate:** replace `channel.state.last_message_at` reads with - `channel.messagePaginator.lastMessageAt`. It is not writable; the value is derived from ingested - messages and the server seed. -- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is an - aggregate over them (the dual of pagination). Deriving it through a `ChannelState` getter that - reached into the paginator's message index risked stale/mixed-basis sorting (a seeded-but-stale - paginator preferred over a fresher server value); a single seeded-then-advanced value on the - paginator removes that hazard. -- **Tracking relocated:** `MessageIntervalPaginator`'s `state.latestMessageId` and the `latestMessage` - getter (id resolved from the pagination `state`) were replaced. The tracked latest now lives on - `MessagePaginator.aggregateState.lastMessage`, advanced on every ingest. - `MessagePaginator.lastMessage` remains as a convenience getter but now reads `aggregateState`. - This matters for reactivity: pagination `state` only emits when the **active** interval is impacted, - so a WS message landing in the (non-active) head interval would not notify a `state`-derived - latest; `aggregateState` is written directly on each advance and emits regardless — subscribe to it - (e.g. for a channel/thread list item's latest-message display). `aggregateState.lastMessage` is a - LIVE reference: refreshed in place on edit/soft-delete/reaction of the current latest and recomputed - on hard-remove, and it honors `skip_last_msg_update_for_system_msgs` (system messages neither - reorder a channel nor become its displayed latest). A consumer that previously showed the unfiltered - newest message (`headmostItem`) as the channel-list preview will now skip system messages under that - config — a deliberate behavior change so the preview and the channel's sort position agree. - -## Newest-loaded window is exposed as computed getters `headItems` / `headmostItem` - -**Area:** `BasePaginator` · **Status:** implemented - -The newest-loaded window is exposed as **computed getters** on the paginator — `paginator.headItems` -(the window; `[]` before the first load) and `paginator.headmostItem` (its single newest item), -derived from the intervals on read. - -- **Migrate:** the v14 `channel.state.latestMessages` reactive array becomes - `channel.messagePaginator.headItems`. Because these are getters, **not** `PaginatorState` fields, - they are not subscribable via `useStateStore(paginator.state, (s) => s.headItems)` — read them - directly, or subscribe to `channel.messagePaginator.aggregateState` for the reactive last-message - signal. -- **Why:** the value is derivable from the intervals on demand, so it does not need to be materialized - (and re-emitted) into pagination state. - -## `Channel.lastMessage()` removed — use `channel.messagePaginator.headmostItem` - -**Area:** `Channel` · **Status:** implemented - -`channel.lastMessage()` was removed. It returned the newest loaded message (the head edge of the -message paginator's latest window); read `channel.messagePaginator.headmostItem` directly instead. - -- **Migrate:** `channel.lastMessage()` → `channel.messagePaginator.headmostItem`. -- **Note:** `headmostItem` is the newest _loaded_ message, **unfiltered** (includes system messages) — - distinct from `channel.messagePaginator.lastMessage`, the filtered chronological latest (honors - `skip_last_msg_update_for_system_msgs`) that backs `lastMessageAt`. Use `headmostItem` for "the newest - message on screen"; use `lastMessage` / `lastMessageAt` for channel-list ordering. -- **Why:** it was a thin wrapper over `headmostItem`, and sharing the name `lastMessage` with the - differently-filtered paginator getter was misleading. - -## `ChannelState.isUpToDate` / `setIsUpToDate` removed - -**Area:** `ChannelState` · **Status:** implemented - -The `isUpToDate` flag and its `setIsUpToDate(boolean)` setter were removed. They gated whether an -incoming `message.new` was appended to the visible message list when the user had scrolled to older -history. - -- **Before:** UI SDKs set `channel.state.setIsUpToDate(false)` when jumping to an older window so live - messages were not forced onto the visible list, and read `channel.state.isUpToDate` to decide - whether to show a "jump to latest" affordance. -- **After:** neither exists. Message routing is handled structurally by the message paginator: a live - message newer than the loaded head lands in the head (or logical-head) interval, which is not the - active window when the viewer has jumped away, so the visible window is preserved with no flag. -- **Migrate:** - - "Am I viewing the newest window?" → `channel.messagePaginator.isActiveIntervalAtHead` (getter). - - "Are there newer messages not yet loaded?" → `channel.messagePaginator.hasMoreHead` (reactive - via `channel.messagePaginator.state`). - - "Jump to the latest" → `channel.messagePaginator.jumpToTheLatestMessage()`. -- **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 is a channel-level fact, - independent of what the UI is currently viewing. -- **Why:** the flag duplicated state the paginator already models, and was only ever set `false` on - disconnect on this branch — its message-list responsibility had already moved to the paginator. - ---- diff --git a/docs/fileUpload.md b/docs/fileUpload.md index bf964ad399..02d19fcfbe 100644 --- a/docs/fileUpload.md +++ b/docs/fileUpload.md @@ -143,7 +143,7 @@ axios' node adapter. A `Content-Type` in `client.axiosRequestConfig.headers` (in override it — headers the SDK sets for a request always win over that global config. `client.createAbortControllerForNextRequest()` is removed — it armed one controller that whichever -request went out next would consume. See `v9-to-v10-migration-guide-methods.md` for the +request went out next would consume. See `ai-docs/v10-breaking-changes.md#client-uploads` for the before/after. ### Upload progress (`onUploadProgress`) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index 55ee469b16..b5a9b942ea 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -1061,7 +1061,7 @@ space was generalized: | `MessageComposerSetupState` | `InstanceSetupState<'messageComposer'>` | | `MessageComposerTearDownFunction` | `InstanceSetupTearDownFunction` | -Also listed in `v9-to-v10-migration-guide-type-renames.md`, so that table stays a complete record of +Also listed in `ai-docs/v10-breaking-changes.md#type-rename-table`, so that table stays a complete record of removed type names. **No supported import path breaks.** They lived in `src/configuration/types.ts` and were never exported diff --git a/docs/logging.md b/docs/logging.md index c08ba0cf69..284bcfd862 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -28,4 +28,4 @@ method that emitted them. For the full surface — per-scope configuration, the `Sink` / `LogLevel` / `ConfigureLoggersOptions` types, and the mapping from every v9 logging API to its v10 replacement — see -[`v9-to-v10-migration-guide-logging.md`](../v9-to-v10-migration-guide-logging.md). +[`ai-docs/v10-breaking-changes.md`](../ai-docs/v10-breaking-changes.md#logging). diff --git a/docs/webhooks.md b/docs/webhooks.md index 8727862088..1051a04191 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -38,7 +38,7 @@ available on the same client with their v9 shapes, and `InvalidWebhookError` is re-exported for `instanceof` checks. Migration details, including the full v9 → node-sdk mapping table: -[`v9-to-v10-migration-guide-server-side.md`](../v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs). +[`ai-docs/v10-breaking-changes.md`](../ai-docs/v10-breaking-changes.md#webhook-helpers-removed). Product documentation for configuring webhooks, SQS, and SNS: . diff --git a/react-thread-paginator-migration.md b/react-thread-paginator-migration.md deleted file mode 100644 index 5fd57c33d2..0000000000 --- a/react-thread-paginator-migration.md +++ /dev/null @@ -1,166 +0,0 @@ -# stream-chat-react: moving to the new thread manager and `ThreadPaginator` - -A guideline for `release-v15`. The LLC side is `feat/thread-paginator`, and the full API delta is in -`v9-to-v10-migration-guide-other.md`. SCRN is already migrated and can serve as a reference (`Channel.tsx`, -`Thread.tsx`, `ThreadList.tsx`, `ThreadListUnreadBanner.tsx`). Everything below was checked against React's -`release-v15` as it is now. - -## The new model, in short - -- `client.threads` keeps **one store of live threads**: every listed thread plus every opened one, one instance per - id. `client.threads.get(id)` resolves any of them. -- The thread list is **`client.threads.paginator`** (a `ThreadPaginator`), with a normal paginator state: `items`, - `isLoading`, `hasMoreTail`, `lastQueryError`. Server order, no client-side sort. -- **An opened thread stays live without being in the list.** It's registered by `client.threads.ensure(...)`, or by - its first `thread.activate()`, and stays subscribed for the session. Nothing has to "adopt" it into the list any - more. -- **`client.threads.ensure({ channel, parentMessage })`** is how to get a thread to open. It returns the stored - instance, or builds one and registers it straight away. A thread it builds starts `isStateStale`, so it loads its - data once when first opened. -- **`client.getThreadAndHydrate()` still returns a new instance** built from the response (with up to 3 replies). It - doesn't look in the store, so callers have to prefer an existing instance (see `viewReplyInThread` below). -- `client.threads.activate()` / `deactivate()` still exist. Activating reloads the list when it needs it: first load, - unseen threads, or stale order. -- **Threads don't listen to the client any more.** `client.threads.registerSubscriptions()` routes each event by id to - the stored thread it belongs to, as `PollManager` does for polls. A `Thread` that isn't in the store gets no events, - and `thread.registerSubscriptions()` on its own covers only its state subscriptions. -- **A thread goes stale when its channel's `watchStatus` goes to `NotWatching`** (e.g. `channel.stopWatching()`), not - on `user.watching.stop`. A dropped connection is handled by connection recovery. Thread queries sent with - `watch: true` (`getThreadAndHydrate`, `queryThreadsAndHydrate`) now set the channel to `Watching`. - -## API mapping - -| `release-v15` today | Now | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `client.threads.state.threads` | `client.threads.paginator.state.items ?? []` | -| `state.ready` | `paginator.state.items !== undefined` | -| `state.pagination.isLoading` (first load) | `paginator.isLoading && !items?.length` | -| `state.pagination.isLoadingNext` | `paginator.isLoading && items.length > 0` | -| `state.pagination.isLoading` during a reload of a loaded list | no public flag: the list stays as it is (track the `reload()` promise if you need it) | -| `state.pagination.nextCursor` | `paginator.state.hasMoreTail` | -| `client.threads.loadNextPage()` | `client.threads.paginator.toTail()` | -| `client.threads.queryThreads()` | `client.queryThreadsAndHydrate()` for one-off queries | -| `client.threads.threadsById[id]` | `client.threads.get(id)`; `paginator.getItem(id)` for "is it in the list" | -| `threadsById[id] ?? new Thread(...)` | `client.threads.ensure({ channel, parentMessage })` | -| `unseenThreadIds`, `isThreadOrderStale`, `unreadThreadCount`, `active` | unchanged, still on `client.threads.state` | - -## Where React needs changes - -**`ThreadList.tsx`: `useThreadList` and `ThreadList`** - -- Drop the manual reset on mount (`pagination`, `ready`, `threads`, `unseenThreadIds`). It existed so the first request - used the default page size. `reload()` now sizes itself as `min(loaded + unseen, pageSize)`, so it's no longer - needed. -- Keep the activate / deactivate on visibility and unmount. Activation reloads when the list needs it. If React wants - a fresh list on every mount, as it does today, keep `client.threads.reload({ force: true })`, but without the - reset. -- Read from `client.threads.paginator.state` (`items`, `isLoading`) instead of `client.threads.state`. -- `atBottomStateChange` should call `client.threads.paginator.toTail()`. It does nothing until the first load - succeeds, and nothing once there are no more pages. -- Show the loading placeholder only on the first load (`isLoading && !threads.length`). A reload of a loaded list keeps - the threads until the new ones land, and a failed reload keeps them. - -**`ThreadListLoadingIndicator.tsx`** - -- `pagination.isLoadingNext` becomes `paginator.isLoading && items.length > 0`, read from `paginator.state`. - -**`ThreadListUnseenThreadsBanner.tsx`** - -- Its `isLoading` comes from `pagination.isLoading`, which doesn't exist any more. A reload of a loaded list sets no - public flag, so keep a local pending state around `await client.threads.reload()` for the "Loading..." look and the - `disabled`. -- `unseenThreadIds` stays on `client.threads.state`. It's cleared only when the reload succeeds, so a failed reload - keeps the banner. - -**`useThreadHighlighting.ts`** - -- The `threads` subscription moves to `threadManager.paginator.state` → `items ?? []`. The `unseenThreadIds` half stays - as it is. -- The two now live in different stores, but the hook already remembers ids as they're reported, so the order the - updates land in doesn't matter. - -**`Thread.tsx`** - -- Remove `isThreadManaged` (the `threads.some(...)` selector) and the effect that prepends the open thread into - `client.threads.state.threads`. The list belongs to the paginator now, and an opened thread stays live without it. -- Keep the "reload while `isStateStale`" effect, and make it **the only load trigger**. Also remove the "load an - unmanaged thread with no replies" effect: - - a thread built by `ensure()` starts stale, so the stale effect loads it; - - with both effects in place, they'd both call `thread.reload()` in the same render, a double fetch. SCRN had exactly - this (F3), and fixed it by skipping the load while a reload is in flight. - - listed threads and `getThreadAndHydrate()` threads arrive with replies already, so neither effect fires for them. -- Update the doc comment: `` no longer registers the thread with the manager. `ensure()` or `activate()` does. - -**`createThreadEntityBinding` (`plugins/SlotLayout/ChatViewNavigationContext.tsx`)** - -- Replace `threadsById[id] ?? new Thread({ ... })` with `client.threads.ensure({ channel, parentMessage })`. -- This matters beyond dedupe. `useActiveThread` only activates the active slot's thread, so a thread opened beside - another one (ctrl/⌘-click) is never activated. `ensure()` registers it on creation, so it stays live anyway. - -**`viewReplyInThread` (`Message/hooks/useMessageAlsoSentInChannelNavigation.ts`)** - -- `threadsById[parentId]` becomes `client.threads.get(parentId)`. -- After `await client.getThreadAndHydrate(...)`, prefer an instance that was registered in the meantime: - `client.threads.get(parentId) ?? fetched`. Otherwise a list query landing during the request leaves two instances. - Activating the second one is refused with a warning, and it stays unsubscribed. - -**`useThreadManagerState` (public hook)** - -- It has no internal callers, but it's exported: integrator selectors that read `threads`, `pagination` or `ready` - break. Worth a note in React's migration guide, and possibly a matching hook for `paginator.state`. - -**No change needed** - -- `useChat`: `client.threads.registerSubscriptions()` / `unregisterSubscriptions()`. This is now also what delivers - thread events, so it has to stay mounted for as long as threads are shown. -- Nothing in React calls `thread.registerSubscriptions()` directly or handles `user.watching.*` for threads, so the - routing and `watchStatus` changes need no code changes. -- `useActiveThread`: `thread.activate()` returns the release (there is no `thread.deactivate()`). Releasing only - flips `active`; the thread stays registered. `useCloseThread` doesn't touch the activation. -- `ChatViewThreadsSelectorButton`: `unreadThreadCount` is still on `client.threads.state`. -- Thread reads: React doesn't call `markRead` on thread open, and the LLC auto-reads the active thread. Don't add an - explicit one, because that sends two reads (SCRN's F8). - -## Behaviour to keep in mind - -- A reply to a listed thread sets `isThreadOrderStale`. A reply to an unlisted one, including a thread that's open but - not listed, adds to `unseenThreadIds`. Same as `master`. -- Reopening a thread that's still registered makes no request, unless it went stale (for example after a reconnect). -- A thread whose channel is deleted, or which the user is removed from, is released automatically. So is everything on - `disconnectUser()`. The UI needs no handler. -- `paginator.items === undefined` means "not loaded yet". `isInitialized` is not the same thing: a failed first query - also sets it. - -## Don't - -- Don't write threads into the manager's state or the list yourself. -- Don't construct `Thread` to open one; use `ensure()` (and prefer `get()` after any `await`). A thread outside the - store gets no events. -- Don't look threads up in `paginator.items`, which covers the list only; use `client.threads.get(id)`. -- Don't reset the manager's state on mount. `resetState()` is teardown, and runs on `disconnectUser()`. - -## Tests to update - -These four seed or read the old shape: - -- `Threads/ThreadList/__tests__/ThreadList.test.tsx` -- `Threads/ThreadList/__tests__/useThreadHighlighting.test.ts` -- `Thread/__tests__/Thread.test.tsx` -- `Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx` - -How to update them: - -- Seed the list through the paginator (`paginator.state.partialNext({ items })`, or a mocked `queryThreads` response), - not `client.threads.state.threads`. -- An opened thread should be found with `client.threads.get(id)`, while `paginator.getItem(id)` stays `undefined`. -- A test that dispatches client events at a thread needs `client.threads.registerSubscriptions()` and the thread in - the store (`ensure()`, `activate()` or a list query). No current React test relies on the old per-thread listeners. - -Cases worth adding: - -- a failed reload keeps the list and the banner; -- the loading placeholder shows on the first load only; -- `ensure()` returns the listed instance; -- a thread built by `ensure()` loads exactly once when opened; -- reopening doesn't fetch; -- `viewReplyInThread` reuses an instance registered while `getThreadAndHydrate` was in flight. diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index b60df76a3e..59536f72d4 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -59,6 +59,11 @@ export type ChannelQueryShape = QueryChannelsRequest & { export type ChannelPaginatorState = PaginatorState; +/** + * Request options sent with every page query (`state`, `watch`, `presence`, `message_limit`, …). + * `limit` and `offset` are owned by the paginator: set the page size with + * `paginatorOptions.pageSize` (the v9 `ChannelManager` `options.limit`). + */ export type ChannelPaginatorRequestOptions = Partial< Omit >; diff --git a/test/types/query-channels-and-hydrate.ts b/test/types/query-channels-and-hydrate.ts new file mode 100644 index 0000000000..e90680ed4f --- /dev/null +++ b/test/types/query-channels-and-hydrate.ts @@ -0,0 +1,41 @@ +/** + * Compile-time contract for the `queryChannelsAndHydrate` overloads. Type-checked, never executed. + * + * The full-response overload must only match when `withResponse: true` is passed explicitly: the + * runtime returns `Channel[]` otherwise, so a call without `stateOptions` must not type as the + * response (it did in 10.0.0-rc.22, where that overload's `stateOptions` was optional). + */ +import type { Channel, QueryChannelsResponseWithChannels, StreamChat } from 'stream-chat'; + +declare const client: StreamChat; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +const assertType = (value: T) => value; + +type NoArgs = Awaited>; +const queryNoArgs = () => client.queryChannelsAndHydrate(); +assertType>(true); + +type RequestOnly = Awaited>; +const queryRequestOnly = () => + client.queryChannelsAndHydrate({ filter_conditions: { type: 'messaging' } }); +assertType>(true); + +type WithoutResponse = Awaited>; +const queryWithoutResponse = () => + client.queryChannelsAndHydrate({ filter_conditions: {} }, { withResponse: false }); +assertType>(true); + +type WithResponse = Awaited>; +const queryWithResponse = () => + client.queryChannelsAndHydrate({ filter_conditions: {} }, { withResponse: true }); +assertType>(true); + +type WithResponseNoRequest = Awaited>; +const queryWithResponseNoRequest = () => + client.queryChannelsAndHydrate(undefined, { withResponse: true }); +assertType>(true); + +// @ts-expect-error -- the full response is only returned when `withResponse: true` is passed +export const notTheResponse: QueryChannelsResponseWithChannels = await queryRequestOnly(); diff --git a/v9-to-v10-migration-guide-client-construction.md b/v9-to-v10-migration-guide-client-construction.md deleted file mode 100644 index e77ed4dd1a..0000000000 --- a/v9-to-v10-migration-guide-client-construction.md +++ /dev/null @@ -1,311 +0,0 @@ -# v9 → v10 Migration Guide — Client Construction - -> Scope: this guide covers **only** changes to `StreamChat` construction (`new StreamChat(...)` and `StreamChat.getInstance(...)`) and what the second argument is. Other v10 changes will be documented separately. - -## TL;DR - -- `secret` is gone. The constructor and `getInstance` no longer accept it. **v10 does not support server-side use.** -- The constructor and `getInstance` are now a single signature: `(key, config?)`. The `(key, secret, options?)` overload has been removed. -- **There is no options object any more.** `StreamChatOptions` is removed, and the second argument is the declarative configuration tree itself — `DeepPartial`, the same thing `client.config.set()` takes. Each former option became a field of the tree, the argument itself, or was removed: see [`StreamChatOptions` became the configuration tree](#streamchatoptions-became-the-configuration-tree). `client.options` is gone too. -- Axios-level fields (`timeout`, `httpsAgent`, `withCredentials`, headers, etc.) are passed through `client.axiosRequestConfig`. -- The remaining axios defaults (`timeout: 3000`, `withCredentials: false`) are still applied, but in v10 they can be overridden through `axiosRequestConfig`. In v9 they could not be — `axiosRequestConfig` only affected per-request calls. -- **The implicit keep-alive `httpsAgent` in node is gone.** v9 created an `https.Agent({ keepAlive: true, keepAliveMsecs: 3000 })` whenever node was detected; v10 no longer imports `node:https` at all. If you relied on connection reuse, pass your own agent — see [`httpsAgent` is no longer defaulted in node](#httpsagent-is-no-longer-defaulted-in-node). -- `paramsSerializer` cannot be overridden. Any `paramsSerializer` passed in `axiosRequestConfig` is ignored; the client always uses its internal `axiosParamsSerializer`. - -## Server-side users — stop here - -v10 removes all server-side functionality (secret-based auth, server-side JWT signing, etc.). If your integration uses `stream-chat` with a `secret` on a backend, **do not migrate to v10**. Switch to the dedicated server SDK: - -- https://github.com/GetStream/stream-node - -For client-side / React Native / browser apps that previously called `new StreamChat(key)` without a secret, keep reading. - -## Constructor signature - -### Removed: the `secret` parameter and its overload - -```ts -// v9 — all of these worked -new StreamChat(API_KEY); -new StreamChat(API_KEY, 'a-secret'); -new StreamChat(API_KEY, { timeout: 5000 }); -new StreamChat(API_KEY, 'a-secret', { timeout: 5000 }); -new StreamChat(API_KEY, undefined, { timeout: 5000 }); -new StreamChat(API_KEY, ''); // empty string was treated as "no secret" -``` - -```ts -// v10 — only this shape is valid -new StreamChat(API_KEY); -new StreamChat(API_KEY, config); -``` - -Same change applies to `StreamChat.getInstance`: - -```ts -// v9 -StreamChat.getInstance(API_KEY, 'a-secret', { timeout: 5000 }); - -// v10 -StreamChat.getInstance(API_KEY, { client: { axiosRequestConfig: { timeout: 5000 } } }); -``` - -### Removed: `client.secret` - -The `secret` field on the client instance no longer exists. The internal `_isUsingServerAuth()` method has also been removed; any guard that branched on it should be deleted (the branch was always the server-side path). - -## `StreamChatOptions` became the configuration tree - -`StreamChatOptions` is removed, and so is `client.options`. The constructor's and `getInstance`'s second argument is now the declarative configuration tree itself (`DeepPartial`) — the same thing `client.config.set()` takes. Every v9 option went to one of three places: a field of the tree, the argument itself, or nowhere (removed). - -| v9 option (`StreamChatOptions`) | v10 | -| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `config` (added in v10 release candidates) | **The argument itself.** Drop the `config` wrapper. | -| `allowServerSideConnect` | `client.allowServerSideConnect` | -| `axiosRequestConfig` | `client.axiosRequestConfig` | -| Axios fields at the top level (`timeout`, `httpsAgent`, `headers`, …) | `client.axiosRequestConfig.` — see [below](#axios-fields-moved-to-axiosrequestconfig) | -| `baseURL` | `client.baseURL` | -| `browser` | `client.browser` | -| `notifications` (a `NotificationManager` instance) | `client.notificationManager` | -| `persistUserOnConnectionFailure` | `client.persistUserOnConnectionFailure` | -| `recoverStateOnReconnect` | `client.connectionRecovery.enabled` | -| `enableWSFallback` | `client.wsConnection.enableWSFallback` — see [long-poll fallback](./v9-to-v10-migration-guide-other.md#long-poll-fallback-enablewsfallback) | -| `WebSocketImpl` (added in v10 release candidates) | `client.wsConnection.webSocketImpl` | -| `wsUrlParams` | `client.wsConnection.urlParams` | -| `wsConnection` | `client.wsConnection.connection` | -| `isLocalUnreadCountEnabled` | `channel.readEvents.localUnreadCountEnabled` | -| `device`, `disableCache`, `enableInsights`, `warmUp`, `logger` | **Removed** — see [Removed options](#removed-options) | - -```diff -- new StreamChat(API_KEY, { -- allowServerSideConnect: true, -- baseURL: 'https://chat-proxy-dublin.stream-io-api.com', -- timeout: 5000, -- recoverStateOnReconnect: false, -- isLocalUnreadCountEnabled: true, -- }); -+ new StreamChat(API_KEY, { -+ client: { -+ allowServerSideConnect: true, -+ baseURL: 'https://chat-proxy-dublin.stream-io-api.com', -+ axiosRequestConfig: { timeout: 5000 }, -+ connectionRecovery: { enabled: false }, -+ wsConnection: { enableWSFallback: true }, -+ }, -+ channel: { readEvents: { localUnreadCountEnabled: true } }, -+ }); -``` - -How soon each field is applied differs, because some are consumed while the client is built: - -| Field | Applied | -| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `client.baseURL`, `client.axiosRequestConfig`, `client.notificationManager` | at construction (`axiosRequestConfig.headers` / `params` are also read on every request) | -| `client.browser` | live, through the `client.browser` / `client.node` getters | -| `client.persistUserOnConnectionFailure` | when a connect fails | -| `client.allowServerSideConnect` | on every connect | -| `client.wsConnection.*` | when the socket is built, so before `connectUser()` | -| everything else | live | - -Things that behave differently from an option: - -- **Fields applied "at construction" are read once.** Registering one later through `client.config.set()` logs a warning and has no effect on the existing client, the same as every other construction-only path. `client.setBaseURL()` remains the way to change the base URL at runtime. `browser` and `persistUserOnConnectionFailure` are read when needed, so registering them later works. -- **The fields are gone from the client.** `client.persistUserOnConnectionFailure` no longer exists as a field, so assigning it does nothing in v10 — call `client.config.setConfig('client', { persistUserOnConnectionFailure })` instead. `client.browser` and `client.node` are now read-only getters that resolve from the configuration (falling back to the `window` check), so assigning to them throws in strict mode; register `client.browser` instead. -- **The configuration is copied.** The tree copies what you register, so keeping a reference to the `axiosRequestConfig` object you passed and mutating it afterwards no longer reaches the client, and `client.axiosRequestConfig === yourObject` no longer holds. Register a new value with `client.config.setConfig('client', { axiosRequestConfig })` instead — keeping in mind the instance-building fields above are read at construction. Class instances (a `NotificationManager`, an `https.Agent`) are passed through by reference. -- **A supplied `NotificationManager` keeps the configuration it was built with.** `new NotificationManager({ durations: { error: 5000 } })` wins, field by field, over `client.notifications.durations` in the tree, and the tree fills in the rest. This also holds after `client.config.reset()`. -- **`client.config.reset()` does not undo construction-only fields.** `client.baseURL` and the manager the client already holds stay as they were built. - -## Axios fields moved to `axiosRequestConfig` - -In v9, the options type was `AxiosRequestConfig & { ... }`. That meant you could pass axios fields directly at the top level: - -```ts -// v9 -new StreamChat(API_KEY, { - timeout: 5000, - withCredentials: true, - httpsAgent: customAgent, - headers: { 'Cache-Control': 'no-cache' }, -}); -``` - -In v10, axios fields must go through `client.axiosRequestConfig`: - -```ts -// v10 -new StreamChat(API_KEY, { - client: { - axiosRequestConfig: { - timeout: 5000, - withCredentials: true, - httpsAgent: customAgent, - headers: { 'Cache-Control': 'no-cache' }, - }, - }, -}); -``` - -The full mapping for top-level axios fields previously accepted in v9 → `axiosRequestConfig.` in v10. - -### `axiosRequestConfig` now actually configures the axios instance - -In v9, `axiosRequestConfig` was stored on `client.options` (removed in v10) but **not** applied to `axios.create` during construction — it was only spread into per-request calls. As a result, defaults like `timeout: 3000` could not be overridden through it. - -In v10, `axiosRequestConfig` is spread into the `axios.create` call during construction, so it can override the baked-in defaults: - -```ts -const client = new StreamChat(API_KEY, { - client: { axiosRequestConfig: { timeout: 9999, withCredentials: true } }, -}); -client.axiosInstance.defaults.timeout; // 9999 -client.axiosInstance.defaults.withCredentials; // true -``` - -The defaults (`timeout: 3000`, `withCredentials: false`) still apply when `axiosRequestConfig` does not set them. - -### `httpsAgent` location moved - -```ts -// v9 — top-level -new StreamChat(API_KEY, { browser: false, httpsAgent: customAgent }); - -// v10 — under client.axiosRequestConfig -new StreamChat(API_KEY, { - client: { browser: false, axiosRequestConfig: { httpsAgent: customAgent } }, -}); -``` - -### `httpsAgent` is no longer defaulted in node - -In v9, node mode (`browser: false` or auto-detected) auto-created a keep-alive agent when none was supplied: - -```ts -// v9 / v10-rc — removed from src/client.ts -httpsAgent: this.node - ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) - : undefined, -``` - -v10 drops the `node:https` import along with the rest of the server-side surface, so axios falls back to Node's default agent — **a fresh TCP connection and TLS handshake per request**. Browser mode is unaffected (it never had an agent). - -Most client-side integrations do not care. If you run `stream-chat` under node — a bot, a worker, an SSR process, a test suite that makes many sequential requests — and want the old behavior back, supply the agent yourself: - -```ts -import https from 'node:https'; - -new StreamChat(API_KEY, { - client: { - axiosRequestConfig: { - httpsAgent: new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }), - }, - }, -}); -``` - -### `paramsSerializer` is fixed - -Any `paramsSerializer` passed via `axiosRequestConfig` is silently dropped. The client always uses its internal `axiosParamsSerializer`: - -```ts -const client = new StreamChat(API_KEY, { - axiosRequestConfig: { paramsSerializer: () => 'overridden' }, -}); -client.axiosInstance.defaults.paramsSerializer; // === axiosParamsSerializer (NOT the override) -``` - -If you relied on a custom serializer, file an issue — there is no supported way to change this in v10. - -### Removed options - -Five options were dropped in v10. All are silent no-ops if left in place — TypeScript will flag -them, but a plain-JS call site will not error, so remove them explicitly. - -| Option | Replacement | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `device` | Call [`client.createDevice({ id, push_provider, push_provider_name? })`](./v9-to-v10-migration-guide-methods.md#clientsetlocaldevice) after connecting. | -| `disableCache` | None — the client always caches. See [`disableCache`](#disablecache). | -| `enableInsights` | None — the telemetry it enabled was removed. See [`enableInsights`](#enableinsights). | -| `logger` | `chatLoggerSystem.configureLoggers(...)`. See the [logging guide](./v9-to-v10-migration-guide-logging.md). | -| `warmUp` | None — see [`warmUp`](#warmup). | - -```diff - const client = new StreamChat(API_KEY, { -- device: { id: pushToken, push_provider: 'firebase' }, -- disableCache: true, -- enableInsights: true, -- logger: myLogger, -- warmUp: true, - }); -+ await client.createDevice({ id: pushToken, push_provider: 'firebase' }); -``` - -#### `device` - -`device` existed to have the device serialized into the WebSocket connect payload, where the -server registered it as a side effect of connecting. The v2 connect endpoint's auth message has -no `device` field, so the option had no remaining effect. It was undocumented and unused by every -Stream SDK, so most integrations are unaffected. `client.setLocalDevice()`, the setter for this -option, was removed with it. - -#### `disableCache` - -`disableCache` suppressed every client-side cache write — `activeChannels`, the channel config -cache, `ClientState.users`, `PollManager.pollCache` and `ReminderManager`. It existed for -integrations that used `stream-chat` as a stateless server-side REST wrapper. - -**In v9 it did nothing unless you also passed a `secret`.** The guard read -`_cacheEnabled = () => !this._isUsingServerAuth() || !this.options.disableCache`, and -`_isUsingServerAuth()` was `!!this.secret` — so for any client-side integration the option was -already a no-op. v10 removes server-side support entirely, so the only configuration in which it -ever took effect is gone; those integrations belong on -[stream-node](https://github.com/GetStream/stream-node). - -There is no replacement. `client._cacheEnabled()` was removed along with it. - -#### `enableInsights` - -Off by default, this opted into WebSocket-health telemetry POSTed to -`https://chat-insights.getstream.io` — `ws_fatal`, `ws_success_after_failure` and -`http_hi_failed`. v10 removes the telemetry entirely: `src/insights.ts` is deleted, along with -the `InsightMetrics`, `postInsights`, `buildWsFatalInsight` and `buildWsSuccessAfterFailureInsight` -exports and the `client.insightMetrics` property. - -If you imported any of those directly, drop the import — there is no replacement. Note that the -insight payload included the connected user's auth token and user details, so removing it also -removes that egress. - -#### `warmUp` - -Off by default, `warmUp` fired a fire-and-forget `GET /hi` before opening the WebSocket so the -first real API call could reuse an already-established connection. - -Under node its benefit depended on the keep-alive `https.Agent` the v9 constructor created, which -v10 [no longer creates](#httpsagent-is-no-longer-defaulted-in-node) — without keep-alive the -warmed socket closes and the next request handshakes again. In the browser it saved at most one -connection setup on a cold start. The `/hi` request is no longer issued at all. - -If you want the node behavior back, supply a keep-alive agent through -`axiosRequestConfig.httpsAgent` rather than reaching for a warm-up request. - -## Unchanged behavior worth confirming - -These are intentionally listed so agents don't "fix" them during migration: - -- `new StreamChat(key)` still works with no configuration. -- `StreamChat.getInstance(key)` still returns the same cached instance on repeated calls and ignores the `key`/`config` of subsequent calls. -- The former options keep their meaning; only their address changed — see [`StreamChatOptions` became the configuration tree](#streamchatoptions-became-the-configuration-tree). -- `STREAM_LOCAL_TEST_RUN` / `STREAM_LOCAL_TEST_HOST` env-var overrides on `client.baseURL` still work the same way. -- `browser` auto-detection (`typeof window !== 'undefined'`) and the `client.browser: true | false` override still work the same way. -- The subsystem managers constructed on the client (`state`, `notifications`, `uploadManager`, `moderation`, `tokenManager`, `threads`, `polls`, `reminders`, `messageDeliveryReporter`, `messageComposerCache`) are identical in v10. `insightMetrics` is **gone** — see [`enableInsights`](#enableinsights) — and one manager is **new**: `connectionIdManager`, which holds the WebSocket connection id and lets requests that watch or subscribe to presence await one (see [watching a channel now requires a connected user](./v9-to-v10-migration-guide-other.md#watching-a-channel-now-requires-a-connected-user)). - -## Mechanical migration recipe - -1. If the call site passes a secret, **stop** — migrate that code to `stream-node` instead. -2. Remove any `secret` argument and any `undefined`/`''` placeholders in the second slot: - - `new StreamChat(key, undefined, opts)` → `new StreamChat(key, config)` - - `new StreamChat(key, '', opts)` → `new StreamChat(key, config)` - - `StreamChat.getInstance(key, undefined, opts)` → `StreamChat.getInstance(key, config)` -3. For each option key, check whether it's an axios field (`timeout`, `withCredentials`, `httpsAgent`, `headers`, `adapter`, `proxy`, `responseType`, etc. — anything from `AxiosRequestConfig`). If yes, move it under `client.axiosRequestConfig`. Then move every other option to its new place using [the mapping table](#streamchatoptions-became-the-configuration-tree) — most go under `client`, but `recoverStateOnReconnect` and `isLocalUnreadCountEnabled` do not — and unwrap `config`, whose contents become the argument. -4. Remove any reads of `client.secret` and any branches gated on `client._isUsingServerAuth()`. -5. Drop any custom `paramsSerializer` you were passing — it has no effect in v10. -6. If the client runs under node and you depended on HTTP keep-alive, add `client.axiosRequestConfig.httpsAgent` explicitly — v10 no longer creates one for you. diff --git a/v9-to-v10-migration-guide-dates.md b/v9-to-v10-migration-guide-dates.md deleted file mode 100644 index 5f0fea651b..0000000000 --- a/v9-to-v10-migration-guide-dates.md +++ /dev/null @@ -1,537 +0,0 @@ -# v9 → v10 Migration Guide — Dates Are Unix-Nanosecond Numbers - -> Scope: this guide covers the one change that touches **every** response and event type in the -> package — server-sent dates are now the unix-**nanosecond** `number` the API puts on the wire, -> rather than `Date` objects. It also covers the SDK state types that carry those values, the request -> fields that did **not** change, and the ways this breaks without a compile error. -> -> Sibling guides: -> -> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) -> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) -> - `v9-to-v10-migration-guide-methods.md` (per-method signatures) -> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) -> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) -> - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) -> - `v9-to-v10-migration-guide-i18n.md` (notification identity, the `@stream-io/i18n` package) -> - `v9-to-v10-migration-guide-other.md` (everything else) - -## TL;DR - -- **Every server-sent date is a unix-nanosecond `number`.** `created_at`, `updated_at`, `deleted_at`, - `last_read`, `last_active`, `last_message_at`, `remind_at`, `pinned_at`, `end_at`, `archived_at`, - `expires` — on every response type, every `WSEvent` member, and the SDK state stores that mirror - them. Not a `Date`, not an ISO string. In v9 these were typed `string`; in the early v10 RCs they - were decoded to `Date`. Both are gone: there is no decoder layer any more. -- **They are typed `TimestampNS`, a branded `number`.** Reading, comparing, sorting and subtracting - work exactly as with a plain `number`. What changes is creating one: a plain `number` is not - assignable, so optimistic objects and fixtures use `nowNs()`, `msToNs()`, `dateToNs()` or - `asTimestampNS()`. See [`TimestampNS` and the `new Date` guard](#timestampns-and-the-new-date-guard). -- **`new Date(timestamp)` is a compile error.** The package's types augment the global - `DateConstructor`, so the most common mistake — `new Date(message.created_at)` — no longer compiles. -- **Outgoing request date fields are still `Date`.** So a response value can no longer be assigned to - a request field — the one thing that _was_ safe in v9, when both sides were `string`. The compiler - catches this. -- **Three things still break with no compile error**: a date library is out of range too, so - `dayjs(ns)` is an `Invalid Date` (only `new Date` is guarded); a millisecond/nanosecond mix-up - between two numbers produces a plausible wrong answer and no complaint; and `0` is now a legitimate - timestamp, so `if (!created_at)` is wrong. -- **`t('timestamp.X', { timestamp })` is not type-checked** — i18next's interpolation bag is untyped, - so a raw nanosecond number reaches the formatter and renders the literal text `Invalid Date` into - your UI. See - [Rendering timestamps](#rendering-timestamps-the-one-path-the-compiler-does-not-guard). -- **Filter operands changed meaning**: a bare `number` in a filter is now read as nanoseconds, not - milliseconds. -- Convert with the helpers the package now exports: `convertTimestampToDate` (guarded), `nsToDate`, - `nsToMs`, `msToNs`, `nowNs`, `dateToNs`, `nsToRfc3339`, `asTimestampNS`, `NS_PER_MS`. - ---- - -## Why the numbers, and what they are - -The API has always sent timestamps as nanoseconds since the unix epoch. v9 typed them `string`; the -first v10 RCs generated a per-model decoder layer that turned them into `Date` objects on the way in. -The client is now generated with `--opt response_dates_as_number`, which types them as what the wire -actually carries, and `src/gen/model-decoders/` is no longer emitted at all. Frames arrive and are -used as-is. - -A current timestamp looks like `1786219962651957000`. - -### The two failure modes - -**Every `Date`-based path is out of range.** `Date` tops out at ±8.64e15 ms (ECMA-262 `TimeClip`, -about ±273,790 years). A nanosecond timestamp is ~1.79e18, so it does not fit — and a date library -reads a bare number as milliseconds, which lands in exactly the same place rather than somewhere -plausible. - -```ts -new Date(message.created_at); // Invalid Date (now a compile error — see below) -new Date(message.created_at).toISOString(); // RangeError: Invalid time value -dayjs(message.created_at).isValid(); // false -dayjs(message.created_at).format(); // 'Invalid Date' — the literal string -``` - -The `new Date` forms are caught at compile time by the -[guard](#timestampns-and-the-new-date-guard); the date-library forms are not, and the two surface -differently: `.toISOString()` **throws**, usually -mid-render in a component that had no reason to expect it, while dayjs's `.format()` quietly returns -the string `'Invalid Date'` and renders it on screen. So this mistake is loud in a `RangeError` stack -trace and near-silent in a formatted timestamp — do not rely on noticing it either way. - -**A unit mix-up between two `number`s is the genuinely silent one.** Because nanoseconds are out of -`Date`'s range, that mistake at least announces itself. Comparing a wire timestamp against -`Date.now()`, adding a millisecond duration to one, or passing epoch milliseconds where nanoseconds -are expected all produce a plausible-looking number and no complaint at all: - -```ts -// Compiles, runs, and is wrong by a factor of a million. -if (mute.expires > Date.now()) { … } // every expiry looks ~56 million years away -``` - -The rest of this guide is organised around that: the conversions are mechanical, and the places -worth auditing are the ones where two numbers meet. - -### Precision - -Nanosecond epoch values exceed `Number.MAX_SAFE_INTEGER` (~9.01e15), so a `double` cannot hold every -one of them: at this magnitude the representable values are 256 ns apart. `JSON.parse` has already -quantised the value before your code sees it. - -What this means in practice: - -- **Ordering and comparison are unaffected.** Two distinct instants more than 256 ns apart compare - correctly, which is every instant a chat application distinguishes. -- **Exact equality against a value that has round-tripped through JSON is not guaranteed.** Do not - build an equality filter on a nanosecond timestamp you sent and read back. -- **`nsToMs` floors.** `nsToMs(msToNs(ms))` can land one millisecond early, because `msToNs` may - round down by up to 128 ns and `Math.floor` then crosses the millisecond boundary. Milliseconds are - the unit of display and of `setTimeout`, so this is cosmetic — but do not treat the ms↔ns round trip - as an identity. - ---- - -## `TimestampNS` and the `new Date` guard - -Every server-sent date field is typed `TimestampNS` rather than `number`: - -```ts -export type TimestampNS = number & { readonly [timestampNsBrand]: true }; -``` - -It is still a `number` at runtime and for every read. Comparison, sorting, subtraction and passing it -where a `number` is expected all compile as before. Two things change. - -**Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field, so code -that _constructs_ a response-shaped object — an optimistic message, a local reaction, a test fixture, -a row read back from your own database — has to say where the value came from: - -| Source | Use | -| ----------------------------------------------------------------------------------------- | ------------------ | -| The local clock | `nowNs()` | -| Epoch milliseconds | `msToNs(ms)` | -| A `Date` | `dateToNs(date)` | -| A number that is already nanoseconds (a DB row, persisted JSON, a fixture, the epoch `0`) | `asTimestampNS(n)` | - -`asTimestampNS` brands without converting — handing it milliseconds produces a mislabelled value, so -reserve it for values that are known to be nanoseconds. Arithmetic drops the brand -(`nowNs() + msToNs(5000)` is a plain `number`); wrap the result in `asTimestampNS` when it goes back -into a timestamp field. - -**`new Date(timestamp)` does not compile.** The package's published types include a global -augmentation (`gen/models/timestamp-guard`) that adds a `DateConstructor` overload for `TimestampNS`: - -```ts -const d: Date = new Date(message.created_at); -// error TS2740: Type '{ readonly ERROR_use_SDKs_nsToDate_helper_instead: never; }' is missing … -new Date(message.created_at).toISOString(); -// error TS2339: Property 'toISOString' does not exist on type '{ readonly ERROR_use_SDKs_… }' -new Date(Date.now()); // unaffected -nsToDate(message.created_at); // the fix -``` - -The helpers that consume an instant — `nsToDate`, `nsToRfc3339`, `convertTimestampToDate` — take a -`TimestampNS` too, so `nsToDate(Date.now())` is an error rather than a date in 1970. `nsToMs` takes a -plain `number`, because it also converts durations (the difference of two timestamps). - -What the guard does **not** catch: - -- **An unused result.** `console.log(new Date(ts))` compiles, since the result is never used as a - `Date`. The overload is `@deprecated`, so editors strike it through and - `@typescript-eslint/no-deprecated` reports it. -- **Date libraries.** `dayjs(ts)` / `moment(ts)` accept any `number`. -- **A fallback or a derived value.** The overload only matches an argument that is _purely_ - `TimestampNS`, and each of these compiles clean and is an `Invalid Date` at runtime: - - ```ts - new Date(message.pinned_at ?? Date.now()); // `TimestampNS | number` collapses to `number` - new Date(message.pinned_at ?? 0); // `TimestampNS | 0` is no longer purely branded - new Date(Math.max(a.created_at, b.created_at)); // `Math.max` returns a plain `number` - ``` - - Convert first, then fall back: `convertTimestampToDate(message.pinned_at) ?? fallbackDate`, and - `nsToDate(asTimestampNS(Math.max(a.created_at, b.created_at)))`. - -- **Untyped paths.** i18next interpolation (see - [Rendering timestamps](#rendering-timestamps-the-one-path-the-compiler-does-not-guard)), `any`, and - values you have widened to `number` yourself. -- **Unit mix-ups.** `ts > Date.now()` is two numbers compared; see - [Comparison and arithmetic](#comparison-and-arithmetic). - -The augmentation is global: it applies to the whole program once `stream-chat`'s types are loaded, -and there is no per-file opt-out. - ---- - -## The helpers - -All exported from the package root. - -| Helper | Signature | Use for | -| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------- | -| `convertTimestampToDate(ts?)` | `TimestampNS \| null \| undefined → Date \| undefined` | The default. Guarded: `undefined` for an absent or non-finite value. | -| `nsToDate(ns)` | `TimestampNS → Date` | A value known to be present. | -| `nsToMs(ns)` | `number → number` | Epoch milliseconds (or a ns duration in ms), for arithmetic. | -| `msToNs(ms)` | `number → TimestampNS` | Milliseconds back into the wire unit. | -| `nowNs()` | `() → TimestampNS` | The local clock as a wire timestamp, for optimistic writes. | -| `dateToNs(date)` | `Date → TimestampNS` | A `Date` into the wire unit. | -| `nsToRfc3339(ns)` | `TimestampNS → string` | A nanosecond-precision RFC3339 string, when milliseconds are not enough. | -| `asTimestampNS(n)` | `number → TimestampNS` | Brands a value already in nanoseconds (DB rows, fixtures). No conversion. | -| `NS_PER_MS` | `1e6` | The conversion constant, if you need it directly. | - -**Prefer `convertTimestampToDate` at the boundary where a wire number becomes something a date -library or a UI prop consumes.** Many timestamps are optional in practice even where the generated -type marks them required, and the guard is the whole point: an absent or `NaN` value returns -`undefined` instead of producing an `Invalid Date` that throws further along. - -```ts -import { convertTimestampToDate } from 'stream-chat'; - -const createdAt = convertTimestampToDate(message.created_at); -if (!createdAt) return null; // nothing renderable -``` - -Do not cast the `undefined` away, and do not paper over it with `new Date()` — that labels a -months-old message "Today". - -```ts -// WRONG — launders `undefined` into a required `Date`. -convertTimestampToDate(message.created_at) as Date; -// WRONG — invents "now". -convertTimestampToDate(message.created_at) ?? new Date(); -``` - -### Comparison and arithmetic - -Compare and sort the raw numbers. Do not round-trip through `Date`. - -```ts -// v9 / early v10 RC -if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … } -messages.sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); - -// v10 -if (a.created_at < b.created_at) { … } -messages.sort((a, b) => a.created_at - b.created_at); -``` - -For a **duration**, subtract in nanoseconds and convert **once**. Durations, intervals and delays stay -in milliseconds throughout the SDK, because that is what `setTimeout` and every "time left" value -speak. Converting each operand separately is how a rounding error becomes a visible one. - -```ts -// Time since a message, in seconds. -const secondsAgo = nsToMs(nowNs() - message.created_at) / 1000; - -// Delay until a live location expires. -setTimeout(stop, Math.max(0, nsToMs(location.end_at - nowNs()))); -``` - -The mirror-image mistake is comparing a wire timestamp against `Date.now()` directly. Both are -numbers, so nothing warns, and every wire timestamp looks about 56 million years in the future. - -```ts -// WRONG -if (mute.expires > Date.now()) { … } -// RIGHT -if (mute.expires > nowNs()) { … } -``` - ---- - -## `0` is a valid timestamp - -`0` is the unix epoch, and `!0` is `true`. Every truthiness check on a date field is now a latent -bug — where it used to be harmless, because a `Date` object is always truthy and an ISO string is -never empty. - -```ts -// WRONG — an epoch timestamp reads as "no timestamp". -if (!message.pinned_at) return; -const at = reminder.remind_at ? new Date(reminder.remind_at) : null; -const last = paginator.lastMessageAt || nowNs(); - -// RIGHT -if (message.pinned_at == null) return; -const at = reminder.remind_at != null ? nsToDate(reminder.remind_at) : null; -const last = paginator.lastMessageAt ?? nowNs(); -``` - -**This is not hypothetical for read state.** `0` is the sentinel this SDK actively writes for "never -read" — `channel.state.read[userId].last_read` is set to `0` when a channel gains its first unread -message for a user with no prior read state, and the offline-DB layer persists `0` for the same case. -So a truthiness check on `last_read` conflates "this user has never read the channel" — precisely -when an unread indicator matters most — with "there is no read state". Use `!= null`. - -The same applies to `channel.countUnread(lastRead?)`: it now distinguishes `null`/`undefined` (fall -back to the stored unread count) from `0` (count everything after the epoch). Passing `0` where you -previously passed `new Date(0)` keeps the old meaning. - ---- - -## Request fields are still `Date` - -Outgoing date fields are unchanged: `JSON.stringify` emits RFC3339 for a `Date`, and that is the -format the request spec declares. The consequence is that **a response value can no longer be handed -to a request field** — in v9 both sides were `string`, so this was safe and common. - -| Request field | Type | -| ------------------------------------------------------------------------------------------------------------- | ------ | -| `MessagePaginationParams.created_at_around` / `_before` / `_before_or_equal` / `_after` / `_after_or_equal` | `Date` | -| `MessageRequest.pinned_at`, `MessageRequest.pin_expires` | `Date` | -| `SharedLocation.end_at`, `UpdateLiveLocationRequest.end_at` | `Date` | -| `CreateReminderRequest.remind_at`, `UpdateReminderRequest.remind_at` | `Date` | -| `MarkUnreadRequest.message_timestamp` | `Date` | -| `SyncRequest.last_sync_at`, `TruncateChannelRequest.truncated_at`, `UpdateChannelRequest.hide_history_before` | `Date` | -| `ReactionRequest.created_at` / `updated_at`, `PushPreferenceInput.disabled_until` | `Date` | - -The compiler catches the mismatch. Convert with `nsToDate` on the way out: - -```ts -// v9 — both sides were strings, so this worked. -await channel.query({ messages: { created_at_around: message.created_at } }); - -// v10 — `created_at` is a number, `created_at_around` is a `Date`. -await channel.query({ messages: { created_at_around: nsToDate(message.created_at) } }); -``` - -`nsToDate` is millisecond-precision, which is all a `Date` can hold. If you need the nanosecond -remainder preserved on an outgoing field, `nsToRfc3339` produces a nine-fractional-digit RFC3339 -string — but note it does not satisfy a `Date`-typed field without a cast, and casting a string into a -`Date` annotation breaks any caller that inspects the payload rather than just serializing it. - -### One field the spec still types as a string - -`MessageDeliveredEvent.last_delivered_at` is declared as a bare `type: string` with no -`format: date-time`, so it arrives as RFC3339 while `created_at` on the very same event arrives as a -number. The SDK normalises it internally (`src/channel.ts`); if you read the field off the event -yourself, parse it rather than treating it as a wire number. This is an upstream spec bug and the -field is expected to become a number. - -### `pinMessage`'s `number` overload rejects a server timestamp - -```ts -client.pinMessage(messageOrId, timeoutOrExpirationDate?, pinnedAt?, requestOptions?); -``` - -For both date arguments a `number` means **relative seconds**, not a timestamp — unchanged from v9. -`message.pinned_at` is now also a number, so both parameters exclude `TimestampNS`: handing a -server timestamp back is a compile error rather than an expiry ~1.79e18 seconds away. - -```ts -// Compile error — would read the timestamp as "1.79e18 seconds from now". -client.pinMessage(id, null, message.pinned_at); -// RIGHT -client.pinMessage(id, null, nsToDate(message.pinned_at)); -``` - -A value you have widened to plain `number` yourself still type-checks as seconds, so keep server -timestamps typed as `TimestampNS` up to this call. - -### Filter operands: a server query needs a `Date`, client-side matching reads a number as nanoseconds - -These two paths behave differently, and only one of them fails loudly. - -**A server-side filter must carry a `Date` (or an RFC3339 string).** The API type-checks the operand -and rejects a number outright — verified against the live API: - -```ts -await client.queryReminders({ filter: { remind_at: { $lte: new Date() } } }); -// -> works - -await client.queryReminders({ filter: { remind_at: { $lte: Date.now() * 1e6 } } }); -// -> QueryReminders failed with error: "field \"remind_at\" expects type date" -``` - -So a wire timestamp read off a response cannot be fed straight back into a filter. Convert it: -`nsToDate(message.created_at)`. - -**Client-side filtering and sorting is the silent one.** The paginators compile filters and -comparators that run against loaded items, and there a bare `number` operand is taken to **already -be** the wire unit. Epoch milliseconds — which worked in v9 — resolve to 1970 with no error: - -```ts -// Silently wrong for client-side matching: epoch ms read as nanoseconds. -{ - created_at: { - $gt: 1700000000000; - } -} -// Right: a Date or an ISO string is converted for you; a number must already be nanoseconds. -{ - created_at: { - $gt: new Date('2023-11-14T12:39:29Z'); - } -} -{ - created_at: { - $gt: msToNs(1700000000000); - } -} -``` - ---- - -## Rendering timestamps: the one path the compiler does not guard - -`@stream-io/i18n` exposes two ways to render a timestamp, and only one of them is type-safe. - -**`getDateString({ messageCreatedAt })` is typed `string | Date`.** A raw number is a compile error, -so this path guides you to the conversion: - -```ts -import { getDateString } from '@stream-io/i18n'; -import { convertTimestampToDate } from 'stream-chat'; - -getDateString({ - messageCreatedAt: convertTimestampToDate(message.created_at), - t, - tDateTimeParser, - timestampTranslationKey: 'timestamp.MessageTimestamp', -}); -``` - -**`t('timestamp.X', { timestamp })` is not.** The `timestamp.*` translation keys carry a -`timestampFormatter` expression, and the value reaches it through i18next's interpolation options — -which are untyped. `timestampFormatter` itself declares `FormatterFactory`, but nothing -enforces that at the call site, so this compiles cleanly: - -```ts -// COMPILES. Renders the literal text 'Invalid Date' into the UI. -t('timestamp.LiveLocation', { timestamp: location.end_at }); -``` - -There is no error, no warning, and no `Invalid Date` to notice in review — just a wrong year in the -UI. **Convert at every `t('timestamp.*', …)` and `t('duration.*', …)` call site**, and treat these as -the places to audit first when migrating: - -```ts -t('timestamp.LiveLocation', { timestamp: convertTimestampToDate(location.end_at) }); -``` - -The formatter tolerates `undefined` — it renders an empty string rather than the literal text -`"undefined"` — so the guarded helper can be passed straight through. - -Two related notes: - -- **`duration.*` keys take a duration, not a timestamp.** `durationFormatter` goes through the date - library's `.duration()`, so it expects a length of time in **milliseconds**. Handing it a timestamp - renders something like "57 years ago". If you are deriving a duration from two wire timestamps, - subtract first and convert once (see [Comparison and arithmetic](#comparison-and-arithmetic)). -- **Presentational props still take `Date`.** The conversion boundary is where core data enters your - component tree, not the leaf that formats it. Components whose job is to render a date keep their - `Date` props. - ---- - -## Changed public types and members in this package - -Type changes where the member name is unchanged and only the type moved from `Date` to `TimestampNS` -(read as `number`; see [`TimestampNS`](#timestampns-and-the-new-date-guard)): - -| Type / member | Was | Now | -| ----------------------------------------------------------- | ------------------- | -------------------------- | -| `ChannelMuteStatus.createdAt` / `.expiresAt` | `Date \| null` | `TimestampNS \| null` | -| `ChannelState['read'][userId].last_read` | `Date` | `TimestampNS` | -| `ChannelState['read'][userId].last_delivered_at` | `Date \| undefined` | `TimestampNS \| undefined` | -| `ThreadState.createdAt` | `Date` | `TimestampNS` | -| `ThreadState.deletedAt` / `.updatedAt` | `Date \| null` | `TimestampNS \| null` | -| `ThreadUserReadState.lastReadAt` | `Date` | `TimestampNS` | -| `ReminderState.created_at` / `.updated_at` | `Date` | `TimestampNS` | -| `ReminderState.remind_at` | `Date \| null` | `TimestampNS \| null` | -| `UnreadSnapshotState.lastReadAt` | `Date \| null` | `TimestampNS \| null` | -| `MessagePaginatorAggregateState.seededLastMessageAt` | `Date \| null` | `TimestampNS \| null` | -| `MessagePaginator.lastMessageAt` (getter) | `Date \| null` | `TimestampNS \| null` | -| `LocalEvent` `created_at`, and `received_at` on every event | `Date` | `TimestampNS` | -| `ConnectedEvent.created_at` / `.received_at` | `Date` | `TimestampNS` | -| `DBDeleteMessagesForChannelType.truncated_at` | `Date \| undefined` | `TimestampNS \| undefined` | - -Signature changes: - -| Member | Was | Now | -| ---------------------------------------------------------------- | -------------------------------------- | ------------------------------------------ | -| `channel.countUnread(lastRead?)` | `Date \| null` | `TimestampNS \| null` | -| `channel.lastRead()` | `Date \| null \| undefined` | `TimestampNS \| null \| undefined` | -| `channel.muteStatus()` | `{ createdAt: Date \| null; … }` | `{ createdAt: TimestampNS \| null; … }` | -| `MessagePaginator.seedLastMessageAt(value)` | `string \| Date \| null \| undefined` | `TimestampNS \| null \| undefined` | -| `MessagePaginator.truncate({ truncatedAt })` | `Date` | `TimestampNS` | -| `MessagePaginator.applyMessageDeletionForUser({ deletedAt })` | `Date` | `TimestampNS` | -| `MessagePaginator.findItemByTimestamp(timestamp, exactTsMatch?)` | epoch **ms** | `TimestampNS` | -| `MessageReceiptsTracker.onMessageDelivered({ deliveredAt })` | `Date` | `TimestampNS` | -| `MessageReceiptsTracker.onMessageRead({ readAt })` | `Date` | `TimestampNS` | -| `MessageReceiptsTracker.reconcileUserRead({ lastReadAt })` | `Date \| undefined` | `TimestampNS \| undefined` | -| `timeLeftMs(remindAt)` | epoch **ms** | `TimestampNS` | -| `client.pinMessage(id, timeoutOrExpirationDate?, pinnedAt?)` | `number` (seconds) accepted any number | `number` (seconds), `TimestampNS` rejected | -| `LocationComposer.validLocation` (getter) | `SharedLocation \| null` | `StaticLocationPreview \| null` | - -Renames — these do **not** fail as a type error if you were reading them off a value typed `any`: - -| Was | Now | -| ------------------------------------------------ | ------------------------------------------------ | -| `CooldownTimerState.ownLatestMessageDate` | `CooldownTimerState.ownLatestMessageTimestamp` | -| `cooldownTimer.ownLatestMessageDate` | `cooldownTimer.ownLatestMessageTimestamp` | -| `cooldownTimer.setOwnLatestMessageDate(date)` | `cooldownTimer.setOwnLatestMessageTimestamp(ns)` | -| `MsgRef.timestampMs` | `MsgRef.timestamp` | -| `OwnMessageReceiptsTrackerMessageLocator(msgMs)` | `OwnMessageReceiptsTrackerMessageLocator(ns)` | - -**Unit changes** — now typed `TimestampNS`, so assigning a millisecond value no longer compiles: - -| Member | Was | Now | -| ------------------------------------------------- | --------------------- | -------------------------- | -| `PollState.lastActivityAt` | `Date` | `TimestampNS` | -| `LastComposerChange.stateUpdate` / `.draftUpdate` | epoch **ms** `number` | `TimestampNS` | -| `CooldownTimerState.ownLatestMessageTimestamp` | — | `TimestampNS \| undefined` | - -Removed: - -- `isDate` is gone from `src/utils`. It was never exported from the package root. The `isDate` in - `@stream-io/i18n` is unrelated and still there — but note it correctly reports that a wire number - is not a `Date`, so `timestamp && isDate(timestamp) ? … : undefined` now yields `undefined` for - every timestamp. Convert instead of guarding. -- `RESERVED_UPDATED_MESSAGE_FIELDS` no longer lists `pinned_at` and now lists - `message_text_updated_at`. `pinned_at`, `pin_expires` and `shared_location` **are** `MessageRequest` - fields, so stripping them from an update payload clears them server-side; they are converted and - sent instead. - ---- - -## Persisted state - -If you persist SDK values yourself — an offline database, a cache, a hydrated store — timestamps -written by a previous version are ISO strings or serialized `Date`s, and nothing in v10 coerces them -any more. In particular **`formatMessage` is no longer a normalisation boundary**: it used to turn -whatever it received into `Date` objects, and now passes a value straight through. - -Version your storage and discard or migrate what was written before the upgrade. `stream-chat-react-native`'s -offline database does this by bumping its schema version, which drops and recreates every table (note -that this also discards queued offline pending tasks). - -Storage-level notes if you maintain your own: - -- Store timestamps as a 64-bit **integer** column, not text. An integral `double` below 2^63 - round-trips through SQLite `INTEGER` exactly, and lexicographic ISO sorting is no longer needed. -- `ORDER BY` and range comparisons become plain numeric ones — drop any `datetime(…)` / - `strftime(…)` wrapping, which silently returns `NULL` for an integer column. -- Rows read back hold plain numbers. Brand them with `asTimestampNS(row.created_at)` when rebuilding - a response-shaped object — do not route them through `msToNs`, which would multiply them again. -- Distinguish absent from epoch. Write `NULL` for an absent timestamp rather than `0` or `''`, or you - reintroduce exactly the ambiguity the `!= null` discipline above exists to remove. diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md deleted file mode 100644 index 253dfd397a..0000000000 --- a/v9-to-v10-migration-guide-i18n.md +++ /dev/null @@ -1,375 +0,0 @@ -# v9 → v10 Migration Guide — Notifications, Poll Validation & i18n - -> Scope: this guide covers **notification identity** (`Notification.type` and `Notification.message`), the **shape of poll-composer field errors**, and the move of the translation runtime into the standalone **`@stream-io/i18n`** package. It is relevant to you even if you never translate anything: the notification and poll-error changes affect any app that renders either. -> -> Sibling guides: -> -> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) -> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) -> - `v9-to-v10-migration-guide-methods.md` (per-method signatures) -> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) -> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) -> - `v9-to-v10-migration-guide-type-renames.md` (type aliases → generated names) -> - `v9-to-v10-migration-guide-dates.md` (server-sent dates as unix-nanosecond numbers — **read this for the `t('timestamp.X', { timestamp })` call sites**) -> - `v9-to-v10-migration-guide-other.md` (everything else) - -## TL;DR - -- **Two notification identifiers were renamed.** `api:messages:query:failed` → `api:message:jump:failed`, and `api:message:query:failed` → `api:message:jumpToLatest:failed`. They were a singular/plural split describing two _different_ operations, which made the pair impossible to grep for reliably. **Breaking** if you switch on either. -- **`PollComposerFieldErrors` values are now objects**, not bare English strings: `{ code, message, metadata? }`. Read `.message` for the previous value, or switch on `.code` to localize. **Breaking.** -- **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. -- **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). -- **The shared translation runtime ships as [`@stream-io/i18n`](https://www.npmjs.com/package/@stream-io/i18n)** — `Streami18n`, formatters and date handling — with React bindings at `@stream-io/i18n/react` and the build-time catalog generator at `@stream-io/i18n/codegen`. It is **not** part of `stream-chat`: install it alongside. -- **`stream-chat` does not depend on `i18next` or `dayjs`.** Both moved with the runtime, so a consumer who never translates anything no longer installs them. -- **Every timestamp you hand a formatter is now a unix-nanosecond number**, and the - `t('timestamp.X', { timestamp })` path is **not type-checked** — i18next's interpolation bag is - untyped, so a raw wire number compiles and renders the literal text `Invalid Date` into your UI. - `getDateString`'s `messageCreatedAt` _is_ typed (`string | Date`), so only the `t(…)` call sites - need auditing. See - [Timestamps reaching a formatter](#timestamps-reaching-a-formatter). -- **`LANGUAGE_NAMES` / `languageNameDefaults` stay in `stream-chat`**, root-exported. They name the languages the API can auto-translate a message into, which is a Chat API concern rather than an i18n one. -- Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. - -## Notification identity - -### `type` is the stable identifier; `message` is not - -Every notification `stream-chat` emits carries a `type`: a stable -`domain:entity:operation:result` identifier. That has been true since v10 rc, but it was typed as a bare -`string`, so nothing checked it and nothing enumerated it. - -v10 exports the full set, so you can switch on it with autocomplete and have a typo caught at compile -time: - -```ts -import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; -import type { CoreNotificationType } from 'stream-chat'; - -client.notifications.state.subscribe(({ notifications }) => { - for (const notification of notifications) { - if (notification.type === CORE_NOTIFICATION_TYPE.attachmentUploadFailed) { - // … - } - } -}); -``` - -The field stays open (`CoreNotificationType | (string & {})`), so identifiers emitted by a UI SDK or by -your own code are still valid — you only lose autocomplete for them. - -### Every identifier core emits - -| Identifier | Suggested translation key | -| ------------------------------------------ | ----------------------------------------- | -| `validation:attachment:file:missing` | `notification.attachmentFileMissing` | -| `validation:attachment:id:missing` | `notification.attachmentIdMissing` | -| `validation:attachment:upload:blocked` | `notification.attachmentUploadBlocked` | -| `api:attachment:upload:failed` | `notification.attachmentUploadFailed` | -| `validation:attachment:upload:in-progress` | `notification.attachmentUploadInProgress` | -| `validation:command:disabled` | `notification.commandDisabled` | -| `validation:command:not-ready` | `notification.commandNotReady` | -| `api:location:create:failed` | `notification.locationCreateFailed` | -| `api:message:jump:failed` | `notification.messageJumpFailed` | -| `api:message:jumpToLatest:failed` | `notification.messageJumpToLatestFailed` | -| `validation:poll:castVote:limit` | `notification.pollCastVoteLimit` | -| `api:poll:create:failed` | `notification.pollCreateFailed` | - -The right-hand column is a suggestion, not an export. Each UI SDK uses its own key names — they predate -this table and integrators' dictionaries are already written against them — so there is no single -canonical set to publish. What _is_ exported, and what makes the mapping safe, is the -`CORE_NOTIFICATION_TYPE` union: keying a `Record` on it turns a new identifier -into a compile error until you map it, and rejects an entry for one that no longer exists. - -`validation:command:disabled` additionally carries `metadata.reason` (`'editing' | 'replying'`), which -its English message varies by. Copy for that key should interpolate `{{ reason }}`. - -### Renamed identifiers - -**Breaking.** Two identifiers described two different operations under near-identical names: - -| v9 / earlier v10 rc | v10 | What it means | -| --------------------------- | --------------------------------- | ------------------------------------ | -| `api:messages:query:failed` | `api:message:jump:failed` | jumping to a specific message failed | -| `api:message:query:failed` | `api:message:jumpToLatest:failed` | jumping to the latest message failed | - -The old pair differed only by a plural `s`, in the opposite order from what you would guess — the -_plural_ name was the single-message jump. Neither UI SDK had ever mapped either one, which is how the -mismatch survived. - -```ts -// v9 / earlier v10 rc -if (notification.type === 'api:messages:query:failed') showJumpError(); - -// v10 -if (notification.type === CORE_NOTIFICATION_TYPE.messageJumpFailed) showJumpError(); -``` - -### Rendering notifications - -`Notification.message` is untranslated English intended as a **developer-facing fallback**. Its exact -wording is not part of the public contract and can be reworded in a minor release. - -This is a contract change rather than an immediate break: the field still exists and still contains the -same text today. But if you render it directly, you are relying on something now documented as unstable, -and you have no way to localize it. - -```ts -// Before — the English sentence is the only thing identifying the notification -toast(notification.message); - -// After — dispatch on the identifier, and fall back to `message` for one you do not recognize -import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; -import type { CoreNotificationType, Notification } from 'stream-chat'; - -const copy: Record string> = { - [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: () => t('notification.uploadFailed'), - // `validation:command:disabled` carries metadata.reason, so branch on it here - [CORE_NOTIFICATION_TYPE.commandDisabled]: (n) => - t('notification.commandDisabled', { reason: n.metadata?.reason }), - // …one entry per identifier; TypeScript will tell you which are missing -}; - -// `message` verbatim for anything unmapped, so a newer `stream-chat` cannot produce an empty toast. -toast( - notification.type && copy[notification.type as CoreNotificationType] - ? copy[notification.type as CoreNotificationType](notification) - : notification.message, -); -``` - -Type the record as `Record` rather than `Record` — that is the whole -point, and it is why core does not ship a ready-made resolver: your keys are yours, and a helper that -resolved them from a table would be invisible to a key-extraction step like the one both UI SDKs run. - -If you are using `stream-chat-react` or `stream-chat-react-native`, this is handled for you; see that -SDK's own i18n guide. - -## Poll-composer field errors - -**Breaking.** Field validation errors on the poll composer were bare English strings, which meant a UI -had to match on prose to localize them. They now carry a stable code: - -```ts -// v9 -type PollComposerFieldErrors = Partial< - Omit, 'options'> & { - options?: Record; - } ->; - -// v10 -type PollComposerValidationError = { - code: PollComposerValidationCode; - /** Untranslated English fallback. Not part of the public contract. */ - message: string; - metadata?: Record; -}; - -type PollComposerFieldErrors = Partial< - Omit, 'options'> & { - options?: Record; - } ->; -``` - -The one-property migration, if you do not want to localize: - -```ts -// v9 -{errors.name} -{errors.options?.[option.id]} - -// v10 -{errors.name?.message} -{errors.options?.[option.id]?.message} -``` - -To localize, switch on `code`: - -```ts -import { POLL_COMPOSER_VALIDATION_CODE } from 'stream-chat'; -import type { PollComposerValidationCode } from 'stream-chat'; - -const copy: Record = { - [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: t('poll.maxVotes.notNumeric'), - // … -}; -const text = errors.name ? (copy[errors.name.code] ?? errors.name.message) : undefined; -``` - -`message` is kept alongside `code` deliberately: a plain-JS integrator gets a compile error with a -one-property fix rather than a silently blank field, and an unrecognized code still renders readable -text. - -### Every poll validation code - -| Code | English fallback | -| --------------------------------------------- | ------------------------------ | -| `validation:poll:maxVotes:notNumeric` | Only numbers are allowed | -| `validation:poll:maxVotes:outOfRange` | Type a number from 2 to 10 | -| `validation:poll:maxVotes:uniqueVoteEnforced` | Enforce unique vote is enabled | -| `validation:poll:name:required` | Question is required | -| `validation:poll:option:duplicate` | Option already exists | -| `validation:poll:option:empty` | Option is empty | - -These are **not** notifications and are deliberately not routed through `NotificationManager` — they are -field-level form state rendered inline next to an input, and a toast per keystroke would be wrong. - -## The translation runtime: `@stream-io/i18n` - -It is a separate package, so install it: - -```sh -npm install @stream-io/i18n -``` - -Add `react` only if you use the `@stream-io/i18n/react` bindings — it is an optional peer. - -The translation runtime shared by the React and React Native SDKs. If you use a UI SDK, you do not -need to import this directly — the SDK re-exports what you need, bound to its own key catalog. - -```ts -import { Streami18n, getDateString, predefinedFormatters } from '@stream-io/i18n'; -``` - -It is a separate **package**, not part of `stream-chat`, because it pulls in `i18next` and `dayjs` and -because translation is not a Chat concern — Video, Feeds and Moderation use the same runtime without -depending on Chat. `stream-chat`'s own bundle carries neither dependency. - -Notable if you are building custom UI directly on `stream-chat`: - -- `Streami18n` is generic over your translation catalog: `new Streami18n(…)`. -- Reactivity goes through `i18n.state`, a `StateStore`. `subscribe` fires synchronously with the current - value, so there is no listener-registration ordering to get right. -- `setLanguage()` returns `Promise`. The new `t` is published to `state`; a returned translator - would go stale on the next language change. -- `init()` is idempotent, safe to call concurrently, and **rejects** if i18next fails to initialize. - Handle it: an unhandled rejection is what you get otherwise. The instance stays usable in a degraded - form either way — `initialized` stays `false` and `t` keeps returning each call site's inline English, - so the UI renders rather than blanking. A later `init()` retries. -- The keys with no inline default at their call site are injected via the `runtimeDefaults` option, - because the catalog belongs to the UI layer rather than to core. - -### Timestamps reaching a formatter - -Server-sent dates are unix-**nanosecond** numbers in v10 (see -[`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md)), and the date layer has -no way to tell one from a millisecond value. There are two entry points and only one of them is -type-safe: - -- **`getDateString({ messageCreatedAt })` is typed `string | Date`.** A wire number is a compile - error, so this path forces the conversion. -- **`t('timestamp.X', { timestamp })` is not.** The `timestamp.*` keys carry a `timestampFormatter` - expression and the value arrives through i18next's interpolation options, which are untyped. - `timestampFormatter` declares `FormatterFactory`, but nothing enforces that at the - call site. - -```ts -// COMPILES CLEANLY. A nanosecond value is out of `Date`'s range, so dayjs cannot parse it and -// `.format()` returns the literal string 'Invalid Date' — which lands on screen. -t('timestamp.LiveLocation', { timestamp: location.end_at }); - -// Convert at the call site. -import { convertTimestampToDate } from 'stream-chat'; -t('timestamp.LiveLocation', { timestamp: convertTimestampToDate(location.end_at) }); -``` - -`convertTimestampToDate` returns `undefined` for an absent or non-finite value, and the formatter -renders an empty string for `undefined` rather than the literal text `"undefined"`, so it can be -passed straight through. - -**`duration.*` keys are the mirror-image trap.** `durationFormatter` goes through the date library's -`.duration()`, so it expects a length of time in **milliseconds** — not a timestamp. Handing it a -timestamp renders something like "57 years ago". Derive a duration by subtracting two wire timestamps -and converting once: `nsToMs(later - earlier)`. - -**`isDate` no longer helps.** `timestamp && isDate(timestamp) ? timestamp.toISOString() : undefined` -was a common idiom, and it now correctly reports that a wire number is not a `Date` — so it yields -`undefined` for every timestamp and the value silently disappears from the UI. Convert instead of -guarding. - -### Removed from the `Streami18n` surface - -Both UI SDKs' v9 classes exposed these. They are gone rather than deprecated — v10 is a breaking -release, so an old name is removed rather than carried with a countdown on it. - -| Removed | Why, and what to do instead | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `getTranslations()` | Returned the raw i18next resource map, which is internal bookkeeping rather than a catalog: prose keys are never bundled, so it never held the SDK's English copy. It had no consumer in either SDK. To check that a key resolves, render it: `i18n.t('some.key')`. | -| `getAvailableLanguages()` | Returned every language with a dictionary, **including** ones created only to carry the bundled defaults — so a language nobody registered appeared "available". Use `i18n.registeredLanguages`, which answers the question people were actually asking. | - -`registeredLanguages` is now a `ReadonlySet`. Reading it (`.has(code)`, spreading it) is -unchanged; `.add()` no longer compiles, because adding to it would claim a language is registered with no -dictionary behind it — exactly the state the unregistered-language warning exists to report. Call -`registerTranslation()` instead. - -These are now internal (`private`), having never been part of either SDK's documented API: -`translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, -`validateCurrentLanguage()`. To register a dayjs locale directly, `@stream-io/i18n` exports -`addOrUpdateDayjsLocale()` and `dayjsLocaleExists()`. - -## The catalog generator: `@stream-io/i18n/codegen` - -Build-time only, **Node-only** and **ESM-only**: it reads the filesystem and uses the TypeScript parser -API. It generates a type-only translation-key catalog from your `t()` call sites, which is how a -mistyped key becomes a compile error. - -There is one artifact and no CommonJS build, since the caller is always a build script you control. From -an ESM script (`.mjs`, `.mts`, or a `"type": "module"` package) import it directly. A CommonJS script can -`require()` it too: `require(esm)` was unflagged in Node 22.12 and this package's floor is now 22.18.0. -`await import('@stream-io/i18n/codegen')` also works, on any Node. - -`typescript` is injected rather than imported, so `stream-chat` does not depend on the compiler: - -```ts -import ts from 'typescript'; -import { generateI18nKeys } from '@stream-io/i18n/codegen'; - -generateI18nKeys({ - ts, - runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', - keysOut: 'src/i18n/keys.ts', -}); -``` - -This is primarily for the UI SDKs. You only need it if you maintain your own translation catalog with -the same call-site-as-source-of-truth approach. - -## Dependency changes - -`stream-chat` **dropped** `i18next` and `dayjs`. They travelled with the translation runtime into -`@stream-io/i18n`, so a consumer who never translates anything no longer installs either — about -2.3 MB unpacked (`i18next` ~416 KB, `dayjs` ~1.9 MB). The root bundle never carried them, so bundle -size is unchanged; this is an install-footprint saving. - -It **added** one: - -| Package | Range | Why | -| ------------------------ | -------- | ---------------------------------------------------------- | -| `@stream-io/state-store` | `^1.1.6` | the `StateStore` this package used to vendor and re-export | - -`StateStore` is no longer exported from `stream-chat`'s root — see the `StateStore` section of -`v9-to-v10-migration-guide-other.md`. `@stream-io/i18n` carries `i18next` and `dayjs` as direct -dependencies rather than optional peers, so installing it is enough; you do not declare them yourself. - -If you declared `i18next` or `dayjs` because a Stream SDK needed them, you can drop them. If you keep -`dayjs` because your own code uses it, match `@stream-io/i18n`'s range (`^1.11.23`) — locale -registration is global, so a second copy means your `import 'dayjs/locale/de'` extends an instance the -SDK never formats with, and dates stay English with no error anywhere: - -```bash -find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' -``` - -## Mechanical migration checklist - -1. `grep -rn "api:messages:query:failed\|api:message:query:failed"` → replace with - `CORE_NOTIFICATION_TYPE.messageJumpFailed` / `.messageJumpToLatestFailed`. -2. `grep -rn "notification.message"` → for anything user-facing, switch on `notification.type` (use - `CORE_NOTIFICATION_TYPE`). Keep `message` - only as the unrecognized-identifier fallback. -3. Typecheck. Every `PollComposerFieldErrors` read will fail: append `?.message`, or switch on `.code`. -4. If you match notification identifiers anywhere, retype the local as `CoreNotificationType` to get the - set checked. -5. If you declared `i18next` or `dayjs` only for a Stream SDK, remove them and verify a single copy - resolves. diff --git a/v9-to-v10-migration-guide-logging.md b/v9-to-v10-migration-guide-logging.md deleted file mode 100644 index 2af998ef0f..0000000000 --- a/v9-to-v10-migration-guide-logging.md +++ /dev/null @@ -1,238 +0,0 @@ -# v9 → v10 Migration Guide — Logging - -> Scope: this guide covers **only** the logging system replacement — the removal of the v9 `logger` option / `client.logger()` / `_log()` surface, and the introduction of the scoped `@stream-io/logger`-based system exposed as `chatLoggerSystem`. Construction, method-signature, and sort changes are covered in separate guides. - -## TL;DR - -- The `logger` option on `StreamChatOptions` is **removed**. `new StreamChat(key, { logger: fn })` no longer type-checks — the field is silently dropped at runtime. -- The `client.logger` field is **removed**. Any `client.logger('info', 'msg', extra)` call no longer compiles. -- The v9 `Logger` and `LogLevel` types (from `stream-chat`) are **removed**. Import their replacements from `stream-chat`'s new logger surface (re-exported from `./logger`): `LogLevel`, `Sink`, `ConfigureLoggersOptions`, `LogLevelEnum`, `ScopedLogger`, `ChatLoggerScope`, `chatLoggerSystem`. -- Log-level enum expanded from **3** values (`'info' | 'warn' | 'error'`) to **5** (`'trace' | 'debug' | 'info' | 'warn' | 'error'`). -- Configure logging by calling `chatLoggerSystem.configureLoggers({...})` **before** constructing the client (there is no constructor option for it — `logLevel` / `logOptions` fields do not exist on `StreamChatOptions`). -- Internal `_log()` methods (notably on `StableWSConnection`) are gone. If you subclassed or spied on them, switch to the scoped loggers. The one survivor is `WSConnectionFallback._log()`, kept with the v9 long-poll; it now writes to the `connection` scope with the `connection_fallback` tag. - -## What ships in v10 - -```ts -// src/logger.ts — re-exported from the package root -import { - chatLoggerSystem, // LoggerSystem - LogLevelEnum, // numeric enum: trace=0 debug=1 info=2 warn=3 error=4 - type ChatLoggerScope, // union of the 15 built-in scopes (see table below) - type ConfigureLoggersOptions, - type LogLevel, // 'trace' | 'debug' | 'info' | 'warn' | 'error' - type Sink, // (logLevel, message, ...data) => void - type ScopedLogger, // = Logger -} from 'stream-chat'; -``` - -`chatLoggerSystem` is a **module-level singleton**. It is created once when `./logger` is imported and is shared across every `StreamChat` instance (and across every SDK internal caller). Configuration is process-wide, not per-client. - -The default sink writes to `console.{trace,debug,info,warn,error}` (with a React-Native-safe fallback for `warn`/`error`). Default level is `'info'`. - -### Built-in scopes - -Every internal module attaches to one of these scopes via `chatLoggerSystem.getLogger('')`: - -| Scope | Emitted by | -| ------------------ | ----------------------------------------------------------------------------------------------------- | -| `api-client` | `src/api-client.ts` — HTTP request/response tracing | -| `channel` | `src/channel.ts` | -| `channel-manager` | `src/channel_manager.ts` | -| `client` | `src/client.ts` — connection lifecycle, event dispatch | -| `connection` | `StableWSConnection.ts` (WS) and `WSConnectionFallback.ts` (long-poll, tag `connection_fallback`) | -| `message-composer` | `src/messageComposer/messageComposer.ts` | -| `offline-db` | `src/offline-support/*` **and** offline-DB paths in `client.ts` / `channel.ts` / `messageComposer.ts` | -| `state-store` | reserved — declared in the scope union, not yet emitted | -| `text-composer` | `src/messageComposer/middleware/textComposer/*` | -| `thread` | `src/thread.ts` | -| `thread-manager` | `src/thread_manager.ts` | -| `token-manager` | `src/token_manager.ts` | -| `upload-manager` | `src/uploadManager.ts` | -| `utils` | `src/utils.ts` — `logChatPromiseExecution`, `isOnline`, `messageSetPagination`, `runDetached` | - -Unknown scope names fall through to `'default'`. The `ChatLoggerScope` union narrows autocomplete but is not enforced at runtime. - -## Removed surface - -| v9 | v10 | -| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `StreamChatOptions.logger` | **REMOVED** — no field on `StreamChatOptions` | -| `client.logger` (instance field) | **REMOVED** | -| `type Logger = (level, message, extra?) => void` | **REMOVED** — no direct replacement (write a `Sink` instead) | -| `type LogLevel = 'info' \| 'error' \| 'warn'` | **REPLACED** — same name, now `'trace' \| 'debug' \| 'info' \| 'warn' \| 'error'` | -| `isFunction(inputOptions.logger)` guard in constructor | gone | -| `StableWSConnection._log(msg, extra?, level?)` | **REMOVED** — use `chatLoggerSystem.getLogger('connection')` | -| `WSConnectionFallback._log(msg, extra?, level?)` | **KEPT** (internal) — `connection` scope, tagged `connection_fallback` | -| `extraData.tags: string[]` convention (`{ tags: ['channel', 'offlineDb'], error }`) | replaced by scope + `.withExtraTags(...)` (see below) | -| Structured extra as second positional arg (`(level, msg, { tags, error, event })`) | passed as rest args after the message (`.error('msg', { error })`) | - -## The v9 → v10 shape shift - -### v9 — a single `logger` function receives everything - -```ts -// v9 -type LogLevel = 'info' | 'error' | 'warn'; -type Logger = ( - logLevel: LogLevel, - message: string, - extraData?: Record, -) => void; - -const client = new StreamChat('api_key', { - logger: (level, message, extraData) => { - // extraData contains a `tags: string[]` array plus arbitrary context - console.log(level, message, extraData); - }, -}); - -client.logger('info', 'anything I want to log', { - tags: ['channel', 'offlineDb'], - error: someError, -}); -``` - -The SDK routed _all_ internal log calls into this single function; disambiguation was done via the `extraData.tags` array (values like `'api'`, `'api_request'`, `'api_response'`, `'client'`, `'channel'`, `'connection'`, `'event'`). - -### v10 — scoped loggers with per-scope sinks and levels - -```ts -// v10 — configure the shared logger system before creating the client -import { chatLoggerSystem, type Sink } from 'stream-chat'; - -const sink: Sink = (logLevel, message, ...rest) => { - // `message` is prefixed with `[](): ` by the system - // `rest` is whatever the SDK passes after the message (e.g. `{ error }`) - myLogger[logLevel](message, ...rest); -}; - -chatLoggerSystem.configureLoggers({ - default: { level: 'info', sink }, -}); - -const client = new StreamChat('api_key'); -``` - -SDK-internal call sites look like this (do not call these yourself unless you're extending the SDK): - -```ts -const logger = chatLoggerSystem.getLogger('connection'); -logger.withExtraTags('_reconnect').info('Initiating a reconnect.'); -// → sink receives: 'warn' | 'info' | ... , '[connection](_reconnect): Initiating a reconnect.', ...rest -``` - -## Configuring logging in v10 - -There is **no constructor option** for logging in v10 (an earlier commit briefly added `logLevel` / `logOptions` to `StreamChatOptions`; both were dropped before release — do not rely on them). Configure `chatLoggerSystem` directly. Because it is a module-level singleton, configuration applies to every `StreamChat` you construct afterwards. - -### Route all output to your own logger - -```ts -import { chatLoggerSystem, type Sink } from 'stream-chat'; - -const sink: Sink = (level, message, ...rest) => myLogger[level](message, ...rest); - -chatLoggerSystem.configureLoggers({ - default: { level: 'info', sink }, -}); -``` - -### Raise the level globally (silence everything below `warn`) - -```ts -chatLoggerSystem.configureLoggers({ default: { level: 'warn' } }); -``` - -### Debug one subsystem without touching the others - -```ts -chatLoggerSystem.configureLoggers({ - connection: { level: 'trace' }, -}); -// leaves `default` and every other scope at 'info' -``` - -### Reset one scope back to defaults, or reset everything - -```ts -chatLoggerSystem.configureLoggers({ - connection: { level: null, sink: null }, // remove per-scope overrides -}); - -chatLoggerSystem.restoreDefaults(); // wipe all overrides, restore default sink + 'info' -``` - -### `configureLoggers` semantics you must know - -- Passing `{ level: 'warn' }` sets the level. Passing `{ level: null }` **deletes** the override (falls back to `'default'`). Same for `sink`. -- The `default` scope can be overridden but **cannot be deleted** — `{ default: { level: null } }` is a no-op. -- Undefined values are ignored — only explicit `null` clears an override. -- Configuration is not additive across calls to `configureLoggers` for the _same_ key — the last call wins per (scope, field). Untouched scopes keep their prior override. -- `chatLoggerSystem` is process-wide. Two `StreamChat` instances in the same process share it; there is no per-instance override. - -### Sink signature - -```ts -type Sink = (logLevel: LogLevel, message: string, ...data: any[]) => void; -``` - -- `logLevel` is the string form (`'trace' | 'debug' | 'info' | 'warn' | 'error'`), not the enum. -- `message` arrives already prefixed by the system with `[](): ` (tags parenthetical is omitted when empty). -- `...data` is whatever the SDK passed after the message (e.g. `{ error }`, `{ event }`, `{ wsURL }`). -- Use `LogLevelEnum` to compare severity numerically: `LogLevelEnum[record.logLevel] >= LogLevelEnum.warn`. - -## Mechanical migration recipe - -For every call site: - -1. **Delete the `logger` option** from `new StreamChat(key, options)` and `StreamChat.getInstance(key, options)`. If you were forwarding to your own logger, replace it with a top-level `chatLoggerSystem.configureLoggers({ default: { sink: yourSink } })` call (once, at bootstrap). -2. **Rewrite `client.logger(level, message, extra)` calls.** If it was your own instrumentation code, drop it — the SDK's internal call sites already log through `chatLoggerSystem`. If you must emit into the same stream, use `chatLoggerSystem.getLogger('')[level](message, extra)`. -3. **Remove any reads of `client.logger`.** Tests and integrations that asserted on `client.logger` being a function must be deleted or rewritten against `chatLoggerSystem`. See the `client.construction-old.test.ts` block in this branch for a concrete v9 example that must be dropped. -4. **Delete the `logger` field from `StreamChatOptions` type-satisfaction sites.** If you had `const opts: StreamChatOptions = { logger, timeout: 5000 }`, drop `logger`. -5. **Replace `import type { Logger } from 'stream-chat'`.** Write a `Sink` instead: - - ```ts - // v9 - import type { Logger } from 'stream-chat'; - const myLogger: Logger = (level, msg, extra) => { … }; - - // v10 - import { type Sink } from 'stream-chat'; - const mySink: Sink = (level, msg, ...rest) => { … }; - ``` - -6. **Update `LogLevel` consumers.** If your code was `switch(level) { case 'info': … case 'warn': … case 'error': … }`, add `case 'trace':` and `case 'debug':` (or let them fall through to a default branch). Any exhaustive union check on `LogLevel` will now fail without those cases. -7. **Drop the `tags` array convention.** In v9, extras looked like `{ tags: ['channel', 'offlineDb'], error }`. In v10, the scope already carries the primary category (`offline-db`, `channel`, …) and `.withExtraTags('sendMessage', channelCid)` adds call-site tags — you don't reconstruct them by hand. When translating an internal callsite, pick the scope that matches the module and use `.withExtraTags(...)` for the finer-grained context. -8. **Remove `_log()` calls in any code that subclassed SDK internals.** Every `_log(msg, extra?, level?)` in v9 mapped to `this.client.logger(level ?? 'info', ':' + msg, { tags: […], ...extra })`. Replace with the appropriate scoped logger. Example (from `StableWSConnection`): - - ```ts - // v9 - this._log(`connect() - established with healthcheck ${hc}`); - - // v10 - const logger = chatLoggerSystem.getLogger('connection'); - logger - .withExtraTags('connect') - .info(`Established a WebSocket connection. Health check: ${hc}.`); - ``` - -## Type-import mapping cheat sheet - -| v9 import from `stream-chat` | v10 replacement | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `Logger` | REMOVED — write a `Sink` instead | -| `LogLevel` | still exported (same name), but the union is now 5 values, not 3 | -| — | `Sink` — new. Sink function signature. | -| — | `LogLevelEnum` — new. Numeric enum, useful for severity comparisons in sinks. | -| — | `ConfigureLoggersOptions` — new. Argument shape for `chatLoggerSystem.configureLoggers`. | -| — | `ChatLoggerScope` — new. String union of the 15 built-in scopes. | -| — | `ScopedLogger` — new. Alias for `Logger` (the return type of `chatLoggerSystem.getLogger(scope)`). | -| — | `chatLoggerSystem` — new. The shared `LoggerSystem` singleton. | - -## Things that did NOT change - -- The categories of information the SDK logs (WS lifecycle, event dispatch, API request/response, offline-DB failures, upload errors, composer state) are unchanged in v10 — only the transport and the shape of the record. -- No log call is now silent-by-default that was noisy in v9 within the shared 3-level range; several v9 `.info` calls became `.debug` (e.g. `client.on/off` listener attach/detach, `openConnection` already-connecting guard). If you rely on those, lower the level to `'debug'` for that scope. -- The default sink still writes to the `console`. No behavior change for callers that never installed a custom `logger` in v9 — they now see richer output (with the `[scope](tags):` prefix), but the surface is still `console`. -- Log messages are not part of the semver contract. Do not pattern-match on message strings in production; use `logLevel` + `scope` (via the `[scope]` prefix) instead. diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md deleted file mode 100644 index ee71d840cf..0000000000 --- a/v9-to-v10-migration-guide-methods.md +++ /dev/null @@ -1,1899 +0,0 @@ -# v9 → v10 Migration Guide — Method Signatures - -> Scope: this guide covers **method signature changes** on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, and `StableWSConnection`. Construction changes are in `v9-to-v10-migration-guide-client-construction.md`. Server-side surfaces are gone in v10 — server-side callers should switch to `@stream-io/node-sdk` (https://github.com/GetStream/stream-node) and ignore this guide. -> -> This document is written for AI agents doing mechanical rewrites. Each entry has the exact v9 signature and the exact v10 replacement. Removed methods are labeled **REMOVED** with the recommended replacement (or "no replacement" when the entire feature is dropped). -> -> **Sort arguments:** every `sort` argument shown below has also changed shape — the v9 `{ field_name: direction }` object form is gone, replaced by `SortParamRequest[]` (`[{ field, direction }]`). This guide shows sort values in the new shape but does **not** re-explain the sort migration itself. For the sort shape change, the full field→field/direction rewrite recipe, and the removed `Sort` / `*SortBase` / `normalizeQuerySort` imports, see `v9-to-v10-migration-guide-sort.md` — agents rewriting call sites that pass a `sort` must consult that guide. - -## Global renames applied everywhere - -Before applying any per-method entry below, apply these repo-wide renames — they are consistent across every class: - -| v9 | v10 | Notes | -| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `userID` (param and field) | `userId` | Field: `client.userID` still readable via a deprecated getter; assignment (`client.userID = …`) no longer compiles. | -| `clientID` (param and field) | `clientId` | Field: `client.clientID` kept as a deprecated getter+setter. | -| `messageID`, `targetID`, `targetMessageID`, `targetUserID`, `flaggedUserID`, `entityCreatorID`, `wsID`, `channelID`, `channelId` parameter | `messageId`, `targetId`, `targetMessageId`, `targetUserId`, `flaggedUserId`, `entityCreatorId`, `wsId`, `channelId` | Named-parameter rename only; call sites using positional args are unaffected. | -| `parent_id` parameter on `keystroke` / `stopTyping` | `parentId` | Positional; call sites unaffected. | -| `Event` (type) | `Event` (still exported; shape changed) | `Event` is now `WSEvent \| LocalEvent \| keyof CustomEventTypes`. The name is unchanged; the wire shape is what's different. Internal handlers that took an untyped `Event` are the same. `EventPayload<'…'>` narrows to a specific event type. | -| `EventTypes` (type import) | `EventType \| string` (via generic) | The public alias renamed to singular `EventType = Event['type'] \| 'all'`. Callers annotating handlers as `EventHandler` are safe; callers importing `EventTypes` need to switch to `EventType`. `CustomEventTypes` module augmentation is unchanged — augment it to add custom event-type keys. | -| `Logger` option / `client.logger()` | `chatLoggerSystem` from `./logger` | See "Logging" note at the end of the guide. | - -The `secret` parameter, `client.secret`, `client._isUsingServerAuth()`, and all server-only methods are gone. Where a v9 method took a `user_id?` / `userID?` / `currentUserID?` override, that argument has been dropped in v10 (the connected user is always used). - -**Every request-issuing method takes an optional trailing `requestOptions`.** The per-method signatures below omit it for readability, but every method generated from the OpenAPI spec ends with `requestOptions?: StreamRequestOptions` — currently `{ signal?: AbortSignal; onUploadProgress?; timeout?: number }`, where `timeout` overrides `axiosRequestConfig.timeout` for that request — and so does every hand-written wrapper around one, on both `StreamChat` and `Channel`. It is always **last**, immediately after the method's own arguments, and is never serialized into the request: - -```ts -const controller = new AbortController(); -await client.search({ payload: { query: 'hello' } }, { signal: controller.signal }); -await channel.queryMembers({ payload: { limit: 10 } }, { signal: controller.signal }); -controller.abort(); -``` - -**`requestOptions` is never the first argument of a method that has no request.** A method whose endpoint takes no query or body fields still has a request slot in front of `requestOptions`, typed `_request?: Record` and never read — pass `undefined` there: - -```ts -await client.getAppSettings(undefined, { signal: controller.signal }); -await client.getMessage({ id: messageId }, undefined, { signal: controller.signal }); -await client.deleteReaction({ id: messageId, type: 'love' }, undefined, { - timeout: 5_000, -}); -await channel.pin(undefined, { signal: controller.signal }); -``` - -The slot keeps `requestOptions` in the same position when the endpoint later gains a query parameter. Without it, `getApp(requestOptions)` would become `getApp(request, requestOptions)`, and options passed by existing callers would quietly go out as query parameters. The generated methods affected today are `getApp`, `unreadCounts`, `listDevices`, `getBlockedUsers`, `getUserLiveLocations`, `getMessage`, `deleteReaction`, `deletePoll`, `getPoll`, `deletePollOption`, `getPollOption`, `deletePollVote` and `deleteReminder` on `client`, and `listQueues`, `getQueue`, `getAppeal` and `deleteActionConfig` on `client.moderation`. The wrappers around them take the same shape (`client.getAppSettings`, `channel.deleteReaction`, `channel.removeVote`). So do the hand-written `Channel` conveniences that send a fixed request: `archive`, `unarchive`, `pin`, `unpin`, `disableSlowMode`, `clearAIIndicator` and `stopAIResponse`. A call that omits `requestOptions` is unaffected (`client.getAppSettings()`, `channel.pin()`). A call that passes it in the first free position is a compile error, because no `StreamRequestOptions` value is assignable to `Record`. - -This replaces v9's `client.createAbortControllerForNextRequest()` — see [below](#clientcreateabortcontrollerfornextrequest). The upload methods are **not** an exception: they take the same `requestOptions`, which carries `onUploadProgress` alongside `signal`. v9's `axiosRequestConfig` parameter is gone everywhere. - -**Path parameters are their own argument.** A generated method whose endpoint has URL path parameters takes them as a separate first argument: `method(pathParams, request?, requestOptions?)`. `request` carries the query and body fields; a method whose arguments are all path parameters keeps an unused `_request` slot so `requestOptions` stays third ([see above](#global-renames-applied-everywhere)). The hand-written methods that wrap one of these take the same shape (`channel.sendReaction`, `channel.vote`, `client.queryReactionsAndHydrate`, `client.queryPollAnswers`, `client.reminders.upsertReminder` / `createReminder` / `updateReminder`). - -```ts -await client.sendReaction({ id: messageId }, { reaction: { type: 'love' } }); -await client.deleteMessage({ id: messageId }, { hard: true }); -await client.getThread({ message_id: parentId }, { reply_limit: 10 }); -await client.getMessage({ id: messageId }); // path parameters only - the request slot is an unused placeholder -``` - -Two things to know when rewriting call sites: - -- **TypeScript does not catch every stale call.** An object literal with a stray field is an error, but a field that arrives through a spread (`{ id, ...options }`) or a pre-built variable is not checked. It lands in `pathParams`, which only fills the URL, so the client **throws** at runtime (`"" is not a path parameter of `) instead of sending the request without it. -- **`request` is sent as given.** The generated methods no longer copy the known fields one by one, so every top-level key you pass reaches the server. Pass only the request's own fields. - ---- - -## StreamChat - -### Removed — no replacement in this SDK (server-side, use `@stream-io/node-sdk`) - -The following `StreamChat` methods no longer exist. All were server-side or admin-only. Rewrites should either delete the call site or move it to the server SDK: - -`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (removed outright — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. - -### Renamed / signature-changed - -#### `client.queryChannels` - -```ts -// v9 — three overloads with positional filter/sort/options -client.queryChannels(filter, sort?, options?, stateOptions?): Promise; -client.queryChannels(filter, options?, stateOptions?): Promise; - -// v10 — split into two methods -client.queryChannels(request?: QueryChannelsRequest): Promise; // raw response (inherited from ChatApi) -client.queryChannelsAndHydrate( - options?: QueryChannelsRequest, - stateOptions?: ChannelStateOptions, -): Promise; // v9 behavior lives here -client.queryChannelsAndHydrate( - options, - stateOptions: ChannelStateOptions & { withResponse: true }, -): Promise; // returns Channels + raw response -``` - -Rewrite: - -```ts -// v9 -const channels = await client.queryChannels( - { type: 'messaging' }, - { last_message_at: -1 }, - { limit: 20 }, -); - -// v10 -const channels = await client.queryChannelsAndHydrate({ - filter_conditions: { type: 'messaging' }, - sort: [{ field: 'last_message_at', direction: -1 }], - limit: 20, -}); -``` - -Sort now uses `Gen_SortParamRequest[]` (`{ field, direction }`), not the v9 record form. See `v9-to-v10-migration-guide-sort.md` for the full sort migration. - -#### `client.queryReactions` - -```ts -// v9 -client.queryReactions(messageID, filter, sort?, options?); - -// v10 — inherited from ChatApi -client.queryReactions(pathParams: { id }, request?: QueryReactionsRequest); // raw response -client.queryReactionsAndHydrate(pathParams: { id }, request?: QueryReactionsRequest); // wraps offline-db merge -``` - -Use `queryReactionsAndHydrate` where v9 code depended on the offline-db reaction reconciliation; otherwise use inherited `queryReactions`. - -#### `client.queryUsers` - -```ts -// v9 -client.queryUsers(filterConditions, sort?, options?); - -// v10 — inherited/overridden -client.queryUsers(request?: { payload?: Gen_QueryUsersPayload }); -// payload: { filter_conditions, sort, limit, offset, presence, ... } -``` - -#### `client.search` - -```ts -// v9 -client.search(filterConditions, query, options?); - -// v10 -client.search(request?: { payload?: SearchPayload }); -// payload combines filter_conditions, message_filter_conditions, query, sort, limit, next, ... -``` - -#### `client.queryThreads` / `client.getThread` - -```ts -// v9 -client.queryThreads(options?); // returned hydrated Thread[] -client.getThread(messageId, options?); // returned hydrated Thread - -// v10 -client.queryThreads(request?); // inherited, raw QueryThreadsResponse -client.queryThreadsAndHydrate(options?); // v9 behavior -client.getThread(pathParams: { message_id }, request?); // inherited, raw; request: { reply_limit?, participant_limit?, member_limit?, watch? } -client.getThreadAndHydrate(messageId, options?); // v9 behavior -``` - -Callers that want hydrated `Thread` instances (the v9 default) must call the `*AndHydrate` variants. - -#### `client.updateMessage` / `client.deleteMessage` - -```ts -// v9 -client.updateMessage(message, userId?, options?); -client.deleteMessage(messageID, hardDelete?); - -// v10 — inherited/overridden from ChatApi -client.updateMessage(pathParams: { id: string }, request: UpdateMessageRequest); -// request: { message, skip_enrich_url?, skip_push? } -client.deleteMessage(pathParams: { id: string }, request?: { hard?: boolean; delete_for_me?: boolean }); -``` - -Note: `hardDelete` boolean is now `hard` on the request. `user_id` override is gone. Both are now the -HTTP request only and no longer queue for offline replay — see -[one HTTP call and one full call per message action](#removed-after-1000-rc16--one-http-call-and-one-full-call-per-message-action). - -#### `client.partialUpdateMessage` / `client.ephemeralUpdateMessage` / `client.undeleteMessage` - -```ts -// v9 -client.partialUpdateMessage(messageID, updates, userId?, options?); -client.ephemeralUpdateMessage(messageID, updates, userId?, options?); -client.undeleteMessage(messageID, userID); - -// v10 -client.updateMessagePartial(pathParams: { id }, request?: UpdateMessagePartialRequest); // inherited; no user_id override -// ephemeralUpdateMessage: REMOVED — call updateMessagePartial with the ephemeral payload directly. -// undeleteMessage: REMOVED — no client-side replacement (was server-side). -``` - -#### `client.getMessage` - -```ts -// v9 -client.getMessage(messageID, options?); - -// v10 — inherited -client.getMessage(pathParams: { id: string }, _request?: Record); -``` - -Options like `show_deleted_message` are no longer accepted here (server-side only). - -#### `client.pinMessage` / `client.unpinMessage` - -Unchanged behavior; parameter name normalized: - -```ts -// v9 -client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); -client.unpinMessage(messageOrMessageId); - -// v10 — same signatures; `userId` positional (v9 fourth arg) is removed -client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); -client.unpinMessage(messageOrMessageId); -``` - -#### `client.markChannelsRead` (and alias `markAllRead`) - -```ts -// v9 -client.markChannelsRead(data?: MarkChannelsReadOptions); -client.markAllRead(data?); // alias — REMOVED - -// v10 — inherited -client.markChannelsRead(request?: Gen_MarkChannelsReadRequest); -``` - -#### `client.markChannelsDelivered` - -```ts -// v9 -client.markChannelsDelivered(data: MarkDeliveredOptions); - -// v10 -client.markChannelsDelivered(request?: Gen_MarkDeliveredRequest); -// v10 short-circuits when `latest_delivered_messages` is empty; still available. -``` - -#### `client.upsertUser` / `client.upsertUsers` (+ aliases `updateUser` / `updateUsers`) - -```ts -// v9 -client.upsertUser(user); -client.upsertUsers([user1, user2]); -client.updateUser(user); // alias — REMOVED -client.updateUsers([user1]); // alias — REMOVED (name reused for the new bulk method) - -// v10 — inherited -client.updateUsers({ users: { [user.id]: user } }); -// `users` is a Record keyed by user ID, not an array. -``` - -Mechanical rewrite for a single user: - -```ts -// v9 -await client.upsertUser({ id: 'u1', name: 'A' }); - -// v10 -await client.updateUsers({ users: { u1: { id: 'u1', name: 'A' } } }); -``` - -#### `client.partialUpdateUsers` - -```ts -// v9 -client.partialUpdateUsers(users: PartialUserUpdate[]); - -// v10 — inherited -client.updateUsersPartial({ users: PartialUserUpdate[] }); -``` - -#### `client.addDevice` / `client.getDevices` / `client.removeDevice` - -```ts -// v9 -client.addDevice(id, pushProvider, userID?, pushProviderName?); -client.getDevices(userID?); -client.removeDevice(id, userID?); - -// v10 — inherited (userID param dropped; server-only) -client.createDevice({ id, push_provider, push_provider_name?, hardware_id? }); -client.listDevices(); -client.deleteDevice({ id }); -``` - -`userID` is gone from all three — server-side callers using the target-user form must move to `@stream-io/node-sdk`. - -#### `client.getUnreadCount` / `client.getUnreadCountBatch` - -```ts -// v9 -client.getUnreadCount(userID?); // could query for another user server-side -client.getUnreadCountBatch(userIDs); // server-side - -// v10 — inherited -client.unreadCounts(); // connected user only -// getUnreadCountBatch: no replacement — was server-side only. -``` - -#### `client.banUser` / `client.unbanUser` / `client.shadowBan` / `client.removeShadowBan` - -```ts -// v9 -client.banUser(targetUserID, options?); -client.unbanUser(targetUserID, options?); -client.shadowBan(targetUserID, options?); -client.removeShadowBan(targetUserID, options?); - -// v10 — same shape; positional rename only -client.banUser(targetUserId, options?); -client.unbanUser(targetUserId, options?); -client.shadowBan(targetUserId, options?); -client.removeShadowBan(targetUserId, options?); -``` - -#### `client.blockUser` / `client.unBlockUser` / `client.getBlockedUsers` - -```ts -// v9 -client.blockUser(blockedUserID, user_id?); // user_id was server-side override -client.unBlockUser(blockedUserID, userID?); // note the mixed-case original name -client.getBlockedUsers(user_id?); - -// v10 -client.blockUser(blockedUserId); // takes only the target -client.unblockUser(blockedUserId); // renamed to lowercase `b` -client.getBlockedUsers(); // no user_id override -``` - -**Rename:** `unBlockUser` → `unblockUser` (lowercase `b`). - -#### `client.muteUser` / `client.unmuteUser` - -```ts -// v9 -client.muteUser(targetID, userID?, options?); // userID was server-side override -client.unmuteUser(targetID, currentUserID?); - -// v10 -client.muteUser(targetId, options?); -client.unmuteUser(targetId); -``` - -#### `client.flagMessage` / `client.flagUser` / `client.unflagMessage` / `client.unflagUser` / `client.unblockMessage` - -```ts -// v9 -client.flagMessage(targetMessageID, options?: { reason?; user_id? }); -client.flagUser(targetID, options?: { reason?; user_id? }); -client.unflagMessage(targetMessageID, options?: { user_id? }); -client.unflagUser(targetID, options?: { user_id? }); -client.unblockMessage(targetMessageID, options?: { user_id? }); - -// v10 -client.flagMessage(targetMessageId, options?: { reason? }); -client.flagUser(targetId, options?: { reason? }); -client.unflagMessage(targetMessageId); -client.unflagUser(targetId); -client.unblockMessage(targetMessageId); -``` - -`user_id` overrides dropped everywhere. - -#### `client.userMuteStatus` - -```ts -// v9 -client.userMuteStatus(targetID); - -// v10 -client.userMuteStatus(targetId); -``` - -#### `client.channel()`, `client.channelManager.ensure()` - -`client.channel()` takes the same arguments in the same forms as in v9, `(type, id?, data?)` and -`(type, data)`, and is now a shorthand for `client.channelManager.ensure()`, which takes one object. -Only the type of `data` changed (below). Both return the stored channel for the cid or create and -store it, as `client.channel()` did. The channel stays stored, as in v9, until it is deleted, the -user is removed from it, or the user logs out. An app that wants to free memory calls -`client.channelManager.releaseUnusedChannels()`, which releases only channels nothing uses (see the -[other changes guide](./v9-to-v10-migration-guide-other.md)); call `channel.activate()` on a channel -you keep, so that call leaves it alone. - -The channel data is typed `ChannelInput`, which replaced `ChannelData` (see the -[type renames guide](./v9-to-v10-migration-guide-type-renames.md)): custom fields go under `custom` -instead of at the top level, and members are `{ user_id }` objects. - -```ts -// v9 -client.channel(type, id); -client.channel(type, id, { name }); -client.channel(type, { members: ['ann', 'bob'] }); -client.channel(type, undefined, { members: ['ann', 'bob'] }); - -// v10 -client.channel(type, id); -client.channel(type, id, { custom: { name } }); -client.channel(type, { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }); -client.channel(type, undefined, { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }); - -// v10, the same channels through ensure() -client.channelManager.ensure({ type, id }); -client.channelManager.ensure({ type, id, data: { custom: { name } } }); -client.channelManager.ensure({ - type, - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, -}); -client.channelManager.ensure({ - type, - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, -}); -``` - -A channel created without an id (from members, as above) is **provisional** (`channel.isProvisional`) -until its query is answered: it has no id yet, so no request but that query can be sent for it. Typing -events and drafts are skipped; any other request throws before it is sent, and nothing is queued for -offline replay. The query's response gives it the id the server assigned. A channel with an id is never -provisional, even one whose id your app generated and the server doesn't have yet: its requests go to -the server as in v9, loaded or not, and the server answers for them. - -A channel created from members is stored under a temporary cid built from the sorted member ids -(`type:!members-ann,bob`), which is also its `channel.cid` until the server answers; it then moves to -the real cid. If another instance was stored under the real cid meanwhile (by an event or a channel -list), that instance stays the one for the cid and takes over: it gets the server's response, the new -instance's local messages (its own local messages, such as failed ones, are kept), and, when the new -instance is open and it isn't, the composer, including uploads still running. The new instance is -marked `channel.supersededBy` (reactive) and receives no events from then on. The SDK doesn't -dispose of it before `disconnectUser()`, as your app may still hold it; call `channel.disconnect()` -on it once nothing uses it (see the [other changes guide](./v9-to-v10-migration-guide-other.md)). -stream-chat-react moves a ChatView slot to the replacing instance on its own; other UIs holding the -new instance should switch to `channel.supersededBy`. - -`channel.initialized` is now set by any query, so `query()` and `create()` set it as `watch()` does. - -`client.getChannelById()` and `client.getChannelByMembers()` are removed; use either form above. - -```ts -// v9 -client.getChannelById(type, id, custom); - -// v10 -client.channel(type, id, custom); -``` - -The SDK itself gets channels through `client.channelManager.ensure()`, so a spy on `client.channel` -sees only your own calls; spy on `client.channelManager.ensure` (called with `{ type, id, data }`) to -see every channel the client gets. - -#### `client.setGuestUser` - -```ts -// v9 -client.setGuestUser(user: UserResponse); - -// v10 -client.setGuestUser(user: UserRequest, requestOptions?); -``` - -`user` is the generated `UserRequest` (`id`, plus optional `name`, `image`, `language`, `invisible`, -`privacy_settings` and `custom`), the shape `createGuest` sends. App custom fields go under `custom` -rather than at the root. - -#### `client.setAnonymousUser` alias - -```ts -// v9 -client.setAnonymousUser = this.connectAnonymousUser; // REMOVED - -// v10 -await client.connectAnonymousUser(); -``` - -#### `client.doAxiosRequest` / `client.dispatchEvent` / `client.errorFromResponse` / `client.sendFile` - -```ts -// v9 — direct methods on the client -client.doAxiosRequest(type, url, data?, options?); -client.dispatchEvent(event); -client.errorFromResponse(response); -client.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); - -// v10 -client.api.doAxiosRequest(type, url, data?, options?); -client.dispatchEvent(event: Event); // Event union is now WSEvent | LocalEvent | CustomEventTypeUnion -client.api.errorFromResponse(response); // moved to ApiClient -// client.sendFile — REMOVED, and there is no `client.api.sendFile` either. Multipart encoding -// now lives in `ApiClient.sendRequest`; use the upload methods below. -``` - -`client.api` is a new getter returning the internal `ApiClient` instance. - -#### `client.createAbortControllerForNextRequest` - -**REMOVED.** Pass an `AbortSignal` through the target method's trailing `requestOptions` instead. - -```ts -// v9 — arm a controller, and whichever request happened to go out next picked it up -const controller = client.createAbortControllerForNextRequest(); -const users = await client.queryUsers({ id: { $in: ['jane'] } }); -controller.abort(); - -// v10 — hand the signal to the call you actually want to cancel -const controller = new AbortController(); -const users = await client.queryUsers( - { payload: { filter_conditions: { id: { $in: ['jane'] } } } }, - { signal: controller.signal }, -); -controller.abort(); -``` - -Why it went away: the v9 mechanism armed a single controller on the client and the **next** outgoing request consumed it, whichever one that turned out to be. With concurrent requests in flight there was no way to say which call the signal would land on. The v10 parameter is explicit per call. - -- **Where it is accepted:** every generated method and every hand-written wrapper around one — see the global note above. -- **Last resort:** for a request with no wrapper at all, `client.api.sendRequest(method, url, pathParams, queryParams, body, contentType, { signal })` is the layer that reads the option and puts `signal` on the axios request config. -- **Search sources** already do this internally: `BaseSearchSource` owns an `AbortController` per query and passes `SearchQueryOptions` (`{ signal }`, structurally the same type) into each source's `query()`, which forwards it as the request options. A custom `BaseSearchSource` subclass should forward the `options` argument it receives rather than arm anything itself. -- **With offline support enabled:** cancelling a call that can be queued (`createDraft`, `deleteDraft`) does **not** queue it for replay — an aborted request is treated as a definitive rejection, and any optimistic local update is rolled back. Cancelling means the operation is dropped, not deferred. - -#### `client.uploadFile` / `client.uploadImage` - -```ts -// v9 — positional args -client.uploadFile(uri, name?, contentType?, user?, axiosRequestConfig?); -client.uploadImage(uri, name?, contentType?, user?, axiosRequestConfig?); - -// v10 — request object + narrow requestOptions -client.uploadFile(request: { file?: FileUploadInput; user? }, requestOptions?); -client.uploadImage(request: { file?: FileUploadInput; upload_sizes?; user? }, requestOptions?); -``` - -`name` and `contentType` are gone as separate parameters — they moved **into** the file: - -```ts -type FileReferenceBase = { uri: string; type: string; name?: string }; -type FileLike = File | Blob; -type FileUploadInput = FileLike | FileReferenceBase | string; -``` - -A browser `File` already carries its name and MIME type, so it needs nothing else. React Native has no `Blob` with a real body behind a picker URI, so it passes a `FileReference` and RN's `FormData` reads `uri` / `name` / `type` off it — the MIME type is still required there, it just lives in a different place: - -```ts -// v9 -await client.uploadImage(localUri, 'IMG_0001.HEIC', 'image/heic'); - -// v10 -await client.uploadImage({ - file: { uri: localUri, name: 'IMG_0001.HEIC', type: 'image/heic' }, -}); -``` - -A bare URI `string` is still accepted and normalized into `{ uri }`, with the name derived from the last path segment. - -> The v10 release-candidate names **`client.uploadFile_` / `client.uploadImage_` are removed**. They existed only because the generated `uploadFile` / `uploadImage` occupied the good names while being unusable; the generated methods now work and the underscore variants are gone. - -**Routes changed.** These now hit `POST /api/v2/uploads/file` and `POST /api/v2/uploads/image` (v9: `/uploads/file`, `/uploads/image`). If you have a proxy, a CSP `connect-src`, an MSW/nock handler, or analytics keyed on the old paths, update them. - -**Response shape.** The generated response adds `metadata` (per-request rate-limit/status info) and types `file` as optional. On a 2xx the server always returns `file`. - -**No node input.** v10 dropped the `form-data` dependency for the platform's global `FormData`, and with it every node-only input: `Buffer` and readable streams are no longer accepted. Backend uploads move to `@stream-io/node-sdk` — see [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#uploads-from-node-are-gone). - -**`axiosRequestConfig` → `requestOptions`.** The last argument is now `StreamRequestOptions`, carrying `onUploadProgress` and `signal`. `timeout: 0` and unbounded `maxContentLength` / `maxBodyLength` are applied automatically for multipart; of the three, only the timeout is caller-overridable, through `requestOptions.timeout`. - -```ts -// v9 -await client.uploadFile(file, file.name, file.type, undefined, { onUploadProgress }); - -// v10 -await client.uploadFile({ file }, { onUploadProgress }); -``` - -#### `client.deleteFile` / `client.deleteImage` - -```ts -// v9 -client.deleteFile(url); -client.deleteImage(url); - -// v10 — inherited -client.deleteFile(request?: { url? }); -client.deleteImage(request?: { url? }); -``` - -#### `client.revokeTokens` - -```ts -// v9 -client.revokeTokens(before: Date | string | null); - -// v10 -client.revokeTokens(before?: Date | null); // string form dropped -``` - -#### `client.getAppSettings` - -Still present but the return type changed (`Gen_GetApplicationResponse` wrapped as `StreamResponse<...>`). It takes no arguments of its own; to pass `requestOptions`, put `undefined` in the unused `_request` slot first: `client.getAppSettings(undefined, { signal })` (see [`requestOptions`](#global-renames-applied-everywhere)). - -#### `client.partialUpdateThread` - -Unchanged signature: `partialUpdateThread(messageId, partialThreadObject)`. - -#### `client.hydrateActiveChannels` - -Renamed to `client.hydrateChannels`, with the same arguments. `client.activeChannels` itself is removed; -see the [other changes guide](./v9-to-v10-migration-guide-other.md). - -#### `client.setBaseURL` / `client.setUserAgent` / `client.getUserAgent` - -Unchanged. `setUserAgent` is still marked `@deprecated` — prefer setting `sdkIdentifier`. - -#### `client.setLocalDevice` - -**Removed**, along with the `device` client option it wrote to (see -[client construction](./v9-to-v10-migration-guide-client-construction.md#removed-options)). - -In v9 the device was serialized into the WebSocket connect payload and registered as a side -effect of connecting. The v2 connect endpoint's auth message has no `device` field, so that -side effect no longer exists and the method had nothing left to do. - -Register push devices explicitly instead. This also works at any point in the connection -lifecycle — notably on push-token refresh, which a connect-time hook never covered: - -```diff -- client.setLocalDevice({ id: pushToken, push_provider: 'firebase', push_provider_name: 'rn-fcm' }); -- await client.connectUser(user, token); -+ await client.connectUser(user, token); -+ await client.createDevice({ -+ id: pushToken, -+ push_provider: 'firebase', -+ push_provider_name: 'rn-fcm', -+ }); -``` - -This mechanism was undocumented and no Stream SDK used it — the React Native, React and Angular -SDKs all register devices explicitly in application code — so most integrations need no change. -Note that the v9 client-side `addDevice(id, provider, userId, providerName)` was itself renamed -to `createDevice({ ... })` in v10; see [`client.addDevice` / `client.getDevices` / `client.removeDevice`](#clientadddevice--clientgetdevices--clientremovedevice). - -#### `client.setOfflineDBApi` / `client.setMessageComposerSetupFunction` - -Unchanged (composer setup function is new in v10 but not a rename). - -#### `client.createChannelManager` - -Removed. The client instantiates the **new** `ChannelManager` (see [ChannelManager](#channelmanager) -below) in its constructor and exposes it as `client.channelManager`; register the channel lists on it -instead of building a manager of your own: - -```diff -- const manager = client.createChannelManager({ -- eventHandlerOverrides: { newMessageHandler: (setChannels, event) => { /* … */ } }, -- options: { lockChannelOrder: true }, -- queryChannelsOverride: (options, stateOptions) => client.queryChannelsAndHydrate(options, stateOptions), -- }); -- await manager.queryChannels({ filter_conditions: { members: { $in: [userId] } }, sort, limit: 20 }); -+ const paginator = new ChannelPaginator({ -+ client, -+ filters: { members: { $in: [userId] } }, -+ sort, -+ paginatorOptions: { pageSize: 20, lockItemOrder: true }, -+ }); -+ client.channelManager.insertPaginator({ paginator }); -+ client.channelManager.registerSubscriptions(); -+ await paginator.toTail(); -``` - -The manager is not configurable through the client options; everything is set on -`client.channelManager` itself — `insertPaginator` / `removePaginator` for the lists, -`setOwnershipResolver` for cross-list ownership, and `addEventHandler` / `setEventHandlers` / -`removeEventHandlers` for the pipelines. `ChannelManagerOptions.eventHandlers` still exists for a -manager you construct yourself; the client-owned one starts from -`ChannelManager.getDefaultHandlers()` and is customized through those methods. - -#### `client._enrichAxiosOptions` / `client._logApiRequest` / `client._logApiError` / `client._normalizeDate` / `client._setupConnection` - -Removed. Callers should not rely on these internals; `_setupConnection` was an alias for `openConnection`. - -#### `client.connect` - -Signature unchanged. - -#### `client.recoverState` / `client._sayHi` / `client._buildWSPayload` - -Removed. - -- `recoverState` — connection recovery is owned by `client.connectionRecovery` (`ConnectionRecoveryManager`), which subscribes to the connection lifecycle itself. Configure it through `options.config.client.connectionRecovery` rather than calling a method. -- `_sayHi` — the `GET /hi` warm-up request went with the [`warmUp`](./v9-to-v10-migration-guide-client-construction.md#warmup) option. -- `_buildWSPayload` — replaced by `client._buildWSAuthMessage()`. `/api/v2/connect` authenticates off the first frame the client sends rather than off the query string, so this now returns the auth frame. - -#### `client.queryUserGroups` - -```ts -// v9 — hand-rolled GET on `/usergroups` -client.queryUserGroups(options?: QueryUserGroupsOptions): Promise; -// QueryUserGroupsResponse = APIResponse & { user_groups: UserGroupResponse[] } - -// v10 — inherited from ChatApi (same underlying endpoint) -client.listUserGroups(request?: ListUserGroupsOptions): Promise>; -``` - -Mechanical rewrite: - -```ts -// v9 -const { user_groups } = await client.queryUserGroups({ team_id: 'engineering' }); - -// v10 -const { user_groups } = await client.listUserGroups({ team_id: 'engineering' }); -``` - -`UserGroupPaginator` still exists and now calls `listUserGroups` internally — consumers using the paginator do not need to change anything. Direct callers of `queryUserGroups` must rename to `listUserGroups`. The request shape is identical (`{ limit?, id_gt?, created_at_gt?, team_id? }`); the response gains a `metadata: RequestMetadata` field via the `StreamResponse<...>` wrapper. See the type-renames guide for the `QueryUserGroupsOptions` / `QueryUserGroupsResponse` type entries. - -#### `client.sync` - -```ts -// v9 -client.sync(channel_cids: string[], last_sync_at: string, options?: SyncOptions); - -// v10 — inherited (payload object) -client.sync(request: { channel_cids, last_sync_at, ... }); -``` - -#### `client.createBlockList` / `client.listBlockLists` / `client.updateBlockList` / `client.deleteBlockList` - -```ts -// v9 -client.createBlockList(blockList: BlockList); -client.listBlockLists(data?: { team? }); -client.getBlockList(name, data?: { team? }); // REMOVED -client.updateBlockList(name, data: { words; team? }); -client.deleteBlockList(name, data?: { team? }); - -// v10 — inherited (request objects) -client.createBlockList(request); -client.listBlockLists(request?); -// getBlockList: no replacement. -client.updateBlockList(pathParams: { name }, request?: { words?; team?; ... }); -client.deleteBlockList(pathParams: { name }, request?: { team? }); -``` - -#### Webhook / SNS / SQS helpers — removed outright - -An intermediate v10 release candidate moved these off the client to module-level exports on `src/signing.ts`. **The final v10 removes them entirely** — there is no webhook, SNS, or SQS surface left in `stream-chat`: - -```ts -// v9 — client methods, used client.secret implicitly -client.verifyWebhook(requestBody, xSignature); -client.verifyAndParseWebhook(rawBody, signature); -client.parseSqs(messageBody); -client.parseSns(notificationBody); - -// v10-rc — module exports (do not migrate to this; it no longer resolves) -import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stream-chat'; - -// v10 — nothing to import. Move the handler to @stream-io/node-sdk. -``` - -Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports `UserFromToken` (the client-side JWT payload decoder) and `DevToken` (the unsigned development token, also `client.devToken(userId)`; it needs no secret). The secret-signing JWT helpers (`JWTUserToken`, `JWTServerToken`) are gone. - -Webhook verification is inherently server-side work: it needs the API secret, which v10 refuses to hold. Port the handler to [`@stream-io/node-sdk`](https://github.com/GetStream/stream-node), which keeps the v9 method names on the client (`client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns`) and takes the secret from construction. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs) for the mapping table. - ---- - -## Channel - -### Constructor and lifecycle - -`getClient()`, `clean()`, `_initializeState(...)` and `create(options?)` are unchanged. `_disconnect()` is -public now, as `channel.disconnect()` (see [below](#channeldisconnect-was-_disconnect)). - -#### `channel._checkInitialized()` / the new `channel._checkHasId()` - -`_checkInitialized()` itself is unchanged — same signature, same "channel hasn't been initialized" error. What changed is **which methods call it**. It was applied inconsistently in v9: to methods that needed nothing from the query response, and not to some that did. v10 splits the two questions apart. - -- **`_checkInitialized()`** — "has the channel been queried?" Now called by `markRead()` and `markUnread()` only. Both read the channel's `read_events` setting, which arrives with the query response, so answering without it would silently use a default. -- **`_checkHasId()`** — **new**, and the weaker of the two: "does the channel have an id?" It throws `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`. A channel built from members alone (`client.channelManager.ensure({ type, data: { members } })`) has no id until it has been queried; every other channel has one immediately. - -Both are `@internal`. The consumer-visible effects: - -**No longer throw on an unqueried channel** (they never needed the query response): `sendEvent()`, `sendAction()`, `hide()`, `show()`. For `sendEvent`/`hide`/`show` the id is still required, and the generated `ChannelApi` raises the `isn't yet created` error itself — so on a members-only channel the error message changes from `hasn't been initialized` to `isn't yet created`. `sendAction()` is keyed by message id and now works on a channel that was never queried. - -**Relaxed from `_checkInitialized()` to `_checkHasId()`**: `banUser()`, `unbanUser()`, `muteStatus()`. These need an id or a cid, not the query response — so they now succeed on a channel that has an id but has not been queried yet. - -**Newly guarded with `_checkHasId()`**: `sendMessage()`, `createDraft()`, `deleteDraft()`, `mute()`, `unmute()`. These previously asserted nothing and read `this.id`/`this.cid` regardless. On a members-only channel the queueing ones wrote an offline-queue task keyed by an `undefined` channel id before failing further down, and `mute()`/`unmute()` sent the placeholder `type:!members-a,b` cid to the server. They now fail up front with the actionable error instead. Call `channel.create()` or `channel.watch()` first — that is what assigns the id. - -`messageOperations.send` on a members-only channel fails with the same error and is not queued, since a task without a channel id could never be replayed. Reactions are addressed by message id alone, so `messageOperations.addReaction` / `deleteReaction` need no channel id: they work, and queue, on a members-only channel. - -`channel._channelURL()` — **REMOVED after `10.0.0-rc.4`**, no replacement. It built a `{baseURL}/channels/{type}/{id}` string for the hand-rolled request layer that no longer exists; every request now goes through the generated API client, which resolves its own paths. Nothing in the SDK called it. If you were using it to build a URL yourself, construct it inline. - -### Removed with a rename → note - -- `channel._update(payload)` — REMOVED. Use `channel.update(request)` (inherited from `ChannelApi`). -- `channel.updateMemberPartial(updates, options?: { userId? })` — REMOVED (v9 wrapper). Use the inherited `channel.updateMemberPartial(request?)` — same name, generated shape. -- `channel.partialUpdateMember(user_id, updates)` — REMOVED. Use `channel.updateMemberPartial({ user_id, ...updates })`. -- `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })`, the generated request (`event: { type, parent_id?, custom? }`). -- `channel.getConfig()` — **REMOVED**. Use the `channel.serverConfig` **getter**, which returns the same value: this channel's server configuration (`ChannelConfigWithInfo`) — mostly type-level, but narrowed by the channel's own `config_overrides` where it has any. It is a property now, not a call — `channel.getConfig()?.uploads` becomes `channel.serverConfig?.uploads`. If you mock it in tests, note that `vi.fn()` cannot stand in for a getter. - - **Not** to be confused with `channel.config`, which is new and different: the channel's _resolved_ configuration, where a handful of server flags have been combined with what you registered through `client.config`. See the table under "Composer & attachment shape" for which fields live where. - -### Signature-changed methods - -#### `channel.sendMessage` - -```ts -// v9 -channel.sendMessage(message: Message, options?: SendMessageOptions); - -// v10 -channel.sendMessage(request: Gen_SendMessageRequest); -// { message, skip_enrich_url?, skip_push?, keep_channel_hidden?, ... } -``` - -Mechanical rewrite: - -```ts -// v9 -await channel.sendMessage({ text: 'hi' }, { skip_push: true }); - -// v10 -await channel.sendMessage({ message: { text: 'hi' }, skip_push: true }); -``` - -`channel.sendMessage` is the HTTP request only: it no longer queues for offline replay and writes no -local state. `channel.messageOperations.send` is the full send — see -[one HTTP call and one full call per message action](#removed-after-1000-rc16--one-http-call-and-one-full-call-per-message-action). - -#### `channel.sendEvent` - -```ts -// v9 -channel.sendEvent(event: Event); - -// v10 -channel.sendEvent(request: { event: EventRequest }, requestOptions?); -// EventRequest = { type: string; parent_id?: string; custom?: CustomEventData } -``` - -Event data goes under `custom` (`{ event: { type: 'friendship_request', custom: { text } } }`): top-level -fields other than `type` and `parent_id` are dropped by the API. The server stamps `created_at` and `user`. -It is the generated method, so on a channel without an id it throws synchronously (`isn't yet created`) -rather than returning a rejected promise. - -#### `channel.search` - -```ts -// v9 -channel.search(query: MessageFilters | string, options?); - -// v10 -channel.search(request?: { payload?: SearchPayload }); -``` - -#### `channel.queryMembers` - -```ts -// v9 -channel.queryMembers(filterConditions, sort?, options?); - -// v10 -channel.queryMembers(request?: { payload?: Partial }); -// payload accepts filter_conditions, sort ([{field, direction}]), limit, offset -``` - -For rewriting the `sort` value, see `v9-to-v10-migration-guide-sort.md`. - -#### `channel.sendReaction` / `channel.deleteReaction` - -```ts -// v9 -channel.sendReaction(messageID, reaction: Reaction, options?); -channel.deleteReaction(messageID, reactionType, user_id?); - -// v10 — the HTTP request (the channel methods pass straight through to the client ones) -channel.sendReaction(pathParams: { id: messageId }, request: { reaction, enforce_unique?, skip_push? }); -channel.deleteReaction(pathParams: { id: messageId, type: reactionType }, _request?: Record); -client.sendReaction(pathParams: { id: messageId }, request: { reaction, enforce_unique?, skip_push? }); -client.deleteReaction(pathParams: { id: messageId, type: reactionType }, _request?: Record); - -// v10 — the full operation (optimistic state + offline queue) -channel.messageOperations.addReaction({ messageId, reaction, options? }); -channel.messageOperations.deleteReaction({ messageId, type }); -``` - -`user_id` overrides dropped. - -#### `channel.getReactions` - -```ts -// v9 -channel.getReactions(message_id, options: { limit?; offset? }); - -// v10 — REMOVED after rc.4 (see below); use the inherited client method -client.getReactions(pathParams: { id: messageId }, request?: { limit?, offset? }); -``` - -#### `channel.getReplies` - -```ts -// v9 -channel.getReplies(parent_id, options?, sort?); - -// v10 — REMOVED after rc.4 (see below); use the inherited client method -client.getReplies(pathParams: { parent_id }, request?); -// request: { id_gt?, id_lt?, id_gte?, id_lte?, id_around?, limit?, sort?, ... } -``` - -`sort` inside the request uses `Gen_SortParamRequest[]` (`{ field, direction }`). See `v9-to-v10-migration-guide-sort.md`. - -#### `channel.update` - -```ts -// v9 -channel.update(channelData?, updateMessage?, options?); - -// v10 (override) -channel.update(request?: Gen_UpdateChannelRequest); -// { data?, message?, skip_push?, hide_history?, ... } -``` - -Mechanical rewrite: - -```ts -// v9 -await channel.update({ name: 'X' }, { text: 'renamed' }); - -// v10 -await channel.update({ data: { name: 'X' }, message: { text: 'renamed' } }); -``` - -#### `channel.updatePartial` - -Same signature: `updatePartial(update: PartialUpdateChannel)`. Internally now calls `updateChannelPartial` (inherited). - -#### `channel.delete` / `channel.truncate` - -```ts -// v9 -channel.delete(options?: { hard_delete? }); -channel.truncate(options?: TruncateOptions); - -// v10 — inherited from ChannelApi -channel.delete(request?: { hard_delete? }); -channel.truncate(request?: TruncateChannelRequest); -// TruncateChannelRequest: { message?, skip_push?, hard_delete?, truncated_at?, user_id? } -``` - -#### `channel.acceptInvite` / `channel.rejectInvite` - -```ts -// v9 -channel.acceptInvite(options?: UpdateChannelOptions); -channel.rejectInvite(options?: UpdateChannelOptions); - -// v10 — options typed as the generated request -channel.acceptInvite(options?: UpdateChannelRequest, requestOptions?); -channel.rejectInvite(options?: UpdateChannelRequest, requestOptions?); -``` - -`options` is the full generated `UpdateChannelRequest`, so it still carries `message` (the system message -sent with the accept / reject), as v9's `UpdateChannelOptions` did. The other membership helpers -(`addMembers`, `inviteMembers`, …) take `ChannelUpdateOptions` (`Omit`), -because they take `message` as a separate positional argument. - -#### `channel.mute` / `channel.unmute` - -```ts -// v9 -channel.mute(opts?: { expiration?; user_id? }); -channel.unmute(opts?: { user_id? }); - -// v10 -channel.mute(options?: Gen_MuteChannelRequest); // { channel_cids?, expiration?, user? } -channel.unmute(options?: Gen_UnmuteChannelRequest); // { channel_cids?, user? } -``` - -#### `channel.archive` / `channel.unarchive` / `channel.pin` / `channel.unpin` - -```ts -// v9 -channel.archive(opts?: { user_id? }); -channel.unarchive(opts?: { user_id? }); -channel.pin(opts?: { user_id? }); -channel.unpin(opts?: { user_id? }); - -// v10 — arguments removed; always acts on the connected user -channel.archive(); -channel.unarchive(); -channel.pin(); -channel.unpin(); -``` - -These now delegate to `channel.updateMemberPartial({ set: { archived: true } })` (etc.) internally. The first parameter is an unused `_request` placeholder, so `requestOptions` goes second: `channel.pin(undefined, { signal })`. The same applies to `channel.disableSlowMode`, `channel.clearAIIndicator` and `channel.stopAIResponse`. - -#### `channel.muteStatus` / `channel.sendAction` / `channel.keystroke` / `channel.stopTyping` - -> Timestamps in these signatures are unix nanoseconds, not `Date`s — see -> [`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md). - -```ts -// v9 -channel.muteStatus(): { muted: boolean; createdAt: number | null; expiresAt: number | null }; -channel.sendAction(messageID, formData); -channel.keystroke(parent_id?, options?: { user_id }); -channel.stopTyping(parent_id?, options?: { user_id }); - -// v10 — positional rename to `messageId` / `parentId`; keystroke / stopTyping drop `options` -channel.muteStatus(); // same return shape -channel.sendAction(messageId, formData); -channel.keystroke(parentId?, requestOptions?); -channel.stopTyping(parentId?, requestOptions?); -``` - -`keystroke` and `stopTyping` lost their `options?: { user_id }` parameter: sending typing events on behalf of -another user is server-side only. The second argument is now `requestOptions`. - -#### `channel.markRead` / `channel.markAsReadRequest` - -**Semantic swap** — read carefully: - -```ts -// v9 -channel.markRead(data?: MarkReadOptions); // batched through MessageDeliveryReporter -channel.markAsReadRequest(data?: MarkReadOptions); // direct API call - -// v10 -channel.markRead(data?: MarkReadRequest); // direct API call (override, requires _checkInitialized + read_events) -channel.markReadViaReporter(data?: MarkReadRequest); // batched through MessageDeliveryReporter — v9 markRead behavior -``` - -`MarkReadOptions` (v9) → `MarkReadRequest` (v10 generated type). See the type-renames guide. - -Migration rule: if you want to preserve the v9 batching behavior, rename `markRead` → `markReadViaReporter`. If your v9 code was calling `markAsReadRequest`, rename it to `markRead`. - -**Return types changed too**, and the batched path is the one that bites: - -```ts -// v9 — both fields required -channel.markRead(...) : Promise // { duration, event } - -// v10 -channel.markRead(...) : Promise> // event? is optional -channel.markReadViaReporter(...) : Promise> | null> -``` - -On the reporter path **every** field is optional, `duration` included, because a caller-supplied `markReadRequest` handler is allowed to return a partial response. So v9 code like `const { event } = await channel.markRead(); event.cid` needs narrowing after the rename: - -```ts -const response = await channel.markReadViaReporter(); -if (response?.event) { - // … -} -``` - -`client.messageDeliveryReporter.markRead(collection, options?)` has the same return type — `MessageDeliveryReporter` is part of the public surface. - -`EventAPIResponse` itself no longer exists; see [the shape-change note](./v9-to-v10-migration-guide-type-renames.md#eventapiresponse--one-type-per-endpoint) for why `MarkReadResponseEvent` is not interchangeable with a WS `Event`. - -#### `channel.markUnread` - -```ts -// v9 -channel.markUnread(data: MarkUnreadOptions); - -// v10 — inherited/override; data is optional -channel.markUnread(data?: MarkUnreadRequest); -``` - -#### `channel.stopWatching` - -```ts -// v9 -channel.stopWatching(); - -// v10 — override -channel.stopWatching(request?: Gen_ChannelStopWatchingRequest); -``` - -A channel that is no longer watched stays in the channel store, as it stayed in -`client.activeChannels` in v9. `client.channelManager.releaseUnusedChannels()` releases it if -nothing still uses it (a channel list, `activate()`, a thread, a cached composer). - -#### `channel.activate` (new) - -`channel.activate()` is new in v10: it sets `channel.active` and returns the function that ends the -activation; each call gets its own, and calling it twice does nothing. On a disposed channel it logs a warning, -leaves `channel.active` unchanged and returns a release that does nothing; get a live instance with -`client.channelManager.ensure()`. - -```ts -const release = channel.activate(); -release(); -``` - -An active channel also stays in the channel store. After `release()` unsets `active`, it is kept -only while it is watched or otherwise used. - -#### `channel.disconnect` (was `_disconnect`) - -`channel._disconnect()` is renamed to `channel.disconnect()` and is public: call it to say your app is -done with a channel instance. It stops the instance for good: it stops listening to the client's -configuration, releases its loaded messages, and sets `pendingDisposal` to `true` and `watchStatus` -to `NotWatching`. It sends no request (call `channel.stopWatching()` first to end a watch on the -server), and calling it again does nothing. - -It doesn't remove the channel from the channel store, as something may still show it; the next -`client.channelManager.releaseUnusedChannels()` does. Until then the SDK treats the channel as not -stored: lists don't take it, an event for its cid gets a fresh instance through -`client.channelManager.ensure()`, and a query that finds it under its cid takes its place. - -```ts -// v9 -channel._disconnect(); - -// v10 -channel.disconnect(); -``` - -#### `channel.disconnected` → `channel.pendingDisposal` - -v9's `channel.disconnected` field is removed. Whether a channel was disconnected is now -`channel.pendingDisposal`, which is read-only: only `channel.disconnect()` sets it, and nothing sets it -back, as a disconnected instance is never revived. Once it is `true`, `getClient()` throws, `query()` -and `watch()` reject before the request is sent, and `activate()` does nothing; get a live instance -with `client.channelManager.ensure({ type, id })`. - -It lives in `channel.state`, so it can also be followed reactively: - -```ts -// v9 -if (channel.disconnected) { - // … -} - -// v10 -if (channel.pendingDisposal) { - // … -} - -channel.state.subscribeWithSelector( - ({ pendingDisposal }) => ({ pendingDisposal }), - ({ pendingDisposal }) => { - // … - }, -); -``` - -#### `channel.ensureWatched` (new) - -`channel.ensureWatched(options?)` watches the channel unless it is already watched, and joins a watch -already in flight with equal options instead of sending a second request. It resolves with the -channel to use from then on: the instance that superseded this one (`channel.supersededBy`), if any. -Use it where several places may ask for the same channel at once, such as opening a search result. -`channel.watch()` and `channel.query()` still always send their request. - -```ts -const channel = await client.channelManager.ensure({ type, id }).ensureWatched(); -``` - -#### `channel.hide` / `channel.show` - -```ts -// v9 -channel.hide(userId: string | null = null, clearHistory = false); -channel.show(userId: string | null = null); - -// v10 — override; positional args replaced with a request payload -channel.hide(request?: Gen_HideChannelRequest); // { clear_history?, user_id?, ... } -channel.show(request?: Gen_ShowChannelRequest); // { user_id?, ... } -``` - -Mechanical rewrite: - -```ts -// v9 -await channel.hide(null, true); -// v10 -await channel.hide({ clear_history: true }); -``` - -#### `channel.banUser` / `channel.unbanUser` / `channel.shadowBan` / `channel.removeShadowBan` - -Same signatures, positional rename only: - -```ts -channel.banUser(targetUserId, options); -channel.unbanUser(targetUserId, options?); -channel.shadowBan(targetUserId, options); -channel.removeShadowBan(targetUserId); -``` - -#### `channel.vote` / `channel.removeVote` - -```ts -// v9 -channel.vote(messageId, pollId, vote: PollVoteData); -channel.removeVote(messageId, pollId, voteId); - -// v10 -channel.vote(pathParams: { message_id, poll_id }, request?: { vote: { option_id?, answer_text? } }); -channel.removeVote(pathParams: { message_id, poll_id, vote_id }, _request?: Record); -``` - -#### `channel.createDraft` / `channel._createDraft` / `channel.deleteDraft` / `channel._deleteDraft` / `channel.getDraft` - -```ts -// v9 -channel.createDraft(message: DraftMessagePayload); -channel.deleteDraft(options?: { parent_id? }); -channel.getDraft(options?: { parent_id? }); - -// v10 — inherited/override with generated shape -channel.createDraft(request: Gen_CreateDraftRequest); // { message: MessageRequest } -channel.deleteDraft(request?: { parent_id? }); -channel.getDraft(request?: { parent_id? }); // inherited unchanged -channel._createDraft(request); // same shape -channel._deleteDraft(request?); // same shape -``` - -Mechanical rewrite for `createDraft`: - -```ts -// v9 -await channel.createDraft({ text: 'draft' }); - -// v10 -await channel.createDraft({ message: { text: 'draft' } }); -``` - -#### `channel.on` / `channel.off` - -```ts -// v9 — signatures -channel.on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void }; -channel.on(callback: EventHandler): { unsubscribe: () => void }; -channel.off(eventType: EventTypes, callback: EventHandler): void; -channel.off(callback: EventHandler): void; - -// v10 -channel.on(eventType: T, callback: EventHandler): { unsubscribe: () => void }; -channel.on(callback: EventHandler): { unsubscribe: () => void }; -channel.off(eventType: T, callback: EventHandler): void; -channel.off(callback: EventHandler): void; -``` - -Callers that imported `EventTypes` need to switch to `EventType` (`EventType = Event['type'] | 'all'`). The `CustomEventTypes` interface is still exported — augment it to add custom event-type keys, same as v9. - -#### `channel.sendFile` / `channel.sendImage` - -**REMOVED** — replaced by `channel.uploadFile` / `channel.uploadImage`, which take the same request-object shape as their client counterparts: - -```ts -// v9 -await channel.sendFile(uri, name?, contentType?, user?, axiosRequestConfig?); -await channel.sendImage(uri, name?, contentType?, user?, axiosRequestConfig?); - -// v10 -await channel.uploadFile({ file, user? }, requestOptions?); -await channel.uploadImage({ file, upload_sizes?, user? }, requestOptions?); -``` - -These are aliases for the generated `channel.uploadChannelFile` / `channel.uploadChannelImage`; either name works. Everything in [`client.uploadFile` / `client.uploadImage`](#clientuploadfile--clientuploadimage) above applies here too — the `FileUploadInput` shape, the `requestOptions` replacing `axiosRequestConfig`, no node inputs, and the added `metadata` on the response. - -**Routes changed** from `/channels/{type}/{id}/file` and `/image` to `POST /api/v2/chat/channels/{type}/{id}/file` and `/image`. The file response additionally carries `moderation_action`. - -#### `channel.deleteFile` / `channel.deleteImage` / `channel.getPinnedMessages` / `channel.getMessagesById` / `channel.lastRead` / `channel.countUnread` / `channel.countUnreadMentions` / `channel.lastMessage` / `channel.watch` / `channel.query` - -Signatures unchanged. - -#### `channel._handleChannelEvent` / `channel._callChannelListeners` - -Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | CustomEventTypeUnion`), but the parameter type name did not. - ---- - -## ChannelState - -Mostly unchanged. The relevant tweaks: - -- `formatMessage`: v9 accepted `MessageResponse | MessageResponseBase | LocalMessage`. v10 accepts only `MessageResponse | LocalMessage` (`MessageResponseBase` no longer exists). -- `deleteUserMessages(...)` internally: `deletedAt` propagation now passes `undefined` where v9 defaulted to `null` — check for `null` guards in downstream code. -- `removeReaction(reaction, message?)` return shape unchanged. -- All other methods (`addMessageSorted`, `addMessagesSorted`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, `addReaction`, `_addReactionToState`, `_addOwnReactionToMessage`, `_removeOwnReactionFromMessage`, `_removeReactionFromState`, `_updateQuotedMessageReferences`, `removeQuotedMessageReferences`, `_updateMessage`, `setIsUpToDate`, `_addToMessageList`, `removeMessage`, `removeMessageFromArray`, `updateUserMessages`, `filterErrorMessages`, `clean`, `clearMessages`, `initMessages`, `loadMessageIntoState`, `findMessage`, `findMessageByTimestamp`, `pruneOldest`) — signatures unchanged. - ---- - -## ChannelManager - -`ChannelManager` was **rewritten**, not renamed: v10's class (`src/ChannelManager.ts`) is the former -`ChannelPaginatorsOrchestrator`, and the v9 `src/channel_manager.ts` is deleted. One manager now holds N -channel lists — each a `ChannelPaginator` — instead of one hand-sorted array. - -The manager is no longer created by the integrator: the client builds it in its constructor and owns it -for its whole lifetime as `client.channelManager`. Lists are registered on it with -`insertPaginator({ paginator, index? })` and detached again with `removePaginator(paginatorOrId)` — a -removed paginator leaves `manager.state.paginators` (so UIs rendering one list per paginator drop its -list), stops receiving WS updates and gets its ownership-filtering wrapper removed, while keeping the -items it had loaded. For batches use `setPaginators(paginators)` / `clearPaginators()`, the primitives -those two build on: one state update for the whole set instead of one per paginator (and none at all -when the set is unchanged). The manager still only handles events once something calls the ref-counted -`registerSubscriptions()`. - -Because the manager now outlives any UI, `client.disconnectUser()` clears the lists' contents through -`resetPaginatorStates()` — every registered paginator goes back to "never queried", since its channels -belong to the user being disconnected — but the registrations survive, so the next connection reuses -the lists the integrator configured. A UI that renders a list should therefore query whenever its -paginator reports no loaded page, not only on mount. - -### State - -| v9 (`manager.state`) | v10 | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `channels: Channel[]` | `paginator.state.items` | -| `pagination.hasNext` | `paginator.state.hasMoreTail` (`hasNext` getter still exists, deprecated) | -| `pagination.isLoading` | `paginator.state.isLoading` | -| `pagination.isLoadingNext` | removed — one `isLoading` flag; combine with `hasMoreTail` if needed | -| `pagination.options` | the paginator's `filters` / `sort` / `options` / `pageSize` | -| `initialized` | removed — `paginator.state.items === undefined` means "never queried"; `paginator.isInitialized` for an imperative check | -| `error` | `paginator.state.lastQueryError` | -| — | `manager.state.paginators` (the lists this manager drives) | - -### Methods - -| v9 | v10 | -| -------------------------------------------- | ---------------------------------------------------------------------- | -| `manager.queryChannels(request, stateOpts?)` | `paginator.toTail({ reset: 'yes' })` (or `paginator.reload()`) | -| `manager.loadNext()` | `paginator.toTail()` / `paginator.toTailDebounced()` | -| `manager.setChannels(valueOrFactory)` | `paginator.setItems({ valueOrFactory })` | -| `manager.setQueryChannelsRequest(fn)` | `paginatorOptions.doRequest` | -| `manager.setOptions(...)` | per-paginator options (see below) | -| `manager.setEventHandlerOverrides(...)` | `manager.setEventHandlers` / `addEventHandler` / `removeEventHandlers` | -| `manager.registerSubscriptions()` | unchanged, but now returns an unsubscribe and is ref-counted | - -### Options - -| v9 option | v10 | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lockChannelOrder` | `paginatorOptions.lockItemOrder` | -| `abortInFlightQuery` | removed — a query is never started while one is in flight; `paginator.cancelScheduledQuery()` cancels a debounced one | -| `allowNotLoadedChannelPromotionForEvent` | removed — insert the exported `ignoreEventsForUnknownChannels` handler at the head of the pipeline (`index: 0`) for the event types you want to ignore:
`manager.addEventHandler({ eventType: 'message.new', handle: ignoreEventsForUnknownChannels, id: 'ignore-unknown', index: 0 })`
It stops the chain for any event whose channel is not in the channel store (`client.channelManager.get(cid)`). | - -### Event handlers - -The 10 named overrides (`newMessageHandler`, `channelDeletedHandler`, …), each receiving -`(setChannels, event)`, are replaced by an `EventHandlerPipeline` per event type. Handlers receive -`{ event, ctx: { channelManager } }` and can stop the chain by returning `{ action: 'stop' }`: - -```diff -- const manager = client.createChannelManager({ -- eventHandlerOverrides: { -- newMessageHandler: (setChannels, event) => setChannels((channels) => reorder(channels, event)), -- }, -- }); -+ manager.setEventHandlers({ -+ eventType: 'message.new', -+ handlers: [{ handle: ({ event, ctx: { channelManager } }) => { /* … */ }, id: 'my-handler' }], -+ }); -``` - -Default-handler ids are `ChannelManager:default-handler:` — pass them to `removeEventHandlers` -or to `position` when inserting. Unlike v9, `channel.updated` and `channel.truncated` are **not** no-ops by -default: they re-insert the channel into the lists, so it moves to its new sort position, and a list -whose filter it no longer matches drops it. `channel.hidden` re-evaluates the filters instead of removing -the channel outright (so a list filtering `{ hidden: true }` keeps it), and -`notification.channel_mutes_updated` re-routes every loaded channel so `muted` filters settle on their own. - -### Types - -Three separate situations — the first is the dangerous one, because that code still compiles. - -**1. Same name, different shape.** These keep their v9 names but now describe the new class: - -| Type | v9 | v10 | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `ChannelManagerState` | `{ channels: Channel[]; initialized: boolean; pagination: ChannelManagerPagination; error: Error \| undefined }` | `{ paginators: ChannelPaginator[] }` — the list state moved to `paginator.state` (see the table above) | -| `ChannelManagerOptions` | `{ abortInFlightQuery?; allowNotLoadedChannelPromotionForEvent?; lockChannelOrder? }` | `{ client: StreamChat; paginators?: ChannelPaginator[]; eventHandlers?: ChannelManagerEventHandlers; ownershipResolver? }` | - -**2. Replaced by a differently-named type.** The mechanism changed, so this is a rewrite rather than a -find-and-replace: - -| v9 | v10 | -| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `ChannelManagerEventHandlerOverrides` (handler-name → override fn) | `ChannelManagerEventHandlers` (event type → ordered `LabeledEventHandler[]` pipeline) | -| `EventHandlerType` / `EventHandlerOverrideType` / `GenericEventHandlerType` | `EventHandlerPipelineHandler` — receives `{ event, ctx: { channelManager } }` | -| `ChannelSetterParameterType` / `ChannelSetterType` | `SetPaginatorItemsParams['valueOrFactory']` (passed to `paginator.setItems`) | -| `QueryChannelsRequestType` / `QueryChannelsRequestOutput` | `PaginatorOptions['doRequest']` | - -**3. Removed with no replacement:** `ChannelManagerPagination`, `ChannelManagerEventTypes`, -`ChannelManagerEventHandlerNames`, `ExecuteChannelsQueryPayload`, `channelManagerEventToHandlerMapping`, -`DEFAULT_CHANNEL_MANAGER_OPTIONS`, `DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS`. - -### Coming from a v10 release candidate? - -`10.0.0-rc.1` and earlier shipped this class as **`ChannelPaginatorsOrchestrator`**. If you are upgrading -from an RC rather than from v9, the change is a pure rename: - -| RC | v10 final | -| ----------------------------------------------------- | ----------------------------------- | -| `ChannelPaginatorsOrchestrator` | `ChannelManager` | -| `ChannelPaginatorsOrchestratorState` | `ChannelManagerState` | -| `ChannelPaginatorsOrchestratorOptions` | `ChannelManagerOptions` | -| `ChannelPaginatorsOrchestratorEventHandlers` | `ChannelManagerEventHandlers` | -| `ChannelPaginatorsOrchestratorEventHandlerContext` | `ChannelManagerEventHandlerContext` | -| handler ctx key `{ orchestrator }` | `{ channelManager }` | -| ids `ChannelPaginatorsOrchestrator:default-handler:*` | `ChannelManager:default-handler:*` | -| module `stream-chat` (unchanged) | `stream-chat` (unchanged) | - -No deprecated alias is exported — the old names are gone. For the RC deltas outside `ChannelManager`, see [Coming from a v10 release candidate — removed type aliases](#coming-from-a-v10-release-candidate--removed-type-aliases) at the end of this guide. - -### Removed helpers (were exported from `stream-chat`) - -`promoteChannel`, `findLastPinnedChannelIndex`, `findPinnedAtSortOrder`, `shouldConsiderPinnedChannels`, -`shouldConsiderArchivedChannels`, `extractSortValue`, `isChannelPinned`, `isChannelArchived`, -`getAndWatchChannel` and the `PromoteChannelParams` type existed only to serve the v9 manager's -hand-written ordering. Replacements: - -| Removed | Use instead | -| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `promoteChannel`, `findLastPinnedChannelIndex`, `findPinnedAtSortOrder`, `shouldConsiderPinnedChannels`, `extractSortValue` | nothing — stop reordering the list yourself; see [If you called `promoteChannel`](#if-you-called-promotechannel) | -| `isChannelPinned`, `isChannelArchived`, `shouldConsiderArchivedChannels` | `paginator.matchesFilter(channel)` with `{ pinned: true }` / `{ archived: true }` filters | -| `getAndWatchChannel` | `client.channelManager.ensure({ type, id }).watch()`, or `.ensureWatched()`, which skips a channel already watched and joins a watch in flight for the same cid. | - -#### If you called `promoteChannel` - -In v9 the list was a plain array you reordered by hand: `promoteChannel` moved a channel to the top, and -the pinned-channel helpers existed to stop it from jumping above the pinned block. In v10 **you do not -reorder the list at all** — position is derived from `sort` by a comparator the paginator compiles, and -every `ingestItem` re-inserts the channel where that comparator says it belongs. - -So the replacement is a sort, declared once: - -```ts -const paginator = new ChannelPaginator({ - client, - filters: { members: { $in: [userId] } }, - // pinned channels form a contiguous block at the top, most recently pinned first; - // everything else follows, most recent activity first - sort: [ - { field: 'pinned_at', direction: -1 }, - { field: 'last_message_at', direction: -1 }, - ], -}); -``` - -Why that puts pins on top: an unpinned channel has no `pinned_at`, and the comparator sorts -missing values to the tail **regardless of direction** — so a leading `pinned_at` splits the list into -"pinned, then the rest", and each group is ordered by the next sort term. Note this only holds while -`pinned_at` is the _first_ term; with anything ahead of it, that term dominates and the pins are no longer a -block. - -Three consequences for code that used to call `promoteChannel` on a WS event: - -- **New message in a loaded channel** — nothing to do. `channel.messagePaginator.lastMessageAt` advances - when the message is ingested, so the next `ingestItem` (which the manager's default handlers perform) - places the channel correctly. -- **Surfacing a channel that is not in the list** — a search result, a freshly created DM — use - `manager.ingestChannel(channel)`. It routes the channel into every list whose filter it matches (honoring - the ownership resolver) and removes it from those it no longer matches. -- **Surfacing a channel whose sort key did not change** — use `paginator.boost(channel.cid, { ttlMs })`. - A boost outranks the sort for that one item until it expires. It is deliberately not pin-aware: if a list - should keep its pins on top, do not boost, and let the sort decide. - -### Paginator method rename - -`buildFilters()` is gone from the whole paginator stack, because it meant "request filters" on -`ChannelPaginator` and "matching filters" on the base/message paginators: - -- **`ChannelPaginator.buildQueryFilters()`** — filters sent to the server (and used as the offline-db cache - key). -- **`buildMatchFilters()`** (on `BasePaginator`, `ChannelPaginator`, `MessageIntervalPaginator`, - `PinnedMessagePaginator`) — filters items are matched against locally, consumed by `matchesFilter()`. - -`ChannelQueryShape` is now the `queryChannels` request itself (`filter_conditions`, `sort`, `limit`, …) -plus `stateOptions`, rather than a `{ filters, sort, options }` wrapper — relevant if you implement -`paginatorOptions.doRequest`. - ---- - -## Moderation - -`Moderation` now `extends ModerationApi`. All complex admin methods were removed; the kept methods have positional-param renames only. - -### Removed — no replacement in this SDK - -- `moderation.muteUser(targetID, options?: ModerationMuteOptions)` — REMOVED (previously used `POST /api/v2/moderation/mute` directly). Use the inherited `moderation.mute(request: MuteRequest)` from `ModerationApi` (accepts `{ target_ids, timeout?, ... }`). -- `moderation.getUserModerationReport` — REMOVED. -- `moderation.queryReviewQueue` — the class-level implementation is gone but `queryReviewQueue` is inherited from `ModerationApi` (request-object shape). -- `moderation.upsertConfig` / `getConfig` / `deleteConfig` / `queryConfigs` — the class-level implementations are gone; `upsertConfig` / `getConfig` / `deleteConfig` / `queryModerationConfigs` (note the last is renamed) are inherited from `ModerationApi`. -- `moderation.submitAction` — inherited from `ModerationApi`. -- `moderation.check` / `moderation.checkUserProfile` — REMOVED. -- `moderation.addCustomFlags` / `moderation.addCustomMessageFlags` — REMOVED. -- `moderation.upsertModerationRule` / `queryModerationRules` / `getModerationRule` / `deleteModerationRule` — REMOVED. - -### Signature-changed - -#### `moderation.flagUser` / `moderation.flagMessage` - -```ts -// v9 -moderation.flagUser(flaggedUserID, reason, options?); -moderation.flagMessage(messageID, reason, options?); - -// v10 — positional rename only; body still built internally -moderation.flagUser(flaggedUserId, reason, options?); -moderation.flagMessage(messageId, reason, options?); -``` - -#### `moderation.flag` - -```ts -// v9 -moderation.flag(entityType, entityId, entityCreatorID, reason, options?); // custom method - -// v10 — inherited -moderation.flag(request: FlagRequest); -// { entity_type, entity_id, entity_creator_id, reason, ...options } -``` - -#### `moderation.unmuteUser` - -```ts -// v9 -moderation.unmuteUser(targetID, options: { user_id? }); - -// v10 — user_id override dropped (server-side only) -moderation.unmuteUser(targetId); -``` - ---- - -## StableWSConnection - -Parameter renames only (`wsID` → `wsId`) and the internal `_log(msg, extra, level)` method has been removed. Everything else (`connect`, `disconnect`, `_connect`, `_reconnect`, `_waitForHealthy`, `_buildUrl`, `onopen` / `onmessage` / `onclose` / `onerror`, `onlineStatusChanged`, `_setHealth`, `_errorFromWSEvent`, `_destroyCurrentWSConnection`, `_setupConnectionPromise`, `scheduleNextPing`, `scheduleConnectionCheck`) keeps the v9 signature. - -```ts -// v9 -_log(msg, extra?, level?); // REMOVED - -// v10 — use the module-scoped logger instead -import { chatLoggerSystem } from './logger'; -const logger = chatLoggerSystem.getLogger('connection'); -logger.info(msg, extra); -``` - ---- - -## Property renames on `StreamChat` (referenced by other classes) - -| v9 | v10 | Availability | -| -------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `client.userID` | `client.userId` | Getter `userID` deprecated. Assignment (`client.userID = …`) no longer supported. | -| `client.clientID` | `client.clientId` | Getter+setter `clientID` deprecated. | -| `client.secret` | — | REMOVED. | -| `client.logger` | — | REMOVED — see logging note below. | -| `client.appSettingsPromise` type | `Promise>` | Wrapper type changed. | -| `client._user` type | `ClientUser` | Type alias replacing v9's `OwnUserResponse \| UserResponse`. | -| `client.api` | new | Public getter that returns the internal `ApiClient` for `doAxiosRequest` / `errorFromResponse`. | - -`_setToken`, `_setUser`, `_setupConnection` are still present but `_setUser` now takes `TokenManagerMinimalUser`; `_setupConnection` is REMOVED. - ---- - -## Coming from a v10 release candidate — removed type aliases - -Skip this section if you are upgrading from v9; everything here is already covered above. It exists for integrations pinned to the `rc` dist-tag, because `10.0.0-rc.1` / `rc.2` still exported five aliases that v10 final deletes outright. **No back-compat alias remains for any of them.** - -| Removed after `rc.2` | Replacement | Detail | -| --------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `EventAPIResponse` | one generated type per endpoint | [shape-change note](./v9-to-v10-migration-guide-type-renames.md#eventapiresponse--one-type-per-endpoint) | -| `APIErrorResponse` | `APIError` | [shape-change note](./v9-to-v10-migration-guide-type-renames.md#apierrorresponse--apierror) — `StatusCode` → `status_code` | -| `DraftMessagePayload` | `MessageRequest` | [shape-change note](./v9-to-v10-migration-guide-type-renames.md#draftmessagepayload--messagerequest) | -| `PartializeKeys` | none | type utility; inline the built-in equivalent — see `v9-to-v10-migration-guide-other.md` | -| `QueryRemindersOptions` | `QueryRemindersRequest` | see `v9-to-v10-migration-guide-other.md` | -| `QueryReactionsRequestWithId` (after `rc.16`) | none — the call is split | `queryReactions` / `queryReactionsAndHydrate` take `({ id }, request?: QueryReactionsRequest)`; there is no single type for both | -| `CreateReminderOptions` (after `rc.16`) | none — the call is split | the reminder methods take `({ message_id }, request?: CreateReminderRequest)`; derive from `Parameters` | - -`QueryReactionsRequestWithId` and `CreateReminderOptions` went later than the rest, after `rc.16`, when [path parameters became their own argument](#global-renames-applied-everywhere): they named the merged `{ id, ...request }` / `{ message_id, ...request }` objects, which no longer exist. - -**Queued offline tasks written by `rc.16` or earlier do not replay.** A pending task persists the call's argument tuple, and the `update-message`, `delete-message`, `send-reaction` and `delete-reaction` tuples changed shape with the split: `[{ id, message }]` is now `[{ id }, { message }]`, and so on. Replaying an old tuple throws (`"" is not a path parameter of `). If you maintain your own `AbstractOfflineDB` and ran an earlier rc, clear the pending-task table on upgrade (or bump your schema version). `stream-chat-react-native`'s offline database already recreates its tables on the v10 schema bump. - -`QueryRemindersOptions` is the one that moved twice: it was the full `Pager & { filter?, sort? }` shape in `rc.1`, a back-compat alias to `QueryRemindersRequest` in `rc.2`, and deleted in final. `ReminderPaginator`'s second generic parameter moved with it — `PaginatorOptions` becomes `PaginatorOptions`. - -### Custom mark-read request handlers - -`ChannelInstanceConfig.requestHandlers.markReadRequest` and its `ThreadInstanceConfig` counterpart are v10-only surface (there is nothing equivalent in v9), but their return type changed after `rc.2`: - -```ts -// rc.1 / rc.2 -type CustomMarkReadRequestFn = (params) => Promise; - -// v10 final -type CustomMarkReadRequestFn = ( - params, -) => Promise> | null>; -``` - -The `Partial<>` is deliberate: it lets a handler return just `{ event }` without fabricating a `duration`, and it means a handler can delegate straight to the SDK — `markReadRequest: ({ channel, options }) => channel.markRead(options)` — which the `rc` signature rejected because `MarkReadResponse.event` is optional. `CustomThreadMarkReadRequestFn` takes `{ thread, options? }` instead of `{ channel, options? }` and additionally permits a `void` return. - -## Removed after `10.0.0-rc.16` — one HTTP call and one full call per message action - -Each message action now has exactly two entry points: the HTTP request alone, and the full operation -(optimistic state, offline queue, reconciling the response, rollback on failure). - -| Action | HTTP request only | Full operation | -| --------------- | ----------------------- | ----------------------------------------------------------- | -| send | `channel.sendMessage` | `channel.messageOperations.send` (or `thread.` for a reply) | -| retry a send | — | `messageOperations.retry` | -| edit | `client.updateMessage` | `messageOperations.update` | -| delete | `client.deleteMessage` | `messageOperations.delete` | -| add reaction | `client.sendReaction` | `messageOperations.addReaction` | -| remove reaction | `client.deleteReaction` | `messageOperations.deleteReaction` | - -| Removed | Use instead | -| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `sendMessageWithLocalUpdate(p)` on `Channel` / `Thread` | `messageOperations.send(p)` | -| `retrySendMessageWithLocalUpdate(p)` | `messageOperations.retry(p)` | -| `updateMessageWithLocalUpdate(p)` | `messageOperations.update(p)` | -| `deleteMessageWithLocalUpdate(p)` | `messageOperations.delete(p)` | -| `addReactionWithLocalUpdate(p)` / `deleteReactionWithLocalUpdate(p)` | `messageOperations.addReaction(p)` / `deleteReaction(p)` | -| `channel._sendMessage`, `client._updateMessage`, `client._deleteMessage`, `channel._sendReaction`, `channel._deleteReaction` | the HTTP method above | - -Parameters are unchanged. - -`channel.sendReaction` / `channel.deleteReaction` were removed in this release too, and came back -after `10.0.0-rc.20` as pass-throughs to `client.sendReaction` / `client.deleteReaction`: the HTTP -request only, with no local state and no offline queueing. - -**Behaviour change for direct callers:** `channel.sendMessage`, `client.updateMessage` and -`client.deleteMessage` no longer queue for offline replay; they used to. Call the full operation -where that matters. Drafts are unchanged — `channel.createDraft` / `deleteDraft` still queue. - -**A thread reply now stops typing.** `thread.messageOperations.send` sends `typing.stop` for the -thread (with its `parent_id`) before the reply goes out, as a channel send already did for the -channel. It follows the thread composer's `publishTypingEvents` setting. Before, a reply left the -thread's typing indicator running until it timed out. - -**No per-call request override.** `messageOperations.send` / `retry` / `update` / `delete` take only -their params object; the optional second `requestFn` argument is gone. It won over whatever the host -SDK registered, the same hazard that removed the per-call `*RequestFn` fields after `10.0.0-rc.12`. -Register a `requestHandlers` entry instead. - -**A request handler can delegate to the default.** Each `requestHandlers` entry now receives the -default request — the HTTP call through the offline queue — as a second argument. A handler that -called `channel.sendMessage` inside to get queueing should call it instead: - -```ts -client.config.set({ - channel: { - requestHandlers: { - sendMessageRequest: async (params, defaultRequest) => { - await auditLog.record('message.send', { id: params.localMessage.id }); - return await defaultRequest(params); - }, - }, - }, -}); -``` - -## Removed after `10.0.0-rc.4` — redundant guards and pass-throughs - -Skip this section if you are upgrading from v9 only in the sense that these methods still existed in `10.0.0-rc.4`; if you are on v9 they are simply gone in v10 final. Each was either a method whose entire body forwarded to another one, or a runtime check that restated something the type system already enforces. - -### `StreamChat` - -- **`client.queryBannedUsers(...)` — REMOVED.** Its whole body was `return await super.queryBannedUsers(...args)`; it was not even marked `override`. The inherited `ChatApi.queryBannedUsers` is unchanged, so **no call-site change is needed** — you were already reaching this implementation. - -- **`client.partialUpdateThread(messageId, partialThreadObject, requestOptions?)` — REMOVED.** Use the inherited `client.updateThreadPartial({ message_id }, { set, unset }, requestOptions?)`. - - ```ts - // before - await client.partialUpdateThread(messageId, { set: { custom_field: 1 } }); - - // after - await client.updateThreadPartial( - { message_id: messageId }, - { set: { custom_field: 1 } }, - ); - ``` - - Two behaviour changes come with it: - 1. **The reserved-field guard is gone.** It threw synchronously for keys in a hardcoded list, and that list had drifted from `ThreadResponse` in both directions: it rejected `id`, `type`, `user` and `participants` (none of which are fields on `ThreadResponse`, so legitimate custom fields with those names were blocked) while letting through `parent_message_id`, `channel_cid`, `created_by_user_id`, `thread_participants`, `reply_count`, `participant_count`, `active_participant_count` and `deleted_at`, all of which _are_ server-owned. The server rejects what it owns; the client no longer guesses. **A rejected write now surfaces as a rejected promise rather than a synchronous `throw`** — adjust any `try`/`catch` that wrapped the call expecting the latter. - 2. **The empty-`messageId` check is gone.** `message_id` is a required path parameter of `updateThreadPartial`, so this is a compile error instead. - - The `PartialThreadUpdate` type is removed with the method — `UpdateThreadPartialRequest` is its replacement. - -### `Channel` - -- **`channel.search(...)` — REMOVED.** Use `client.search(...)`. The removed method forwarded straight to `client.search()` **without scoping the query to the channel**, so despite the name it searched every channel the user could see. If you were relying on that behaviour, `client.search()` is the same call. If you assumed it was channel-scoped, add the scope to your filter explicitly — that is a real bug fix in your integration, not a regression. - -- **`channel.getReplies(...)` — REMOVED.** Use `client.getReplies({ parent_id }, request?)`. Pure forward; the removed method's own comment noted it did nothing with the result. - -- **`channel.getReactions(...)` — REMOVED.** Use `client.getReactions({ id }, request?)`. Pure forward. - -- **`channel.sendAction(messageId, formData, requestOptions?)` — KEPT**, but its `if (!messageId) throw Error('Message ID is missing')` guard is gone. `runMessageAction` requires `id: string`, so an empty id is a compile error; a runtime empty string reaches the server and is rejected there. - -## Removed after `10.0.0-rc.4` — server-side role assignment - -**`channel.assignRoles(roles, message?, options?, requestOptions?)` — REMOVED.** Assigning -channel roles is a server-side operation; use -[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node). The `RoleName` type went -with it, along with the rest of the v1 permission surface — see -[the v1 permission system](./v9-to-v10-migration-guide-type-renames.md#the-v1-permission-system--removed). - -## Removed after `10.0.0-rc.12` — per-call message request overrides - -Three optional fields are gone from the `*WithLocalUpdate` parameter objects on both `Channel` and -`Thread`: - -| Removed field | On | -| ------------------------ | ------------------------------------ | -| `sendMessageRequestFn` | `SendMessageWithStateUpdateParams` | -| `updateMessageRequestFn` | `UpdateMessageWithStateUpdateParams` | -| `deleteMessageRequestFn` | `DeleteMessageWithStateUpdateParams` | - -They were a second way to do what `ChannelConfig.requestHandlers` already does, and the narrower of -the two: both layers receive the **identical** argument (`normalizeOutgoingMessage` has already run, -so a handler sees `parent_id` on a thread reply and can branch), both are resolved per call, and the -per-call layer had no `retrySendMessageRequestFn` at all — so it could not distinguish a retry from a -send, which the config layer can. - -It was also a hazard. Resolution order was `requestFn ?? handlers. ?? default`, so passing one -**won over** whatever the host SDK had registered. In React Native that meant silently skipping the -registered `sendMessageRequest` handler — and with it the attachment-upload step that runs inside it. - -**Migrate to the instance handler seam**, which is unchanged: - -```ts -// before -await channel.updateMessageWithLocalUpdate({ - localMessage, - updateMessageRequestFn: async ({ localMessage, options }) => - myUpdate(localMessage, options), -}); - -// after — registered once, per channel instance -client.config.set({ - channel: { - requestHandlers: { - updateMessageRequest: async ({ localMessage, options }) => - myUpdate(localMessage, options), - }, - }, -}); - -await channel.messageOperations.update({ localMessage }); -``` - -A handler needing to vary by message branches on what it is given — `localMessage.id`, custom fields, -or `parent_id` to tell a thread reply from a channel message. See -[`docs/instance-configuration.md`](./docs/instance-configuration.md) for the full seam. - -The handler types themselves were renamed in the same release — see -[one vocabulary for message-operation types](#removed-after-1000-rc12--one-vocabulary-for-message-operation-types) -below. `requestHandlers` entries keep the same shape either way; only the type's name changed. - -There is no replacement for per-call **transport** options (an `AbortSignal`, an upload-progress -callback). Neither layer ever carried them: a handler's `options` is the wire payload -(`SendMessageOptions`), not `StreamRequestOptions`. If you need one, open an issue — the fix is to -thread `requestOptions` through the params so the registered handler keeps running, not to reinstate -a competing override. - -## Removed after `10.0.0-rc.12` — one vocabulary for message-operation types - -Seven exported type aliases are gone. Each was a hand-written duplicate of a generic that already -existed in the same module, derived from `MessageOperationSpec`. The replacements are **exactly** -the same types — verified by mutual-assignability assertions, not by eye — so this is a rename, not -a shape change. - -| Removed | Replacement | -| --------------------------------------- | ------------------------------------------- | -| `SendMessageWithStateUpdateParams` | `OperationParams<'send'>` | -| `UpdateMessageWithStateUpdateParams` | `OperationParams<'update'>` | -| `DeleteMessageWithStateUpdateParams` | `OperationParams<'delete'>` | -| `RetrySendMessageWithLocalUpdateParams` | `Omit, 'message'>` | -| `CustomSendMessageRequestFn` | `OperationRequestFn<'send'>` | -| `CustomUpdateMessageRequestFn` | `OperationRequestFn<'update'>` | -| `CustomDeleteMessageRequestFn` | `OperationRequestFn<'delete'>` | - -`OperationKind`, `OperationParams`, `OperationRequestFn`, `OperationResponse` and -`MessageOperationSpec` are now exported from the package root. - -`ChannelConfig.requestHandlers` is retyped accordingly. Handler bodies need **no change** — the -parameter and return types are identical: - -```ts -client.config.set({ - channel: { - requestHandlers: { - // was CustomSendMessageRequestFn, now OperationRequestFn<'send'> - sendMessageRequest: async ({ localMessage, message, options }) => { - const { message: sent } = await sendViaProxy(message, options); - return { message: sent }; - }, - }, - }, -}); -``` - -Only code that referenced one of the seven **by name** needs editing. Code that derives its types -positionally — `NonNullable['sendMessageRequest']`, or -`Parameters[0]` — is unaffected. - -`CustomMarkReadRequestFn` and `CustomThreadMarkReadRequestFn` are **not** affected: mark-read is not -a message operation and has no `OperationKind`. - -## Removed after `10.0.0-rc.8` — moderator promotion moves server-side - -**`channel.addModerators(members, message?, options?, requestOptions?)` and -`channel.demoteModerators(members, message?, options?, requestOptions?)` — REMOVED.** The -clientside API no longer publishes `add_moderators` / `demote_moderators` on the channel-update -payload. Promoting or demoting an existing member is a server-side operation; use -[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node). A member can still be given a -role at insert time, clientside, via -`channel.addMembers([{ user_id: 'thierry', channel_role: 'channel_moderator' }])`. This is the same -rationale as the `channel.assignRoles` removal above. - -## Removed after `10.0.0-rc.4` — moderation moves to the generated V2 API - -The `/moderation/*` methods on `StreamChat` were the last endpoints in the SDK that built -their own request instead of calling the generated client. Every one that has a generated -equivalent now delegates to it, and the ones no SDK used are gone. - -### Migrated to `client.moderation.*` - -These four are **removed** from `StreamChat`. The replacement is the generated V2 method, -reachable through `client.moderation`: - -| Removed | Replacement | -| ----------------------------------------------- | ------------------------------------------------------------------- | -| `client.banUser(targetUserId, options?)` | `client.moderation.ban({ target_user_id, ...options })` | -| `client.muteUser(targetId, options?)` | `client.moderation.mute({ target_ids: [targetId], ...options })` | -| `client.unmuteUser(targetId)` | `client.moderation.unmute({ target_ids: [targetId] })` | -| `client.flagMessage(targetMessageId, options?)` | `client.moderation.flagMessage(targetMessageId, reason?, options?)` | - -Note the shape change on mute/unmute: V2 takes `target_ids` as an **array**, so a single -id becomes `[targetId]`. - -Three consequences worth checking in your integration: - -- **Return types change.** These now resolve to `StreamResponse<…>` of the generated - response, so they also carry `metadata`. Two lose fields: `mute` (singular) is no longer - on the mute response — use `mutes` — and the flag response is now - `FlagItemResponse { duration, item_id }` rather than a nested `flag` object. If you only - `await` these calls you are unaffected. -- **Two ban options are gone.** V2 `BanRequest` has no `delete_reactions` and no - `ban_from_future_channels`. There is no replacement for either. -- **`BanUserOptions` and `MuteUserOptions`.** `BanUserOptions` is now - `Omit` — derived, so it tracks the spec. `MuteUserOptions` - is removed; V2 mute accepts only `timeout`. - -`MuteUserResponse`, `FlagMessageResponse`, `FlagUserResponse` and `UnmuteUserResponse` are -removed with them. - -### Removed with no replacement — unused by the React and React Native SDKs - -- **`client.flagUser(...)`** — use `client.moderation.flagUser(targetUserId, reason?)`. -- **`client.unflagMessage(...)`, `client.unflagUser(...)`** — V2 has no unflag endpoint. -- **`client.unblockMessage(...)`** — no V2 equivalent. -- **`client.shadowBan(targetUserId, options?)`** → `client.moderation.ban({ target_user_id, shadow: true, ...options })`. -- **`client.removeShadowBan(targetUserId, options?)`** → `client.unbanUser(targetUserId, { shadow: true, ...options })`. -- **`channel.shadowBan(...)`** → `channel.banUser(targetUserId, { shadow: true })`. -- **`channel.removeShadowBan(...)`** → `channel.unbanUser(targetUserId, { shadow: true })`. - -Shadow banning itself is unaffected — `shadow` is still a documented option on both ban -paths. Only the convenience wrappers are gone. - -### Still hand-written: `unbanUser` - -`client.unbanUser(targetUserId, options?)` and `channel.unbanUser(...)` keep their v1 -implementation, because **the generated layer has no unban endpoint**: `ChatApi` exposes -only the reads (`queryBannedUsers`, `queryFutureChannelBans`) and V2 moderation has `ban` -without a matching `unban`. Ban and unban have to target the same system, so both stay -reachable until the spec publishes one. `APIResponse` and `UnBanUserOptions` survive for -the same reason. - -Note the asymmetry this creates while it lasts: `channel.banUser` scopes through V2's -`channel_cid`, while `channel.unbanUser` still scopes through v1's `type` + `id`. Both -still take a channel-scoped ban and clear it; only the wire format differs. - -## Logging (applies to every class) - -`options.logger` (function) and `client.logger(level, msg, extra?)` are gone. To capture logs in v10, configure the shared `chatLoggerSystem` before constructing the client: - -```ts -import { chatLoggerSystem, type Sink } from 'stream-chat'; - -const sink: Sink = (level, message, ...rest) => { - /* forward to your logger; message is prefixed with `[](): ` */ -}; - -chatLoggerSystem.configureLoggers({ - default: { level: 'info', sink }, -}); -``` - -Class-internal call sites use scoped loggers such as `chatLoggerSystem.getLogger('client')`, `'channel'`, `'connection'`, `'api-client'`, `'thread'`, `'thread-manager'`, `'upload-manager'`, `'offline-db'`, `'state-store'`, `'token-manager'`, `'message-composer'`, `'text-composer'`, `'utils'`, `'channel-manager'`. See `v9-to-v10-migration-guide-logging.md` for the full logging system reference. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md deleted file mode 100644 index 9cbb2d8b77..0000000000 --- a/v9-to-v10-migration-guide-other.md +++ /dev/null @@ -1,1549 +0,0 @@ -# v9 → v10 Migration Guide — Everything Else - -> Scope: this guide catches breaking changes **not** covered by the topic-specific guides: -> -> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) -> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) -> - `v9-to-v10-migration-guide-methods.md` (per-method signatures on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, `StableWSConnection`) -> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) -> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal, dropped Node-only deps) -> - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) -> - `v9-to-v10-migration-guide-i18n.md` (notification identity, poll-composer field errors, the `@stream-io/i18n` package) -> - `v9-to-v10-migration-guide-dates.md` (server-sent dates as unix-nanosecond numbers) -> -> Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. -> -> **Start with the dates guide.** It is the change with the widest blast radius — every response and -> event type — and the only one whose main failure modes produce no compile error. - -## TL;DR - -- **`client.defaultWSTimeout` is gone**, and so are the `WebSocketImpl`, `wsUrlParams`, - `wsConnection` and `enableWSFallback` client options. Everything the WebSocket reads now lives in one - configuration slice — `client.config.set({ client: { wsConnection: { … } } })` — as - `connectTimeoutMs`, `enableWSFallback`, `pingIntervalMs`, `healthCheckGracePeriodMs`, - `webSocketImpl`, `urlParams` and `connection`. Unlike the fields and - options they replace, these survive a reconnect. -- **Server-sent dates are unix-nanosecond `number`s** on every response and event type — not `Date` - objects and not ISO strings, while outgoing **request** date fields are still `Date`. `new Date(ns)` - is out of range, date libraries read a bare number as milliseconds, and `0` is a legitimate - timestamp so `if (!created_at)` is wrong. Full treatment, including the `t('timestamp.X', …)` call - sites the compiler does not guard, in - [`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md). -- **`engines.node` is now `>=22.18.0`** (was `>=18`). Node 22.18 is the release that unflagged - TypeScript type stripping, which the package's own build scripts need — `prepare` runs the build, so a - git-ref install has to be able to execute them. A registry install never builds, so if you are pinned - to an older Node the runtime code itself is unlikely to care; `engines` is advisory and most package - managers warn rather than fail. But 18 and 20 are no longer tested. See the note below on what this - means if you deploy the WebSocket client on Node 18 or 20. -- **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. -- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with two exports left, `UserFromToken` and `DevToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. -- **`connection.changed` is removed.** Connectivity is published as two reactive stores, `client.wsConnection.state` for this client's socket (or its long-poll, once `enableWSFallback` has switched to it) and `client.networkConnection.state` for the device's network. A handler for the event simply stops firing, with no compile error in plain JavaScript, and a "connection lost" banner has to hold a drop itself where the event used to. The socket's own `isHealthy` is unchanged; what moved is where you read it. See below. -- **Watching waits instead of degrading.** A request that watches a channel or subscribes to presence is held until the WebSocket handshake produces the connection id the server keys that subscription by, rather than being sent without one and silently registering nothing. It throws only when no socket is open and none is being opened. An explicit `watch: false` is never held. See below. -- **The WebSocket connect endpoint moved to `/api/v2/connect`.** The hello event is now `connection.ok` rather than `health.check`. The long-poll fallback works as in v9, against `/api/v2/longpoll`, with `enableWSFallback` moved into the `wsConnection` configuration and `client.defaultWSTimeoutWithFallback` gone. -- `Event` (type name) is kept, but its shape changed: `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`, where each key augmented into `CustomEventTypes` becomes an event object whose data sits under `custom` (typed by `CustomEventData`) instead of at the root. `EventPayload<''>` narrows to a specific event. -- `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. -- Filter payloads now carry **per-endpoint operator constraints** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. -- `ChannelState.membership` initializes to `undefined` (was `{}`); `ChannelState.typing` values are now `EventPayload<'typing.start' | 'typing.stop'>` (were `Event`); read receipts merged with the generated `ReadStateResponse`. -- Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`. `LocationComposer` state holds the **request** shape, so its `end_at` is a `Date` (was ISO string) — while an `end_at` read off a message is a unix-nanosecond number. `validLocation` returns `StaticLocationPreview | null` (was `SharedLocation | null`). See the [dates guide](./v9-to-v10-migration-guide-dates.md). -- Composer configuration gained required `polls`, `attachments.enabled` and `attachments.customCdn` (all defaulted — only full-literal annotations break). The channel type's `uploads` / `polls` flags now resolve **into** that configuration, so read `composer.config` rather than `channel.serverConfig`. **Silent behaviour change:** a custom `doUploadRequest` no longer waives the `upload-file` capability — set `attachments.customCdn: true` if you upload to storage Stream does not host. -- **`client.threads.threadsById` is removed.** It was built from the thread list alone. Use - `client.threads.get(id)` for the live thread (listed, or open anywhere) and - `client.threads.paginator.getItem(id)` for list membership. An open thread no longer has to be added to - the list to stay live: `thread.activate()` registers it. See below. -- **The thread list moved to `client.threads.paginator`**, a `ThreadPaginator`. `state.threads`, - `state.pagination` and `state.ready` are gone from `ThreadManagerState` (as is the - `ThreadManagerPagination` type); read `paginator.state` (`items`, `isLoading`, `hasMoreTail`, - `lastQueryError`) instead. `unseenThreadIds`, - `isThreadOrderStale` and `unreadThreadCount` stay on `client.threads.state`. - `client.threads.loadNextPage()` and `client.threads.queryThreads()` are removed: use - `paginator.toTail()` and `client.queryThreadsAndHydrate()`. See below. -- **`client.activeChannels` is removed.** Channels live in a channel store owned by - `client.channelManager`: read one with `client.channelManager.get(cid)`, all of them with - `client.channelManager.values()`. As in v9, a channel stays stored until it is deleted, the user - is removed from it, or the user logs out. To free memory, call - `client.channelManager.releaseUnusedChannels()`: it drops every channel your app disconnected - (`channel.disconnect()`, formerly `_disconnect()`), and disposes of and drops every channel that is - neither watched nor used (not in a channel list, not opened with `activate()`, not a thread's or a - cached composer's). See below for what disposing of a channel does. - `client.hydrateActiveChannels()` is renamed to `client.hydrateChannels()`. `client.channel()` is - still available, as a shorthand for `client.channelManager.ensure({ type, id, data })`; its `data` - is now typed `ChannelInput`, so custom fields go under `custom` and members are `{ user_id }` - objects. See below. -- `Role` type renamed to `RoleName`. -- Assorted small tightenings: `TokenManager.setTokenOrProvider` user param narrowed, `revokeTokens(before)` no longer accepts `string`. - ---- - -## Node version floor - -`engines.node` moves from `>=18` to `>=22.18.0`. - -The driver is the build, not the runtime: the package's build scripts are `.mts`, executed by `node` -with no loader, and unflagged type stripping landed in **22.18.0** (24.3.0 on the 24 line, 23.6.0 on -the 23 line). Because `prepare` runs `yarn build`, anyone installing from a git ref has to be able to -run them. Note that 22.12 — the `require(esm)` milestone — is _not_ sufficient for this; the two are -often conflated. - -**If you install from the npm registry, nothing in the shipped runtime is known to need 22.18.** You get -a prebuilt `dist/` and never run the build. `engines` is advisory, and npm/yarn warn rather than fail by -default. Treat the bump as "18 and 20 are no longer tested" rather than "the code will not run". - -**One place this needs care:** [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md) -documents running the WebSocket client on Node 18/20 by injecting a `WebSocketImpl` (Node only gained a -global `WebSocket` in 22). That guidance still works mechanically, and is still the right answer if you -are stuck on an older runtime — but it is now below the declared floor, so it is unsupported rather than -supported. If you are on Node 18 or 20 and rely on that path, plan the upgrade to 22.18+, where no -`WebSocketImpl` is needed at all. - -## Public export surface - -`src/index.ts` barrel changes: - -| Removed export barrel | Reason | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `export * from './events'` | `src/events.ts` deleted along with `EVENT_MAP`. Event-type set is now derived from the generated event decoders, no longer a hand-rolled map. | -| `export * from './base64'` | `src/base64.ts` deleted along with the `base64-js` dependency. `encodeBase64` / `decodeBase64` are gone; `UserFromToken` now decodes through the global `atob`. Take base64 helpers from a package of your own if you were importing these. | -| `export * from './store'` | `src/store.ts` deleted. `StateStore`, `MergedStateStore`, `isPatch` and their types now come from **`@stream-io/state-store`**, which this package depends on rather than vendoring. See below. | - -| Emptied module (barrel still present, no named exports) | Reason | -| ------------------------------------------------------- | ------------------------------------------------------------- | -| `./campaign` | `Campaign` was a server-side admin surface; module is a stub. | -| `./segment` | Same as `campaign`. | -| `./channel_batch_updater` | `ChannelBatchUpdater` was a server-side admin surface. | - -| Added export barrel | What it exposes | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `export * from './logger'` | `chatLoggerSystem`, `LogLevel`, `LogLevelEnum`, `Sink`, `ScopedLogger`, `ChatLoggerScope`, `ConfigureLoggersOptions`. See logging guide. | - -Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP, encodeBase64, decodeBase64 } from 'stream-chat'` will fail to resolve. Delete those imports; there is no drop-in replacement in this SDK. `CustomEventTypes` is still exported from `stream-chat` and its interface is unchanged — augment it to declare custom event-type keys the same way as in v9. - -### `./signing` is down to one export - -The barrel is still there, but it holds only `UserFromToken` and `DevToken` (also exposed as `client.devToken(userId)`). Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). - -`UserFromToken` itself changed implementation: it decodes the JWT payload with the global `atob` instead of the removed `base64-js` helpers. It runs on the `connectUser` path, so older React Native / Hermes targets — Hermes only gained `atob` / `btoa` around React Native 0.74 — must install a base64 polyfill before the first `connectUser`, or connecting throws `ReferenceError: atob is not defined`. Verify with `typeof atob` on the target rather than by version number; browsers, Node 16+, Bun, and Deno all have it natively. - ---- - -### `StateStore` moved to `@stream-io/state-store` - -`stream-chat` used to carry its own copy of `StateStore` and re-export it. It is now a dependency, -and the class is **no longer part of this package's public API**. - -```diff -- import { StateStore } from 'stream-chat'; -+ import { StateStore } from '@stream-io/state-store'; -``` - -Add the package to your own dependencies: - -```sh -npm install @stream-io/state-store -``` - -`MergedStateStore`, `isPatch`, and the `Handler` / `Patch` / `Preprocessor` / `RemovePreprocessor` / -`Unsubscribe` / `ValueOrPatch` types move with it. The runtime behaviour is unchanged — the store -instances on `channel.messagePaginator.state`, `client.state`, composer state and so on are the same -objects; only where you import the class from changes. - -**Why it matters beyond tidiness:** TypeScript compares classes with `protected` members -_nominally_, so two copies of `StateStore` are not assignable to one another even when byte-identical. -A single shared declaration is what lets `stream-chat`, `@stream-io/i18n` and the UI SDKs pass stores -across package boundaries at all. - -**That makes one resolved copy a hard requirement, not a preference.** `stream-chat`, -`@stream-io/i18n`, `stream-chat-react` and `stream-chat-react-native` each declare -`@stream-io/state-store` at `^1.1.6`, which dedupes to one install. Declare the same range yourself. If -you ever end up with two — a nested copy from a pinned or bumped range — the symptom is a compile -error rather than a runtime one, typically `TS2345` when you pass `client.state` into a hook the SDK -exported: - -``` -Argument of type 'StateStore' is not assignable to parameter of type 'StateStore'. - Types have separate declarations of a private property 'handlers'. -``` - -Two identical-looking types in one message is the tell. Check with: - -```bash -find . -maxdepth 5 -path '*node_modules/@stream-io/state-store' -type d -``` - -## Removed feature modules / subsystems - -Beyond the individual server-side methods listed in the methods guide, entire subsystems are gone. If your app used one of these, the client-side wrapper is not coming back — move to `@stream-io/node-sdk`: - -| Subsystem | v9 shape | v10 status | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Campaigns** | `client.campaign`, `queryCampaigns`, `createCampaign`, `startCampaign`, `stopCampaign`, `updateCampaign`, `deleteCampaign`, `getCampaign` | removed | -| **Segments** | `client.segment`, `createSegment`, `createUserSegment`, `createChannelSegment`, `updateSegment`, `getSegment`, `deleteSegment`, `querySegments`, `segmentTargetExists`, `addSegmentTargets`, `removeSegmentTargets`, `querySegmentTargets` | removed | -| **`ChannelBatchUpdater`** | `client.channelBatchUpdater`, `client.updateChannelsBatch(...)` | removed | -| **Retention policies** | `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns` | removed | -| **Team usage stats** | `queryTeamUsageStats` | removed | -| **User groups** | `createUserGroup`, `getUserGroup`, `searchUserGroups`, `updateUserGroup`, `deleteUserGroup`, `addUserGroupMembers`, `removeUserGroupMembers` | mutations removed. Read path is now `listUserGroups` (v9 `queryUserGroups` renamed — see methods guide); `UserGroupPaginator` remains and delegates to `listUserGroups` internally. | -| **Predefined filters (client)** | `deletePredefinedFilter`, `PredefinedFilterSort(Param)` types, `mapPredefinedFilterSortToChannelSort` helper | removed. Read paths remain via the generated API. | -| **Reminder client batch API** | `client.createReminder`, `client.updateReminder`, `client.deleteReminder`, `client.queryReminders` (v9 `QueryRemindersOptions` shape) | hand-rolled `createReminder`/`updateReminder`/`deleteReminder` removed from `StreamChat`. `queryReminders` is still available via `ChatApi` inheritance but takes the generated `QueryRemindersRequest` shape. `ReminderManager` remains — use it. See "Reminders" below for shape change. | -| **Push provider admin** | `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences` | removed | -| **Roles / Permissions admin** | `createRole`, `listRoles`, `deleteRole`, `getPermission`, `createPermission`, `updatePermission`, `deletePermission`, `listPermissions` | removed. `searchRoles` remains, inherited from the generated API. | -| **Channel-types admin** | `createChannelType`, `getChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes` | removed | -| **Commands admin** | `createCommand`, `getCommand`, `updateCommand`, `deleteCommand`, `listCommands` | removed | -| **Imports / Exports** | `_createImport`, `_createImportURL`, `_getImport`, `_listImports`, `exportChannel`, `exportChannels`, `exportUsers`, `getExportChannelStatus`, `getTask` | removed | -| **App-settings mutations** | `updateAppSettings`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `translate`, `translateMessage`, `getHookEvents` | removed. `getAppSettings` remains. | -| **User admin** | `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser(s)`, `deactivateUser(s)`, `exportUser`, `revokeUserToken`, `revokeUsersToken`, `sendUserCustomEvent`, `deleteUsers` | removed | -| **Flag admin** | `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `updateFlags` | removed. `queryMessageFlags` remains via `ChatApi` inheritance (generated request shape). User/message flagging by the connected user remains via `client.flagMessage` / `client.flagUser`. | -| **Webhook / SQS / SNS helpers** | `client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns` (used `client.secret` implicitly) | removed. An intermediate v10 rc moved them to module exports on `./signing`; the final v10 drops them entirely, along with `verifySignature` / `CheckSignature` / `InvalidWebhookError`. Port the handler to `@stream-io/node-sdk`. | -| **Misc.** | `commitMessage`, `undeleteMessage`, `getSharedLocations`, `updateLocation`, `getUnreadCountBatch`, `getBlockList`, `enrichURL`, `_normalizeDate`, `validateServerSideAuth`, `_setupConnection`, `_enrichAxiosOptions`, `_logApiRequest`, `_logApiError` | removed | - -If your call site was gated on `client._isUsingServerAuth()` (which is also removed), delete the branch — it was only ever true on the server-side path. - ---- - -## Event system - -`src/events.ts` — the single-source `EVENT_MAP` — is **deleted**. Event types are now driven by the generated event decoders (`src/gen/model-decoders/event-decoder-mapping.ts`) plus a small local overlay. The public `Event` type is kept but its definition changed: - -### Union types you'll see - -```ts -// Wire events (over WS) — every generated event type. -type WSEvent = /* union of all generated Gen_*Event shapes */; - -// SDK-only events not received over the wire. -type LocalEvent = ( - | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) - | ({ type: 'live_location_sharing.stopped' } & { live_location?: SharedLocationResponseData }) - | ({ type: 'channels.queried' } & { - queriedChannels: { - channels: ChannelStateResponseFields[]; - isLatestMessageSet: boolean; - }; - }) - | { type: 'connection.recovered' } - | ({ type: 'offline_reactions.queried' } & { offlineReactions: ReactionResponse[] }) - | ({ type: 'capabilities.changed' } & { - cid: string; - own_capabilities: ChannelOwnCapability[]; - }) - | ({ type: 'message.read_locally' } & { - channel_type: string; - cid: string; - created_at: number; - channel_id?: string; - last_read_message_id?: string; - team?: string; - user?: UserResponse; - }) -) & { received_at?: number }; - -// Public alias — same name as in v9, wider shape. `WSEvent` includes the hello event of the v2 -// connect endpoint, `connection.ok` (`ConnectedEvent`; see "WebSocket transport" below). -export type Event = - | (WSEvent & { received_at?: number }) - | LocalEvent - | CustomEventTypeUnion; - -// One event object per key augmented into `CustomEventTypes`; `never` when none are declared. -type CustomEventTypeUnion = { - [K in keyof CustomEventTypes]: { type: K } & Omit & { - received_at?: TimestampNS; - }; -}[keyof CustomEventTypes]; -export type EventType = Event['type'] | 'all'; -export type EventHandler = (event: Extract) => void; - -export type EventPayload = Extract< - Event, - { type: T } ->; -``` - -### v9 → v10 replacement table - -| v9 | v10 | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to is now `WSEvent \| LocalEvent \| CustomEventTypeUnion`. | -| `Event` as a callback argument type | `Event` still works; prefer `EventPayload<'message.new'>` for narrowed events. | -| `import { EventTypes } from 'stream-chat'` | `import { EventType } from 'stream-chat'` — singular; same shape (`Event['type'] \| 'all'`) | -| `import { EVENT_MAP } from 'stream-chat'` | removed — no runtime table. Match on `event.type` directly. | -| `interface CustomEventTypes { my_custom: 'my_custom'; ... }` (module augmentation) | unchanged — augment `CustomEventTypes` exactly the same way. The interface is still exported from `stream-chat`. | -| Hand-rolled `ReminderEvent`, `PollEvent`, `PollUpdatedEvent`, `PollVoteCastedEvent`, `PollClosedEvent`, `PollAnswerCastedEvent`, `VoteChangedEvent`, `VoteCastedEvent`, `VoteRemovedEvent`, `AnswerCastedEvent`, and similar aliases | replaced by `EventPayload<'reminder.created' \| 'reminder.updated' \| ...>` etc. `ReminderManager.ReminderEvent` now aliases to `EventPayload<`reminder.${string}` \| 'notification.reminder_due'>`. | - -### Narrowing a listener - -```ts -// v9 -client.on('message.new', (event: Event) => { - event.message; // any-typed -}); - -// v10 -client.on('message.new', (event) => { - event.message; // narrowed via EventPayload<'message.new'> -}); - -// Or explicit: -import type { EventPayload } from 'stream-chat'; -const handler = (event: EventPayload<'message.new'>) => event.message; -``` - -### Custom event types (module augmentation) - -The `CustomEventTypes` module-augmentation contract is unchanged from v9: - -```ts -declare module 'stream-chat' { - interface CustomEventTypes { - my_app_custom: 'my_app_custom'; - } -} -``` - -Because the v10 generic on `channel.on` accepts any `string`, unknown listener keys still type-check without augmentation, but the event payload will not be narrowed. Augmenting `CustomEventTypes` adds the custom key to `Event['type']`, which flows through `EventType` and `EventHandler` narrowing. - -What changed is where a custom event's data lives. v9's `Event` spread `CustomEventData` over its root; -in v10 a custom event is the generated `CustomEvent` shape (`type`, `created_at`, `custom`), and its data -is under `custom`, typed by `CustomEventData`. That is also where the API reads it when you send one — -top-level fields other than `type` and `parent_id` are dropped: - -```ts -declare module 'stream-chat' { - interface CustomEventTypes { - friendship_request: true; - } - interface CustomEventData { - text?: string; - } -} - -// v9 -await channel.sendEvent({ type: 'friendship_request', text: 'hi' }); -client.on('friendship_request', (event) => event.text); - -// v10 -await channel.sendEvent({ - event: { type: 'friendship_request', custom: { text: 'hi' } }, -}); -client.on('friendship_request', (event) => event.custom.text); -``` - -> **Larger topic** — the event system rewrite (removed hand-rolled event types across `poll`, `poll_manager`, `thread`, `reminders`, live-location, and the client itself; the shift from a hand-maintained `EVENT_MAP` to generated decoders) touches enough call sites that it may warrant a dedicated guide. Flag me if you want one written. - ---- - -## `connection.changed` is gone; connectivity is two stores - -`connection.changed` reported this client's WebSocket health, but nothing in its name or payload said -so — the field was called `online`. That is why UI built on it ends up telling users they have no -network when in fact the server closed the socket, the token expired, or a health check timed out on a -perfectly good connection. - -v10 publishes the two facts as reactive state instead, one store each, and removes the event. - -```ts -client.wsConnection.state.getLatestValue(); -// { isHealthy, lastHealthyAt, lastUnhealthyAt } - -client.networkConnection.state.getLatestValue(); -// { isOnline, lastOnlineAt, lastOfflineAt } -``` - -`client.wsConnection` is this client's WebSocket — or, once `enableWSFallback` has switched to -long-polling, the long-poll, which writes the same store, just as v9's long-poll dispatched -`connection.changed`. `client.networkConnection` is the **device's** network status, reported by a -platform reporter you install — a separate fact that routinely disagrees with the socket in both -directions. Neither is derived from the other. - -### What breaks - -**The event.** `client.on('connection.changed', …)` no longer fires, and in plain JavaScript nothing -says so. Subscribe to whichever store you meant: - -```diff -- client.on('connection.changed', (event) => { -- if (!event.online) showReconnecting(); -- }); -+ client.wsConnection.state.subscribeWithSelector( -+ ({ isOnline }) => ({ isOnline }), -+ ({ isOnline }) => { -+ if (!isOnline) showReconnecting(); -+ }, -+ ); -``` - -Grep every `connection.changed` handler and decide, per handler, which connection it actually meant. -If the answer is "the device's network", that is what `client.networkConnection` is for. - -**A banner needs its own delay.** The event held a drop for five seconds and dropped it entirely if the -socket returned inside that window, which is what stopped a brief flap from strobing a "connection -lost" banner. The stores publish every transition as it happens, so a banner has to do that itself: -wait `client.wsConnection.config.offlineNotificationDisplayDelayMs`, cancel the wait if the socket -comes back, and show a recovery without delay. - -**`isHealthy` keeps its name, and moves.** It was a field on the socket, which is replaced on every -connect; it is now a getter on `client.wsConnection`, which is not, and it is backed by the store. The -call site is unchanged and keeps working: - -```ts -if (client.wsConnection?.isHealthy) { … } -``` - -The device's network is the one that reads `isOnline`, on `client.networkConnection` — a different -fact with a different name, so the two can no longer be confused for each other. - -**`connection.recovered` survives, and carries nothing.** It is dispatched after channel lists, active -channels and active threads have been reloaded following a socket reconnect. It never reported the -device's network, so it has no discriminator to narrow on. - ---- - -## `client.defaultWSTimeout` is now `wsConnection` configuration - -`client.defaultWSTimeout` was a public mutable field with no option behind it, no validation and no -documentation. Assigning to it worked, which is the only reason the connect timeout was tunable at -all. It has been removed in favour of a configuration slice. - -```ts -// v9 -client.defaultWSTimeout = 5000; - -// v10 -client.config.set({ client: { wsConnection: { connectTimeoutMs: 5000 } } }); -// …or, imperatively: -client.wsConnection.updateConfig({ connectTimeoutMs: 5000 }); -``` - -Two sibling values move with it, because they were public mutable fields on `StableWSConnection` for -the same accidental reason: - -| Field | Default | Was | -| -------------------------- | ------- | -------------------------------------------------------------------- | -| `connectTimeoutMs` | `15000` | `client.defaultWSTimeout` | -| `pingIntervalMs` | `25000` | `StableWSConnection.pingInterval` | -| `healthCheckGracePeriodMs` | `10000` | `StableWSConnection.connectionCheckTimeout`, which held ping + grace | - -Read them back through `client.wsConnection.config`, or subscribe to `client.wsConnection.configState`. - -**These were reachable before, but not durable.** The two socket fields were reset to their defaults by -every reconnect, because each connect built a fresh `StableWSConnection` whose constructor reassigned -them. If you were setting them, they were silently reverting on you. Through the config they persist. - -**`connectionCheckTimeout` is now expressed as a grace period, not as a deadline.** The old field held the whole -window, which allowed a connection check _shorter_ than the ping interval — a socket that declares itself dead -on a perfectly healthy connection, then does it again after every reconnect. The connection check now fires at -`pingIntervalMs + healthCheckGracePeriodMs`, so changing the ping interval moves it too, and the grace period is -floored at 1s. - -**`pingIntervalMs` can only be lowered.** 25s is both the default and the maximum, and a higher value is -clamped back to it with a warning rather than rejected. A slower ping risks the connection being closed -for idleness — by Stream, or by load balancers and proxies in between — which surfaces as an apparently -random disconnect rather than as a setting. The useful direction is downward: shortening the interval -makes a dead socket noticed sooner, because the connection check moves with it. The floor is 1s, so a -few-millisecond interval cannot flood the server with health checks. - -### `client._getConnectionID()` removed - -It was a one-line indirection over the connection ID, which now has a home of its own: - -```ts -// v9 -const id = client._getConnectionID(); - -// v10 -const id = client.connectionIdManager.connectionId; -``` - -You rarely need it. The request layer awaits one for you on anything that watches a channel or -subscribes to presence, so those requests wait out a reconnect rather than going out keyed to a -connection the server has closed. Read `client.wsConnection.isHealthy` for "connected now". - -### A request that watches waits for a connection id instead of degrading - -`channel.watch()` and `queryChannels()` used to send `watch: false` when the client had no connection -ID, resolving with unwatched data. Now any request that watches a channel or subscribes to presence is -held by the request layer until an id exists, and always goes out watching. - -**What this fixes.** The old guard read the connection ID, which was assigned on a successful connect -and never cleared — so during a reconnect it did _not_ downgrade. It sent `watch: true` against a dead -connection and the channel then recorded `watchStatus = Watching` when nothing was watching. Where it -_did_ downgrade, you got unwatched data that a second, watched query had to follow. The server is the -other half of it: without an id it answers `200` and registers nothing, so the channel never receives -an event. - -**What changes for you.** Opening a channel while the socket is down waits for the reconnect rather -than returning unwatched data immediately. On a working network with a dead socket that is a delay -where there used to be content. - -It waits rather than fails, so a reconnect is ridden out. It throws only when no amount of waiting -would help — no socket open and none being opened, which means no user connected, or a socket closed -deliberately with `client.closeConnection()`, the mobile backgrounding path: - -``` -No connection id is available: there is no WebSocket connection, and none is being established. -``` - -A throwing `watch()` is not a dead end either: the channel stays unwatched, offline support renders it -from the local database, and `ConnectionRecoveryManager` reloads it on the next reconnect — its -recovery is filtered on whether a channel is _active_, never on `watchStatus`. - -An explicit `watch: false` is still honoured, and a request that asks for neither a watch nor presence -is never held at all, which is what makes it usable offline. An abort signal reaches the wait as well -as the request, so an abandoned query does not sit on it. - -`channel.stopWatching()` carries no flag but is held the same way, since it tells the server which -connection should stop watching: it always waits for the connection id — rejecting, like a watching -request, when there is no connection and none is being established — and honours the abort signal. - -### `connection.recovered` is withheld when the socket drops mid-recovery - -`ConnectionRecoveryManager` reloads active channels and threads with `Promise.allSettled`, so one -failure never stops the others. The consequence was that a drop during a recovery could fail **every** -reload while `connection.recovered` was still dispatched — telling consumers that what is on screen is -fresh when none of it had been refreshed. - -It is now withheld in that case. If you key work off `connection.recovered` — the UI SDKs' -mark-read-on-catch-up does — you will correctly stop seeing it for recoveries that recovered nothing. - -Withholding is safe rather than stranding. A drop guarantees a later reconnect, and a reconnect that -lands while a recovery is still running is not lost either: the pass in flight runs once more against -the new connection rather than being dropped or stacked alongside a second one. - -The socket is the only connection consulted. Every reload is a request over it, so it is the one whose -failure invalidates them, and a device that loses its network takes the socket with it. Reading the -device's network as well would have meant different behaviour on hosts that cannot report one. - -### `WebSocketImpl`, `wsUrlParams` and `wsConnection` moved off `StreamChatOptions` - -All three were read only by the WebSocket layer, so they now sit with the rest of the socket's -settings — and the first two are renamed to suit the namespace they moved into: - -```ts -// v9 -const client = new StreamChat('key', { - WebSocketImpl: WebSocket, - wsUrlParams: new URLSearchParams({ foo: '1' }), -}); - -// v10 -const client = new StreamChat('key'); -client.config.set({ - client: { - wsConnection: { - webSocketImpl: WebSocket, - urlParams: new URLSearchParams({ foo: '1' }), - }, - }, -}); -``` - -`webSocketImpl` rather than `WebSocketImpl`, because a config object of camelCase fields should not -have one PascalCase outlier; `urlParams` rather than `wsUrlParams`, because the `wsConnection` -namespace already says which connection it is about. - -**Set them before `connectUser()`, not necessarily at construction.** The socket is built inside -`connect()`, so anything applied before that takes effect — and unlike the options they replace, these -survive a reconnect. - -`wsUrlParams` used to default to an empty `URLSearchParams`; `urlParams` defaults to `undefined`. The -URL is unchanged either way, since the socket does `new URLSearchParams(urlParams)` and both inputs -produce no parameters. - -`wsConnection` — a pre-built `StableWSConnection`, which only tests supply — keeps its name and moves -the same way: - -```ts -// v9 -const client = new StreamChat('key', { wsConnection: socket }); - -// v10 -const client = new StreamChat('key'); -client.wsConnection.updateConfig({ connection: socket }); -``` - -One thing to know: `client.config.reset()` restores these to their defaults like any other field, so -after a reset the next `connect()` builds a real socket with the global `WebSocket`. A socket already -open is unaffected. It matters mainly in tests — the connection attempt fails at the connect, not at -the reset, so the breakage shows up away from its cause. - -### One timing that is _not_ configurable - -The retry delay after the device network returns (10ms) is a constant in `src/wsConnection/config.ts`. -It was a bare literal inside the socket that no caller could reach, so exposing it would be new -surface rather than a preserved capability. - -The delay before a UI reports a drop **is** configurable, as -`wsConnection.offlineNotificationDisplayDelayMs`. Nothing in the client waits on it; it is declared so -the UI SDKs share one value. - -The announce delay in particular is load-bearing: it is what stops a brief flap from strobing a -"connection lost" banner, and `stream-chat-react`'s `useReportLostConnectionSystemNotification` renders -a persistent toast off that event. If you want the drop _without_ the wait, subscribe to -`client.wsConnection.state`, which publishes the raw edge immediately — that is what the separate status -store is for. - ---- - -## WebSocket transport - -### The connect endpoint moved to `/api/v2/connect` - -v9 opened the WebSocket against `/connect`, passing the auth payload in the query string -(`?json=…&authorization=…`). v10 uses `/api/v2/connect`, which authenticates off the **first -frame the client sends** instead. The client handles this internally — the change is only -visible in two places: - -- **The hello event is `connection.ok`, not `health.check`.** `connectUser()` / - `openConnection()` resolve with a `ConnectedEvent` carrying `connection_id` and `me`, the same - `OwnUserResponse` v9's `health.check` carried. Periodic health checks still arrive as - `health.check`, so listeners for that keep working; only the _first_ event changed. - - ```diff - - client.on('health.check', (event) => { if (event.me) seedOwnUser(event.me); }); - + client.on('connection.ok', (event) => seedOwnUser(event.me)); - ``` - - `ConnectionOpen` (the resolved type of `connectUser`) is now a union of both events, so narrow - on `type` before touching event-specific fields. - -- **The device is no longer part of the connect payload.** See - [`client.setLocalDevice`](./v9-to-v10-migration-guide-methods.md#clientsetlocaldevice). - -Anonymous connections are unaffected: `connectAnonymousUser()` works the same way. - -### Long-poll fallback (`enableWSFallback`) - -Works as in v9, but the flag moved off `StreamChatOptions` into the `wsConnection` configuration, -with the socket's other settings. Left in the constructor options, it is silently ignored: - -```ts -// v9 -const client = new StreamChat(API_KEY, { enableWSFallback: true }); - -// v10 -const client = new StreamChat(API_KEY); -client.config.set({ client: { wsConnection: { enableWSFallback: true } } }); -``` - -With it on, the WebSocket connects as it would without the flag, within `connectTimeoutMs` (15s by -default). v9 gave it 6s while the flag was on, through `client.defaultWSTimeoutWithFallback`, which is -gone: lower `connectTimeoutMs` to switch sooner. If the WebSocket fails with a network error, the -client switches to HTTP long-polling, dispatches `connection.fallback_activated` with -`mode: 'longpoll'`, and stays on long-poll for the rest of its lifetime. What else changed around it: - -- It polls `/api/v2/longpoll` instead of `/longpoll`. -- The switch event is `connection.fallback_activated`, renamed from `transport.changed`. Its payload - is unchanged: `mode: 'longpoll'`. -- Its status is `client.wsConnection.state`, as for the WebSocket, since `connection.changed` is gone. -- A reconnect recovers state through the usual connection recovery; `client.recoverState()` is gone. -- It takes online/offline changes from `client.networkConnection` instead of `window` events, so a - network status reporter you install, such as one wrapping NetInfo, reaches it too. It ignores them - while closed by `closeConnection()` / `disconnectUser()` and follows them again once reconnected; - v9 stopped listening for good at its first disconnect. -- `client.wsFallback` moved to `client.wsConnection.fallback`, beside the socket. - `client.wsConnection.isConnecting` reports the long-poll's connect attempts once switched. -- State is also recovered after `closeConnection()` → `openConnection()`, which v9's long-poll never - did: recovery follows the status store rather than being called from `connect()`. -- The switch is skipped when a network status reporter says the device is offline: the browser's, - or one you installed. v9 checked only `navigator.onLine`. On hosts without a network API, the - default reporter mirrors the WebSocket, so its "offline" only means the socket is down, and the - switch goes ahead. - -### Watching a channel now requires a connected user - -The server keys channel watches and presence subscriptions by **WebSocket connection id**, and -answers `200` while registering nothing when a request that needs one arrives without it. v9 papered -over this: `queryChannels()` and `channel.watch()` checked `client._hasConnectionID()` and silently -downgraded `watch: true` to `watch: false` when there was no connection — a server-side -accommodation that outlived the server-side surface (see -[`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md)). - -v10 removes the downgrade. Waiting for the connection id now happens centrally in the API client, -and a request that asks for a subscription it cannot get one for **throws** rather than quietly -returning unwatched state: - -``` -No connection id is available: there is no WebSocket connection, and none is being established. -A request that watches a channel or subscribes to presence needs one. Call `client.connectUser()` -if no user is connected, or `client.openConnection()` if the socket was closed with -`client.closeConnection()`. -``` - -```diff - const client = StreamChat.getInstance(API_KEY); -- // v9: resolved, but the channels were never watched - no events ever arrived -- const channels = await client.queryChannels(filters, sort); -+ await client.connectUser(user, token); -+ const channels = await client.queryChannels(filters, sort); -``` - -You do **not** have to await `connectUser()` before issuing the request — a request made while the -handshake is in flight waits for it and then goes out with the id. - -#### Closing the connection does not fail the request - -`closeConnection()` suspends the wait rather than ending it, which is what the React Native -background/foreground cycle needs: a watching request that is already in flight stays pending while -the socket is closed and completes against the **reopened** socket, carrying the new connection id. -This matches the contract `wsPromise` has had since -[#1868](https://github.com/GetStream/stream-chat-js/pull/1868) — the promise survives the gap so the -reopen settles it. - -```ts -const channels = client.queryChannels(filters); // watches, so it waits for an id -client.closeConnection(); // app backgrounds - the request is not failed -await client.openConnection(); // app foregrounds -await channels; // finishes, against the new connection id -``` - -`disconnectUser()` is the opposite case: nothing will reopen, so anything still waiting is rejected -with `Connection was closed because disconnectUser() was called` rather than left pending. The same -split applies to `wsPromise`. - -Note the consequence of the first half: a `closeConnection()` that is never followed by an -`openConnection()` leaves such a request pending indefinitely. Call `disconnectUser()` if you are -tearing the client down rather than backgrounding it. - -#### Which requests are affected - -Only requests that actually **ask** for a subscription. The gate reads the `watch` / `presence` -flags, so a query that opts out is unaffected and still works with no connection at all: - -```ts -// gated - waits for (or demands) a connection id -await client.queryChannels({ filter_conditions: filters, watch: true }); -await channel.watch(); - -// NOT gated - registers nothing, so it needs no connection -await client.queryChannels({ filter_conditions: filters, watch: false }); -await channel.query({ watch: false }); -``` - -Note that `client.queryChannels()` defaults to `watch: true`, so the bare call _is_ gated; pass -`watch: false` explicitly for a read-only load. - -Affected when they ask for a watch or presence: `client.queryChannels()`, -`client.groupedQueryChannels()`, `client.sync()`, `client.queryThreads()`, `client.getThread()`, -`channel.query()`, `channel.watch()` and `client.queryUsers()`. `channel.stopWatching()` is always -affected — it carries no flag and is connection-scoped by definition, since it tells the server -which connection should stop watching. - -Every other request is unaffected, and — unlike v9 — no longer carries a `connection_id` query -param at all. It is now attached only to the requests listed above, and to the long-poll -fallback's own polls and close. - -#### `closeConnection()` drops the id too - -`client._getConnectionID()` reads through the new `client.connectionIdManager` rather than -`client.wsConnection.connectionID`, so it correctly returns `undefined` once the socket is closed — -in v9 the id outlived the socket it belonged to. - -`StableWSConnection.connectionID` is **removed** with it. Leaving the field in place would have -recreated the same bug one level down: it was written on every handshake and never cleared, so it -kept reporting an id for a socket that was gone. Read `client._getConnectionID()` (or -`client.connectionIdManager.connectionId`) instead — both are dropped the moment the connection -stops being healthy. - -`client.wsConnection.fallback.connectionID` (v9's `client.wsFallback.connectionID`) is **removed** -for the same reason: it was not cleared when the long-poll went down, only when it reconnected or -was disconnected. The long-poll reads its id from `client.connectionIdManager` like everything else, -so read `client._getConnectionID()` for the long-poll's id too. - -The consequence for mobile apps: `closeConnection()` (the documented background/foreground seam) -now makes the gated calls above throw until `openConnection()` has been called, even though the -user is still set. Reopen the connection before issuing them, or pass `watch: false` for loads that -do not need a subscription. - -#### Reconnects drop the id too - -The id belongs to the socket, so it is dropped the moment the connection stops being healthy — not -only on a deliberate `closeConnection()` / `disconnectUser()`. In v9 it outlived the socket on every -path, so a `watch: true` issued during a reconnect went out carrying a dead id: the server answered -`200`, registered the watch against a connection it had already torn down, and no event ever -arrived. There was nothing to notice. - -Dropping the id and arming its replacement happen in the same step, so there is no moment at which -the client reports "not connected" for a socket that is simply reconnecting. A gated request issued -during the outage **waits** for the new handshake and then goes out with the new id. - -That wait is new, and it lasts as long as the reconnect does — the backoff grows to 25s per attempt -and retries until the socket is back. It is not bounded by the axios `timeout`, which only starts -once the request is actually issued. If a call must not block for the length of an outage, issue it -with `watch: false`: requests that register nothing are never gated and keep flowing throughout. - -`client.connectionRecovery` still re-queries channel lists and active channels once the socket is -healthy again, which is what restores the watches that were interrupted. - ---- - -## Filter payloads — per-endpoint operator constraints - -The generated client is produced with `typed_filters` enabled, so each request interface declares its filter inline as `Filters<{ … }>` (the helper lives at the top of `src/gen/models/index.ts`) rather than as a permissive `Record`. The filter spec names one entry per legal field: - -```ts -// src/gen/models/index.ts — QueryRemindersRequest -filter?: Filters<{ - channel_cid: { type: string; operators: '$eq' | '$in' }; - created_at: { type: Date | string; operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte' }; - message_id: { type: string; operators: '$eq' | '$in' }; - remind_at: { type: Date | string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$lt' | '$lte' }; -}>; -``` - -**Note the asymmetry on date fields.** A filter operand is `Date | string`, while the same field on the -_response_ is a unix-nanosecond `number` — so a value read off a response cannot be fed back into a -filter, and a bare `number` operand is read as nanoseconds rather than milliseconds. See the -[dates guide](./v9-to-v10-migration-guide-dates.md#filter-operands-read-a-bare-number-as-nanoseconds). - -Endpoints carrying a typed filter, and the property it sits on: - -| Request type | Property | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | -| `QueryBannedUsersPayload`, `QueryChannelsRequest`, `QueryMembersPayload`, `QueryMessageFlagsPayload`, `QueryUsersPayload` | `filter_conditions` | -| `SearchPayload` | `filter_conditions` and `message_filter_conditions` | -| `QueryAppealsRequest`, `QueryDraftsRequest`, `QueryModerationConfigsRequest`, `QueryPollsRequest`, `QueryPollVotesRequest`, `QueryReactionsRequest`, `QueryRemindersRequest`, `QueryReviewQueueRequest`, `QueryThreadsRequest` | `filter` | - -**Breaking effect 1 — undeclared operators.** Any v9 filter object using an operator not declared for a field stops type-checking: - -```ts -// v9 — accepted (typing was permissive) -client.queryChannels({ frozen: { $exists: true } as any }, sort); - -// v10 — `frozen` declares only `{ type: boolean; operators: '$eq' }`. -// Use { frozen: true } or { frozen: { $eq: true } } instead. -``` - -**Breaking effect 2 — one operator per field.** The generated shape uses `RequireOnlyOne`, so combining operators on a single field is now an error. Use `$and` to intersect: - -```ts -// ✗ v10 — rejected -const bad: ReminderFilters = { created_at: { $gt: a, $lt: b } }; -// ✓ v10 -const good: ReminderFilters = { - $and: [{ created_at: { $gt: a } }, { created_at: { $lt: b } }], -}; -``` - -**Breaking effect 3 — `null` is no longer a valid array element.** In v9 the hand-written filter types built on `PrimitiveFilter = T | null`, which made `$in` an array of _nullable_ elements. The generated shape puts the nullability outside the array: - -```ts -// v9: $in?: (string | null)[] v10: $in: string[] | null -const bad: ReminderFilters = { message_id: { $in: ['a', null] } }; // ✗ now rejected -const good: ReminderFilters = { message_id: { $in: ['a', 'b'] } }; // ✓ -``` - -Field-name typos in a typed filter are now compile errors. If you were relying on the v9 permissive shape, casting is the escape hatch; the correct fix is to use the declared fields and operators. - -### Filter aliases now derive from the request types - -`ChannelFilters`, `MessageFilters`, `ReactionFilters`, `ThreadFilters`, `UserFilters` still exist as convenience aliases but derive from the constrained request types. (The _sort_ aliases did not survive — unlike a filter alias, which resolves to a per-endpoint `Filters<{...}>` carrying that endpoint's declared operators, every sort alias was the same `SortParamRequest[]`. See the [sort guide](./v9-to-v10-migration-guide-sort.md#the-sort-aliases-are-gone).) The three remaining hand-written poll/reminder filter types are now migrated the same way: - -| Alias | Now derives from | -| ------------------- | ---------------------------------------------- | -| `QueryPollsFilters` | `NonNullable` | -| `QueryVotesFilters` | `NonNullable` | -| `ReminderFilters` | `NonNullable` | - -`QueryRemindersOptions` is **not** in that table: unlike the aliases above it does not survive at all. v9 defined it as `Pager & { filter?: ReminderFilters; sort?: ReminderSort }`; v10 removes it with no replacement alias — use `QueryRemindersRequest` directly. (It is not a pure rename: `sort` is now `SortParamRequest[]` and `filter` is operator-constrained, per the sections above.) `ReminderPaginator`'s second generic parameter moved with it, so `PaginatorOptions` becomes `PaginatorOptions`. - -Beyond the three breaking effects above, the field sets shifted to match the API spec: - -- **Removed** — `ReminderFilters.user_id`, `QueryPollsFilters.user_id`, `QueryVotesFilters.created_by_id`. These were never declared by the endpoints; filter on a supported field instead (e.g. `created_by_id` for polls). -- **Added** — `QueryVotesFilters` gains `poll_id`, and `QueryPollsFilters` gains a `custom.${string}` index signature for filtering on custom poll data. - -### Removed — the hand-written filter building blocks - -`QueryFilter` and `PrimitiveFilter` are **removed**, along with `ExtendedQueryFilter`, `ExtendedQueryFilters` and `ExtendedQueryLogicalOperators` (`src/pagination/FilterBuilder.ts`). `Unpacked` goes with them — it existed only to let `QueryFilter` reach the element type of an array-valued key, and `Filters<>` takes the element type directly. `QueryFilters` and `RequireOnlyOne` are still exported, but note that `QueryFilters` is now the **generated** type (`src/gen/models`) and takes a different parameter: v9's `QueryFilters` took the item type and mapped its keys itself, whereas `QueryFilters` takes an already-built operator map — the shape `Filters<>` produces. A v9 `QueryFilters<{ [K in keyof MyItem]?: ... }>` therefore does not mean the same thing any more; write the `Filters<>` form below instead. - -If you used `Unpacked` in your own code, it was a plain conditional type with no dependency on this SDK; copy it across rather than importing it: - -```ts -type Unpacked = T extends (infer U)[] - ? U - : T extends (...args: any[]) => infer U - ? U - : T extends Promise - ? U - : T; -``` - -These were the v9 building blocks for hand-authoring a filter type — every alias in the table above used to be assembled from them. Nothing in the SDK uses them any more. If you composed your own filter type for `itemMatchesFilter`, a paginator or a `FilterBuilder`, declare it with the generated `Filters<>` helper instead: one entry per key, carrying the operators that key accepts. - -```ts -// v9 -type MyFilters = QueryFilters<{ - [Key in keyof MyItem]?: - | RequireOnlyOne> - | PrimitiveFilter; -}>; - -// v10 -type MyFilters = Filters<{ - id: { type: string; operators: '$autocomplete' | '$eq' | '$in' }; - age: { type: number; operators: '$gt' | '$lt' }; - // Array-valued keys take the *element* type, with a `valueTypes` override where the - // backend also accepts the whole array — the way the spec models `members` / `teams`. - teams: { - type: string; - operators: '$contains' | '$eq' | '$in'; - valueTypes: { $eq: Array }; - }; -}>; -``` - -`Filters<>` is what the generated request types themselves are built from, so it is strictly more expressive than what it replaces: operators are constrained per key (`$autocomplete` on a numeric field is now a compile error), `$ne` and `$nin` are available on the keys that declare them, `RequireOnlyOne` is applied for you, and the bare-value shorthand (`{ id: 'u1' }`) still works wherever `$eq` is allowed. - -One shape does not survive the move: an all-optional operator object. `QueryFilter` made every operator optional, so `{}` type-checked; `Filters<>` requires exactly one. Express "no constraint" by omitting the key. - -### Removed — `ArrayOneOrMore` and `ArrayTwoOrMore` - -The two non-empty-array utilities are gone from `stream-chat`. In v9 they existed only to constrain the logical operators — `$and` / `$nor` required at least one element (`ArrayOneOrMore`), `$or` at least two (`ArrayTwoOrMore`): - -```ts -// v9 — src/types.ts -export type ArrayOneOrMore = { 0: T } & Array; -export type ArrayTwoOrMore = { 0: T; 1: T } & Array; -``` - -**Why they were dropped.** That encoding is only satisfied by a value whose length TypeScript knows statically, so any array _variable_ is rejected. It probably wasn't a common use-case for the logical operators, but the helpers were generically named, and cause issues when used in other places, for example: - -```ts -declare const ids: string[]; - -// TS2322: Property '0' is missing in type 'string[]' -const a: ChannelFilters = { members: { $in: ids } }; -// same -const b: ChannelFilters = { members: { $in: [...ids] } }; -// only literals and non-empty tuples pass -const c: ChannelFilters = { members: { $in: ['u1'] } }; -``` - -No non-empty encoding avoids this: TypeScript cannot prove a `T[]` has at least one element, so the guarantee costs a hard compile error on working code. An empty array stays a runtime 400 with a clear message, which is the cheaper failure. - -**What to do.** Use a plain `Array` (or `T[]`) wherever you referenced them: - -```ts -// v9 -import type { ArrayOneOrMore, ArrayTwoOrMore } from 'stream-chat'; - -type MyLogicalOperators = { - $and?: ArrayOneOrMore>; - $nor?: ArrayOneOrMore>; - $or?: ArrayTwoOrMore>; -}; - -// v10 -type MyLogicalOperators = { - $and?: Array>; - $nor?: Array>; - $or?: Array>; -}; -``` - -This mirrors the generated `QueryLogicalOperators` (`src/gen/models`), which types all three as plain arrays. The change is widening, not narrowing: everything that compiled in v9 still compiles, and the array-variable cases above now compile too. The only thing you lose is the compile error on an empty (or single-element `$or`) array, which the API rejects at runtime anyway. - ---- - -## State shape changes - -### `ThreadManagerState.lastConnectionDropAt` removed - -`client.threads.state` no longer carries `lastConnectionDropAt`. It was a timestamp the thread list -recorded for itself, and it never worked: - -- It was written from a connectivity event that was not a reliable disconnect signal: the drop was - announced 5s late, dropped entirely if the socket returned inside that window, and never announced - at all by `closeConnection()`, the documented mobile background/foreground path. -- It was set once and never cleared, so after the first drop of a session it stayed truthy forever. - -The thread list now reloads on `connection.recovered` alone, which `ConnectionRecoveryManager` -dispatches on every reconnect path — so it already implies a drop happened. The practical fix is that a -backgrounded app coming back no longer keeps a stale thread list. - -If you were reading the field, read `client.wsConnection.state.lastUnhealthyAt` instead. It is written -on every status transition, including the deliberate `disconnect()`: - -```ts -// v9 -const { lastConnectionDropAt } = client.threads.state.getLatestValue(); - -// v10 -const { lastUnhealthyAt } = client.wsConnection.state.getLatestValue(); -``` - -### `ThreadManager.threadsById` removed; threads resolve through a registry - -`threadsById` was built from `state.threads`, so it only knew about threads the list had loaded. A -thread opened from a message list was invisible to it: a hard delete, an offline-replayed reply, -connection recovery and an edit composer's submit target all missed it. The workaround was to add -the open thread to `state.threads` yourself, which put it on the thread list screen too. - -`ThreadManager` now keeps a registry holding the list's threads plus every **opened** one. -`thread.activate()` registers a thread and subscribes it for the rest of the session, and returns the -function that ends that activation, as `channel.activate()` does. `thread.deactivate()` is removed: -call the returned function instead. Ending the activation does not release the thread, so reopening the thread reuses the loaded instance with no fetch. It is dropped -when its channel is deleted or the user is removed from it, and on `disconnectUser()`. A reconnect -marks every registered thread nobody is displaying stale, and it reloads the next time it is activated. - -```ts -// v9 -const thread = - client.threads.threadsById[id] ?? new Thread({ channel, client, parentMessage }); -client.threads.state.next((s) => ({ ...s, threads: [thread, ...s.threads] })); // to keep it live - -// v10 -const thread = client.threads.ensure({ channel, parentMessage }); -const release = thread.activate(); // on mount; release() on unmount -``` - -`ensure()` returns the stored instance, or builds a new one, and registers it either way, so a list -query that lands before `activate()` reuses it rather than creating a second one. A thread it builds -has only its parent message, so it starts stale and loads its thread data once, when it is first -activated. -`client.threads.get(id)` only looks a thread up, and `client.threads.paginator.getItem(id)` answers -whether the list holds it. `client.queryThreadsAndHydrate()` and `client.getThreadAndHydrate()` also -return the registered instance for a thread `client.threads` already holds, rather than a second -`Thread` for the same id, so the instance you get is the one that receives events. - -A thread no longer listens to the client itself: `client.threads` hands each thread event to the -threads it holds, the way `client.polls` does for polls. A thread therefore gets events only once -`client.threads` holds it (listed, `ensure()`d or activated) and `client.threads.registerSubscriptions()` -has run; calling `thread.registerSubscriptions()` on a thread you built yourself is no longer enough. - -A thread query sent with `watch: true` (`getThreadAndHydrate()`, `queryThreadsAndHydrate()`, and -through them `thread.reload()` and the thread list) now sets the channel's `watchStatus` to `Watching`, -since the server does watch the thread's channel. A thread marks itself stale when that status becomes -`NotWatching` (the watch was stopped while connected) instead of reacting to `user.watching.stop`; a -dropped connection is handled by connection recovery, as before. - -### `client.activeChannels` removed; channels live in the channel store - -`client.activeChannels` was a plain object keyed by cid that only grew: a channel left it only when it -was deleted, when the user was removed from it, or on `disconnectUser()`. Every channel ever loaded -stayed in memory with its subscriptions and message lists. - -`client.channelManager` now owns a channel store with one `Channel` instance per cid. -`client.channelManager.ensure()` (and `client.channel()`, its shorthand) and every query and event go -through it, so they all return the same instance: - -```ts -// v9 -const channel = client.activeChannels[cid]; -const all = Object.values(client.activeChannels); - -// v10 -const channel = client.channelManager.get(cid); -const all = client.channelManager.values(); -``` - -**A channel stays in the store until it ends, the user logs out, or the app releases it.** The SDK -never releases a channel on its own: it can't see the channels your app keeps in its own state. A -deleted channel, one the user is removed from, and every channel on `disconnectUser()` are disposed -of right away, whatever uses them. - -**`client.channelManager.releaseUnusedChannels()` releases the channels nothing uses.** Call it to -free memory, for example after closing a search screen (`searchController.dispose()` first). It -first drops every stored channel your app disposed of with `channel.disconnect()`, whatever still -holds it, and every list showing it drops it too. Then it releases the unused ones. A -watched channel (`watchStatus` `watching`, or `wasWatching` after a dropped connection, until the -watch is restored) always stays: its events keep it current, which is what makes it worth keeping. -An unwatched channel stays while it is used: - -- a channel list (`ChannelPaginator`) has it in its loaded pages; -- it is active: `channel.activate()` was called and its release function not yet; -- a `watch()`, `query()` or `create()` for it is in flight; -- it is the channel of a thread in `client.threads`, or of a composer in - `client.messageComposerCache` (such as a message edit's); -- an active channel search shows it. - -Every other stored channel is removed from the store and disposed of. It holds a snapshot nothing -shows, so releasing it costs no request beyond the query that using it again needs anyway. - -Disposing of a channel (`channel.disconnect()`, public now; it was `_disconnect()`) stops the -instance for good: it stops listening to the client's configuration, releases its loaded messages, -and sets `pendingDisposal` to `true` and `watchStatus` to `NotWatching`. It sends no request, and -frees no memory by itself: once the SDK no longer refers to the instance, it is garbage collected -when your code doesn't either. A channel your app disconnects stays stored until the next -`releaseUnusedChannels()`, but the SDK treats it as gone: lists don't take it, an event for its cid -gets a fresh instance, and a query that finds it under its cid takes its place. - -So when your app calls `releaseUnusedChannels()`, a channel your code keeps a reference to, without -watching or opening it, is disposed of too. A disposed channel throws from `getClient()`, and its -`query()` and `watch()` reject before the request is sent; -`client.channelManager.ensure({ type, id })` returns a fresh instance. Call `activate()` on a channel -you keep, as a UI does when it opens one. - -**Channel search results are not watched.** `ChannelSearchSource` queries with `watch: false` unless -its `searchOptions` say otherwise: a result is a preview. Watch a result when the user opens it. - -**Neither are the channels of message search results.** `MessageSearchSource` fetches the channels of -its results that the client doesn't have yet, and now does so with `watch: false` unless its -`channelQueryOptions` say otherwise. Watch the channel when the user opens a result, for example with -`channel.messagePaginator.jumpToMessage(messageId, { watchChannel: true })`, which loads the message -and watches the channel in one request. - -**`channel.activate()` is new.** It declares that a consumer is showing the channel, which sets -`channel.active`, and returns the function that ends it. Each call gets its own, and calling it twice -does nothing, so one consumer can't end another's activation: - -```ts -const release = channel.activate(); // on mount -release(); // on unmount -``` - -The release function unsets `active` once the last consumer releases. It doesn't drop the channel -from the store by itself: an unwatched channel nothing else uses goes at the next release of unused -channels. - -**`client.hydrateActiveChannels()` is renamed to `client.hydrateChannels()`**, with the same -arguments. - -**`channel.data` is read from and written to `channel.state`.** Assigning `channel.data = …` publishes -`data`, `memberCount` and `ownCapabilities` in one state update, so subscribers see it. An object that -omits `member_count` or `own_capabilities` gets the last known values on a copy: the assigned object -isn't changed, and `channel.data` may then not be the same object you assigned. Editing -`channel.data.x` in place still doesn't reach the state; assign a new object. - -**One `ChannelManager` per client.** The client creates its own; constructing a second one for the same -client is unsupported, because only `client.channelManager` sees the channels `ensure()` creates. - -### The thread list is a `ThreadPaginator` - -`client.threads.paginator` holds the list, in the order `queryThreads` returns it. There is no -client-side sort, and the list does not reorder live: a reply to a listed thread still sets -`isThreadOrderStale`, and one to an unlisted thread adds to `unseenThreadIds`, for a reload to pick up. - -```ts -// v9 -const { threads, pagination } = client.threads.state.getLatestValue(); -const { isLoading, isLoadingNext, nextCursor } = pagination; - -// v10 -const { items, isLoading, hasMoreTail } = client.threads.paginator.state.getLatestValue(); -const threads = items ?? []; -const isLoadingFirstPage = isLoading && !threads.length; -const isLoadingNext = isLoading && threads.length > 0; -``` - -- `reload()` loads the first page, or re-queries a loaded list and replaces it in place. The first load - sets `paginator.isLoading`, like any paginator. A re-query of a loaded list sets no loading flag: the - current threads stay in `paginator.items` until the new ones land, and a failed re-query leaves them - untouched. There is no replacement for `pagination.isLoading` during a re-query; show the list as it is. -- `paginator.isLoading` with threads loaded is the old `pagination.isLoadingNext`. A reload and a next - page are guarded independently, as before. -- `client.threads.queryThreads()` is removed; the list queries through its paginator. For a one-off - query use `client.queryThreadsAndHydrate(...)`, which returns `Thread` instances. -- `loadNextPage()` is removed. Load the next page with `client.threads.paginator.toTail()`, as for any - paginator. It is a no-op until a first load succeeds (the old `ready`, now - `paginator.items !== undefined`) and once there is no next page (the old `!nextCursor`), and a failed - one keeps the list and cursor so the next call retries. -- A reset (`resetState()`, `disconnectUser()`) returns `paginator.items` to `undefined`. - -### `ChannelState.membership` - -```ts -// v9 -membership: ChannelMemberResponse; // initialized to {} -if (channel.state.membership.role === 'admin') { ... } // OK - -// v10 -membership: ChannelMemberResponse | undefined; // initialized to undefined -if (channel.state.membership?.role === 'admin') { ... } // must guard -``` - -Unguarded reads of `channel.state.membership.` now crash on freshly-constructed channels. Add `?.` or a `membership &&` guard at every read site. - -### `ChannelState.typing` - -```ts -// v9 -typing: Record; - -// v10 -typing: Record>; -``` - -Any code that inspected the typing entry's fields is now narrowed to typing-event fields only. Reading `state.typing[userId].message` etc. no longer compiles. - -### `ChannelState.read` (`ChannelReadStatus`) - -The per-user record now composes the generated `ReadStateResponse` plus an SDK-only `first_unread_message_id`: - -```ts -type ChannelReadStatus = Record< - string, - ReadStateResponse & { first_unread_message_id?: string } ->; -``` - -Field names are unchanged (`last_read`, `unread_messages`, `user`, `last_read_message_id`, `last_delivered_at`, `last_delivered_message_id`), but `user` is now `UserResponseCommonFields`-shaped (from the generator) rather than the v9 `UserResponse`. Downstream code that reads fields off `read[uid].user` should be fine; code that assigned back onto it may not. - -### `ChannelState.formatMessage` - -`MessageResponseBase` is removed from the type signature (see methods guide, ChannelState section). Callers passing a hand-rolled `MessageResponseBase`-typed value must cast or reshape to `MessageResponse | LocalMessage`. - ---- - -## Composer & attachment shape - -### Attachment previews — flat metadata moved under `.custom` - -`AttachmentManager.fileToLocalUploadAttachment` (and downstream identity checks) no longer place `mime_type`, `file_size`, or `duration` at the attachment root. They are nested under `custom`: - -```ts -// v9 — flat -{ mime_type: 'image/png', file_size: 1024, type: 'image', duration: 3.5, ... } - -// v10 — nested -{ custom: { mime_type: 'image/png', file_size: 1024, duration: 3.5 }, type: 'image', ... } -``` - -Consequences: - -- `isFileAttachment(a)` and `isVideoAttachment(a)` now read `(a as FileAttachment).custom?.mime_type`. -- `duration` is only populated for `type === 'voiceRecording'` (v9 populated it whenever a `FileReference` carried one). -- Any UI code reading `attachment.mime_type` / `attachment.file_size` from a preview built by the composer must switch to `attachment.custom?.mime_type` / `attachment.custom?.file_size`. - -### `LocationComposer` preview - -```ts -// v9 — `StaticLocationPayload` / `LiveLocationPayload` were hand-written and are gone -export type LiveLocationPreview = Omit & { - durationMs?: number; -}; -// end_at was set to `new Date(...).toISOString()` - -// v10 — both build on the generated request type `SharedLocation` -export type StaticLocationPreview = SharedLocation & { message_id?: string }; -export type LiveLocationPreview = Omit & { - durationMs?: number; - message_id?: string; -}; -// end_at is a Date, because composer state holds the REQUEST shape -``` - -If your app called `preview.end_at.toISOString()` or passed `end_at` directly to a `