stream-chat 9.50.3 → 10.0.0-rc.2

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 (242) hide show
  1. package/dist/cjs/index.browser.js +19798 -14472
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +19817 -14482
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +19795 -14469
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/ChannelManager.d.ts +221 -0
  8. package/dist/types/EventHandlerPipeline.d.ts +104 -0
  9. package/dist/types/api-client.d.ts +26 -0
  10. package/dist/types/campaign.d.ts +0 -44
  11. package/dist/types/channel.d.ts +454 -479
  12. package/dist/types/channel_batch_updater.d.ts +0 -93
  13. package/dist/types/channel_state.d.ts +53 -201
  14. package/dist/types/client.d.ts +451 -1920
  15. package/dist/types/client_state.d.ts +4 -4
  16. package/dist/types/configuration/InstanceConfigurationService.d.ts +26 -0
  17. package/dist/types/configuration/index.d.ts +1 -0
  18. package/dist/types/configuration/types.d.ts +53 -0
  19. package/dist/types/connection.d.ts +49 -41
  20. package/dist/types/connection_fallback.d.ts +6 -4
  21. package/dist/types/constants.d.ts +0 -4
  22. package/dist/types/custom_types.d.ts +2 -0
  23. package/dist/types/entityStore/EntityStore.d.ts +111 -0
  24. package/dist/types/entityStore/StoreBackedItemIndex.d.ts +60 -0
  25. package/dist/types/entityStore/applyReactionLocally.d.ts +26 -0
  26. package/dist/types/entityStore/index.d.ts +2 -0
  27. package/dist/types/errors.d.ts +10 -2
  28. package/dist/types/gen/chat/ChannelApi.d.ts +45 -0
  29. package/dist/types/gen/chat/ChatApi.d.ts +334 -0
  30. package/dist/types/gen/model-decoders/decoders.d.ts +3 -0
  31. package/dist/types/gen/model-decoders/event-decoder-mapping.d.ts +6 -0
  32. package/dist/types/gen/models/index.d.ts +8836 -0
  33. package/dist/types/gen/moderation/ModerationApi.d.ts +50 -0
  34. package/dist/types/gen-imports.d.ts +3 -0
  35. package/dist/types/index.d.ts +8 -7
  36. package/dist/types/insights.d.ts +9 -8
  37. package/dist/types/logger.d.ts +9 -0
  38. package/dist/types/messageComposer/LocationComposer.d.ts +7 -4
  39. package/dist/types/messageComposer/attachmentIdentity.d.ts +2 -2
  40. package/dist/types/messageComposer/attachmentManager.d.ts +8 -4
  41. package/dist/types/messageComposer/configuration/types.d.ts +11 -11
  42. package/dist/types/messageComposer/linkPreviewsManager.d.ts +25 -13
  43. package/dist/types/messageComposer/messageComposer.d.ts +10 -10
  44. package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +3 -3
  45. package/dist/types/messageComposer/middleware/pollComposer/types.d.ts +4 -7
  46. package/dist/types/messageComposer/middleware/textComposer/commandUtils.d.ts +3 -3
  47. package/dist/types/messageComposer/middleware/textComposer/commands.d.ts +7 -7
  48. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +218 -4
  49. package/dist/types/messageComposer/middleware/textComposer/types.d.ts +9 -4
  50. package/dist/types/messageComposer/pollComposer.d.ts +5 -4
  51. package/dist/types/messageComposer/textComposer.d.ts +8 -3
  52. package/dist/types/messageComposer/types.d.ts +6 -28
  53. package/dist/types/messageDelivery/MessageDeliveryReporter.d.ts +40 -25
  54. package/dist/types/messageDelivery/MessageReceiptsTracker.d.ts +53 -12
  55. package/dist/types/messageOperations/MessageOperationStatePolicy.d.ts +19 -0
  56. package/dist/types/messageOperations/MessageOperations.d.ts +17 -0
  57. package/dist/types/messageOperations/index.d.ts +3 -0
  58. package/dist/types/messageOperations/types.d.ts +49 -0
  59. package/dist/types/moderation.d.ts +21 -203
  60. package/dist/types/notifications/types.d.ts +8 -7
  61. package/dist/types/offline-support/offline_support_api.d.ts +111 -87
  62. package/dist/types/offline-support/offline_sync_manager.d.ts +0 -7
  63. package/dist/types/offline-support/types.d.ts +19 -23
  64. package/dist/types/offline-support/util.d.ts +4 -2
  65. package/dist/types/pagination/FilterBuilder.d.ts +1 -1
  66. package/dist/types/pagination/ItemIndex.d.ts +25 -0
  67. package/dist/types/pagination/cursorDerivation/createdAtAroundPaginationFlags.d.ts +9 -0
  68. package/dist/types/pagination/cursorDerivation/idAroundPaginationFlags.d.ts +6 -0
  69. package/dist/types/pagination/cursorDerivation/index.d.ts +1 -0
  70. package/dist/types/pagination/cursorDerivation/linearPaginationFlags.d.ts +6 -0
  71. package/dist/types/pagination/filterCompiler.d.ts +7 -0
  72. package/dist/types/pagination/index.d.ts +1 -3
  73. package/dist/types/pagination/paginators/BasePaginator.d.ts +848 -0
  74. package/dist/types/pagination/paginators/ChannelPaginator.d.ts +212 -0
  75. package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +282 -0
  76. package/dist/types/pagination/paginators/MessagePaginator.d.ts +208 -0
  77. package/dist/types/pagination/paginators/PinnedMessagePaginator.d.ts +39 -0
  78. package/dist/types/pagination/paginators/ReminderPaginator.d.ts +19 -0
  79. package/dist/types/pagination/paginators/UserGroupPaginator.d.ts +24 -0
  80. package/dist/types/pagination/paginators/index.d.ts +7 -0
  81. package/dist/types/pagination/paginators/stateThrottling.d.ts +2 -0
  82. package/dist/types/pagination/sortCompiler.d.ts +49 -0
  83. package/dist/types/pagination/types.normalization.d.ts +11 -0
  84. package/dist/types/pagination/utility.normalization.d.ts +18 -0
  85. package/dist/types/pagination/utility.queryChannel.d.ts +26 -0
  86. package/dist/types/pagination/utility.search.d.ts +13 -0
  87. package/dist/types/permissions.d.ts +1 -1
  88. package/dist/types/poll.d.ts +29 -32
  89. package/dist/types/poll_manager.d.ts +2 -2
  90. package/dist/types/reminders/Reminder.d.ts +2 -5
  91. package/dist/types/reminders/ReminderManager.d.ts +2 -9
  92. package/dist/types/search/MessageSearchSource.d.ts +4 -4
  93. package/dist/types/search/UserSearchSource.d.ts +1 -1
  94. package/dist/types/search/types.d.ts +3 -3
  95. package/dist/types/segment.d.ts +0 -34
  96. package/dist/types/signing.d.ts +105 -65
  97. package/dist/types/store.d.ts +1 -0
  98. package/dist/types/thread.d.ts +70 -34
  99. package/dist/types/thread_manager.d.ts +3 -3
  100. package/dist/types/token_manager.d.ts +15 -10
  101. package/dist/types/types.d.ts +197 -3404
  102. package/dist/types/utils/FixedSizeQueueCache.d.ts +12 -6
  103. package/dist/types/utils/WithSubscriptions.d.ts +2 -1
  104. package/dist/types/utils/concurrency.d.ts +4 -4
  105. package/dist/types/utils/mergeWith/mergeWith.d.ts +3 -3
  106. package/dist/types/utils/mergeWith/mergeWithCore.d.ts +8 -3
  107. package/dist/types/utils/retryable.d.ts +34 -0
  108. package/dist/types/utils/throttling/throttle.d.ts +36 -0
  109. package/dist/types/utils.d.ts +100 -194
  110. package/package.json +7 -3
  111. package/src/ChannelManager.ts +724 -0
  112. package/src/CooldownTimer.ts +2 -2
  113. package/src/EventHandlerPipeline.ts +228 -0
  114. package/src/LiveLocationManager.ts +12 -11
  115. package/src/api-client.ts +275 -0
  116. package/src/campaign.ts +1 -77
  117. package/src/channel.ts +1344 -1282
  118. package/src/channel_batch_updater.ts +1 -211
  119. package/src/channel_state.ts +144 -1013
  120. package/src/client.ts +936 -3995
  121. package/src/client_state.ts +4 -4
  122. package/src/configuration/InstanceConfigurationService.ts +73 -0
  123. package/src/configuration/index.ts +1 -0
  124. package/src/configuration/types.ts +81 -0
  125. package/src/connection.ts +145 -94
  126. package/src/connection_fallback.ts +29 -27
  127. package/src/constants.ts +1 -5
  128. package/src/custom_types.ts +4 -1
  129. package/src/entityStore/EntityStore.ts +208 -0
  130. package/src/entityStore/StoreBackedItemIndex.ts +115 -0
  131. package/src/entityStore/applyReactionLocally.ts +113 -0
  132. package/src/entityStore/index.ts +2 -0
  133. package/src/errors.ts +14 -2
  134. package/src/gen/chat/ChannelApi.ts +275 -0
  135. package/src/gen/chat/ChatApi.ts +2554 -0
  136. package/src/gen/model-decoders/decoders.ts +2680 -0
  137. package/src/gen/model-decoders/event-decoder-mapping.ts +198 -0
  138. package/src/gen/models/index.ts +12673 -0
  139. package/src/gen/moderation/ModerationApi.ts +607 -0
  140. package/src/gen-imports.ts +3 -0
  141. package/src/index.ts +11 -12
  142. package/src/insights.ts +6 -5
  143. package/src/logger.ts +28 -0
  144. package/src/messageComposer/LocationComposer.ts +9 -5
  145. package/src/messageComposer/MessageComposerEffectHandlers.ts +1 -0
  146. package/src/messageComposer/attachmentIdentity.ts +16 -9
  147. package/src/messageComposer/attachmentManager.ts +13 -8
  148. package/src/messageComposer/configuration/types.ts +11 -11
  149. package/src/messageComposer/linkPreviewsManager.ts +9 -10
  150. package/src/messageComposer/messageComposer.ts +57 -53
  151. package/src/messageComposer/middleware/messageComposer/attachments.ts +1 -2
  152. package/src/messageComposer/middleware/messageComposer/cleanData.ts +1 -1
  153. package/src/messageComposer/middleware/messageComposer/compositionValidation.ts +2 -2
  154. package/src/messageComposer/middleware/messageComposer/messageComposerState.ts +2 -2
  155. package/src/messageComposer/middleware/messageComposer/sharedLocation.ts +4 -3
  156. package/src/messageComposer/middleware/messageComposer/textComposer.ts +2 -2
  157. package/src/messageComposer/middleware/messageComposer/types.ts +3 -4
  158. package/src/messageComposer/middleware/messageComposer/userDataInjection.ts +9 -5
  159. package/src/messageComposer/middleware/pollComposer/state.ts +0 -2
  160. package/src/messageComposer/middleware/pollComposer/types.ts +4 -7
  161. package/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts +10 -1
  162. package/src/messageComposer/middleware/textComposer/commandEffects.ts +4 -4
  163. package/src/messageComposer/middleware/textComposer/commandUtils.ts +3 -6
  164. package/src/messageComposer/middleware/textComposer/commands.ts +3 -3
  165. package/src/messageComposer/middleware/textComposer/mentionUtils.ts +1 -1
  166. package/src/messageComposer/middleware/textComposer/mentions.ts +31 -15
  167. package/src/messageComposer/middleware/textComposer/types.ts +9 -4
  168. package/src/messageComposer/pollComposer.ts +6 -8
  169. package/src/messageComposer/textComposer.ts +27 -2
  170. package/src/messageComposer/types.ts +6 -28
  171. package/src/messageDelivery/MessageDeliveryReporter.ts +101 -45
  172. package/src/messageDelivery/MessageReceiptsTracker.ts +294 -21
  173. package/src/messageOperations/MessageOperationStatePolicy.ts +77 -0
  174. package/src/messageOperations/MessageOperations.ts +212 -0
  175. package/src/messageOperations/index.ts +10 -0
  176. package/src/messageOperations/types.ts +64 -0
  177. package/src/moderation.ts +38 -431
  178. package/src/notifications/types.ts +8 -7
  179. package/src/offline-support/offline_support_api.ts +200 -133
  180. package/src/offline-support/offline_sync_manager.ts +23 -44
  181. package/src/offline-support/types.ts +23 -26
  182. package/src/offline-support/util.ts +5 -4
  183. package/src/pagination/FilterBuilder.ts +4 -1
  184. package/src/pagination/ItemIndex.ts +25 -0
  185. package/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts +73 -0
  186. package/src/pagination/cursorDerivation/idAroundPaginationFlags.ts +53 -0
  187. package/src/pagination/cursorDerivation/index.ts +1 -0
  188. package/src/pagination/cursorDerivation/linearPaginationFlags.ts +86 -0
  189. package/src/pagination/filterCompiler.ts +196 -0
  190. package/src/pagination/index.ts +1 -3
  191. package/src/pagination/paginators/BasePaginator.ts +2859 -0
  192. package/src/pagination/paginators/ChannelPaginator.ts +729 -0
  193. package/src/pagination/paginators/MessageIntervalPaginator.ts +1116 -0
  194. package/src/pagination/paginators/MessagePaginator.ts +508 -0
  195. package/src/pagination/paginators/PinnedMessagePaginator.ts +108 -0
  196. package/src/pagination/paginators/ReminderPaginator.ts +107 -0
  197. package/src/pagination/paginators/UserGroupPaginator.ts +127 -0
  198. package/src/pagination/paginators/index.ts +7 -0
  199. package/src/pagination/paginators/stateThrottling.ts +31 -0
  200. package/src/pagination/sortCompiler.ts +193 -0
  201. package/src/pagination/types.normalization.ts +14 -0
  202. package/src/pagination/utility.normalization.ts +106 -0
  203. package/src/pagination/utility.queryChannel.ts +84 -0
  204. package/src/pagination/utility.search.ts +80 -0
  205. package/src/permissions.ts +7 -8
  206. package/src/poll.ts +101 -110
  207. package/src/poll_manager.ts +14 -8
  208. package/src/reminders/Reminder.ts +2 -5
  209. package/src/reminders/ReminderManager.ts +19 -21
  210. package/src/search/BaseSearchSource.ts +0 -3
  211. package/src/search/ChannelMemberSearchSource.ts +7 -1
  212. package/src/search/ChannelSearchSource.ts +10 -3
  213. package/src/search/MessageSearchSource.ts +29 -30
  214. package/src/search/UserSearchSource.ts +12 -8
  215. package/src/search/types.ts +3 -4
  216. package/src/segment.ts +1 -95
  217. package/src/signing.ts +107 -67
  218. package/src/store.ts +2 -1
  219. package/src/thread.ts +562 -238
  220. package/src/thread_manager.ts +37 -16
  221. package/src/token_manager.ts +21 -10
  222. package/src/types.ts +364 -4533
  223. package/src/uploadManager.ts +8 -0
  224. package/src/utils/FixedSizeQueueCache.ts +12 -6
  225. package/src/utils/WithSubscriptions.ts +2 -1
  226. package/src/utils/concurrency.ts +4 -4
  227. package/src/utils/mergeWith/mergeWith.ts +3 -3
  228. package/src/utils/mergeWith/mergeWithCore.ts +140 -164
  229. package/src/utils/mergeWith/mergeWithDiff.ts +3 -3
  230. package/src/utils/retryable.ts +117 -0
  231. package/src/utils/throttling/throttle.ts +130 -0
  232. package/src/utils.ts +208 -754
  233. package/dist/types/channel_manager.d.ts +0 -122
  234. package/dist/types/events.d.ts +0 -72
  235. package/dist/types/pagination/BasePaginator.d.ts +0 -69
  236. package/dist/types/pagination/ReminderPaginator.d.ts +0 -16
  237. package/dist/types/pagination/UserGroupPaginator.d.ts +0 -21
  238. package/src/channel_manager.ts +0 -830
  239. package/src/events.ts +0 -75
  240. package/src/pagination/BasePaginator.ts +0 -184
  241. package/src/pagination/ReminderPaginator.ts +0 -56
  242. package/src/pagination/UserGroupPaginator.ts +0 -93
