From e6e0652acee0cc30026aacd8635acb77e0d71e06 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 22 Oct 2026 14:19:22 +0200 Subject: [PATCH 1/3] refactor: call the paginator intervals intervals, not windows Renames the private and protected paginator members and the comments that called a loaded interval a window, e.g. scheduleWindowPublish -> scheduleActiveIntervalPublish, replaceWindowsWithNewestPage -> replaceIntervalsWithNewestPage, headWindow -> headInterval, belowWindow -> idsTailwardOfPage. BasePaginator gets a doc comment defining the interval terms. "window" stays for the browser global and time spans. No public API changes. Co-Authored-By: Claude Opus 5.5 --- src/CooldownTimer.ts | 6 +- src/channel.ts | 34 +- src/channel_state.ts | 2 +- src/client.ts | 4 +- src/entityStore/StoreBackedItemIndex.ts | 2 +- .../MessageDeliveryReporter.ts | 4 +- src/pagination/paginators/BasePaginator.ts | 282 ++++++------ src/pagination/paginators/ChannelPaginator.ts | 2 +- .../paginators/MessageIntervalPaginator.ts | 403 +++++++++--------- src/pagination/paginators/MessagePaginator.ts | 99 ++--- .../paginators/PinnedMessagePaginator.ts | 4 +- src/pagination/paginators/stateThrottling.ts | 2 +- src/thread.ts | 10 +- .../instanceConfiguration.integration.test.ts | 6 +- .../messagePaginator.config.test.ts | 12 +- .../BasePaginator.stateThrottle.test.ts | 2 +- .../paginator.initializeConfig.test.ts | 12 +- .../paginators/MessagePaginator.test.ts | 3 +- 18 files changed, 465 insertions(+), 424 deletions(-) diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 195499b91d..5721a57869 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -93,9 +93,9 @@ export class CooldownTimer extends WithSubscriptions { const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode'); const paginator = this.channel.messagePaginator; - // The newest messages: the window that reaches the newest message, and the live messages received - // while it was not loaded. Without that window an own message may sit in the part not loaded, so - // the timestamp already known is kept rather than cleared. + // The newest messages: the interval that reaches the newest message, and the live messages + // received while it was not loaded. Without that interval an own message may sit in the part + // not loaded, so the timestamp already known is kept rather than cleared. const ownLatestMessageTimestamp = this.findOwnLatestMessageTimestamp({ messages: [...paginator.anchoredHeadItems, ...paginator.logicalHeadItems], diff --git a/src/channel.ts b/src/channel.ts index 73d82f2e9a..1539565e1f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1312,9 +1312,9 @@ export class Channel extends ChannelApi { * offline support the reset is persisted. * * The event's `last_read_message_id` is the newest server-confirmed message, whatever its type, - * of the window holding the channel's newest messages - * (`messagePaginator.anchoredHeadServerConfirmedItem`). While that window is not loaded (e.g. - * only a jumped-to window is), the event carries none and the read covers everything up to its + * of the interval holding the channel's newest messages + * (`messagePaginator.anchoredHeadServerConfirmedItem`). While that interval is not loaded (e.g. + * only a jumped-to interval is), the event carries none and the read covers everything up to its * `created_at`. * * @returns The dispatched `message.read_locally` event, or `undefined` if there is no connected @@ -1438,7 +1438,7 @@ export class Channel extends ChannelApi { } /** - * Re-watches the channel and refreshes its whole loaded message window (the channel analogue of + * Re-watches the channel and refreshes its whole loaded message interval (the channel analogue of * {@link Thread.reload}). Used on reconnect. Hard deletes reach other clients through no event, so * the watch query reconciles the loaded newest messages with the re-queried page * (`messagePaginator.reconcileNewestMessages`, against the loaded IDs captured before its await). @@ -1452,7 +1452,7 @@ export class Channel extends ChannelApi { this._reloading = true; try { const paginator = this.messagePaginator; - // The reload refreshes the window that reaches the newest message, sized to what it holds. + // The reload refreshes the interval that reaches the newest message, sized to what it holds. const requestedLimit = Math.max(paginator.anchoredHeadItems.length, paginator.pageSize ?? 0) || undefined; @@ -1561,10 +1561,10 @@ export class Channel extends ChannelApi { } /** - * The channel's newest messages that are loaded: the window that reaches the newest message (the - * paginator's anchored head), or, while that window is not loaded, the messages received live since. - * A window reached by a jump is never part of it, as the messages between it and the newest are - * unknown. + * The channel's newest messages that are loaded: the interval that reaches the newest message (the + * paginator's anchored head), or, while that interval is not loaded, the messages received live + * since. An interval reached by a jump is never part of it, as the messages between it and the + * newest are unknown. */ private newestKnownMessages() { const paginator = this.messagePaginator; @@ -1753,13 +1753,13 @@ export class Channel extends ChannelApi { /** * Sends the channel query and takes in what the response says about the channel itself: its id, * the cid it is stored under, its config, and its `last_message_at`, against which the message - * paginator's head window is checked before any page is placed (see + * paginator's head interval is checked before any page is placed (see * `MessagePaginator.unmarkStaleHead`). The request of {@link runChannelQuery} and of the message * paginator's own queries, which run it as a tracked query ({@link runTrackedQuery}); it does not * track the query itself. Refused for a disposed instance, before anything is sent. * * `placesNewestPage`: the caller places the newest page with this response, which brings the - * newest messages up to date, so the head window is not checked. + * newest messages up to date, so the head interval is not checked. * * @internal */ @@ -1803,7 +1803,7 @@ export class Channel extends ChannelApi { // a page reaches the newest message (`_initializeState` sets it too, after). this.messagePaginator.setQueriedLatestMessageAt(channel.last_message_at); // Checked before any page is placed, as a page that reaches the newest message marks its own - // window as the head. + // interval as the head. if (!placesNewestPage && this.isHeadLeftWithoutRefresh(queryPayload)) { this.messagePaginator.unmarkStaleHead(); } @@ -1812,10 +1812,10 @@ export class Channel extends ChannelApi { } /** - * Whether nothing but this query brings the head window up to date, so its `last_message_at` may - * show the window stale: the watch was stopped (`NotWatching`), or this query itself watches + * Whether nothing but this query brings the head interval up to date, so its `last_message_at` may + * show the interval stale: the watch was stopped (`NotWatching`), or this query itself watches * again after a dropped connection (`WasWatching`), leaving nothing to re-watch with the newest - * page. While the channel is watched, a message the window lacks is still on its way; after a + * page. While the channel is watched, a message the interval lacks is still on its way; after a * dropped connection, the re-watch that follows places the newest page (see * `ConnectionRecoveryManager`). */ @@ -2327,7 +2327,7 @@ export class Channel extends ChannelApi { this.pinnedMessagesPaginator.removeItem({ id: event.message.id }); } else { // A soft delete changes neither `created_at` nor `pinned`: one store write reaches every - // holder, where an `ingestItem` per collection would re-emit each window. + // holder, where an `ingestItem` per collection would re-emit each active interval. const store = this.getClient().messageStore; if (store.has(event.message.id)) store.upsert(formattedMessage); } @@ -2359,7 +2359,7 @@ export class Channel extends ChannelApi { const isThreadMessage = isThreadOnlyReply(event.message); if (!isThreadMessage) { // `ingestItem` advances the tracked latest message. A message arriving while the viewer - // is in an older window lands in the head interval, leaving the view as it is. + // is in an older interval lands in the head interval, leaving the view as it is. const formattedMessage = formatMessage(event.message); this.messagePaginator.ingestItem(formattedMessage); // Added only when pinned (`matchesFilter`). diff --git a/src/channel_state.ts b/src/channel_state.ts index 53f5b9e877..c9d1c92e1a 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -142,7 +142,7 @@ export type ChannelActivationState = { /** * Whether a consumer declared this channel as the one it is reading, via `channel.activate()`. Its * own state then takes precedence over bulk writes: channel-list hydration places no page in the - * message list of an `active` channel (`channel.reload()` owns that window). + * message list of an `active` channel (`channel.reload()` owns its loaded interval). */ active: boolean; }; diff --git a/src/client.ts b/src/client.ts index aff04d36f8..8ccd6b59a8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1577,8 +1577,8 @@ export class StreamChat extends ChatApi { } // from the read state the response brought, as `channel.watch()` does if (placedFirstPage) c.messagePaginator.seedUnreadSnapshot(); - // Unless the newest page was placed, which brings the window up to date, the head window is - // checked against this response's `last_message_at`, as `Channel.requestChannelState` does + // Unless the newest page was placed, which brings the interval up to date, the head interval + // is checked against this response's `last_message_at`, as `Channel.requestChannelState` does // for a channel query; an on-screen channel is left to its own `channel.reload()`, as above. if ( willInitialize && diff --git a/src/entityStore/StoreBackedItemIndex.ts b/src/entityStore/StoreBackedItemIndex.ts index 36ef6ab5b1..13109e2455 100644 --- a/src/entityStore/StoreBackedItemIndex.ts +++ b/src/entityStore/StoreBackedItemIndex.ts @@ -47,7 +47,7 @@ export class StoreBackedItemIndex implements ItemIndexApi { owner?.onIdChanged?.(oldId, newId); }, onEntityRemoved: (id, entity) => { - // Owner first: dropping the item from its windows still reads it through this index. + // Owner first: dropping the item from its intervals still reads it through this index. owner?.onEntityRemoved?.(id, entity); this.memberIds.delete(id); }, diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index b4e1c11811..f6e56f7086 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -191,8 +191,8 @@ export class MessageDeliveryReporter { const ownUserId = this.client.user?.id; if (!ownUserId) return; - // The newest message the collection knows, whether or not the window holding it is loaded: a - // message received live while an older window is active is still delivered. + // The newest message the collection knows, whether or not the interval holding it is loaded: a + // message received live while an older interval is active is still delivered. let latestMessage: LocalMessage | null = null; // Unix nanoseconds, comparable with a message's `created_at`. let lastDeliveredAt: TimestampNS | undefined; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index deb997173c..e8affa4ee0 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -287,9 +287,9 @@ export type PaginatorState = { * {@link BasePaginator.logicalHeadItems}, {@link BasePaginator.logicalTailItems}. */ export type PaginatorIntervalViews = { - /** Out-of-order items above the loaded window. */ + /** Out-of-order items above the loaded interval. */ logicalHead: T[]; - /** Out-of-order items below the loaded window. */ + /** Out-of-order items below the loaded interval. */ logicalTail: T[]; /** The loaded page bounded at the dataset head (`isHead`); empty when the head is not loaded. */ anchoredHead: T[]; @@ -388,7 +388,7 @@ export type PaginatorOptions = { */ lockItemOrder?: boolean; /** - * Caps the loaded items: when the head window exceeds it, the oldest are released as new ones + * Caps the loaded items: when the head interval exceeds it, the oldest are released as new ones * arrive and the tailward edge re-opens, so scrolling back re-fetches. Unset: unbounded. Values * below `pageSize` are clamped up to it. See {@link BasePaginator.pruneTailToLimit}. */ @@ -445,6 +445,27 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = deepFre } as const, ); +/** + * Loads a sorted dataset page by page and keeps what it loaded as intervals. + * + * Terms used across the paginators, their names and their docs: + * - **interval**: a contiguous run of loaded items, with nothing missing between them + * ({@link Interval}). A page that connects to a loaded interval merges into it + * ({@link IntervalMergePolicy}); one that does not is kept as an interval of its own, so several + * intervals can be loaded at once. + * - **head / tail**: the two ends of the dataset in its sort order, and the matching ends of an + * interval. "Headward" and "tailward" are the directions toward them. + * - **active interval**: the interval whose items `state.items` exposes. The paginator's own + * navigation (loading toward the head or tail, jumping to an item) moves it; a page placed from + * outside moves it only where a subclass's placement rules say so. + * - **anchored head interval**: the loaded interval that reaches the head of the dataset + * (`isHead`); see {@link BasePaginator.anchoredHeadItems}. An interval reached by a jump is not + * anchored until it loads up to the head. + * - **logical head / tail interval**: items that arrived beyond the loaded head or tail and wait + * there until a page covers them ({@link LogicalInterval}). + * + * Say "interval", never "window": "window" is kept for the browser global and for time spans. + */ export abstract class BasePaginator> { state: StateStore>; /** @@ -464,12 +485,12 @@ export abstract class BasePaginator> { 'createItemIndex' | 'declarativeConfig' | 'itemIndex' >; - /** Throttle for the active-window `state.items` publish. Set only with `stateThrottleMs`. */ - private _windowPublishThrottle?: Throttled<[]>; + /** Throttle for the active-interval `state.items` publish. Set only with `stateThrottleMs`. */ + private _activeIntervalPublishThrottle?: Throttled<[]>; /** - * Throttle for interval-view publishes. Independent of {@link _windowPublishThrottle} so a view - * refresh lands on its own trailing edge even when `state.items` is quiet. Created with it. + * Throttle for interval-view publishes. Independent of {@link _activeIntervalPublishThrottle} so + * a view refresh lands on its own trailing edge even when `state.items` is quiet. Created with it. */ private _viewPublishThrottle?: Throttled<[]>; @@ -478,17 +499,17 @@ export abstract class BasePaginator> { /** * Depth of active {@link batch} `coalesce` scopes. While > 0, `ingestItem`/`removeItem` record - * the changed window ({@link _staleWindowState}) instead of publishing. + * the changed interval ({@link _staleActiveIntervalState}) instead of publishing. */ - private _windowPublishSuspendDepth = 0; + private _activeIntervalPublishSuspendDepth = 0; /** * The interval a prune just shortened. Its `hasMoreTail` and `cursor` ride on the next * `state.items` publish, which can be a throttle interval late, so * {@link consumePendingPrunePaginationState} rereads them from the interval by ID when it fires. */ private _prunedIntervalId?: string; - /** The state shown when a suspended op last changed the active window (see {@link batch}). */ - private _staleWindowState?: PaginatorState; + /** The state shown when a suspended op last changed the active interval (see {@link batch}). */ + private _staleActiveIntervalState?: PaginatorState; /** Disconnected ranges of items, e.g. after jumping to non-sequential pages. */ protected _itemIntervals: Map = new Map(); @@ -611,8 +632,8 @@ export abstract class BasePaginator> { } /** - * True once a query has run or a non-empty window was loaded without one (`setItems`, or a - * subclass calling `recordLoadedWindowQueryShape`). Cleared by `resetState`. + * True once a query has run or a non-empty interval was loaded without one (`setItems`, or a + * subclass calling `recordLoadedIntervalQueryShape`). Cleared by `resetState`. */ get isInitialized() { return typeof this._lastQueryShape !== 'undefined'; @@ -718,19 +739,20 @@ export abstract class BasePaginator> { return itv && isLiveTailInterval(itv) ? itv : undefined; } - /** `intervalViews.logicalHead`: out-of-order items above the loaded window. */ + /** `intervalViews.logicalHead`: out-of-order items above the loaded interval. */ get logicalHeadItems(): T[] { return this.intervalViews.getLatestValue().logicalHead; } - /** `intervalViews.logicalTail`: out-of-order items below the loaded window. */ + /** `intervalViews.logicalTail`: out-of-order items below the loaded interval. */ get logicalTailItems(): T[] { return this.intervalViews.getLatestValue().logicalTail; } /** - * The anchored head interval: the loaded window that reaches the head of the dataset (`isHead`). - * `undefined` while that window is not loaded, for example when only a window reached by a jump is. + * The anchored head interval: the loaded interval that reaches the head of the dataset (`isHead`). + * `undefined` while that interval is not loaded, for example when only an interval reached by a + * jump is. */ protected get anchoredHeadInterval(): Interval | undefined { for (const interval of this._itemIntervals.values()) { @@ -740,7 +762,7 @@ export abstract class BasePaginator> { } /** - * The items of the anchored head interval; empty while it is not loaded (a window reached by a + * The items of the anchored head interval; empty while it is not loaded (an interval reached by a * jump never counts). Reactive counterpart: `intervalViews.anchoredHead`. */ get anchoredHeadItems(): T[] { @@ -750,8 +772,8 @@ export abstract class BasePaginator> { /** * The item at the head edge of the anchored head interval (see {@link anchoredHeadItems}). - * `undefined` while that window is not loaded, including once a window is known to lack items - * toward the head (see {@link unmarkHead}). A loaded window always holds an item, so this also + * `undefined` while that interval is not loaded, including once an interval is known to lack items + * toward the head (see {@link unmarkHead}). A loaded interval always holds an item, so this also * tells whether it is loaded. */ get anchoredHeadItem(): T | undefined { @@ -888,7 +910,8 @@ export abstract class BasePaginator> { /** * Drops every loaded interval and item without publishing `state`, so the next ingest starts from - * empty storage while `state.items` keeps showing the previous window until the caller publishes. + * empty storage while `state.items` keeps showing the previous interval until the caller + * publishes. */ protected clearItemStorage() { this.setIntervals([]); @@ -996,28 +1019,28 @@ export abstract class BasePaginator> { protected get isStateThrottled(): boolean { return ( isStateThrottlingEnabled() && - !!this._windowPublishThrottle && + !!this._activeIntervalPublishThrottle && !this.config.lockItemOrder ); } - protected get isWindowPublishSuspended(): boolean { - return this._windowPublishSuspendDepth > 0; + protected get isActiveIntervalPublishSuspended(): boolean { + return this._activeIntervalPublishSuspendDepth > 0; } - /** Re-projects the active window from its live interval; `undefined` when none is active. */ - private projectActiveWindow(): T[] | undefined { + /** Re-projects the items of the active interval; `undefined` when none is active. */ + private projectActiveInterval(): T[] | undefined { if (!this._activeIntervalId) return undefined; const active = this._itemIntervals.get(this._activeIntervalId); return active ? this.intervalToItems(active) : undefined; } /** - * Publishes the active window to `state`, re-projected from the active interval at call time. - * Clears the visible window when the active interval is gone. + * Publishes the active interval to `state`, re-projected at call time. Clears the shown interval + * when the active interval is gone. */ - private flushWindowPublish(): void { - const items = this.projectActiveWindow(); + private flushActiveIntervalPublish(): void { + const items = this.projectActiveInterval(); if (items) { this.state.partialNext({ items, ...this.consumePendingPrunePaginationState() }); return; @@ -1029,33 +1052,33 @@ export abstract class BasePaginator> { /** * Publishes the active interval's items with `state` and any pending prune changes, at once. - * Inside a coalescing {@link batch}, call it after the batch's last window change: the batch then - * publishes nothing more. + * Inside a coalescing {@link batch}, call it after the batch's last interval change: the batch + * then publishes nothing more. */ protected publishActiveInterval(state: Partial> = {}) { this.state.partialNext({ - items: this.projectActiveWindow(), + items: this.projectActiveInterval(), ...this.consumePendingPrunePaginationState(), ...state, }); } /** - * Schedules a throttled (leading and trailing) active-window publish. Live mutations call it + * Schedules a throttled (leading and trailing) active-interval publish. Live mutations call it * instead of publishing inline when {@link isStateThrottled}. Repeated calls coalesce. */ - protected scheduleWindowPublish(): void { - this._windowPublishThrottle?.throttledFn(); + protected scheduleActiveIntervalPublish(): void { + this._activeIntervalPublishThrottle?.throttledFn(); } /** - * Runs `fn` with window publishes suspended and emits once on exit (a replace is - * remove-then-insert, which would emit twice), unless the window was published after its last + * Runs `fn` with active-interval publishes suspended and emits once on exit (a replace is + * remove-then-insert, which would emit twice), unless the interval was published after its last * change. Only the outermost scope emits. `exit: 'auto'` rides the throttle when there is one, * `'sync'` always flushes ({@link batch}). Skipped for `lockItemOrder` lists, whose emit * republishes the last array and must not be re-derived from the interval. */ - protected withSingleWindowPublish( + protected withSingleActiveIntervalPublish( fn: () => void, { exit = 'auto' }: { exit?: 'auto' | 'sync' } = {}, ): void { @@ -1063,25 +1086,25 @@ export abstract class BasePaginator> { fn(); return; } - const isOutermost = this._windowPublishSuspendDepth === 0; - this._windowPublishSuspendDepth += 1; + const isOutermost = this._activeIntervalPublishSuspendDepth === 0; + this._activeIntervalPublishSuspendDepth += 1; try { fn(); } finally { - this._windowPublishSuspendDepth -= 1; + this._activeIntervalPublishSuspendDepth -= 1; } - const staleState = this._staleWindowState; + const staleState = this._staleActiveIntervalState; if (!isOutermost || !staleState) return; - this._staleWindowState = undefined; - // the window was published after its last change + this._staleActiveIntervalState = undefined; + // the interval was published after its last change if (this.items !== staleState.items) return; - if (exit === 'auto' && this.isStateThrottled) this.scheduleWindowPublish(); - else this.flushWindowPublish(); + if (exit === 'auto' && this.isStateThrottled) this.scheduleActiveIntervalPublish(); + else this.flushActiveIntervalPublish(); } - /** Immediately flushes the pending throttled window and interval-view publishes. */ + /** Immediately flushes the pending throttled active-interval and interval-view publishes. */ protected flushPendingPublishes(): void { - this._windowPublishThrottle?.flush(); + this._activeIntervalPublishThrottle?.flush(); this._viewPublishThrottle?.flush(); } @@ -1102,7 +1125,7 @@ export abstract class BasePaginator> { } /** - * The window cap, or `undefined` when unbounded. Clamped up to `pageSize`: a smaller cap would + * The interval cap, or `undefined` when unbounded. Clamped up to `pageSize`: a smaller cap would * prune each tailward page as it lands and `onEndReached` would refetch it in a loop. */ protected get effectiveMaxLoadedItems(): number | undefined { @@ -1120,7 +1143,7 @@ export abstract class BasePaginator> { * suspended ({@link isPruningSuspended}) and the interval is the anchored, active head interval * and over the cap. Assumes cursor pagination (`removeItem` is bypassed, so * {@link shrinkOffsetAfterRemoval} never runs). Items the server does not know - * ({@link isPaginationAnchorable}) are skipped, so the window may sit a few items above the cap. + * ({@link isPaginationAnchorable}) are skipped, so the interval may sit a few items above the cap. */ protected pruneTailToLimit(interval: AnyInterval): boolean { const limit = this.effectiveMaxLoadedItems; @@ -1167,7 +1190,7 @@ export abstract class BasePaginator> { for (const id of dropped) this._itemIndex.remove(id); }); - // Older items exist on the server again: re-open the tail edge, even if the window had reached + // Older items exist on the server again: re-open the tail edge, even if the interval had reached // it (a stale `isTail` would also mislead interval merging). interval.isTail = false; interval.hasMoreTail = true; @@ -1200,9 +1223,9 @@ export abstract class BasePaginator> { /** * Returns the `state` pagination fields a prune moved, derived from the committed active interval - * at call time, and clears the pending prune. Publishes of the active window spread it in. Only + * at call time, and clears the pending prune. Publishes of the active interval spread it in. Only * changed fields are returned, so an unchanged `cursor` keeps its identity. Left alone: another - * window than the pruned one, `cursor.headward` (a `deriveCursor` verdict survives), and a null + * interval than the pruned one, `cursor.headward` (a `deriveCursor` verdict survives), and a null * tail edge (every item is locally created: `null` would read as exhausted, so the cursor stays). */ private consumePendingPrunePaginationState(): Partial> { @@ -1276,12 +1299,12 @@ export abstract class BasePaginator> { } /** - * Reconciles the window and interval views after another holder swapped the shared item objects - * they reference: refreshes views holding a changed ID and re-projects (or slot-swaps) the active - * window, coalesced through the publish throttles when throttling is on. + * Reconciles the active interval and interval views after another holder swapped the shared item + * objects they reference: refreshes views holding a changed ID and re-projects (or slot-swaps) the + * active interval, coalesced through the publish throttles when throttling is on. */ protected reconcileChangedIds(changedIds: ReadonlySet): void { - // Views refresh independently of the active window (they can hold IDs it does not). Throttled: + // Views refresh independently of the active interval (they can hold IDs it does not). Throttled: // buffer the IDs so refreshes coalesce. if (this.isStateThrottled) { for (const id of changedIds) this._pendingViewChangedIds.add(id); @@ -1295,13 +1318,13 @@ export abstract class BasePaginator> { if (!activeInterval) return; // Throttled: `this.items` lags the intervals, so no slot swap. Schedule a re-projection, but - // only if something changed (a sibling may have written the object the window already shows). + // only if something changed (a sibling may have written the object the interval already shows). if (this.isStateThrottled) { const currentItems = this.items; if (!currentItems || currentItems.length !== activeInterval.itemIds.length) { // Positions only line up when the lengths match: gate on membership alone. if (this.intervalHoldsAnyChangedId(activeInterval, changedIds)) { - this.scheduleWindowPublish(); + this.scheduleActiveIntervalPublish(); } return; } @@ -1310,16 +1333,16 @@ export abstract class BasePaginator> { if (!changedIds.has(id)) continue; const updated = this._itemIndex.get(id); if (!updated || updated !== currentItems[i]) { - this.scheduleWindowPublish(); + this.scheduleActiveIntervalPublish(); return; } } return; } - // Fast path: an in-place update keeps membership and order, so when the window lines up with + // Fast path: an in-place update keeps membership and order, so when the interval lines up with // the interval, swap only the changed slots (unchanged item references survive). Skipped when a - // boost is active: it can reorder the window, which the full projection below applies. + // boost is active: it can reorder the interval, which the full projection below applies. const currentItems = this.items; this.clearExpiredBoosts(); if ( @@ -1348,7 +1371,7 @@ export abstract class BasePaginator> { } } - // Fallback (membership or order drifted, no window to patch, or a boost is active): re-project + // Fallback (membership or order drifted, no interval to patch, or a boost is active): re-project // only if a changed ID is in the active interval. if (this.intervalHoldsAnyChangedId(activeInterval, changedIds)) { this.state.partialNext({ items: this.intervalToItems(activeInterval) }); @@ -2017,7 +2040,7 @@ export abstract class BasePaginator> { } /** - * Moves items the loaded window proves are parked on the wrong side: an item in the live head + * Moves items the loaded interval proves are parked on the wrong side: an item in the live head * that sorts past the tail edge of everything loaded belongs to the live tail, and vice versa. * Compared against the outermost anchored interval, not the page just ingested: an item between * two loaded pages is a gap item and stays. @@ -2308,7 +2331,7 @@ export abstract class BasePaginator> { */ ingestItem(ingestedItem: T): boolean { let ingested = false; - this.withSingleWindowPublish(() => { + this.withSingleActiveIntervalPublish(() => { ingested = this.applyItemIngestion(ingestedItem); }); return ingested; @@ -2341,10 +2364,14 @@ export abstract class BasePaginator> { if (!this.matchesFilter(ingestedItem)) { // It left the result set, not just this list. this.shrinkOffsetAfterRemoval(removedItemCoordinates); - // Throttled: the removal deferred its emit, so publish the settled window once. Suspended + // Throttled: the removal deferred its emit, so publish the settled interval once. Suspended // (coalescing batch): `removeItemAtCoordinates` recorded it and `batch()` emits on exit. - if (!this.isWindowPublishSuspended && this.isStateThrottled && itemHasBeenRemoved) - this.scheduleWindowPublish(); + if ( + !this.isActiveIntervalPublishSuspended && + this.isStateThrottled && + itemHasBeenRemoved + ) + this.scheduleActiveIntervalPublish(); return itemHasBeenRemoved; } @@ -2371,7 +2398,7 @@ export abstract class BasePaginator> { if (!targetInterval) { // No anchored interval contains the new snapshot: place it relative to the loaded intervals - // (logical head or tail, the head-anchored window it slid out of, or nowhere if its page is + // (logical head or tail, the head-anchored interval it slid out of, or nowhere if its page is // unloaded). if (this._itemIntervals.size === 0) { // No pages yet: keep it in the logical head. @@ -2399,17 +2426,17 @@ export abstract class BasePaginator> { }; } else if (tailEdges && this.aIsMoreTailwardThanB(ingestedItem, tailEdges.tail)) { // After the loaded tail: normally the item moved to an unloaded page, so it waits in the - // logical tail. Not when it slid out of a head-anchored window (the "first N items"): it + // logical tail. Not when it slid out of a head-anchored interval (the "first N items"): it // is still one of them, and exiling it looked like a deletion. Its rank is approximate // until the next page settles it. - const slidOutOfHeadAnchoredWindow = + const slidOutOfHeadAnchoredInterval = !!previousInterval && !isLogicalInterval(previousInterval) && previousInterval.isHead && previousInterval.id === tailInterval?.id && (previousCoords?.interval?.currentIndex ?? -1) > -1; - targetInterval = slidOutOfHeadAnchoredWindow + targetInterval = slidOutOfHeadAnchoredInterval ? this.insertItemIdIntoInterval(previousInterval, ingestedItem) : liveTailLogical ? this.insertItemIdIntoInterval(liveTailLogical, ingestedItem) @@ -2421,11 +2448,11 @@ export abstract class BasePaginator> { // Inside the global bounds but on an unloaded page: it stays invisible until that page is // fetched. if ( - !this.isWindowPublishSuspended && + !this.isActiveIntervalPublishSuspended && this.isStateThrottled && itemHasBeenRemoved ) - this.scheduleWindowPublish(); + this.scheduleActiveIntervalPublish(); return itemHasBeenRemoved; } } @@ -2444,7 +2471,7 @@ export abstract class BasePaginator> { ) { this.setActiveInterval( targetInterval, - this.isWindowPublishSuspended ? { updateState: false } : undefined, + this.isActiveIntervalPublishSuspended ? { updateState: false } : undefined, ); } @@ -2465,11 +2492,11 @@ export abstract class BasePaginator> { this._activeIntervalId, ) ) { - if (this.isWindowPublishSuspended) { - // Coalescing batch: record the change; batch() emits the settled window once on exit. - this._staleWindowState = this.state.getLatestValue(); + if (this.isActiveIntervalPublishSuspended) { + // Coalescing batch: record the change; batch() emits the settled interval once on exit. + this._staleActiveIntervalState = this.state.getLatestValue(); } else if (this.isStateThrottled) { - this.scheduleWindowPublish(); + this.scheduleActiveIntervalPublish(); } else { const items = this.items ?? []; // With `lockItemOrder`, `state.items` can keep an item the intervals moved elsewhere, so @@ -2507,17 +2534,20 @@ export abstract class BasePaginator> { /** * Runs `fn` as one batched mutation: per-item store notifications fold into one flush, so sibling - * holders of the same IDs re-project once. With `coalesce: true` the active window also emits - * exactly once on exit ({@link flushWindowPublish}), independent of throttling: for one-shot - * operations. Omit it in WS event handlers so events ride the throttle. If `fn` publishes the - * window after its last change (see {@link publishActiveInterval}), `batch` publishes nothing. + * holders of the same IDs re-project once. With `coalesce: true` the active interval also emits + * exactly once on exit ({@link flushActiveIntervalPublish}), independent of throttling: for + * one-shot operations. Omit it in WS event handlers so events ride the throttle. If `fn` + * publishes the interval after its last change (see {@link publishActiveInterval}), `batch` + * publishes nothing. */ batch(fn: () => void, { coalesce = false }: { coalesce?: boolean } = {}): void { if (!coalesce) { this._itemIndex.batch(fn); return; } - this.withSingleWindowPublish(() => this._itemIndex.batch(fn), { exit: 'sync' }); + this.withSingleActiveIntervalPublish(() => this._itemIndex.batch(fn), { + exit: 'sync', + }); } protected removeItemAtCoordinates(coords: ItemCoordinates): ItemCoordinates { @@ -2543,8 +2573,8 @@ export abstract class BasePaginator> { } if (stateLocation && stateLocation.currentIndex > -1) { - if (this.isWindowPublishSuspended) { - this._staleWindowState = this.state.getLatestValue(); + if (this.isActiveIntervalPublishSuspended) { + this._staleActiveIntervalState = this.state.getLatestValue(); } else if (!this.isStateThrottled) { const newItems = [...(this.items ?? [])]; newItems.splice(stateLocation.currentIndex, 1); @@ -2564,12 +2594,12 @@ export abstract class BasePaginator> { /** * Keeps `offset` addressing the same position in the *server's* result set after an item left the - * loaded window. + * loaded interval. */ protected shrinkOffsetAfterRemoval(coordinates?: ItemCoordinates) { // cursor pagination does not address items by position, so nothing can drift if (this.isCursorPagination) return; - // not part of the loaded window, so no page ever counted it + // not part of the loaded interval, so no page ever counted it if ((coordinates?.state?.currentIndex ?? -1) < 0) return; const { offset } = this.state.getLatestValue(); @@ -2595,9 +2625,9 @@ export abstract class BasePaginator> { const result = this.removeItemAtCoordinates(coords); this._itemIndex.remove(this.getItemId(item)); this.shrinkOffsetAfterRemoval(result); - // Throttled: the removal deferred its emit, so publish the settled window once. - if (!this.isWindowPublishSuspended && this.isStateThrottled) - this.scheduleWindowPublish(); + // Throttled: the removal deferred its emit, so publish the settled interval once. + if (!this.isActiveIntervalPublishSuspended && this.isStateThrottled) + this.scheduleActiveIntervalPublish(); return result; } @@ -2642,17 +2672,17 @@ export abstract class BasePaginator> { return newState; }); - this.recordLoadedWindowQueryShape(); + this.recordLoadedIntervalQueryShape(); } /** - * Records the query shape of the loaded window so the next pagination continues it. Data that + * Records the query shape of the loaded interval so the next pagination continues it. Data that * arrives without the paginator's own query (`setItems`, a page from a query it did not run, a - * subclass reconciling a loaded window) would otherwise make the next query a first page that - * publishes `items: undefined` first, and an empty response then loses the window. Needs items: - * an empty window has no shape to continue from. + * subclass reconciling a loaded interval) would otherwise make the next query a first page that + * publishes `items: undefined` first, and an empty response then loses the interval. Needs items: + * an empty interval has no shape to continue from. */ - protected recordLoadedWindowQueryShape() { + protected recordLoadedIntervalQueryShape() { if ( typeof this._lastQueryShape === 'undefined' && (this.state.getLatestValue().items?.length ?? 0) > 0 @@ -2677,14 +2707,15 @@ export abstract class BasePaginator> { private rebuildStatePublishThrottles(stateThrottleMs?: number) { this.flushPendingPublishes(); - this._windowPublishThrottle = undefined; + this._activeIntervalPublishThrottle = undefined; this._viewPublishThrottle = undefined; if (!stateThrottleMs) return; - // The trailing edge re-projects the active window, so a burst emits about once per interval. - this._windowPublishThrottle = throttle( - () => this.flushWindowPublish(), + // The trailing edge re-projects the active interval, so a burst emits about once per throttle + // interval. + this._activeIntervalPublishThrottle = throttle( + () => this.flushActiveIntervalPublish(), stateThrottleMs, { leading: true, @@ -2743,7 +2774,7 @@ export abstract class BasePaginator> { isFirstPageQuery = ( params: { queryShape?: unknown } & Pick, 'reset'>, ): boolean => { - // No loaded window: first page. Items without a loaded page (a live event ingested one into a + // No loaded interval: first page. Items without a loaded page (a live event ingested one into a // never-queried list) have no recorded query shape, so `shouldResetStateBeforeQuery` reports a // first page for them too. if (typeof this.items === 'undefined') return true; @@ -2758,7 +2789,7 @@ export abstract class BasePaginator> { ...this.initialState, isLoading: true, }; - // Offset pagination: the head is loaded exactly when the window starts at offset 0, so + // Offset pagination: the head is loaded exactly when the interval starts at offset 0, so // `hasMoreHead` is anchored here, not re-derived per page (the growing offset would read as // "more headward"). Cursor pagination learns it from the response. if (!this.isCursorPagination) { @@ -2785,12 +2816,12 @@ export abstract class BasePaginator> { * does not say (a `null` {@link PaginationQueryReturnValue.headward} does), with the page sorted in * item order, before it is ingested. * - * - `true`: the page reaches the head; the window it lands in is marked as the head. + * - `true`: the page reaches the head; the interval it lands in is marked as the head. * - `false`: the page does not reach the head. * - `undefined` (the default): not known. * * Only `true` acts. On `false` or `undefined` the query shape and the cursor derivation decide, so a - * window they mark as the head stays marked — for example a newest page that a live item joins. + * interval they mark as the head stays marked — for example a newest page that a live item joins. */ protected pageReachesHead(_page: T[], _queryShape: Q | undefined): boolean | undefined { return undefined; @@ -2997,9 +3028,9 @@ export abstract class BasePaginator> { // Reset before the query shape is built: `getNextQueryShape()` reads the position (offset, // cursor) from state, so a `reload()` at offset 20 would otherwise fetch the third page. - // A forced reset also clears the loaded windows, so the page cannot merge into stale ones. + // A forced reset also clears the loaded intervals, so the page cannot merge into stale ones. // Other first pages keep them: with cursors every page looks like a first page, and filter or - // sort changes clear the windows themselves (`resetState()`). + // sort changes clear the intervals themselves (`resetState()`). const isForcedReset = reset === 'yes' && !keepPreviousItems; if (isForcedReset) { this.clearItemStorage(); @@ -3055,7 +3086,7 @@ export abstract class BasePaginator> { } } else if (!silent) { // A next page, or a refresh keeping the items: loading shows without blanking the list, and - // the page merges into the loaded windows in `placeQueryPage`. + // the page merges into the loaded intervals in `placeQueryPage`. this.state.partialNext({ isLoading: true }); } @@ -3132,12 +3163,12 @@ export abstract class BasePaginator> { } /** - * Places a page in the paginator's windows — in the window it continues, or in a window of its - * own merged with the windows it overlaps — and derives the cursors and the more-pages flags. - * With `updateState` (the default), its window becomes the shown one and the state is published. - * Without it, the page is only stored, and published only if it landed in the active interval, - * so the shown items never go stale. A page proven to reach the head of the dataset makes its - * interval the only head (see {@link makeOnlyHead}). Where cursors are derived from the + * Places a page in the paginator's intervals — in the interval it continues, or in an interval of + * its own merged with the intervals it overlaps — and derives the cursors and the more-pages + * flags. With `updateState` (the default), its interval becomes the shown one and the state is + * published. Without it, the page is only stored, and published only if it landed in the active + * interval, so the shown items never go stale. A page proven to reach the head of the dataset + * makes its interval the only head (see {@link makeOnlyHead}). Where cursors are derived from the * interval (`config.deriveCursor`), an interval a headward page joins to the head interval * reaches the head, whatever the page says (see {@link inheritsHeadMark}). * @@ -3176,8 +3207,8 @@ export abstract class BasePaginator> { const reachesHead = headward === null || this.pageReachesHead(items, queryShape) === true; const reachesTail = tailward === null; - // A non-destructive refresh merges page 1 into the window it kept, so the ids that window held - // have to be read before the ingest below adds the page to it. + // A non-destructive refresh merges page 1 into the interval it kept, so the ids that interval + // held have to be read before the ingest below adds the page to it. const keptItemIds = isFirstPage && keepPreviousItems && !this.isCursorPagination && this.items?.length ? new Set(this.items.map((item) => this.getItemId(item))) @@ -3219,9 +3250,10 @@ export abstract class BasePaginator> { // The cursor comes from `config.deriveCursor` (derived from the ingested interval) or from the // extracted page (`headward`/`tailward`, `null` meaning the edge was hit). - // The flags the derivation starts from. A page in the active window continues that window's - // flags; a page in a window of its own (a jump, or a page whose place relative to the active - // window is unknown) starts from that window's own, as the active window says nothing about it. + // The flags the derivation starts from. A page in the active interval continues that interval's + // flags; a page in an interval of its own (a jump, or a page whose place relative to the active + // interval is unknown) starts from that interval's own, as the active interval says nothing + // about it. const continuesActiveIntervalFlags = !isSeparateInterval || (!!interval && this.isActiveInterval(interval)); const knownFlags = @@ -3287,8 +3319,8 @@ export abstract class BasePaginator> { // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset) const startOffset = this.offset ?? 0; stateUpdate.offset = startOffset + items.length; - // After a `keepPreviousItems` refresh whose page connects to the kept window, continue from - // the window's end instead of re-fetching it. + // After a `keepPreviousItems` refresh whose page connects to the kept interval, continue from + // the interval's end instead of re-fetching it. const lastPageItem = items[items.length - 1]; if ( interval && @@ -3328,10 +3360,10 @@ export abstract class BasePaginator> { this.syncAnchoredHeadAfterHeadFlip(interval, wasHead); if (reachesHead && interval.isHead) this.makeOnlyHead(interval); } else if (!items.length && direction && !isSeparateInterval) { - // An empty directional response continuing the active window means the dataset edge was + // An empty directional response continuing the active interval means the dataset edge was // reached, but `ingestPage` returns no interval for an empty page: flag the active interval, // or its `isHead`/`isTail` stay stale and the head never reads as loaded. An empty page whose - // place relative to the active window is unknown says nothing about it. + // place relative to the active interval is unknown says nothing about it. const activeInterval = this._activeIntervalId ? this._itemIntervals.get(this._activeIntervalId) : undefined; @@ -3409,7 +3441,7 @@ export abstract class BasePaginator> { * teardown, not a reset: the owner is not reused. */ dispose(): void { - this._windowPublishThrottle?.cancelTimer(); + this._activeIntervalPublishThrottle?.cancelTimer(); this._viewPublishThrottle?.cancelTimer(); this._pendingViewChangedIds.clear(); this._itemIndex.clear(); diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 0bc0c08f34..21a44f19d5 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -286,7 +286,7 @@ export class ChannelPaginator extends BasePaginator * keeps the items. */ private _itemsLoadedFromOfflineDB = false; - /** The query in flight refreshes a cache-only window, so its response replaces the window. */ + /** The query in flight refreshes a cache-only interval, so its response replaces the interval. */ private _replaceOfflineSeedOnResponse = false; /** The query handed to the sync manager, from the cache read until it runs. See `executeQuery`. */ private _queryAwaitingSync: ChannelQueryShape | undefined; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 1abcba4211..7895dd7d36 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -82,7 +82,7 @@ export type JumpToMessageOptions = { suppressFocusSignal?: boolean; /** * Also watches the channel (so it receives events) when it is not watched yet. The request that - * loads the window around the message asks to watch too, so it stays one request, and a message + * loads the interval around the message asks to watch too, so it stays one request, and a message * already loaded is loaded again. Ignored for a thread's reply list (its request cannot watch) * and when the paginator's `doRequest` loads the messages. */ @@ -164,11 +164,11 @@ export type ReconcileNewestMessagesOptions = { * The `limit` the caller passed to the query that produced `page`. Compared to `page.length`, it * says whether `page` reached the channel start (see * {@link MessageIntervalPaginator.pageReachedChannelStart}, which ignores a limit above - * `pageSize`), deciding `hasMoreTail`, whether `page` can be separated from the head window by + * `pageSize`), deciding `hasMoreTail`, whether `page` can be separated from the head interval by * a gap, and whether a reload probes for messages deleted below the page (see - * {@link MessageIntervalPaginator.pruneBelowWindowIfReachedStart}). On its own it never removes a - * message. Pass the limit actually sent: a larger one makes a short page look like the channel - * start, welding it to the head window across a real gap. + * {@link MessageIntervalPaginator.pruneTailwardOfPageIfReachedStart}). On its own it never + * removes a message. Pass the limit actually sent: a larger one makes a short page look like the + * channel start, welding it to the head interval across a real gap. */ requestedLimit?: number; /** @@ -246,12 +246,12 @@ export class MessageIntervalPaginator extends BasePaginator< private messageFocusSignalToken = 0; protected _requestSort = DEFAULT_BACKEND_SORT; protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; - /** Pending below-window reached-start probe (exposed so tests can await it). */ - private _belowWindowReconcile?: Promise; + /** Pending below-interval reached-start probe (exposed so tests can await it). */ + private _tailwardOfPageReconcile?: Promise; /** - * The ID of the newest server-confirmed message of the window whose head mark was cleared last + * The ID of the newest server-confirmed message of the interval whose head mark was cleared last * (see {@link unmarkedHeadInterval}). Forgotten once a newest page is reconciled with no head - * window loaded, and when the loaded windows are cleared. + * interval loaded, and when the loaded intervals are cleared. */ private unmarkedHeadEdgeId: string | undefined; sortComparator: (a: LocalMessage, b: LocalMessage) => number; @@ -275,7 +275,7 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Drops a message from this paginator's windows after the entity store removed it. + * Drops a message from this paginator's intervals after the entity store removed it. * * @internal * @param id - ID of the removed message. @@ -299,7 +299,7 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Holds off dropping the oldest loaded messages, so pruning does not pull content from under a user - * reading near them: the window may grow past `maxLoadedItems` until they scroll back. A plain + * reading near them: the interval may grow past `maxLoadedItems` until they scroll back. A plain * field, not a `StateStore`: nothing observes it and a scroll-driven signal must not cost a render. */ setPruningSuspended = (suspended: boolean) => { @@ -607,7 +607,7 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Places a page from an external query — one this paginator did not run, whose page the caller - * chose — in the windows it belongs to, never removing anything loaded. Bringing the loaded + * chose — in the intervals it belongs to, never removing anything loaded. Bringing the loaded * newest messages up to date with a fresh newest page, removals included, is * {@link reconcileNewestMessages}'s job instead. * @@ -616,20 +616,20 @@ export class MessageIntervalPaginator extends BasePaginator< * page comes with. `messagePaginationParams` are the * options the page was queried with; they decide where it goes: * - * - The newest page: when no window is active yet (nothing loaded, or only live messages), its - * window becomes the active one, as the channel's first page. When the active window is the - * newest window, the page lands in it. + * - The newest page: when no interval is active yet (nothing loaded, or only live messages), its + * interval becomes the active one, as the channel's first page. When the active interval is the + * newest interval, the page lands in it. * - Any other page — a page at a cursor or around a message, or the newest page while an older - * window is active — is only stored, as a separate interval merged only with an interval it + * interval is active — is only stored, as a separate interval merged only with an interval it * overlaps. * * The active interval stays active: changing it is this paginator's own methods' job * (`jumpToMessage()` and the like). The exception is an inactive paginator (see - * {@link isActivePaginator}), where the newest page is shown as when no window is + * {@link isActivePaginator}), where the newest page is shown as when no interval is * active. A newest * page for loaded messages goes to {@link reconcileNewestMessages} instead - * (`prepareExternalQuery`, the channel list's query). When a page lands in the active window, - * overlaps it, or takes in an active window of live messages only, that window is shown again + * (`prepareExternalQuery`, the channel list's query). When a page lands in the active interval, + * overlaps it, or takes in an active interval of live messages only, that interval is shown again * with the page. The paginator's own queries are left alone too: a running one keeps its loading * flag, and its last query error and query shape stay. * @@ -659,12 +659,12 @@ export class MessageIntervalPaginator extends BasePaginator< const activeInterval = this.itemIntervals.find((interval) => this.isActiveInterval(interval), ); - // only live messages, with no page loaded yet, count as no active window, as does any window of - // an inactive paginator + // only live messages, with no page loaded yet, count as no active interval, as does any + // interval of an inactive paginator const hasActiveInterval = !!activeInterval && !isLogicalInterval(activeInterval) && this.isActivePaginator(); - // The newest page lands in the active window when that is the newest window; any other page has - // no known place relative to the active window, so it is stored as a separate interval. + // The newest page lands in the active interval when that is the newest interval; any other page + // has no known place relative to the active interval, so it is stored as a separate interval. const landsInActiveInterval = isNewestPage && this.isActiveIntervalAtHead; const showsPage = isNewestPage && (!hasActiveInterval || landsInActiveInterval); const placed = this.placePage( @@ -687,21 +687,21 @@ export class MessageIntervalPaginator extends BasePaginator< }, { endsQuery: false }, ); - // A stored page that took in a live-only active window (only live messages, no page) carries - // on as the active window: its live messages are still shown, with what the page added. + // A stored page that took in a live-only active interval (only live messages, no page) carries + // on as the active interval: its live messages are still shown, with what the page added. const tookInActiveInterval = !!activeInterval && !this.itemIntervals.some((interval) => this.isActiveInterval(interval)); const intervalTakingOver = tookInActiveInterval ? placed.targetInterval : null; if (intervalTakingOver) { this.setActiveInterval(intervalTakingOver, { updateState: false }); - // placing did not publish it: the window it took in becomes the active one only here + // placing did not publish it: the interval it took in becomes the active one only here const { cursor, hasMoreHead, hasMoreTail } = placed.stateCandidate; this.publishActiveInterval({ cursor, hasMoreHead, hasMoreTail }); } if (showsPage) { - // shown without a query of this paginator, so its next query has to continue this window - this.recordLoadedWindowQueryShape(); + // shown without a query of this paginator, so its next query has to continue this interval + this.recordLoadedIntervalQueryShape(); } return showsPage && isFirstPage; } @@ -719,10 +719,10 @@ export class MessageIntervalPaginator extends BasePaginator< /** * The snapshot a newest page is reconciled against (see * {@link ReconcileNewestMessagesOptions.headIntervalItemIdsAtRequest}): the IDs of the head - * window's server-confirmed messages. Take it right before the request goes out. Unsent messages + * interval's server-confirmed messages. Take it right before the request goes out. Unsent messages * are left out: one confirmed during the request proves neither that the page connects to the - * head window nor, when the page lacks it, that it was deleted. `undefined` while no head window - * is loaded. + * head interval nor, when the page lacks it, that it was deleted. `undefined` while no head + * interval is loaded. * * @internal */ @@ -922,7 +922,7 @@ export class MessageIntervalPaginator extends BasePaginator< }; /** - * Shows a loaded window as it is: its items, its own pagination flags and the cursor continuing + * Shows a loaded interval as it is: its items, its own pagination flags and the cursor continuing * it. Claiming the active interval is the caller's choice (see `claimActiveInterval`). */ protected showLoadedInterval(interval: Interval) { @@ -934,7 +934,7 @@ export class MessageIntervalPaginator extends BasePaginator< }); } - /** Makes `interval` the active window and publishes `state` for it in one update. */ + /** Makes `interval` the active interval and publishes `state` for it in one update. */ private showInterval( interval: AnyInterval, state: Partial>, @@ -943,15 +943,15 @@ export class MessageIntervalPaginator extends BasePaginator< this.setActiveInterval(interval, { updateState: false }); } this.state.partialNext(state); - // shown without a query of this paginator, so its next query has to continue this window - this.recordLoadedWindowQueryShape(); + // shown without a query of this paginator, so its next query has to continue this interval + this.recordLoadedIntervalQueryShape(); } /** - * Shows the window holding the latest message and jumps to that message. Without a loaded head - * window (see {@link latestHeadInterval}), the newest page is queried first: no cursor, so it - * holds the latest message however many pages are missing. The page merges with a window it - * overlaps and is a window of its own otherwise, and is shown either way. Resolves `false` when + * Shows the interval holding the latest message and jumps to that message. Without a loaded head + * interval (see {@link latestHeadInterval}), the newest page is queried first: no cursor, so it + * holds the latest message however many pages are missing. The page merges with an interval it + * overlaps and is an interval of its own otherwise, and is shown either way. Resolves `false` when * the latest message cannot be shown, or when a later claim on the active interval (see * `claimActiveInterval`) was made before the page arrived: that page is then only stored. */ @@ -984,8 +984,8 @@ export class MessageIntervalPaginator extends BasePaginator< }; /** - * The head window when it is the newest loaded interval, so its newest message is the latest - * one; `undefined` while no head window is loaded or live messages newer than it are. + * The head interval when it is the newest loaded interval, so its newest message is the latest + * one; `undefined` while no head interval is loaded or live messages newer than it are. */ private get latestHeadInterval(): Interval | undefined { const newest = this.itemIntervals[0]; @@ -997,37 +997,41 @@ export class MessageIntervalPaginator extends BasePaginator< * are brought up to date in place — messages added and updated, those the page proves were * hard-deleted removed — rather than blanked and reloaded. The page comes from an external query * (a reload, a channel-list query, connection recovery). In an active paginator (see - * {@link isActivePaginator}) the active interval stays active, the head window included - * (changing it is `jumpToTheLatestMessage()`'s job); in an inactive one, the head window is shown. + * {@link isActivePaginator}) the active interval stays active, the head interval included + * (changing it is `jumpToTheLatestMessage()`'s job); in an inactive one, the head interval is + * shown. * - * Where the page goes depends on the head window, the loaded window reaching the newest message: + * Where the page goes depends on the head interval, the loaded interval reaching the newest + * message: * - * 1. NO HEAD WINDOW (only live messages, a jumped-to window, or a cleared head mark, see - * {@link unmarkHead}) - the page becomes the head window, merging with the windows it + * 1. NO HEAD INTERVAL (only live messages, a jumped-to interval, or a cleared head mark, see + * {@link unmarkHead}) - the page becomes the head interval, merging with the intervals it * overlaps, live messages included, minus what it proves was hard-deleted. It is shown if the - * live messages it absorbed were active; an active window that it overlaps grows. A - * page separated by a gap from the window whose head mark was cleared is placed as in 2. + * live messages it absorbed were active; an active interval that it overlaps grows. A + * page separated by a gap from the interval whose head mark was cleared is placed as in 2. * * 2. SEPARATED BY A GAP (see {@link isSeparatedFromHeadByGap}) - merging would weld the two * across the gap, hiding the messages in between, so the page takes the place of the head - * window (see {@link placeNewestPageAcrossGap}): stored beside the active interval when that - * is an older window, shown in place of the loaded windows otherwise. + * interval (see {@link placeNewestPageAcrossGap}): stored beside the active interval when that + * is an older interval, shown in place of the loaded intervals otherwise. * - * 3. OTHERWISE - the page merges into the head window, which keeps every loaded message and drops - * what the page proves was hard-deleted (see {@link reconcileHeadAgainstPage}). An active head - * window gets its tail boundary from the page (see {@link showHeadInterval}); otherwise the - * active interval stays active, and grows into the head window if the page overlaps it. + * 3. OTHERWISE - the page merges into the head interval, which keeps every loaded message and + * drops what the page proves was hard-deleted (see {@link reconcileHeadAgainstPage}). An + * active head interval gets its tail boundary from the page (see {@link showHeadInterval}); + * otherwise the active interval stays active, and grows into the head interval if the page + * overlaps it. * - * Only a gap drops loaded windows, never an older active window, and with nothing loaded it + * Only a gap drops loaded intervals, never an older active interval, and with nothing loaded it * does nothing: placing a first page is {@link placeQueryPage}'s job. An empty page changes * nothing on its own but, given a pre-fetch snapshot (`options.headIntervalItemIdsAtRequest`), is - * reconciled as "the channel has no messages": every server-confirmed message of the head window + * reconciled as "the channel has no messages": every server-confirmed message of the head interval * is removed. * - * The query shape of the loaded window is recorded on every exit (see - * `recordLoadedWindowQueryShape`): a window left without one loses itself on the next pagination. + * The query shape of the loaded interval is recorded on every exit (see + * `recordLoadedIntervalQueryShape`): an interval left without one loses itself on the next + * pagination. * - * @param page - The fetched newest window (may be empty). `created_at` order is normalized on + * @param page - The fetched newest interval (may be empty). `created_at` order is normalized on * ingest. * @param options - See {@link ReconcileNewestMessagesOptions}. Omit to prune only within the * page's own span. @@ -1043,7 +1047,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.state.partialNext({ lastQueryError: undefined }); } this._reconcileNewestMessages(page, options); - this.recordLoadedWindowQueryShape(); + this.recordLoadedIntervalQueryShape(); }; private _reconcileNewestMessages = ( @@ -1052,10 +1056,10 @@ export class MessageIntervalPaginator extends BasePaginator< ) => { const headInterval = this.itemIntervals[0] as Interval | undefined; if (!headInterval?.isHead) { - // The newest messages are not loaded as a window: only live content arrived (e.g. the first + // The newest messages are not loaded as an interval: only live content arrived (e.g. the first // query came back empty and the user's reply went to the logical head), only a jumped-to - // window is loaded, or the head mark was cleared (see `unmarkHead`). The page becomes the - // head window, and `ingestPage` merges into it the windows it overlaps, live messages + // interval is loaded, or the head mark was cleared (see `unmarkHead`). The page becomes the + // head interval, and `ingestPage` merges into it the intervals it overlaps, live messages // included, else the newest messages could never be reached. With nothing loaded, placing // a first page is `placeQueryPage`'s job. const logicalHead = this.liveHeadLogical; @@ -1064,9 +1068,9 @@ export class MessageIntervalPaginator extends BasePaginator< ); if (!this.itemIntervals.length || hasAnchoredHead || !page?.length) return; - // The window that reached the newest message until its head mark was cleared is judged as - // the head window is, gap included. The page becomes the head window either way, so that - // window is judged only once. + // The interval that reached the newest message until its head mark was cleared is judged as + // the head interval is, gap included. The page becomes the head interval either way, so that + // interval is judged only once. const unmarkedHead = this.unmarkedHeadInterval; this.unmarkedHeadEdgeId = undefined; if (unmarkedHead && this.isSeparatedFromHeadByGap(unmarkedHead, page, options)) { @@ -1075,19 +1079,19 @@ export class MessageIntervalPaginator extends BasePaginator< } // Take over the view if the live content being absorbed is active, or the paginator is - // inactive. An active interval elsewhere (a window jumped to) stays active, with the - // page anchored as the head window (the rule the jumped away branch below enforces). + // inactive. An active interval elsewhere (an interval jumped to) stays active, with the + // page anchored as the head interval (the rule the jumped away branch below enforces). const takeOverView = !this.isActivePaginator() || (!!logicalHead && this.isActiveInterval(logicalHead)); const anchored = this.ingestPage({ page, isHead: true, setActive: false }); if (!anchored) return; - // The page may have merged into a window that stopped reaching the newest message (see + // The page may have merged into an interval that stopped reaching the newest message (see // `unmarkHead`); remove from it, as from the head, what the page proves was hard-deleted. this.reconcileHeadAgainstPage(page, options); if (!takeOverView) { - // The page merged into the active window, which now reaches the head: show what it gained. + // The page merged into the active interval, which now reaches the head: show what it gained. if (this.isActiveInterval(anchored)) { this.publishActiveIntervalGrownIntoHead(); } @@ -1096,7 +1100,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.showHeadInterval(page, options); return; } - // Inputs for the below-window probe, captured before the merge overwrites state. + // Inputs for the below-interval probe, captured before the merge overwrites state. const wasAtChannelStart = !this.state.getLatestValue().hasMoreTail; const preMergeLoadedCount = headInterval.itemIds.length; @@ -1112,7 +1116,7 @@ export class MessageIntervalPaginator extends BasePaginator< return; } - // Another window is shown; the head window is still reconciled, so returning to it + // Another interval is shown; the head interval is still reconciled, so returning to it // (scroll-to-latest) shows neither ghosts nor stale newest messages. if (!this.isActiveInterval(headInterval)) { this.reconcileHeadAgainstPage(page, options); @@ -1120,7 +1124,7 @@ export class MessageIntervalPaginator extends BasePaginator< return; } - // the head window is active: merge in place, keeping every loaded message + // the head interval is active: merge in place, keeping every loaded message const interval = this.ingestPage({ page, isHead: true, @@ -1130,27 +1134,27 @@ export class MessageIntervalPaginator extends BasePaginator< if (!interval) return; this.showHeadInterval(page, options); - const belowWindow = this.reconcileHeadAgainstPage(page, options); + const idsTailwardOfPage = this.reconcileHeadAgainstPage(page, options); // The leftovers are ambiguous (hard-deleted oldest vs server-capped over-request). Only a - // full-window reload (`requestedLimit >= loaded`, not the small list hydrate) that believed it + // full-interval reload (`requestedLimit >= loaded`, not the small list hydrate) that believed it // was at the start can settle them: probe then, otherwise skip. const requestedLimit = options?.requestedLimit; if ( - belowWindow.length && + idsTailwardOfPage.length && wasAtChannelStart && typeof requestedLimit === 'number' && requestedLimit >= preMergeLoadedCount ) { - this._belowWindowReconcile = this.pruneBelowWindowIfReachedStart( - belowWindow, + this._tailwardOfPageReconcile = this.pruneTailwardOfPageIfReachedStart( + idsTailwardOfPage, this.getItemId(page[0]), ); } }; /** - * Shows the head window a fresh newest page merged into, with no newer messages to load. It has + * Shows the head interval a fresh newest page merged into, with no newer messages to load. It has * older ones unless the page reached the channel start (see {@link pageReachedChannelStart}); * `isTail` follows, which also clears a stale offline-DB `isTail: true` that would let * `intervalsOverlap` weld a far page across a gap. @@ -1174,23 +1178,23 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Whether a fresh newest page and the head window (or the window whose head mark was cleared, see - * {@link unmarkedHeadInterval}) are separated by a gap, with messages between them possibly not - * loaded. They are not when the two together form one unbroken stretch of the channel: + * Whether a fresh newest page and the head interval (or the interval whose head mark was cleared, + * see {@link unmarkedHeadInterval}) are separated by a gap, with messages between them possibly + * not loaded. They are not when the two together form one unbroken stretch of the channel: * - * - the page starts inside the head window (see {@link pageStartsInsideHead}); - * - the page covers the head window (see {@link pageCoversHead}); + * - the page starts inside the head interval (see {@link pageStartsInsideHead}); + * - the page covers the head interval (see {@link pageCoversHead}); * - the page reached the channel start (see {@link pageReachedChannelStart}), so nothing older * than it exists to be missing. * * Sharing a message is not enough on its own: a message the user sent while offline is confirmed * by the server before the reconnect's newest page is requested, so it sits at the top of the - * head window and in the page, with the messages missed below it in neither. For example, the - * window holds m1–m100 and the user's queued "hello"; the server got m101–m210 meanwhile. A page - * of 25 holds m187–m210 and "hello": it does not start inside the window (m187 was never loaded) + * head interval and in the page, with the messages missed below it in neither. For example, the + * interval holds m1–m100 and the user's queued "hello"; the server got m101–m210 meanwhile. A page + * of 25 holds m187–m210 and "hello": it does not start inside the interval (m187 was never loaded) * nor cover it (m1 is not in it), so it is past a gap. When the user wrote the last 10 loaded * messages and others sent 20 since, a page of 25 holds the user's last 5 and the 20 new ones: - * its oldest was loaded, so it starts inside the window. + * its oldest was loaded, so it starts inside the interval. * * A message missed while the socket was down comes with connection recovery's newest page. */ @@ -1206,24 +1210,24 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Whether the page's oldest message the head window could hold (see - * {@link getPageOldestHoldableMessage}) was in the head window when the request went out. The - * page runs unbroken from that message to the newest and the window is taken to run unbroken + * Whether the page's oldest message the head interval could hold (see + * {@link getPageOldestHoldableMessage}) was in the head interval when the request went out. The + * page runs unbroken from that message to the newest and the interval is taken to run unbroken * below it, so nothing between them is missing. * * With a snapshot of the request (`options.headIntervalItemIdsAtRequest`), the message must be - * in it and held by no window other than the head window: a message that reached the window - * during the request, live or as a send confirmed meanwhile, may sit above messages the window - * never loaded. One no longer loaded at all still counts: if the head window holds messages - * older than it, the window ran unbroken through it and it was removed (e.g. hard-deleted); - * if not, the page covers the window anyway. Without a snapshot, the message must be in the - * head window and server-confirmed now. + * in it and held by no interval other than the head interval: a message that reached the interval + * during the request, live or as a send confirmed meanwhile, may sit above messages the interval + * never loaded. One no longer loaded at all still counts: if the head interval holds messages + * older than it, the interval ran unbroken through it and it was removed (e.g. hard-deleted); + * if not, the page covers the interval anyway. Without a snapshot, the message must be in the + * head interval and server-confirmed now. * - * The window is not unbroken below a send the server confirmed while the channel received no + * The interval is not unbroken below a send the server confirmed while the channel received no * events, such as one replayed from the offline queue before the reconnect's newest page is * requested. If that send is exactly the page's oldest (as many newer messages as the page has - * room for were sent after it), the page is taken to start inside the window although messages - * below the send were missed. The page and the window cannot tell this apart from a send + * room for were sent after it), the page is taken to start inside the interval although messages + * below the send were missed. The page and the interval cannot tell this apart from a send * confirmed while events were arriving. */ private pageStartsInsideHead( @@ -1238,13 +1242,13 @@ export class MessageIntervalPaginator extends BasePaginator< if (!idsAtRequest) { return head.itemIds.includes(id) && this.isServerConfirmedItemId(id); } - const heldByAnotherWindow = !head.itemIds.includes(id) && !!this.getItem(id); - return idsAtRequest.has(id) && !heldByAnotherWindow; + const heldByAnotherInterval = !head.itemIds.includes(id) && !!this.getItem(id); + return idsAtRequest.has(id) && !heldByAnotherInterval; } /** - * Whether the page spans the whole head window: the window holds no server-confirmed message - * older than the page. What the window holds beyond the page is newer (it arrived as an event + * Whether the page spans the whole head interval: the interval holds no server-confirmed message + * older than the page. What the interval holds beyond the page is newer (it arrived as an event * after the page was produced) or unsent (it carries the device's clock). */ private pageCoversHead(head: Interval, page: LocalMessage[]): boolean { @@ -1261,8 +1265,8 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * The page's oldest message that the head window could have loaded: server-confirmed and kept - * in intervals (see {@link filterQueryResults}). A message the window filters out, such as a + * The page's oldest message that the head interval could have loaded: server-confirmed and kept + * in intervals (see {@link filterQueryResults}). A message the interval filters out, such as a * shadowed one, would never be found in it. {@link pageCoversHead} compares with the page's * oldest message of any kind instead: it only needs the page's span. */ @@ -1274,9 +1278,9 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * The window whose head mark was cleared last (see {@link unmarkHead}), while it is still loaded. - * It reached the newest message until then, so a newest page is judged against it while no head - * window is loaded. + * The interval whose head mark was cleared last (see {@link unmarkHead}), while it is still + * loaded. It reached the newest message until then, so a newest page is judged against it while + * no head interval is loaded. */ private get unmarkedHeadInterval(): Interval | undefined { const id = this.unmarkedHeadEdgeId; @@ -1288,8 +1292,8 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Remembers the window by a message that stays in it: a server-confirmed one, as an unsent one - * may be deleted or retried; the raw newest item only when the window holds none. + * Remembers the interval by a message that stays in it: a server-confirmed one, as an unsent one + * may be deleted or retried; the raw newest item only when the interval holds none. */ protected override unmarkHead(interval: Interval) { const edge = @@ -1314,61 +1318,61 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Places a fresh newest page separated by a gap from `headWindow` (the head window, or the - * window whose head mark was cleared, see {@link isSeparatedFromHeadByGap}). The page becomes - * the head window either way, and the windows nothing brought up to date are dropped, as a + * Places a fresh newest page separated by a gap from `headInterval` (the head interval, or the + * interval whose head mark was cleared, see {@link isSeparatedFromHeadByGap}). The page becomes + * the head interval either way, and the intervals nothing brought up to date are dropped, as a * message deleted meanwhile would come back with them: * - * - an active interval other than the head window (see {@link getActiveIntervalApartFromHead}) + * - an active interval other than the head interval (see {@link getActiveIntervalApartFromHead}) * stays active, and the page is stored beside it (see * {@link storeNewestPageBesideActiveInterval}); - * - otherwise the page replaces the loaded windows and is shown (see - * {@link replaceWindowsWithNewestPage}). + * - otherwise the page replaces the loaded intervals and is shown (see + * {@link replaceIntervalsWithNewestPage}). */ private placeNewestPageAcrossGap( page: LocalMessage[], - headWindow: Interval, + headInterval: Interval, options?: ReconcileNewestMessagesOptions, ) { - const activeInterval = this.getActiveIntervalApartFromHead(headWindow); + const activeInterval = this.getActiveIntervalApartFromHead(headInterval); if (activeInterval) { this.storeNewestPageBesideActiveInterval(page, activeInterval, options); } else { - this.replaceWindowsWithNewestPage(page, options); + this.replaceIntervalsWithNewestPage(page, options); } } /** - * The active loaded window when it is not `headWindow`, such as a window jumped to; `undefined` - * when `headWindow` or live messages only are active, and in an inactive paginator (see - * {@link isActivePaginator}). + * The active loaded interval when it is not `headInterval`, such as an interval jumped to; + * `undefined` when `headInterval` or live messages only are active, and in an inactive paginator + * (see {@link isActivePaginator}). */ - private getActiveIntervalApartFromHead(headWindow: Interval): Interval | undefined { + private getActiveIntervalApartFromHead(headInterval: Interval): Interval | undefined { if (!this.isActivePaginator()) return undefined; return this.itemIntervals.find( (interval): interval is Interval => !isLogicalInterval(interval) && this.isActiveInterval(interval) && - interval.id !== headWindow.id, + interval.id !== headInterval.id, ); } /** - * Replaces the loaded windows with a fresh newest page separated from the head window by a gap - * (see {@link placeNewestPageAcrossGap}) and shows it. The page's window keeps the loaded + * Replaces the loaded intervals with a fresh newest page separated from the head interval by a gap + * (see {@link placeNewestPageAcrossGap}) and shows it. The page's interval keeps the loaded * messages that must not be lost (see {@link getMessagesKeptAcrossGap}) and has older messages * to load. */ - private replaceWindowsWithNewestPage( + private replaceIntervalsWithNewestPage( page: LocalMessage[], options?: ReconcileNewestMessagesOptions, ) { const keptMessages = this.getMessagesKeptAcrossGap(page, this.itemIntervals); - // one publish, once the page's window is in place and reconciled, so subscribers read it whole + // one publish, once the page's interval is in place and reconciled, so subscribers read it whole this.batch( () => { this.clearItemStorage(); - this.ingestPageAsHeadWindow(page, keptMessages, options); + this.ingestPageAsHeadInterval(page, keptMessages, options); this.showHeadInterval(page, options); }, { coalesce: true }, @@ -1376,39 +1380,39 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Stores a fresh newest page separated from the head window by a gap (see - * {@link placeNewestPageAcrossGap}) as the head window, beside `activeInterval`, which stays - * active and shown as it is. Every other loaded window is dropped and loads again on - * demand. The page's window keeps the messages of the dropped windows that must not be lost (see - * {@link getMessagesKeptAcrossGap}) and has older messages to load. + * Stores a fresh newest page separated from the head interval by a gap (see + * {@link placeNewestPageAcrossGap}) as the head interval, beside `activeInterval`, which stays + * active and shown as it is. Every other loaded interval is dropped and loads again on + * demand. The page's interval keeps the messages of the dropped intervals that must not be lost + * (see {@link getMessagesKeptAcrossGap}) and has older messages to load. * * The active interval keeps all its messages, unsent ones included, so nothing moves in the * shown list, and it is not brought up to date: a message deleted or edited meanwhile stays as it was - * loaded. It can load newer messages, up to the head window it then joins. + * loaded. It can load newer messages, up to the head interval it then joins. */ private storeNewestPageBesideActiveInterval( page: LocalMessage[], activeInterval: Interval, options?: ReconcileNewestMessagesOptions, ) { - const droppedWindows = this.itemIntervals.filter( + const droppedIntervals = this.itemIntervals.filter( (interval) => interval.id !== activeInterval.id, ); - const keptMessages = this.getMessagesKeptAcrossGap(page, droppedWindows); + const keptMessages = this.getMessagesKeptAcrossGap(page, droppedIntervals); const keepingIds = new Set( [...page, ...keptMessages].map((message) => this.getItemId(message)), ); - // one publish, once the page's window is in place and reconciled, so subscribers read it whole + // one publish, once the page's interval is in place and reconciled, so subscribers read it whole this.batch( () => { - this.dropWindows(droppedWindows, { keepingIds }); + this.dropIntervals(droppedIntervals, { keepingIds }); this.unmarkedHeadEdgeId = undefined; - // Not the head window: a head mark would weld the page to it (`intervalsOverlap` joins - // anything beyond a head window's head edge). + // Not the head interval: a head mark would weld the page to it (`intervalsOverlap` joins + // anything beyond a head interval's head edge). activeInterval.isHead = false; - const pageWindow = this.ingestPageAsHeadWindow(page, keptMessages, options); - if (pageWindow && this.isActiveInterval(pageWindow)) { - // the page overlapped the active interval, which grew into the head window + const pageInterval = this.ingestPageAsHeadInterval(page, keptMessages, options); + if (pageInterval && this.isActiveInterval(pageInterval)) { + // the page overlapped the active interval, which grew into the head interval this.publishActiveIntervalGrownIntoHead(); return; } @@ -1419,36 +1423,36 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Ingests a fresh newest page placed across a gap, with `keptMessages`, as the head window, which - * has older messages to load, and removes from it what the page proves was hard-deleted (see - * {@link reconcileHeadAgainstPage}). Returns the window the page landed in. + * Ingests a fresh newest page placed across a gap, with `keptMessages`, as the head interval, + * which has older messages to load, and removes from it what the page proves was hard-deleted (see + * {@link reconcileHeadAgainstPage}). Returns the interval the page landed in. */ - private ingestPageAsHeadWindow( + private ingestPageAsHeadInterval( page: LocalMessage[], keptMessages: LocalMessage[], options?: ReconcileNewestMessagesOptions, ): Interval | null { - const pageWindow = this.ingestPage({ + const pageInterval = this.ingestPage({ page: [...page, ...keptMessages], isHead: true, isTail: false, setActive: false, }); this.reconcileHeadAgainstPage(page, options); - return pageWindow; + return pageInterval; } /** - * Drops `windows` from storage and releases their messages, except those with `keepingIds`, + * Drops `intervals` from storage and releases their messages, except those with `keepingIds`, * which the caller ingests again. */ - private dropWindows( - windows: AnyInterval[], + private dropIntervals( + intervals: AnyInterval[], { keepingIds }: { keepingIds: ReadonlySet }, ) { - for (const window of windows) { - this.dropInterval(window.id); - for (const id of window.itemIds) { + for (const interval of intervals) { + this.dropInterval(interval.id); + for (const id of interval.itemIds) { if (!keepingIds.has(id)) this._itemIndex.remove(id); } } @@ -1456,20 +1460,20 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Marks the active interval, which does not reach the newest message, as able to load newer - * messages. Publishes the flag and cursor only when they changed, so the window shown stays as + * messages. Publishes the flag and cursor only when they changed, so the interval shown stays as * it is. */ - private markActiveIntervalBelowHead(window: Interval) { - window.hasMoreHead = true; - const cursor = this.getCursorFromInterval(window); + private markActiveIntervalBelowHead(interval: Interval) { + interval.hasMoreHead = true; + const cursor = this.getCursorFromInterval(interval); const shown = this.state.getLatestValue(); if (shown.hasMoreHead && shown.cursor?.headward === cursor.headward) return; this.state.partialNext({ hasMoreHead: true, cursor }); } /** - * The messages of `windows`, missing from a newest page placed across a gap, that the page's - * window keeps: + * The messages of `intervals`, missing from a newest page placed across a gap, that the page's + * interval keeps: * * - unsent messages (see {@link isServerConfirmedMessage}); * - messages from the page's oldest on: those that arrived during the request stay, and @@ -1477,13 +1481,13 @@ export class MessageIntervalPaginator extends BasePaginator< */ private getMessagesKeptAcrossGap( page: LocalMessage[], - windows: AnyInterval[], + intervals: AnyInterval[], ): LocalMessage[] { const pageIds = new Set(page.map((message) => this.getItemId(message))); const pageOldest = this.getPageOldestMessage(page); const isOlderThanPage = (message: LocalMessage) => !!pageOldest && this.aIsMoreTailwardThanB(message, pageOldest); - return windows + return intervals .flatMap((interval) => this.intervalToItems(interval)) .filter( (message) => @@ -1493,9 +1497,10 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Merges a fresh newest page, not separated by a gap, into the head window while another window - * is shown. That window stays active and grows into the head window if the page overlaps it. In - * an inactive paginator (see {@link isActivePaginator}), the head window is shown instead. + * Merges a fresh newest page, not separated by a gap, into the head interval while another + * interval is shown. That interval stays active and grows into the head interval if the page + * overlaps it. In an inactive paginator (see {@link isActivePaginator}), the head interval is + * shown instead. */ private mergePageIntoInactiveHead( page: LocalMessage[], @@ -1510,7 +1515,7 @@ export class MessageIntervalPaginator extends BasePaginator< } } - /** Publishes the active interval once a newest page grew it into the head window. */ + /** Publishes the active interval once a newest page grew it into the head interval. */ private publishActiveIntervalGrownIntoHead() { this.publishActiveInterval({ hasMoreHead: false, @@ -1537,14 +1542,14 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Settles the below-window leftovers {@link reconcileHeadAgainstPage} returned (loaded messages + * Settles the below-interval leftovers {@link reconcileHeadAgainstPage} returned (loaded messages * older than `anchorId`, the page's oldest, and absent from it): from the page alone a * hard-deleted oldest looks like a server-capped over-request. It asks the server whether anything * is older than `anchorId`; if not, they are real deletes and are removed and the tail settled. A * probe failure or a head change across the await keeps them. The caller decides when this runs. */ - private async pruneBelowWindowIfReachedStart( - belowWindow: string[], + private async pruneTailwardOfPageIfReachedStart( + idsTailwardOfPage: string[], anchorId: string, ): Promise { const headId = (this.itemIntervals[0] as Interval | undefined)?.id; @@ -1553,7 +1558,7 @@ export class MessageIntervalPaginator extends BasePaginator< // Revalidate the head across the await (a jump, reset or newer merge aborts). const head = this.itemIntervals[0] as Interval | undefined; if (!head?.isHead || head.id !== headId) return; - this.removeReconciledItems(belowWindow); + this.removeReconciledItems(idsTailwardOfPage); head.hasMoreTail = false; head.isTail = true; if (this.isActiveInterval(head)) { @@ -1599,9 +1604,9 @@ export class MessageIntervalPaginator extends BasePaginator< /** * The failed messages a reload restores with {@link restoreFailedMessages}: those of the shown - * window and the head window, and those of `persisted` (e.g. the offline database's rows of the - * channel) this list holds (see {@link isListedMessage}). The loaded copy wins, as it is at least - * as fresh. Take them before the reload's request. + * interval and the head interval, and those of `persisted` (e.g. the offline database's rows of + * the channel) this list holds (see {@link isListedMessage}). The loaded copy wins, as it is at + * least as fresh. Take them before the reload's request. * * @internal */ @@ -1617,8 +1622,8 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Ingests again each failed message taken before a reload (see - * {@link getFailedMessagesToRestore}) that no window holds after it, so a reload never loses the - * user's unsent work. One a window holds is left as it is: it may have been resent. + * {@link getFailedMessagesToRestore}) that no interval holds after it, so a reload never loses the + * user's unsent work. One an interval holds is left as it is: it may have been resent. * * @internal */ @@ -1637,7 +1642,7 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Destructive half of {@link reconcileNewestMessages}: removes loaded head-interval messages - * that `page` (the fresh newest window) proves were hard-deleted while the client received no + * that `page` (the fresh newest interval) proves were hard-deleted while the client received no * events, by `created_at`: * * - within `page`'s span: a server-confirmed message absent from `page` was deleted (a live @@ -1647,11 +1652,11 @@ export class MessageIntervalPaginator extends BasePaginator< * arrival or a send confirmed during the request. * * Messages below `page`'s oldest are kept (a hard-deleted oldest looks like a server-capped - * over-request) and returned for {@link pruneBelowWindowIfReachedStart}. Removal goes through + * over-request) and returned for {@link pruneTailwardOfPageIfReachedStart}. Removal goes through * {@link removeItem}, so the index, intervals, message store and any subclass bookkeeping (e.g. * the latest message of {@link MessagePaginator}) stay correct. * - * @returns The below-window leftover IDs. Empty for the no-head and empty-page paths. + * @returns The below-interval leftover IDs. Empty for the no-head and empty-page paths. */ protected reconcileHeadAgainstPage( page: LocalMessage[], @@ -1678,11 +1683,11 @@ export class MessageIntervalPaginator extends BasePaginator< const newestReturnedTs = getMessageCreatedAtTimestamp(page[page.length - 1]); const oldestReturnedTs = getMessageCreatedAtTimestamp(page[0]); - // Nothing below the page's oldest is removed here: those go to `belowWindow` for the probe. - const windowLowTs = oldestReturnedTs ?? Number.POSITIVE_INFINITY; + // Nothing below the page's oldest is removed here: those go to `idsTailwardOfPage` for the probe. + const pageTailTs = oldestReturnedTs ?? Number.POSITIVE_INFINITY; const toRemove: string[] = []; - const belowWindow: string[] = []; + const idsTailwardOfPage: string[] = []; for (const id of loadedIds) { if (pageIds.has(id)) continue; const message = this.getItem(id); @@ -1699,12 +1704,12 @@ export class MessageIntervalPaginator extends BasePaginator< } // Within the page's span a live arrival cannot be here, so absence means a hard delete. At or // below the page's oldest the page cannot decide: return it for the reached-start probe. - if (ts > windowLowTs) toRemove.push(id); - else belowWindow.push(id); + if (ts > pageTailTs) toRemove.push(id); + else idsTailwardOfPage.push(id); } this.removeReconciledItems(toRemove); - return belowWindow; + return idsTailwardOfPage; } /** Removes reconciled IDs in one coalesced batch (one publish), then mirrors it offline. */ @@ -1855,10 +1860,10 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Partial truncation for `channel.truncated` with a `truncated_at`: drops every loaded message - * strictly older than the cutoff across all windows (a full truncation uses + * strictly older than the cutoff across all intervals (a full truncation uses * {@link MessageIntervalPaginator.clearStateAndCache}). Only the interval straddling the cutoff is * scanned member by member and becomes the new global tail; intervals entirely newer keep their - * flags, since unloaded older messages may sit between them and the cutoff. The active window is + * flags, since unloaded older messages may sit between them and the cutoff. The active interval is * re-emitted once. */ truncate({ truncatedAt }: { truncatedAt: TimestampNS }) { @@ -1903,7 +1908,7 @@ export class MessageIntervalPaginator extends BasePaginator< // edge (the interval sort key) never changes. this.setIntervals(survivingIntervals); - // `setIntervals` does not emit: re-emit the active window once. + // `setIntervals` does not emit: re-emit the active interval once. const active = this._activeIntervalId ? this._itemIntervals.get(this._activeIntervalId) : undefined; @@ -1917,7 +1922,7 @@ export class MessageIntervalPaginator extends BasePaginator< return; } - // The active window was truncated away: activate the tail-most (oldest) surviving anchored + // The active interval was truncated away: activate the tail-most (oldest) surviving anchored // interval rather than blank the message list. const anchoredSurvivors = this.itemIntervals.filter( (itv): itv is Interval => !isLogicalInterval(itv), @@ -1943,7 +1948,7 @@ export class MessageIntervalPaginator extends BasePaginator< }) => { const loadedMessages = this._itemIndex.values(); - // Coalesce: one emit for this window and one shared-store flush for the sibling holders. + // Coalesce: one emit for this interval and one shared-store flush for the sibling holders. this.batch( () => { for (const message of loadedMessages) { @@ -2001,7 +2006,7 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Reflects an updated `user` onto every cached message authored by that user (not * `quoted_message.user`). Patches the shared item index in one batch and re-emits the active - * window once, if it held an affected message. + * interval once, if it held an affected message. */ reflectUserUpdate(user: UserResponse) { const activeIds = new Set((this.items ?? []).map((m) => this.getItemId(m))); @@ -2025,9 +2030,9 @@ export class MessageIntervalPaginator extends BasePaginator< /** * The last loaded message created at or before `timestamp` (a floor), or `null` when it precedes * every loaded message. With `exactTsMatch`, only a message created exactly at `timestamp` matches. - * Searches the loaded windows from the head down (each already sorted, O(log n) per window): the - * first holding a message at or before `timestamp` holds the answer. Used by the receipts tracker - * to resolve read/delivered cursors. + * Searches the loaded intervals from the head down (each already sorted, O(log n) per interval): + * the first holding a message at or before `timestamp` holds the answer. Used by the receipts + * tracker to resolve read/delivered cursors. */ findItemByTimestamp = ( timestamp: TimestampNS, @@ -2045,8 +2050,8 @@ export class MessageIntervalPaginator extends BasePaginator< }; /** - * {@link findItemByTimestamp} within one window (`items` ascending by `created_at`). `undefined` - * when every item is newer than `timestamp`, so an older window has to be searched. + * {@link findItemByTimestamp} within one interval (`items` ascending by `created_at`). `undefined` + * when every item is newer than `timestamp`, so an older interval has to be searched. */ private findItemByTimestampIn( items: LocalMessage[], @@ -2061,7 +2066,7 @@ export class MessageIntervalPaginator extends BasePaginator< const t = getMessageCreatedAtTimestamp(items[i]); return t === null || t > timestamp; }); - if (firstAfter === 0) return undefined; // target precedes every message in this window + if (firstAfter === 0) return undefined; // target precedes every message in this interval const found = items[firstAfter - 1]; const foundTimestamp = getMessageCreatedAtTimestamp(found); // A message without a server timestamp (e.g. optimistic) cannot be located. diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index ebff114dee..22bdd83ac5 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -29,7 +29,7 @@ export type { export { MessageIntervalPaginator } from './MessageIntervalPaginator'; /** - * Whole-collection aggregates, independent of the active pagination window. `latestMessageAt` is + * Whole-collection aggregates, independent of the active pagination interval. `latestMessageAt` is * the time of the channel's latest message and orders the channel list * (`channel.messagePaginator.latestMessageAt`): the later of * {@link MessagePaginatorAggregateState.latestMessage}'s `created_at` and the `last_message_at` of @@ -37,9 +37,9 @@ export { MessageIntervalPaginator } from './MessageIntervalPaginator'; */ export type MessagePaginatorAggregateState = { /** - * The newest message the client holds that counts as the channel's latest, from any loaded window - * or from an event. Not necessarily the server's latest message: when the client does not hold - * that one, {@link MessagePaginator.latestMessageAt} is later than its `created_at` (see + * The newest message the client holds that counts as the channel's latest, from any loaded + * interval or from an event. Not necessarily the server's latest message: when the client does + * not hold that one, {@link MessagePaginator.latestMessageAt} is later than its `created_at` (see * {@link MessagePaginator.latestMessage}). For display (latest-message preview, latest-reply * avatar). A live reference: it advances to a strictly newer message, is refreshed in place on * edit, soft-delete or reaction, and is recomputed when hard-removed. Follows @@ -47,7 +47,7 @@ export type MessagePaginatorAggregateState = { * and thread-only replies). `null` until one is ingested. * * It lives outside pagination `state`, which only emits when the active interval is affected: a - * message landing in the head interval while an older window is active would go stale there. + * message landing in the head interval while an older interval is active would go stale there. */ latestMessage: LocalMessage | null; /** @@ -186,17 +186,17 @@ export class MessagePaginator extends MessageIntervalPaginator { /** * The newest message the client holds that counts as the channel's latest (see - * {@link shouldAdvanceLatestMessage}), from any loaded window or from an event. Not necessarily + * {@link shouldAdvanceLatestMessage}), from any loaded interval or from an event. Not necessarily * the server's latest message: {@link latestMessageAt} is later than its `created_at` when the * server has newer messages the client does not hold. `null` until a message is ingested (a * `last_message_at` from a query sets {@link latestMessageAt} but leaves this `null`). * Convenience read of {@link aggregateState}; subscribe to `aggregateState` for reactivity. * - * It differs from {@link anchoredHeadItem}, the newest message of the window known to reach the + * It differs from {@link anchoredHeadItem}, the newest message of the interval known to reach the * server's latest message, in two ways: * - It skips messages that do not count. With system messages skipped, a system message can be * `anchoredHeadItem` but never `latestMessage`. - * - It does not need that window. A channel opened at its first unread message loads m40–m60 + * - It does not need that interval. A channel opened at its first unread message loads m40–m60 * while the server's newest is m95: `latestMessage` is m60, `latestMessageAt` is m95's time and * `anchoredHeadItem` is `undefined`. */ @@ -205,9 +205,9 @@ export class MessagePaginator extends MessageIntervalPaginator { } /** - * The newest server-confirmed message of the window that reaches the newest message (see - * {@link anchoredHeadItems}). Any message the window holds counts, system messages included, but - * not an unsent one: its id is local. `undefined` while that window is not loaded or holds no + * The newest server-confirmed message of the interval that reaches the newest message (see + * {@link anchoredHeadItems}). Any message the interval holds counts, system messages included, but + * not an unsent one: its id is local. `undefined` while that interval is not loaded or holds no * server-confirmed message. * * @internal @@ -237,7 +237,7 @@ export class MessagePaginator extends MessageIntervalPaginator { * Advances {@link latestMessage} (and so {@link latestMessageAt}) to `message` when it is newer * and {@link shouldAdvanceLatestMessage} permits. Writes {@link aggregateState} only, so it is * safe inside a `state.next` updater (`ingestPage`). Returns `true` when the value moved. Public - * for callers that advance it without an in-window ingest, e.g. offline pending-message replay. + * for callers that advance it without an in-interval ingest, e.g. offline pending-message replay. */ trackLatestMessage(message: LocalMessage): boolean { if (!this.shouldAdvanceLatestMessage(message)) return false; @@ -251,7 +251,7 @@ export class MessagePaginator extends MessageIntervalPaginator { return true; } // Otherwise only a strictly newer message advances. Compare against the current message's own - // timestamp, not `latestMessageAt`: a queried `last_message_at` newer than the loaded window + // timestamp, not `latestMessageAt`: a queried `last_message_at` newer than the loaded interval // would reject the message. const currentTs = current ? getMessageCreatedAtTimestamp(current) : null; if (currentTs !== null && incoming <= currentTs) return false; @@ -276,23 +276,23 @@ export class MessagePaginator extends MessageIntervalPaginator { } /** - * Clears the head mark of the head window when it is stale (see {@link unmarkHead}). It is stale + * Clears the head mark of the head interval when it is stale (see {@link unmarkHead}). It is stale * when its newest message is older than the `last_message_at` the latest query returned (see - * {@link setQueriedLatestMessageAt}): the channel has newer messages the window lacks. The window - * then stops being the head window ({@link anchoredHeadItem} becomes `undefined`) and can load - * newer messages, and a message that then arrives from an event starts a separate head window - * instead of joining it across the gap. Messages that exist only locally, such as a failed send, - * are left out of the comparison: they carry the device's clock. + * {@link setQueriedLatestMessageAt}): the channel has newer messages the interval lacks. The + * interval then stops being the head interval ({@link anchoredHeadItem} becomes `undefined`) and + * can load newer messages, and a message that then arrives from an event starts a separate head + * interval instead of joining it across the gap. Messages that exist only locally, such as a + * failed send, are left out of the comparison: they carry the device's clock. * - * For example, the window holds m1–m20 and the channel is not watched while m21–m25 are sent. A - * query returns m25's time: the window can now load m21–m25, and m26 from an event does not land + * For example, the interval holds m1–m20 and the channel is not watched while m21–m25 are sent. A + * query returns m25's time: the interval can now load m21–m25, and m26 from an event does not land * right after m20. * * Call it with each response that does not bring the newest messages up to date, before any page - * the response carries is placed: a page that reaches the newest message marks its own window as + * the response carries is placed: a page that reaches the newest message marks its own interval as * the head. Call it only when no events were arriving before the response and nothing else - * brings the newest messages: while the channel is watched, a message the window lacks is still - * on its way, and a re-watch that places the newest page settles the window itself. + * brings the newest messages: while the channel is watched, a message the interval lacks is still + * on its way, and a re-watch that places the newest page settles the interval itself. */ unmarkStaleHead() { const head = this.anchoredHeadInterval; @@ -328,10 +328,10 @@ export class MessagePaginator extends MessageIntervalPaginator { /** * Ingests a single message, from an event or an operation such as a send. A message that starts - * the newest window is placed by {@link ingestAsNewestInterval}. Any other goes into the window - * it belongs to and the active interval stays active, except for the current user's new send (see - * {@link isOwnSendWhileOlderWindowIsActive}): the newest window holding it is shown, so the user - * sees what they sent. + * the newest interval is placed by {@link ingestAsNewestInterval}. Any other goes into the + * interval it belongs to and the active interval stays active, except for the current user's new + * send (see {@link isOwnSendWhileOlderIntervalIsActive}): the newest interval holding it is + * shown, so the user sees what they sent. */ ingestItem(item: LocalMessage): boolean { // Only items passing the filter advance the aggregate (`ingestItem` also removes the others). @@ -340,21 +340,21 @@ export class MessagePaginator extends MessageIntervalPaginator { this.trackLatestMessage(item); } // judged before ingesting, while the message is not held yet - const showsNewestWindow = this.isOwnSendWhileOlderWindowIsActive(item); + const showsNewestInterval = this.isOwnSendWhileOlderIntervalIsActive(item); const ingested = super.ingestItem(item); const newest = this.anchoredHeadInterval; - if (ingested && showsNewestWindow && newest?.itemIds.includes(item.id)) { - this.showWindowOfOwnSend(newest); + if (ingested && showsNewestInterval && newest?.itemIds.includes(item.id)) { + this.showIntervalOfOwnSend(newest); } return ingested; } /** * Whether `message` is the current user's own send (see {@link isOwnSend}) that the paginator - * does not hold yet, while the newest window is loaded but another window is active. A message + * does not hold yet, while the newest interval is loaded but another interval is active. A message * already held (a retry of a failed send, a replayed send, its server confirmation) is not new. */ - private isOwnSendWhileOlderWindowIsActive(message: LocalMessage): boolean { + private isOwnSendWhileOlderIntervalIsActive(message: LocalMessage): boolean { const newest = this.anchoredHeadInterval; return ( !!newest && @@ -365,18 +365,19 @@ export class MessagePaginator extends MessageIntervalPaginator { } /** - * Shows the window holding the current user's own send. The send decides the window shown, so it - * claims the active interval as a jump does: a page a query claimed earlier is then only stored. + * Shows the interval holding the current user's own send. The send decides the interval shown, so + * it claims the active interval as a jump does: a page a query claimed earlier is then only + * stored. */ - private showWindowOfOwnSend(interval: Interval) { + private showIntervalOfOwnSend(interval: Interval) { this.claimActiveInterval(); this.showLoadedInterval(interval); } /** * A message the paginator does not hold yet, at or after {@link latestMessageAt} (as known before - * it arrives), while no loaded window reaches the newest message. It is then the newest message, - * so it starts the newest window, as a page holding it would (see {@link pageReachesHead}). + * it arrives), while no loaded interval reaches the newest message. It is then the newest message, + * so it starts the newest interval, as a page holding it would (see {@link pageReachesHead}). */ private isNewNewestMessage(message: LocalMessage): boolean { if (this.anchoredHeadItem !== undefined || this.getItem(message.id)) return false; @@ -404,12 +405,12 @@ export class MessagePaginator extends MessageIntervalPaginator { } /** - * Starts the newest window with `message`, together with the + * Starts the newest interval with `message`, together with the * {@link logicalHeadItemsSinceQueriedLatestMessageAt} ingested before it. It can load older * messages unless none are left to load (see {@link hasNoMessagesBeforeLiveMessages}). It becomes - * the active window when no window was active, when the logical head it takes items from was, or - * when `message` is the current user's own send (see {@link isOwnSend}), as every own send shows - * the newest window (see {@link ingestItem}). + * the active interval when no interval was active, when the logical head it takes items from was, + * or when `message` is the current user's own send (see {@link isOwnSend}), as every own send + * shows the newest interval (see {@link ingestItem}). */ private ingestAsNewestInterval(message: LocalMessage): boolean { const liveHead = this.liveHeadLogical; @@ -422,7 +423,7 @@ export class MessagePaginator extends MessageIntervalPaginator { if (!interval) return false; if (this.isOwnSend(message)) { - this.showWindowOfOwnSend(interval); + this.showIntervalOfOwnSend(interval); } else if ( !this._activeIntervalId || (!!liveHead && this.isActiveInterval(liveHead)) @@ -433,9 +434,9 @@ export class MessagePaginator extends MessageIntervalPaginator { } /** - * Whether no message older than the live messages is left to load: no window of queried messages + * Whether no message older than the live messages is left to load: no interval of queried messages * is loaded and the list reports no older messages, as after a newest page that came back empty. - * A list never queried, or one holding a window (a jumped-to one, or one whose head mark was + * A list never queried, or one holding an interval (a jumped-to one, or one whose head mark was * cleared), has older messages to load. */ private get hasNoMessagesBeforeLiveMessages(): boolean { @@ -541,9 +542,9 @@ export class MessagePaginator extends MessageIntervalPaginator { /** * The still-loaded message with the greatest `created_at` that passes - * {@link shouldAdvanceLatestMessage} (skips system / thread-only per config), from the window + * {@link shouldAdvanceLatestMessage} (skips system / thread-only per config), from the interval * that reaches the newest message and the {@link logicalHeadItemsSinceQueriedLatestMessageAt}. - * A window reached by a jump is not searched, nor an out-of-order older message: neither is the + * An interval reached by a jump is not searched, nor an out-of-order older message: neither is the * channel's previous message. Used to recompute the tracked latest after the current one is * removed; `serverConfirmedOnly` skips unsent messages. `null` when nothing loaded qualifies. * It compares timestamps rather than positions, so it holds for any `itemOrder` / `requestSort`. @@ -611,8 +612,8 @@ export class MessagePaginator extends MessageIntervalPaginator { * * Resolution order: * 1. an explicit first-unread ID (snapshot or read state): jump to it; - * 2. the last-read timestamp: infer the first unread message from the loaded window, or from a - * `created_at_around` query when the window does not straddle the boundary, and jump to it; + * 2. the last-read timestamp: infer the first unread message from the loaded interval, or from a + * `created_at_around` query when the interval does not straddle the boundary, and jump to it; * 3. the last-read ID, when no timestamp is known. * * The inference is not written to the snapshot: `firstUnreadMessageId` is reserved for an explicit diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index 45f4d0dcc2..1b220bacdf 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -120,8 +120,8 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { /** * The newest page (no cursor, not around a message) holds the most recently pinned messages, so - * it reaches the head: its window becomes the only newest window, and an older window that was - * the newest one can load newer pins (see `makeOnlyHead`). A newest page comes from this + * it reaches the head: its interval becomes the only newest interval, and an older interval that + * was the newest one can load newer pins (see `makeOnlyHead`). A newest page comes from this * paginator's own query or with a channel response. */ protected override pageReachesHead( diff --git a/src/pagination/paginators/stateThrottling.ts b/src/pagination/paginators/stateThrottling.ts index a69f631a9c..340df8ac84 100644 --- a/src/pagination/paginators/stateThrottling.ts +++ b/src/pagination/paginators/stateThrottling.ts @@ -13,7 +13,7 @@ const isTestRunner = (): boolean => { }; /** - * Global switch for the `state` publish throttle (see `BasePaginator.scheduleWindowPublish`). On by + * Global switch for the `state` publish throttle (see `BasePaginator.scheduleActiveIntervalPublish`). On by * default, off under test runners (Vitest / Jest) so every mutation publishes synchronously. Tests * of the throttle itself turn it on with {@link setStateThrottlingEnabled}. */ diff --git a/src/thread.ts b/src/thread.ts index d38e384141..37b96807cd 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -208,8 +208,8 @@ export class Thread extends WithSubscriptions { { pageSize: DEFAULT_PAGE_LIMIT }, ); - // Seeds the first reply page without a fetch. `latest_replies` is the newest window: it reaches - // the head, and the tail only if it holds every reply. + // Seeds the first reply page without a fetch. `latest_replies` is the newest interval: it + // reaches the head, and the tail only if it holds every reply. if (threadData?.latest_replies?.length) { const replies = threadData.latest_replies.map(formatMessage); const isLastPage = holdsEveryReply(replies, threadData); @@ -326,7 +326,7 @@ export class Thread extends WithSubscriptions { }; /** - * Re-queries the replies, sized to the loaded window, and reconciles them in place (the thread + * Re-queries the replies, sized to the loaded interval, and reconciles them in place (the thread * analogue of {@link Channel.reload}; recovery calls it for active threads). The thread applies * the response to itself ({@link Thread.hydrateState}), whether or not it is registered in * `client.threads`. Failed replies are kept: those loaded in the reply paginator or in the @@ -386,7 +386,7 @@ export class Thread extends WithSubscriptions { /** * Updates the thread's state and its channel's members and custom data, and merges the replies - * into the loaded window (with `reconcile`) or replaces it (without). + * into the loaded interval (with `reconcile`) or replaces it (without). */ public hydrateState = ( threadData: ThreadStateResponse, @@ -416,7 +416,7 @@ export class Thread extends WithSubscriptions { this.messagePaginator.setQueriedLatestMessageAt(threadData.last_message_at); if (typeof this.messagePaginator.items === 'undefined') { // Nothing loaded yet (e.g. a thread opened from a message): `reconcileNewestMessages` no-ops - // without a loaded window and would drop the fetched replies, so set them as the constructor + // without a loaded interval and would drop the fetched replies, so set them as the constructor // does. const isLastPage = holdsEveryReply(incomingReplies, threadData); this.messagePaginator.setItems({ diff --git a/test/unit/configuration/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts index 0d2a6e1edd..0374c5db4f 100644 --- a/test/unit/configuration/instanceConfiguration.integration.test.ts +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -177,10 +177,10 @@ describe('instance configuration — cross-instance', () => { const channel = openChannel(); const internals = channel.messagePaginator as unknown as { _executeQueryDebounced: unknown; - _windowPublishThrottle: unknown; + _activeIntervalPublishThrottle: unknown; }; const debounceBefore = internals._executeQueryDebounced; - const throttleBefore = internals._windowPublishThrottle; + const throttleBefore = internals._activeIntervalPublishThrottle; client.config.setConfig('channel', { messagePaginator: { debounceMs: 900 } }); @@ -191,7 +191,7 @@ describe('instance configuration — cross-instance', () => { // The throttle is *not*, because 250 did not move. The old code rebuilt it on every derivation // regardless, flushing pending publishes each time for nothing. expect(channel.messagePaginator.config.stateThrottleMs).toBe(250); - expect(internals._windowPublishThrottle).toBe(throttleBefore); + expect(internals._activeIntervalPublishThrottle).toBe(throttleBefore); }); }); diff --git a/test/unit/configuration/messagePaginator.config.test.ts b/test/unit/configuration/messagePaginator.config.test.ts index bfc0d86086..4ee9646133 100644 --- a/test/unit/configuration/messagePaginator.config.test.ts +++ b/test/unit/configuration/messagePaginator.config.test.ts @@ -294,29 +294,29 @@ describe("the shared 'messagePaginator' configuration key", () => { // controller's change hook, so every route gets it. const channel = openChannel(); const internals = channel.messagePaginator as unknown as { - _windowPublishThrottle: unknown; + _activeIntervalPublishThrottle: unknown; }; - const before = internals._windowPublishThrottle; + const before = internals._activeIntervalPublishThrottle; client.config.set({ messagePaginator: { stateThrottleMs: 350 } }); expect(channel.messagePaginator.config.stateThrottleMs).toBe(350); - expect(internals._windowPublishThrottle).not.toBe(before); + expect(internals._activeIntervalPublishThrottle).not.toBe(before); }); it('does not rebuild a read-once field whose value did not move', () => { const channel = openChannel(); const internals = channel.messagePaginator as unknown as { - _windowPublishThrottle: unknown; + _activeIntervalPublishThrottle: unknown; }; - const before = internals._windowPublishThrottle; + const before = internals._activeIntervalPublishThrottle; client.config.set({ messagePaginator: { pageSize: 33 } }); expect(channel.messagePaginator.config.pageSize).toBe(33); // The old code re-ran the rebuild on every derivation regardless; flushing and swapping a throttle // that nothing changed is pure churn on a path that runs per channel, per registration. - expect(internals._windowPublishThrottle).toBe(before); + expect(internals._activeIntervalPublishThrottle).toBe(before); }); describe('interaction with setup functions', () => { diff --git a/test/unit/pagination/BasePaginator.stateThrottle.test.ts b/test/unit/pagination/BasePaginator.stateThrottle.test.ts index f06d16045d..cc52d0a47c 100644 --- a/test/unit/pagination/BasePaginator.stateThrottle.test.ts +++ b/test/unit/pagination/BasePaginator.stateThrottle.test.ts @@ -72,7 +72,7 @@ describe('BasePaginator.setStateThrottleOptions', () => { const paginator = new MessagePaginator({ channel }); const internals = paginator as unknown as { flushPendingPublishes: () => void; - scheduleWindowPublish: () => void; + scheduleActiveIntervalPublish: () => void; }; const flush = vi.spyOn(internals, 'flushPendingPublishes'); diff --git a/test/unit/pagination/paginator.initializeConfig.test.ts b/test/unit/pagination/paginator.initializeConfig.test.ts index af5b2e0a42..eb06964bbb 100644 --- a/test/unit/pagination/paginator.initializeConfig.test.ts +++ b/test/unit/pagination/paginator.initializeConfig.test.ts @@ -229,13 +229,15 @@ describe('paginator initializeConfig', () => { it('rebuilds the publish throttles on a plain updateConfig', () => { const paginator = new MessagePaginator({ channel }); - const internals = paginator as unknown as { _windowPublishThrottle: unknown }; - const before = internals._windowPublishThrottle; + const internals = paginator as unknown as { + _activeIntervalPublishThrottle: unknown; + }; + const before = internals._activeIntervalPublishThrottle; paginator.updateConfig({ stateThrottleMs: 111 }); expect(paginator.config.stateThrottleMs).toBe(111); - expect(internals._windowPublishThrottle).not.toBe(before); + expect(internals._activeIntervalPublishThrottle).not.toBe(before); }); it('drops the throttles when the interval is cleared', () => { @@ -244,8 +246,8 @@ describe('paginator initializeConfig', () => { paginator.updateConfig({ stateThrottleMs: undefined }); expect( - (paginator as unknown as { _windowPublishThrottle: unknown }) - ._windowPublishThrottle, + (paginator as unknown as { _activeIntervalPublishThrottle: unknown }) + ._activeIntervalPublishThrottle, ).toBeUndefined(); }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index b4cb48f92b..fff23f9d42 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -4939,7 +4939,8 @@ describe('MessagePaginator', () => { const mockGetReplies = () => channel.getReplies as unknown as ReturnType; const flushProbe = (p: MessagePaginator) => - (p as unknown as { _belowWindowReconcile?: Promise })._belowWindowReconcile; + (p as unknown as { _tailwardOfPageReconcile?: Promise }) + ._tailwardOfPageReconcile; const makePaginator = (pageSize: number, parentMessageId?: string) => new MessagePaginator({ channel, From 999accf8ec601aa9a0269cc4fc74a1edfcf24b56 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 22 Oct 2026 14:19:28 +0200 Subject: [PATCH 2/3] docs: point agents to the paginator interval terms AGENTS.md asks for "interval" over "window" and points to BasePaginator's doc comment; CLAUDE.md's paginator notes use the terms. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++++ CLAUDE.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1201ddaa9c..e5738d7d8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,11 @@ Conventions the agent must follow All source is TypeScript under src/. Keep strict types and update .d.ts surface when needed. Run yarn lint-fix and ensure zero ESLint errors (flat config). +Paginators hold their loaded items as **intervals**: call them that in names, comments and docs, +never "windows" ("window" stays for the browser global and time spans). The terms (interval, +head/tail, active interval, anchored head interval, logical head/tail interval) are defined in the +doc comment on `BasePaginator` (`src/pagination/paginators/BasePaginator.ts`). + #### Generated code (src/gen) `src/gen` is generated from the OpenAPI spec by `yarn generate-client`. The only post-processing this repo applies to it is mapping `custom` fields to the `Custom*Data` interfaces (`scripts/apply-custom-data-types.mts`). diff --git a/CLAUDE.md b/CLAUDE.md index 7c8135b9e8..27aa15bb0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, config?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` (the active window) / `.getItem(id)` / `.latestMessage` (the channel's latest message, skipping what does not count as one, e.g. thread-only replies) / `.anchoredHeadItems` and `.anchoredHeadItem` (the loaded window that reaches the newest message, and its newest item; empty and `undefined` while only an older, jumped-to window is loaded), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp is `channel.messagePaginator.latestMessageAt`: the later of `latestMessage.created_at` and `queriedLatestMessageAt`, the `last_message_at` of the latest channel query, which comes without the message (both live on its `aggregateState` store; events only ever update `latestMessage`). See `ai-docs/v10-breaking-changes.md#channel-state`. +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` (the active interval) / `.getItem(id)` / `.latestMessage` (the channel's latest message, skipping what does not count as one, e.g. thread-only replies) / `.anchoredHeadItems` and `.anchoredHeadItem` (the anchored head interval, the loaded interval that reaches the newest message, and its newest item; empty and `undefined` while only an older, jumped-to interval is loaded), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp is `channel.messagePaginator.latestMessageAt`: the later of `latestMessage.created_at` and `queriedLatestMessageAt`, the `last_message_at` of the latest channel query, which comes without the message (both live on its `aggregateState` store; events only ever update `latestMessage`). See `ai-docs/v10-breaking-changes.md#channel-state`. - **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `ai-docs/v10-breaking-changes.md#channel-manager`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers and ordering comes from a comparator compiled from `sort`. The manager is instantiated by the `StreamChat` constructor and lives as long as the client (`client.channelManager`) — it is not configurable through the client options; register lists with `insertPaginator({ paginator, index? })`, detach them with `removePaginator(paginatorOrId)` and set cross-list ownership with `setOwnershipResolver(resolverOrPriorityIds?)`. `setPaginators(paginators)` is the primitive the other two build on — use it (or `clearPaginators()`) for batches, since it publishes one state update instead of one per paginator, and skips the update entirely when the set is unchanged. Registration and loaded data have different owners: `disconnectUser` calls `resetPaginatorStates()`, which discards each list's channels (they belong to the user going away) while leaving the lists themselves registered, since which lists exist is the integrator's configuration. Event handling stays customizable: `ChannelManagerOptions.eventHandlers` replaces the default map wholesale at construction (start from `getDefaultHandlers()` to enrich it instead), and `addEventHandler` / `setEventHandlers` / `removeEventHandlers` adjust the pipelines afterwards — which is the only route for `client.channelManager`, since the client constructs it without options. The exported `ignoreEventsForUnknownChannels` handler, inserted at `index: 0`, is how a list opts out of pulling in channels it has not loaded. - **`connection/wsConnection/StableWSConnection.ts` (`StableWSConnection`)** — the realtime socket. With `client.wsConnection.config.enableWSFallback` (a `wsConnection` config key; a `StreamChatOptions` entry in v9), `client.wsConnection.connect()` switches to v9's `WSConnectionFallback` (`src/connection/wsConnection/WSConnectionFallback.ts`, long-poll on `/api/v2/longpoll`, held on `client.wsConnection.fallback`) when the socket fails with a network error — which it reports after the full `connectTimeoutMs`, where v9 cut it to 6s — dispatches `connection.fallback_activated`, and stays on it, across `disconnectUser()` too. That class is v9's, adapted only where v10 changed what it relied on: it writes `client.wsConnection.state`, drives `ConnectionIdManager`, takes network status from `WSConnection`'s one subscription through `_applyNetworkStatus` (ignored while disconnected, where v9 stopped listening for good at its first disconnect), exposes `isConnecting`, and sets `connection_id` on its own polls and close. It calls the generated `client.longPoll()`, with per-request `timeout`s through `StreamRequestOptions` and an `AbortController` for cancellation; `ApiClient.resolveUrl` maps a local API's `:3030` to `:8900` for that path. The socket connects to `/api/v2/connect`, which authenticates off the **first frame the client sends** (`client._buildWSAuthMessage()`) rather than the query string, and answers with a `connection.ok` hello event instead of v1's `health.check`. It runs a 25s ping and a 35s connection check, and reconnects on close/error, publishing every status transition into `client.wsConnection.state`. `connection.ok` (`ConnectedEvent`) and `connection.error` (`ConnectionErrorEvent`) come from the generated `WSEvent` union like every other wire event. **It no longer registers `window` listeners.** The offline/online edge now arrives from `client.networkConnection` (see below), which inverts the old dependency: the socket is a _consumer_ of network status rather than the thing that detects it. Its own settings — connect timeout, `enableWSFallback`, ping interval, connection-check grace period, the UI's offline-notification delay, `webSocketImpl`, `urlParams` and an injectable socket — live in `client.wsConnection.config`, not in `StreamChatOptions`; `client.defaultWSTimeout` is gone. Every instance is created by `WSConnection.connect()`, which also disconnects the one it replaces, and read live from that config so an `updateConfig` reaches an already-open socket. It holds its `WSConnection` parent rather than the client, and reaches the client through it. From 13218959a25e06a0746f0812b6526da223c839de Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 22 Oct 2026 14:25:55 +0200 Subject: [PATCH 3/3] refactor: name the tailward-of-page removal after what it does pruneTailwardOfPageIfReachedStart -> removeIdsTailwardOfPageIfNothingOlder: it asks the server whether anything is older than the page's oldest message and removes the loaded messages tailward of the page only when nothing is. Its variables name what they hold (pageTailId, headIdBeforeRequest, hasMessagesOlderThanPage, headAfterRequest), and the pending request is _pendingTailwardOfPageRemoval. Co-Authored-By: Claude Opus 5.5 --- .../paginators/MessageIntervalPaginator.ts | 60 ++++++++++--------- .../paginators/MessagePaginator.test.ts | 4 +- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 7895dd7d36..771fab2189 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -165,8 +165,8 @@ export type ReconcileNewestMessagesOptions = { * says whether `page` reached the channel start (see * {@link MessageIntervalPaginator.pageReachedChannelStart}, which ignores a limit above * `pageSize`), deciding `hasMoreTail`, whether `page` can be separated from the head interval by - * a gap, and whether a reload probes for messages deleted below the page (see - * {@link MessageIntervalPaginator.pruneTailwardOfPageIfReachedStart}). On its own it never + * a gap, and whether a reload asks the server about messages deleted below the page (see + * {@link MessageIntervalPaginator.removeIdsTailwardOfPageIfNothingOlder}). On its own it never * removes a message. Pass the limit actually sent: a larger one makes a short page look like the * channel start, welding it to the head interval across a real gap. */ @@ -246,8 +246,8 @@ export class MessageIntervalPaginator extends BasePaginator< private messageFocusSignalToken = 0; protected _requestSort = DEFAULT_BACKEND_SORT; protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; - /** Pending below-interval reached-start probe (exposed so tests can await it). */ - private _tailwardOfPageReconcile?: Promise; + /** The pending {@link removeIdsTailwardOfPageIfNothingOlder} (kept so tests can await it). */ + private _pendingTailwardOfPageRemoval?: Promise; /** * The ID of the newest server-confirmed message of the interval whose head mark was cleared last * (see {@link unmarkedHeadInterval}). Forgotten once a newest page is reconciled with no head @@ -1100,7 +1100,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.showHeadInterval(page, options); return; } - // Inputs for the below-interval probe, captured before the merge overwrites state. + // Inputs for removeIdsTailwardOfPageIfNothingOlder, captured before the merge overwrites state. const wasAtChannelStart = !this.state.getLatestValue().hasMoreTail; const preMergeLoadedCount = headInterval.itemIds.length; @@ -1136,9 +1136,9 @@ export class MessageIntervalPaginator extends BasePaginator< const idsTailwardOfPage = this.reconcileHeadAgainstPage(page, options); - // The leftovers are ambiguous (hard-deleted oldest vs server-capped over-request). Only a - // full-interval reload (`requestedLimit >= loaded`, not the small list hydrate) that believed it - // was at the start can settle them: probe then, otherwise skip. + // Messages tailward of the page may be deleted or just older than the page reached. Only a reload + // of the whole head interval (`requestedLimit >= loaded`, not the small list hydrate) that was at + // the channel start can settle them, by asking the server; otherwise they are kept. const requestedLimit = options?.requestedLimit; if ( idsTailwardOfPage.length && @@ -1146,7 +1146,7 @@ export class MessageIntervalPaginator extends BasePaginator< typeof requestedLimit === 'number' && requestedLimit >= preMergeLoadedCount ) { - this._tailwardOfPageReconcile = this.pruneTailwardOfPageIfReachedStart( + this._pendingTailwardOfPageRemoval = this.removeIdsTailwardOfPageIfNothingOlder( idsTailwardOfPage, this.getItemId(page[0]), ); @@ -1542,26 +1542,30 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Settles the below-interval leftovers {@link reconcileHeadAgainstPage} returned (loaded messages - * older than `anchorId`, the page's oldest, and absent from it): from the page alone a - * hard-deleted oldest looks like a server-capped over-request. It asks the server whether anything - * is older than `anchorId`; if not, they are real deletes and are removed and the tail settled. A - * probe failure or a head change across the await keeps them. The caller decides when this runs. + * Settles the loaded messages {@link reconcileHeadAgainstPage} found tailward of the page (older + * than `pageTailId`, the page's oldest, and absent from it): from the page alone a hard-deleted + * oldest message looks like one the page was too short to include. It asks the server whether + * anything is older than `pageTailId`. When nothing is, those messages were deleted: they are + * removed and the head interval becomes the tail. A failed request, or a head interval replaced + * while waiting, keeps them. The caller decides when this runs. */ - private async pruneTailwardOfPageIfReachedStart( + private async removeIdsTailwardOfPageIfNothingOlder( idsTailwardOfPage: string[], - anchorId: string, + pageTailId: string, ): Promise { - const headId = (this.itemIntervals[0] as Interval | undefined)?.id; - // Something older exists (or the probe failed): the start is not proven, keep the leftovers. - if (await this.hasMessagesOlderThan(anchorId).catch(() => true)) return; - // Revalidate the head across the await (a jump, reset or newer merge aborts). - const head = this.itemIntervals[0] as Interval | undefined; - if (!head?.isHead || head.id !== headId) return; + const headIdBeforeRequest = (this.itemIntervals[0] as Interval | undefined)?.id; + // A failed request proves nothing, so it counts as "something older exists". + const hasMessagesOlderThanPage = await this.hasMessagesOlderThan(pageTailId).catch( + () => true, + ); + if (hasMessagesOlderThanPage) return; + // A jump, a reset or another newest page may have replaced the head interval meanwhile. + const headAfterRequest = this.itemIntervals[0] as Interval | undefined; + if (!headAfterRequest?.isHead || headAfterRequest.id !== headIdBeforeRequest) return; this.removeReconciledItems(idsTailwardOfPage); - head.hasMoreTail = false; - head.isTail = true; - if (this.isActiveInterval(head)) { + headAfterRequest.hasMoreTail = false; + headAfterRequest.isTail = true; + if (this.isActiveInterval(headAfterRequest)) { this.state.partialNext({ hasMoreTail: false, cursor: { headward: null, tailward: null }, @@ -1652,7 +1656,7 @@ export class MessageIntervalPaginator extends BasePaginator< * arrival or a send confirmed during the request. * * Messages below `page`'s oldest are kept (a hard-deleted oldest looks like a server-capped - * over-request) and returned for {@link pruneTailwardOfPageIfReachedStart}. Removal goes through + * over-request) and returned for {@link removeIdsTailwardOfPageIfNothingOlder}. Removal goes through * {@link removeItem}, so the index, intervals, message store and any subclass bookkeeping (e.g. * the latest message of {@link MessagePaginator}) stay correct. * @@ -1683,7 +1687,7 @@ export class MessageIntervalPaginator extends BasePaginator< const newestReturnedTs = getMessageCreatedAtTimestamp(page[page.length - 1]); const oldestReturnedTs = getMessageCreatedAtTimestamp(page[0]); - // Nothing below the page's oldest is removed here: those go to `idsTailwardOfPage` for the probe. + // Nothing below the page's oldest is removed here: those go to `idsTailwardOfPage`. const pageTailTs = oldestReturnedTs ?? Number.POSITIVE_INFINITY; const toRemove: string[] = []; @@ -1703,7 +1707,7 @@ export class MessageIntervalPaginator extends BasePaginator< continue; } // Within the page's span a live arrival cannot be here, so absence means a hard delete. At or - // below the page's oldest the page cannot decide: return it for the reached-start probe. + // below the page's oldest the page cannot decide: return it for the caller to settle. if (ts > pageTailTs) toRemove.push(id); else idsTailwardOfPage.push(id); } diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index fff23f9d42..bd3e70367e 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -4939,8 +4939,8 @@ describe('MessagePaginator', () => { const mockGetReplies = () => channel.getReplies as unknown as ReturnType; const flushProbe = (p: MessagePaginator) => - (p as unknown as { _tailwardOfPageReconcile?: Promise }) - ._tailwardOfPageReconcile; + (p as unknown as { _pendingTailwardOfPageRemoval?: Promise }) + ._pendingTailwardOfPageRemoval; const makePaginator = (pageSize: number, parentMessageId?: string) => new MessagePaginator({ channel,