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

feat(client): support custom_set and custom_unset in batch channel update#1856
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
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