stream-chat 9.50.2 → 9.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cjs/index.browser.js +252 -94
  2. package/dist/cjs/index.browser.js.map +3 -3
  3. package/dist/cjs/index.node.js +252 -94
  4. package/dist/cjs/index.node.js.map +3 -3
  5. package/dist/esm/index.mjs +252 -94
  6. package/dist/esm/index.mjs.map +3 -3
  7. package/dist/types/channel.d.ts +3 -2
  8. package/dist/types/client.d.ts +13 -9
  9. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +15 -9
  10. package/dist/types/offline-support/offline_support_api.d.ts +7 -1
  11. package/dist/types/offline-support/offline_sync_manager.d.ts +19 -1
  12. package/dist/types/search/BaseSearchSource.d.ts +42 -24
  13. package/dist/types/search/ChannelMemberSearchSource.d.ts +9 -4
  14. package/dist/types/search/ChannelSearchSource.d.ts +9 -3
  15. package/dist/types/search/MessageSearchSource.d.ts +14 -3
  16. package/dist/types/search/UserSearchSource.d.ts +9 -3
  17. package/dist/types/search/types.d.ts +14 -1
  18. package/dist/types/types.d.ts +20 -1
  19. package/dist/types/utils.d.ts +1 -1
  20. package/package.json +7 -7
  21. package/src/channel.ts +4 -0
  22. package/src/client.ts +54 -12
  23. package/src/messageComposer/middleware/textComposer/mentions.ts +75 -23
  24. package/src/offline-support/offline_support_api.ts +17 -2
  25. package/src/offline-support/offline_sync_manager.ts +73 -14
  26. package/src/search/BaseSearchSource.ts +123 -39
  27. package/src/search/ChannelMemberSearchSource.ts +22 -11
  28. package/src/search/ChannelSearchSource.ts +16 -5
  29. package/src/search/MessageSearchSource.ts +36 -4
  30. package/src/search/UserSearchSource.ts +16 -5
  31. package/src/search/types.ts +14 -1
  32. package/src/types.ts +21 -1
  33. package/src/utils.ts +6 -3
package/src/channel.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  QueryMembersOptions,
63
63
  Reaction,
64
64
  ReactionAPIResponse,
65
+ RequestOptions,
65
66
  SearchAPIResponse,
66
67
  SearchMessageSortBase,
67
68
  SearchOptions,
@@ -377,6 +378,7 @@ export class Channel {
377
378
  * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}].
378
379
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}]
379
380
  * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10}
381
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
380
382
  *
381
383
  * @return {Promise<ChannelMemberAPIResponse>} Query Members response
382
384
  */
@@ -384,6 +386,7 @@ export class Channel {
384
386
  filterConditions: MemberFilters,
385
387
  sort: MemberSort = [],
386
388
  options: QueryMembersOptions = {},
389
+ requestOptions: RequestOptions = {},
387
390
  ) {
388
391
  let id: string | undefined;
389
392
  const type = this.type;
@@ -406,6 +409,7 @@ export class Channel {
406
409
  ...options,
407
410
  },
408
411
  },
412
+ requestOptions,
409
413
  );
410
414
  }
411
415
 
