Skip to content

[CHA-3618] feat: support custom_set and custom_unset in batch channel update - #270

Merged
kanat merged 4 commits into
mainfrom
feat/cha-3618-batch-custom-set-unset
Sep 9, 2026
Merged

[CHA-3618] feat: support custom_set and custom_unset in batch channel update#270
kanat merged 4 commits into
mainfrom
feat/cha-3618-batch-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. ChannelsBatchOptions carries only operation, filter, members and data, so this SDK cannot send them.

Solution

ChannelsBatchOptions gets customSet (Map<String, Object>) and customUnset (List<String>), serialized as custom_set and custom_unset at the request root. Both are omitted when unset.

ChannelBatchUpdater keeps a single operation-aligned verb, updateData. Its new overload accepts ChannelBatchDataUpdateOptions, which can contain channel data, customSet, and customUnset:

// Patch only, without a null data placeholder
updater.updateData(
    filter,
    ChannelBatchDataUpdateOptions.builder()
        .customSet(Map.of("group", "old"))
        .customUnset(List.of("location_id"))
        .build()).request();

// Channel data and a custom patch in the same request
updater.updateData(
    filter,
    ChannelBatchDataUpdateOptions.builder()
        .data(data)
        .customSet(Map.of("group", "old"))
        .build()).request();

The existing updateData(filter, ChannelDataUpdate) method is unchanged. ChannelBatchDataUpdateOptions is a helper-level type only; the updater unpacks it into the request fields, so custom_set and custom_unset remain 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

  1. ./gradlew test --tests '*ChannelBatchCustomPatchTest*' passes (6 tests, no API credentials needed).
  2. ./gradlew spotlessCheck javadoc passes.

Release note

Batch updateData no longer sends custom: null when custom data is unset, preventing unrelated channel updates from replacing existing custom data.

…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]>
kanat and others added 2 commits September 8, 2026 15:38
…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 mogita left a comment

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.

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());

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

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 40194859ChannelDataUpdate.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);

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.

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

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

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.

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

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.

Added a Release note section to the PR description covering the impact on existing updateData calls.

@kanat
kanat merged commit 7fe96dc into main Sep 9, 2026
5 checks passed
@kanat
kanat deleted the feat/cha-3618-batch-custom-set-unset branch September 9, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants