@@ -35,16 +35,19 @@ export type PaginatorState<T = any> = {
3535export type PaginatorOptions = {
3636 /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
3737 debounceMs ?: number ;
38+ /** Will prevent changing the index of existing items */
39+ lockItemOrder ?: boolean ;
3840 pageSize ?: number ;
3941} ;
4042export const DEFAULT_PAGINATION_OPTIONS : Required < PaginatorOptions > = {
4143 debounceMs : 300 ,
44+ lockItemOrder : false ,
4245 pageSize : 10 ,
4346} as const ;
4447
4548export abstract class BasePaginator < T > {
4649 state : StateStore < PaginatorState < T > > ;
47- pageSize : number ;
50+ config : Required < PaginatorOptions > ;
4851 protected _executeQueryDebounced ! : DebouncedExecQueryFunction ;
4952 protected _isCursorPagination = false ;
5053 /**
@@ -72,10 +75,16 @@ export abstract class BasePaginator<T> {
7275 * @protected
7376 */
7477 protected _filterFieldToDataResolvers : FieldToDataResolver < T > [ ] ;
78+ /**
79+ * Ephemeral priority for attention UX without breaking sort invariants
80+ * @protected
81+ */
82+ protected boosts = new Map < string , { until : number ; seq : number } > ( ) ;
83+ protected _maxBoostSeq : number = 0 ;
7584
7685 protected constructor ( options ?: PaginatorOptions ) {
77- const { debounceMs , pageSize } = { ...DEFAULT_PAGINATION_OPTIONS , ...options } ;
78- this . pageSize = pageSize ;
86+ this . config = { ...DEFAULT_PAGINATION_OPTIONS , ...options } ;
87+ const { debounceMs } = this . config ;
7988 this . state = new StateStore < PaginatorState < T > > ( this . initialState ) ;
8089 this . setDebounceOptions ( { debounceMs } ) ;
8190 this . sortComparator = noOrderChange ;
@@ -126,6 +135,19 @@ export abstract class BasePaginator<T> {
126135 return this . state . getLatestValue ( ) . offset ;
127136 }
128137
138+ get pageSize ( ) {
139+ return this . config . pageSize ;
140+ }
141+
142+ /** Single point of truth: always use the effective comparator */
143+ get effectiveComparator ( ) {
144+ return this . boostComparator ;
145+ }
146+
147+ get maxBoostSeq ( ) {
148+ return this . _maxBoostSeq ;
149+ }
150+
129151 abstract query ( params : PaginationQueryParams ) : Promise < PaginationQueryReturnValue < T > > ;
130152
131153 abstract filterQueryResults ( items : T [ ] ) : T [ ] | Promise < T [ ] > ;
@@ -149,37 +171,98 @@ export abstract class BasePaginator<T> {
149171 } ) ;
150172 }
151173
174+ protected clearExpiredBoosts ( now = Date . now ( ) ) {
175+ for ( const [ id , b ] of this . boosts ) if ( now > b . until ) this . boosts . delete ( id ) ;
176+ this . _maxBoostSeq = Math . max (
177+ ...Array . from ( this . boosts . values ( ) ) . map ( ( boost ) => boost . seq ) ,
178+ 0 ,
179+ ) ;
180+ }
181+
182+ /** Comparator that consults boosts first, then falls back to sortComparator */
183+ protected boostComparator = ( a : T , b : T ) : number => {
184+ const now = Date . now ( ) ;
185+ this . clearExpiredBoosts ( now ) ;
186+
187+ const idA = this . getItemId ( a ) ;
188+ const idB = this . getItemId ( b ) ;
189+ const boostA = this . getBoost ( idA ) ;
190+ const boostB = this . getBoost ( idB ) ;
191+
192+ const aIsBoosted = ! ! ( boostA && now <= boostA . until ) ;
193+ const bIsBoosted = ! ! ( boostB && now <= boostB . until ) ;
194+
195+ if ( aIsBoosted && ! bIsBoosted ) return - 1 ;
196+ if ( ! aIsBoosted && bIsBoosted ) return 1 ;
197+
198+ if ( aIsBoosted && bIsBoosted ) {
199+ // higher seq wins
200+ const seqDistance = ( boostB . seq ?? 0 ) - ( boostA . seq ?? 0 ) ;
201+ if ( seqDistance !== 0 ) return seqDistance > 0 ? 1 : - 1 ;
202+ // fall through to normal comparator for stability
203+ }
204+ return this . sortComparator ( a , b ) ;
205+ } ;
206+
207+ /** Public API to manage boosts */
208+ boost ( id : string , opts ?: { ttlMs ?: number ; until ?: number ; seq ?: number } ) {
209+ const now = Date . now ( ) ;
210+ const until = opts ?. until ?? ( opts ?. ttlMs != null ? now + opts . ttlMs : now + 15000 ) ; // default 15s
211+
212+ if ( typeof opts ?. seq === 'number' && opts . seq > this . _maxBoostSeq ) {
213+ this . _maxBoostSeq = opts . seq ;
214+ }
215+
216+ const seq = opts ?. seq ?? 0 ;
217+ this . boosts . set ( id , { until, seq } ) ;
218+ }
219+
220+ getBoost ( id : string ) {
221+ return this . boosts . get ( id ) ;
222+ }
223+
224+ removeBoost ( id : string ) {
225+ this . boosts . delete ( id ) ;
226+ this . _maxBoostSeq = Math . max (
227+ ...Array . from ( this . boosts . values ( ) ) . map ( ( boost ) => boost . seq ) ,
228+ 0 ,
229+ ) ;
230+ }
231+
232+ isBoosted ( id : string ) {
233+ const boost = this . getBoost ( id ) ;
234+ return ! ! ( boost && Date . now ( ) <= boost . until ) ;
235+ }
236+
152237 ingestItem ( ingestedItem : T ) : boolean {
153238 const items = this . items ?? [ ] ;
154239 const id = this . getItemId ( ingestedItem ) ;
155-
240+ const next = items . slice ( ) ;
156241 // If it doesn't match this paginator's filters, remove if present and exit.
157242 const existingIndex = items . findIndex ( ( ch ) => this . getItemId ( ch ) === id ) ;
158243 if ( ! this . matchesFilter ( ingestedItem ) ) {
159244 if ( existingIndex >= 0 ) {
160- const next = items . slice ( ) ;
161245 next . splice ( existingIndex , 1 ) ;
162246 this . state . partialNext ( { items : next } ) ;
163247 return true ; // list changed (item removed)
164248 }
165249 return false ; // no change
166250 }
167251
168- // Build comparator once per call (you can cache it when sort changes).
169-
170- const next = items . slice ( ) ;
171-
172252 if ( existingIndex >= 0 ) {
173253 // Update existing: remove then re-insert at the correct position
174254 next . splice ( existingIndex , 1 ) ;
175255 }
176256
177- // Find insertion index via binary search: first index where existing > ingestionItem
178- const insertAt = binarySearchInsertIndex ( {
179- needle : ingestedItem ,
180- sortedArray : next ,
181- compare : this . sortComparator ,
182- } ) ;
257+ const insertAt =
258+ this . config . lockItemOrder && existingIndex >= 0
259+ ? existingIndex
260+ : // Find insertion index via binary search: first index where existing > ingestionItem
261+ binarySearchInsertIndex ( {
262+ needle : ingestedItem ,
263+ sortedArray : next ,
264+ compare : this . effectiveComparator ,
265+ } ) ;
183266
184267 next . splice ( insertAt , 0 , ingestedItem ) ;
185268 this . state . partialNext ( { items : next } ) ;
@@ -246,18 +329,18 @@ export abstract class BasePaginator<T> {
246329 const insertionIndex = binarySearchInsertIndex ( {
247330 needle,
248331 sortedArray : items ,
249- compare : this . sortComparator ,
332+ compare : this . effectiveComparator ,
250333 } ) ;
251334
252335 // quick neighbor checks
253336 const id = this . getItemId ( needle ) ;
254337 const left = insertionIndex - 1 ;
255- if ( left >= 0 && this . sortComparator ( items [ left ] , needle ) === 0 ) {
338+ if ( left >= 0 && this . effectiveComparator ( items [ left ] , needle ) === 0 ) {
256339 if ( this . getItemId ( items [ left ] ) === id ) return { index : left , insertionIndex } ;
257340 }
258341 if (
259342 insertionIndex < items . length &&
260- this . sortComparator ( items [ insertionIndex ] , needle ) === 0
343+ this . effectiveComparator ( items [ insertionIndex ] , needle ) === 0
261344 ) {
262345 if ( this . getItemId ( items [ insertionIndex ] ) === id )
263346 return { index : insertionIndex , insertionIndex } ;
@@ -269,14 +352,14 @@ export abstract class BasePaginator<T> {
269352 ? locateOnPlateauAlternating (
270353 items ,
271354 needle ,
272- this . sortComparator ,
355+ this . effectiveComparator ,
273356 this . getItemId . bind ( this ) ,
274357 insertionIndex ,
275358 )
276359 : locateOnPlateauScanOneSide (
277360 items ,
278361 needle ,
279- this . sortComparator ,
362+ this . effectiveComparator ,
280363 this . getItemId . bind ( this ) ,
281364 insertionIndex ,
282365 ) ;
@@ -381,4 +464,9 @@ export abstract class BasePaginator<T> {
381464 prevDebounced = ( ) => {
382465 this . _executeQueryDebounced ( { direction : 'prev' } ) ;
383466 } ;
467+
468+ reload = async ( ) => {
469+ this . resetState ( ) ;
470+ await this . next ( ) ;
471+ } ;
384472}
0 commit comments