stream-chat 9.50.2 → 10.0.0-rc.1

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