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