[CHA-3618] feat: support custom_set and custom_unset in batch channel update - #270
Conversation
…pdate (CHA-3618) Add customSet and customUnset to ChannelsBatchOptions, serialized as the root-level custom_set and custom_unset of PUT /channels/batch and omitted when unset. They patch individual keys of a channel's custom object instead of replacing it, which is what data.custom does today. ChannelBatchUpdater.updateData gains an overload carrying both, with a nullable data so a custom patch can be sent on its own. Validation stays on the server: it owns the rules for which combinations are rejected. Co-Authored-By: Claude Opus 5 <[email protected]>
…eCustom
The 4-arg updateData(filter, data, customSet, customUnset) made the dominant
case read as updateData(filter, null, Map.of("group", "old"), null): a null
sandwich for a request that only patches one custom key.
Drop that overload and restore updateData(filter, data) to its pre-existing
form. The patch-only case is now updateCustom(filter, customSet, customUnset),
with no data argument, and the rare combined case is updateData(filter, data,
ChannelCustomPatch), a two-member helper value type next to ChannelsBatchOptions
that is unpacked into custom_set and custom_unset and never serialized itself.
The wire shape is unchanged: ChannelsBatchOptions still carries customSet and
customUnset as the root-level custom_set and custom_unset.
Co-Authored-By: Claude Opus 5 <[email protected]>
mogita
left a comment
There was a problem hiding this comment.
comment / needs discussion: 1 must fix, 1 should fix.
The patch fields themselves are right. custom_set / custom_unset really do live at the request root (chat/lib/chat/controller/v1/payload/channel_batch_update.go:36), the per-field @JsonInclude(NON_NULL) is the correct call over a class-level one (that would change what members and data look like on the wire), and leaving validation to the backend matches ValidateStruct there. Nothing is removed or renamed, so the freeze is clear.
The blocker is one field this PR does not touch. ChannelDataUpdate serializes "custom": null, and on v1 that null lands in the extra-fields sink, so the documented data + customSet request is a 400. The same null is also overwriting channel custom data on the existing updateData path today. Both inline on ChannelBatchUpdater.java:216.
| ChannelsBatchOptions options = new ChannelsBatchOptions(); | ||
| options.setOperation(ChannelBatchOperation.UPDATE_DATA); | ||
| options.setFilter(filter); | ||
| options.setData(update.getData()); |
There was a problem hiding this comment.
[Must Fix] data plus customSet in one request always comes back 400.
ChannelDataUpdate has no null-inclusion setting, so Jackson writes every unset field, custom included:
{"operation":"updateData","filter":{...},"members":null,
"data":{"frozen":true,"disabled":null,"custom":null,"team":null,...},
"custom_set":{"group":"old"},"custom_unset":["location_id"]}
On the v1 route data.custom is the extra-fields sink, and a null-valued key is still captured into it (kit/jsonextra/decode_test.go:870, spec section 5.3). So Data.Custom decodes to ExtraFields{"custom": nil}, which is non-nil, and ChannelBatchUpdateRequest.ValidateStruct (chat/lib/chat/controller/v1/payload/channel_batch_update.go:57) returns:
'custom' (and any other custom field sent directly in 'data') replaces the whole custom object and thus cannot be used together with 'custom_set' or 'custom_unset'
That is the second example in the PR description and in docs/channels/channel_management/batch-updates.md. The custom-only path is fine, only the combined one fails.
Same root cause, already shipping today on the existing updateData(filter, data) path: "custom": null makes HasCustomUpdate() true, so chat/monolith/tasks/channelstasks/batch/operations/update_channel_data_processor.go:59-63 writes the custom column and replaces each matched channel's whole custom object with {"custom": null}. The channel display name lives in custom, so a batch updateData that only sets frozen deletes it.
Fix: @JsonInclude(JsonInclude.Include.NON_NULL) on ChannelDataUpdate.custom (Channel.java:2056), or class-level on ChannelDataUpdate to match ChannelsBatchFilters at Channel.java:2079.
How I checked
Jackson side, same mapper config as DefaultClient.buildRetrofitClient (ALL=NONE, FIELD=ANY), same annotations as ChannelDataUpdate and post-PR ChannelsBatchOptions:
data + patch : {"operation":"updateData","filter":{"cids":{"$in":["messaging:a"]}},"members":null,"data":{"frozen":true,"disabled":null,"custom":null,"team":null,"config_overrides":null,"auto_translation_enabled":null,"auto_translation_language":null},"custom_set":{"group":"old"},"custom_unset":["location_id"]}
patch only : {"operation":"updateData","filter":{"cids":{"$in":["messaging:a"]}},"members":null,"data":null,"custom_set":{"group":"old"}}
Backend side, feeding that exact body through jsonextra.Unmarshal into payload.ChannelBatchUpdateRequest on chat main (085fdb38adf):
java-shape: Custom==nil=false Custom=jsonextra.ExtraFields{"custom":interface {}(nil)}
java-shape: ValidateStruct=400 'custom' ... cannot be used together with 'custom_set' or 'custom_unset'
omitted-shape: Custom==nil=true ValidateStruct=<nil>
custom-only: Custom==nil=true ValidateStruct=<nil>
There was a problem hiding this comment.
Fixed in 40194859 — ChannelDataUpdate.custom is now omitted when null, covering both the combined patch and existing data-only paths.
| var fields = new ArrayList<String>(); | ||
| root.fieldNames().forEachRemaining(fields::add); | ||
| Assertions.assertEquals( | ||
| List.of("operation", "filter", "members", "data", "custom_set", "custom_unset"), fields); |
There was a problem hiding this comment.
[Should Fix] These tests only look at the root field names, and that is the gap that let the data.custom 400 through: nothing here asserts what the serialized data object contains.
This line also pins the exact ordered list of all six root fields, so any future field added to ChannelsBatchOptions fails a test about the custom patch. Assert the absence of what could actually leak instead:
Assertions.assertFalse(root.has("customSet"));
Assertions.assertFalse(root.has("customUnset"));
Assertions.assertFalse(root.has("update"));and add one case that serializes data and asserts custom is absent from it, which is the field the backend's mutual-exclusion check reads.
There was a problem hiding this comment.
Fixed in 40194859 — the tests now assert that data.custom is absent for both paths and use targeted field-name assertions instead of an ordered root-field list.
Omit ChannelDataUpdate.custom when it is unset so the v1 extra-fields decoder does not treat null as a whole custom replacement. This allows data to be combined with custom_set/custom_unset and prevents data-only updates from replacing existing custom data. Replace the ordered root-field assertion with targeted wire assertions for data.custom and helper-only field names.
|
|
||
| @Nullable | ||
| @JsonProperty("custom") | ||
| @JsonInclude(JsonInclude.Include.NON_NULL) |
There was a problem hiding this comment.
[Should Fix] This also changes behavior for plain updateData calls that never touch custom: before this line the SDK always sent "custom": null, which replaced each matched channel's whole custom object. Please call that out in the release notes so upgrading users know their earlier batch updateData calls were affected.
There was a problem hiding this comment.
Added a Release note section to the PR description covering the impact on existing updateData calls.
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.ChannelsBatchOptionscarries onlyoperation,filter,membersanddata, so this SDK cannot send them.Solution
ChannelsBatchOptionsgetscustomSet(Map<String, Object>) andcustomUnset(List<String>), serialized ascustom_setandcustom_unsetat the request root. Both are omitted when unset.ChannelBatchUpdaterkeeps a single operation-aligned verb,updateData. Its new overload acceptsChannelBatchDataUpdateOptions, which can contain channeldata,customSet, andcustomUnset:The existing
updateData(filter, ChannelDataUpdate)method is unchanged.ChannelBatchDataUpdateOptionsis a helper-level type only; the updater unpacks it into the request fields, socustom_setandcustom_unsetremain at the request root.Validation stays server-side: the backend owns the rules for rejected field combinations, while the SDK carries the fields.
How to verify
./gradlew test --tests '*ChannelBatchCustomPatchTest*'passes (6 tests, no API credentials needed)../gradlew spotlessCheck javadocpasses.Release note
Batch
updateDatano longer sendscustom: nullwhen custom data is unset, preventing unrelated channel updates from replacing existing custom data.