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