package/src/client.ts CHANGED
@@ -219,6 +219,7 @@ import type {
219
219
  ReminderAPIResponse,
220
220
  RemoveUserGroupMembersOptions,
221
221
  RemoveUserGroupMembersResponse,
222
+ RequestOptions,
222
223
  ReviewFlagReportOptions,
223
224
  ReviewFlagReportResponse,
224
225
  SdkIdentifier,
@@ -1312,6 +1313,9 @@ export class StreamChat {
1312
1313
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1313
1314
  } catch (e: any /**TODO: generalize error types */) {
1314
1315
  e.client_request_id = requestConfig.headers?.['x-client-request-id'];
1316
+ // An aborted request is a deliberate cancellation, not a failure - logging it
1317
+ // and counting it towards the token-refresh backoff would be misleading.
1318
+ if (axios.isCancel(e)) throw e;
1315
1319
  this._logApiError(type, url, e);
1316
1320
  this.consecutiveFailures += 1;
1317
1321
  if (e.response) {
@@ -1333,16 +1337,22 @@ export class StreamChat {
1333
1337
  }
1334
1338
  };
1335
1339
 
1336
- get<T>(url: string, params?: AxiosRequestConfig['params']) {
1337
- return this.doAxiosRequest<T>('get', url, null, { params });
1340
+ get<T>(
1341
+ url: string,
1342
+ params?: AxiosRequestConfig['params'],
1343
+ // `params` is omitted deliberately: _enrichAxiosOptions spreads `config` after the
1344
+ // enriched params, so a `config.params` would drop api_key/user_id/connection_id.
1345
+ config?: Omit<AxiosRequestConfig, 'params'>,
1346
+ ) {
1347
+ return this.doAxiosRequest<T>('get', url, null, { params, config });
1338
1348
  }
1339
1349
 
1340
1350
  put<T>(url: string, data?: unknown) {
1341
1351
  return this.doAxiosRequest<T>('put', url, data);
1342
1352
  }
1343
1353
 
1344
- post<T>(url: string, data?: unknown) {
1345
- return this.doAxiosRequest<T>('post', url, data);
1354
+ post<T>(url: string, data?: unknown, config?: Omit<AxiosRequestConfig, 'params'>) {
1355
+ return this.doAxiosRequest<T>('post', url, data, { config });
1346
1356
  }
1347
1357
 
1348
1358
  patch<T>(url: string, data?: unknown) {
@@ -1837,6 +1847,7 @@ export class StreamChat {
1837
1847
  * @param {UserSort} sort Sort options, for instance [{last_active: -1}].
1838
1848
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}]
1839
1849
  * @param {UserOptions} options Option object, {presence: true}
1850
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
1840
1851
  *
1841
1852
  * @return {Promise<{ users: Array<UserResponse> }>} User Query Response
1842
1853
  */
@@ -1844,6 +1855,7 @@ export class StreamChat {
1844
1855
  filterConditions: UserFilters,
1845
1856
  sort: UserSort = [],
1846
1857
  options: UserOptions = {},
1858
+ requestOptions: RequestOptions = {},
1847
1859
  ) {
1848
1860
  const defaultOptions = {
1849
1861
  presence: false,
@@ -1867,6 +1879,7 @@ export class StreamChat {
1867
1879
  ...options,
1868
1880
  },
1869
1881
  },
1882
+ requestOptions,
1870
1883
  );
1871
1884
 
1872
1885
  this.state.updateUsers(data.users);
@@ -1918,13 +1931,17 @@ export class StreamChat {
1918
1931
  * searchUserGroups - Search user groups by prefix for autocomplete
1919
1932
  *
1920
1933
  * @param {SearchUserGroupsOptions} options The search options
1921
- *
1934
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
1922
1935
  * @return {Promise<SearchUserGroupsResponse>} User Group Search Response
1923
1936
  */
1924
- async searchUserGroups(options: SearchUserGroupsOptions) {
1937
+ async searchUserGroups(
1938
+ options: SearchUserGroupsOptions,
1939
+ requestOptions: RequestOptions = {},
1940
+ ) {
1925
1941
  return await this.get<SearchUserGroupsResponse>(
1926
1942
  this.baseURL + '/usergroups/search',
1927
1943
  options,
1944
+ requestOptions,
1928
1945
  );
1929
1946
  }
1930
1947
 
@@ -2061,6 +2078,7 @@ export class StreamChat {
2061
2078
  * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}.
2062
2079
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}]
2063
2080
  * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters.
2081
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
2064
2082
  *
2065
2083
  * @return {Promise<QueryChannelsAPIResponse>} full search channels response
2066
2084
  */
@@ -2068,6 +2086,7 @@ export class StreamChat {
2068
2086
  filterConditions: ChannelFilters,
2069
2087
  sort: ChannelSort = [],
2070
2088
  options: ChannelOptions = {},
2089
+ requestOptions: RequestOptions = {},
2071
2090
  ): Promise<QueryChannelsAPIResponse> {
2072
2091
  const defaultOptions: ChannelOptions = {
2073
2092
  state: true,
@@ -2101,7 +2120,11 @@ export class StreamChat {
2101
2120
  ...restOptions,
2102
2121
  };
2103
2122
 
2104
- return await this.post<QueryChannelsAPIResponse>(this.baseURL + '/channels', payload);
2123
+ return await this.post<QueryChannelsAPIResponse>(
2124
+ this.baseURL + '/channels',
2125
+ payload,
2126
+ requestOptions,
2127
+ );
2105
2128
  }
2106
2129
 
2107
2130
  /**
@@ -2147,6 +2170,7 @@ export class StreamChat {
2147
2170
  * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list.
2148
2171
  * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response.
2149
2172
  * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`.
2173
+ * - stateOptions.signal - Aborts the request. See AbortController.
2150
2174
  *
2151
2175
  * @return {Promise<Array<Channel>>} search channels response
2152
2176
  */
@@ -2172,6 +2196,8 @@ export class StreamChat {
2172
2196
  filterConditions,
2173
2197
  sort,
2174
2198
  options,
2199
+ // stateOptions is never serialized, so it carries the abort signal
2200
+ { signal: stateOptions.signal },
2175
2201
  );
2176
2202
  const channels = queryChannelsResponse.channels;
2177
2203
 
@@ -2317,6 +2343,7 @@ export class StreamChat {
2317
2343
  * @param {ChannelFilters} filterConditions MongoDB style filter conditions
2318
2344
  * @param {MessageFilters | string} query search query or object MongoDB style filters
2319
2345
  * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'}
2346
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
2320
2347
  *
2321
2348
  * @return {Promise<SearchAPIResponse>} search messages response
2322
2349
  */
@@ -2324,6 +2351,7 @@ export class StreamChat {
2324
2351
  filterConditions: ChannelFilters,
2325
2352
  query: string | MessageFilters,
2326
2353
  options: SearchOptions = {},
2354
+ requestOptions: RequestOptions = {},
2327
2355
  ) {
2328
2356
  if (options.offset && options.next) {
2329
2357
  throw Error(`Cannot specify offset with next`);
@@ -2346,7 +2374,11 @@ export class StreamChat {
2346
2374
  // Make sure we wait for the connect promise if there is a pending one
2347
2375
  await this.wsPromise;
2348
2376
 
2349
- return await this.get<SearchAPIResponse>(this.baseURL + '/search', { payload });
2377
+ return await this.get<SearchAPIResponse>(
2378
+ this.baseURL + '/search',
2379
+ { payload },
2380
+ requestOptions,
2381
+ );
2350
2382
  }
2351
2383
 
2352
2384
  /**
@@ -3806,6 +3838,12 @@ export class StreamChat {
3806
3838
  ...axiosRequestConfigRest
3807
3839
  } = this.options.axiosRequestConfig || {};
3808
3840
 
3841
+ // Most specific wins, and it is spread last so that a config carrying an explicit
3842
+ // `signal: undefined` cannot clobber a client-wide one or an armed
3843
+ // nextRequestAbortController.
3844
+ const resolvedSignal =
3845
+ options.config?.signal ?? axiosRequestConfigRest.signal ?? signal;
3846
+
3809
3847
  return {
3810
3848
  params: {
3811
3849
  user_id: this.userID,
@@ -3821,9 +3859,9 @@ export class StreamChat {
3821
3859
  ...options.headers,
3822
3860
  ...(axiosRequestConfigHeaders || {}),
3823
3861
  },
3824
- ...(signal ? { signal } : {}),
3825
3862
  ...options.config,
3826
- ...(axiosRequestConfigRest || {}),
3863
+ ...axiosRequestConfigRest,
3864
+ ...(resolvedSignal ? { signal: resolvedSignal } : {}),
3827
3865
  };
3828
3866
  }
3829
3867
 
@@ -3993,8 +4031,12 @@ export class StreamChat {
3993
4031
  *
3994
4032
  * @returns {Promise<APIResponse>}
3995
4033
  */
3996
- searchRoles(options: SearchRolesOptions) {
3997
- return this.get<SearchRolesAPIResponse>(`${this.baseURL}/roles/search`, options);
4034
+ searchRoles(options: SearchRolesOptions, requestOptions: RequestOptions = {}) {
4035
+ return this.get<SearchRolesAPIResponse>(
4036
+ `${this.baseURL}/roles/search`,
4037
+ options,
4038
+ requestOptions,
4039
+ );
3998
4040
  }
3999
4041
 
4000
4042
  /** deleteRole - deletes a custom role
@@ -9,7 +9,11 @@ import {
9
9
  userSuggestionToMentionEntity,
10
10
  userSuggestionToUserResponse,
11
11
  } from './mentionUtils';
12
- import { BaseSearchSource, type SearchSourceOptions } from '../../../search';
12
+ import {
13
+ BaseSearchSource,
14
+ type SearchQueryOptions,
15
+ type SearchSourceOptions,
16
+ } from '../../../search';
13
17
  import { mergeWith } from '../../../utils/mergeWith';
14
18
  import type {
15
19
  ChannelMentionSuggestion,
@@ -92,6 +96,13 @@ export const calculateLevenshtein = (query: string, name: string) => {
92
96
  };
93
97
 
94
98
  export type MentionsSearchSourceOptions = SearchSourceOptions & {
99
+ /** Static base filters for the app-wide user query (mentionAllAppUsers). */
100
+ userFilters?: UserFilters;
101
+ userSort?: UserSort;
102
+ /** Static base filters for the channel member query. */
103
+ memberFilters?: MemberFilters;
104
+ memberSort?: MemberSort;
105
+ searchOptions?: Omit<UserOptions, 'limit' | 'offset'>;
95
106
  mentionAllAppUsers?: boolean;
96
107
  suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides;
97
108
  textComposerText?: string;
@@ -299,20 +310,34 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
299
310
  userSort: UserSort | undefined;
300
311
  memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members
301
312
  searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined;
302
- config: MentionsSearchSourceOptions;
313
+ config: Pick<
314
+ MentionsSearchSourceOptions,
315
+ 'mentionAllAppUsers' | 'suggestionFactoryMappers' | 'textComposerText' | 'trigger'
316
+ >;
303
317
 
304
318
  constructor(channel: Channel, options?: MentionsSearchSourceOptions) {
305
319
  const {
306
320
  mentionAllAppUsers,
321
+ memberFilters,
322
+ memberSort,
323
+ searchOptions,
307
324
  suggestionFactoryMappers,
308
325
  textComposerText,
309
326
  transliterate,
310
327
  trigger,
328
+ userFilters,
329
+ userSort,
311
330
  ...restOptions
312
331
  } = options || {};
313
- super(restOptions);
332
+ // suggestions are shown for a bare trigger, so the empty query must be allowed
333
+ super({ ...restOptions, allowEmptySearchString: true });
314
334
  this.client = channel.getClient();
315
335
  this.channel = channel;
336
+ this.userFilters = userFilters;
337
+ this.userSort = userSort;
338
+ this.memberFilters = memberFilters;
339
+ this.memberSort = memberSort;
340
+ this.searchOptions = searchOptions;
316
341
  this.config = {
317
342
  mentionAllAppUsers,
318
343
  suggestionFactoryMappers,
@@ -425,11 +450,6 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
425
450
  };
426
451
  }
427
452
 
428
- canExecuteQuery = (newSearchString?: string) => {
429
- const hasNewSearchQuery = typeof newSearchString !== 'undefined';
430
- return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext);
431
- };
432
-
433
453
  protected updatePaginationStateFromQuery() {
434
454
  const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 };
435
455
 
@@ -472,10 +492,13 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
472
492
  : []),
473
493
  ].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery));
474
494
 
475
- getRoleMentionSuggestions = async (query: string): Promise<RoleMentionSuggestion[]> => {
495
+ getRoleMentionSuggestions = async (
496
+ query: string,
497
+ queryOptions: SearchQueryOptions = {},
498
+ ): Promise<RoleMentionSuggestion[]> => {
476
499
  if (!this.isMentionTypeAllowed('role')) return [];
477
500
  if (!query) return [];
478
- const { roles } = await this.client.searchRoles({ query });
501
+ const { roles } = await this.client.searchRoles({ query }, queryOptions);
479
502
  return [...(roles?.map((role) => role.name) ?? [])]
480
503
  .sort((left, right) => left.localeCompare(right))
481
504
  .map((role) => this.toRoleMentionSuggestion(role, query));
@@ -558,23 +581,40 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
558
581
  };
559
582
  };
560
583
 
561
- queryUsers = async (searchQuery: string, offset = 0) => {
584
+ queryUsers = async (
585
+ searchQuery: string,
586
+ offset = 0,
587
+ queryOptions: SearchQueryOptions = {},
588
+ ) => {
562
589
  const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset);
563
- const { users } = await this.client.queryUsers(filters, sort, options);
590
+ const { users } = await this.client.queryUsers(filters, sort, options, queryOptions);
564
591
  return users;
565
592
  };
566
593
 
567
- queryMembers = async (searchQuery: string, offset = 0) => {
594
+ queryMembers = async (
595
+ searchQuery: string,
596
+ offset = 0,
597
+ queryOptions: SearchQueryOptions = {},
598
+ ) => {
568
599
  const { filters, sort, options } = this.prepareQueryMembersParams(
569
600
  searchQuery,
570
601
  offset,
571
602
  );
572
- const response = await this.channel.queryMembers(filters, sort, options);
603
+ const response = await this.channel.queryMembers(
604
+ filters,
605
+ sort,
606
+ options,
607
+ queryOptions,
608
+ );
573
609
 
574
610
  return response.members.map((member) => member.user) as UserResponse[];
575
611
  };
576
612
 
577
- getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => {
613
+ getUserSuggestionsPage = async (
614
+ searchQuery: string,
615
+ userOffset = 0,
616
+ queryOptions: SearchQueryOptions = {},
617
+ ) => {
578
618
  if (!this.isMentionTypeAllowed('user')) {
579
619
  return {
580
620
  items: [],
@@ -587,7 +627,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
587
627
  this.allMembersLoadedWithInitialChannelQuery || !searchQuery;
588
628
 
589
629
  if (this.config.mentionAllAppUsers) {
590
- users = await this.queryUsers(searchQuery, userOffset);
630
+ users = await this.queryUsers(searchQuery, userOffset, queryOptions);
591
631
  } else if (shouldSearchLocally) {
592
632
  const localUsers = this.searchMembersLocally(searchQuery);
593
633
  const items = localUsers
@@ -601,7 +641,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
601
641
  : undefined,
602
642
  };
603
643
  } else {
604
- users = await this.queryMembers(searchQuery, userOffset);
644
+ users = await this.queryMembers(searchQuery, userOffset, queryOptions);
605
645
  }
606
646
 
607
647
  const items = users.map((user) => this.toUserSuggestion(user, searchQuery));
@@ -623,7 +663,11 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
623
663
  } satisfies UserGroupSearchCursor);
624
664
  };
625
665
 
626
- getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => {
666
+ getUserGroupSuggestionsPage = async (
667
+ searchQuery: string,
668
+ cursor?: string,
669
+ queryOptions: SearchQueryOptions = {},
670
+ ) => {
627
671
  if (!this.isMentionTypeAllowed('user_group')) {
628
672
  return {
629
673
  items: [],
@@ -647,7 +691,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
647
691
  ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}),
648
692
  ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}),
649
693
  };
650
- const { user_groups } = await this.client.searchUserGroups(options);
694
+ const { user_groups } = await this.client.searchUserGroups(options, queryOptions);
651
695
 
652
696
  return {
653
697
  items: user_groups.map((userGroup) =>
@@ -657,20 +701,28 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
657
701
  };
658
702
  };
659
703
 
660
- async query(searchQuery: string) {
704
+ async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
661
705
  const userOffset = this.offset ?? 0;
662
706
  const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined';
663
707
  const previousUserPaginationState = this.latestUserPaginationState;
664
708
  const previousUserGroupCursor = this.userGroupCursor;
665
709
  const [userResultsState, userGroupResultsState, roleSuggestionsState] =
666
710
  await Promise.allSettled([
667
- this.getUserSuggestionsPage(searchQuery, userOffset),
668
- this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor),
711
+ this.getUserSuggestionsPage(searchQuery, userOffset, queryOptions),
712
+ this.getUserGroupSuggestionsPage(
713
+ searchQuery,
714
+ previousUserGroupCursor,
715
+ queryOptions,
716
+ ),
669
717
  isFirstPage
670
- ? this.getRoleMentionSuggestions(searchQuery)
718
+ ? this.getRoleMentionSuggestions(searchQuery, queryOptions)
671
719
  : Promise.resolve([] as RoleMentionSuggestion[]),
672
720
  ]);
673
721
 
722
+ // On abort the requests above reject and the fallback branches below would write
723
+ // empty results into the pagination cursors, corrupting them for the newer query.
724
+ if (queryOptions.signal?.aborted) return { items: [] };
725
+
674
726
  const userResults =
675
727
  userResultsState.status === 'fulfilled'
676
728
  ? userResultsState.value
@@ -40,9 +40,24 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
40
40
  public syncManager: OfflineDBSyncManager;
41
41
  public state: StateStore<OfflineDBState>;
42
42
 
43
- constructor({ client }: { client: StreamChat }) {
43
+ constructor({
44
+ client,
45
+ syncMaxEventCount,
46
+ }: {
47
+ client: StreamChat;
48
+ /**
49
+ * Optional positive cap forwarded to the {@link OfflineDBSyncManager}, which
50
+ * owns sync. `undefined` or any non-positive value means "no limit". See
51
+ * {@link OfflineDBSyncManager.syncMaxEventCount}.
52
+ */
53
+ syncMaxEventCount?: number;
54
+ }) {
44
55
  this.client = client;
45
- this.syncManager = new OfflineDBSyncManager({ client, offlineDb: this });
56
+ this.syncManager = new OfflineDBSyncManager({
57
+ client,
58
+ offlineDb: this,
59
+ syncMaxEventCount,
60
+ });
46
61
  this.state = new StateStore<OfflineDBState>({
47
62
  initialized: false,
48
63
  userId: this.client.userID,
@@ -20,16 +20,29 @@ export class OfflineDBSyncManager {
20
20
  new Map();
21
21
  private client: StreamChat;
22
22
  private offlineDb: AbstractOfflineDB;
23
+ /**
24
+ * Optional positive cap on the number of events a single `/sync` response may
25
+ * contain before this manager skips event replay into local storage and relies
26
+ * on the reconnect-time channel refresh (queryChannels + `channel.watch()`) to
27
+ * bring visible surfaces up to date.
28
+ *
29
+ * `undefined` or any non-positive value means "no limit" and event replay always
30
+ * runs with whatever maximum the server decides to return.
31
+ */
32
+ public readonly syncMaxEventCount?: number;
23
33
 
24
34
  constructor({
25
35
  client,
26
36
  offlineDb,
37
+ syncMaxEventCount,
27
38
  }: {
28
39
  client: StreamChat;
29
40
  offlineDb: AbstractOfflineDB;
41
+ syncMaxEventCount?: number;
30
42
  }) {
31
43
  this.client = client;
32
44
  this.offlineDb = offlineDb;
45
+ this.syncMaxEventCount = syncMaxEventCount;
33
46
  }
34
47
 
35
48
  /**
@@ -111,14 +124,27 @@ export class OfflineDBSyncManager {
111
124
  */
112
125
  private invokeSyncStatusListeners = async (status: boolean) => {
113
126
  this.syncStatus = status;
114
- this.syncStatusListeners.forEach((l) => l(status));
127
+ this.syncStatusListeners.forEach((l) => {
128
+ try {
129
+ l(status);
130
+ } catch (error) {
131
+ console.log('Error in a sync status listener.', error);
132
+ }
133
+ });
115
134
 
116
135
  if (status) {
117
- const promises = Array.from(this.scheduledSyncStatusCallbacks.values()).map((cb) =>
118
- cb(),
136
+ const promises = Array.from(this.scheduledSyncStatusCallbacks.values()).map(
137
+ async (cb) => {
138
+ try {
139
+ await cb();
140
+ } catch (error) {
141
+ console.log('Error executing a scheduled sync status callback.', error);
142
+ }
143
+ },
119
144
  );
145
+ // Every callback is isolated above, so Promise.all never rejects and clear()
146
+ // always runs (no double-execution on the next sync).
120
147
  await Promise.all(promises);
121
-
122
148
  this.scheduledSyncStatusCallbacks.clear();
123
149
  }
124
150
  };
@@ -160,14 +186,29 @@ export class OfflineDBSyncManager {
160
186
  await this.offlineDb.resetDB();
161
187
  } else {
162
188
  const result = await this.client.sync(cids, lastSyncedAtDate.toISOString());
163
- const queryPromises = result.events.map((event) =>
164
- this.offlineDb.handleEvent({ event, execute: false }),
165
- );
166
- const queriesArray = await Promise.all(queryPromises);
167
- const queries = queriesArray.flat() as ExecuteBatchDBQueriesType;
168
-
169
- if (queries.length) {
170
- await this.offlineDb.executeSqlBatch(queries);
189
+
190
+ // Opt-in positive cap owned by this manager; undefined/non-positive = no limit.
191
+ const { syncMaxEventCount } = this;
192
+ const exceedsLimit =
193
+ typeof syncMaxEventCount === 'number' &&
194
+ syncMaxEventCount > 0 &&
195
+ result.events.length > syncMaxEventCount;
196
+ if (exceedsLimit) {
197
+ this.client.logger(
198
+ 'warn',
199
+ `Skipping sync event replay: received ${result.events.length} events, which exceeds the configured limit of ${syncMaxEventCount}. Visible channels are refreshed on reconnect instead.`,
200
+ { tags: ['sync', 'offline'] },
201
+ );
202
+ } else {
203
+ const queryPromises = result.events.map((event) =>
204
+ this.offlineDb.handleEvent({ event, execute: false }),
205
+ );
206
+ const queriesArray = await Promise.all(queryPromises);
207
+ const queries = queriesArray.flat() as ExecuteBatchDBQueriesType;
208
+
209
+ if (queries.length) {
210
+ await this.offlineDb.executeSqlBatch(queries);
211
+ }
171
212
  }
172
213
  }
173
214
  }
@@ -198,9 +239,27 @@ export class OfflineDBSyncManager {
198
239
 
199
240
  /**
200
241
  * Executes any tasks that were queued while offline and then performs a sync.
242
+ *
243
+ * Each step is isolated so a failure in one does not prevent the other, and
244
+ * neither can escape to the callers (init + the connection.changed handler).
245
+ * This guarantees the subsequent invokeSyncStatusListeners(true) always runs,
246
+ * so syncStatus recovers to true and gated channel queries are unblocked.
247
+ * Failed syncs degrade to "possibly stale data until the next query" rather
248
+ * than freezing all future queries. See issue #1816.
201
249
  */
202
250
  private syncAndExecutePendingTasks = async () => {
203
- await this.offlineDb.executePendingTasks();
204
- await this.sync();
251
+ try {
252
+ await this.offlineDb.executePendingTasks();
253
+ } catch (error) {
254
+ console.log('Error executing pending tasks during sync.', error);
255
+ }
256
+ // Note: sync() has its own try/catch, but its catch block calls resetDB(),
257
+ // which can itself throw on a corrupted DB and re-reject. This outer guard
258
+ // absorbs that case.
259
+ try {
260
+ await this.sync();
261
+ } catch (error) {
262
+ console.log('Error while syncing the DB.', error);
263
+ }
205
264
  };
206
265
  }