@@ -0,0 +1,2859 @@
1
+ import type { ItemLocation } from '../sortCompiler';
2
+ import { binarySearch } from '../sortCompiler';
3
+ import { itemMatchesFilter } from '../filterCompiler';
4
+ import { isPatch, StateStore, type ValueOrPatch } from '../../store';
5
+ import { debounce, type DebouncedFunc, generateUUIDv4, sleep } from '../../utils';
6
+ import { throttle, type Throttled } from '../../utils/throttling/throttle';
7
+ import { isStateThrottlingEnabled } from './stateThrottling';
8
+ import type { FieldToDataResolver } from '../types.normalization';
9
+ import { ComparisonResult } from '../types.normalization';
10
+ import type { ItemIndexApi } from '../ItemIndex';
11
+ import { StoreBackedItemIndex } from '../../entityStore/StoreBackedItemIndex';
12
+ import { isEqual } from '../../utils/mergeWith/mergeWithCore';
13
+ import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../constants';
14
+
15
+ const noOrderChange = () => 0;
16
+
17
+ export const LOGICAL_HEAD_INTERVAL_ID = '__logical_head__';
18
+ export const LOGICAL_TAIL_INTERVAL_ID = '__logical_tail__';
19
+
20
+ type IntervalSortBounds<T> = { start: T; end: T };
21
+ type IntervalPaginationEdges<T> = { head: T; tail: T };
22
+
23
+ export type LogicalInterval = {
24
+ itemIds: string[];
25
+ id: typeof LOGICAL_HEAD_INTERVAL_ID | typeof LOGICAL_TAIL_INTERVAL_ID;
26
+ };
27
+
28
+ export type Interval = {
29
+ hasMoreHead: boolean;
30
+ hasMoreTail: boolean;
31
+ itemIds: string[];
32
+ id: string;
33
+ /**
34
+ * True if this interval represents the global head of the dataset
35
+ * under the current sortComparator.
36
+ *
37
+ * Cursor pagination:
38
+ * headward === null
39
+ *
40
+ * Offset pagination:
41
+ * offset === 0
42
+ */
43
+ isHead: boolean;
44
+ /**
45
+ * True if this interval represents the global tail of the dataset
46
+ * under the current sortComparator.
47
+ *
48
+ * Cursor pagination:
49
+ * tailward === null
50
+ *
51
+ * Offset pagination:
52
+ * returnedItems.length < pageSize
53
+ */
54
+ isTail: boolean;
55
+ };
56
+
57
+ export type AnyInterval = Interval | LogicalInterval;
58
+
59
+ export type IntervalMergePolicy = 'auto' | 'strict-overlap-only';
60
+
61
+ type ItemIntervalCoordinates = ItemLocation & {
62
+ interval: Interval | LogicalInterval;
63
+ };
64
+
65
+ export type ItemCoordinates = {
66
+ /** Location inside state.items (visible list) */
67
+ state?: ItemLocation;
68
+ /** Location inside an interval (anchored or logical) */
69
+ interval?: ItemIntervalCoordinates;
70
+ };
71
+
72
+ export const isLiveHeadInterval = (interval: AnyInterval): interval is LogicalInterval =>
73
+ interval.id === LOGICAL_HEAD_INTERVAL_ID;
74
+
75
+ export const isLiveTailInterval = (interval: AnyInterval): interval is LogicalInterval =>
76
+ interval.id === LOGICAL_TAIL_INTERVAL_ID;
77
+
78
+ export const isLogicalInterval = (interval: AnyInterval): interval is LogicalInterval =>
79
+ isLiveHeadInterval(interval) || isLiveTailInterval(interval);
80
+
81
+ function cloneInterval(interval: Interval): Interval {
82
+ return {
83
+ ...interval,
84
+ itemIds: [...interval.itemIds],
85
+ };
86
+ }
87
+
88
+ export type MakeIntervalParams<T> = {
89
+ page: T[];
90
+ isHead?: boolean;
91
+ isTail?: boolean;
92
+ };
93
+
94
+ export type SetPaginatorItemsParams<T> = {
95
+ valueOrFactory: ValueOrPatch<T[]>;
96
+ cursor?: PaginatorCursor;
97
+ /**
98
+ * Relevant only is using item interval storage in the paginator.
99
+ * Indicates that the page would be the head of pagination intervals array.
100
+ * Items falling outside this intervals head bound will be merged into this interval.
101
+ */
102
+ isFirstPage?: boolean;
103
+ /**
104
+ * Relevant only is using item interval storage in the paginator.
105
+ * Indicates that the page would be the tail of pagination intervals array
106
+ * Items falling outside this intervals tail bound will be merged into this interval.
107
+ */
108
+ isLastPage?: boolean;
109
+ };
110
+
111
+ type MergeIntervalsResult = {
112
+ logicalHead: LogicalInterval | null;
113
+ merged: Interval | null;
114
+ logicalTail: LogicalInterval | null;
115
+ };
116
+
117
+ /**
118
+ * headward - going from page X -> X-Y -> 0
119
+ * tailward - goring from page 0 -> X -> X + Y ...
120
+ *
121
+ * Head is the place where new items are added - same as git.
122
+ * Tail is the place where retrieved pages are appended.
123
+ */
124
+ export type PaginationDirection = 'headward' | 'tailward';
125
+
126
+ export type CursorDeriveContext<T, Q> = {
127
+ /**
128
+ * Current cursor to be merged with the newly derived cursor.
129
+ * Allows to preserve the direction we have not paginated with the given request.
130
+ */
131
+ cursor: PaginatorCursor | undefined;
132
+ /**
133
+ * Direction we just paginated in.
134
+ *
135
+ * May be undefined for non-directional queries (e.g. jump-to / *_around).
136
+ */
137
+ direction: PaginationDirection | undefined;
138
+ hasMoreTail: boolean;
139
+ hasMoreHead: boolean;
140
+ /** The parent interval the page was ingested into (if any) */
141
+ interval: Interval;
142
+ /** The page we just received after filtering */
143
+ page: T[];
144
+ /** Last query shape (sometimes useful for bespoke logic) */
145
+ queryShape: Q | undefined;
146
+ /** Number we asked for */
147
+ requestedPageSize: number;
148
+ };
149
+
150
+ export type PaginationFlags = {
151
+ hasMoreHead: boolean;
152
+ hasMoreTail: boolean;
153
+ };
154
+
155
+ export type CursorDeriveResult = PaginationFlags & {
156
+ cursor: PaginatorCursor | undefined;
157
+ };
158
+
159
+ export type CursorDerivator<T, Q> = (
160
+ ctx: CursorDeriveContext<T, Q>,
161
+ ) => CursorDeriveResult;
162
+ /**
163
+ * string - there is a next page in the given direction
164
+ * null - pagination in the given direction has been exhausted
165
+ * undefined - no page has been requested in the given pagination direction
166
+ */
167
+ export type PaginatorCursor = {
168
+ tailward: string | null | undefined;
169
+ headward: string | null | undefined;
170
+ };
171
+ export const ZERO_PAGE_CURSOR: PaginatorCursor = {
172
+ tailward: undefined,
173
+ headward: undefined,
174
+ };
175
+
176
+ type StateResetPolicy = 'auto' | 'yes' | 'no' | (string & {});
177
+
178
+ export type PaginationQueryShapeChangeIdentifier<S> = (
179
+ toHeadQueryShape?: S,
180
+ toTailQueryShape?: S,
181
+ ) => boolean;
182
+
183
+ export type PaginationQueryParams<Q> = {
184
+ direction?: PaginationDirection;
185
+ /**
186
+ * Keep the currently loaded items (and cursor/flags) visible while a first-page query runs
187
+ * instead of blanking to the empty initial state. The freshly fetched page is merged into the
188
+ * active interval by `postQueryReconcile` (upserting changed items, appending new ones), so the
189
+ * list is refreshed in place with no loading-screen flash. Used by the non-destructive refresh.
190
+ */
191
+ keepPreviousItems?: boolean;
192
+ /** Data that define the query (filters, sort, ...) */
193
+ queryShape?: Q;
194
+ /** Per-call override of the reset behavior. */
195
+ reset?: StateResetPolicy;
196
+ /**
197
+ * How many times to **retry** a failed request, i.e. `retryCount + 1` attempts in total. Per-call
198
+ * override of `PaginatorOptions.retryCount`, which defaults to 0 (no retry).
199
+ */
200
+ retryCount?: number;
201
+ /**
202
+ * Suppress `isLoading` transitions for this query (a silent, background refresh). When falsy
203
+ * (default) the usual loading state is surfaced so the UI can show a spinner.
204
+ */
205
+ silent?: boolean;
206
+ /** Determines, whether the page loaded with the query will be committed to the paginator state. Default: true. */
207
+ updateState?: boolean;
208
+ };
209
+
210
+ export type PostQueryReconcileParams<T, Q> = Pick<
211
+ PaginationQueryParams<Q>,
212
+ 'direction' | 'queryShape' | 'updateState' | 'keepPreviousItems'
213
+ > & {
214
+ isFirstPage: boolean;
215
+ requestedPageSize: number;
216
+ results: PaginationQueryReturnValue<T> | null;
217
+ };
218
+
219
+ export type ExecuteQueryReturnValue<T> = {
220
+ /**
221
+ * State object resulting from the post query processing.
222
+ * The object is committed to the state if PaginationQueryParams<Q>['updateState'] === true.
223
+ */
224
+ stateCandidate: Partial<PaginatorState<T>>;
225
+ /** In case the items are kept in intervals, the interval into which the page has been merged, will be returned. */
226
+ targetInterval: AnyInterval | null;
227
+ };
228
+
229
+ export type PaginationQueryReturnValue<T> = { items: T[] } & {
230
+ headward?: string;
231
+ tailward?: string;
232
+ /**
233
+ * @deprecated Use `tailward` instead.
234
+ */
235
+ next?: string;
236
+ /**
237
+ * @deprecated Use `headward` instead.
238
+ */
239
+ prev?: string;
240
+ };
241
+ export type PaginatorDebounceOptions = {
242
+ debounceMs: number;
243
+ };
244
+ type DebouncedExecQueryFunction<Q> = DebouncedFunc<
245
+ (params: PaginationQueryParams<Q>) => Promise<void>
246
+ >;
247
+
248
+ export type PaginatorState<T> = {
249
+ hasMoreHead: boolean;
250
+ hasMoreTail: boolean;
251
+ isLoading: boolean;
252
+ items: T[] | undefined;
253
+ lastQueryError?: Error;
254
+ cursor?: PaginatorCursor;
255
+ offset?: number;
256
+ };
257
+
258
+ /**
259
+ * Reactive projections of specific (fixed-identity) intervals, published independently of the
260
+ * paginated `state` (which tracks only the *active* interval). See {@link BasePaginator.intervalViews}.
261
+ * A field is rewritten only when its own interval changes, so a `useStateStore`/`subscribeWithSelector`
262
+ * consumer selecting one field only wakes when *that* interval changes. (The head-most window that can
263
+ * be either a logical or an anchored interval is a derived *role*, not a fixed interval, so it is not
264
+ * published here — read it one-shot via {@link BasePaginator.headItems} / {@link BasePaginator.headmostItem}.)
265
+ */
266
+ export type PaginatorIntervalViews<T> = {
267
+ /** Live logical-head interval — out-of-order items above the loaded window. */
268
+ logicalHead: T[];
269
+ /** Live logical-tail interval — out-of-order items below the loaded window. */
270
+ logicalTail: T[];
271
+ /**
272
+ * Anchored head interval — the loaded page bounded at the dataset head (`isHead`), i.e. the newest
273
+ * loaded page; empty when the head is not loaded. Its content updates when that page ingests/removes
274
+ * an item, and its identity updates when a page's `isHead` flag flips during query reconciliation.
275
+ */
276
+ anchoredHead: T[];
277
+ };
278
+
279
+ // todo: think whether plugins are necessary. Maybe we could just document how to add
280
+
281
+ export type PaginatorItemsChangeProcessor<T> = (params: {
282
+ nextItems: T[] | undefined;
283
+ previousItems: T[] | undefined;
284
+ }) => T[] | undefined;
285
+
286
+ export interface PaginatorPlugin<T> {
287
+ /**
288
+ * Optional plugin hook invoked immediately before the paginator emits a new
289
+ * `items` value to subscribers, but only when the `items` array has actually
290
+ * changed by reference.
291
+ *
292
+ * This hook allows plugins to post-process the visible items—such as
293
+ * deduplicating, normalizing, sorting, enriching, or otherwise transforming
294
+ * the array—at the final stage of state emission. The processed value becomes
295
+ * the `items` value delivered to subscribers.
296
+ *
297
+ * Return a new array to replace `nextState.items`, or return `undefined`
298
+ * to leave the items unchanged.
299
+ *
300
+ * Executed in the order plugins are registered.
301
+ */
302
+ onBeforeItemsEmitted?: PaginatorItemsChangeProcessor<T>;
303
+
304
+ // future hooks (examples)
305
+ // onQueryStart?(ctx: { params: PaginationQueryParams<Q>; paginator: BasePaginator<T, Q> }): void | Promise<void>;
306
+ // onQuerySuccess?(ctx: { state: PaginatorState<T>; results: PaginationQueryReturnValue<T>; paginator: BasePaginator<T, Q> }): void | Promise<void>;
307
+ // onQueryError?(ctx: { error: unknown; paginator: BasePaginator<T, Q> }): void | Promise<void>;
308
+ }
309
+
310
+ /**
311
+ * Optional list of plugins that can hook into paginator lifecycle events.
312
+ *
313
+ * Plugins allow you to encapsulate cross-cutting behavior (such as items
314
+ * post-processing, analytics, offline caching, etc.) without modifying
315
+ * the core paginator logic. Each plugin can register handlers like
316
+ * `onItemsChange` that are invoked when relevant events occur.
317
+ *
318
+ * All registered plugins are executed in the order they appear in this array.
319
+ */
320
+ // plugins?: PaginatorPlugin<T, Q>[];
321
+
322
+ export type PaginatorOptions<T, Q> = {
323
+ /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
324
+ debounceMs?: number;
325
+ /**
326
+ * When set (and not disabled for tests — see `stateThrottling.ts`), coalesces the paginator's own
327
+ * live `state.items` publishes to at most once per `stateThrottleMs` (leading + trailing edge): a
328
+ * burst of live mutations (WS `message.new`, reactions, reads) re-projects the active window and
329
+ * publishes it ~2×/sec instead of once per event. Only the paginator's OWN writes to `state.items`
330
+ * are batched — `state.getLatestValue()` is untouched (no `StateStore` change), pagination / jump /
331
+ * query publishes stay immediate, and an immediate flush past it is available via
332
+ * {@link flushPendingPublishes}. Unset ⇒ no throttle (default). Enabled at
333
+ * 500ms for the message list — see {@link MessagePaginator}.
334
+ */
335
+ stateThrottleMs?: number;
336
+ /**
337
+ * Function containing custom logic that decides, whether the next pagination query to be executed should be considered the first page query.
338
+ * It makes sense to consider the next query as the first page query if filters, sort, options etc. (query params) excluding the page size have changed.
339
+ */
340
+
341
+ hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier<any>;
342
+ /**
343
+ * Optional hook to fully control cursor + hasMore logic in 'derived' mode.
344
+ * If not provided, BasePaginator uses its own default implementation.
345
+ */
346
+ deriveCursor?: CursorDerivator<T, Q>;
347
+ /** Custom function to retrieve items pages and optionally return a cursor in case of cursor pagination. */
348
+ doRequest?: (queryParams: Q) => Promise<{ items: T[]; cursor?: PaginatorCursor }>;
349
+ /** In case of cursor pagination, specify the initial cursor value. */
350
+ initialCursor?: PaginatorCursor;
351
+ /** In case of offset pagination, specify the initial offset value. */
352
+ initialOffset?: number;
353
+ /** If item index is provided, this index ensures updates in a single place and all consumers have access to a single source of data. */
354
+ itemIndex?: ItemIndexApi<T>;
355
+ /**
356
+ * Factory for the item index, invoked with the fully-constructed paginator as `owner`.
357
+ * Lets a subclass back the paginator with an adapter that needs a reference to the
358
+ * owner (e.g. a shared, client-global store) without the `this`-before-`super` problem.
359
+ * Ignored when an explicit `itemIndex` is supplied.
360
+ */
361
+ createItemIndex?: (owner: BasePaginator<T, Q>) => ItemIndexApi<T>;
362
+ /**
363
+ * Comparator defining in-memory item ordering for interval math and visible list rendering.
364
+ * Defaults to `sortComparator` to preserve existing paginator behavior.
365
+ */
366
+ itemOrderComparator?: (a: T, b: T) => number;
367
+ /**
368
+ * Will prevent changing the index of existing items in state.
369
+ * If true, an item that is already visible keeps its relative position in the current items array when updated.
370
+ * It does not guarantee global stability across interval changes or page jumps.
371
+ */
372
+ lockItemOrder?: boolean;
373
+ /** The item page size to be requested from the server. */
374
+ pageSize?: number;
375
+ /**
376
+ * How many times to **retry** a failed request before giving up, i.e. `retryCount + 1` attempts in
377
+ * total, with `DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES` between them. Defaults to 0 (no retry);
378
+ * `PaginationQueryParams.retryCount` overrides it per call.
379
+ */
380
+ retryCount?: number;
381
+ /** Prevent silencing the errors thrown during the pagination execution. Default is false. */
382
+ throwErrors?: boolean;
383
+ };
384
+
385
+ type OptionalPaginatorConfigFields =
386
+ | 'stateThrottleMs'
387
+ | 'deriveCursor'
388
+ | 'doRequest'
389
+ | 'initialCursor'
390
+ | 'initialOffset'
391
+ | 'itemIndex'
392
+ | 'createItemIndex'
393
+ | 'itemOrderComparator'
394
+ | 'throwErrors';
395
+
396
+ export type BasePaginatorConfig<T, Q> = Pick<
397
+ PaginatorOptions<T, Q>,
398
+ OptionalPaginatorConfigFields
399
+ > &
400
+ Required<Omit<PaginatorOptions<T, Q>, OptionalPaginatorConfigFields>>;
401
+
402
+ const baseHasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier<
403
+ unknown
404
+ > = (prevQueryShape, nextQueryShape) => !isEqual(prevQueryShape, nextQueryShape);
405
+
406
+ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig<any, any> = {
407
+ debounceMs: 300,
408
+ lockItemOrder: false,
409
+ pageSize: 10,
410
+ hasPaginationQueryShapeChanged: baseHasPaginationQueryShapeChanged,
411
+ retryCount: 0,
412
+ throwErrors: false,
413
+ } as const;
414
+
415
+ export abstract class BasePaginator<T, Q> {
416
+ state: StateStore<PaginatorState<T>>;
417
+ /**
418
+ * Reactive projections of specific intervals — see {@link PaginatorIntervalViews}. Unlike `state`
419
+ * (which only re-emits when the *active* interval is impacted), a field here is rewritten whenever
420
+ * its own interval changes, regardless of which interval is active — so consumers can reactively
421
+ * render off-window "sideloaded" content (`logicalHead`/`logicalTail`) and the newest loaded page
422
+ * (`anchoredHead`). Kept separate from `state` so the paginated-list contract stays focused on the
423
+ * active window + pagination status.
424
+ */
425
+ intervalViews: StateStore<PaginatorIntervalViews<T>>;
426
+ config: BasePaginatorConfig<T, Q>;
427
+
428
+ /**
429
+ * Throttle for the active-window `state.items` publish (message list). Created only when
430
+ * `config.stateThrottleMs` is set; drives {@link scheduleWindowPublish} / {@link flushPendingPublishes}. See
431
+ * `stateThrottleMs` in {@link PaginatorOptions}.
432
+ */
433
+ private _windowPublishThrottle?: Throttled<[]>;
434
+
435
+ /**
436
+ * Throttle for the interval-view publishes (`anchoredHead` / `logicalHead` / `logicalTail`) driven
437
+ * by sibling store updates. Independent of {@link _windowPublishThrottle} so a view refresh lands on
438
+ * its own trailing edge even when `state.items` stays quiet. Created alongside it (only when
439
+ * `config.stateThrottleMs` is set); buffers changed ids in {@link _pendingViewChangedIds}.
440
+ */
441
+ private _viewPublishThrottle?: Throttled<[]>;
442
+
443
+ /** Changed ids buffered since the last {@link flushIntervalViewPublish} (throttled paginators only). */
444
+ private _pendingViewChangedIds = new Set<string>();
445
+
446
+ /**
447
+ * Intervals keep items in disconnected ranges.
448
+ * That is a scenario of jumping to non-sequential pages.
449
+ * Intervals are populated only if itemIndex is provided.
450
+ */
451
+ protected _itemIntervals: Map<string, AnyInterval> = new Map();
452
+ protected _activeIntervalId: string | undefined;
453
+
454
+ /**
455
+ * ItemIndex is a canonical, ID-addressable storage layer for domain items.
456
+ * It serves as a single source of truth for all those that need to access the items
457
+ * outside the paginator.
458
+ */
459
+ protected _itemIndex: ItemIndexApi<T>;
460
+
461
+ protected _executeQueryDebounced!: DebouncedExecQueryFunction<Q>;
462
+ /** Last effective query shape produced by subclass for the most recent request. */
463
+ protected _lastQueryShape?: Q;
464
+ protected _nextQueryShape?: Q;
465
+
466
+ /**
467
+ * Stable, performs purely item data-driven (age, last_message_at, etc.) comparison.
468
+ * Used under the hood
469
+ * 1. as a fallback by effectiveComparator / boostComparator if boost comparison is not conclusive
470
+ * 2. interval comparator
471
+ *
472
+ * Intervals cannot be sorted using boostComparator, because boosting the interval boundary (top item)
473
+ * would lead to the boosting of the entire interval when sorting the intervals.
474
+ *
475
+ * Sorting within a single interval should be done using effectiveComparator, which by default uses boostComparator.
476
+ */
477
+ sortComparator: (a: T, b: T) => number;
478
+ protected _filterFieldToDataResolvers: FieldToDataResolver<T>[];
479
+
480
+ protected boosts = new Map<string, { until: number; seq: number }>();
481
+ protected _maxBoostSeq = 0;
482
+
483
+ /**
484
+ * Describes how `interval.itemIds` are oriented relative to pagination semantics.
485
+ *
486
+ * - `true` => `itemIds[0]` is the pagination head edge (default)
487
+ * - `false` => `itemIds[itemIds.length - 1]` is the pagination head edge
488
+ *
489
+ * NOTE: This does not affect the *sorting* of `itemIds` (they are always kept
490
+ * in `sortComparator` order). It only affects which side is considered
491
+ * "head" for interval ordering and live ingestion decisions.
492
+ */
493
+ protected get intervalItemIdsAreHeadFirst(): boolean {
494
+ return true;
495
+ }
496
+
497
+ /**
498
+ * Determines the ordering of intervals in the internal interval list.
499
+ *
500
+ * This controls only the ordering of intervals relative to each other (by comparing
501
+ * their head edges using `sortComparator`). It is intentionally decoupled from:
502
+ * - the ordering of itemIds inside an interval
503
+ * - the meaning of the head edge (controlled by `intervalItemIdsAreHeadFirst`)
504
+ */
505
+ protected get intervalSortDirection(): 'asc' | 'desc' {
506
+ return 'asc';
507
+ }
508
+
509
+ protected constructor({
510
+ initialCursor,
511
+ initialOffset,
512
+ itemIndex,
513
+ createItemIndex,
514
+ ...options
515
+ }: PaginatorOptions<T, Q> = {}) {
516
+ this.config = {
517
+ ...DEFAULT_PAGINATION_OPTIONS,
518
+ initialCursor,
519
+ initialOffset,
520
+ ...options,
521
+ };
522
+ const { debounceMs } = this.config;
523
+ this.state = new StateStore<PaginatorState<T>>({
524
+ ...this.initialState,
525
+ cursor: initialCursor,
526
+ offset: initialOffset ?? 0,
527
+ });
528
+ if (this.config.stateThrottleMs) {
529
+ // Coalesce the paginator's own live `state.items` publishes (see `stateThrottleMs` doc). The
530
+ // trailing edge re-projects the active window fresh, so a burst emits ~once per interval.
531
+ this._windowPublishThrottle = throttle(
532
+ () => this.flushWindowPublish(),
533
+ this.config.stateThrottleMs,
534
+ { leading: true, trailing: true },
535
+ );
536
+ // Interval view publishes ride their own throttle so they coalesce like `state.items` but land
537
+ // on an independent trailing edge (see {@link _viewPublishThrottle}).
538
+ this._viewPublishThrottle = throttle(
539
+ () => this.flushIntervalViewPublish(),
540
+ this.config.stateThrottleMs,
541
+ { leading: true, trailing: true },
542
+ );
543
+ }
544
+ this.intervalViews = new StateStore<PaginatorIntervalViews<T>>({
545
+ logicalHead: [],
546
+ logicalTail: [],
547
+ anchoredHead: [],
548
+ });
549
+ this.setDebounceOptions({ debounceMs });
550
+ this.sortComparator = noOrderChange;
551
+ this._filterFieldToDataResolvers = [];
552
+ this._itemIndex =
553
+ itemIndex ??
554
+ createItemIndex?.(this) ??
555
+ new StoreBackedItemIndex({ getEntityId: this.getItemId.bind(this) });
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Basic getters
560
+ // ---------------------------------------------------------------------------
561
+
562
+ get lastQueryError() {
563
+ return this.state.getLatestValue().lastQueryError;
564
+ }
565
+
566
+ get hasMoreTail() {
567
+ return this.state.getLatestValue().hasMoreTail;
568
+ }
569
+
570
+ get hasMoreHead() {
571
+ return this.state.getLatestValue().hasMoreHead;
572
+ }
573
+
574
+ /**
575
+ * @deprecated Use `hasMoreTail` instead.
576
+ */
577
+ get hasNext() {
578
+ return this.hasMoreTail;
579
+ }
580
+
581
+ /**
582
+ * @deprecated Use `hasMoreHead` instead.
583
+ */
584
+ get hasPrev() {
585
+ return this.hasMoreHead;
586
+ }
587
+
588
+ get hasResults() {
589
+ return Array.isArray(this.state.getLatestValue().items);
590
+ }
591
+
592
+ get isLoading() {
593
+ return this.state.getLatestValue().isLoading;
594
+ }
595
+
596
+ /** Signals that the paginator has not performed any query so far */
597
+ get isInitialized() {
598
+ return typeof this._lastQueryShape !== 'undefined';
599
+ }
600
+
601
+ get isOfflineSupportEnabled() {
602
+ return false;
603
+ }
604
+
605
+ get isCursorPagination() {
606
+ return !!this.cursor;
607
+ }
608
+
609
+ get initialState(): PaginatorState<T> {
610
+ return {
611
+ hasMoreHead: true,
612
+ hasMoreTail: true,
613
+ isLoading: false,
614
+ items: undefined,
615
+ lastQueryError: undefined,
616
+ cursor: this.config.initialCursor,
617
+ offset: this.config.initialOffset ?? 0,
618
+ };
619
+ }
620
+
621
+ get items() {
622
+ return this.state.getLatestValue().items;
623
+ }
624
+
625
+ /**
626
+ * The newest loaded window of items, independent of which window is currently *active*
627
+ * (`items` follows the active interval, which may point at a jumped-to / searched window). This is
628
+ * the head-most loaded interval under the paginator's ordering (anchored or the live-head logical
629
+ * interval).
630
+ *
631
+ * NOTE: this deliberately uses the head-*most loaded* interval rather than requiring the
632
+ * `isHead` flag — the query/hydration seed does not reliably mark a freshly loaded latest page
633
+ * as `isHead`, so an isHead-only check would miss channel-list channels entirely. The trade-off
634
+ * is that after jumping to an older window with the latest window not loaded, this reports that
635
+ * older window as "latest" (best effort). Use for "latest"-derived reads: last message, unread
636
+ * counting, delivery candidates, channel-list previews.
637
+ */
638
+ get headItems(): T[] {
639
+ const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals);
640
+ return head ? this.intervalToItems(head) : [];
641
+ }
642
+
643
+ /**
644
+ * The item on the head edge of the head pagination interval (of {@link BasePaginator.headItems}).
645
+ * `undefined` when nothing is loaded.
646
+ */
647
+ get headmostItem(): T | undefined {
648
+ const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals);
649
+ return head ? (this.getIntervalPaginationEdges(head)?.head ?? undefined) : undefined;
650
+ }
651
+
652
+ get cursor() {
653
+ return this.state.getLatestValue().cursor;
654
+ }
655
+
656
+ get offset() {
657
+ return this.state.getLatestValue().offset;
658
+ }
659
+
660
+ get pageSize() {
661
+ return this.config.pageSize;
662
+ }
663
+
664
+ set pageSize(size: number) {
665
+ this.config.pageSize = size;
666
+ }
667
+
668
+ set initialCursor(cursor: PaginatorCursor) {
669
+ this.config.initialCursor = cursor;
670
+ }
671
+
672
+ set initialOffset(offset: number) {
673
+ this.config.initialOffset = offset;
674
+ }
675
+
676
+ /** Single point of truth: always use the effective comparator */
677
+ get effectiveComparator() {
678
+ return this.boostComparator;
679
+ }
680
+
681
+ protected get itemOrderComparator() {
682
+ return this.config.itemOrderComparator ?? this.sortComparator;
683
+ }
684
+
685
+ get intervalComparator() {
686
+ return (a: AnyInterval, b: AnyInterval) => {
687
+ const aEdges = this.getIntervalPaginationEdges(a);
688
+ const bEdges = this.getIntervalPaginationEdges(b);
689
+ if (!aEdges || !bEdges) return 0;
690
+ if (!aEdges) return 1; // move interval without bounds to the end
691
+ if (!bEdges) return -1; // keep interval a preceding b
692
+ return this.compareIntervalHeadEdges(aEdges.head, bEdges.head);
693
+ };
694
+ }
695
+
696
+ get maxBoostSeq() {
697
+ return this._maxBoostSeq;
698
+ }
699
+
700
+ protected get itemIntervals(): AnyInterval[] {
701
+ return Array.from(this._itemIntervals.values());
702
+ }
703
+
704
+ protected get liveHeadLogical(): LogicalInterval | undefined {
705
+ const itv = this._itemIntervals.get(LOGICAL_HEAD_INTERVAL_ID);
706
+ return itv && isLiveHeadInterval(itv) ? itv : undefined;
707
+ }
708
+
709
+ protected get liveTailLogical(): LogicalInterval | undefined {
710
+ const itv = this._itemIntervals.get(LOGICAL_TAIL_INTERVAL_ID);
711
+ return itv && isLiveTailInterval(itv) ? itv : undefined;
712
+ }
713
+
714
+ /**
715
+ * The current contents of the live logical-head interval (items ingested out of pagination order).
716
+ * Reads the same value published to {@link BasePaginator.intervalViews}.`logicalHead`.
717
+ */
718
+ get logicalHeadItems(): T[] {
719
+ return this.intervalViews.getLatestValue().logicalHead;
720
+ }
721
+
722
+ /**
723
+ * The current contents of the live logical-tail interval (out-of-order items below the loaded
724
+ * window). Reads the same value published to {@link BasePaginator.intervalViews}.`logicalTail`.
725
+ */
726
+ get logicalTailItems(): T[] {
727
+ return this.intervalViews.getLatestValue().logicalTail;
728
+ }
729
+
730
+ /**
731
+ * The current contents of the anchored head interval (the loaded page bounded at the dataset head,
732
+ * `isHead`). Reads the same value published to {@link BasePaginator.intervalViews}.`anchoredHead`.
733
+ */
734
+ get anchoredHeadItems(): T[] {
735
+ return this.intervalViews.getLatestValue().anchoredHead;
736
+ }
737
+
738
+ /**
739
+ * Commit an interval into storage. Single choke point for adding/updating an interval, so it also
740
+ * republishes the matching {@link intervalViews} field when the committed interval is a tracked one
741
+ * (logical head / logical tail / anchored head). Use this instead of writing `_itemIntervals`
742
+ * directly — bulk re-sorting (which does not change any interval's membership) goes through
743
+ * {@link setIntervals}.
744
+ */
745
+ protected commitInterval(interval: AnyInterval) {
746
+ this._itemIntervals.set(interval.id, interval);
747
+ this.publishIntervalViewFor(interval);
748
+ }
749
+
750
+ /** Drop an interval from storage, republishing the matching {@link intervalViews} field if tracked. */
751
+ protected dropInterval(id: string) {
752
+ const removed = this._itemIntervals.get(id);
753
+ this._itemIntervals.delete(id);
754
+ if (removed) this.publishIntervalViewFor(removed, { removed: true });
755
+ }
756
+
757
+ /**
758
+ * Republish the {@link intervalViews} field backed by the given interval — called from
759
+ * {@link commitInterval} / {@link dropInterval} (i.e. when that interval ingests or removes an item).
760
+ * A write to an untracked interval touches nothing here. (The anchored head is also published
761
+ * directly via {@link publishAsAnchoredHead} from the reconciliation points that flip `isHead` —
762
+ * see {@link postQueryReconcile}.)
763
+ */
764
+ private publishIntervalViewFor(interval: AnyInterval, { removed = false } = {}) {
765
+ if (interval.id === LOGICAL_HEAD_INTERVAL_ID) {
766
+ this.intervalViews.partialNext({
767
+ logicalHead: this.intervalItemsOrEmpty(this.liveHeadLogical),
768
+ });
769
+ } else if (interval.id === LOGICAL_TAIL_INTERVAL_ID) {
770
+ this.intervalViews.partialNext({
771
+ logicalTail: this.intervalItemsOrEmpty(this.liveTailLogical),
772
+ });
773
+ } else if ((interval as Interval).isHead) {
774
+ // On removal the head page is gone (no other interval is `isHead`) → clear; otherwise the
775
+ // committed page IS the head.
776
+ this.publishAsAnchoredHead(removed ? undefined : interval);
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Publish `interval` as the anchored head — the loaded page bounded at the dataset head (`isHead`),
782
+ * or `undefined` to clear it (the head page was removed or a page stopped being the head). Callers
783
+ * pass the interval they already have, so this does not re-scan storage for the head. Its content
784
+ * changes via ingest/remove (routed through {@link commitInterval}/{@link dropInterval}) and its
785
+ * identity changes when a page's `isHead` flag flips during query reconciliation — both call here.
786
+ */
787
+ protected publishAsAnchoredHead(interval: AnyInterval | undefined) {
788
+ this.intervalViews.partialNext({ anchoredHead: this.intervalItemsOrEmpty(interval) });
789
+ }
790
+
791
+ /**
792
+ * Keep `anchoredHead` in sync after a page's `isHead` flag was (re)computed during query
793
+ * reconciliation, given its value `wasHead` beforehand. Acts only on an actual transition:
794
+ * - became the head page → publish it as the anchored head;
795
+ * - stopped being the head page → clear the anchored head;
796
+ * - unchanged → nothing (a content change, if any, was already published when the interval was
797
+ * committed — see {@link commitInterval}).
798
+ */
799
+ protected syncAnchoredHeadAfterHeadFlip(interval: Interval, wasHead: boolean) {
800
+ if (interval.isHead === wasHead) return;
801
+ this.publishAsAnchoredHead(interval.isHead ? interval : undefined);
802
+ }
803
+
804
+ private intervalItemsOrEmpty(interval: AnyInterval | undefined): T[] {
805
+ return interval ? this.intervalToItems(interval) : [];
806
+ }
807
+
808
+ /**
809
+ * Empty every {@link intervalViews} field. Used by reset paths that clear intervals in bulk (via
810
+ * {@link setIntervals}), which bypasses the per-interval {@link commitInterval}/{@link dropInterval}
811
+ * publishing. No-ops when the views are already empty so a reset does not emit needlessly.
812
+ */
813
+ protected clearIntervalViews() {
814
+ this._pendingViewChangedIds.clear();
815
+ const { logicalHead, logicalTail, anchoredHead } =
816
+ this.intervalViews.getLatestValue();
817
+ // Clear whenever any view holds items; skip only when all are already empty, so a reset on an
818
+ // empty paginator does not emit a redundant empty→empty change (new `[]` refs would wake selectors).
819
+ const alreadyEmpty =
820
+ logicalHead.length === 0 && logicalTail.length === 0 && anchoredHead.length === 0;
821
+ if (alreadyEmpty) return;
822
+ this.intervalViews.partialNext({
823
+ logicalHead: [],
824
+ logicalTail: [],
825
+ anchoredHead: [],
826
+ });
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // Abstracts
831
+ // ---------------------------------------------------------------------------
832
+
833
+ abstract query(
834
+ params: PaginationQueryParams<Q>,
835
+ ): Promise<PaginationQueryReturnValue<T>>;
836
+
837
+ abstract filterQueryResults(items: T[]): T[];
838
+
839
+ /**
840
+ * Subclasses must return the query shape.
841
+ */
842
+ protected getNextQueryShape(
843
+ _params: Pick<PaginationQueryParams<Q>, 'direction'> = {},
844
+ ): Q {
845
+ throw new Error('Paginator.getNextQueryShape() is not implemented');
846
+ }
847
+
848
+ /**
849
+ * Filters an item is matched against locally (`matchesFilter`) — NOT the filters sent to the server.
850
+ * A paginator whose backend query is filtered has to build the request filters separately (see
851
+ * `ChannelPaginator.buildQueryFilters`), because the two can differ: the backend may resolve a
852
+ * server-side stored filter of its own, and some paginators filter locally without sending anything.
853
+ */
854
+ protected buildMatchFilters(): object | null {
855
+ return null; // === no filters
856
+ }
857
+
858
+ matchesFilter(item: T): boolean {
859
+ const filters = this.buildMatchFilters();
860
+ if (filters == null) return true;
861
+ return itemMatchesFilter<T>(item, filters, {
862
+ resolvers: this._filterFieldToDataResolvers,
863
+ });
864
+ }
865
+
866
+ setFilterResolvers(resolvers: FieldToDataResolver<T>[]) {
867
+ this._filterFieldToDataResolvers = resolvers;
868
+ }
869
+
870
+ addFilterResolvers(resolvers: FieldToDataResolver<T>[]) {
871
+ this._filterFieldToDataResolvers.push(...resolvers);
872
+ }
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // Item accessors
876
+ // ---------------------------------------------------------------------------
877
+ getItemId(item: T): string {
878
+ return (item as { id: string }).id;
879
+ }
880
+
881
+ getItem(id: string | undefined): T | undefined {
882
+ return typeof id === 'string' ? this._itemIndex?.get(id) : undefined;
883
+ }
884
+
885
+ /**
886
+ * Whether this paginator's live `state.items` publishes are currently throttled — `stateThrottleMs`
887
+ * is set, throttling is not globally disabled (tests), and item order is not locked (a locked-order
888
+ * list must emit the caller-computed, order-preserved array, not a re-projection). When false, every
889
+ * live mutation publishes immediately, exactly as before this feature.
890
+ */
891
+ protected get isStateThrottled(): boolean {
892
+ return (
893
+ isStateThrottlingEnabled() &&
894
+ !!this._windowPublishThrottle &&
895
+ !this.config.lockItemOrder
896
+ );
897
+ }
898
+
899
+ /** Re-project the active window from its (live, source-of-truth) interval. `undefined` when inactive. */
900
+ private projectActiveWindow(): T[] | undefined {
901
+ if (!this._activeIntervalId) return undefined;
902
+ const active = this._itemIntervals.get(this._activeIntervalId);
903
+ return active ? this.intervalToItems(active) : undefined;
904
+ }
905
+
906
+ /**
907
+ * Publish the active window to `state`, re-projecting it fresh from the active interval at call time
908
+ * (the throttle boundary / flush). Because the intervals are mutated synchronously by every live op,
909
+ * this always reflects the latest settled state, so intermediate values within a window coalesce
910
+ * away. Clears the visible window when the active interval is gone (e.g. the last item was removed).
911
+ */
912
+ private flushWindowPublish(): void {
913
+ const items = this.projectActiveWindow();
914
+ if (items) {
915
+ this.state.partialNext({ items });
916
+ return;
917
+ }
918
+ if ((this.state.getLatestValue().items?.length ?? 0) > 0) {
919
+ this.state.partialNext({ items: [] });
920
+ }
921
+ }
922
+
923
+ /**
924
+ * Schedule a throttled active-window publish (leading + trailing). Called from live mutations
925
+ * (ingest / content-change / remove) INSTEAD of an inline `state.partialNext({ items })` when
926
+ * {@link isStateThrottled}. Safe to call many times within one op — they coalesce to a single emit.
927
+ */
928
+ protected scheduleWindowPublish(): void {
929
+ this._windowPublishThrottle?.throttledFn();
930
+ }
931
+
932
+ /**
933
+ * Flush any pending throttled window + interval-view publishes immediately. No-op when nothing
934
+ * is pending or throttling is off.
935
+ */
936
+ protected flushPendingPublishes(): void {
937
+ this._windowPublishThrottle?.flush();
938
+ this._viewPublishThrottle?.flush();
939
+ }
940
+
941
+ /**
942
+ * {@link _viewPublishThrottle} boundary: apply every interval-view change buffered since the last
943
+ * flush, then clear the buffer. Independent of the `state.items` window publish, so a view update
944
+ * lands within one throttle interval even when the active window stays quiet (e.g. a reaction on a
945
+ * head message while a non-head window is active). No-op when nothing was buffered.
946
+ */
947
+ private flushIntervalViewPublish(): void {
948
+ if (!this._pendingViewChangedIds.size) return;
949
+ this.refreshIntervalViewsForChangedIds(this._pendingViewChangedIds);
950
+ this._pendingViewChangedIds.clear();
951
+ }
952
+
953
+ /**
954
+ * Refresh any tracked {@link intervalViews} field (logical head, logical tail, anchored head) that
955
+ * holds one of `changedIds`. A sibling holder writing new content through the shared item store
956
+ * swaps the item object those views reference, but only this paginator's own ingest/remove
957
+ * ({@link commitInterval}/{@link dropInterval}) republish them — so a reaction/edit made elsewhere
958
+ * would otherwise leave stale references in `anchoredHead`/`logicalHead`/`logicalTail` even though
959
+ * the active window (`state.items`, see {@link reconcileChangedIds}) was refreshed.
960
+ *
961
+ * Called immediately for un-throttled paginators, or from {@link flushIntervalViewPublish} at the
962
+ * view-publish throttle boundary when throttled — see {@link reconcileChangedIds}. Handled
963
+ * independently of `state.items`. When the anchored head is also the active interval its content is
964
+ * projected here as well as into `state.items`; deduping that double projection is a separate,
965
+ * deferred perf follow-up.
966
+ */
967
+ private refreshIntervalViewsForChangedIds(changedIds: ReadonlySet<string>): void {
968
+ const head = this.liveHeadLogical;
969
+ if (head && this.intervalHoldsAnyChangedId(head, changedIds)) {
970
+ this.intervalViews.partialNext({ logicalHead: this.intervalToItems(head) });
971
+ }
972
+ const tail = this.liveTailLogical;
973
+ if (tail && this.intervalHoldsAnyChangedId(tail, changedIds)) {
974
+ this.intervalViews.partialNext({ logicalTail: this.intervalToItems(tail) });
975
+ }
976
+ let anchored: Interval | undefined;
977
+ for (const itv of this._itemIntervals.values()) {
978
+ if (!isLogicalInterval(itv) && itv.isHead) {
979
+ anchored = itv;
980
+ break;
981
+ }
982
+ }
983
+ if (anchored && this.intervalHoldsAnyChangedId(anchored, changedIds)) {
984
+ this.publishAsAnchoredHead(anchored);
985
+ }
986
+ }
987
+
988
+ private intervalHoldsAnyChangedId(
989
+ interval: AnyInterval,
990
+ changedIds: ReadonlySet<string>,
991
+ ): boolean {
992
+ for (const id of interval.itemIds) if (changedIds.has(id)) return true;
993
+ return false;
994
+ }
995
+
996
+ /**
997
+ * Reconcile the projected window + interval views against a set of changed ids: another holder
998
+ * swapped the shared item object those views reference. Refreshes any tracked interval view that
999
+ * holds a changed id and re-projects (or slot-swaps) the active window — coalesced through the
1000
+ * publish throttles when throttling is on.
1001
+ */
1002
+ protected reconcileChangedIds(changedIds: ReadonlySet<string>): void {
1003
+ // A sibling holder changed shared content: refresh any tracked interval view (logical head/tail,
1004
+ // anchored head) holding a changed id — independent of the active window below, since a view can
1005
+ // hold a changed id the active window does not. When throttled, buffer the ids and tick the
1006
+ // view-publish throttle so refreshes coalesce (once per interval) yet still land on their own
1007
+ // trailing edge even if `state.items` never publishes again; otherwise publish immediately.
1008
+ if (this.isStateThrottled) {
1009
+ for (const id of changedIds) this._pendingViewChangedIds.add(id);
1010
+ this._viewPublishThrottle?.throttledFn();
1011
+ } else {
1012
+ this.refreshIntervalViewsForChangedIds(changedIds);
1013
+ }
1014
+
1015
+ if (!this._activeIntervalId) return;
1016
+ const activeInterval = this._itemIntervals.get(this._activeIntervalId);
1017
+ if (!activeInterval) return;
1018
+
1019
+ // Throttled (message list): the slot-swap fast path below reads the last-published `items`, which
1020
+ // lags the live intervals while throttled — so skip it. Gate on membership only and schedule a
1021
+ // single coalesced re-projection; the boundary re-derives the window fresh from the interval.
1022
+ if (this.isStateThrottled) {
1023
+ for (const id of activeInterval.itemIds) {
1024
+ if (changedIds.has(id)) {
1025
+ this.scheduleWindowPublish();
1026
+ return;
1027
+ }
1028
+ }
1029
+ return;
1030
+ }
1031
+
1032
+ // Fast path: a content update (an in-place edit written through a sibling holder) changes
1033
+ // items in place without changing membership or order. When the current window still lines
1034
+ // up with the interval one-to-one, shallow-copy it and swap only the changed slots — this
1035
+ // preserves every unchanged item reference (so memoized rows bail) and avoids re-mapping and
1036
+ // re-sorting the whole active window on every event. Skipped when a boost is active (a boost
1037
+ // can reorder the visible window, which a slot-swap would not reflect) — then we fall through
1038
+ // to the full projection below, which applies the boost order.
1039
+ const currentItems = this.items;
1040
+ this.clearExpiredBoosts();
1041
+ if (
1042
+ currentItems &&
1043
+ currentItems.length === activeInterval.itemIds.length &&
1044
+ (this.config.lockItemOrder || this.boosts.size === 0)
1045
+ ) {
1046
+ let next: T[] | undefined;
1047
+ let needsFullProjection = false;
1048
+ for (let i = 0; i < currentItems.length; i++) {
1049
+ const id = this.getItemId(currentItems[i]);
1050
+ if (!changedIds.has(id)) continue;
1051
+ const updated = this._itemIndex.get(id);
1052
+ if (!updated) {
1053
+ // The id left the store (a removal, not an in-place update) — resync via a full projection.
1054
+ needsFullProjection = true;
1055
+ break;
1056
+ }
1057
+ if (updated === currentItems[i]) continue;
1058
+ if (!next) next = currentItems.slice();
1059
+ next[i] = updated;
1060
+ }
1061
+ if (!needsFullProjection) {
1062
+ if (next) this.state.partialNext({ items: next });
1063
+ return;
1064
+ }
1065
+ }
1066
+
1067
+ // Fallback: membership/order drifted (or no window to patch, or a boost is active). Re-project,
1068
+ // but only if a changed id is actually in the active interval.
1069
+ for (const id of activeInterval.itemIds) {
1070
+ if (changedIds.has(id)) {
1071
+ this.state.partialNext({ items: this.intervalToItems(activeInterval) });
1072
+ return;
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ // ---------------------------------------------------------------------------
1078
+ // Boosts
1079
+ // ---------------------------------------------------------------------------
1080
+
1081
+ protected clearExpiredBoosts(now = Date.now()) {
1082
+ for (const [id, b] of this.boosts) if (now > b.until) this.boosts.delete(id);
1083
+ this._maxBoostSeq = Math.max(
1084
+ ...Array.from(this.boosts.values()).map((boost) => boost.seq),
1085
+ 0,
1086
+ );
1087
+ }
1088
+
1089
+ /**
1090
+ * Applied by the effectiveComparator to take into consideration item boosts when sorting items.
1091
+ *
1092
+ * @param a - The first item to compare.
1093
+ * @param b - The second item to compare.
1094
+ */
1095
+ protected boostComparator = (a: T, b: T): number => {
1096
+ const now = Date.now();
1097
+ this.clearExpiredBoosts(now);
1098
+
1099
+ const idA = this.getItemId(a);
1100
+ const idB = this.getItemId(b);
1101
+ const boostA = this.getBoost(idA);
1102
+ const boostB = this.getBoost(idB);
1103
+
1104
+ const aIsBoosted = !!(boostA && now <= boostA.until);
1105
+ const bIsBoosted = !!(boostB && now <= boostB.until);
1106
+
1107
+ if (aIsBoosted && !bIsBoosted) return -1;
1108
+ if (!aIsBoosted && bIsBoosted) return 1;
1109
+
1110
+ if (aIsBoosted && bIsBoosted) {
1111
+ const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0);
1112
+ if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1;
1113
+ }
1114
+ return this.itemOrderComparator(a, b);
1115
+ };
1116
+
1117
+ /**
1118
+ * Increases the item's importance when sorting.
1119
+ * Boost affects position inside an item interval (if used), but should not redefine interval boundaries.
1120
+ *
1121
+ * @param itemId - Id of the item to boost.
1122
+ * @param opts - Boost options: `ttlMs` / `until` control expiry and `seq` orders concurrent boosts.
1123
+ */
1124
+ boost(itemId: string, opts?: { ttlMs?: number; until?: number; seq?: number }) {
1125
+ const now = Date.now();
1126
+ const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000);
1127
+
1128
+ if (typeof opts?.seq === 'number' && opts.seq > this._maxBoostSeq) {
1129
+ this._maxBoostSeq = opts.seq;
1130
+ }
1131
+
1132
+ const seq = opts?.seq ?? 0;
1133
+ this.boosts.set(itemId, { until, seq });
1134
+ }
1135
+
1136
+ getBoost(id: string) {
1137
+ return this.boosts.get(id);
1138
+ }
1139
+
1140
+ removeBoost(id: string) {
1141
+ this.boosts.delete(id);
1142
+ this._maxBoostSeq = Math.max(
1143
+ ...Array.from(this.boosts.values()).map((boost) => boost.seq),
1144
+ 0,
1145
+ );
1146
+ }
1147
+
1148
+ isBoosted(id: string) {
1149
+ const boost = this.getBoost(id);
1150
+ return !!(boost && Date.now() <= boost.until);
1151
+ }
1152
+
1153
+ // ---------------------------------------------------------------------------
1154
+ // Interval manipulation
1155
+ // ---------------------------------------------------------------------------
1156
+
1157
+ generateIntervalId(_page: (T | string)[]): string {
1158
+ return `interval-${generateUUIDv4()}`;
1159
+ }
1160
+
1161
+ intervalToItems(interval: Interval | LogicalInterval): T[] {
1162
+ const items = interval.itemIds
1163
+ .map((id) => this._itemIndex?.get(id))
1164
+ .filter((item): item is T => !!item);
1165
+
1166
+ // When lockItemOrder is true, we must *not* reflect boosts in state.items.
1167
+ if (this.config.lockItemOrder) {
1168
+ return items;
1169
+ }
1170
+
1171
+ // itemIds are maintained in itemOrder, so the mapped items are already ordered; only an active
1172
+ // boost can reorder the visible window. Skip the otherwise-redundant full re-sort when none are
1173
+ // active (the common case), so a page ingest / full projection is a map, not a map + sort.
1174
+ this.clearExpiredBoosts();
1175
+ if (this.boosts.size === 0) {
1176
+ return items;
1177
+ }
1178
+
1179
+ // Visible ordering uses boost-aware comparator
1180
+ return items.sort(this.effectiveComparator.bind(this));
1181
+ }
1182
+
1183
+ makeInterval({ page, isHead, isTail }: MakeIntervalParams<T>): Interval {
1184
+ const sorted = [...page].sort((a, b) => this.itemOrderComparator(a, b));
1185
+ return {
1186
+ id: this.generateIntervalId(page),
1187
+ // Default semantics:
1188
+ // - if interval is known global head/tail, there is no more data in that direction
1189
+ // - otherwise treat it as unknown => "has more" (until proven otherwise by a query)
1190
+ hasMoreHead: isHead ? false : true,
1191
+ hasMoreTail: isTail ? false : true,
1192
+ itemIds: sorted.map(this.getItemId.bind(this)),
1193
+ isHead: !!isHead,
1194
+ isTail: !!isTail,
1195
+ };
1196
+ }
1197
+
1198
+ protected getCursorFromInterval(interval: Interval): PaginatorCursor {
1199
+ // Prefer resolving edge items via sort bounds, because:
1200
+ // - interval ordering can differ from interval sorting (intervalSortDirection)
1201
+ // - "head" is a semantic concept (where new items appear), not necessarily `itemIds[0]`
1202
+ // - itemIds are stored in sortComparator order, but we want the *pagination* edges
1203
+ const edges = this.getIntervalPaginationEdges(interval);
1204
+
1205
+ const fallbackFirstId = interval.itemIds[0] ?? null;
1206
+ const fallbackLastId = interval.itemIds.slice(-1)[0] ?? null;
1207
+
1208
+ const fallbackHeadId = this.intervalItemIdsAreHeadFirst
1209
+ ? fallbackFirstId
1210
+ : fallbackLastId;
1211
+ const fallbackTailId = this.intervalItemIdsAreHeadFirst
1212
+ ? fallbackLastId
1213
+ : fallbackFirstId;
1214
+
1215
+ const headId = edges?.head ? this.getItemId(edges.head) : fallbackHeadId;
1216
+ const tailId = edges?.tail ? this.getItemId(edges.tail) : fallbackTailId;
1217
+
1218
+ return {
1219
+ headward: interval.hasMoreHead ? headId : null,
1220
+ tailward: interval.hasMoreTail ? tailId : null,
1221
+ };
1222
+ }
1223
+
1224
+ isActiveInterval(interval: AnyInterval): boolean {
1225
+ return this._activeIntervalId === interval.id;
1226
+ }
1227
+
1228
+ /**
1229
+ * Whether the currently active (viewed) interval is the anchored head. Used to decide if it is
1230
+ * safe to re-seed an already-loaded paginator with a fresh newest page: only when at the head
1231
+ * (the newest page overlaps it, so the re-seed reconciles + re-derives cursors in place). When the
1232
+ * caller has jumped to an older window (active interval is NOT the head), a first-page re-seed
1233
+ * would force-merge the newest page into that window across the gap - so the re-seed is skipped.
1234
+ */
1235
+ get isActiveIntervalAtHead(): boolean {
1236
+ const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals);
1237
+ return (
1238
+ !!head &&
1239
+ !isLogicalInterval(head) &&
1240
+ !!(head as Interval).isHead &&
1241
+ this.isActiveInterval(head)
1242
+ );
1243
+ }
1244
+
1245
+ setActiveInterval(interval: AnyInterval | undefined, opts?: { updateState?: boolean }) {
1246
+ this._activeIntervalId = interval?.id;
1247
+
1248
+ // Public API expectation: activating an anchored interval should immediately
1249
+ // reflect its pagination ability in paginator state.
1250
+ //
1251
+ // Internal callers that are in the middle of a transactional `state.next()`
1252
+ // update must pass `{ updateState: false }` and project these flags into the
1253
+ // state object directly.
1254
+ if (opts?.updateState === false) return;
1255
+ if (!interval || isLogicalInterval(interval)) return;
1256
+
1257
+ this.state.partialNext({
1258
+ items: this.intervalToItems(interval),
1259
+ hasMoreHead: interval.hasMoreHead,
1260
+ hasMoreTail: interval.hasMoreTail,
1261
+ });
1262
+ }
1263
+
1264
+ protected getIntervalSortBounds(
1265
+ interval: Interval | LogicalInterval,
1266
+ ): IntervalSortBounds<T> | null {
1267
+ const ids = interval.itemIds;
1268
+ if (!this._itemIndex || ids.length === 0) return null;
1269
+ const start = this._itemIndex?.get?.(ids[0]);
1270
+ const end = this._itemIndex?.get?.(ids[ids.length - 1]);
1271
+ return { start, end } as IntervalSortBounds<T>;
1272
+ }
1273
+
1274
+ /**
1275
+ * Returns pagination head/tail edges of an interval.
1276
+ *
1277
+ * IMPORTANT:
1278
+ * - Edges are derived from the *sort bounds* of the interval (min/max under `sortComparator`).
1279
+ * - Which bound is treated as the pagination "head" is controlled by `intervalItemIdsAreHeadFirst`.
1280
+ * - This is a semantic notion of head/tail (where new items are expected to appear),
1281
+ * not necessarily "min/max under sortComparator".
1282
+ * New items are always expected to appear at the head of the interval.
1283
+ */
1284
+ protected getIntervalPaginationEdges(
1285
+ interval: Interval | LogicalInterval,
1286
+ ): IntervalPaginationEdges<T> | null {
1287
+ const bounds = this.getIntervalSortBounds(interval);
1288
+ if (!bounds) return null;
1289
+ return this.intervalItemIdsAreHeadFirst
1290
+ ? { head: bounds.start, tail: bounds.end }
1291
+ : { head: bounds.end, tail: bounds.start };
1292
+ }
1293
+
1294
+ protected compareIntervalHeadEdges(a: T, b: T): number {
1295
+ const cmp = this.itemOrderComparator(a, b);
1296
+ return this.intervalSortDirection === 'asc' ? cmp : -cmp;
1297
+ }
1298
+
1299
+ protected aIsMoreHeadwardThanB(a: T, b: T): boolean {
1300
+ return this.intervalItemIdsAreHeadFirst
1301
+ ? this.itemOrderComparator(a, b) === ComparisonResult.A_PRECEDES_B
1302
+ : this.itemOrderComparator(b, a) === ComparisonResult.A_PRECEDES_B;
1303
+ }
1304
+
1305
+ protected aIsMoreTailwardThanB(a: T, b: T): boolean {
1306
+ return this.intervalItemIdsAreHeadFirst
1307
+ ? this.itemOrderComparator(b, a) === ComparisonResult.A_PRECEDES_B
1308
+ : this.itemOrderComparator(a, b) === ComparisonResult.A_PRECEDES_B;
1309
+ }
1310
+
1311
+ protected getHeadIntervalFromSortedIntervals(
1312
+ intervals: AnyInterval[],
1313
+ ): AnyInterval | undefined {
1314
+ if (intervals.length === 0) return undefined;
1315
+ if (intervals.length === 1) return intervals[0];
1316
+
1317
+ const headIsLowerSortValue = this.intervalItemIdsAreHeadFirst;
1318
+ const intervalsSortedAsc = this.intervalSortDirection === 'asc';
1319
+
1320
+ const headIndex =
1321
+ headIsLowerSortValue === intervalsSortedAsc ? 0 : intervals.length - 1;
1322
+ return intervals[headIndex];
1323
+ }
1324
+
1325
+ protected getTailIntervalFromSortedIntervals(
1326
+ intervals: AnyInterval[],
1327
+ ): AnyInterval | undefined {
1328
+ if (intervals.length === 0) return undefined;
1329
+ if (intervals.length === 1) return intervals[0];
1330
+
1331
+ const headIsLowerSortValue = this.intervalItemIdsAreHeadFirst;
1332
+ const intervalsSortedAsc = this.intervalSortDirection === 'asc';
1333
+
1334
+ const tailIndex =
1335
+ headIsLowerSortValue === intervalsSortedAsc ? intervals.length - 1 : 0;
1336
+ return intervals[tailIndex];
1337
+ }
1338
+
1339
+ protected sortIntervals<I extends AnyInterval>(intervals: I[]): I[] {
1340
+ const intervalsCopy = [...intervals];
1341
+ intervalsCopy.sort(this.intervalComparator.bind(this));
1342
+ return intervalsCopy;
1343
+ }
1344
+
1345
+ protected setIntervals(intervals: AnyInterval[]) {
1346
+ this._itemIntervals = new Map(intervals.map((i) => [i.id, i]));
1347
+ }
1348
+
1349
+ protected intervalsStrictlyOverlap(a: AnyInterval, b: AnyInterval): boolean {
1350
+ const aBounds = this.getIntervalSortBounds(a);
1351
+ const bBounds = this.getIntervalSortBounds(b);
1352
+ if (!aBounds || !bBounds) return false;
1353
+ return (
1354
+ this.itemOrderComparator(aBounds.start, bBounds.end) <= 0 &&
1355
+ this.itemOrderComparator(bBounds.start, aBounds.end) <= 0
1356
+ );
1357
+ }
1358
+
1359
+ /**
1360
+ * Returns true if intervals A and B should be merged.
1361
+ *
1362
+ * 1) Strict overlap (range overlap in `sortComparator` order):
1363
+ * A.min ≤ B.max AND B.min ≤ A.max
1364
+ *
1365
+ * 2) Forced merge (policy: 'auto' only):
1366
+ * If one interval is marked as `isHead`/`isTail`, treat the other as mergeable
1367
+ * when it extends beyond that interval's pagination head/tail edge
1368
+ * (computed via `getIntervalPaginationEdges` + headward/tailward helpers).
1369
+ *
1370
+ * In 'strict-overlap-only' policy, only (1) applies.
1371
+ */
1372
+ protected intervalsOverlap(
1373
+ a: AnyInterval,
1374
+ b: AnyInterval,
1375
+ policy: IntervalMergePolicy = 'auto',
1376
+ ): boolean {
1377
+ const aBounds = this.getIntervalSortBounds(a);
1378
+ const bBounds = this.getIntervalSortBounds(b);
1379
+ if (!aBounds || !bBounds) return false;
1380
+
1381
+ // Strict overlap if:
1382
+ // a.first <= b.last && b.first <= a.last
1383
+ if (
1384
+ this.itemOrderComparator(aBounds.start, bBounds.end) <= 0 &&
1385
+ this.itemOrderComparator(bBounds.start, aBounds.end) <= 0
1386
+ )
1387
+ return true;
1388
+
1389
+ // If policy is strict-overlap-only, return false if the intervals do not strictly overlap.
1390
+ if (policy === 'strict-overlap-only') return false;
1391
+
1392
+ const aIsHead = (a as Interval).isHead;
1393
+ const bIsHead = (b as Interval).isHead;
1394
+ const aIsTail = (a as Interval).isTail;
1395
+ const bIsTail = (b as Interval).isTail;
1396
+
1397
+ const aEdges = this.getIntervalPaginationEdges(a);
1398
+ const bEdges = this.getIntervalPaginationEdges(b);
1399
+ if (!aEdges || !bEdges) return false;
1400
+
1401
+ if (bIsHead && this.aIsMoreHeadwardThanB(aEdges.head, bEdges.head)) return true;
1402
+ if (aIsHead && this.aIsMoreHeadwardThanB(bEdges.head, aEdges.head)) return true;
1403
+ if (bIsTail && this.aIsMoreTailwardThanB(aEdges.tail, bEdges.tail)) return true;
1404
+ if (aIsTail && this.aIsMoreTailwardThanB(bEdges.tail, aEdges.tail)) return true;
1405
+
1406
+ return false;
1407
+ }
1408
+
1409
+ /**
1410
+ * Whether an item belongs to an anchored interval.
1411
+ */
1412
+ protected belongsToInterval(item: T, interval: AnyInterval): boolean {
1413
+ const sortBounds = this.getIntervalSortBounds(interval);
1414
+ if (!sortBounds) return false;
1415
+ const { start, end } = sortBounds;
1416
+ if (
1417
+ this.itemOrderComparator(start, item) <= 0 &&
1418
+ this.itemOrderComparator(item, end) <= 0
1419
+ )
1420
+ return true;
1421
+
1422
+ const edges = this.getIntervalPaginationEdges(interval);
1423
+ if (!edges) return false;
1424
+
1425
+ // Items beyond head/tail edges are considered belonging to the head/tail pages.
1426
+ if ((interval as Interval).isHead && this.aIsMoreHeadwardThanB(item, edges.head))
1427
+ return true;
1428
+
1429
+ return (interval as Interval).isTail && this.aIsMoreTailwardThanB(item, edges.tail);
1430
+ }
1431
+
1432
+ protected mergeTwoAnchoredIntervals(
1433
+ preceding: Interval,
1434
+ following: Interval,
1435
+ ): Interval {
1436
+ const mergeIds = (a: string[], b: string[]): string[] => {
1437
+ const itemIndex = this._itemIndex;
1438
+ if (!itemIndex) return a;
1439
+
1440
+ const seen = new Set<string>();
1441
+ const merged: T[] = [];
1442
+ const mergedIds: string[] = [];
1443
+
1444
+ const pushId = (id: string) => {
1445
+ if (seen.has(id)) return;
1446
+ const item = itemIndex.get(id);
1447
+ if (!item) return;
1448
+ seen.add(id);
1449
+ const { insertionIndex } = binarySearch({
1450
+ needle: item,
1451
+ length: merged.length,
1452
+ getItemAt: (index: number) => merged[index],
1453
+ itemIdentityEquals: (item1, item2) =>
1454
+ this.getItemId(item1) === this.getItemId(item2),
1455
+ // inter-interval operation sorts using the base comparator
1456
+ compare: this.itemOrderComparator.bind(this),
1457
+ });
1458
+ if (insertionIndex > -1) {
1459
+ merged.splice(insertionIndex, 0, item);
1460
+ mergedIds.splice(insertionIndex, 0, this.getItemId(item));
1461
+ }
1462
+ };
1463
+
1464
+ a.forEach(pushId);
1465
+ b.forEach(pushId);
1466
+
1467
+ return mergedIds;
1468
+ };
1469
+
1470
+ const mergedItemIds = mergeIds(preceding.itemIds, following.itemIds);
1471
+
1472
+ const precedingEdges = this.getIntervalPaginationEdges(preceding);
1473
+ const followingEdges = this.getIntervalPaginationEdges(following);
1474
+
1475
+ const isHead = preceding.isHead || following.isHead;
1476
+ const isTail = preceding.isTail || following.isTail;
1477
+
1478
+ // Default conservative merge:
1479
+ // - if any contributor already concluded "no more" in a direction, keep that
1480
+ let hasMoreHead = preceding.hasMoreHead && following.hasMoreHead;
1481
+ let hasMoreTail = preceding.hasMoreTail && following.hasMoreTail;
1482
+
1483
+ if (precedingEdges && followingEdges) {
1484
+ const headMost = this.aIsMoreHeadwardThanB(precedingEdges.head, followingEdges.head)
1485
+ ? preceding
1486
+ : following;
1487
+ const tailMost = this.aIsMoreTailwardThanB(precedingEdges.tail, followingEdges.tail)
1488
+ ? preceding
1489
+ : following;
1490
+
1491
+ hasMoreHead = headMost.hasMoreHead;
1492
+ hasMoreTail = tailMost.hasMoreTail;
1493
+ }
1494
+
1495
+ return {
1496
+ ...preceding,
1497
+ itemIds: mergedItemIds,
1498
+ // Boundary intervals stay boundaries even if their edge shifts due to forced merges.
1499
+ hasMoreHead: isHead ? false : hasMoreHead,
1500
+ hasMoreTail: isTail ? false : hasMoreTail,
1501
+ isHead,
1502
+ isTail,
1503
+ };
1504
+ }
1505
+
1506
+ /**
1507
+ * Merges anchored intervals. Returns null if there are no intervals to merge.
1508
+ */
1509
+ protected mergeAnchoredIntervals(
1510
+ intervals: Interval[],
1511
+ baseInterval?: Interval,
1512
+ ): Interval | null {
1513
+ if (intervals.length === 0) return null;
1514
+
1515
+ const intervalsCopy = this.sortIntervals(intervals);
1516
+
1517
+ let acc = cloneInterval(baseInterval ?? intervalsCopy[0]);
1518
+ for (let i = baseInterval ? 0 : 1; i < intervalsCopy.length; i++) {
1519
+ const next = intervalsCopy[i];
1520
+ acc = this.mergeTwoAnchoredIntervals(acc, next);
1521
+ }
1522
+
1523
+ return acc;
1524
+ }
1525
+
1526
+ // ---------------------------------------------------------------------------
1527
+ // Locate items and intervals
1528
+ // ---------------------------------------------------------------------------
1529
+
1530
+ protected locateIntervalIndex(interval: Interval): number {
1531
+ const intervals = this.itemIntervals.filter(
1532
+ (i) => !isLogicalInterval(i),
1533
+ ) as Interval[];
1534
+ if (intervals.length === 0) return -1;
1535
+ if (intervals.length === 1) return interval.id === intervals[0].id ? 0 : -1;
1536
+
1537
+ return binarySearch({
1538
+ needle: interval,
1539
+ length: intervals.length,
1540
+ // eslint-disable-next-line
1541
+ getItemAt: (index: number) => {
1542
+ return intervals[index];
1543
+ },
1544
+ itemIdentityEquals: (item1, item2) => item1.id === item2.id,
1545
+ compare: this.intervalComparator.bind(this),
1546
+ plateauScan: true,
1547
+ }).currentIndex;
1548
+ }
1549
+ /**
1550
+ * Locate item inside a specific interval using the same logic as locateByItem,
1551
+ * but scoped to interval items.
1552
+ */
1553
+ protected locateByItemInInterval({
1554
+ item,
1555
+ interval,
1556
+ }: {
1557
+ item: T;
1558
+ interval: Interval | LogicalInterval;
1559
+ }): ItemLocation | null {
1560
+ const ids = interval.itemIds;
1561
+
1562
+ return binarySearch({
1563
+ needle: item,
1564
+ length: ids.length,
1565
+ getItemAt: (index: number) => this.getItem(ids[index]),
1566
+ itemIdentityEquals: (item1, item2) =>
1567
+ this.getItemId(item1) === this.getItemId(item2),
1568
+ // items in intervals are not sorted by effectiveComparator
1569
+ compare: this.itemOrderComparator.bind(this),
1570
+ plateauScan: true,
1571
+ });
1572
+ }
1573
+
1574
+ protected locateIntervalForItem(item: T): AnyInterval | undefined {
1575
+ if (this._itemIntervals.size === 0) return undefined;
1576
+
1577
+ for (const itv of this.itemIntervals) {
1578
+ if (this.belongsToInterval(item, itv)) {
1579
+ return itv;
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ /**
1585
+ * The interval whose `itemIds` lists this id, regardless of where the item now sorts.
1586
+ */
1587
+ protected findIntervalHoldingItem(item: T): AnyInterval | undefined {
1588
+ const id = this.getItemId(item);
1589
+ for (const interval of this.itemIntervals) {
1590
+ if (interval.itemIds.includes(id)) return interval;
1591
+ }
1592
+ return undefined;
1593
+ }
1594
+
1595
+ protected locateByItemInIntervals(item: T): ItemCoordinates['interval'] | undefined {
1596
+ // Two different questions, asked in this order:
1597
+ // 1. which interval's sort bounds does this item fall into (`locateIntervalForItem`)? The only
1598
+ // one that can answer for an item this paginator has never stored.
1599
+ // 2. which interval actually lists this id (`findIntervalHoldingItem`)?
1600
+ //
1601
+ // They disagree when an item is updated in place: the interval holds the very object being
1602
+ // ingested, so it already carries the item's NEW sort value, and an item that moved outside its
1603
+ // own window's bounds is no longer found by (1) — even though (2) still lists it. The fallback
1604
+ // is what lets `ingestItem` take the old entry out before re-inserting; without it the id stays
1605
+ // behind in that interval and ends up stored in two places at once.
1606
+ const interval =
1607
+ this.locateIntervalForItem(item) ?? this.findIntervalHoldingItem(item);
1608
+ if (!interval) return undefined;
1609
+ const itemLocation = this.locateByItemInInterval({ item, interval });
1610
+ if (!itemLocation) return undefined;
1611
+ return { interval, ...itemLocation };
1612
+ }
1613
+
1614
+ /**
1615
+ * Locates the current position of the item and the index at which the item should be inserted
1616
+ * according to effectiveComparator.
1617
+ *
1618
+ * @param item - The item to locate within the current state.
1619
+ */
1620
+ protected locateItemInState(item: T): ItemLocation | null {
1621
+ const items = [...(this.items ?? [])];
1622
+
1623
+ return binarySearch({
1624
+ needle: item,
1625
+ length: items.length,
1626
+ getItemAt: (index: number) => items[index],
1627
+ itemIdentityEquals: (item1, item2) =>
1628
+ this.getItemId(item1) === this.getItemId(item2),
1629
+ compare: this.effectiveComparator.bind(this),
1630
+ plateauScan: true,
1631
+ });
1632
+ }
1633
+
1634
+ locateByItem = (item: T): ItemCoordinates => {
1635
+ const result: ItemCoordinates = {};
1636
+
1637
+ // 1. Search in visible state.items
1638
+ const stateLoc = this.locateItemInState(item);
1639
+ if (stateLoc) {
1640
+ result.state = stateLoc;
1641
+ }
1642
+
1643
+ // 2. Search in intervals if interval-mode is active
1644
+ const intervalLoc = this.locateByItemInIntervals(item);
1645
+ if (intervalLoc) {
1646
+ result.interval = intervalLoc;
1647
+ }
1648
+
1649
+ return result;
1650
+ };
1651
+
1652
+ // ---------------------------------------------------------------------------
1653
+ // Item ingestion
1654
+ // ---------------------------------------------------------------------------
1655
+
1656
+ protected removeItemIdFromInterval({
1657
+ interval,
1658
+ ...itemLocation
1659
+ }: ItemIntervalCoordinates): ItemIntervalCoordinates {
1660
+ if (
1661
+ // If already at the correct position, nothing to change
1662
+ itemLocation.currentIndex >= 0 &&
1663
+ itemLocation.currentIndex === itemLocation.insertionIndex
1664
+ )
1665
+ return { interval, ...itemLocation };
1666
+
1667
+ const itemIds = [...interval.itemIds];
1668
+
1669
+ // Adjust insertion index if we are removing the item before reinserting index.
1670
+ // locateByItemInInterval() computed insertionIndex with the item still in the array.
1671
+ let insertionIndex = itemLocation.insertionIndex;
1672
+ if (
1673
+ itemLocation.currentIndex >= 0 &&
1674
+ itemLocation.insertionIndex > itemLocation.currentIndex
1675
+ ) {
1676
+ insertionIndex--;
1677
+ }
1678
+
1679
+ // Remove existing occurrence if present
1680
+ if (itemLocation.currentIndex >= 0) {
1681
+ itemIds.splice(itemLocation.currentIndex, 1);
1682
+ }
1683
+ return {
1684
+ interval: { ...interval, itemIds },
1685
+ currentIndex: itemLocation.currentIndex,
1686
+ insertionIndex,
1687
+ };
1688
+ }
1689
+
1690
+ /**
1691
+ * Inserts an item ID into the interval in the correct sorted position.
1692
+ * Returns unchanged interval if the correct insertion position could not be determined.
1693
+ */
1694
+ protected insertItemIdIntoInterval<I extends Interval | LogicalInterval>(
1695
+ interval: I,
1696
+ item: T,
1697
+ ): I {
1698
+ const itemLocation = this.locateByItemInInterval({ item, interval });
1699
+ let insertionIndex = itemLocation?.insertionIndex;
1700
+ let itemIds = [...interval.itemIds];
1701
+
1702
+ if (itemLocation && itemLocation.insertionIndex > -1) {
1703
+ const removal = this.removeItemIdFromInterval({ interval, ...itemLocation });
1704
+ insertionIndex = removal.insertionIndex;
1705
+ itemIds = removal.interval.itemIds;
1706
+ }
1707
+
1708
+ const id = this.getItemId(item);
1709
+
1710
+ // Insert at the new position
1711
+ if (typeof insertionIndex !== 'undefined' && insertionIndex > -1) {
1712
+ itemIds.splice(insertionIndex, 0, id);
1713
+ }
1714
+
1715
+ return {
1716
+ ...interval,
1717
+ itemIds,
1718
+ };
1719
+ }
1720
+
1721
+ /**
1722
+ * Re-evaluates what the logical (live head / tail) intervals still hold against a freshly ingested
1723
+ * page, returning the resulting anchored interval. Live updates park items there before any page
1724
+ * exists — a channel archived while the archived list is untouched lands in the live head — and
1725
+ * once a page covers such an item, leaving it gives the same id two homes, which is what renders
1726
+ * as a duplicated row. The merge in `ingestPage` only folds logical intervals in when its
1727
+ * `isHead`/overlap heuristics fire; this pass is unconditional and idempotent.
1728
+ */
1729
+ protected reconcileLogicalIntervalsAgainst(anchored: Interval): Interval {
1730
+ let merged = anchored;
1731
+
1732
+ for (const logical of [this.liveHeadLogical, this.liveTailLogical]) {
1733
+ if (!logical?.itemIds.length) continue;
1734
+
1735
+ const { mergedAnchored, remainingLogical } = this.mergeItemsFromLogicalInterval(
1736
+ logical,
1737
+ merged,
1738
+ );
1739
+ merged = mergedAnchored;
1740
+
1741
+ if (!remainingLogical) {
1742
+ this.dropInterval(logical.id);
1743
+ continue;
1744
+ }
1745
+ if (remainingLogical.itemIds.length !== logical.itemIds.length) {
1746
+ this.commitInterval(remainingLogical);
1747
+ }
1748
+ this.moveMisfiledItemsToOppositeSide(remainingLogical, merged);
1749
+ }
1750
+
1751
+ return merged;
1752
+ }
1753
+
1754
+ /**
1755
+ * Moves items the loaded window proves are parked on the wrong side: an item sitting in the live
1756
+ * head that actually sorts past the tail edge of everything loaded belongs to the live tail (the
1757
+ * unloaded region on that side), and vice versa. Leaving it claims the wrong end of the list —
1758
+ * the archived channel would render above a page it sorts below.
1759
+ *
1760
+ * Compared against the outermost anchored interval, not the page just ingested: an item that
1761
+ * lands between two loaded pages is not "beyond" either side, it is a gap item, and those stay
1762
+ * where they are (`ingestPage` turns them into their own island once the merge reaches the edge).
1763
+ */
1764
+ protected moveMisfiledItemsToOppositeSide(
1765
+ logical: LogicalInterval,
1766
+ ingested: Interval,
1767
+ ) {
1768
+ const isHeadSide = isLiveHeadInterval(logical);
1769
+ const anchoredIntervals = this.sortIntervals([
1770
+ ...this.itemIntervals.filter(
1771
+ (itv) => !isLogicalInterval(itv) && itv.id !== ingested.id,
1772
+ ),
1773
+ ingested,
1774
+ ]) as Interval[];
1775
+ const outermost = isHeadSide
1776
+ ? this.getTailIntervalFromSortedIntervals(anchoredIntervals)
1777
+ : this.getHeadIntervalFromSortedIntervals(anchoredIntervals);
1778
+ const edges = outermost ? this.getIntervalPaginationEdges(outermost) : null;
1779
+ if (!edges) return;
1780
+
1781
+ const stayIds: string[] = [];
1782
+ const movedIds: string[] = [];
1783
+ for (const id of logical.itemIds) {
1784
+ const item = this.getItem(id);
1785
+ const isBeyondOutermostEdge =
1786
+ !!item &&
1787
+ (isHeadSide
1788
+ ? this.aIsMoreTailwardThanB(item, edges.tail)
1789
+ : this.aIsMoreHeadwardThanB(item, edges.head));
1790
+ (isBeyondOutermostEdge ? movedIds : stayIds).push(id);
1791
+ }
1792
+ if (!movedIds.length) return;
1793
+
1794
+ const oppositeId = isHeadSide ? LOGICAL_TAIL_INTERVAL_ID : LOGICAL_HEAD_INTERVAL_ID;
1795
+ let opposite: LogicalInterval = (isHeadSide
1796
+ ? this.liveTailLogical
1797
+ : this.liveHeadLogical) ?? { id: oppositeId, itemIds: [] };
1798
+ for (const id of movedIds) {
1799
+ const item = this.getItem(id);
1800
+ opposite = item
1801
+ ? this.insertItemIdIntoInterval(opposite, item)
1802
+ : { ...opposite, itemIds: [...opposite.itemIds, id] };
1803
+ }
1804
+
1805
+ this.commitInterval(opposite);
1806
+ if (stayIds.length) this.commitInterval({ ...logical, itemIds: stayIds });
1807
+ else this.dropInterval(logical.id);
1808
+ }
1809
+
1810
+ /**
1811
+ * Splits a logical interval by checking each item individually.
1812
+ * Items overlapping anchoredInterval are merged into it.
1813
+ * Others stay in a retained logical interval.
1814
+ */
1815
+ protected mergeItemsFromLogicalInterval(
1816
+ logical: LogicalInterval,
1817
+ anchored: Interval,
1818
+ ): { mergedAnchored: Interval; remainingLogical: LogicalInterval | null } {
1819
+ const mergeIds: string[] = [];
1820
+ const keepIds: string[] = [];
1821
+
1822
+ for (const id of logical.itemIds) {
1823
+ const item = this.getItem(id);
1824
+ if (!item) {
1825
+ keepIds.push(id);
1826
+ continue;
1827
+ }
1828
+
1829
+ if (this.belongsToInterval(item, anchored)) mergeIds.push(id);
1830
+ else keepIds.push(id);
1831
+ }
1832
+
1833
+ let merged = anchored;
1834
+ for (const id of mergeIds) {
1835
+ const item = this.getItem(id);
1836
+ if (!item) continue;
1837
+ merged = this.insertItemIdIntoInterval(merged, item);
1838
+ }
1839
+
1840
+ return {
1841
+ mergedAnchored: merged,
1842
+ remainingLogical: keepIds.length > 0 ? { ...logical, itemIds: keepIds } : null,
1843
+ };
1844
+ }
1845
+
1846
+ /**
1847
+ * Merges all intervals (anchored + logical head/tail).
1848
+ * Returns:
1849
+ * - merged anchored interval (or null if none merged)
1850
+ * - possibly reduced logical head / tail intervals
1851
+ */
1852
+ protected mergeIntervals(
1853
+ intervals: AnyInterval[],
1854
+ baseInterval?: Interval,
1855
+ ): MergeIntervalsResult {
1856
+ let logicalHead: LogicalInterval | null = null;
1857
+ let logicalTail: LogicalInterval | null = null;
1858
+
1859
+ if (intervals.length <= 1 && !baseInterval)
1860
+ return { logicalHead, merged: null, logicalTail };
1861
+
1862
+ const anchored: Interval[] = [];
1863
+
1864
+ // Separate logical vs anchored
1865
+ for (const itv of intervals) {
1866
+ if (isLiveHeadInterval(itv)) logicalHead = itv;
1867
+ else if (isLiveTailInterval(itv)) logicalTail = itv;
1868
+ else anchored.push(itv);
1869
+ }
1870
+
1871
+ // nothing to merge
1872
+ if (anchored.length === 0 && logicalHead && logicalTail) {
1873
+ return { logicalHead, merged: null, logicalTail };
1874
+ }
1875
+
1876
+ // Merge anchored intervals into one interval (if possible)
1877
+ const mergedAnchored = this.mergeAnchoredIntervals(anchored, baseInterval);
1878
+
1879
+ // No anchored intervals → just return logical ones
1880
+ if (!mergedAnchored) {
1881
+ return { logicalHead, merged: null, logicalTail };
1882
+ }
1883
+
1884
+ let merged = mergedAnchored;
1885
+
1886
+ // Merge items from logical HEAD interval
1887
+ if (logicalHead) {
1888
+ const { mergedAnchored, remainingLogical } = this.mergeItemsFromLogicalInterval(
1889
+ logicalHead,
1890
+ merged,
1891
+ );
1892
+ merged = mergedAnchored;
1893
+ logicalHead = remainingLogical;
1894
+ }
1895
+
1896
+ // Merge items from logical TAIL interval
1897
+ if (logicalTail) {
1898
+ const { mergedAnchored, remainingLogical } = this.mergeItemsFromLogicalInterval(
1899
+ logicalTail,
1900
+ merged,
1901
+ );
1902
+ merged = mergedAnchored;
1903
+ logicalTail = remainingLogical;
1904
+ }
1905
+
1906
+ return { logicalHead, merged, logicalTail };
1907
+ }
1908
+
1909
+ // ---------------------------------------------------------------------------
1910
+ // Consume and manage items
1911
+ // ---------------------------------------------------------------------------
1912
+
1913
+ /**
1914
+ * Ingests the whole page into intervals and returns the resulting anchored interval.
1915
+ */
1916
+ ingestPage({
1917
+ page,
1918
+ policy = 'auto',
1919
+ isHead,
1920
+ isTail,
1921
+ targetIntervalId,
1922
+ setActive,
1923
+ }: {
1924
+ page: T[];
1925
+ /**
1926
+ * Describes the policy for merging intervals.
1927
+ * - 'auto' (default): Merge intervals if they overlap.
1928
+ * - 'strict-overlap-only': Merge intervals only if they strictly overlap. Useful for jumping to a specific message.
1929
+ * - This is useful for jumping to a specific message.
1930
+ */
1931
+ policy?: IntervalMergePolicy;
1932
+ isHead?: boolean;
1933
+ isTail?: boolean;
1934
+ targetIntervalId?: string;
1935
+ setActive?: boolean;
1936
+ }): Interval | null {
1937
+ if (!page?.length) return null;
1938
+
1939
+ const pageInterval = this.makeInterval({
1940
+ page,
1941
+ isHead,
1942
+ isTail,
1943
+ });
1944
+
1945
+ // Coalesce per-item change notifications into a single flush, so a page of N
1946
+ // items wakes each subscribing paginator once rather than N times.
1947
+ this._itemIndex.batch(() => {
1948
+ for (const item of page) {
1949
+ this._itemIndex.setOne(item);
1950
+ }
1951
+ });
1952
+
1953
+ const targetInterval = targetIntervalId
1954
+ ? this._itemIntervals.get(targetIntervalId)
1955
+ : undefined;
1956
+
1957
+ // Set the base interval in the following order of importance
1958
+ // 1. if target interval
1959
+ // a) is not logical interval and
1960
+ // b) merge would not lead to corrupted interval sorting
1961
+ // (pages: [a], [b,c], merging page [x] to [a] -> [a,x], [b,c] or pages: [b,c], [x] and merging [a] to [x] => [b,c], [a,x] )
1962
+ // 2. if one of the overlappingLogical is an active interval, use it as a base
1963
+ // 3. if existing single anchored interval use it as a base
1964
+ let baseInterval: Interval | undefined;
1965
+
1966
+ // Find intervals that overlap with this page
1967
+ const overlappingAnchored: Interval[] = [];
1968
+ const overlappingLogical: LogicalInterval[] = [];
1969
+ for (const itv of this.itemIntervals) {
1970
+ // target interval will be used as base
1971
+ if (targetInterval?.id === itv.id) continue;
1972
+ if (this.intervalsOverlap(pageInterval, itv, policy)) {
1973
+ if (this.isActiveInterval(itv) && !isLogicalInterval(itv)) {
1974
+ baseInterval = itv;
1975
+ } else {
1976
+ if (!isLogicalInterval(itv)) overlappingAnchored.push(itv);
1977
+ else overlappingLogical.push(itv);
1978
+ }
1979
+ } else if (
1980
+ (isHead && isLiveHeadInterval(itv)) ||
1981
+ (isTail && isLiveTailInterval(itv))
1982
+ ) {
1983
+ overlappingLogical.push(itv);
1984
+ }
1985
+ }
1986
+
1987
+ // If caller specifies an anchored target interval, treat it as the merge anchor.
1988
+ // The role of ingestPage method is to merge intervals that overlap + the target
1989
+ // interval. Decision, whether target interval is a correct base interval is
1990
+ // upon the ingestPage method caller, not ingestPage method, because the method
1991
+ // does not know, in which context it has been invoked and cannot reliably tell,
1992
+ // whether it is a valid move to merge into the target interval as when
1993
+ // paginating linearly, the ingested page will never overlap with the previous page.
1994
+ if (targetInterval && !isLogicalInterval(targetInterval)) {
1995
+ baseInterval = targetInterval;
1996
+ } else if (!baseInterval && overlappingAnchored.length === 1) {
1997
+ baseInterval = overlappingAnchored[0];
1998
+ overlappingAnchored.length = 0;
1999
+ }
2000
+
2001
+ const toMerge: AnyInterval[] = [
2002
+ ...overlappingLogical,
2003
+ ...overlappingAnchored,
2004
+ pageInterval,
2005
+ ];
2006
+
2007
+ const { logicalHead, merged, logicalTail } = this.mergeIntervals(
2008
+ toMerge,
2009
+ baseInterval,
2010
+ );
2011
+
2012
+ let resultingInterval = pageInterval;
2013
+ // Remove all intervals that participated
2014
+ if (merged) {
2015
+ resultingInterval = merged;
2016
+ for (const itv of toMerge) {
2017
+ if (merged.id === itv.id) continue;
2018
+ this.dropInterval(itv.id);
2019
+ }
2020
+ }
2021
+
2022
+ // Store logical head/tail (if any)
2023
+ if (logicalHead) {
2024
+ // the leftovers that do not pertain to the first page should be migrated to a separate anchored interval
2025
+ if (merged?.isHead) {
2026
+ const convertedInterval = {
2027
+ id: this.generateIntervalId(logicalHead.itemIds),
2028
+ hasMoreHead: true,
2029
+ hasMoreTail: true,
2030
+ itemIds: logicalHead.itemIds,
2031
+ isHead: false,
2032
+ isTail: false,
2033
+ };
2034
+ this.commitInterval(convertedInterval);
2035
+ } else {
2036
+ this.commitInterval(logicalHead);
2037
+ }
2038
+ }
2039
+
2040
+ if (logicalTail) {
2041
+ // the leftovers that do not pertain to the last page should be migrated to a separate anchored interval
2042
+ if (merged?.isTail) {
2043
+ const convertedInterval = {
2044
+ id: this.generateIntervalId(logicalTail.itemIds),
2045
+ hasMoreHead: true,
2046
+ hasMoreTail: true,
2047
+ itemIds: logicalTail.itemIds,
2048
+ isHead: false,
2049
+ isTail: false,
2050
+ };
2051
+ this.commitInterval(convertedInterval);
2052
+ } else {
2053
+ this.commitInterval(logicalTail);
2054
+ }
2055
+ }
2056
+
2057
+ resultingInterval = this.reconcileLogicalIntervalsAgainst(resultingInterval);
2058
+
2059
+ this.commitInterval(resultingInterval);
2060
+ // keep the intervals sorted
2061
+ this.setIntervals(this.sortIntervals(this.itemIntervals));
2062
+
2063
+ if (
2064
+ resultingInterval &&
2065
+ setActive // || this.isActiveInterval(resultingInterval)
2066
+ ) {
2067
+ this.setActiveInterval(resultingInterval, { updateState: false });
2068
+ this.state.partialNext({
2069
+ items: this.intervalToItems(resultingInterval),
2070
+ hasMoreHead: resultingInterval.hasMoreHead,
2071
+ hasMoreTail: resultingInterval.hasMoreTail,
2072
+ });
2073
+ }
2074
+
2075
+ return resultingInterval;
2076
+ }
2077
+
2078
+ /**
2079
+ * Ingests a single item on live update:
2080
+ * - update the ItemIndex
2081
+ * - find an anchored interval whose sort bounds contain the item
2082
+ * - insert the item into that interval using locate+plateau logic
2083
+ * - if this is the active interval, re-emit state.items from interval
2084
+ */
2085
+ ingestItem(ingestedItem: T): boolean {
2086
+ const id = this.getItemId(ingestedItem);
2087
+ const previousItem = this._itemIndex.get(id);
2088
+
2089
+ // 0. PRE-ANALYSIS: capture previous coordinates BEFORE any mutations
2090
+ const previousCoords = this.locateByItem(previousItem || ingestedItem);
2091
+
2092
+ const originalIndexInState = previousCoords?.state?.currentIndex ?? -1;
2093
+ const keepOrderInState = this.config.lockItemOrder && originalIndexInState >= 0;
2094
+
2095
+ // 1. Remove the old snapshot from state & intervals.
2096
+ const activeIntervalIdBeforeRemoval = this._activeIntervalId;
2097
+ let removedItemCoordinates: ItemCoordinates | undefined;
2098
+ if (previousCoords) {
2099
+ removedItemCoordinates = this.removeItemAtCoordinates(previousCoords);
2100
+ }
2101
+ const itemHasBeenRemoved =
2102
+ !!removedItemCoordinates?.state && removedItemCoordinates.state.currentIndex > -1;
2103
+
2104
+ // 2. Update canonical storage (ItemIndex) to the *new* snapshot,
2105
+ // regardless of filters – this keeps the index authoritative.
2106
+ this._itemIndex.setOne(ingestedItem);
2107
+
2108
+ // 3. If it no longer matches the filter, we’re done (it has been removed above).
2109
+ if (!this.matchesFilter(ingestedItem)) {
2110
+ // Throttled: the removal above deferred its emit — publish the (settled) window once.
2111
+ if (this.isStateThrottled && itemHasBeenRemoved) this.scheduleWindowPublish();
2112
+ return itemHasBeenRemoved;
2113
+ }
2114
+
2115
+ const previousInterval = previousCoords?.interval?.interval;
2116
+
2117
+ const onlyLogicalIntervals =
2118
+ this.itemIntervals.length <= 2 &&
2119
+ this.itemIntervals.every((itv) => isLogicalInterval(itv));
2120
+ // IMPORTANT: decide if the new snapshot still belongs to the same anchored interval,
2121
+ // using the OLD bounds.
2122
+ const stillBelongsToPreviousAnchoredInterval =
2123
+ previousInterval &&
2124
+ // 1) If we *only* have logical intervals and the item used to live in one of them,
2125
+ // keep it there. This prevents items from disappearing on update.
2126
+ ((onlyLogicalIntervals && isLogicalInterval(previousInterval)) ||
2127
+ // 2) Normal: for anchored intervals, only reuse if the new snapshot is still
2128
+ // within that interval's sort bounds.
2129
+ (!isLogicalInterval(previousInterval) &&
2130
+ this.belongsToInterval(ingestedItem, previousInterval)));
2131
+
2132
+ let targetInterval = stillBelongsToPreviousAnchoredInterval
2133
+ ? previousInterval
2134
+ : this.locateIntervalForItem(ingestedItem);
2135
+
2136
+ const { liveHeadLogical, liveTailLogical } = this;
2137
+
2138
+ if (!targetInterval) {
2139
+ // No anchored interval currently contains the new snapshot.
2140
+ // Decide whether it belongs to logical head, logical tail,
2141
+ // or to a brand-new anchored interval.
2142
+ if (this._itemIntervals.size === 0) {
2143
+ // No pages at all yet → keep in logical head.
2144
+ targetInterval = {
2145
+ id: LOGICAL_HEAD_INTERVAL_ID,
2146
+ itemIds: [this.getItemId(ingestedItem)],
2147
+ };
2148
+ if (!this._activeIntervalId) {
2149
+ this.setActiveInterval(targetInterval);
2150
+ }
2151
+ } else {
2152
+ const intervals = this.itemIntervals;
2153
+ const headInterval = this.getHeadIntervalFromSortedIntervals(intervals);
2154
+ const tailInterval = this.getTailIntervalFromSortedIntervals(intervals);
2155
+ const headEdges = headInterval && this.getIntervalPaginationEdges(headInterval);
2156
+ const tailEdges = tailInterval && this.getIntervalPaginationEdges(tailInterval);
2157
+
2158
+ if (headEdges && this.aIsMoreHeadwardThanB(ingestedItem, headEdges.head)) {
2159
+ // Falls before the loaded head → logical head.
2160
+ targetInterval = liveHeadLogical
2161
+ ? this.insertItemIdIntoInterval(liveHeadLogical, ingestedItem)
2162
+ : {
2163
+ id: LOGICAL_HEAD_INTERVAL_ID,
2164
+ itemIds: [this.getItemId(ingestedItem)],
2165
+ };
2166
+ } else if (tailEdges && this.aIsMoreTailwardThanB(ingestedItem, tailEdges.tail)) {
2167
+ // Falls after the loaded tail: normally the item moved into a page this paginator has
2168
+ // not loaded, so it waits in the pending tail region. Not so when the window it left is
2169
+ // anchored at the head — that window is "the first N items" and the item is one of them,
2170
+ // it only slid to the bottom. Exiling it is what made unpinning the bottom-most loaded
2171
+ // channel look like a deletion; its rank is then approximate until the next page settles
2172
+ // it. A floating middle window has unloaded pages on both sides, so there it really is
2173
+ // on another page.
2174
+ const slidOutOfHeadAnchoredWindow =
2175
+ !!previousInterval &&
2176
+ !isLogicalInterval(previousInterval) &&
2177
+ previousInterval.isHead &&
2178
+ previousInterval.id === tailInterval?.id &&
2179
+ (previousCoords?.interval?.currentIndex ?? -1) > -1;
2180
+
2181
+ targetInterval = slidOutOfHeadAnchoredWindow
2182
+ ? this.insertItemIdIntoInterval(previousInterval, ingestedItem)
2183
+ : liveTailLogical
2184
+ ? this.insertItemIdIntoInterval(liveTailLogical, ingestedItem)
2185
+ : {
2186
+ id: LOGICAL_TAIL_INTERVAL_ID,
2187
+ itemIds: [this.getItemId(ingestedItem)],
2188
+ };
2189
+ } else {
2190
+ // Falls somewhere *inside* the global bounds, but we don't have that page loaded.
2191
+ // We’ve already removed any old occurrence, so from the paginator's perspective
2192
+ // this item won't be visible again until the relevant page is fetched.
2193
+ if (this.isStateThrottled && itemHasBeenRemoved) this.scheduleWindowPublish();
2194
+ return itemHasBeenRemoved;
2195
+ }
2196
+ }
2197
+ } else {
2198
+ // Found an anchored interval whose bounds contain the new snapshot.
2199
+ targetInterval = this.insertItemIdIntoInterval(targetInterval, ingestedItem);
2200
+ }
2201
+
2202
+ // If removing the previous snapshot emptied and dropped what was the active interval
2203
+ // (e.g. the sole reply in a freshly-opened thread), and we are re-adding the item into
2204
+ // that same interval, restore it as the active interval. Otherwise the re-added item is
2205
+ // never emitted to state.items below — the emit is gated on _activeIntervalId — so it
2206
+ // silently disappears from the visible list until the interval is reloaded.
2207
+ const removedIntervalId = removedItemCoordinates?.interval?.interval.id;
2208
+ if (
2209
+ !this._activeIntervalId &&
2210
+ !!activeIntervalIdBeforeRemoval &&
2211
+ activeIntervalIdBeforeRemoval === removedIntervalId &&
2212
+ targetInterval.id === removedIntervalId
2213
+ ) {
2214
+ this.setActiveInterval(targetInterval);
2215
+ }
2216
+
2217
+ const addedNewInterval = !this._itemIntervals.has(targetInterval.id);
2218
+ this.commitInterval(targetInterval);
2219
+
2220
+ if (addedNewInterval) {
2221
+ this.setIntervals(this.sortIntervals(this.itemIntervals));
2222
+ }
2223
+
2224
+ // emit new state if active interval impacted by ingestion
2225
+ if (
2226
+ this._activeIntervalId &&
2227
+ [targetInterval.id, removedItemCoordinates?.interval?.interval.id].includes(
2228
+ this._activeIntervalId,
2229
+ )
2230
+ ) {
2231
+ if (this.isStateThrottled) {
2232
+ this.scheduleWindowPublish();
2233
+ } else {
2234
+ const items = this.items ?? [];
2235
+ /**
2236
+ * Having config.lockItemOrder enabled when working with intervals will lead to
2237
+ * discrepancies once active intervals are switched:
2238
+ * 1. state.items [a,b,c] intervals [a,b,c], [d]
2239
+ * 2. a changed and is moved to another interval state.items is now [a,b,c], intervals [b,c,], [d, a]
2240
+ * 3. jumping / changing active interval to [d,a] - state.items is now [d,a], intervals [b,c], [d,a]
2241
+ */
2242
+ if (keepOrderInState) {
2243
+ // Item was visible before → reinsert at its old index
2244
+ const nextView = items.slice();
2245
+ const insertAt = Math.min(originalIndexInState, nextView.length);
2246
+ nextView.splice(insertAt, 0, ingestedItem);
2247
+ this.state.partialNext({ items: nextView });
2248
+ } else {
2249
+ /**
2250
+ * Select a correct interval from which the state.items array is derived
2251
+ */
2252
+ this.state.partialNext({
2253
+ items: this.intervalToItems(
2254
+ this._activeIntervalId === removedItemCoordinates?.interval?.interval.id &&
2255
+ this._activeIntervalId !== targetInterval.id
2256
+ ? removedItemCoordinates.interval.interval
2257
+ : targetInterval,
2258
+ ),
2259
+ });
2260
+ }
2261
+ }
2262
+ }
2263
+
2264
+ return true;
2265
+ }
2266
+
2267
+ // ---------------------------------------------------------------------------
2268
+ // Remove / contains
2269
+ // ---------------------------------------------------------------------------
2270
+
2271
+ protected removeItemAtCoordinates(coords: ItemCoordinates): ItemCoordinates {
2272
+ const { state: stateLocation, interval: intervalLocation } = coords;
2273
+
2274
+ const result: ItemCoordinates = {
2275
+ state: { currentIndex: -1, insertionIndex: -1 },
2276
+ };
2277
+
2278
+ // 1) Remove from interval, if present
2279
+ if (intervalLocation && intervalLocation.currentIndex > -1) {
2280
+ const updatedInterval = this.removeItemIdFromInterval(intervalLocation);
2281
+ const { interval } = updatedInterval;
2282
+ if (interval.itemIds.length === 0) {
2283
+ // Drop empty interval
2284
+ this.dropInterval(interval.id);
2285
+
2286
+ // If it was active -> clear active
2287
+ if (this.isActiveInterval(interval)) {
2288
+ this.setActiveInterval(undefined);
2289
+ }
2290
+ } else {
2291
+ this.commitInterval(updatedInterval.interval);
2292
+ }
2293
+ result.interval = updatedInterval;
2294
+ }
2295
+
2296
+ // 2) Remove from visible state.items, if present
2297
+ if (stateLocation && stateLocation.currentIndex > -1) {
2298
+ if (!this.isStateThrottled) {
2299
+ const newItems = [...(this.items ?? [])];
2300
+ newItems.splice(stateLocation.currentIndex, 1);
2301
+ this.state.partialNext({ items: newItems });
2302
+ }
2303
+
2304
+ // keep insertionIndex consistent if someone uses it later
2305
+ if (stateLocation.insertionIndex > stateLocation.currentIndex) {
2306
+ stateLocation.insertionIndex--;
2307
+ }
2308
+
2309
+ result.state = stateLocation;
2310
+ }
2311
+
2312
+ return result;
2313
+ }
2314
+
2315
+ /**
2316
+ * Meaning of location values
2317
+ * - currentIndex === -1 could not be found
2318
+ * - insertionIndex === -1 insertion index was no intended to be determined
2319
+ *
2320
+ * If we are removing the last item from the currently active interval, we do not search for a new active interval.
2321
+ * If the number of items approach 0 in an active interval, we expect from the UI to load new pages to populate
2322
+ * the active interval.
2323
+ */
2324
+ removeItem({ id, item: inputItem }: { id?: string; item?: T }): ItemCoordinates {
2325
+ const noAction = { state: { currentIndex: -1, insertionIndex: -1 } };
2326
+ if (!id && !inputItem) return noAction;
2327
+
2328
+ const item = inputItem ?? this.getItem(id);
2329
+
2330
+ if (item) {
2331
+ const coords = this.locateByItem(item);
2332
+ if (!coords.state && !coords.interval) return noAction;
2333
+ const result = this.removeItemAtCoordinates(coords);
2334
+ this._itemIndex.remove(this.getItemId(item));
2335
+ // Throttled: removeItemAtCoordinates deferred its emit — publish the (settled) window once.
2336
+ if (this.isStateThrottled) this.scheduleWindowPublish();
2337
+ return result;
2338
+ }
2339
+
2340
+ return noAction;
2341
+ }
2342
+
2343
+ /** Sets the items in the state, ingesting them so the active interval is updated. */
2344
+ setItems({
2345
+ valueOrFactory,
2346
+ cursor,
2347
+ isFirstPage,
2348
+ isLastPage,
2349
+ }: SetPaginatorItemsParams<T>) {
2350
+ this.state.next((current) => {
2351
+ const { items: currentItems = [] } = current;
2352
+ const newItems = isPatch(valueOrFactory)
2353
+ ? valueOrFactory(currentItems)
2354
+ : valueOrFactory;
2355
+
2356
+ // If the references between the two values are the same, just return the
2357
+ // current state; otherwise trigger a state change.
2358
+ if (currentItems === newItems) {
2359
+ return current;
2360
+ }
2361
+ const newState = { ...current, items: newItems };
2362
+
2363
+ if (cursor) {
2364
+ newState.cursor = cursor;
2365
+ } else {
2366
+ newState.offset = newItems.length;
2367
+ }
2368
+
2369
+ const interval = this.ingestPage({
2370
+ page: newItems,
2371
+ isHead: isFirstPage,
2372
+ isTail: isLastPage,
2373
+ });
2374
+ if (interval) {
2375
+ this.setActiveInterval(interval, { updateState: false });
2376
+ newState.hasMoreHead = interval.hasMoreHead;
2377
+ newState.hasMoreTail = interval.hasMoreTail;
2378
+ }
2379
+
2380
+ return newState;
2381
+ });
2382
+
2383
+ // A populated page means a first page is effectively "loaded". Record a query shape so the
2384
+ // paginator counts as initialized and the next pagination continues from this page - otherwise
2385
+ // an undefined `_lastQueryShape` makes the first query look like a shape change, triggering a
2386
+ // first page reset that wipes the seeded items and re-fetches the first page before paginating.
2387
+ if (
2388
+ typeof this._lastQueryShape === 'undefined' &&
2389
+ (this.state.getLatestValue().items?.length ?? 0) > 0
2390
+ ) {
2391
+ this._lastQueryShape = this.getNextQueryShape({});
2392
+ }
2393
+ }
2394
+
2395
+ // ---------------------------------------------------------------------------
2396
+ // Debounce & query execution
2397
+ // ---------------------------------------------------------------------------
2398
+
2399
+ setDebounceOptions = ({ debounceMs }: PaginatorDebounceOptions) => {
2400
+ this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs);
2401
+ };
2402
+
2403
+ protected shouldResetStateBeforeQuery(
2404
+ prevQueryShape: unknown | undefined,
2405
+ nextQueryShape: unknown | undefined,
2406
+ ): boolean {
2407
+ return (
2408
+ typeof prevQueryShape === 'undefined' ||
2409
+ this.config.hasPaginationQueryShapeChanged(prevQueryShape, nextQueryShape)
2410
+ );
2411
+ }
2412
+
2413
+ protected canExecuteQuery = ({
2414
+ direction,
2415
+ reset,
2416
+ }: { direction?: PaginationDirection } & Pick<PaginationQueryParams<Q>, 'reset'>) =>
2417
+ !this.isLoading &&
2418
+ (reset === 'yes' ||
2419
+ // If direction is undefined, we are jumping to a specific message.
2420
+ typeof direction === 'undefined' ||
2421
+ (direction === 'tailward' && this.hasMoreTail) ||
2422
+ (direction === 'headward' && this.hasMoreHead));
2423
+
2424
+ isFirstPageQuery = (
2425
+ params: { queryShape?: unknown } & Pick<PaginationQueryParams<Q>, 'reset'>,
2426
+ ): boolean => {
2427
+ // A paginator with no loaded window starts its pagination from the first page. Note the third
2428
+ // branch below covers the case where items are present without any page having been loaded (a
2429
+ // live event ingested one into a never-queried list): no query shape has been recorded yet, so
2430
+ // `shouldResetStateBeforeQuery` reports a first page for it too.
2431
+ if (typeof this.items === 'undefined') return true;
2432
+ if (params.reset === 'yes') return true;
2433
+ if (params.reset === 'no') return false;
2434
+
2435
+ return this.shouldResetStateBeforeQuery(this._lastQueryShape, params.queryShape);
2436
+ };
2437
+
2438
+ protected getStateBeforeFirstQuery(): PaginatorState<T> {
2439
+ const state: PaginatorState<T> = {
2440
+ ...this.initialState,
2441
+ isLoading: true,
2442
+ };
2443
+ // This is the one moment the loaded window is (re)established from its start offset. For offset
2444
+ // pagination the head (beginning) is loaded exactly when that window starts at offset 0, so
2445
+ // hasMoreHead is a constant known before the query runs — anchor it here, once. It must NOT be
2446
+ // re-derived per page in postQueryReconcile, because the offset only grows tailward from here and
2447
+ // would then read as "more headward" even for a list that started at the head. Cursor pagination
2448
+ // learns hasMoreHead from the query response, so leave the optimistic default for it.
2449
+ if (!this.isCursorPagination) {
2450
+ state.hasMoreHead = (this.config.initialOffset ?? 0) > 0;
2451
+ }
2452
+ return state;
2453
+ }
2454
+
2455
+ isJumpQueryShape(_queryShape: Q): boolean {
2456
+ return false;
2457
+ }
2458
+
2459
+ protected getStateAfterQuery(
2460
+ stateUpdate: Partial<PaginatorState<T>>,
2461
+
2462
+ _isFirstPage: boolean,
2463
+ ): PaginatorState<T> {
2464
+ const current = this.state.getLatestValue();
2465
+ return {
2466
+ ...current,
2467
+ lastQueryError: undefined,
2468
+ ...stateUpdate,
2469
+ isLoading: false,
2470
+ items: stateUpdate.items,
2471
+ };
2472
+ }
2473
+
2474
+ preloadFirstPageFromOfflineDb = (
2475
+ _params: PaginationQueryParams<Q>,
2476
+ ): Promise<T[] | undefined> | T[] | undefined => undefined;
2477
+
2478
+ populateOfflineDbAfterQuery = (_params: {
2479
+ items: T[] | undefined;
2480
+ queryShape: Q | undefined;
2481
+ }): Promise<T[] | undefined> | T[] | undefined => undefined;
2482
+
2483
+ protected async runQueryRetryable(
2484
+ params: PaginationQueryParams<Q> = {},
2485
+ ): Promise<PaginationQueryReturnValue<T> | null> {
2486
+ const remainingRetries = params.retryCount ?? 0;
2487
+ try {
2488
+ return await this.query(params);
2489
+ } catch (e) {
2490
+ const isOfflineSupportEnabledWithItems =
2491
+ this.isOfflineSupportEnabled && (this.items ?? []).length > 0;
2492
+ if (!isOfflineSupportEnabledWithItems) {
2493
+ this.state.partialNext({ lastQueryError: e as Error });
2494
+ }
2495
+
2496
+ if (remainingRetries > 0) {
2497
+ await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES);
2498
+ return await this.runQueryRetryable({
2499
+ ...params,
2500
+ retryCount: remainingRetries - 1,
2501
+ });
2502
+ }
2503
+ if (this.config.throwErrors) {
2504
+ this.state.partialNext({ isLoading: false });
2505
+ throw e;
2506
+ }
2507
+ return null;
2508
+ }
2509
+ }
2510
+
2511
+ /**
2512
+ * Falsy return value means query was not successful.
2513
+ *
2514
+ * @param params - Query parameters.
2515
+ * @param params.direction - Direction to paginate in (headward or tailward).
2516
+ * @param params.keepPreviousItems - Keep already-loaded items instead of clearing them on a first-page query.
2517
+ * @param params.queryShape - Explicit query shape overriding the one derived from current state.
2518
+ * @param params.reset - Whether to reset the loaded state before querying.
2519
+ * @param params.retryCount - Number of remaining retry attempts on failure.
2520
+ * @param params.silent - Suppress loading/state updates for this query.
2521
+ * @param params.updateState - Whether to write the query results back to state.
2522
+ */
2523
+ async executeQuery({
2524
+ direction,
2525
+ keepPreviousItems,
2526
+ queryShape: forcedQueryShape,
2527
+ reset,
2528
+ retryCount = this.config.retryCount,
2529
+ silent,
2530
+ updateState = true,
2531
+ }: PaginationQueryParams<Q> = {}): Promise<ExecuteQueryReturnValue<T> | void> {
2532
+ if (!this.canExecuteQuery({ direction, reset })) return;
2533
+
2534
+ // A forced reset must happen BEFORE the request is built: `getNextQueryShape()` reads the
2535
+ // pagination position (offset / cursor) out of state, so building first would re-query the page
2536
+ // the previous pagination stopped at — a `reload()` at offset 20 would return the third page.
2537
+ // `reset: 'yes'` is a first page by definition, so no query shape is needed to decide.
2538
+ //
2539
+ // Clearing the interval storage here also stops the incoming page from merging into stale
2540
+ // intervals. Only a forced reset clears it: a first page reached through ordinary shape-change
2541
+ // detection (cursor pagination looks like a new shape every page) must keep the cache so
2542
+ // adjacent pages merge; filter/sort changes clear it via `resetState()` in their setters.
2543
+ const isForcedReset = reset === 'yes' && !keepPreviousItems;
2544
+ if (isForcedReset) {
2545
+ this.setIntervals([]);
2546
+ this.setActiveInterval(undefined);
2547
+ this._itemIndex.clear();
2548
+ this.clearIntervalViews();
2549
+ this.state.next(this.getStateBeforeFirstQuery());
2550
+ } else if (reset === 'yes' && !forcedQueryShape) {
2551
+ // A `keepPreviousItems` refresh (reconnect / pull-to-refresh) must still restart pagination from
2552
+ // page 1, but WITHOUT clearing the loaded window — the list stays visible while the fresh first
2553
+ // page loads. Reset only the pagination position; `getNextQueryShape()` below reads it from state.
2554
+ this.state.partialNext({
2555
+ cursor: this.config.initialCursor,
2556
+ offset: this.config.initialOffset ?? 0,
2557
+ });
2558
+ }
2559
+
2560
+ const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction });
2561
+
2562
+ const isFirstPage = this.isFirstPageQuery({ queryShape, reset });
2563
+
2564
+ if (isFirstPage && !keepPreviousItems) {
2565
+ const state = this.getStateBeforeFirstQuery();
2566
+ let items: T[] | undefined = undefined;
2567
+ if (!this.isInitialized) {
2568
+ items =
2569
+ (await this.preloadFirstPageFromOfflineDb({
2570
+ direction,
2571
+ queryShape,
2572
+ reset,
2573
+ retryCount,
2574
+ })) ?? state.items;
2575
+ }
2576
+ // A forced reset already published this state above; re-publishing an identical value would
2577
+ // only emit a second time. Publish again solely when the offline preload produced items.
2578
+ if (!isForcedReset || items !== undefined) {
2579
+ this.state.next({ ...state, items });
2580
+ }
2581
+ } else if (!silent) {
2582
+ // Non-first-page, or a keepPreviousItems refresh: surface loading without blanking the list. The
2583
+ // freshly fetched page is merged into the still-loaded intervals in postQueryReconcile.
2584
+ this.state.partialNext({ isLoading: true });
2585
+ }
2586
+
2587
+ this._nextQueryShape = queryShape;
2588
+ const results = await this.runQueryRetryable({
2589
+ direction,
2590
+ queryShape,
2591
+ reset,
2592
+ retryCount,
2593
+ });
2594
+ return this.postQueryReconcile({
2595
+ direction,
2596
+ isFirstPage,
2597
+ keepPreviousItems,
2598
+ queryShape,
2599
+ requestedPageSize: this.pageSize,
2600
+ results,
2601
+ updateState,
2602
+ });
2603
+ }
2604
+
2605
+ postQueryReconcile({
2606
+ direction,
2607
+ isFirstPage,
2608
+ keepPreviousItems,
2609
+ queryShape,
2610
+ requestedPageSize,
2611
+ results,
2612
+ updateState = true,
2613
+ }: PostQueryReconcileParams<T, Q>): ExecuteQueryReturnValue<T> {
2614
+ this._lastQueryShape = queryShape;
2615
+ this._nextQueryShape = undefined;
2616
+
2617
+ const stateUpdate: Partial<PaginatorState<T>> = {
2618
+ isLoading: false,
2619
+ };
2620
+
2621
+ if (!results) {
2622
+ this.state.partialNext(stateUpdate);
2623
+ return { stateCandidate: stateUpdate, targetInterval: null };
2624
+ }
2625
+
2626
+ // Backward compatibility for custom BasePaginator subclasses:
2627
+ // - old PaginationQueryReturnValue used next/prev
2628
+ // - new contract uses tailward/headward
2629
+ //
2630
+ // Internal SDK paginators already return tailward/headward, so this fallback is
2631
+ // only to keep non-migrated external subclasses working during transition.
2632
+ const { items, headward, tailward, next, prev } = results;
2633
+ const resolvedHeadward = headward ?? prev;
2634
+ const resolvedTailward = tailward ?? next;
2635
+
2636
+ stateUpdate.lastQueryError = undefined;
2637
+ // Filtering is a synchronous local predicate (see filterQueryResults), so the whole
2638
+ // reconciliation runs in a single tick. The channel-open seed relies on this to populate the
2639
+ // paginator synchronously (MessagePaginator.seedFirstPageSync) before read-state hydration.
2640
+ const filteredItems = this.filterQueryResults(items);
2641
+ stateUpdate.items = filteredItems;
2642
+
2643
+ const isJumpQuery = !!queryShape && this.isJumpQueryShape(queryShape);
2644
+ const interval = this.ingestPage({
2645
+ page: stateUpdate.items,
2646
+ policy: isJumpQuery ? 'strict-overlap-only' : 'auto',
2647
+ // the first page should be always marked as head
2648
+ isHead: isJumpQuery
2649
+ ? undefined //head/tail doesn't apply / is unknown for this ingestion
2650
+ : isFirstPage ||
2651
+ (direction === 'headward' ? requestedPageSize > items.length : undefined),
2652
+ // even though the page is first, we have to compare the requested vs returned page size
2653
+ isTail: isJumpQuery
2654
+ ? undefined //head/tail doesn't apply / is unknown for this ingestion
2655
+ : isFirstPage || direction === 'tailward'
2656
+ ? requestedPageSize > items.length
2657
+ : undefined,
2658
+ targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId,
2659
+ });
2660
+ if (interval && updateState) {
2661
+ this.setActiveInterval(interval, { updateState: false });
2662
+ stateUpdate.items = this.intervalToItems(interval);
2663
+ } else if (updateState && !items.length && (keepPreviousItems || !isFirstPage)) {
2664
+ // An empty page must NOT wipe the loaded items on a non-destructive refresh
2665
+ // (keepPreviousItems) or an incremental query. `ingestPage` returns null for an empty page
2666
+ // (leaving the active interval untouched), so `stateUpdate.items` still holds the empty
2667
+ // `filteredItems` here and committing that would blank the list. This happens when a refresh
2668
+ // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead.
2669
+ // A genuine reset (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset
2670
+ // shows empty.
2671
+ stateUpdate.items = this.items;
2672
+ }
2673
+
2674
+ /**
2675
+ * Cursor can be calculated client-side or returned from the server.
2676
+ * Therefore, the BasePaginator.cursorSource can be 'derived' | 'query'
2677
+ * - derived - the BasePaginator applies the default client-side logic based on the pagination options (id_lt, id_gt, id_around...)
2678
+ * - query - BasePaginator.query() resp. BasePaginator.config.doRequest (called inside query()) is expected to provide the cursor and abide by the rules that when the wall is hit in
2679
+ * a given direction, the cursor will be set to null.
2680
+ *
2681
+ * The 'derived' calculation will perform the following steps:
2682
+ * 1. After ingesting into the parent interval determine the cursor candidate values from the first and the last item in the interval.
2683
+ * 2. Decide, whether the candidates can be set based on the requested vs real page size
2684
+ * 3. If the page size from the response is smaller that the requested page size, then in the given direction
2685
+ * the cursor will be set to null.
2686
+ */
2687
+ if (this.isCursorPagination) {
2688
+ if (this.config.deriveCursor && interval) {
2689
+ const { cursor, hasMoreTail, hasMoreHead } = this.config.deriveCursor({
2690
+ direction,
2691
+ interval,
2692
+ queryShape,
2693
+ page: results.items,
2694
+ requestedPageSize,
2695
+ cursor: this.cursor,
2696
+ hasMoreHead: this.hasMoreHead,
2697
+ hasMoreTail: this.hasMoreTail,
2698
+ });
2699
+ stateUpdate.cursor = cursor;
2700
+ stateUpdate.hasMoreTail = hasMoreTail;
2701
+ stateUpdate.hasMoreHead = hasMoreHead;
2702
+ } else {
2703
+ stateUpdate.cursor = {
2704
+ tailward: resolvedTailward || null,
2705
+ headward: resolvedHeadward || null,
2706
+ };
2707
+ stateUpdate.hasMoreTail = !!resolvedTailward;
2708
+ stateUpdate.hasMoreHead = !!resolvedHeadward;
2709
+ }
2710
+ } else {
2711
+ // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset)
2712
+ const startOffset = this.offset ?? 0;
2713
+ stateUpdate.offset = startOffset + items.length;
2714
+ // Only hasMoreTail depends on the page result. hasMoreHead is fixed by where the loaded window
2715
+ // starts (offset 0 => head loaded) and was anchored once at the reset (getStateBeforeFirstQuery);
2716
+ // the offset only grows tailward from here, so leave hasMoreHead untouched.
2717
+ stateUpdate.hasMoreTail = items.length === this.pageSize;
2718
+ }
2719
+
2720
+ if (interval) {
2721
+ const current = this.state.getLatestValue();
2722
+ const resolvedHasMoreHead =
2723
+ typeof stateUpdate.hasMoreHead === 'boolean'
2724
+ ? stateUpdate.hasMoreHead
2725
+ : current.hasMoreHead;
2726
+ const resolvedHasMoreTail =
2727
+ typeof stateUpdate.hasMoreTail === 'boolean'
2728
+ ? stateUpdate.hasMoreTail
2729
+ : current.hasMoreTail;
2730
+
2731
+ const wasHead = interval.isHead;
2732
+ interval.hasMoreHead = resolvedHasMoreHead;
2733
+ interval.hasMoreTail = resolvedHasMoreTail;
2734
+ interval.isHead = resolvedHasMoreHead === false;
2735
+ interval.isTail = resolvedHasMoreTail === false;
2736
+ // `isHead` is decided here (not at ingest); reflect any head-status flip in `anchoredHead`.
2737
+ this.syncAnchoredHeadAfterHeadFlip(interval, wasHead);
2738
+ } else if (!items.length && direction) {
2739
+ // An empty directional response means the dataset edge was reached in `direction`, but
2740
+ // `ingestPage` returns no interval for an empty page so the block above never runs. Flag the
2741
+ // currently active interval as reaching that edge; otherwise its `isHead`/`isTail` stay stale
2742
+ // (e.g. `jumpToTheLatestMessage` would never see the head as loaded, and a "scroll to latest"
2743
+ // affordance would never clear).
2744
+ const activeInterval = this._activeIntervalId
2745
+ ? this._itemIntervals.get(this._activeIntervalId)
2746
+ : undefined;
2747
+ if (activeInterval && !isLogicalInterval(activeInterval)) {
2748
+ if (direction === 'headward') {
2749
+ const wasHead = activeInterval.isHead;
2750
+ activeInterval.isHead = true;
2751
+ activeInterval.hasMoreHead = false;
2752
+ // The active page just reached the dataset head; reflect the flip in `anchoredHead`.
2753
+ this.syncAnchoredHeadAfterHeadFlip(activeInterval, wasHead);
2754
+ } else if (direction === 'tailward') {
2755
+ activeInterval.isTail = true;
2756
+ activeInterval.hasMoreTail = false;
2757
+ }
2758
+ }
2759
+ }
2760
+
2761
+ const state = this.getStateAfterQuery(stateUpdate, isFirstPage);
2762
+ if (updateState) this.state.next(state);
2763
+ this.populateOfflineDbAfterQuery({ items: state.items, queryShape });
2764
+
2765
+ return {
2766
+ stateCandidate: state,
2767
+ targetInterval: interval,
2768
+ };
2769
+ }
2770
+
2771
+ // ---------------------------------------------------------------------------
2772
+ // Public API: navigation
2773
+ // ---------------------------------------------------------------------------
2774
+
2775
+ cancelScheduledQuery() {
2776
+ this._executeQueryDebounced.cancel();
2777
+ }
2778
+
2779
+ resetState() {
2780
+ this.state.next(this.initialState);
2781
+ this.setIntervals([]);
2782
+ this.setActiveInterval(undefined);
2783
+ this.clearIntervalViews();
2784
+ // Nothing is loaded anymore, so the next query is a first page again. Without this the reset
2785
+ // paginator would still report itself as initialized and a re-query with an unchanged shape
2786
+ // (a filter/sort setter that resets, `ChannelManager.resetPaginatorStates()` on disconnect)
2787
+ // would be treated as a continuation — ingested without `isHead`, leaving any live-updated
2788
+ // logical interval unreconciled.
2789
+ this._lastQueryShape = undefined;
2790
+ }
2791
+
2792
+ /**
2793
+ * Releases this paginator's hold on its item content. With a shared, refcounted item index this
2794
+ * unlinks every member id from the backing store, so the store no longer strong-references this
2795
+ * paginator through its subscriber registry — otherwise a discarded owner stays pinned, keeps
2796
+ * receiving change notifications, and its items never garbage-collect. Call on teardown of the
2797
+ * owner: a discard, not a reset (the owner is not reused; a re-appearing id gets a fresh instance;
2798
+ * leftover interval/state caches die with the paginator when it is dropped). Also cancels any
2799
+ * pending throttled window/view publish, so nothing emits after teardown.
2800
+ */
2801
+ dispose(): void {
2802
+ this._windowPublishThrottle?.cancelTimer();
2803
+ this._viewPublishThrottle?.cancelTimer();
2804
+ this._pendingViewChangedIds.clear();
2805
+ this._itemIndex.clear();
2806
+ }
2807
+
2808
+ toTail = (params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {}) =>
2809
+ this.executeQuery({ direction: 'tailward', ...params });
2810
+
2811
+ toHead = (params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {}) =>
2812
+ this.executeQuery({ direction: 'headward', ...params });
2813
+
2814
+ /**
2815
+ * @deprecated Use `toTail` instead.
2816
+ */
2817
+ next = (params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {}) =>
2818
+ this.toTail(params);
2819
+
2820
+ /**
2821
+ * @deprecated Use `toHead` instead.
2822
+ */
2823
+ prev = (params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {}) =>
2824
+ this.toHead(params);
2825
+
2826
+ toTailDebounced = (
2827
+ params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {},
2828
+ ) => {
2829
+ this._executeQueryDebounced({ direction: 'tailward', ...params });
2830
+ };
2831
+
2832
+ toHeadDebounced = (
2833
+ params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {},
2834
+ ) => {
2835
+ this._executeQueryDebounced({ direction: 'headward', ...params });
2836
+ };
2837
+
2838
+ /**
2839
+ * @deprecated Use `toTailDebounced` instead.
2840
+ */
2841
+ nextDebounced = (
2842
+ params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {},
2843
+ ) => {
2844
+ this.toTailDebounced(params);
2845
+ };
2846
+
2847
+ /**
2848
+ * @deprecated Use `toHeadDebounced` instead.
2849
+ */
2850
+ prevDebounced = (
2851
+ params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {},
2852
+ ) => {
2853
+ this.toHeadDebounced(params);
2854
+ };
2855
+
2856
+ reload = async () => {
2857
+ await this.toTail({ reset: 'yes' });
2858
+ };
2859
+ }