feat(client): support custom_set and custom_unset in batch channel update - #1856
Conversation
…date Add custom_set and custom_unset to UpdateChannelsBatchOptions. They are root-level fields of PUT /channels/batch that patch individual keys of a channel's custom object, unlike data.custom, which replaces the whole object. They sit at the request root, next to operation and filter, rather than inside data: on the v1 routes data is the extra-fields sink, so a custom_set key sent inside it means "replace custom with a key literally named custom_set". ChannelBatchUpdater.updateData takes an optional custom patch, and data is now optional so a patch can be sent on its own. Validation stays server-side: it owns the rules for which combinations are rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Patching custom keys with no other channel data is the dominant case, and it went through updateData(filter, undefined, patch) — an undefined placeholder for the argument the call is not using. updateCustom(filter, customSet, customUnset) names that case and sends no data key at all. Name the patch pair ChannelCustomPatch and use it as updateData's third parameter type, so the concept the combined case takes has a name the docs and the other SDKs can refer to. Mirrors the helper shape of GetStream/stream-chat-java. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| async updateData( | ||
| filter: UpdateChannelsBatchFilters, | ||
| data: BatchChannelDataUpdate, | ||
| update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, |
There was a problem hiding this comment.
The union type lets a mixed literal compile, and the mixed call then silently drops the channel-data fields.
updater.updateData(filter, { frozen: true, custom_set: { group: 'new' } }); // no type errorTypeScript relaxes the excess-property check across a union: frozen is known to BatchChannelDataUpdate, custom_set to ChannelBatchDataUpdateOptions, so the literal passes (checked with tsc --strict). At runtime isChannelBatchDataUpdateOptions sees custom_set and returns true, so the whole object is spread at the request root and frozen goes out as a root-level key. The request root has no extra-fields sink, so the server ignores it: the caller gets a task_id back and frozen never applies.
Two real overloads on updateData, one per argument shape, reject that literal at compile time (TS2769) while every valid call in the tests still type-checks.
There was a problem hiding this comment.
Fixed in e8a082cb — restored the two public overloads and added a declaration test that rejects the mixed literal.
Restore separate public overloads for legacy channel data and the new options object. TypeScript now rejects fresh literals that mix channel fields with root-level custom patch fields. The union remains only on the implementation signature. Add consumer declaration checks for legacy, patch-only, combined, and rejected mixed forms.
| operation: 'updateData', | ||
| filter, | ||
| data, | ||
| ...options, |
There was a problem hiding this comment.
[Must Fix] ...options is spread after operation and filter, so a caller's own keys win at runtime.
ChannelBatchDataUpdateOptions is all-optional, so a non-fresh object needs only one matching key to satisfy the weak-type check, and excess-property checking does not apply to it:
const o = { operation: 'hide' as const, filter: oldFilter, data: { frozen: true } };
updater.updateData(newFilter, o); // typechecks, sends operation: "hide" with oldFilterThat is the shape a refactor from client.updateChannelsBatch(options) to this helper produces, and BatchUpdateOperation includes hide and removeMembers, so a data update can silently become a visibility or membership change against the wrong filter. Before this PR the second argument was always nested under data, so a stray key could not reach the request root.
Pick the three known keys instead of spreading:
const { data, custom_set, custom_unset } = isChannelBatchDataUpdateOptions(update)
? update
: { data: update };
return await this.client.updateChannelsBatch({
operation: 'updateData',
filter,
...(data && { data }),
...(custom_set && { custom_set }),
...(custom_unset && { custom_unset }),
});Worth one test that passes operation and filter inside the options object and asserts the request still carries updateData and the filter argument, since none of the five current tests can fail on this.
There was a problem hiding this comment.
Fixed in 5512b213 — the helper now copies only data, custom_set, and custom_unset, with a regression test for conflicting operation and filter fields.
Copy only data, custom_set, and custom_unset from the helper argument into the request. This prevents structurally compatible objects from overriding the fixed updateData operation or the filter supplied to the helper. Cover the regression with an options object that also contains conflicting operation and filter fields.
## [9.53.0](v9.52.1...v9.53.0) (2026-09-15) ### Bug Fixes * add missing app config fields to AppSettingsAPIResponse ([#1854](#1854)) ([18bc3cf](18bc3cf)) * hanging wsPromise after closeConnection ([#1868](#1868)) ([b4e7a89](b4e7a89)), closes [#1122](#1122) [#1863](#1863) ### Features * **client:** support custom_set and custom_unset in batch channel update ([#1856](#1856)) ([d05c2f5](d05c2f5))
|
🎉 This PR is included in version 9.53.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Brings 9.52.0–9.53.0 into the v10 line. Where master changed code v10 had already rewritten or removed, the v10 shape wins and the fix is ported onto it: - Attachment sanitization (#1845) moved to the v10 send paths: `Channel._sendMessage` and `StreamChat._updateMessage`, the points every path (including offline replay) converges on. `sanitizeOutgoingAttachments` drops its `client` argument — v10 has no `client.logger` — and reports through the `utils` scope of `chatLoggerSystem`. - Event listener isolation (#1850) reworked for v10's `Map<string, Set<…>>` listener registries and scoped loggers: `invokeEventListener` now takes a `ScopedLogger`, and `noopLogger` is gone with `options.logger`. - Thread-read guard (#1835) folded into v10's `message.read` / `notification.mark_read` branch, which already skipped `thread_id` reads. - Hanging wsPromise fix (#1868) ported whole, using v10's `userId`/`clientId` and keeping v10's `isWSFailure` branch in the `connectUser` catch. Dropped as no longer applicable to v10, which removed the APIs they cover: `ChannelBatchUpdater` and its `custom_set`/`custom_unset` support (#1856), the hand-written `AppSettingsAPIResponse` fields (#1854), the deprecated `flagMessage`/`unflagMessage` wrappers (#1846), and the `markAsReadRequest` capability gate (#1853), whose v10 counterpart landed as #1855. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## [10.0.0-rc.12](v10.0.0-rc.11...v10.0.0-rc.12) (2026-09-18) ### ⚠ BREAKING CHANGES * `connection.changed` is removed. Connectivity is published as two state stores and nothing else: `client.networkConnection.state` for the device and `client.wsConnection.state` for this client's socket. The event was silent on `closeConnection()` and two error paths, and held a drop for five seconds; the stores are written on every transition and publish immediately. * `client.wsConnection` is now a `WSConnection` wrapper rather than the `StableWSConnection` itself, and is never null. The live socket is `client.wsConnection.connection`, replaced on every connect; read `isHealthy`, `isConnecting`, `connect()` and `disconnect()` from the wrapper. `StableWSConnection` is constructed with `{ wsConnection }` instead of `{ client }`. * `client.defaultWSTimeout` is removed, along with the `WebSocketImpl` and `wsUrlParams` client options. They move to the socket's configuration as `connectTimeoutMs`, `webSocketImpl` and `urlParams`, set with `client.config.set({ client: { wsConnection: { … } } })`. Unlike the fields and options they replace, these survive a reconnect. * `ThreadManagerState.lastConnectionDropAt` is removed. Read `client.wsConnection.state.lastUnhealthyAt`, which is written on every status transition, including the `disconnect()` path the old event was silent about. * requests that watch a channel or subscribe to presence now wait for a WebSocket connection id instead of silently returning unwatched data. The gate is in `ApiClient._doRequest`, so it covers every endpoint carrying `watch` or `presence`, plus `stopWatchingChannel` and `longPoll`. With no socket open and none being established the request rejects with "No connection id is available"; an explicit `watch: false` is still honoured, and a caller's `AbortSignal` abandons the wait. Test fixtures that fake a connected user without a live socket will now throw. * a UI that renders a "connection lost" banner must hold the drop itself. The socket's store publishes drops the moment they happen, where the old event delayed them by five seconds. `client.wsConnection.config.offlineNotificationDisplayDelayMs` (5s) is the shared value to wait for; nothing in this package acts on it. * `connection.recovered` is no longer dispatched when the socket drops while a recovery is running, because every reload in it can have failed. Work keyed off that event will correctly stop running for recoveries that recovered nothing. * openapi related clean up (#1870) ### Bug Fixes * add missing app config fields to AppSettingsAPIResponse ([#1854](#1854)) ([18bc3cf](18bc3cf)) * do not reset channel unread count on thread read ([#1835](#1835)) ([79fbf54](79fbf54)) * hanging wsPromise after closeConnection ([#1868](#1868)) ([b4e7a89](b4e7a89)), closes [#1122](#1122) [#1863](#1863) * isolate event listener errors from the dispatch loop ([#1850](#1850)) ([dc56e57](dc56e57)) * reconnect past connection timeout ([#1874](#1874)) ([2004a83](2004a83)), closes [#1760](#1760) * send the read request regardless of read receipt privacy settings ([#1853](#1853)) ([59d8f31](59d8f31)) ### Features * **client:** support custom_set and custom_unset in batch channel update ([#1856](#1856)) ([d05c2f5](d05c2f5)) * establish network connection observer services ([#1859](#1859)) ([4c46949](4c46949)) * message pruning ([696f56f](696f56f)) * message pruning ([#1875](#1875)) ([d4d2c6c](d4d2c6c)) * **MessageComposer:** add composition middleware for pending attachment uploads ([#1845](#1845)) ([68e5d69](68e5d69)) * openapi related clean up ([#1870](#1870)) ([b3fa906](b3fa906))
|
🎉 This PR is included in version 10.0.0-rc.12 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Ticket
CHA-3618
Problem
PUT /channels/batchgained two root-level fields in GetStream/chat#15840 (merged 2026-09-01, first released inv237.13.0):custom_setandcustom_unset. They patch individual keys of a channel'scustomobject, unlikedata.custom, which replaces the whole object — and since a channel's displaynamelives insidecustom, a full replace that omitsnamedeletes the channel name.UpdateChannelsBatchOptionscarries onlyoperation,filter,membersanddata, so this SDK cannot send them.Solution
UpdateChannelsBatchOptionsgetscustom_set?: Record<string, unknown>(the same value type asBatchChannelDataUpdate.custom) andcustom_unset?: string[].custom_setmerges its keys into each matched channel's existingcustom,custom_unsetdeletes its keys, and every other custom key is left untouched.Both sit at the request root, next to
operationandfilter, not insidedata. That placement is load-bearing: on the v1 routesdatais decoded through an extra-fields sink, sodata: { custom_set: … }is already a valid payload today meaning "replacecustomwith a key literally namedcustom_set". The request root has no sink.ChannelBatchUpdaterkeeps one operation-aligned helper,updateData:updateData(filter, data)calls remain source- and runtime-compatible.updateData(filter, { custom_set, custom_unset })sends a custom-only patch without anundefinedplaceholder or adatakey.updateData(filter, { data, custom_set, custom_unset })combines channel data and custom-key changes in one request.The exported
ChannelBatchDataUpdateOptionstype names the options-object form. The client normalizes only the legacy form into the root-level request shape; the wire format is unchanged.Validation stays server-side: the backend owns the rules for which field combinations it rejects (patch together with
data.custom, a patch on any operation other thanupdateData, the same or overlapping key in both, empty dot-path segments, whitespace in keys — all 400s). The SDK only carries the fields.How to verify
yarn vitest run test/unit/channel_batch_update.test.ts— 5 tests, no API credentials needed. They pin both JSON key names and their placement at the request root, the legacyupdateData(filter, data)request, the custom-only options form with nodatakey, and the combined form.yarn types,yarn run-types-gen,yarn lint, andyarn buildpass.yarn vitest runpasses: 80 files, 3151 tests passed, 1 skipped.