From 952c8d8efd5a820512a8394561f18ffe43e83529 Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 14:42:51 -0400 Subject: [PATCH 1/6] feat(client): support custom_set and custom_unset in batch channel update 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 --- src/channel_batch_updater.ts | 11 +++- src/client.ts | 4 ++ src/types.ts | 19 +++++++ test/unit/channel_batch_update.test.ts | 78 ++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 test/unit/channel_batch_update.test.ts diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 88ad0bfb1f..041894c411 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -4,6 +4,7 @@ import type { BatchChannelDataUpdate, NewMemberPayload, UpdateChannelsBatchFilters, + UpdateChannelsBatchOptions, UpdateChannelsBatchResponse, } from './types'; @@ -194,18 +195,26 @@ export class ChannelBatchUpdater { /** * updateData - Update data on channels matching the filter * + * `data.custom` replaces the channel's whole custom object. To patch + * individual custom keys instead, pass `custom_set` / `custom_unset`, which + * the client sends at the request root rather than inside `data`; `data` may + * then be omitted. + * * @param {UpdateChannelsBatchFilters} filter Filter to select channels * @param {BatchChannelDataUpdate} data Data to update + * @param {Pick} customPatch Custom keys to merge in or delete * @return {Promise} The server response */ async updateData( filter: UpdateChannelsBatchFilters, - data: BatchChannelDataUpdate, + data?: BatchChannelDataUpdate, + customPatch?: Pick, ): Promise { return await this.client.updateChannelsBatch({ operation: 'updateData', filter, data, + ...customPatch, }); } } diff --git a/src/client.ts b/src/client.ts index dacb34e286..d000ef3baa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5306,6 +5306,10 @@ export class StreamChat { /** * Update Channels Batch * + * For the `updateData` operation, `data.custom` replaces the channel's whole + * custom object, while the root-level `custom_set` / `custom_unset` patch + * individual keys and leave the rest untouched. + * * @param {UpdateChannelsBatchOptions} payload for updating channels in batch * @return {Promise} The server response */ diff --git a/src/types.ts b/src/types.ts index 38f571c139..2d0343c8bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4998,6 +4998,25 @@ export type UpdateChannelsBatchOptions = { filter: UpdateChannelsBatchFilters; members?: string[] | Array; data?: BatchChannelDataUpdate; + /** + * `updateData` only. Merges these keys into each matched channel's existing + * custom object, leaving every other custom key untouched — unlike + * `data.custom`, which replaces the whole object. Keys are dot-paths, so + * `a.b` sets key `b` inside object `a` (the parent object must already + * exist). Cannot be combined with `data.custom`. + * + * Lives at the request root, not inside `data`. + */ + custom_set?: Record; + /** + * `updateData` only. Deletes these keys from each matched channel's existing + * custom object, leaving every other custom key untouched. Keys are + * dot-paths; deleting a key that does not exist is a no-op. Cannot be + * combined with `data.custom`. + * + * Lives at the request root, not inside `data`. + */ + custom_unset?: string[]; }; export type UpdateChannelsBatchFilters = QueryFilters<{ diff --git a/test/unit/channel_batch_update.test.ts b/test/unit/channel_batch_update.test.ts new file mode 100644 index 0000000000..3ec63d7b24 --- /dev/null +++ b/test/unit/channel_batch_update.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StreamChat } from '../../src/client'; +import type { + APIResponse, + UpdateChannelsBatchOptions, + UpdateChannelsBatchResponse, +} from '../../src/types'; + +describe('updateChannelsBatch', () => { + let client: StreamChat; + let putSpy: ReturnType; + + const mockResponse: APIResponse & UpdateChannelsBatchResponse = { + duration: '0.01s', + result: {}, + task_id: 'task-id', + }; + + beforeEach(() => { + client = new StreamChat('api_key', 'api_secret'); + putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); + }); + + it('sends custom_set and custom_unset at the request root', async () => { + const options: UpdateChannelsBatchOptions = { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + custom_set: { group: 'old', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }; + + await client.updateChannelsBatch(options); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + custom_set: { group: 'old', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }); + + // The two fields are siblings of `operation` and `filter`. `data` is an + // extra-fields sink on the v1 routes, so a `custom_set` key sent inside it + // would mean "replace custom with a key named custom_set" instead. + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(Object.keys(body)).toContain('custom_set'); + expect(Object.keys(body)).toContain('custom_unset'); + expect(body.data).toBeUndefined(); + }); + + it('omits custom_set and custom_unset when they are not provided', async () => { + const options: UpdateChannelsBatchOptions = { + operation: 'updateData', + filter: { types: { $eq: 'messaging' } }, + data: { frozen: true }, + }; + + await client.updateChannelsBatch(options); + + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(body).not.toHaveProperty('custom_set'); + expect(body).not.toHaveProperty('custom_unset'); + }); + + it('forwards a custom patch from channelBatchUpdater().updateData to the root', async () => { + await client + .channelBatchUpdater() + .updateData({ cids: { $eq: 'messaging:a' } }, undefined, { + custom_set: { group: 'new' }, + custom_unset: ['location_id'], + }); + + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(body.operation).toBe('updateData'); + expect(body.custom_set).toEqual({ group: 'new' }); + expect(body.custom_unset).toEqual(['location_id']); + expect(body.data).toBeUndefined(); + }); +}); From 814a1dbb7eb79d17b72bf4014550dbcc1f583aa9 Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 15:34:38 -0400 Subject: [PATCH 2/6] feat(client): add channelBatchUpdater().updateCustom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/channel_batch_updater.ts | 42 ++++++++++++++++++++++---- src/types.ts | 10 ++++++ test/unit/channel_batch_update.test.ts | 38 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 041894c411..ec4134a578 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -2,9 +2,9 @@ import type { StreamChat } from './client'; import type { APIResponse, BatchChannelDataUpdate, + ChannelCustomPatch, NewMemberPayload, UpdateChannelsBatchFilters, - UpdateChannelsBatchOptions, UpdateChannelsBatchResponse, } from './types'; @@ -196,19 +196,20 @@ export class ChannelBatchUpdater { * updateData - Update data on channels matching the filter * * `data.custom` replaces the channel's whole custom object. To patch - * individual custom keys instead, pass `custom_set` / `custom_unset`, which - * the client sends at the request root rather than inside `data`; `data` may - * then be omitted. + * individual custom keys alongside other channel data, pass `customPatch`, + * which the client sends at the request root rather than inside `data`; + * `data` may then be omitted. To send a patch on its own, prefer + * `updateCustom`. * * @param {UpdateChannelsBatchFilters} filter Filter to select channels * @param {BatchChannelDataUpdate} data Data to update - * @param {Pick} customPatch Custom keys to merge in or delete + * @param {ChannelCustomPatch} customPatch Custom keys to merge in or delete * @return {Promise} The server response */ async updateData( filter: UpdateChannelsBatchFilters, data?: BatchChannelDataUpdate, - customPatch?: Pick, + customPatch?: ChannelCustomPatch, ): Promise { return await this.client.updateChannelsBatch({ operation: 'updateData', @@ -217,4 +218,33 @@ export class ChannelBatchUpdater { ...customPatch, }); } + + /** + * updateCustom - Patch individual custom keys on channels matching the filter + * + * `customSet` merges its keys into each matched channel's existing custom + * object and `customUnset` deletes its keys, both leaving every other custom + * key untouched — unlike `data.custom`, which replaces the whole object. Keys + * are dot-paths. No `data` is sent, so nothing but the named custom keys + * changes; to update other channel data in the same call, use `updateData`. + * + * The backend owns the rules for the combinations it rejects. + * + * @param {UpdateChannelsBatchFilters} filter Filter to select channels + * @param {Record} customSet Custom keys to merge in + * @param {string[]} customUnset Custom keys to delete + * @return {Promise} The server response + */ + async updateCustom( + filter: UpdateChannelsBatchFilters, + customSet?: Record, + customUnset?: string[], + ): Promise { + return await this.client.updateChannelsBatch({ + operation: 'updateData', + filter, + custom_set: customSet, + custom_unset: customUnset, + }); + } } diff --git a/src/types.ts b/src/types.ts index 2d0343c8bd..99c0f39f2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5019,6 +5019,16 @@ export type UpdateChannelsBatchOptions = { custom_unset?: string[]; }; +/** + * The `custom_set` / `custom_unset` pair of `UpdateChannelsBatchOptions`, as a + * value on its own: a patch of individual custom keys to merge in and to + * delete. See the two fields for their semantics. + */ +export type ChannelCustomPatch = Pick< + UpdateChannelsBatchOptions, + 'custom_set' | 'custom_unset' +>; + export type UpdateChannelsBatchFilters = QueryFilters<{ cids?: | RequireOnlyOne, '$in' | '$eq'>> diff --git a/test/unit/channel_batch_update.test.ts b/test/unit/channel_batch_update.test.ts index 3ec63d7b24..99fb3a6057 100644 --- a/test/unit/channel_batch_update.test.ts +++ b/test/unit/channel_batch_update.test.ts @@ -75,4 +75,42 @@ describe('updateChannelsBatch', () => { expect(body.custom_unset).toEqual(['location_id']); expect(body.data).toBeUndefined(); }); + + it('sends only a custom patch from channelBatchUpdater().updateCustom', async () => { + await client + .channelBatchUpdater() + .updateCustom( + { cids: { $in: ['messaging:a', 'messaging:b'] } }, + { group: 'new', 'expiration.value': 3 }, + ['location_id'], + ); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + custom_set: { group: 'new', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }); + + // Both fields are siblings of `operation` and `filter`, and no `data` key + // is sent at all — a `data.custom` next to a patch is a 400, and `data` is + // the extra-fields sink that would swallow a misplaced `custom_set`. + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(Object.keys(body)).toContain('custom_set'); + expect(Object.keys(body)).toContain('custom_unset'); + expect(Object.keys(body)).not.toContain('data'); + }); + + it('sends updateCustom with only the keys it was given', async () => { + await client + .channelBatchUpdater() + .updateCustom({ types: { $eq: 'messaging' } }, { group: 'new' }); + + const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; + expect(body.operation).toBe('updateData'); + expect(body.filter).toEqual({ types: { $eq: 'messaging' } }); + expect(body.custom_set).toEqual({ group: 'new' }); + expect(body.custom_unset).toBeUndefined(); + expect(Object.keys(body)).not.toContain('data'); + }); }); From 6da2bb6af0070066d1fdddcf50437bca7bcb949b Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 16:02:00 -0400 Subject: [PATCH 3/6] refactor(client): use updateData for custom batch patches --- src/channel_batch_updater.ts | 61 ++++++++++---------------- src/types.ts | 10 ++--- test/unit/channel_batch_update.test.ts | 60 ++++++++++++------------- 3 files changed, 54 insertions(+), 77 deletions(-) diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index ec4134a578..21fd4e99a3 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -2,12 +2,17 @@ import type { StreamChat } from './client'; import type { APIResponse, BatchChannelDataUpdate, - ChannelCustomPatch, + ChannelBatchDataUpdateOptions, NewMemberPayload, UpdateChannelsBatchFilters, UpdateChannelsBatchResponse, } from './types'; +const isChannelBatchDataUpdateOptions = ( + update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, +): update is ChannelBatchDataUpdateOptions => + 'data' in update || 'custom_set' in update || 'custom_unset' in update; + /** * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations */ @@ -196,55 +201,33 @@ export class ChannelBatchUpdater { * updateData - Update data on channels matching the filter * * `data.custom` replaces the channel's whole custom object. To patch - * individual custom keys alongside other channel data, pass `customPatch`, - * which the client sends at the request root rather than inside `data`; - * `data` may then be omitted. To send a patch on its own, prefer - * `updateCustom`. + * individual custom keys, pass an options object with `custom_set` or + * `custom_unset`. The client sends those fields at the request root rather + * than inside `data`. The options object can also include `data` to update + * channel fields in the same request. * * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {BatchChannelDataUpdate} data Data to update - * @param {ChannelCustomPatch} customPatch Custom keys to merge in or delete + * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options * @return {Promise} The server response */ async updateData( filter: UpdateChannelsBatchFilters, - data?: BatchChannelDataUpdate, - customPatch?: ChannelCustomPatch, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'updateData', - filter, - data, - ...customPatch, - }); - } - - /** - * updateCustom - Patch individual custom keys on channels matching the filter - * - * `customSet` merges its keys into each matched channel's existing custom - * object and `customUnset` deletes its keys, both leaving every other custom - * key untouched — unlike `data.custom`, which replaces the whole object. Keys - * are dot-paths. No `data` is sent, so nothing but the named custom keys - * changes; to update other channel data in the same call, use `updateData`. - * - * The backend owns the rules for the combinations it rejects. - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {Record} customSet Custom keys to merge in - * @param {string[]} customUnset Custom keys to delete - * @return {Promise} The server response - */ - async updateCustom( + data: BatchChannelDataUpdate, + ): Promise; + async updateData( + filter: UpdateChannelsBatchFilters, + options: ChannelBatchDataUpdateOptions, + ): Promise; + async updateData( filter: UpdateChannelsBatchFilters, - customSet?: Record, - customUnset?: string[], + update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, ): Promise { + const options = isChannelBatchDataUpdateOptions(update) ? update : { data: update }; + return await this.client.updateChannelsBatch({ operation: 'updateData', filter, - custom_set: customSet, - custom_unset: customUnset, + ...options, }); } } diff --git a/src/types.ts b/src/types.ts index 99c0f39f2f..33b1618464 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5020,13 +5020,13 @@ export type UpdateChannelsBatchOptions = { }; /** - * The `custom_set` / `custom_unset` pair of `UpdateChannelsBatchOptions`, as a - * value on its own: a patch of individual custom keys to merge in and to - * delete. See the two fields for their semantics. + * Options for {@link ChannelBatchUpdater.updateData}. `custom_set` and + * `custom_unset` are sent at the request root, while `data` contains the + * channel fields to update. */ -export type ChannelCustomPatch = Pick< +export type ChannelBatchDataUpdateOptions = Pick< UpdateChannelsBatchOptions, - 'custom_set' | 'custom_unset' + 'data' | 'custom_set' | 'custom_unset' >; export type UpdateChannelsBatchFilters = QueryFilters<{ diff --git a/test/unit/channel_batch_update.test.ts b/test/unit/channel_batch_update.test.ts index 99fb3a6057..8969607cbe 100644 --- a/test/unit/channel_batch_update.test.ts +++ b/test/unit/channel_batch_update.test.ts @@ -61,56 +61,50 @@ describe('updateChannelsBatch', () => { expect(body).not.toHaveProperty('custom_unset'); }); - it('forwards a custom patch from channelBatchUpdater().updateData to the root', async () => { + it('keeps the existing channelBatchUpdater().updateData data argument', async () => { await client .channelBatchUpdater() - .updateData({ cids: { $eq: 'messaging:a' } }, undefined, { + .updateData({ cids: { $eq: 'messaging:a' } }, { frozen: true }); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter: { cids: { $eq: 'messaging:a' } }, + data: { frozen: true }, + }); + }); + + it('forwards a custom patch from channelBatchUpdater().updateData to the root', async () => { + await client.channelBatchUpdater().updateData( + { cids: { $eq: 'messaging:a' } }, + { custom_set: { group: 'new' }, custom_unset: ['location_id'], - }); + }, + ); const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; expect(body.operation).toBe('updateData'); expect(body.custom_set).toEqual({ group: 'new' }); expect(body.custom_unset).toEqual(['location_id']); - expect(body.data).toBeUndefined(); + expect(body).not.toHaveProperty('data'); }); - it('sends only a custom patch from channelBatchUpdater().updateCustom', async () => { - await client - .channelBatchUpdater() - .updateCustom( - { cids: { $in: ['messaging:a', 'messaging:b'] } }, - { group: 'new', 'expiration.value': 3 }, - ['location_id'], - ); + it('updates channel data and custom fields in one updateData call', async () => { + await client.channelBatchUpdater().updateData( + { cids: { $in: ['messaging:a', 'messaging:b'] } }, + { + data: { frozen: true }, + custom_set: { group: 'new', 'expiration.value': 3 }, + custom_unset: ['location_id'], + }, + ); expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { operation: 'updateData', filter: { cids: { $in: ['messaging:a', 'messaging:b'] } }, + data: { frozen: true }, custom_set: { group: 'new', 'expiration.value': 3 }, custom_unset: ['location_id'], }); - - // Both fields are siblings of `operation` and `filter`, and no `data` key - // is sent at all — a `data.custom` next to a patch is a 400, and `data` is - // the extra-fields sink that would swallow a misplaced `custom_set`. - const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; - expect(Object.keys(body)).toContain('custom_set'); - expect(Object.keys(body)).toContain('custom_unset'); - expect(Object.keys(body)).not.toContain('data'); - }); - - it('sends updateCustom with only the keys it was given', async () => { - await client - .channelBatchUpdater() - .updateCustom({ types: { $eq: 'messaging' } }, { group: 'new' }); - - const body = putSpy.mock.calls[0][1] as UpdateChannelsBatchOptions; - expect(body.operation).toBe('updateData'); - expect(body.filter).toEqual({ types: { $eq: 'messaging' } }); - expect(body.custom_set).toEqual({ group: 'new' }); - expect(body.custom_unset).toBeUndefined(); - expect(Object.keys(body)).not.toContain('data'); }); }); From 049259b057a979be8e3718a16111de838c05a4cc Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 16:14:01 -0400 Subject: [PATCH 4/6] refactor(client): simplify batch update signature --- src/channel_batch_updater.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 21fd4e99a3..558cc62bd5 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -210,14 +210,6 @@ export class ChannelBatchUpdater { * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options * @return {Promise} The server response */ - async updateData( - filter: UpdateChannelsBatchFilters, - data: BatchChannelDataUpdate, - ): Promise; - async updateData( - filter: UpdateChannelsBatchFilters, - options: ChannelBatchDataUpdateOptions, - ): Promise; async updateData( filter: UpdateChannelsBatchFilters, update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, From e8a082cbd764fb259f8ab1669052e8b574f1782b Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 9 Sep 2026 10:56:55 -0400 Subject: [PATCH 5/6] fix(client): reject mixed batch update arguments 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. --- src/channel_batch_updater.ts | 8 ++++++++ test/typescript/unit-test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 558cc62bd5..21fd4e99a3 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -210,6 +210,14 @@ export class ChannelBatchUpdater { * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options * @return {Promise} The server response */ + async updateData( + filter: UpdateChannelsBatchFilters, + data: BatchChannelDataUpdate, + ): Promise; + async updateData( + filter: UpdateChannelsBatchFilters, + options: ChannelBatchDataUpdateOptions, + ): Promise; async updateData( filter: UpdateChannelsBatchFilters, update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, diff --git a/test/typescript/unit-test.ts b/test/typescript/unit-test.ts index 61f11e118d..3957f12c06 100644 --- a/test/typescript/unit-test.ts +++ b/test/typescript/unit-test.ts @@ -88,6 +88,19 @@ const singletonClient2: StreamChat = StreamChat.getInstance(apiKey, { timeout: 3000, }); +const channelBatchUpdater = client.channelBatchUpdater(); +channelBatchUpdater.updateData({}, { frozen: true }); +channelBatchUpdater.updateData({}, { custom_set: { group: 'new' } }); +channelBatchUpdater.updateData( + {}, + { + data: { frozen: true }, + custom_set: { group: 'new' }, + }, +); +// @ts-expect-error channel data fields must be nested under data in the options form +channelBatchUpdater.updateData({}, { frozen: true, custom_set: { group: 'new' } }); + const devToken: string = client.devToken('joshua'); const token: string = client.createToken('james', 3600); const authType: string = client.getAuthType(); From 5512b213299e0d9742172bfa6244455eee2415c6 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 9 Sep 2026 11:31:20 -0400 Subject: [PATCH 6/6] fix(client): whitelist batch update option 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. --- src/channel_batch_updater.ts | 8 ++++++-- test/unit/channel_batch_update.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 21fd4e99a3..6efda06016 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -222,12 +222,16 @@ export class ChannelBatchUpdater { filter: UpdateChannelsBatchFilters, update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions, ): Promise { - const options = isChannelBatchDataUpdateOptions(update) ? update : { data: update }; + const { data, custom_set, custom_unset } = isChannelBatchDataUpdateOptions(update) + ? update + : { data: update }; return await this.client.updateChannelsBatch({ operation: 'updateData', filter, - ...options, + ...(data && { data }), + ...(custom_set && { custom_set }), + ...(custom_unset && { custom_unset }), }); } } diff --git a/test/unit/channel_batch_update.test.ts b/test/unit/channel_batch_update.test.ts index 8969607cbe..4ed5f28ce9 100644 --- a/test/unit/channel_batch_update.test.ts +++ b/test/unit/channel_batch_update.test.ts @@ -107,4 +107,21 @@ describe('updateChannelsBatch', () => { custom_unset: ['location_id'], }); }); + + it('does not let update options override the operation or filter', async () => { + const filter = { cids: { $eq: 'messaging:a' } } as const; + const options = { + operation: 'hide' as const, + filter: { cids: { $eq: 'messaging:b' } } as const, + data: { frozen: true }, + }; + + await client.channelBatchUpdater().updateData(filter, options); + + expect(putSpy).toHaveBeenCalledWith(`${client.baseURL}/channels/batch`, { + operation: 'updateData', + filter, + data: { frozen: true }, + }); + }); });