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.
- package/dist/cjs/index.browser.js +18869 -13986
- package/dist/cjs/index.browser.js.map +4 -4
- package/dist/cjs/index.node.js +18888 -13992
- package/dist/cjs/index.node.js.map +4 -4
- package/dist/esm/index.mjs +18866 -13983
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/ChannelPaginatorsOrchestrator.d.ts +130 -0
- package/dist/types/EventHandlerPipeline.d.ts +104 -0
- package/dist/types/api-client.d.ts +26 -0
- package/dist/types/campaign.d.ts +0 -44
- package/dist/types/channel.d.ts +436 -480
- package/dist/types/channel_batch_updater.d.ts +0 -93
- package/dist/types/channel_manager.d.ts +8 -10
- package/dist/types/channel_state.d.ts +53 -201
- package/dist/types/client.d.ts +440 -1910
- package/dist/types/client_state.d.ts +4 -4
- package/dist/types/configuration/InstanceConfigurationService.d.ts +26 -0
- package/dist/types/configuration/index.d.ts +1 -0
- package/dist/types/configuration/types.d.ts +53 -0
- package/dist/types/connection.d.ts +49 -41
- package/dist/types/connection_fallback.d.ts +6 -4
- package/dist/types/constants.d.ts +0 -4
- package/dist/types/custom_types.d.ts +2 -0
- package/dist/types/errors.d.ts +2 -2
- package/dist/types/gen/chat/ChannelApi.d.ts +45 -0
- package/dist/types/gen/chat/ChatApi.d.ts +334 -0
- package/dist/types/gen/model-decoders/decoders.d.ts +3 -0
- package/dist/types/gen/model-decoders/event-decoder-mapping.d.ts +6 -0
- package/dist/types/gen/models/index.d.ts +8836 -0
- package/dist/types/gen/moderation/ModerationApi.d.ts +50 -0
- package/dist/types/gen-imports.d.ts +3 -0
- package/dist/types/index.d.ts +5 -5
- package/dist/types/insights.d.ts +7 -6
- package/dist/types/logger.d.ts +9 -0
- package/dist/types/messageComposer/LocationComposer.d.ts +7 -4
- package/dist/types/messageComposer/attachmentIdentity.d.ts +2 -2
- package/dist/types/messageComposer/attachmentManager.d.ts +8 -4
- package/dist/types/messageComposer/configuration/types.d.ts +11 -11
- package/dist/types/messageComposer/linkPreviewsManager.d.ts +25 -13
- package/dist/types/messageComposer/messageComposer.d.ts +10 -10
- package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +3 -3
- package/dist/types/messageComposer/middleware/pollComposer/types.d.ts +4 -7
- package/dist/types/messageComposer/middleware/textComposer/commandUtils.d.ts +3 -3
- package/dist/types/messageComposer/middleware/textComposer/commands.d.ts +7 -7
- package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +218 -4
- package/dist/types/messageComposer/middleware/textComposer/types.d.ts +9 -4
- package/dist/types/messageComposer/pollComposer.d.ts +5 -4
- package/dist/types/messageComposer/textComposer.d.ts +8 -3
- package/dist/types/messageComposer/types.d.ts +6 -28
- package/dist/types/messageDelivery/MessageDeliveryReporter.d.ts +40 -25
- package/dist/types/messageDelivery/MessageReceiptsTracker.d.ts +53 -12
- package/dist/types/messageOperations/MessageOperationStatePolicy.d.ts +19 -0
- package/dist/types/messageOperations/MessageOperations.d.ts +17 -0
- package/dist/types/messageOperations/index.d.ts +3 -0
- package/dist/types/messageOperations/types.d.ts +49 -0
- package/dist/types/moderation.d.ts +21 -203
- package/dist/types/notifications/types.d.ts +8 -7
- package/dist/types/offline-support/offline_support_api.d.ts +110 -87
- package/dist/types/offline-support/types.d.ts +19 -23
- package/dist/types/offline-support/util.d.ts +4 -2
- package/dist/types/pagination/FilterBuilder.d.ts +1 -1
- package/dist/types/pagination/ItemIndex.d.ts +82 -0
- package/dist/types/pagination/cursorDerivation/createdAtAroundPaginationFlags.d.ts +9 -0
- package/dist/types/pagination/cursorDerivation/idAroundPaginationFlags.d.ts +6 -0
- package/dist/types/pagination/cursorDerivation/index.d.ts +1 -0
- package/dist/types/pagination/cursorDerivation/linearPaginationFlags.d.ts +6 -0
- package/dist/types/pagination/filterCompiler.d.ts +7 -0
- package/dist/types/pagination/index.d.ts +1 -3
- package/dist/types/pagination/paginators/BasePaginator.d.ts +709 -0
- package/dist/types/pagination/paginators/ChannelPaginator.d.ts +58 -0
- package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +260 -0
- package/dist/types/pagination/paginators/MessagePaginator.d.ts +208 -0
- package/dist/types/pagination/paginators/PinnedMessagePaginator.d.ts +39 -0
- package/dist/types/pagination/paginators/ReminderPaginator.d.ts +19 -0
- package/dist/types/pagination/paginators/UserGroupPaginator.d.ts +24 -0
- package/dist/types/pagination/paginators/index.d.ts +7 -0
- package/dist/types/pagination/sortCompiler.d.ts +49 -0
- package/dist/types/pagination/types.normalization.d.ts +11 -0
- package/dist/types/pagination/utility.normalization.d.ts +18 -0
- package/dist/types/pagination/utility.queryChannel.d.ts +26 -0
- package/dist/types/pagination/utility.search.d.ts +13 -0
- package/dist/types/permissions.d.ts +1 -1
- package/dist/types/poll.d.ts +29 -32
- package/dist/types/poll_manager.d.ts +2 -2
- package/dist/types/reminders/Reminder.d.ts +2 -5
- package/dist/types/reminders/ReminderManager.d.ts +2 -9
- package/dist/types/search/MessageSearchSource.d.ts +4 -4
- package/dist/types/search/UserSearchSource.d.ts +1 -1
- package/dist/types/search/types.d.ts +3 -3
- package/dist/types/segment.d.ts +0 -34
- package/dist/types/signing.d.ts +105 -65
- package/dist/types/store.d.ts +1 -0
- package/dist/types/thread.d.ts +51 -34
- package/dist/types/thread_manager.d.ts +3 -3
- package/dist/types/token_manager.d.ts +15 -10
- package/dist/types/types.d.ts +196 -3384
- package/dist/types/utils/FixedSizeQueueCache.d.ts +12 -6
- package/dist/types/utils/WithSubscriptions.d.ts +2 -1
- package/dist/types/utils/concurrency.d.ts +4 -4
- package/dist/types/utils/mergeWith/mergeWith.d.ts +3 -3
- package/dist/types/utils/mergeWith/mergeWithCore.d.ts +8 -3
- package/dist/types/utils/retryable.d.ts +34 -0
- package/dist/types/utils.d.ts +104 -137
- package/package.json +7 -3
- package/src/ChannelPaginatorsOrchestrator.ts +545 -0
- package/src/CooldownTimer.ts +2 -2
- package/src/EventHandlerPipeline.ts +228 -0
- package/src/LiveLocationManager.ts +12 -11
- package/src/api-client.ts +275 -0
- package/src/campaign.ts +1 -77
- package/src/channel.ts +1262 -1277
- package/src/channel_batch_updater.ts +1 -211
- package/src/channel_manager.ts +92 -88
- package/src/channel_state.ts +144 -1013
- package/src/client.ts +888 -3944
- package/src/client_state.ts +4 -4
- package/src/configuration/InstanceConfigurationService.ts +73 -0
- package/src/configuration/index.ts +1 -0
- package/src/configuration/types.ts +81 -0
- package/src/connection.ts +145 -94
- package/src/connection_fallback.ts +29 -27
- package/src/constants.ts +1 -5
- package/src/custom_types.ts +4 -1
- package/src/errors.ts +2 -2
- package/src/gen/chat/ChannelApi.ts +275 -0
- package/src/gen/chat/ChatApi.ts +2554 -0
- package/src/gen/model-decoders/decoders.ts +2680 -0
- package/src/gen/model-decoders/event-decoder-mapping.ts +198 -0
- package/src/gen/models/index.ts +12673 -0
- package/src/gen/moderation/ModerationApi.ts +607 -0
- package/src/gen-imports.ts +3 -0
- package/src/index.ts +5 -10
- package/src/insights.ts +6 -5
- package/src/logger.ts +28 -0
- package/src/messageComposer/LocationComposer.ts +9 -5
- package/src/messageComposer/MessageComposerEffectHandlers.ts +1 -0
- package/src/messageComposer/attachmentIdentity.ts +16 -9
- package/src/messageComposer/attachmentManager.ts +13 -8
- package/src/messageComposer/configuration/types.ts +11 -11
- package/src/messageComposer/linkPreviewsManager.ts +9 -10
- package/src/messageComposer/messageComposer.ts +57 -53
- package/src/messageComposer/middleware/messageComposer/attachments.ts +1 -2
- package/src/messageComposer/middleware/messageComposer/cleanData.ts +1 -1
- package/src/messageComposer/middleware/messageComposer/compositionValidation.ts +2 -2
- package/src/messageComposer/middleware/messageComposer/messageComposerState.ts +2 -2
- package/src/messageComposer/middleware/messageComposer/sharedLocation.ts +4 -3
- package/src/messageComposer/middleware/messageComposer/textComposer.ts +2 -2
- package/src/messageComposer/middleware/messageComposer/types.ts +3 -4
- package/src/messageComposer/middleware/messageComposer/userDataInjection.ts +9 -5
- package/src/messageComposer/middleware/pollComposer/state.ts +0 -2
- package/src/messageComposer/middleware/pollComposer/types.ts +4 -7
- package/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts +10 -1
- package/src/messageComposer/middleware/textComposer/commandEffects.ts +4 -4
- package/src/messageComposer/middleware/textComposer/commandUtils.ts +3 -6
- package/src/messageComposer/middleware/textComposer/commands.ts +3 -3
- package/src/messageComposer/middleware/textComposer/mentionUtils.ts +1 -1
- package/src/messageComposer/middleware/textComposer/mentions.ts +31 -15
- package/src/messageComposer/middleware/textComposer/types.ts +9 -4
- package/src/messageComposer/pollComposer.ts +6 -8
- package/src/messageComposer/textComposer.ts +27 -2
- package/src/messageComposer/types.ts +6 -28
- package/src/messageDelivery/MessageDeliveryReporter.ts +101 -45
- package/src/messageDelivery/MessageReceiptsTracker.ts +294 -21
- package/src/messageOperations/MessageOperationStatePolicy.ts +77 -0
- package/src/messageOperations/MessageOperations.ts +212 -0
- package/src/messageOperations/index.ts +10 -0
- package/src/messageOperations/types.ts +64 -0
- package/src/moderation.ts +38 -431
- package/src/notifications/types.ts +8 -7
- package/src/offline-support/offline_support_api.ts +198 -132
- package/src/offline-support/offline_sync_manager.ts +17 -7
- package/src/offline-support/types.ts +23 -26
- package/src/offline-support/util.ts +5 -4
- package/src/pagination/FilterBuilder.ts +4 -1
- package/src/pagination/ItemIndex.ts +112 -0
- package/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts +73 -0
- package/src/pagination/cursorDerivation/idAroundPaginationFlags.ts +53 -0
- package/src/pagination/cursorDerivation/index.ts +1 -0
- package/src/pagination/cursorDerivation/linearPaginationFlags.ts +86 -0
- package/src/pagination/filterCompiler.ts +196 -0
- package/src/pagination/index.ts +1 -3
- package/src/pagination/paginators/BasePaginator.ts +2393 -0
- package/src/pagination/paginators/ChannelPaginator.ts +427 -0
- package/src/pagination/paginators/MessageIntervalPaginator.ts +1073 -0
- package/src/pagination/paginators/MessagePaginator.ts +496 -0
- package/src/pagination/paginators/PinnedMessagePaginator.ts +105 -0
- package/src/pagination/paginators/ReminderPaginator.ts +105 -0
- package/src/pagination/paginators/UserGroupPaginator.ts +125 -0
- package/src/pagination/paginators/index.ts +7 -0
- package/src/pagination/sortCompiler.ts +178 -0
- package/src/pagination/types.normalization.ts +14 -0
- package/src/pagination/utility.normalization.ts +106 -0
- package/src/pagination/utility.queryChannel.ts +84 -0
- package/src/pagination/utility.search.ts +80 -0
- package/src/permissions.ts +7 -8
- package/src/poll.ts +101 -110
- package/src/poll_manager.ts +14 -8
- package/src/reminders/Reminder.ts +2 -5
- package/src/reminders/ReminderManager.ts +19 -21
- package/src/search/BaseSearchSource.ts +0 -3
- package/src/search/ChannelMemberSearchSource.ts +7 -1
- package/src/search/ChannelSearchSource.ts +10 -3
- package/src/search/MessageSearchSource.ts +29 -30
- package/src/search/UserSearchSource.ts +12 -8
- package/src/search/types.ts +3 -4
- package/src/segment.ts +1 -95
- package/src/signing.ts +107 -67
- package/src/store.ts +2 -1
- package/src/thread.ts +439 -236
- package/src/thread_manager.ts +36 -15
- package/src/token_manager.ts +21 -10
- package/src/types.ts +375 -4524
- package/src/uploadManager.ts +8 -0
- package/src/utils/FixedSizeQueueCache.ts +12 -6
- package/src/utils/WithSubscriptions.ts +2 -1
- package/src/utils/concurrency.ts +4 -4
- package/src/utils/mergeWith/mergeWith.ts +3 -3
- package/src/utils/mergeWith/mergeWithCore.ts +140 -164
- package/src/utils/mergeWith/mergeWithDiff.ts +3 -3
- package/src/utils/retryable.ts +117 -0
- package/src/utils.ts +133 -524
- package/dist/types/events.d.ts +0 -72
- package/dist/types/pagination/BasePaginator.d.ts +0 -69
- package/dist/types/pagination/ReminderPaginator.d.ts +0 -16
- package/dist/types/pagination/UserGroupPaginator.d.ts +0 -21
- package/src/events.ts +0 -75
- package/src/pagination/BasePaginator.ts +0 -184
- package/src/pagination/ReminderPaginator.ts +0 -56
- 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
|
+
}
|