diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts new file mode 100644 index 000000000..6eceb7b1a --- /dev/null +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -0,0 +1,335 @@ +import { EventHandlerPipeline } from './EventHandlerPipeline'; +import { WithSubscriptions } from './utils/WithSubscriptions'; +import type { Event, EventTypes } from './types'; +import type { ChannelPaginator } from './pagination'; +import type { StreamChat } from './client'; +import type { Unsubscribe } from './store'; +import { StateStore } from './store'; +import type { + EventHandlerPipelineHandler, + InsertEventHandlerPayload, + LabeledEventHandler, +} from './EventHandlerPipeline'; +import { getChannel } from './pagination/utility.queryChannel'; +import type { Channel } from './channel'; + +export type ChannelPaginatorsOrchestratorEventHandlerContext = { + orchestrator: ChannelPaginatorsOrchestrator; +}; + +type SupportedEventType = EventTypes | (string & {}); + +const reEmit: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = ({ event, ctx: { orchestrator } }) => { + if (!event.cid) return; + const channel = orchestrator.client.activeChannels[event.cid]; + if (!channel) return; + orchestrator.paginators.forEach((paginator) => { + const items = paginator.items; + if (paginator.findItem(channel) && items) { + paginator.state.partialNext({ items: [...items] }); + } + }); +}; + +const removeItem: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = ({ event, ctx: { orchestrator } }) => { + if (!event.cid) return; + const channel = orchestrator.client.activeChannels[event.cid]; + orchestrator.paginators.forEach((paginator) => { + paginator.removeItem({ id: event.cid, item: channel }); + }); +}; + +const updateLists: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = async ({ event, ctx: { orchestrator } }) => { + let channel: Channel | undefined = undefined; + if (event.cid) { + channel = orchestrator.client.activeChannels[event.cid]; + } else if (event.channel_id && event.channel_type) { + // todo: is there a central method to construct the cid from type and channel id? + channel = + orchestrator.client.activeChannels[`${event.channel_type}:${event.channel_id}`]; + } else if (event.channel) { + channel = orchestrator.client.activeChannels[event.channel.cid]; + } else { + return; + } + + if (!channel) { + const [type, id] = event.cid + ? event.cid.split(':') + : [event.channel_type, event.channel_id]; + + channel = await getChannel({ + client: orchestrator.client, + id, + type, + }); + } + + if (!channel) return; + + orchestrator.paginators.forEach((paginator) => { + if (paginator.matchesFilter(channel)) { + const channelBoost = paginator.getBoost(channel.cid); + if ( + [ + 'message.new', + 'notification.message_new', + 'notification.added_to_channel', + 'channel.visible', + ].includes(event.type) && + (!channelBoost || channelBoost.seq < paginator.maxBoostSeq) + ) { + paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 }); + } + paginator.ingestItem(channel); + } else { + // remove if it does not match the filter anymore + paginator.removeItem({ item: channel }); + } + }); +}; + +// we have to make sure that client.activeChannels is always up-to-date +const channelDeletedHandler: LabeledEventHandler = + { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', + }; + +// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state, +// we need to re-emit the whole list to reflect the changes +const channelUpdatedHandler: LabeledEventHandler = + { + handle: reEmit, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.updated', + }; + +// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state, +// we need to re-emit the whole list to reflect the changes +const channelTruncatedHandler: LabeledEventHandler = + { + handle: reEmit, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.truncated', + }; + +const channelVisibleHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', + }; + +// members filter - should not be impacted as id is stable - cannot be updated +// member.user.name - can be impacted +const memberUpdatedHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:member.updated', + }; + +const messageNewHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:message.new', + }; + +const notificationAddedToChannelHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel', + }; + +const notificationMessageNewHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new', + }; + +const notificationRemovedFromChannelHandler: LabeledEventHandler = + { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.removed_from_channel', + }; + +// fixme: updates users for member object in all the channels which are loaded with that member - normalization would be beneficial +const userPresenceChangedHandler: LabeledEventHandler = + { + handle: ({ event, ctx: { orchestrator } }) => { + const eventUser = event.user; + if (!eventUser?.id) return; + orchestrator.paginators.forEach((paginator) => { + const paginatorItems = paginator.items; + if (!paginatorItems) return; + let updated = false; + paginatorItems.forEach((channel) => { + if (channel.state.members[eventUser.id]) { + channel.state.members[eventUser.id].user = event.user; + updated = true; + } + if (channel.state.membership.user?.id === eventUser.id) { + channel.state.membership.user = eventUser; + updated = true; + } + }); + if (updated) { + // fixme: user is not reactive and so the whole list has to be re-rendered + paginator.state.partialNext({ items: [...paginatorItems] }); + } + }); + }, + id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed', + }; + +export type ChannelPaginatorsOrchestratorState = { + paginators: ChannelPaginator[]; +}; + +export type ChannelPaginatorsOrchestratorEventHandlers = Partial< + Record< + SupportedEventType, + LabeledEventHandler[] + > +>; + +export type ChannelPaginatorsOrchestratorOptions = { + client: StreamChat; + paginators?: ChannelPaginator[]; + eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers; +}; + +export class ChannelPaginatorsOrchestrator extends WithSubscriptions { + client: StreamChat; + state: StateStore; + protected pipelines = new Map< + SupportedEventType, + EventHandlerPipeline + >(); + + protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = + { + 'channel.deleted': [channelDeletedHandler], + 'channel.updated': [channelUpdatedHandler], + 'channel.truncated': [channelTruncatedHandler], + 'channel.visible': [channelVisibleHandler], + 'member.updated': [memberUpdatedHandler], + 'message.new': [messageNewHandler], + 'notification.added_to_channel': [notificationAddedToChannelHandler], + 'notification.message_new': [notificationMessageNewHandler], + 'notification.removed_from_channel': [notificationRemovedFromChannelHandler], + 'user.presence.changed': [userPresenceChangedHandler], + }; + + constructor({ + client, + eventHandlers, + paginators, + }: ChannelPaginatorsOrchestratorOptions) { + super(); + this.client = client; + this.state = new StateStore({ paginators: paginators ?? [] }); + const finalEventHandlers = + eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers(); + for (const [type, handlers] of Object.entries(finalEventHandlers)) { + if (handlers) this.ensurePipeline(type).replaceAll(handlers); + } + } + + get paginators(): ChannelPaginator[] { + return this.state.getLatestValue().paginators; + } + + private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext { + return { orchestrator: this }; + } + + /** + * Returns deep copy of default handlers mapping. + * The defaults can be enriched with custom handlers or the custom handlers can be replaced. + */ + static getDefaultHandlers(): ChannelPaginatorsOrchestratorEventHandlers { + const src = ChannelPaginatorsOrchestrator.defaultEventHandlers; + const out: ChannelPaginatorsOrchestratorEventHandlers = {}; + for (const [type, handlers] of Object.entries(src)) { + if (!handlers) continue; + out[type as SupportedEventType] = [...handlers]; + } + return out; + } + + getPaginatorById(id: string) { + return this.paginators.find((p) => p.id === id); + } + + /** + * If paginator already exists → remove old, reinsert at new index. + * If index not provided → append at the end. + * If index provided → insert (or move) at that index. + * @param paginator + * @param index + */ + insertPaginator({ paginator, index }: { paginator: ChannelPaginator; index?: number }) { + const paginators = [...this.paginators]; + const existingIndex = paginators.findIndex((p) => p.id === paginator.id); + if (existingIndex > -1) { + paginators.splice(existingIndex, 1); + } + const validIndex = Math.max( + 0, + Math.min(index ?? paginators.length, paginators.length), + ); + paginators.splice(validIndex, 0, paginator); + this.state.partialNext({ paginators }); + } + + addEventHandler({ + eventType, + ...payload + }: { + eventType: SupportedEventType; + } & InsertEventHandlerPayload): Unsubscribe { + return this.ensurePipeline(eventType).insert(payload); + } + + /** Subscribe to WS (and more buses via attachBus) */ + registerSubscriptions(): Unsubscribe { + if (!this.hasSubscriptions) { + this.addUnsubscribeFunction( + // todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client + this.client.on((event: Event) => { + const pipe = this.pipelines.get(event.type); + if (pipe) { + pipe.run(event, this.ctx); + } + }).unsubscribe, + ); + } + + this.incrementRefCount(); + return () => this.unregisterSubscriptions(); + } + + ensurePipeline( + eventType: SupportedEventType, + ): EventHandlerPipeline { + let pipe = this.pipelines.get(eventType); + if (!pipe) { + pipe = new EventHandlerPipeline({ + id: `ChannelPaginatorsOrchestrator:${eventType}`, + }); + this.pipelines.set(eventType, pipe); + } + return pipe; + } + + reload = async () => + await Promise.allSettled( + this.paginators.map(async (paginator) => { + await paginator.reload(); + }), + ); +} diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts new file mode 100644 index 000000000..c2b63b976 --- /dev/null +++ b/src/EventHandlerPipeline.ts @@ -0,0 +1,163 @@ +import { generateUUIDv4 } from './utils'; +import type { Event } from './types'; +import type { Unsubscribe } from './store'; + +export type EventHandlerResult = { action: 'stop' }; // event processing run will be cancelled + +export type InsertEventHandlerPayload> = { + handle: EventHandlerPipelineHandler; + index?: number; + id?: string; + replace?: boolean; + revertOnUnsubscribe?: boolean; +}; + +export type EventHandlerPipelineHandler> = (payload: { + event: Event; + ctx: CTX; +}) => EventHandlerResult | void | Promise; + +export type LabeledEventHandler> = { + handle: EventHandlerPipelineHandler; + id?: string; +}; + +export class EventHandlerPipeline = {}> { + id: string; + protected handlers: LabeledEventHandler[] = []; + private runnerExecutionPromise = Promise.resolve(); + + constructor({ id }: { id: string }) { + this.id = id; + } + + get size(): number { + return this.handlers.length; + } + + /** + * Insert a handler into the pipeline at the given index. + * + * - If `replace` is `true` and the index is within bounds, the existing handler + * at that position will be replaced by the new one. + * - If `revertOnUnsubscribe` is also `true`, then calling the returned + * unsubscribe will both remove the inserted handler *and* restore the + * previously replaced handler at the same index. + * - If `replace` is `false` (default), the new handler is inserted at the index + * (or appended if the index is greater than the pipeline size). Unsubscribe + * will only remove this handler. + * + * @param handler The handler function to insert. + * @param index Target index in the pipeline (clamped to valid range). + * @param replace If true, replace existing handler at index instead of inserting. + * @param revertOnUnsubscribe If true, restore the replaced handler when unsubscribing. + * @returns An unsubscribe function that removes (and optionally restores) the handler. + */ + + insert({ + handle, + id, + index, + replace = false, + revertOnUnsubscribe, + }: InsertEventHandlerPayload): Unsubscribe { + const validIndex = Math.max( + 0, + Math.min(index ?? this.handlers.length, this.handlers.length), + ); + const handler: LabeledEventHandler = { + handle, + id: id ?? generateUUIDv4(), + }; + + if (replace && validIndex < this.handlers.length) { + const old = this.handlers[validIndex]; + this.handlers[validIndex] = handler; + return () => { + this.remove(handler); + if (revertOnUnsubscribe) this.handlers.splice(validIndex, 0, old); + }; + } else { + this.handlers.splice(validIndex, 0, handler); + return () => this.remove(handler); + } + } + + remove(h: LabeledEventHandler | EventHandlerPipelineHandler): void { + const index = this.handlers.findIndex((handler) => + typeof (h as LabeledEventHandler).handle === 'function' + ? (h as LabeledEventHandler).handle === handler.handle + : h === handler.handle, + ); + if (index >= 0) this.handlers.splice(index, 1); + } + + replaceAll(handlers: LabeledEventHandler[]): void { + this.handlers = handlers.slice(); + } + + clear(): void { + this.handlers = []; + } + + /** + * Queue an event for processing. Events are processed serially, in the order + * `run` is called. Returns a promise that resolves/rejects for this specific + * event’s processing, while the internal chain continues (errors won’t break it). + */ + run(event: Event, ctx: CTX): Promise { + let resolveTask!: () => void; + let rejectTask!: (e: unknown) => void; + // Per-task promise the caller can await + const taskPromise = new Promise((res, rej) => { + resolveTask = res; + rejectTask = rej; + }); + + // Queue this event’s work + this.runnerExecutionPromise = this.runnerExecutionPromise + .then(async () => { + try { + await this.processOne(event, ctx); + resolveTask(); + } catch (e) { + // Reject this task’s promise, but keep the chain alive. + rejectTask(e); + } + }) + .catch((e) => { + console.error(`[pipeline:${this.id}] execution error`, e); + // Ensure the chain remains resolved for the next enqueue: + this.runnerExecutionPromise = Promise.resolve(); + }); + + return taskPromise; + } + + /** + * Wait until all queued events have been processed. + */ + async drain(): Promise { + await this.runnerExecutionPromise; + } + + /** + * Process a single event through a stable snapshot of handlers to avoid + * mid-iteration mutations (insert/remove) affecting this run. + */ + private async processOne(event: Event, ctx: CTX): Promise { + const snapshot = this.handlers.slice(); + for (let i = 0; i < snapshot.length; i++) { + const handler = snapshot[i]; + try { + const result = await handler.handle({ event, ctx }); + if (result?.action === 'stop') return; + } catch { + console.error(`[pipeline:${this.id}] handler failed`, { + handlerId: handler.id ?? 'unknown', + handlerIndex: i, + }); + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 202df56fb..882f699c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,3 +57,5 @@ export { promoteChannel, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +export * from './ChannelPaginatorsOrchestrator'; +export * from './EventHandlerPipeline'; diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index 7f73f0f53..18e96f087 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -1,9 +1,15 @@ +import { binarySearchInsertIndex } from './sortCompiler'; +import { itemMatchesFilter } from './filterCompiler'; import { StateStore } from '../store'; import { debounce, type DebouncedFunc } from '../utils'; +import type { FieldToDataResolver } from './types.normalization'; +import { locateOnPlateauAlternating, locateOnPlateauScanOneSide } from './utility.search'; + +const noOrderChange = () => 0; type PaginationDirection = 'next' | 'prev'; type Cursor = { next: string | null; prev: string | null }; -export type PaginationQueryParams = { direction: PaginationDirection }; +export type PaginationQueryParams = { direction?: PaginationDirection }; export type PaginationQueryReturnValue = { items: T[] } & { next?: string; prev?: string; @@ -29,24 +35,60 @@ export type PaginatorState = { export type PaginatorOptions = { /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ debounceMs?: number; + /** Will prevent changing the index of existing items */ + lockItemOrder?: boolean; pageSize?: number; }; export const DEFAULT_PAGINATION_OPTIONS: Required = { debounceMs: 300, + lockItemOrder: false, pageSize: 10, } as const; export abstract class BasePaginator { state: StateStore>; - pageSize: number; + config: Required; protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; + /** + * Comparison function used to keep items in a paginator sorted. + * + * The comparator must follow the standard contract of `Array.prototype.sort`: + * - return a negative number if `a` should come before `b` + * - return a positive number if `a` should come after `b` + * - return 0 if they are considered equal for ordering + * + * Typical implementations are generated from a "sort spec" (e.g. `{ field: 1, otherField: -1 }`) + * so that insertion and pagination can maintain the same order as the backend. + * + * Notes: + * - The comparator must be deterministic: the same inputs always return + * the same result. + * - If multiple fields are used, they are evaluated in order of normalized sort ({ direction: AscDesc; field: keyof T }[]) + * until a non-zero comparison is found. + * - Equality (0) does not imply object identity; it only means neither item + * is considered greater than the other by the sort rules. + */ + sortComparator: (a: T, b: T) => number; + /** + * Allows defining data extraction logic for filter fields like member.user.name or members + * @protected + */ + protected _filterFieldToDataResolvers: FieldToDataResolver[]; + /** + * Ephemeral priority for attention UX without breaking sort invariants + * @protected + */ + protected boosts = new Map(); + protected _maxBoostSeq: number = 0; protected constructor(options?: PaginatorOptions) { - const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; - this.pageSize = pageSize; + this.config = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; + const { debounceMs } = this.config; this.state = new StateStore>(this.initialState); this.setDebounceOptions({ debounceMs }); + this.sortComparator = noOrderChange; + this._filterFieldToDataResolvers = []; } get lastQueryError() { @@ -93,10 +135,251 @@ export abstract class BasePaginator { return this.state.getLatestValue().offset; } + get pageSize() { + return this.config.pageSize; + } + + /** Single point of truth: always use the effective comparator */ + get effectiveComparator() { + return this.boostComparator; + } + + get maxBoostSeq() { + return this._maxBoostSeq; + } + abstract query(params: PaginationQueryParams): Promise>; abstract filterQueryResults(items: T[]): T[] | Promise; + protected buildFilters(): object | null { + return null; // === no filters' + } + + getItemId(item: T): string { + return (item as { id: string }).id; + } + + matchesFilter(item: T): boolean { + const filters = this.buildFilters(); + + // no filters => accept all + if (filters == null) return true; + + return itemMatchesFilter(item, filters, { + resolvers: this._filterFieldToDataResolvers, + }); + } + + protected clearExpiredBoosts(now = Date.now()) { + for (const [id, b] of this.boosts) if (now > b.until) this.boosts.delete(id); + this._maxBoostSeq = Math.max( + ...Array.from(this.boosts.values()).map((boost) => boost.seq), + 0, + ); + } + + /** Comparator that consults boosts first, then falls back to sortComparator */ + protected boostComparator = (a: T, b: T): number => { + const now = Date.now(); + this.clearExpiredBoosts(now); + + const idA = this.getItemId(a); + const idB = this.getItemId(b); + const boostA = this.getBoost(idA); + const boostB = this.getBoost(idB); + + const aIsBoosted = !!(boostA && now <= boostA.until); + const bIsBoosted = !!(boostB && now <= boostB.until); + + if (aIsBoosted && !bIsBoosted) return -1; + if (!aIsBoosted && bIsBoosted) return 1; + + if (aIsBoosted && bIsBoosted) { + // higher seq wins + const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0); + if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1; + // fall through to normal comparator for stability + } + return this.sortComparator(a, b); + }; + + /** Public API to manage boosts */ + boost(id: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { + const now = Date.now(); + const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000); // default 15s + + if (typeof opts?.seq === 'number' && opts.seq > this._maxBoostSeq) { + this._maxBoostSeq = opts.seq; + } + + const seq = opts?.seq ?? 0; + this.boosts.set(id, { until, seq }); + } + + getBoost(id: string) { + return this.boosts.get(id); + } + + removeBoost(id: string) { + this.boosts.delete(id); + this._maxBoostSeq = Math.max( + ...Array.from(this.boosts.values()).map((boost) => boost.seq), + 0, + ); + } + + isBoosted(id: string) { + const boost = this.getBoost(id); + return !!(boost && Date.now() <= boost.until); + } + + ingestItem(ingestedItem: T): boolean { + const items = this.items ?? []; + const id = this.getItemId(ingestedItem); + const next = items.slice(); + // If it doesn't match this paginator's filters, remove if present and exit. + const existingIndex = items.findIndex((ch) => this.getItemId(ch) === id); + if (!this.matchesFilter(ingestedItem)) { + if (existingIndex >= 0) { + next.splice(existingIndex, 1); + this.state.partialNext({ items: next }); + return true; // list changed (item removed) + } + return false; // no change + } + + if (existingIndex >= 0) { + // Update existing: remove then re-insert at the correct position + next.splice(existingIndex, 1); + } + + const insertAt = + this.config.lockItemOrder && existingIndex >= 0 + ? existingIndex + : // Find insertion index via binary search: first index where existing > ingestionItem + binarySearchInsertIndex({ + needle: ingestedItem, + sortedArray: next, + compare: this.effectiveComparator, + }); + + next.splice(insertAt, 0, ingestedItem); + this.state.partialNext({ items: next }); + return true; // list changed (added or repositioned) + } + + /** + * Removes item from the paginator's state. + * It is preferable to provide item for better search performance. + * @param id + * @param item + */ + removeItem({ id, item }: { id?: string; item?: T }): boolean { + if (!id && !item) return false; + let index: number; + if (item) { + const location = this.locateByItem(item); + index = location.index; + } else { + index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; + } + + if (index === -1) return false; + const newItems = [...(this.items ?? [])]; + newItems.splice(index, 1); + this.state.partialNext({ items: newItems }); + return true; + } + + contains(item: T): boolean { + return !!this.items?.find((i) => this.getItemId(i) === this.getItemId(item)); + } + + /** + * Find the exact index of `needle` by ID (via getItemId) under the current sortComparator. + * Returns: + * - `index`: actual index if found, otherwise -1 + * - `insertionIndex`: lower-bound position where `needle` would be inserted + * to preserve order (always defined). + * + * Time: O(log n) + O(k) for a tie plateau of size k (unless comparator has ID tiebreaker). + * + * ### Usage examples + * + * ```ts + * const { index, insertionIndex } = paginator.locateByItem(channel); + * + * if (index > -1) { + * // Found -> e.g. remove the item + * items.splice(index, 1); + * } else { + * // Insert new at the right position + * items.splice(insertionIndex, 0, channel); + * } + * ``` + */ + public locateByItem( + needle: T, + options?: { alternatePlateauScan?: boolean }, + ): { index: number; insertionIndex: number } { + const items = this.items ?? []; + if (items.length === 0) return { index: -1, insertionIndex: 0 }; + + const insertionIndex = binarySearchInsertIndex({ + needle, + sortedArray: items, + compare: this.effectiveComparator, + }); + + // quick neighbor checks + const id = this.getItemId(needle); + const left = insertionIndex - 1; + if (left >= 0 && this.effectiveComparator(items[left], needle) === 0) { + if (this.getItemId(items[left]) === id) return { index: left, insertionIndex }; + } + if ( + insertionIndex < items.length && + this.effectiveComparator(items[insertionIndex], needle) === 0 + ) { + if (this.getItemId(items[insertionIndex]) === id) + return { index: insertionIndex, insertionIndex }; + } + + // plateau scan + const index = + (options?.alternatePlateauScan ?? true) + ? locateOnPlateauAlternating( + items, + needle, + this.effectiveComparator, + this.getItemId.bind(this), + insertionIndex, + ) + : locateOnPlateauScanOneSide( + items, + needle, + this.effectiveComparator, + this.getItemId.bind(this), + insertionIndex, + ); + + return { index, insertionIndex }; + } + + findItem(needle: T, options?: { alternatePlateauScan?: boolean }): T | undefined { + const { index } = this.locateByItem(needle, options); + return index > -1 ? (this.items ?? [])[index] : undefined; + } + + setFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers = resolvers; + } + + addFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers.push(...resolvers); + } + setDebounceOptions = ({ debounceMs }: PaginatorDebounceOptions) => { this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs); }; @@ -181,4 +464,9 @@ export abstract class BasePaginator { prevDebounced = () => { this._executeQueryDebounced({ direction: 'prev' }); }; + + reload = async () => { + this.resetState(); + await this.next(); + }; } diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/ChannelPaginator.ts new file mode 100644 index 000000000..559e7b31c --- /dev/null +++ b/src/pagination/ChannelPaginator.ts @@ -0,0 +1,206 @@ +import type { + PaginationQueryParams, + PaginationQueryReturnValue, + PaginatorOptions, + PaginatorState, +} from './BasePaginator'; +import { BasePaginator } from './BasePaginator'; +import type { FilterBuilderOptions } from './FilterBuilder'; +import { FilterBuilder } from './FilterBuilder'; +import { makeComparator } from './sortCompiler'; +import { generateUUIDv4 } from '../utils'; +import type { StreamChat } from '../client'; +import type { Channel } from '../channel'; +import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { FieldToDataResolver, PathResolver } from './types.normalization'; +import { resolveDotPathValue } from './utility.normalization'; + +const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} + +export type ChannelPaginatorState = PaginatorState; + +export type ChannelPaginatorRequestOptions = Partial< + Omit +>; + +export type ChannelPaginatorOptions = { + client: StreamChat; + filterBuilderOptions?: FilterBuilderOptions; + filters?: ChannelFilters; + id?: string; + paginatorOptions?: PaginatorOptions; + requestOptions?: ChannelPaginatorRequestOptions; + sort?: ChannelSort | ChannelSort[]; +}; + +const pinnedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'pinned', + resolve: (channel) => !!channel.state.membership.pinned_at, +}; + +const membersFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'members', + resolve: (channel) => + channel.state.members + ? Object.values(channel.state.members).reduce((ids, member) => { + if (member.user?.id) { + ids.push(member.user?.id); + } + return ids; + }, []) + : [], +}; + +const memberUserNameFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'member.user.name', + resolve: (channel) => + channel.state.members + ? Object.values(channel.state.members).reduce((names, member) => { + if (member.user?.name) { + names.push(member.user.name); + } + return names; + }, []) + : [], +}; + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (channel, path) => resolveDotPathValue(channel.data, path), +}; + +// very, very unfortunately channel data is dispersed btw Channel.data and Channel.state +const channelSortPathResolver: PathResolver = (channel, path) => { + switch (path) { + case 'last_message_at': + return channel.state.last_message_at; + case 'has_unread': { + const userId = channel.getClient().user?.id; + return !!(userId && channel.state.read[userId].unread_messages); + } + case 'last_updated': { + // combination of last_message_at and updated_at + const lastMessageAt = channel.state.last_message_at?.getTime() ?? 0; + const updatedAt = channel.data?.updated_at + ? new Date(channel.data?.updated_at).getTime() + : 0; + return lastMessageAt >= updatedAt ? lastMessageAt : updatedAt; + } + case 'pinned_at': + return channel.state.membership.pinned_at; + case 'unread_count': { + const userId = channel.getClient().user?.id; + return userId ? channel.state.read[userId].unread_messages : 0; + } + default: + return resolveDotPathValue(channel.data, path); + } +}; + +// todo: maybe items could be just an array of {cid: string} and the data would be retrieved from client.activeChannels +// todo: maybe we should introduce client._cache.channels that would be reactive and orchestrator would subscribe to client._cache.channels state to keep all the dependent state in sync +export class ChannelPaginator extends BasePaginator { + // state: StateStore; + private client: StreamChat; + protected _filters: ChannelFilters | undefined; + protected _sort: ChannelSort | ChannelSort[] | undefined; + protected _options: ChannelPaginatorRequestOptions | undefined; + private _id: string; + sortComparator: (a: Channel, b: Channel) => number; + filterBuilder: FilterBuilder; + + constructor({ + client, + id, + filterBuilderOptions, + filters, + paginatorOptions, + requestOptions, + sort, + }: ChannelPaginatorOptions) { + super(paginatorOptions); + const definedSort = sort ?? DEFAULT_BACKEND_SORT; + this.client = client; + this._id = id ?? `channel-paginator-${generateUUIDv4()}`; + this._sort = definedSort; + this._filters = filters; + this._options = requestOptions; + this.filterBuilder = new FilterBuilder(filterBuilderOptions); + this.sortComparator = makeComparator({ + sort: definedSort, + resolvePathValue: channelSortPathResolver, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + this.setFilterResolvers([ + pinnedFilterResolver, + membersFilterResolver, + memberUserNameFilterResolver, + dataFieldFilterResolver, + ]); + } + + get id() { + return this._id; + } + + get filters(): ChannelFilters | undefined { + return this._filters; + } + + get sort(): ChannelSort | undefined { + return this._sort; + } + + get options(): ChannelOptions | undefined { + return this._options; + } + + set filters(filters: ChannelFilters | undefined) { + this._filters = filters; + this.resetState(); + } + + set sort(sort: ChannelSort | ChannelSort[] | undefined) { + this._sort = sort; + this.sortComparator = makeComparator({ + sort: this.sort ?? DEFAULT_BACKEND_SORT, + }); + this.resetState(); + } + + set options(options: ChannelPaginatorRequestOptions | undefined) { + this._options = options; + this.resetState(); + } + + getItemId(item: Channel): string { + return item.cid; + } + + buildFilters = (): ChannelFilters => + this.filterBuilder.buildFilters({ + baseFilters: { ...this.filters }, + }); + + query = async ({ direction }: PaginationQueryParams = {}): Promise< + PaginationQueryReturnValue + > => { + if (direction) { + console.warn('Direction is not supported with channel pagination.'); + } + const filters = this.buildFilters(); + const options: ChannelOptions = { + ...this.options, + limit: this.pageSize, + offset: this.offset, + }; + const items = await this.client.queryChannels(filters, this.sort, options); + return { items }; + }; + + filterQueryResults = (items: Channel[]) => items; +} diff --git a/src/pagination/FilterBuilder.ts b/src/pagination/FilterBuilder.ts index 9945dc9a2..53182a2c9 100644 --- a/src/pagination/FilterBuilder.ts +++ b/src/pagination/FilterBuilder.ts @@ -31,7 +31,10 @@ export type FilterBuilderGenerators< }; }; -export type FilterBuilderOptions> = { +export type FilterBuilderOptions< + TFilters, + TContext extends Record = Record, +> = { initialFilterConfig?: FilterBuilderGenerators; initialContext?: TContext; }; diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/ReminderPaginator.ts index ff81b5dc9..789354cd8 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/ReminderPaginator.ts @@ -37,7 +37,9 @@ export class ReminderPaginator extends BasePaginator { query = async ({ direction, - }: PaginationQueryParams): Promise> => { + }: Required): Promise< + PaginationQueryReturnValue + > => { const cursor = this.cursor?.[direction]; const { reminders: items, diff --git a/src/pagination/filterCompiler.ts b/src/pagination/filterCompiler.ts new file mode 100644 index 000000000..a60f74516 --- /dev/null +++ b/src/pagination/filterCompiler.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + arraysEqualAsSets, + asArray, + compare, + isIterableButNotString, + normalizeComparedValues, + resolveDotPathValue, + toIterableArray, + tokenize, +} from './utility.normalization'; +import type { FieldToDataResolver } from './types.normalization'; +import type { QueryFilters } from '../types'; + +export type ItemMatchesFilterOptions = { + /** Custom resolvers to extract values from an item given a path */ + resolvers?: ReadonlyArray>; +}; + +export function itemMatchesFilter( + item: T, + filter: QueryFilters, + options: ItemMatchesFilterOptions, +): boolean { + const resolvers = options.resolvers ?? []; + const resolverValueCache = new Map(); + + const resolveOnce = (field: string) => { + if (resolverValueCache.has(field)) return resolverValueCache.get(field); + const resolver = resolvers?.find((resolver) => resolver.matchesField(field)) ?? { + resolve: resolveDotPathValue, + }; + const value = resolver.resolve(item, field); + resolverValueCache.set(field, value); + return value; + }; + + const matches = (filterNode: QueryFilters): boolean => { + if (!filterNode || typeof filterNode !== 'object') return true; + + if (filterNode.$and) return filterNode.$and.every((n) => matches(n)); + if (filterNode.$or) return filterNode.$or.some((n) => matches(n)); + if (filterNode.$nor) return !filterNode.$nor.some((n) => matches(n)); + + for (const [field, condition] of Object.entries(filterNode)) { + const itemPropertyValue = resolveOnce(field); + + if ( + typeof condition !== 'object' || + condition === null || + Array.isArray(condition) + ) { + if (!equalsOp(itemPropertyValue, condition)) return false; + continue; + } + + for (const [op, filterValue] of Object.entries(condition)) { + switch (op) { + case '$eq': + if (!equalsOp(itemPropertyValue, filterValue)) return false; + break; + case '$ne': + if (equalsOp(itemPropertyValue, filterValue)) return false; + break; + + case '$in': + if (!inSetOp(itemPropertyValue, asArray(filterValue))) return false; + break; + case '$nin': + if (inSetOp(itemPropertyValue, asArray(filterValue))) return false; + break; + + case '$gt': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c > 0)) + return false; + break; + case '$gte': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c >= 0)) + return false; + break; + case '$lt': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c < 0)) + return false; + break; + case '$lte': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c <= 0)) + return false; + break; + + case '$exists': + if (!!itemPropertyValue !== !!filterValue) return false; + break; + case '$contains': + if (!containsOp(itemPropertyValue, filterValue)) return false; + break; + case '$autocomplete': + if (!autoCompleteOp(itemPropertyValue, filterValue)) return false; + break; + default: + return false; + } + } + } + return true; + }; + return matches(filter); +} + +/** + * Duplicates ignored for array–array equality: ['a','a','b'] equals ['b','a']. + * + * Empty arrays: [] equals []; a scalar never equals []. + * + * This reuses your normalizeComparedValues so '1' equals 1, ISO dates compare correctly, etc. + * + * $gt/$gte/$lt/$lte remain scalar-only (return false if either side is iterable), as you wanted. + * + * $in/$nin left may be scalar or iterable; the right is a list. + * @param a + * @param b + * @param ok + */ +function orderedCompareOp(a: any, b: any, ok: (c: number) => boolean): boolean { + if (isIterableButNotString(a) || isIterableButNotString(b)) return false; + const n = normalizeComparedValues(a, b); + if (n.kind === 'incomparable') return false; + return ok(compare(n.a, n.b)); +} + +function equalsOp(left: any, right: any): boolean { + const leftIsIter = isIterableButNotString(left); + const rightIsIter = isIterableButNotString(right); + + if (!leftIsIter && !rightIsIter) { + // scalar vs scalar + const n = normalizeComparedValues(left, right); + if (n.kind === 'incomparable') return Object.is(left, right); + return n.a === n.b; + } + + if (leftIsIter && rightIsIter) { + // array vs array → set equality (order-insensitive) + const a = toIterableArray(left); + const b = toIterableArray(right); + return arraysEqualAsSets(a, b); + } + + // one side scalar, the other iterable → membership + if (leftIsIter) { + const a = toIterableArray(left); + return a.some((elem) => equalsOp(elem, right)); + } else { + const b = toIterableArray(right); + return b.some((elem) => equalsOp(left, elem)); + } +} + +function inSetOp(a: any, arr: any[]): boolean { + return arr.some((b) => equalsOp(a, b)); +} + +function containsOp(value: any, needle: any): boolean { + if (Array.isArray(value)) return value.includes(needle); + if (typeof value === 'string' && typeof needle === 'string') + return value.includes(needle); + return false; +} + +/** + * A value matches an autocomplete query if: + * - value is string: every query token is a prefix of some token in the value + * - value is string[]: any element matches as above + * - query can be string (tokenized) or string[] + */ +function autoCompleteOp(value: any, query: any): boolean { + if (value == null || query == null) return false; + + const queryTokens: string[] = Array.isArray(query) + ? query.map(String).flatMap(tokenize) + : tokenize(String(query)); + if (queryTokens.length === 0) return false; + + const matchOneString = (s: string): boolean => { + const valTokens = tokenize(s); + return queryTokens.every((qt) => valTokens.some((vt) => vt.includes(qt))); + }; + + if (typeof value === 'string') return matchOneString(value); + if (Array.isArray(value)) + return value.some((v) => typeof v === 'string' && matchOneString(v)); + return false; +} diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 19e2a53b8..733c5efe8 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,3 +1,4 @@ export * from './BasePaginator'; +export * from './ChannelPaginator'; export * from './FilterBuilder'; export * from './ReminderPaginator'; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts new file mode 100644 index 000000000..b56e9cd13 --- /dev/null +++ b/src/pagination/sortCompiler.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + compare, + resolveDotPathValue as defaultResolvePathValue, + normalizeComparedValues, +} from './utility.normalization'; +import { normalizeQuerySort } from '../utils'; +import type { AscDesc } from '../types'; +import type { Comparator, PathResolver } from './types.normalization'; + +export function binarySearchInsertIndex({ + compare, + needle, + sortedArray, +}: { + sortedArray: T[]; + needle: T; + compare: Comparator; +}): number { + let low = 0; + let high = sortedArray.length; + + while (low < high) { + const middle = (low + high) >>> 1; // fast floor((low+high)/2) + const comparisonResult = compare(sortedArray[middle], needle); + + // We want the first position where existing > needle to insert before it + if (comparisonResult > 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +/** + * Negative number (< 0) → a comes before b + * + * Zero (0) → leave a and b unchanged relative to each other + * (but they can still move relative to others — sort in JS is not guaranteed stable in older engines, though modern V8/Node/Chrome/Firefox make it stable) + * + * Positive number (> 0) → a comes after b + * @param sort + * @param resolvePathValue + * @param tiebreaker + */ +export function makeComparator< + T, + S extends Record | Record[], +>({ + sort, + resolvePathValue = defaultResolvePathValue, + tiebreaker = (a, b) => compare((a as any).cid, (b as any).cid), +}: { + sort: S; + resolvePathValue?: PathResolver; + tiebreaker?: Comparator; +}): Comparator { + const terms = normalizeQuerySort(sort); + + return (a: T, b: T) => { + for (const { field: path, direction } of terms) { + const leftValue = resolvePathValue(a, path); + const rightValue = resolvePathValue(b, path); + const normalized = normalizeComparedValues(leftValue, rightValue); + let comparison: number; + switch (normalized.kind) { + case 'date': + case 'number': + case 'string': + case 'boolean': + comparison = compare(normalized.a, normalized.b); + break; + default: + // deterministic fallback: null/undefined last; else string compare + if (leftValue == null && rightValue == null) comparison = 0; + else if (leftValue == null) comparison = 1; + else if (rightValue == null) comparison = -1; + else { + const stringLeftValue = String(leftValue), + stringRightValue = String(rightValue); + comparison = + stringLeftValue === stringRightValue + ? 0 + : stringLeftValue < stringRightValue + ? -1 + : 1; + } + } + if (comparison !== 0) return direction === 1 ? comparison : -comparison; + } + return tiebreaker ? tiebreaker(a, b) : 0; + }; +} diff --git a/src/pagination/types.normalization.ts b/src/pagination/types.normalization.ts new file mode 100644 index 000000000..1932a5bc7 --- /dev/null +++ b/src/pagination/types.normalization.ts @@ -0,0 +1,7 @@ +export type PathResolver = (item: DataSource, field: string) => unknown; +export type Comparator = (left: T, right: T) => number; + +export type FieldToDataResolver = { + matchesField: (field: string) => boolean; + resolve: PathResolver; +}; diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts new file mode 100644 index 000000000..c7df10aef --- /dev/null +++ b/src/pagination/utility.normalization.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function asArray(v: any): any[] { + return Array.isArray(v) ? v : [v]; +} + +export function isISODateString(x: any): x is string { + return typeof x === 'string' && x.includes('T') && !Number.isNaN(Date.parse(x)); +} + +export function toEpochMillis(x: any): number | null { + if (x instanceof Date) return x.getTime(); + if (typeof x === 'number' && Number.isFinite(x)) return x; // treat as epoch ms + if (isISODateString(x)) return Date.parse(x); + return null; +} + +export function toNumberLike(x: any): number | null { + if (typeof x === 'number' && Number.isFinite(x)) return x; + if (typeof x === 'string' && x.trim() !== '') { + const n = Number(x); + if (Number.isFinite(n)) return n; + } + return null; +} + +export function normalizeComparedValues(a: any, b: any) { + const Ad = toEpochMillis(a), + Bd = toEpochMillis(b); + if (Ad !== null && Bd !== null) return { kind: 'date', a: Ad, b: Bd }; + + const An = toNumberLike(a), + Bn = toNumberLike(b); + if (An !== null && Bn !== null) return { kind: 'number', a: An, b: Bn }; + + if (typeof a === 'string' && typeof b === 'string') return { kind: 'string', a, b }; + if (typeof a === 'boolean' && typeof b === 'boolean') return { kind: 'boolean', a, b }; + + return { kind: 'incomparable', a, b }; +} + +export function normKey(x: unknown): string { + // Use your normalizeComparedValues to coerce pairs; here we need a unary form. + // We can piggyback by normalizing x against itself: + const n = normalizeComparedValues(x, x); + switch (n.kind) { + case 'date': + case 'number': + case 'string': + case 'boolean': + return `${n.kind}:${String(n.a)}`; + default: + // fallback: use JSON-like string with type tag for determinism + return `other:${String(x)}`; + } +} + +export function compare(a: any, b: any): number { + if (a === b) return 0; + return a < b ? -1 : 1; +} + +export function arraysEqualAsSets(aList: unknown[], bList: unknown[]): boolean { + // de-duplicate by normalized key + const aKeys = new Set(aList.map(normKey)); + const bKeys = new Set(bList.map(normKey)); + if (aKeys.size !== bKeys.size) return false; + for (const k of aKeys) if (!bKeys.has(k)) return false; + return true; +} + +export function normalizeString(s: string): string { + return s.normalize('NFKC').toLowerCase().trim(); +} + +export function normalizeStringAccentInsensitive(s: string): string { + return s + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); +} + +export function tokenize(s: string): string[] { + // split on whitespace; keep simple & deterministic + return normalizeString(s).split(/\s+/).filter(Boolean); +} + +// dot-path accessor +export function resolveDotPathValue(obj: any, path: string): unknown[] { + return path + .split('.') + .reduce((reduced, key) => (!reduced ? undefined : reduced[key]), obj); +} + +export function isIterableButNotString(v: unknown): v is Iterable { + return ( + v != null && + typeof v !== 'string' && + typeof (v as any)[Symbol.iterator] === 'function' + ); +} + +export function toIterableArray(v: unknown): unknown[] { + if (Array.isArray(v)) return v; + if (isIterableButNotString(v)) return Array.from(v as Iterable); + return [v]; // scalar as a single-element list +} diff --git a/src/pagination/utility.queryChannel.ts b/src/pagination/utility.queryChannel.ts new file mode 100644 index 000000000..2a2fedd9b --- /dev/null +++ b/src/pagination/utility.queryChannel.ts @@ -0,0 +1,77 @@ +import type { ChannelQueryOptions, QueryChannelAPIResponse } from '../types'; +import type { StreamChat } from '../client'; +import type { Channel } from '../channel'; +import { generateChannelTempCid } from '../utils'; + +/** + * prevent from duplicate invocation of channel.watch() + * when events 'notification.message_new' and 'notification.added_to_channel' arrive at the same time + */ +const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< + string, + Promise | undefined +> = {}; + +type GetChannelParams = { + client: StreamChat; + channel?: Channel; + id?: string; + members?: string[]; + options?: ChannelQueryOptions; + type?: string; +}; +/** + * Watches a channel, coalescing concurrent invocations for the same CID. + * If a watch is already in flight, this call waits for it to settle instead of + * issuing another network request. + * @param client + * @param members + * @param options + * @param type + * @param id + * @param channel + */ +export const getChannel = async ({ + channel, + client, + id, + members, + options, + type, +}: GetChannelParams) => { + if (!channel && !type) { + throw new Error('Channel or channel type have to be provided to query a channel.'); + } + + // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const theChannel = channel || client.channel(type!, id, { members }); + + // need to keep as with call to channel.watch the id can be changed from undefined to an actual ID generated server-side + const originalCid = theChannel?.id + ? theChannel.cid + : members && members.length + ? generateChannelTempCid(theChannel.type, members) + : undefined; + + if (!originalCid) { + throw new Error( + 'Channel ID or channel members array have to be provided to query a channel.', + ); + } + + const queryPromise = WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; + + if (queryPromise) { + await queryPromise; + } else { + try { + WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = theChannel.watch(options); + await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; + } finally { + delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; + } + } + + return theChannel; +}; diff --git a/src/pagination/utility.search.ts b/src/pagination/utility.search.ts new file mode 100644 index 000000000..4d1cec419 --- /dev/null +++ b/src/pagination/utility.search.ts @@ -0,0 +1,56 @@ +export function locateOnPlateauAlternating( + items: readonly T[], + needle: T, + compare: (left: T, right: T) => number, + getItemId: (x: T) => string, + insertionIndex: number, +): number { + const targetId = getItemId(needle); + let leftIndex = insertionIndex - 1; + let rightIndex = insertionIndex; + + for (let step = 0; ; step++) { + const searchRight = step % 2 === 0; + + if (searchRight) { + if (rightIndex < items.length && compare(items[rightIndex], needle) === 0) { + if (getItemId(items[rightIndex]) === targetId) return rightIndex; + rightIndex++; + continue; + } + } else { + if (leftIndex >= 0 && compare(items[leftIndex], needle) === 0) { + if (getItemId(items[leftIndex]) === targetId) return leftIndex; + leftIndex--; + continue; + } + } + + const rightOut = + rightIndex >= items.length || compare(items[rightIndex], needle) !== 0; + const leftOut = leftIndex < 0 || compare(items[leftIndex], needle) !== 0; + if (rightOut && leftOut) break; // plateau exhausted + } + + return -1; +} + +export function locateOnPlateauScanOneSide( + items: readonly T[], + needle: T, + compare: (left: T, right: T) => number, + getItemId: (x: T) => string, + insertionIndex: number, +): number { + const targetId = getItemId(needle); + + // scan left + for (let i = insertionIndex - 1; i >= 0 && compare(items[i], needle) === 0; i--) { + if (getItemId(items[i]) === targetId) return i; + } + // scan right + for (let i = insertionIndex; i < items.length && compare(items[i], needle) === 0; i++) { + if (getItemId(items[i]) === targetId) return i; + } + return -1; +} diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts new file mode 100644 index 000000000..0d29234e9 --- /dev/null +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -0,0 +1,698 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClientWithUser } from './test-utils/getClient'; +import { + ChannelPaginator, + ChannelResponse, + EventTypes, + type StreamChat, +} from '../../src'; +import { ChannelPaginatorsOrchestrator } from '../../src/ChannelPaginatorsOrchestrator'; +vi.mock('../../src/pagination/utility.queryChannel', async () => { + return { + getChannel: vi.fn(async ({ client, id, type }) => { + return client.channel(type, id); + }), + }; +}); +import { getChannel as mockGetChannel } from '../../src/pagination/utility.queryChannel'; + +describe('ChannelPaginatorsOrchestrator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser(); + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('initiates with default options', () => { + // @ts-expect-error accessing protected property + const defaultHandlers = ChannelPaginatorsOrchestrator.defaultEventHandlers; + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + expect(orchestrator.paginators).toHaveLength(0); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length); + }); + + it('initiates with custom options', () => { + const paginator = new ChannelPaginator({ client }); + const customChannelVisibleHandler = vi.fn(); + const customChannelDeletedHandler = vi.fn(); + const customEventHandler = vi.fn(); + + // @ts-expect-error accessing protected property + const defaultHandlers = ChannelPaginatorsOrchestrator.defaultEventHandlers; + const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + + eventHandlers['channel.visible'] = [ + ...(eventHandlers['channel.visible'] ?? []), + { + id: 'channel.visible:custom', + handle: customChannelVisibleHandler, + }, + ]; + + eventHandlers['channel.deleted'] = [ + { + id: 'channel.deleted:custom', + handle: customChannelDeletedHandler, + }, + ]; + + eventHandlers['custom.event'] = [ + { + id: 'custom.event', + handle: customEventHandler, + }, + ]; + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + eventHandlers, + paginators: [paginator], + }); + expect(orchestrator.paginators).toHaveLength(1); + expect(orchestrator.getPaginatorById(paginator.id)).toStrictEqual(paginator); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length + 1); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.visible').size).toBe(2); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.visible').handlers[0].id).toBe( + eventHandlers['channel.visible'][0].id, + ); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.visible').handlers[1].id).toBe( + eventHandlers['channel.visible'][1].id, + ); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.deleted').size).toBe(1); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.deleted').handlers[0].id).toBe( + eventHandlers['channel.deleted'][0].id, + ); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('custom.event').size).toBe(1); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('custom.event').handlers[0].id).toBe( + eventHandlers['custom.event'][0].id, + ); + }); + }); + + describe('registerSubscriptions', () => { + it('subscribes only once', async () => { + const onSpy = vi.spyOn(client, 'on'); + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + orchestrator.registerSubscriptions(); + orchestrator.registerSubscriptions(); + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it('routes events to correct pipelines', async () => { + const customChannelDeletedHandler = vi.fn(); + const customEventHandler = vi.fn(); + + const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + + eventHandlers['channel.deleted'] = [ + { + id: 'channel.deleted:custom', + handle: customChannelDeletedHandler, + }, + ]; + + eventHandlers['custom.event'] = [ + { + id: 'custom.event', + handle: customEventHandler, + }, + ]; + + const orchestrator = new ChannelPaginatorsOrchestrator({ client, eventHandlers }); + orchestrator.registerSubscriptions(); + + const channelDeletedEvent = { type: 'channel.deleted', cid: 'x' } as const; + + client.dispatchEvent(channelDeletedEvent); + + await vi.waitFor(() => { + expect(customChannelDeletedHandler).toHaveBeenCalledTimes(1); + expect(customChannelDeletedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: { orchestrator }, + event: channelDeletedEvent, + }), + ); + }); + + const customEvent = { type: 'custom.event' as EventTypes, x: 'abc' } as const; + + client.dispatchEvent(customEvent); + + await vi.waitFor(() => { + expect(customEventHandler).toHaveBeenCalledTimes(1); + expect(customEventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: { orchestrator }, + event: customEvent, + }), + ); + }); + }); + }); + + describe('insertPaginator', () => { + it('appends when no index is provided', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.insertPaginator({ paginator: p2 }); + + expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); + }); + + it('inserts at specific index', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + const p3 = new ChannelPaginator({ client }); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.insertPaginator({ paginator: p3 }); + orchestrator.insertPaginator({ paginator: p2, index: 1 }); + + expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id, p3.id]); + }); + + it('moves existing paginator to new index', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + const p3 = new ChannelPaginator({ client }); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.insertPaginator({ paginator: p2 }); + orchestrator.insertPaginator({ paginator: p3 }); + + // move p1 from 0 to 2 + orchestrator.insertPaginator({ paginator: p1, index: 2 }); + expect(orchestrator.paginators.map((p) => p.id)).toEqual([p2.id, p3.id, p1.id]); + }); + + it('clamps out-of-bounds index', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + + orchestrator.insertPaginator({ paginator: p1, index: -10 }); // -> 0 + orchestrator.insertPaginator({ paginator: p2, index: 999 }); // -> end + + expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); + }); + }); + + describe('addEventHandler', () => { + it('registers a custom handler and can unsubscribe it', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelUpdatedHandler = vi.fn(); + const unsubscribe = orchestrator.addEventHandler({ + eventType: 'channel.updated', + id: 'custom', + handle: channelUpdatedHandler, + }); + + orchestrator.registerSubscriptions(); + const channelUpdatedEvent = { type: 'channel.updated', cid: 'x' } as const; + + client.dispatchEvent(channelUpdatedEvent); + // event listeners are executed async + await vi.waitFor(() => { + expect(channelUpdatedHandler).toHaveBeenCalledWith({ + ctx: { orchestrator }, + event: channelUpdatedEvent, + }); + }); + + // Unsubscribe the custom handler and ensure it no longer fires + unsubscribe(); + client.dispatchEvent(channelUpdatedEvent); + + // still 1 call total (did not increment) + expect(channelUpdatedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('ensurePipeline', () => { + it('returns the same pipeline instance for the same event type', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = orchestrator.ensurePipeline('channel.updated'); + const p2 = orchestrator.ensurePipeline('channel.updated'); + expect(p1).toBe(p2); + }); + }); + + describe('reload', () => { + it('calls reload on all the paginators', async () => { + const paginator1 = new ChannelPaginator({ client }); + const paginator2 = new ChannelPaginator({ client }); + vi.spyOn(paginator1, 'reload').mockResolvedValue(); + vi.spyOn(paginator2, 'reload').mockResolvedValue(); + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [paginator1, paginator2], + }); + await orchestrator.reload(); + expect(paginator1.reload).toHaveBeenCalledTimes(1); + expect(paginator2.reload).toHaveBeenCalledTimes(1); + }); + }); + + // Helper to create a minimal channel with needed state + function makeChannel(cid: string) { + const [type, id] = cid.split(':'); + return client.channel(type, id); + } + + describe('channel.deleted', () => { + it('removes the channel from all paginators', async () => { + const cid = 'messaging:1'; + const ch = makeChannel(cid); + + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + const r1 = vi.spyOn(p1, 'removeItem'); + const r2 = vi.spyOn(p2, 'removeItem'); + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + client.activeChannels[cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'channel.deleted', cid } as const); + + await vi.waitFor(() => { + // client.activeChannels does not contain the deleted channel, therefore the search is performed with id + expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); + expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); + }); + }); + + it('is a no-op when cid is missing', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: 'channel.deleted' } as const); // no cid + await vi.waitFor(() => { + expect(r).not.toHaveBeenCalled(); + }); + }); + + it('tries to remove non-existent channel from all paginators', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: 'channel.deleted', cid: 'messaging:404' }); // no such channel + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); + }); + }); + }); + + describe.each(['notification.removed_from_channel'] as EventTypes[])( + '%s', + (eventType) => { + it('removes the channel from all paginators', async () => { + const cid = 'messaging:2'; + const ch = makeChannel(cid); + + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + const r1 = vi.spyOn(p1, 'removeItem'); + const r2 = vi.spyOn(p2, 'removeItem'); + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + client.activeChannels[cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: eventType, cid } as const); + + await vi.waitFor(() => { + // client.activeChannels contains the hidden channel, therefore the search is performed with item + expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: ch }); + expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: ch }); + }); + }); + + it('is a no-op when cid is missing', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType } as const); // no cid + await vi.waitFor(() => { + expect(r).not.toHaveBeenCalled(); + }); + }); + + it('tries to remove non-existent channel from all paginators', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: 'messaging:404' }); // no such channel + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); + }); + }); + }, + ); + + describe.each(['channel.updated', 'channel.truncated'] as EventTypes[])( + '%s', + (eventType) => { + it('re-emits item lists for paginators that already contain the channel', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:3'); + client.activeChannels[ch.cid] = ch; + + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + p1.state.partialNext({ items: [ch] }); + vi.spyOn(p1, 'findItem').mockReturnValue(ch); + vi.spyOn(p2, 'findItem').mockReturnValue(undefined); + const partialNextSpy1 = vi.spyOn(p1.state, 'partialNext'); + const partialNextSpy2 = vi.spyOn(p2.state, 'partialNext'); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + expect(partialNextSpy2).toHaveBeenCalledTimes(0); + expect(partialNextSpy1).toHaveBeenCalledTimes(1); + const last = partialNextSpy1.mock.calls.at(-1)![0]; + expect(last.items!.length).toBe(1); + expect(last.items![0]).toStrictEqual(ch); + }); + }); + }, + ); + + describe.each([ + 'channel.visible', + 'member.updated', + 'message.new', + 'notification.added_to_channel', + 'notification.message_new', + ] as EventTypes[])('%s', (eventType) => { + it('ingests when matchesFilter, removes when not', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:5'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + const matchesFilterSpy = vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + expect(matchesFilterSpy).toHaveBeenCalledWith(ch); + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + + matchesFilterSpy.mockReturnValue(false); + client.dispatchEvent({ type: eventType, cid: 'messaging:5' }); + + await vi.waitFor(() => { + expect(removeItemSpy).toHaveBeenCalledWith({ item: ch }); + expect(ingestItemSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('loads channel by (type,id) when not in activeChannels', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + + const p = new ChannelPaginator({ client }); + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel_type: 'messaging', + channel_id: '6', + }); + + await vi.waitFor(() => { + expect(mockGetChannel).toHaveBeenCalledWith({ + client, + id: '6', + type: 'messaging', + }); + const ch = makeChannel('messaging:6'); + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + }); + + it('uses event.channel if provided', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:7'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel: { cid: 'messaging:7' } as ChannelResponse, + }); + await vi.waitFor(() => { + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + }); + + it('removes channel if does not match the filter anymore', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:7'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(false); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel: { cid: 'messaging:7' } as ChannelResponse, + }); + await vi.waitFor(() => { + expect(ingestItemSpy).not.toHaveBeenCalled(); + expect(removeItemSpy).toHaveBeenCalledWith({ item: ch }); + }); + }); + }); + + it.each([ + 'message.new', + 'notification.message_new', + 'notification.added_to_channel', + 'channel.visible', + ] as EventTypes[])( + 'boosts ingested channel on %s if the item is not already boosted at the top', + async (eventType) => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); + + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:5'); + client.activeChannels[ch.cid] = ch; + + const paginator = new ChannelPaginator({ client }); + const matchesFilterSpy = vi.spyOn(paginator, 'matchesFilter').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator }); + orchestrator.registerSubscriptions(); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + // already at the top + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + matchesFilterSpy.mockReturnValue(false); + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + matchesFilterSpy.mockReturnValue(true); + // @ts-expect-error accessing protected property + paginator._maxBoostSeq = 1000; + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + // some other channel has a higher boost + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1001, until: now.getTime() + 15000 }], + ]); + }); + + nowSpy.mockRestore(); + vi.useRealTimers(); + }, + ); + + it.each([ + 'channel.updated', + 'channel.truncated', + 'member.updated', + 'user.presence.changed', + ] as EventTypes[])('does not boost ingested channel on %s', async (eventType) => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); + + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:5'); + client.activeChannels[ch.cid] = ch; + + const paginator = new ChannelPaginator({ client }); + const matchesFilterSpy = vi.spyOn(paginator, 'matchesFilter').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator }); + orchestrator.registerSubscriptions(); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + }); + }); + + describe('user.presence.changed', () => { + it('updates user on channels where the user is a member and re-emits lists', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + + const ch1 = makeChannel('messaging:13'); + ch1.state.members = { + u1: { user: { id: 'u1', name: 'Old' } }, + u3: { user: { id: 'u3', name: 'Old3' } }, + }; + ch1.state.membership = { user: { id: 'u1', name: 'Old' } }; + + const ch2 = makeChannel('messaging:14'); + ch2.state.members = { + u1: { user: { id: 'u1', name: 'Old' } }, + u2: { user: { id: 'u2', name: 'Old2' } }, + u3: { user: { id: 'u3', name: 'Old3' } }, + }; + ch2.state.membership = { user: { id: 'u1', name: 'Old' } }; + + client.activeChannels[ch1.cid] = ch1; + client.activeChannels[ch2.cid] = ch2; + + const p = new ChannelPaginator({ client }); + p.state.partialNext({ items: [ch1, ch2] }); + const partialNextSpy = vi.spyOn(p.state, 'partialNext'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + // user u1 presence changed + client.dispatchEvent({ + type: 'user.presence.changed', + user: { id: 'u1', name: 'NewName' }, + }); + + await vi.waitFor(() => { + expect(ch1.state.members['u1'].user?.name).toBe('NewName'); + expect(ch1.state.members['u3'].user?.name).toBe('Old3'); + + expect(ch2.state.members['u1'].user?.name).toBe('NewName'); + expect(ch2.state.members['u2'].user?.name).toBe('Old2'); + expect(ch2.state.members['u3'].user?.name).toBe('Old3'); + + expect(ch1.state.membership.user?.name).toBe('NewName'); + expect(ch2.state.membership.user?.name).toBe('NewName'); + expect(partialNextSpy).toHaveBeenCalledTimes(1); + expect(partialNextSpy).toHaveBeenCalledWith({ items: [ch1, ch2] }); + }); + + // Now user without id → ignored + partialNextSpy.mockClear(); + client.dispatchEvent({ type: 'user.presence.changed', user: {} as any }); + expect(partialNextSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/EventHandlerPipeline.test.ts b/test/unit/EventHandlerPipeline.test.ts new file mode 100644 index 000000000..67a0ce934 --- /dev/null +++ b/test/unit/EventHandlerPipeline.test.ts @@ -0,0 +1,525 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EventHandlerPipeline, + type LabeledEventHandler, +} from '../../src/EventHandlerPipeline'; + +type TestEvent = { type: string; payload?: any }; +type TestCtx = { tag: string }; + +const makeEvt = (type: string): TestEvent => ({ type }); +const ctx: TestCtx = { tag: 'ctx' }; + +describe('EventHandlerPipeline', () => { + let pipeline: EventHandlerPipeline; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + pipeline = new EventHandlerPipeline({ id: 'test-pipe' }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('constructor & size', () => { + it('initializes with id and zero handlers', () => { + expect(pipeline.id).toBe('test-pipe'); + expect(pipeline.size).toBe(0); + }); + }); + + describe('insert', () => { + it('appends by default when no index', async () => { + const calls: string[] = []; + const h1 = { + id: 'h1', + handle: () => { + calls.push('h1'); + }, + }; + const h2 = { + id: 'h2', + handle: () => { + calls.push('h2'); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + + expect(pipeline.size).toBe(2); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx).then(() => { + expect(calls).toEqual(['h1', 'h2']); + }); + }); + + it('inserts at clamped index (negative -> 0, too large -> append)', () => { + const order: string[] = []; + const a = { + id: 'a', + handle: () => { + order.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + order.push('b'); + }, + }; + const c = { + id: 'c', + handle: () => { + order.push('c'); + }, + }; + const d = { + id: 'd', + handle: () => { + order.push('d'); + }, + }; + + pipeline.insert(a); // [a] + pipeline.insert(b); // [a,b] + pipeline.insert({ ...c, index: -10 }); // clamp to 0 => [c,a,b] + pipeline.insert({ ...d, index: 999 }); // append => [c,a,b,d] + + expect(pipeline.size).toBe(4); + // @ts-expect-error passing custom event type + return pipeline.run(makeEvt('e'), ctx).then(() => { + expect(order).toEqual(['c', 'a', 'b', 'd']); + }); + }); + + it('replace=false inserts and unsubscribe removes only target handler', async () => { + const calls: string[] = []; + const a = { + id: 'a', + handle: () => { + calls.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + calls.push('b'); + }, + }; + + const unsubA = pipeline.insert({ ...a, index: 0, replace: false }); + const unsubB = pipeline.insert({ ...b, index: 0, replace: false }); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx); + expect(calls).toEqual(['b', 'a']); + + unsubB(); // remove only b + expect(pipeline.size).toBe(1); + + // reset the array contents + calls.length = 0; + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('y'), ctx); + expect(calls).toEqual(['a']); + + unsubA(); + expect(pipeline.size).toBe(0); + }); + + it('replace=true replaces existing handler and revertOnUnsubscribe restores it', async () => { + const calls: string[] = []; + const orig = { + id: 'orig', + handle: () => { + calls.push('orig'); + }, + }; + const repl = { + id: 'repl', + handle: () => { + calls.push('repl'); + }, + }; + + // seed + pipeline.insert({ ...orig, index: 0 }); + // replace at 0 with repl + const unsub = pipeline.insert({ + ...repl, + index: 0, + replace: true, + revertOnUnsubscribe: true, + }); + + // handlers: [repl] + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('1'), ctx); + expect(calls).toEqual(['repl']); + + // unsubscribe => remove repl and restore orig at index 0 + unsub(); + calls.length = 0; + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('2'), ctx); + expect(calls).toEqual(['orig']); + }); + + it('replace=true at index >= length behaves like insert (does not revert)', async () => { + const calls: string[] = []; + const a = { + id: 'a', + handle: () => { + calls.push('a'); + }, + }; + const repl = { + id: 'repl', + handle: () => { + calls.push('repl'); + }, + }; + + pipeline.insert(a); // [a] + const unsub = pipeline.insert({ + ...repl, + index: 5, + replace: true, + revertOnUnsubscribe: true, + }); //[a,repl] + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx); + expect(calls).toEqual(['a', 'repl']); // reverse exec + + unsub(); // should only remove repl; no original to restore + calls.length = 0; + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('y'), ctx); + expect(calls).toEqual(['a']); + }); + }); + + describe('remove', () => { + it('removes by handler object identity', async () => { + const out: string[] = []; + const h1: LabeledEventHandler = { + id: 'h1', + handle: () => { + out.push('h1'); + }, + }; + const h2: LabeledEventHandler = { + id: 'h2', + handle: () => { + out.push('h2'); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + pipeline.remove(h2); // remove by object + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); + expect(out).toEqual(['h1']); // reverse exec; only h1 left + }); + + it('removes by function reference', async () => { + const out: string[] = []; + const fn = () => { + out.push('fn'); + }; + const h1: LabeledEventHandler = { id: 'h1', handle: fn }; + pipeline.insert(h1); + pipeline.remove(fn); // remove by function ref + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); + expect(out).toEqual([]); // removed + }); + + it('no-op remove for unknown handler', async () => { + const out: string[] = []; + const fn = () => { + out.push('a'); + }; + pipeline.remove(fn); // nothing inserted yet + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); // no errors + expect(out).toEqual([]); + expect(pipeline.size).toBe(0); + }); + }); + + describe('replaceAll & clear', () => { + it('replaceAll swaps the entire handler list', async () => { + const out: string[] = []; + const a = { + id: 'a', + handle: () => { + out.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + out.push('b'); + }, + }; + const c = { + id: 'c', + handle: () => { + out.push('c'); + }, + }; + + pipeline.insert(a); + pipeline.insert(b); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(out).toEqual(['a', 'b']); + out.length = 0; + + pipeline.replaceAll([c]); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e2'), ctx); + expect(out).toEqual(['c']); + expect(pipeline.size).toBe(1); + }); + + it('clear removes all handlers', async () => { + const out: string[] = []; + pipeline.insert({ + id: 'a', + handle: () => { + out.push('a'); + }, + }); + pipeline.insert({ + id: 'b', + handle: () => { + out.push('b'); + }, + }); + expect(pipeline.size).toBe(2); + + pipeline.clear(); + expect(pipeline.size).toBe(0); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(out).toEqual([]); // nothing ran + }); + }); + + describe('run / drain / execution order', () => { + it('serializes events: second run waits for the first to finish', async () => { + const seen: string[] = []; + let hAsyncHandlerRunCount = 0; + let resolveRun1!: () => void; + const hAsync = { + id: 'async', + handle: () => + new Promise((res) => { + if (hAsyncHandlerRunCount === 0) { + resolveRun1 = () => { + seen.push('A-done'); + res(); + }; + ++hAsyncHandlerRunCount; + } else { + setTimeout(() => { + seen.push('A-done'); + res(); + }, 0); + } + seen.push('A-start'); + }), + }; + + const hSync = { + id: 'sync', + handle: () => { + seen.push('B-run'); + }, + }; + + pipeline.insert(hAsync); + pipeline.insert(hSync); + + // @ts-expect-error passing custom event type + const eventRun1 = pipeline.run(makeEvt('ev1'), ctx); + // @ts-expect-error passing custom event type + const eventRun2 = pipeline.run(makeEvt('ev2'), ctx); + + // At this point, first run has started (A-start), + // but the hSync is not run until we resolveRun1 and then eventRun1 can be resolved + await Promise.resolve(); // tick microtasks + expect(seen).toEqual(['A-start']); + + resolveRun1(); + await eventRun1; + expect(seen).toEqual(['A-start', 'A-done', 'B-run']); + + // Now second event runs + await eventRun2; + + // total should be 6 entries + expect(seen).toEqual(['A-start', 'A-done', 'B-run', 'A-start', 'A-done', 'B-run']); + }); + + it('drain waits for the last queued event to finish', async () => { + const marks: string[] = []; + let handlerRunCount = 0; + let resolveLater!: () => void; + + pipeline.insert({ + id: 'hold', + handle: () => + new Promise((res) => { + if (handlerRunCount === 0) { + resolveLater = () => { + marks.push('released'); + res(); + }; + ++handlerRunCount; + } else { + setTimeout(() => { + marks.push('released'); + res(); + }, 0); + } + marks.push('held'); + }), + }); + + // @ts-expect-error passing custom event type + pipeline.run(makeEvt('e1'), ctx); + // @ts-expect-error passing custom event type + pipeline.run(makeEvt('e2'), ctx); + const drained = pipeline.drain(); + + await Promise.resolve(); + expect(marks).toEqual(['held']); // first event started + + resolveLater(); // finish first; second starts then finishes too + expect(marks).toEqual(['held', 'released']); // first event started + await drained; + expect(marks).toEqual(['held', 'released', 'held', 'released']); + }); + + it('stop action halts remaining handlers for that event only', async () => { + const order: string[] = []; + pipeline.insert({ + id: 'a', + handle: () => { + order.push('a'); + }, + }); + pipeline.insert({ + id: 'stopper', + handle: () => { + order.push('stopper'); + return { action: 'stop' }; + }, + }); + pipeline.insert({ + id: 'c', + handle: () => { + order.push('c'); + }, + }); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(order).toEqual(['a', 'stopper']); + }); + + it('handler exceptions are logged but do not break processing', async () => { + const order: string[] = []; + const before = { + id: 'before', + handle: () => { + order.push('before'); + }, + }; + + const boom = { + id: 'boom', + handle: () => { + order.push('boom'); + throw new Error('fail'); + }, + }; + + const after = { + id: 'after', + handle: () => { + order.push('after'); + }, + }; + + pipeline.insert(before); + pipeline.insert(boom); + pipeline.insert(after); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + // reverse exec: after -> boom -> before; boom throws but processing continues + expect(order).toEqual(['before', 'boom', 'after']); + expect(consoleErrorSpy).toHaveBeenCalled(); // logged + }); + + it('snapshot isolation: handlers added during a run do not affect the current event', async () => { + const order: string[] = []; + + const late = { + id: 'late', + handle: () => { + order.push('late'); + }, + }; + const head = { + id: 'head', + handle: () => { + order.push('head'); + }, + }; + const inserter = { + id: 'inserter', + handle: () => { + order.push('inserter'); + // insert a new handler while processing this event + pipeline.insert(late); + }, + }; + const tail = { + id: 'tail', + handle: () => { + order.push('tail'); + }, + }; + + pipeline.insert(head); + pipeline.insert(inserter); + pipeline.insert(tail); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e1'), ctx); + // 'late' must NOT run for e1 + expect(order).toEqual(['head', 'inserter', 'tail']); + + order.length = 0; + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e2'), ctx); + // For the next event, late is present + expect(order).toEqual(['head', 'inserter', 'tail', 'late']); + }); + }); +}); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 114810637..922c55fe7 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -74,7 +74,9 @@ describe('LiveLocationManager', () => { watchLocation, }); expect(manager.deviceId).toEqual(deviceId); + // @ts-expect-error accessing private property expect(manager.getDeviceId).toEqual(getDeviceId); + // @ts-expect-error accessing private property expect(manager.watchLocation).toEqual(watchLocation); expect(manager.state.getLatestValue()).toEqual({ messages: new Map(), diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts index 1f988e22e..bf4aa43e8 100644 --- a/test/unit/pagination/BasePaginator.test.ts +++ b/test/unit/pagination/BasePaginator.test.ts @@ -1,23 +1,34 @@ import { describe, expect, it, vi } from 'vitest'; import { + AscDesc, BasePaginator, DEFAULT_PAGINATION_OPTIONS, PaginationQueryParams, PaginationQueryReturnValue, type PaginatorOptions, -} from '../../../src/pagination'; + QueryFilters, +} from '../../../src'; import { sleep } from '../../../src/utils'; +import { makeComparator } from '../../../src/pagination/sortCompiler'; const toNextTick = async () => { const sleepPromise = sleep(0); vi.advanceTimersByTime(0); await sleepPromise; }; + type TestItem = { id: string; + name?: string; + teams?: string[]; + blocked?: boolean; + createdAt?: string; // date string + age?: number; }; class Paginator extends BasePaginator { + sort: QueryFilters | undefined; + sortComparator: (a: TestItem, b: TestItem) => number = vi.fn(); queryResolve: Function = vi.fn(); queryReject: Function = vi.fn(); queryPromise: Promise> | null = null; @@ -26,6 +37,7 @@ class Paginator extends BasePaginator { constructor(options: PaginatorOptions = {}) { super(options); } + query(params: PaginationQueryParams): Promise> { const promise = new Promise>( (queryResolve, queryReject) => { @@ -57,7 +69,10 @@ describe('BasePaginator', () => { cursor: undefined, offset: 0, }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); }); + it('initiates with custom options', () => { const paginator = new Paginator({ pageSize: 1 }); expect(paginator.pageSize).not.toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); @@ -73,6 +88,7 @@ describe('BasePaginator', () => { }); }); }); + describe('pagination API', () => { it('paginates to next pages', async () => { const paginator = new Paginator(); @@ -225,4 +241,816 @@ describe('BasePaginator', () => { expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); }); }); + + describe('item management', () => { + const item: TestItem = { + id: 'id1', + name: 'test', + age: 100, + teams: ['abc', 'efg'], + }; + + const item2 = { + ...item, + id: 'id2', + name: 'test2', + age: 101, + }; + + const item3 = { + ...item, + id: 'id3', + name: 'test3', + age: 102, + }; + + describe('matchesFilter', () => { + it('returns true if no filter is provided', async () => { + const paginator = new Paginator(); + expect(paginator.matchesFilter(item)).toBeTruthy(); + }); + it('returns false if does not match the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + name: { $eq: 'test1' }, + }); + expect(paginator.matchesFilter(item)).toBeFalsy(); + }); + it('returns true if item matches the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + $or: [{ name: { $eq: 'test1' } }, { teams: { $contains: 'abc' } }], + }); + expect(paginator.matchesFilter(item)).toBeTruthy(); + }); + }); + + describe('ingestItem', () => { + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'exists but does not match the filter anymore removes the item %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + }); + + const adjustedItem = { + ...item, + teams: ['efg'], // removed from the team abc + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed + expect(paginator.items).toHaveLength(2); + }, + ); + + it.each([ + [' adjusts the order on lockItemOrder: false', false], + [' does not adjust the order on lockItemOrder: true', true], + ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sort = { age: 1 }; + + const adjustedItem = { + ...item, + age: 103, + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); + + if (lockItemOrder) { + expect(paginator.items).toStrictEqual([adjustedItem, item2, item3]); + } else { + expect(paginator.items).toStrictEqual([item2, item3, adjustedItem]); + } + }); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and does not match the filter results in no action %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem = { + ...item, + id: 'id2', + name: 'test2', + }; + + expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action + expect(paginator.items).toStrictEqual([item]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to default sort order (append) %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item, item2]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to sort order %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toHaveLength(3); + expect(paginator.items![0]).toStrictEqual(item3); + expect(paginator.items![1]).toStrictEqual(item2); + expect(paginator.items![2]).toStrictEqual(item); + }, + ); + + it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item]); + }); + + it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sort = { age: 1 }; + + const adjustedItem = { + ...item2, + age: 103, + }; + paginator.boost(item2.id); + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); + + expect(paginator.items).toStrictEqual([adjustedItem, item, item3]); + }); + + it('does not reflect the boost priority on lockItemOrder: true', () => { + const paginator = new Paginator({ lockItemOrder: true }); + paginator.state.partialNext({ + items: [item, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sort = { age: 1 }; + + const adjustedItem = { + ...item2, + age: 103, + }; + paginator.boost(item2.id); + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); + + expect(paginator.items).toStrictEqual([item, adjustedItem, item3]); + }); + + it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { + const paginator = new Paginator({ lockItemOrder: true }); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item]); + }); + }); + + describe('removeItem', () => { + it('removes existing item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toBeTruthy(); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item); + }); + + it('results in no action for non-existent item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item2, item], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toBeFalsy(); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item); + }); + }); + + describe('reload', () => { + it('starts the pagination from the beginning', async () => { + const a: TestItem = { id: 'a', age: 30 }; + const b: TestItem = { id: 'b', age: 25 }; + const c: TestItem = { id: 'c', age: 25 }; + const d: TestItem = { id: 'd', age: 20 }; + + const paginator = new Paginator(); + const nextSpy = vi.spyOn(paginator, 'next').mockResolvedValue(); + paginator.state.next({ + hasNext: false, + hasPrev: false, + isLoading: false, + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], + offset: 4, + }); + await paginator.reload(); + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + nextSpy.mockRestore(); + }); + }); + + describe('contains', () => { + it('returns true if the item exists', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + expect(paginator.contains(item3)).toBeTruthy(); + }); + + it('returns false if the items does not exist', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item2, item], + }); + expect(paginator.contains(item3)).toBeFalsy(); + }); + }); + + describe('locateByItem', () => { + const a: TestItem = { id: 'a', age: 30, name: 'A' }; + const b: TestItem = { id: 'b', age: 25, name: 'B' }; + const c: TestItem = { id: 'c', age: 25, name: 'C' }; + const d: TestItem = { id: 'd', age: 20, name: 'D' }; + + const tieBreakerById = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + it('returns {index:-1, insertionIndex:0} for empty list', () => { + const paginator = new Paginator(); + const res = paginator.locateByItem(a); + expect(res).toEqual({ index: -1, insertionIndex: 0 }); + }); + + it('finds an existing item on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + // comparator: age desc only (ties produce a plateau) + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + // items are already sorted by age desc + paginator.state.partialNext({ items: [a, b, c, d] }); + + const res = paginator.locateByItem(c); + expect(res.index).toBe(2); // c is at index 2 in [a, b, c, d] + // insertionIndex for identical key (age 25) is after the plateau + expect(res.insertionIndex).toBe(3); + }); + + it('returns insertion index when not found on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // same sort keys as b/c but different id; not present + const x: TestItem = { id: 'x', age: 25, name: 'X' }; + const res = paginator.locateByItem(x); + // insertion point should be after the 25-plateau (after c at index 2) + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(3); + }); + + it('finds exact index with ID tiebreaker in comparator (pure O(log n))', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + // tie-breaker on id asc guarantees a total order + tiebreaker: tieBreakerById, + }); + + // With tiebreaker, the order within age==25 is by id asc: b (id 'b'), then c (id 'c') + paginator.state.partialNext({ items: [a, b, c, d] }); + + const res = paginator.locateByItem(c); + expect(res.index).toBe(2); + // In this setting the insertionIndex is deterministic but not strictly needed when found + expect(res.insertionIndex).toBeGreaterThanOrEqual(2); + }); + + it('computes insertion at the beginning when needle sorts before all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 40, name: 'Z' }; // highest age → goes to front + const res = paginator.locateByItem(z); + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(0); + }); + + it('computes insertion at the end when needle sorts after all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 10, name: 'Z' }; // lowest age → goes to end + const res = paginator.locateByItem(z); + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(4); + }); + + it('checks both immediate neighbors before plateau scan (fast path)', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // needle equal to left neighbor of insertionIndex + const resLeftNeighbor = paginator.locateByItem(c); + expect(resLeftNeighbor.index).toBe(2); + + // needle equal to right neighbor (craft by duplicating c’s sort but different id not present) + const y: TestItem = { id: 'y', age: 25, name: 'Y' }; + const resRightNeighbor = paginator.locateByItem(y); + expect(resRightNeighbor.index).toBe(-1); + expect(resRightNeighbor.insertionIndex).toBe(3); + }); + }); + + describe('findItem', () => { + const a: TestItem = { id: 'a', age: 30 }; + const b: TestItem = { id: 'b', age: 25 }; + const c: TestItem = { id: 'c', age: 25 }; + const d: TestItem = { id: 'd', age: 20 }; + + it('returns the exact item instance when present', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // Same identity object: + expect(paginator.findItem(c)).toBe(c); + + // Same identity by id but different object reference still matches by locateByItem: + const cClone = { ...c }; + expect(paginator.findItem(cClone)).toBe(c); + }); + + it('returns undefined when not present', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, d] }); + + const needle: TestItem = { id: 'x', age: 25 }; + expect(paginator.findItem(needle)).toBeUndefined(); + }); + + it('works with an ID tie-breaker comparator as well', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + expect(paginator.findItem(c)).toBe(c); + const x: TestItem = { id: 'x', age: 25 }; + expect(paginator.findItem(x)).toBeUndefined(); + }); + + it('handles empty list', () => { + const paginator = new Paginator(); + expect(paginator.findItem({ id: 'z' })).toBeUndefined(); + }); + }); + + describe('filter resolvers', () => { + const resolvers1 = [{ matchesField: () => true, resolve: () => 'abc' }]; + const resolvers2 = [ + { matchesField: () => false, resolve: () => 'efg' }, + { matchesField: () => true, resolve: () => 'hij' }, + ]; + it('get overridden with setFilterResolvers', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + + paginator.setFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers1.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.setFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers2.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers2); + + paginator.setFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + }); + + it('get expanded with addFilterResolvers', () => { + const paginator = new Paginator(); + paginator.addFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.addFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + + paginator.addFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + }); + }); + + describe('item boosting', () => { + const a = { id: 'a', age: 10, name: 'A' } as TestItem; + const b = { id: 'b', age: 20, name: 'B' } as TestItem; + const c = { id: 'c', age: 30, name: 'C' } as TestItem; + + const byIdAsc = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + describe('clearExpiredBoosts', () => { + it('removes expired boosts and updates maxBoostSeq', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.boosts.clear(); + const now = 1000000; + + paginator.boost('fresh', { until: now + 1000, seq: 1 }); + paginator.boost('stale', { until: now - 1, seq: 5 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(now); + + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.keys())).toEqual(['fresh']); + expect(paginator.maxBoostSeq).toBe(1); + }); + + it('sets maxBoostSeq to 0 when no boosts remain', () => { + const paginator = new Paginator(); + // two expired boosts at "now" + paginator.boost('x', { until: 1000, seq: 1 }); + paginator.boost('y', { until: 1500, seq: 3 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(10000); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('boostComparator', () => { + it('prioritizes boosted over non-boosted', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + // Boost only "a" + paginator.boost('b', { ttlMs: 10000, seq: 0 }); + + // @ts-expect-error: protected method + expect(paginator.boostComparator(a, b)).toBe(1); // a after b + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); // b stays before a + + // Let boost expire + vi.setSystemTime(new Date(now.getTime() + 11000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // fallback to byIdAsc + vi.useRealTimers(); + }); + + it('when both boosted, higher seq comes first; ties fall back to sortComparator', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + // Fallback comparator id asc + paginator.sortComparator = byIdAsc; + + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 3 }); + + // b has higher seq → should come first → comparator(a,b) > 0 + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + // reverse check + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); + + // Equal seq → fall back to sortComparator (id asc => a before b) + paginator.boost('a', { ttlMs: 60000, seq: 2 }); + paginator.boost('b', { ttlMs: 60000, seq: 2 }); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); + + vi.useRealTimers(); + }); + + it('ignores expired boosts automatically during comparison', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + paginator.boost('b', { ttlMs: 5000, seq: 10 }); + // Initially boosted + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + + // Advance beyond TTL so boost is expired; comparator should fall back + vi.setSystemTime(new Date(now.getTime() + 6000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // byIdAsc, not boost + vi.useRealTimers(); + }); + }); + + describe('boost', () => { + it('assigns default TTL (15s) and default seq=0; updates maxBoostSeq only upward', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + + paginator.boost('k'); // default 15s, seq 0 + const b1 = paginator.getBoost('k')!; + expect(b1.seq).toBe(0); + expect(b1.until).toBe(now.getTime() + 15000); + expect(paginator.maxBoostSeq).toBe(0); + + // Raise max seq + paginator.boost('m', { ttlMs: 1000, seq: 5 }); + expect(paginator.maxBoostSeq).toBe(5); + + // Lower seq should NOT decrease maxBoostSeq + paginator.boost('n', { ttlMs: 1000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + vi.useRealTimers(); + }); + + it('accepts explicit until and seq', () => { + const paginator = new Paginator(); + paginator.boost('z', { until: 42, seq: 7 }); + const b = paginator.getBoost('z')!; + expect(b.until).toBe(42); + expect(b.seq).toBe(7); + expect(paginator.maxBoostSeq).toBe(7); + }); + }); + + describe('getBoost', () => { + it('returns the boost record when present; otherwise undefined', () => { + const paginator = new Paginator(); + expect(paginator.getBoost('missing')).toBeUndefined(); + paginator.boost('a', { ttlMs: 1000, seq: 1 }); + const b = paginator.getBoost('a'); + expect(b).toBeDefined(); + expect(b!.seq).toBe(1); + }); + }); + + describe('removeBoost', () => { + it('removes a boost and recalculates maxBoostSeq', () => { + const paginator = new Paginator(); + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 5 }); + paginator.boost('c', { ttlMs: 60000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + paginator.removeBoost('b'); // remove current max + expect(paginator.getBoost('b')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(2); + + paginator.removeBoost('c'); + expect(paginator.getBoost('c')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(1); + + paginator.removeBoost('a'); + expect(paginator.getBoost('a')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('isBoosted', () => { + it('returns true when boost exists and now <= until; false otherwise', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + expect(paginator.isBoosted('x')).toBe(false); + + paginator.boost('x', { ttlMs: 5000, seq: 0 }); + expect(paginator.isBoosted('x')).toBe(true); + + // Exactly at until is still considered boosted per <= check + vi.setSystemTime(new Date(now.getTime() + 5000)); + expect(paginator.isBoosted('x')).toBe(true); + + // After until → false + vi.setSystemTime(new Date(now.getTime() + 5001)); + expect(paginator.isBoosted('x')).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('integration: ingestion respects boostComparator implicitly', () => { + it('newly ingested boosted items float above non-boosted regardless of fallback sort', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: 1 }, // ascending age (so normally a < b < c by age) + }); + paginator.state.partialNext({ items: [a, b] }); + + // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest + paginator.boost('c', { ttlMs: 60000, seq: 1 }); + expect(paginator.ingestItem(c)).toBeTruthy(); + + // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) + expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); + + vi.useRealTimers(); + }); + }); + }); + }); }); diff --git a/test/unit/pagination/ChannelPaginator.test.ts b/test/unit/pagination/ChannelPaginator.test.ts new file mode 100644 index 000000000..b33b18a8b --- /dev/null +++ b/test/unit/pagination/ChannelPaginator.test.ts @@ -0,0 +1,441 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + Channel, + type ChannelFilters, + ChannelOptions, + ChannelPaginator, + ChannelSort, + DEFAULT_PAGINATION_OPTIONS, + type FilterBuilderGenerators, + type StreamChat, +} from '../../../src'; +import { getClientWithUser } from '../test-utils/getClient'; +import type { FieldToDataResolver } from '../../../src/pagination/types.normalization'; + +const user = { id: 'custom-id' }; + +describe('ChannelPaginator', () => { + let client: StreamChat; + let channel1: Channel; + let channel2: Channel; + + beforeEach(() => { + client = getClientWithUser(user); + + channel1 = new Channel(client, 'type', 'id1', {}); + channel1.state.last_message_at = new Date('1972-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; + + channel2 = new Channel(client, 'type', 'id1', {}); + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + }); + + it('initiates with defaults', () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); + expect(paginator.sortComparator).toBeDefined(); + + channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + + expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({}); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), + ).toStrictEqual({}); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + }); + + it('initiates with options', () => { + const customId = 'custom-id'; + const filterGenerators: FilterBuilderGenerators = { + custom: { + enabled: true, + generate: (context) => context, + }, + }; + const initialFilterBuilderContext = { x: 'y' }; + + channel1.data!.created_at = '1970-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + + const paginator = new ChannelPaginator({ + client, + id: customId, + filterBuilderOptions: { + initialContext: initialFilterBuilderContext, + initialFilterConfig: filterGenerators, + }, + filters: { type: 'type' }, + paginatorOptions: { pageSize: 2 }, + requestOptions: { member_limit: 5 }, + sort: { created_at: 1 }, + }); + expect(paginator.pageSize).toBe(2); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.id.startsWith(customId)).toBeTruthy(); + + expect(paginator.sortComparator(channel1, channel2)).toBe(-1); // channel1 comes before channel2 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({ + ...initialFilterBuilderContext, + }); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), + ).toStrictEqual({ + type: 'type', + ...initialFilterBuilderContext, + }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + }); + + describe('sortComparator', () => { + const changeOrder = 1; + const keepOrder = -1; + it('should sort be default sort', () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + + channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + + it('should sort by non-existent attribute', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + expect(paginator.sortComparator(channel1, channel2)).toBe(0); + }); + + it('should sort by attribute with the same values', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + channel1.data!.created_at = '1971-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(0); + }); + + it('should sort by created_at', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + channel1.data!.created_at = '1972-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by has_unread', () => { + const paginator = new ChannelPaginator({ client, sort: { has_unread: 1 } }); + channel1.state.read[user.id] = { + last_read: new Date('1972-01-01T08:39:35.235Z'), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date('1972-01-01T08:39:35.235Z'), + unread_messages: 0, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by last_message_at', () => { + const paginator = new ChannelPaginator({ client, sort: { last_message_at: 1 } }); + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by last_updated', () => { + const paginator = new ChannelPaginator({ client, sort: { last_updated: 1 } }); + + // compares channel1.state.last_message_at with channel2.data!.updated_at + channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + // compares channel2.state.last_message_at with channel1.data!.updated_at + channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1976-01-01T08:39:35.235Z'; + channel2.state.last_message_at = new Date('1978-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by member_count', () => { + const paginator = new ChannelPaginator({ client, sort: { member_count: 1 } }); + channel1.data!.member_count = 2; + channel2.data!.member_count = 1; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by pinned_at', () => { + const paginator = new ChannelPaginator({ client, sort: { pinned_at: 1 } }); + channel1.state.membership = { pinned_at: '1972-01-01T08:39:35.235Z' }; + channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.state.membership = { pinned_at: '1970-01-01T08:39:35.235Z' }; + channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by unread_count', () => { + const paginator = new ChannelPaginator({ client, sort: { unread_count: 1 } }); + channel1.state.read[user.id] = { + last_read: new Date(), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date(), + unread_messages: 0, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.state.read[user.id] = { + last_read: new Date(), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date(), + unread_messages: 11, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by updated_at', () => { + const paginator = new ChannelPaginator({ client, sort: { updated_at: 1 } }); + + channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by custom field', () => { + // @ts-expect-error using field not declared among CustomChannelData + const paginator = new ChannelPaginator({ client, sort: { customField: 1 } }); + + // @ts-expect-error using field not declared among CustomChannelData + channel1.data!.customField = 'B'; + // @ts-expect-error using field not declared among CustomChannelData + channel2.data!.customField = 'A'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + // @ts-expect-error using field not declared among CustomChannelData + channel1.data!.customField = 'A'; + // @ts-expect-error using field not declared among CustomChannelData + channel2.data!.customField = 'B'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + }); + + describe('filter resolvers', () => { + it('resolves "pinned" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, pinned: true }, + }); + + channel1.state.members = { + [user.id]: { user }, + ['other-member']: { user: { id: 'other-member' } }, + }; + + channel1.state.membership = { + user, + pinned_at: '2025-09-03T12:19:39.101089Z', + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.membership = { + user, + pinned_at: undefined, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves "members" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] } }, + }); + channel1.state.members = { + [user.id]: { user }, + ['other-member']: { user: { id: 'other-member' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.members = { + ['other-member']: { user: { id: 'other-member' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves "member.user.name" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { 'member.user.name': { $autocomplete: '-' } }, + }); + channel1.state.members = { + [user.id]: { user: { ...user, name: 'name' } }, + ['other-member']: { user: { id: 'other-member', name: 'na-me' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.members = { + [user.id]: { user: { ...user, name: 'name' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves ChannelResponse fields', () => { + const paginator = new ChannelPaginator({ client, filters: { blocked: true } }); + channel1.data!.blocked = true; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.data!.blocked = false; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves custom fields stored in channel.data', () => { + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error declaring custom property field in filter + filters: { x: { $contains: 'specific' } }, + }); + // @ts-expect-error using undeclared custom property + channel1.data!.x = ['a', 'b', 'specific']; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + // @ts-expect-error using undeclared custom property + channel1.data!.x = undefined; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('overrides filter resolvers', () => { + const resolver: FieldToDataResolver = { + matchesField: (field) => field === 'custom.nested', + resolve: (item, field) => { + // @ts-expect-error accessing undeclared custom property + return item.data!.custom?.nested; + }, + }; + + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error using undeclared custom property + filters: { 'custom.nested': { $eq: 'x' } }, + }); + paginator.setFilterResolvers([resolver]); + + // @ts-expect-error using undeclared custom property + channel1.data!.custom = { nested: 'x' }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + // @ts-expect-error using undeclared custom property + channel1.data!.custom = { nested: 'y' }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + }); + + describe('setters', () => { + const stateAfterQuery = { + items: [channel1, channel2], + hasNext: false, + hasPrev: false, + offset: 10, + isLoading: false, + lastQueryError: undefined, + cursor: undefined, + }; + it('filters reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.filters = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + it('sort reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.sort = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + it('options reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.options = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + }); + + describe('query', () => { + it('is called with correct parameters', async () => { + const queryChannelsSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + const filters: ChannelFilters = { name: 'A' }; + const sort: ChannelSort = { has_unread: -1 }; + const requestOptions: ChannelOptions = { message_limit: 3 }; + const paginator = new ChannelPaginator({ + client, + filters, + sort, + requestOptions, + filterBuilderOptions: { + initialFilterConfig: { + custom: { + enabled: true, + generate: (context: { num?: number }) => ({ + muted: { $eq: !!context.num }, + }), + }, + }, + initialContext: { num: 5 }, + }, + paginatorOptions: { pageSize: 22 }, + }); + + await paginator.query(); + expect(queryChannelsSpy).toHaveBeenCalledWith( + { + muted: { + $eq: true, + }, + name: 'A', + }, + { + has_unread: -1, + }, + { + limit: 22, + message_limit: 3, + offset: 0, + }, + ); + }); + }); +}); diff --git a/test/unit/pagination/FilterBuilder.test.ts b/test/unit/pagination/FilterBuilder.test.ts index 7be4dfb3f..2935b4bf1 100644 --- a/test/unit/pagination/FilterBuilder.test.ts +++ b/test/unit/pagination/FilterBuilder.test.ts @@ -4,7 +4,7 @@ import { FilterBuilderGenerators, ExtendedQueryFilter, ExtendedQueryFilters, -} from '../../../src/pagination/FilterBuilder'; +} from '../../../src'; type BasicFilterFieldsSchema = { name: ExtendedQueryFilter; diff --git a/test/unit/pagination/filterCompiler.test.ts b/test/unit/pagination/filterCompiler.test.ts new file mode 100644 index 000000000..38f96b6e1 --- /dev/null +++ b/test/unit/pagination/filterCompiler.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from 'vitest'; +import { + ChannelData, + ChannelMemberResponse, + ChannelResponse, + ContainsOperator, + PrimitiveFilter, + QueryFilter, + QueryFilters, + RequireOnlyOne, +} from '../../../src'; +import { + itemMatchesFilter, + ItemMatchesFilterOptions, +} from '../../../src/pagination/filterCompiler'; +import { resolveDotPathValue } from '../../../src/pagination/utility.normalization'; + +type CustomChannelData = { + custom1?: string[]; + custom2?: string; + custom3?: number; + custom4?: boolean; + custom5?: string; + data?: { + members: ChannelMemberResponse[]; + }; + name?: string; +}; +type CustomChannelFilters = QueryFilters< + ContainsOperator> & { + archived?: boolean; + 'member.user.name'?: + | RequireOnlyOne<{ + $autocomplete?: string; + $eq?: string; + }> + | string; + + members?: + | RequireOnlyOne, '$in'>> + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + name?: + | RequireOnlyOne< + { + $autocomplete?: string; + } & QueryFilter + > + | PrimitiveFilter; + pinned?: boolean; + } & { + [Key in keyof Omit]: + | RequireOnlyOne> + | PrimitiveFilter; + } +>; + +type TestChannel = ChannelData & CustomChannelData; + +const filter: CustomChannelFilters = { + $or: [ + { + $and: [ + { custom1: { $contains: 'a' } }, + { custom2: { $eq: '5' } }, + { custom3: { $lt: 10 } }, + { custom4: { $eq: true } }, + ], + }, + { + $and: [ + { custom1: { $contains: 'b' } }, + { custom2: { $eq: '15' } }, + { custom3: { $lt: 10 } }, + { custom4: { $eq: false } }, + ], + }, + { + $or: [ + { name: { $autocomplete: 'ith' } }, + { name: { $autocomplete: 'Sm' } }, + { 'member.user.name': { $autocomplete: 'ack' } }, + { blocked: true }, + { custom2: { $eq: '5' } }, + { custom2: { $lt: '2020-08-26T11:09:07.814Z' } }, + { custom2: { $gt: '2022-08-26T11:09:07.814Z' } }, + { custom3: { $gt: 10 } }, + { custom4: { $exists: true } }, + { custom1: { $contains: 'b' } }, + { custom5: { $in: ['Rob', 'Bob'] } }, + ], + }, + ], +}; + +const options: ItemMatchesFilterOptions = { + resolvers: [ + { + matchesField: () => true, + resolve: (item, path) => resolveDotPathValue(item, path), + }, + ], +}; + +describe('itemMatchesFilter', () => { + it('determines that data do not match the filter', () => { + const item: TestChannel = {}; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match a primitive filter', () => { + const item: TestChannel = { blocked: true }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match a primitive filter', () => { + const item: TestChannel = { blocked: undefined }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $eq filter', () => { + const item: TestChannel = { custom2: '5' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $eq filter', () => { + const item: TestChannel = { custom2: '55' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data match the $ne filter', () => { + const item: TestChannel = {}; + expect( + itemMatchesFilter(item, { name: { $ne: 'Channel Bob' } }, options), + ).toBeTruthy(); + }); + + it('determines that data do not match the $ne filter', () => { + const item: TestChannel = { name: 'Channel Bob' }; + expect( + itemMatchesFilter(item, { name: { $ne: 'Channel Bob' } }, options), + ).toBeFalsy(); + }); + + it('determines that data match the number comparison filter', () => { + const item: TestChannel = { custom3: 11 }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the number comparison filter', () => { + const item: TestChannel = { custom3: 10 }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the date comparison filter', () => { + const item: TestChannel = { custom2: '2020-08-26T11:09:07.714Z' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the date comparison filter', () => { + const item: TestChannel = { custom2: '2021-08-26T11:09:07.714Z' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $exists filter', () => { + // @ts-expect-error custom4 does not match the TestChannel definition + const item: TestChannel = { custom4: ['a', '5'] }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $exists filter', () => { + // @ts-expect-error custom3 does not match the TestChannel definition + const item: TestChannel = { custom3: ['a', 5] }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $autocomplete filter', () => { + const item: TestChannel = { name: 'Smith' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $autocomplete filter', () => { + const item: TestChannel = { name: 'it' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $contains filter', () => { + const item: TestChannel = { custom1: ['a', 'b', 'c'] }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $contains filter', () => { + const item: TestChannel = { custom1: ['a', 'bb', 'c'] }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $in filter', () => { + const item: TestChannel = { custom5: 'Rob' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $in filter', () => { + const item: TestChannel = { custom5: 'Ro' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $nin filter', () => { + const item: TestChannel = { custom5: 'Ro' }; + expect( + itemMatchesFilter( + item, + { custom5: { $nin: ['Rob', 'Bob'] } }, + options, + ), + ).toBeTruthy(); + }); + + it('determines that data do not match the $nin filter', () => { + const item: TestChannel = { custom5: 'Rob' }; + expect( + itemMatchesFilter( + item, + { custom5: { $nin: ['Rob', 'Bob'] } }, + options, + ), + ).toBeFalsy(); + }); + + it('determines that data match the $and filter', () => { + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 9, + custom4: false, + }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $and filter', () => { + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 10, + custom4: false, + }; + const andFilters = filter.$or!.slice(0, 2); + // @ts-ignore + expect( + itemMatchesFilter(item, { $or: andFilters }, options), + ).toBeFalsy(); + }); + + it('determines that data match the $nor filter', () => { + const item: TestChannel = { + custom1: ['x', 'y'], + // @ts-expect-error custom2 does not match the TestChannel definition + custom2: { a: 'b' }, + // @ts-expect-error custom3 does not match the TestChannel definition + custom3: true, + custom4: false, + }; + expect( + itemMatchesFilter(item, { $nor: filter.$or }, options), + ).toBeTruthy(); + }); + + it('determines that data do not match the $nor filter', () => { + // matches the 2nd $and + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 9, + custom4: false, + }; + expect( + itemMatchesFilter(item, { $nor: filter.$or }, options), + ).toBeFalsy(); + }); + + it('determines that data match filter by property dot path', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '1', name: 'Jack' } }, + { user: { id: '2', name: 'Bob' } }, + { user: { id: '3', name: 'Mark' } }, + ], + }, + }; + + expect( + itemMatchesFilter( + item, + { 'member.user.name': { $autocomplete: 'rk' } }, + { + resolvers: [ + { + matchesField: (field) => field === 'member.user.name', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.name) ?? []; + }, + }, + ], + }, + ), + ).toBeTruthy(); + }); + + it('determines that data match filter by $eq: array', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '123', name: 'Jack' } }, + { user: { id: '234', name: 'Bob' } }, + { user: { id: '345', name: 'Mark' } }, + ], + }, + }; + + // has to match all the ids + expect( + itemMatchesFilter( + item, + { members: { $eq: ['345', '123', '234'] } }, + { + resolvers: [ + { + matchesField: (field) => field === 'members', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.id) ?? []; + }, + }, + ], + }, + ), + ).toBeTruthy(); + }); + + it('determines that data do not match filter by $eq: array', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '123', name: 'Jack' } }, + { user: { id: '234', name: 'Bob' } }, + { user: { id: '345', name: 'Mark' } }, + ], + }, + }; + + // one id is missing + expect( + itemMatchesFilter( + item, + { members: { $eq: ['123', '234'] } }, + { + resolvers: [ + { + matchesField: (field) => field === 'members', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.id) ?? []; + }, + }, + ], + }, + ), + ).toBeFalsy(); + }); +}); diff --git a/test/unit/pagination/sortCompiler.test.ts b/test/unit/pagination/sortCompiler.test.ts new file mode 100644 index 000000000..500ab3eaf --- /dev/null +++ b/test/unit/pagination/sortCompiler.test.ts @@ -0,0 +1,267 @@ +// sortCompiler.spec.ts +import { describe, it, expect } from 'vitest'; +import { + binarySearchInsertIndex, + makeComparator, +} from '../../../src/pagination/sortCompiler'; +import { resolveDotPathValue as defaultResolvePathValue } from '../../../src/pagination/utility.normalization'; +import type { AscDesc } from '../../../src'; + +// Minimal item type for tests +type Item = { + cid: string; // tie-breaker field (default tiebreak compares by cid) + v?: unknown; // primary field for many tests + nested?: { x?: unknown }; // nested field for dot-path tests +}; + +// Small utility: sort a shallow copy and return cids to verify ordering +function orderByComparator(items: Item[], cmp: (a: Item, b: Item) => number): string[] { + return [...items].sort(cmp).map((i) => i.cid); +} + +/** + * Helper to build a comparator with optional resolvePathValue override. + */ +function toComparator( + sort: Record | Array>, + resolvePathValue = defaultResolvePathValue, +) { + return makeComparator | Array>>({ + sort, + resolvePathValue, + }); +} + +describe('makeComparator', () => { + it('sorts numbers ascending/descending', () => { + const items: Item[] = [ + { cid: 'c', v: 10 }, + { cid: 'a', v: 2 }, + { cid: 'b', v: 2 }, // equal to test tie-breaker by cid + { cid: 'd', v: 100 }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['a', 'b', 'c', 'd']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['d', 'c', 'a', 'b']); + }); + + it('sorts strings ascending/descending with tie-break on cid', () => { + const items: Item[] = [ + { cid: '2', v: 'beta' }, + { cid: '1', v: 'alpha' }, + { cid: '4', v: 'alpha' }, // same string as cid=1; tie-break by cid + { cid: '3', v: 'gamma' }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['1', '4', '2', '3']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['3', '2', '1', '4']); + }); + + it('sorts booleans (false < true)', () => { + const items: Item[] = [ + { cid: 'c', v: true }, + { cid: 'a', v: false }, + { cid: 'b', v: false }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['a', 'b', 'c']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['c', 'a', 'b']); + }); + + it('sorts dates (Date objects) descending', () => { + const items: Item[] = [ + { cid: 'a', v: new Date('2023-01-01T00:00:00Z') }, + { cid: 'b', v: new Date('2024-01-01T00:00:00Z') }, + { cid: 'c', v: new Date('2022-06-15T00:00:00Z') }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('sorts dates given as ISO strings equivalently to Date objects', () => { + const items: Item[] = [ + { cid: 'a', v: '2023-01-01T00:00:00Z' }, + { cid: 'b', v: '2024-01-01T00:00:00Z' }, + { cid: 'c', v: '2022-06-15T00:00:00Z' }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('sorts dates given as epoch ms (numbers) equivalently', () => { + const items: Item[] = [ + { cid: 'a', v: Date.parse('2023-01-01T00:00:00Z') }, + { cid: 'b', v: Date.parse('2024-01-01T00:00:00Z') }, + { cid: 'c', v: Date.parse('2022-06-15T00:00:00Z') }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('uses resolvePathValue for nested paths', () => { + const items: Item[] = [ + { cid: 'a', nested: { x: 100 } }, + { cid: 'b', nested: { x: 50 } }, + { cid: 'c', nested: { x: 75 } }, + ]; + + const cmp = toComparator({ 'nested.x': 1 }); + expect(orderByComparator(items, cmp)).toEqual(['b', 'c', 'a']); + }); + + it('applies multi-field sorting in order (then uses cid tiebreaker)', () => { + const items: Item[] = [ + { cid: '3', v: 1, nested: { x: 5 } }, + { cid: '1', v: 1, nested: { x: 10 } }, + { cid: '2', v: 1, nested: { x: 10 } }, + { cid: '4', v: 2, nested: { x: 0 } }, + ]; + + // First by v asc, then nested.x desc; if both equal, tie-break by cid asc + const cmp = toComparator([{ v: 1 }, { 'nested.x': -1 }]); + expect(orderByComparator(items, cmp)).toEqual(['1', '2', '3', '4']); + }); + + it('fallback ordering: null/undefined come last (ascending) and first (descending)', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: undefined }, + { cid: 'c', v: null }, + { cid: 'd', v: 5 }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); // null/undefined last + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'c', 'a', 'd']); // null/undefined first + }); + + it('applies custom tiebreaker when provided', () => { + const items: Item[] = [ + { cid: 'b', v: 1 }, + { cid: 'a', v: 1 }, + { cid: 'c', v: 1 }, + ]; + + const customTiebreaker = (l: Item, r: Item) => r.cid.localeCompare(l.cid); + + const cmp = makeComparator>({ + sort: { v: 1 }, // all v equal + resolvePathValue: defaultResolvePathValue, + tiebreaker: customTiebreaker, + }); + + expect(orderByComparator(items, cmp)).toEqual(['c', 'b', 'a']); + }); + + it('accepts array sort spec and object sort spec equivalently', () => { + const items: Item[] = [ + { cid: '3', v: 2 }, + { cid: '1', v: 1 }, + { cid: '2', v: 1 }, + ]; + + const arrayBasedComparator = toComparator([{ v: 1 }]); + const objectBasedComparator = toComparator({ v: 1 }); + + expect(orderByComparator(items, arrayBasedComparator)).toEqual(['1', '2', '3']); + expect(orderByComparator(items, objectBasedComparator)).toEqual(['1', '2', '3']); + }); +}); + +describe('binarySearchInsertIndex', () => { + it('inserts at beginning, middle, and end as expected', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: 20 }, + { cid: 'c', v: 30 }, + { cid: 'd', v: 40 }, + ]; + const cmp = toComparator({ v: 1 }); + + // Insert before all + let index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'x', v: 5 }, + compare: cmp, + }); + expect(index).toBe(0); + + // Insert in the middle + index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'y', v: 25 }, + compare: cmp, + }); + expect(index).toBe(2); // between 20 and 30 + + // Insert after all + index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'z', v: 50 }, + compare: cmp, + }); + expect(index).toBe(4); + }); + + it('inserts after equal values block (stable position after equals)', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: 10 }, + { cid: 'c', v: 10 }, + ]; + const cmp = toComparator({ v: 1 }); + + const index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'x', v: 10 }, + compare: cmp, + }); + + // By design, our binary search returns the first position where existing > needle. + // For equals, it advances to the right of the equal block. + expect(index).toBe(3); + }); + + it('respects multi-field comparator (e.g., secondary key decides insertion point)', () => { + const items: Item[] = [ + { cid: '2', v: 1, nested: { x: 5 } }, + { cid: '1', v: 1, nested: { x: 10 } }, // comes earlier due to nested.x desc + { cid: '3', v: 2, nested: { x: 0 } }, + ]; + const cmp = toComparator([{ v: 1 }, { 'nested.x': -1 }]); + + // Needle with same v=1 but nested.x=7 should go between cid=1 (x=10) and cid=2 (x=5) + const index = binarySearchInsertIndex({ + sortedArray: orderByComparator(items, cmp).map( + (cid) => items.find((i) => i.cid === cid)!, + ) as Item[], + needle: { cid: 'x', v: 1, nested: { x: 7 } }, + compare: cmp, + }); + + expect(index).toBe(1); // after the 10, before the 5 + }); +});