Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/channel_batch_updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import type { StreamChat } from './client';
import type {
APIResponse,
BatchChannelDataUpdate,
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
*/
Expand Down Expand Up @@ -194,18 +200,38 @@ 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, 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 {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options
* @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
*/
async updateData(
filter: UpdateChannelsBatchFilters,
data: BatchChannelDataUpdate,
): Promise<APIResponse & UpdateChannelsBatchResponse>;
async updateData(
filter: UpdateChannelsBatchFilters,
options: ChannelBatchDataUpdateOptions,
): Promise<APIResponse & UpdateChannelsBatchResponse>;
async updateData(
filter: UpdateChannelsBatchFilters,
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.

): Promise<APIResponse & UpdateChannelsBatchResponse> {
const { data, custom_set, custom_unset } = isChannelBatchDataUpdateOptions(update)
? update
: { data: update };

return await this.client.updateChannelsBatch({
operation: 'updateData',
filter,
data,
...(data && { data }),
...(custom_set && { custom_set }),
...(custom_unset && { custom_unset }),
});
}
}
4 changes: 4 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<APIResponse & UpdateChannelsBatchResponse>} The server response
*/
Expand Down
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4998,8 +4998,37 @@ export type UpdateChannelsBatchOptions = {
filter: UpdateChannelsBatchFilters;
members?: string[] | Array<NewMemberPayload>;
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<string, unknown>;
/**
* `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[];
};

/**
* 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 ChannelBatchDataUpdateOptions = Pick<
UpdateChannelsBatchOptions,
'data' | 'custom_set' | 'custom_unset'
>;

export type UpdateChannelsBatchFilters = QueryFilters<{
cids?:
| RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>>
Expand Down
13 changes: 13 additions & 0 deletions test/typescript/unit-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
127 changes: 127 additions & 0 deletions test/unit/channel_batch_update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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<typeof vi.spyOn>;

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('keeps the existing channelBatchUpdater().updateData data argument', async () => {
await client
.channelBatchUpdater()
.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).not.toHaveProperty('data');
});

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'],
});
});

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 },
});
});
});