Skip to content

feat(client): support custom_set and custom_unset in batch channel update - #1856

Merged
kanat merged 6 commits into
masterfrom
feat/batch-channel-update-custom-set-unset
Sep 9, 2026
Merged

kanat merged 6 commits into
masterfrom
feat/batch-channel-update-custom-set-unset

Conversation

@kanat

@kanat kanat commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Ticket

CHA-3618

Problem

PUT /channels/batch gained two root-level fields in GetStream/chat#15840 (merged 2026-09-01, first released in v237.13.0): custom_set and custom_unset. They patch individual keys of a channel's custom object, unlike data.custom, which replaces the whole object — and since a channel's display name lives inside custom, a full replace that omits name deletes the channel name. UpdateChannelsBatchOptions carries only operation, filter, members and data, so this SDK cannot send them.

Solution

UpdateChannelsBatchOptions gets custom_set?: Record<string, unknown> (the same value type as BatchChannelDataUpdate.custom) and custom_unset?: string[]. custom_set merges its keys into each matched channel's existing custom, custom_unset deletes its keys, and every other custom key is left untouched.

Both sit at the request root, next to operation and filter, not inside data. That placement is load-bearing: on the v1 routes data is decoded through an extra-fields sink, so data: { custom_set: … } is already a valid payload today meaning "replace custom with a key literally named custom_set". The request root has no sink.

ChannelBatchUpdater keeps one operation-aligned helper, updateData:

  • Existing updateData(filter, data) calls remain source- and runtime-compatible.
  • updateData(filter, { custom_set, custom_unset }) sends a custom-only patch without an undefined placeholder or a data key.
  • updateData(filter, { data, custom_set, custom_unset }) combines channel data and custom-key changes in one request.

The exported ChannelBatchDataUpdateOptions type 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 than updateData, 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

  1. 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 legacy updateData(filter, data) request, the custom-only options form with no data key, and the combined form.
  2. yarn types, yarn run-types-gen, yarn lint, and yarn build pass.
  3. Full yarn vitest run passes: 80 files, 3151 tests passed, 1 skipped.

…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>
kanat and others added 3 commits September 8, 2026 15:34
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 error

TypeScript 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread src/channel_batch_updater.ts Outdated
operation: 'updateData',
filter,
data,
...options,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 oldFilter

That 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@kanat
kanat merged commit d05c2f5 into master Sep 9, 2026
7 checks passed
@kanat
kanat deleted the feat/batch-channel-update-custom-set-unset branch September 9, 2026 15:47
github-actions Bot pushed a commit that referenced this pull request Sep 15, 2026
## [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))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 9.53.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

szuperaz added a commit that referenced this pull request Sep 17, 2026
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>
github-actions Bot pushed a commit that referenced this pull request Sep 18, 2026
## [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))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.12 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants