@tanstack/pacer 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  import { parseFunctionOrValue } from './utils'
2
- import type { AnyAsyncFunction } from './types'
2
+ import type { AnyAsyncFunction, OptionalKeys } from './types'
3
3
  import type { QueuePosition } from './queuer'
4
4
 
5
- export interface AsyncQueuerOptions<TValue> {
5
+ export type AsyncQueuerFn = AnyAsyncFunction & { priority?: number }
6
+
7
+ export interface AsyncQueuerOptions<TFn extends AsyncQueuerFn> {
6
8
  /**
7
9
  * Default position to add items to the queuer
8
10
  * @default 'back'
@@ -13,7 +15,7 @@ export interface AsyncQueuerOptions<TValue> {
13
15
  * Can be a number or a function that returns a number.
14
16
  * @default 1
15
17
  */
16
- concurrency?: number | ((queuer: AsyncQueuer<TValue>) => number)
18
+ concurrency?: number | ((queuer: AsyncQueuer<TFn>) => number)
17
19
  /**
18
20
  * Maximum time in milliseconds that an item can stay in the queue
19
21
  * If not provided, items will never expire
@@ -23,7 +25,7 @@ export interface AsyncQueuerOptions<TValue> {
23
25
  * Function to determine if an item has expired
24
26
  * If provided, this overrides the expirationDuration behavior
25
27
  */
26
- getIsExpired?: (item: () => Promise<TValue>, addedAt: number) => boolean
28
+ getIsExpired?: (item: TFn, addedAt: number) => boolean
27
29
  /**
28
30
  * Default position to get items from during processing
29
31
  * @default 'front'
@@ -34,64 +36,89 @@ export interface AsyncQueuerOptions<TValue> {
34
36
  * Higher priority items will be processed first
35
37
  * If not provided, will use static priority values attached to tasks
36
38
  */
37
- getPriority?: (item: () => Promise<TValue>) => number
39
+ getPriority?: (item: TFn) => number
38
40
  /**
39
41
  * Initial items to populate the queuer with
40
42
  */
41
- initialItems?: Array<(() => Promise<TValue>) & { priority?: number }>
43
+ initialItems?: Array<TFn & { priority?: number }>
42
44
  /**
43
45
  * Maximum number of items allowed in the queuer
44
46
  */
45
47
  maxSize?: number
48
+ /**
49
+ * Optional error handler for when a task throws.
50
+ * If provided, the handler will be called with the error and queuer instance.
51
+ * This can be used alongside throwOnError - the handler will be called before any error is thrown.
52
+ */
53
+ onError?: (error: unknown, queuer: AsyncQueuer<TFn>) => void
54
+ /**
55
+ * Callback fired whenever an item expires in the queuer
56
+ */
57
+ onExpire?: (item: TFn, queuer: AsyncQueuer<TFn>) => void
46
58
  /**
47
59
  * Callback fired whenever an item is removed from the queuer
48
60
  */
49
- onGetNextItem?: (
50
- item: () => Promise<TValue>,
51
- queuer: AsyncQueuer<TValue>,
52
- ) => void
61
+ onGetNextItem?: (item: TFn, queuer: AsyncQueuer<TFn>) => void
53
62
  /**
54
63
  * Callback fired whenever the queuer's running state changes
55
64
  */
56
- onIsRunningChange?: (queuer: AsyncQueuer<TValue>) => void
65
+ onIsRunningChange?: (queuer: AsyncQueuer<TFn>) => void
57
66
  /**
58
67
  * Callback fired whenever an item is added or removed from the queuer
59
68
  */
60
- onItemsChange?: (queuer: AsyncQueuer<TValue>) => void
69
+ onItemsChange?: (queuer: AsyncQueuer<TFn>) => void
61
70
  /**
62
71
  * Callback fired whenever an item is rejected from being added to the queuer
63
72
  */
64
- onReject?: (item: () => Promise<TValue>, queuer: AsyncQueuer<TValue>) => void
73
+ onReject?: (item: TFn, queuer: AsyncQueuer<TFn>) => void
65
74
  /**
66
- * Callback fired whenever an item expires in the queuer
75
+ * Optional callback to call when a task is settled
76
+ */
77
+ onSettled?: (queuer: AsyncQueuer<TFn>) => void
78
+ /**
79
+ * Optional callback to call when a task succeeds
67
80
  */
68
- onExpire?: (item: () => Promise<TValue>, queuer: AsyncQueuer<TValue>) => void
81
+ onSuccess?: (result: TFn, queuer: AsyncQueuer<TFn>) => void
69
82
  /**
70
83
  * Whether the queuer should start processing tasks immediately or not.
71
84
  */
72
85
  started?: boolean
86
+ /**
87
+ * Whether to throw errors when they occur.
88
+ * Defaults to true if no onError handler is provided, false if an onError handler is provided.
89
+ * Can be explicitly set to override these defaults.
90
+ */
91
+ throwOnError?: boolean
73
92
  /**
74
93
  * Time in milliseconds to wait between processing items.
75
94
  * Can be a number or a function that returns a number.
76
95
  * @default 0
77
96
  */
78
- wait?: number | ((queuer: AsyncQueuer<TValue>) => number)
97
+ wait?: number | ((queuer: AsyncQueuer<TFn>) => number)
79
98
  }
80
99
 
81
- const defaultOptions: Required<AsyncQueuerOptions<any>> = {
100
+ type AsyncQueuerOptionsWithOptionalCallbacks = OptionalKeys<
101
+ Required<AsyncQueuerOptions<any>>,
102
+ | 'onError'
103
+ | 'onExpire'
104
+ | 'onGetNextItem'
105
+ | 'onIsRunningChange'
106
+ | 'onItemsChange'
107
+ | 'onReject'
108
+ | 'onSettled'
109
+ | 'onSuccess'
110
+ | 'throwOnError'
111
+ >
112
+
113
+ const defaultOptions: AsyncQueuerOptionsWithOptionalCallbacks = {
82
114
  addItemsTo: 'back',
83
115
  concurrency: 1,
84
116
  expirationDuration: Infinity,
85
117
  getIsExpired: () => false,
86
118
  getItemsFrom: 'front',
87
- getPriority: (item) => (item as any)?.priority ?? 0,
119
+ getPriority: (item: any) => item?.priority ?? 0,
88
120
  initialItems: [],
89
121
  maxSize: Infinity,
90
- onGetNextItem: () => {},
91
- onIsRunningChange: () => {},
92
- onItemsChange: () => {},
93
- onReject: () => {},
94
- onExpire: () => {},
95
122
  started: true,
96
123
  wait: 0,
97
124
  }
@@ -111,37 +138,48 @@ const defaultOptions: Required<AsyncQueuerOptions<any>> = {
111
138
  * Tasks are processed concurrently up to the configured concurrency limit. When a task completes,
112
139
  * the next pending task is processed if below the concurrency limit.
113
140
  *
141
+ * Error Handling:
142
+ * - If an `onError` handler is provided, it will be called with the error and queuer instance
143
+ * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
144
+ * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
145
+ * - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
146
+ * - The error state can be checked using the underlying AsyncQueuer instance
147
+ *
114
148
  * @example
115
149
  * ```ts
116
- * const asyncQueuer = new AsyncQueuer<string>({ concurrency: 2 });
150
+ * const asyncQueuer = new AsyncQueuer<string>({
151
+ * concurrency: 2,
152
+ * onSuccess: (result) => {
153
+ * console.log(result); // 'Hello'
154
+ * }
155
+ * });
117
156
  *
118
157
  * asyncQueuer.addItem(async () => {
119
158
  * return 'Hello';
120
159
  * });
121
160
  *
122
161
  * asyncQueuer.start();
123
- *
124
- * asyncQueuer.onSuccess((result) => {
125
- * console.log(result); // 'Hello'
126
- * });
127
162
  * ```
128
163
  */
129
- export class AsyncQueuer<TValue> {
130
- private _options: Required<AsyncQueuerOptions<TValue>>
131
- private _activeItems: Set<() => Promise<TValue>> = new Set()
132
- private _executionCount = 0
164
+ export class AsyncQueuer<TFn extends AsyncQueuerFn> {
165
+ private _options: AsyncQueuerOptionsWithOptionalCallbacks
166
+ private _activeItems: Set<TFn> = new Set()
167
+ private _successCount = 0
168
+ private _errorCount = 0
169
+ private _settledCount = 0
133
170
  private _rejectionCount = 0
134
171
  private _expirationCount = 0
135
- private _items: Array<() => Promise<TValue>> = []
172
+ private _items: Array<TFn> = []
136
173
  private _itemTimestamps: Array<number> = []
137
- private _onErrorCallbacks: Array<(error: Error) => void> = []
138
- private _onSettledCallbacks: Array<(result: TValue | Error) => void> = []
139
- private _onSuccessCallbacks: Array<(result: TValue) => void> = []
140
174
  private _pendingTick = false
141
175
  private _running: boolean
142
176
 
143
- constructor(initialOptions: AsyncQueuerOptions<TValue> = defaultOptions) {
144
- this._options = { ...defaultOptions, ...initialOptions }
177
+ constructor(initialOptions: AsyncQueuerOptions<TFn> = defaultOptions) {
178
+ this._options = {
179
+ ...defaultOptions,
180
+ ...initialOptions,
181
+ throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
182
+ }
145
183
  this._running = this._options.started
146
184
 
147
185
  for (let i = 0; i < this._options.initialItems.length; i++) {
@@ -155,14 +193,14 @@ export class AsyncQueuer<TValue> {
155
193
  * Updates the queuer options
156
194
  * Returns the new options state
157
195
  */
158
- setOptions(newOptions: Partial<AsyncQueuerOptions<TValue>>): void {
196
+ setOptions(newOptions: Partial<AsyncQueuerOptions<TFn>>): void {
159
197
  this._options = { ...this._options, ...newOptions }
160
198
  }
161
199
 
162
200
  /**
163
201
  * Returns the current queuer options
164
202
  */
165
- getOptions(): Required<AsyncQueuerOptions<TValue>> {
203
+ getOptions(): AsyncQueuerOptions<TFn> {
166
204
  return this._options
167
205
  }
168
206
 
@@ -201,28 +239,28 @@ export class AsyncQueuer<TValue> {
201
239
  break
202
240
  }
203
241
  this._activeItems.add(nextFn)
204
- this._options.onItemsChange(this)
242
+ this._options.onItemsChange?.(this)
205
243
  ;(async () => {
206
- let success = false
207
- let res!: TValue
208
- let error: Error | undefined
244
+ let res!: TFn
209
245
 
210
246
  try {
211
247
  res = await nextFn()
212
- success = true
213
- } catch (e) {
214
- error = e as Error
248
+ this._successCount++
249
+ this._options.onSuccess?.(res, this)
250
+ } catch (error) {
251
+ this._errorCount++
252
+ this._options.onError?.(error, this)
253
+ if (this._options.throwOnError) {
254
+ throw error
255
+ } else {
256
+ console.error(error)
257
+ }
215
258
  } finally {
259
+ this._settledCount++
216
260
  this._activeItems.delete(nextFn)
217
- this._options.onItemsChange(this)
218
- }
219
-
220
- if (success) {
221
- this._onSuccessCallbacks.forEach((cb) => cb(res))
222
- } else {
223
- this._onErrorCallbacks.forEach((cb) => cb(error!))
261
+ this._options.onItemsChange?.(this)
262
+ this._options.onSettled?.(this)
224
263
  }
225
- this._onSettledCallbacks.forEach((cb) => cb(success ? res : error!))
226
264
 
227
265
  const wait = this.getWait()
228
266
  if (wait > 0) {
@@ -240,7 +278,7 @@ export class AsyncQueuer<TValue> {
240
278
  /**
241
279
  * Checks for and removes expired items from the queuer
242
280
  */
243
- private checkExpiredItems() {
281
+ private checkExpiredItems(): void {
244
282
  if (
245
283
  this._options.expirationDuration === Infinity &&
246
284
  this._options.getIsExpired === defaultOptions.getIsExpired
@@ -279,11 +317,11 @@ export class AsyncQueuer<TValue> {
279
317
  this._items.splice(index, 1)
280
318
  this._itemTimestamps.splice(index, 1)
281
319
  this._expirationCount++
282
- this._options.onExpire(expiredItem, this)
320
+ this._options.onExpire?.(expiredItem, this)
283
321
  }
284
322
 
285
323
  if (expiredIndices.length > 0) {
286
- this._options.onItemsChange(this)
324
+ this._options.onItemsChange?.(this)
287
325
  }
288
326
  }
289
327
 
@@ -296,7 +334,7 @@ export class AsyncQueuer<TValue> {
296
334
  this._pendingTick = true
297
335
  this.tick()
298
336
  }
299
- this._options.onIsRunningChange(this)
337
+ this._options.onIsRunningChange?.(this)
300
338
 
301
339
  return new Promise<void>((resolve) => {
302
340
  const checkIdle = () => {
@@ -316,7 +354,7 @@ export class AsyncQueuer<TValue> {
316
354
  stop(): void {
317
355
  this._running = false
318
356
  this._pendingTick = false
319
- this._options.onIsRunningChange(this)
357
+ this._options.onIsRunningChange?.(this)
320
358
  }
321
359
 
322
360
  /**
@@ -324,7 +362,7 @@ export class AsyncQueuer<TValue> {
324
362
  */
325
363
  clear(): void {
326
364
  this._items = []
327
- this._options.onItemsChange(this)
365
+ this._options.onItemsChange?.(this)
328
366
  }
329
367
 
330
368
  /**
@@ -332,7 +370,9 @@ export class AsyncQueuer<TValue> {
332
370
  */
333
371
  reset(withInitialItems?: boolean): void {
334
372
  this.clear()
335
- this._executionCount = 0
373
+ this._successCount = 0
374
+ this._errorCount = 0
375
+ this._settledCount = 0
336
376
  if (withInitialItems) {
337
377
  this._items = [...this._options.initialItems]
338
378
  }
@@ -343,74 +383,59 @@ export class AsyncQueuer<TValue> {
343
383
  * Adds a task to the queuer
344
384
  */
345
385
  addItem(
346
- fn: AnyAsyncFunction & { priority?: number },
386
+ fn: TFn,
347
387
  position: QueuePosition = this._options.addItemsTo,
348
388
  runOnItemsChange: boolean = true,
349
- ): Promise<TValue> {
389
+ ): void {
350
390
  if (this.getIsFull()) {
351
391
  this._rejectionCount++
352
- this._options.onReject(fn, this)
353
- return Promise.reject(new Error('Queuer is full'))
392
+ this._options.onReject?.(fn, this)
393
+ return
354
394
  }
355
395
 
356
- return new Promise<TValue>((resolve, reject) => {
357
- const task = Object.assign(
358
- async () => {
359
- try {
360
- const result = await fn()
361
- resolve(result)
362
- return result
363
- } catch (error) {
364
- reject(error)
365
- throw error
366
- }
367
- },
368
- { priority: fn.priority ?? undefined },
369
- )
370
-
371
- // Get priority either from the function or from getPriority option
372
- const priority =
373
- this._options.getPriority !== defaultOptions.getPriority
374
- ? this._options.getPriority(task)
375
- : task.priority
376
-
377
- if (priority !== undefined) {
378
- // Insert based on priority
379
- const insertIndex = this._items.findIndex((existing) => {
380
- const existingPriority =
381
- this._options.getPriority !== defaultOptions.getPriority
382
- ? this._options.getPriority(existing)
383
- : (existing as any).priority
384
- return existingPriority > priority
385
- })
386
-
387
- if (insertIndex === -1) {
388
- this._items.push(task)
389
- this._itemTimestamps.push(Date.now())
390
- } else {
391
- this._items.splice(insertIndex, 0, task)
392
- this._itemTimestamps.splice(insertIndex, 0, Date.now())
393
- }
396
+ // Get priority either from the function or from getPriority option
397
+ const priority =
398
+ this._options.getPriority !== defaultOptions.getPriority
399
+ ? this._options.getPriority(fn)
400
+ : fn.priority
401
+
402
+ if (priority !== undefined) {
403
+ // Insert based on priority
404
+ const insertIndex = this._items.findIndex((existing) => {
405
+ const existingPriority =
406
+ this._options.getPriority !== defaultOptions.getPriority
407
+ ? this._options.getPriority(existing)
408
+ : (existing as any).priority
409
+ return existingPriority > priority
410
+ })
411
+
412
+ if (insertIndex === -1) {
413
+ this._items.push(fn)
414
+ this._itemTimestamps.push(Date.now())
394
415
  } else {
416
+ this._items.splice(insertIndex, 0, fn)
417
+ this._itemTimestamps.splice(insertIndex, 0, Date.now())
418
+ }
419
+ } else {
420
+ if (position === 'front') {
395
421
  // Default FIFO/LIFO behavior
396
- if (position === 'front') {
397
- this._items.unshift(task)
398
- this._itemTimestamps.unshift(Date.now())
399
- } else {
400
- this._items.push(task)
401
- this._itemTimestamps.push(Date.now())
402
- }
422
+ this._items.unshift(fn)
423
+ this._itemTimestamps.unshift(Date.now())
424
+ } else {
425
+ // LIFO
426
+ this._items.push(fn)
427
+ this._itemTimestamps.push(Date.now())
403
428
  }
429
+ }
404
430
 
405
- if (runOnItemsChange) {
406
- this._options.onItemsChange(this)
407
- }
431
+ if (runOnItemsChange) {
432
+ this._options.onItemsChange?.(this)
433
+ }
408
434
 
409
- if (this._running && !this._pendingTick) {
410
- this._pendingTick = true
411
- this.tick()
412
- }
413
- })
435
+ if (this._running && !this._pendingTick) {
436
+ this._pendingTick = true
437
+ this.tick()
438
+ }
414
439
  }
415
440
 
416
441
  /**
@@ -418,8 +443,8 @@ export class AsyncQueuer<TValue> {
418
443
  */
419
444
  getNextItem(
420
445
  position: QueuePosition = this._options.getItemsFrom,
421
- ): (() => Promise<TValue>) | undefined {
422
- let item: (() => Promise<TValue>) | undefined
446
+ ): TFn | undefined {
447
+ let item: TFn | undefined
423
448
 
424
449
  if (position === 'front') {
425
450
  item = this._items.shift()
@@ -430,9 +455,8 @@ export class AsyncQueuer<TValue> {
430
455
  }
431
456
 
432
457
  if (item !== undefined) {
433
- this._executionCount++
434
- this._options.onItemsChange(this)
435
- this._options.onGetNextItem(item, this)
458
+ this._options.onItemsChange?.(this)
459
+ this._options.onGetNextItem?.(item, this)
436
460
  }
437
461
  return item
438
462
  }
@@ -440,9 +464,7 @@ export class AsyncQueuer<TValue> {
440
464
  /**
441
465
  * Returns an item without removing it
442
466
  */
443
- getPeek(
444
- position: QueuePosition = 'front',
445
- ): (() => Promise<TValue>) | undefined {
467
+ getPeek(position: QueuePosition = 'front'): TFn | undefined {
446
468
  if (position === 'front') {
447
469
  return this._items[0]
448
470
  }
@@ -473,29 +495,43 @@ export class AsyncQueuer<TValue> {
473
495
  /**
474
496
  * Returns a copy of all items in the queuer
475
497
  */
476
- getAllItems(): Array<() => Promise<TValue>> {
498
+ getAllItems(): Array<TFn> {
477
499
  return [...this.getActiveItems(), ...this.getPendingItems()]
478
500
  }
479
501
 
480
502
  /**
481
503
  * Returns the active items
482
504
  */
483
- getActiveItems(): Array<() => Promise<TValue>> {
505
+ getActiveItems(): Array<TFn> {
484
506
  return Array.from(this._activeItems)
485
507
  }
486
508
 
487
509
  /**
488
510
  * Returns the pending items
489
511
  */
490
- getPendingItems(): Array<() => Promise<TValue>> {
512
+ getPendingItems(): Array<TFn> {
491
513
  return [...this._items]
492
514
  }
493
515
 
494
516
  /**
495
- * Returns the number of items that have been removed from the queuer
517
+ * Returns the number of items that have been successfully processed
518
+ */
519
+ getSuccessCount(): number {
520
+ return this._successCount
521
+ }
522
+
523
+ /**
524
+ * Returns the number of items that have failed processing
525
+ */
526
+ getErrorCount(): number {
527
+ return this._errorCount
528
+ }
529
+
530
+ /**
531
+ * Returns the number of items that have completed processing (success or error)
496
532
  */
497
- getExecutionCount(): number {
498
- return this._executionCount
533
+ getSettledCount(): number {
534
+ return this._settledCount
499
535
  }
500
536
 
501
537
  /**
@@ -519,40 +555,6 @@ export class AsyncQueuer<TValue> {
519
555
  return this._running && this.getIsEmpty() && this._activeItems.size === 0
520
556
  }
521
557
 
522
- /**
523
- * Adds a callback to be called when a task succeeds
524
- */
525
- onSuccess(cb: (result: TValue) => void) {
526
- this._onSuccessCallbacks.push(cb)
527
- return () => {
528
- this._onSuccessCallbacks = this._onSuccessCallbacks.filter(
529
- (d) => d !== cb,
530
- )
531
- }
532
- }
533
-
534
- /**
535
- * Adds a callback to be called when a task errors
536
- */
537
- onError(cb: (error: Error) => void) {
538
- this._onErrorCallbacks.push(cb)
539
- return () => {
540
- this._onErrorCallbacks = this._onErrorCallbacks.filter((d) => d !== cb)
541
- }
542
- }
543
-
544
- /**
545
- * Adds a callback to be called when a task is settled
546
- */
547
- onSettled(cb: (result: TValue | Error) => void) {
548
- this._onSettledCallbacks.push(cb)
549
- return () => {
550
- this._onSettledCallbacks = this._onSettledCallbacks.filter(
551
- (d) => d !== cb,
552
- )
553
- }
554
- }
555
-
556
558
  /**
557
559
  * Returns the number of items that have expired from the queuer
558
560
  */
@@ -565,6 +567,13 @@ export class AsyncQueuer<TValue> {
565
567
  * Creates a new AsyncQueuer instance with the given options and returns a bound addItem function.
566
568
  * The queuer is automatically started and ready to process items.
567
569
  *
570
+ * Error Handling:
571
+ * - If an `onError` handler is provided, it will be called with the error and queuer instance
572
+ * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
573
+ * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
574
+ * - Both onError and throwOnError can be used together - the handler will be called before any error is thrown
575
+ * - The error state can be checked using the underlying AsyncQueuer instance
576
+ *
568
577
  * @example
569
578
  * ```ts
570
579
  * const enqueue = asyncQueue<string>();
@@ -578,7 +587,9 @@ export class AsyncQueuer<TValue> {
578
587
  * @param options - Configuration options for the AsyncQueuer
579
588
  * @returns A bound addItem function that can be used to add tasks to the queuer
580
589
  */
581
- export function asyncQueue<TValue>(options: AsyncQueuerOptions<TValue>) {
582
- const queuer = new AsyncQueuer<TValue>(options)
590
+ export function asyncQueue<TFn extends AsyncQueuerFn>(
591
+ options: AsyncQueuerOptions<TFn>,
592
+ ) {
593
+ const queuer = new AsyncQueuer<TFn>(options)
583
594
  return queuer.addItem.bind(queuer)
584
595
  }