@tanstack/pacer 0.22.0 → 0.23.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.
Files changed (100) hide show
  1. package/README.md +4 -4
  2. package/dist/async-batcher.d.ts +11 -13
  3. package/dist/async-batcher.js +185 -131
  4. package/dist/async-debouncer.d.ts +6 -8
  5. package/dist/async-debouncer.js +186 -142
  6. package/dist/async-queuer.d.ts +11 -13
  7. package/dist/async-queuer.js +383 -290
  8. package/dist/async-rate-limiter.d.ts +6 -8
  9. package/dist/async-rate-limiter.js +197 -143
  10. package/dist/async-retryer.d.ts +6 -8
  11. package/dist/async-retryer.js +209 -179
  12. package/dist/async-throttler.d.ts +6 -8
  13. package/dist/async-throttler.js +214 -157
  14. package/dist/batcher.d.ts +5 -7
  15. package/dist/batcher.js +107 -87
  16. package/dist/debouncer.d.ts +6 -8
  17. package/dist/debouncer.js +99 -86
  18. package/dist/event-client.d.ts +8 -10
  19. package/dist/event-client.js +1 -2
  20. package/dist/queuer.d.ts +7 -9
  21. package/dist/queuer.js +277 -212
  22. package/dist/rate-limiter.d.ts +6 -8
  23. package/dist/rate-limiter.js +127 -109
  24. package/dist/throttler.d.ts +6 -8
  25. package/dist/throttler.js +127 -90
  26. package/dist/types.d.ts +4 -6
  27. package/dist/utils.d.ts +3 -5
  28. package/dist/utils.js +1 -2
  29. package/package.json +21 -68
  30. package/dist/async-batcher.cjs +0 -337
  31. package/dist/async-batcher.cjs.map +0 -1
  32. package/dist/async-batcher.d.cts +0 -343
  33. package/dist/async-batcher.js.map +0 -1
  34. package/dist/async-debouncer.cjs +0 -327
  35. package/dist/async-debouncer.cjs.map +0 -1
  36. package/dist/async-debouncer.d.cts +0 -299
  37. package/dist/async-debouncer.js.map +0 -1
  38. package/dist/async-queuer.cjs +0 -516
  39. package/dist/async-queuer.cjs.map +0 -1
  40. package/dist/async-queuer.d.cts +0 -440
  41. package/dist/async-queuer.js.map +0 -1
  42. package/dist/async-rate-limiter.cjs +0 -370
  43. package/dist/async-rate-limiter.cjs.map +0 -1
  44. package/dist/async-rate-limiter.d.cts +0 -356
  45. package/dist/async-rate-limiter.js.map +0 -1
  46. package/dist/async-retryer.cjs +0 -365
  47. package/dist/async-retryer.cjs.map +0 -1
  48. package/dist/async-retryer.d.cts +0 -321
  49. package/dist/async-retryer.js.map +0 -1
  50. package/dist/async-throttler.cjs +0 -344
  51. package/dist/async-throttler.cjs.map +0 -1
  52. package/dist/async-throttler.d.cts +0 -319
  53. package/dist/async-throttler.js.map +0 -1
  54. package/dist/batcher.cjs +0 -200
  55. package/dist/batcher.cjs.map +0 -1
  56. package/dist/batcher.d.cts +0 -179
  57. package/dist/batcher.js.map +0 -1
  58. package/dist/debouncer.cjs +0 -203
  59. package/dist/debouncer.cjs.map +0 -1
  60. package/dist/debouncer.d.cts +0 -166
  61. package/dist/debouncer.js.map +0 -1
  62. package/dist/event-client.cjs +0 -64
  63. package/dist/event-client.cjs.map +0 -1
  64. package/dist/event-client.d.cts +0 -65
  65. package/dist/event-client.js.map +0 -1
  66. package/dist/index.cjs +0 -52
  67. package/dist/index.d.cts +0 -15
  68. package/dist/queuer.cjs +0 -406
  69. package/dist/queuer.cjs.map +0 -1
  70. package/dist/queuer.d.cts +0 -345
  71. package/dist/queuer.js.map +0 -1
  72. package/dist/rate-limiter.cjs +0 -263
  73. package/dist/rate-limiter.cjs.map +0 -1
  74. package/dist/rate-limiter.d.cts +0 -214
  75. package/dist/rate-limiter.js.map +0 -1
  76. package/dist/throttler.cjs +0 -215
  77. package/dist/throttler.cjs.map +0 -1
  78. package/dist/throttler.d.cts +0 -206
  79. package/dist/throttler.js.map +0 -1
  80. package/dist/types.cjs +0 -0
  81. package/dist/types.d.cts +0 -13
  82. package/dist/utils.cjs +0 -13
  83. package/dist/utils.cjs.map +0 -1
  84. package/dist/utils.d.cts +0 -7
  85. package/dist/utils.js.map +0 -1
  86. package/src/async-batcher.ts +0 -594
  87. package/src/async-debouncer.ts +0 -566
  88. package/src/async-queuer.ts +0 -988
  89. package/src/async-rate-limiter.ts +0 -648
  90. package/src/async-retryer.ts +0 -673
  91. package/src/async-throttler.ts +0 -634
  92. package/src/batcher.ts +0 -329
  93. package/src/debouncer.ts +0 -334
  94. package/src/event-client.ts +0 -129
  95. package/src/index.ts +0 -24
  96. package/src/queuer.ts +0 -751
  97. package/src/rate-limiter.ts +0 -429
  98. package/src/throttler.ts +0 -380
  99. package/src/types.ts +0 -12
  100. package/src/utils.ts +0 -12
@@ -1,988 +0,0 @@
1
- import { Store } from '@tanstack/store'
2
- import { AsyncRetryer } from './async-retryer'
3
- import { parseFunctionOrValue } from './utils'
4
- import { emitChange, pacerEventClient } from './event-client'
5
- import type { AsyncRetryerOptions } from './async-retryer'
6
- import type { OptionalKeys } from './types'
7
- import type { QueuePosition } from './queuer'
8
-
9
- export interface AsyncQueuerState<TValue> {
10
- /**
11
- * Items currently being processed by the queuer
12
- */
13
- activeItems: Array<TValue>
14
- /**
15
- * Number of times addItem has been called (for reduction calculations)
16
- */
17
- addItemCount: number
18
- /**
19
- * Number of task executions that have resulted in errors
20
- */
21
- errorCount: number
22
- /**
23
- * Number of times execute has been called
24
- */
25
- executeCount: number
26
- /**
27
- * Number of items that have been removed from the queue due to expiration
28
- */
29
- expirationCount: number
30
- /**
31
- * Whether the queuer has no items to process (items array is empty)
32
- */
33
- isEmpty: boolean
34
- /**
35
- * Whether the queuer is currently executing
36
- */
37
- isExecuting: boolean
38
- /**
39
- * Whether the queuer has reached its maximum capacity
40
- */
41
- isFull: boolean
42
- /**
43
- * Whether the queuer is not currently processing any items
44
- */
45
- isIdle: boolean
46
- /**
47
- * Whether the queuer is active and will process items automatically
48
- */
49
- isRunning: boolean
50
- /**
51
- * Array of items currently waiting to be processed
52
- */
53
- items: Array<TValue>
54
- /**
55
- * Timestamps when items were added to the queue for expiration tracking
56
- */
57
- itemTimestamps: Array<number>
58
- /**
59
- * The result from the most recent task execution
60
- */
61
- lastResult: any
62
- /**
63
- * Whether the queuer has a pending timeout for processing the next item
64
- */
65
- pendingTick: boolean
66
- /**
67
- * Number of items that have been rejected from being added to the queue
68
- */
69
- rejectionCount: number
70
- /**
71
- * Number of task executions that have completed (either successfully or with errors)
72
- */
73
- settledCount: number
74
- /**
75
- * Number of items currently in the queue
76
- */
77
- size: number
78
- /**
79
- * Current processing status - 'idle' when not processing, 'running' when active, 'stopped' when paused
80
- */
81
- status: 'idle' | 'running' | 'stopped'
82
- /**
83
- * Number of task executions that have completed successfully
84
- */
85
- successCount: number
86
- }
87
-
88
- function getDefaultAsyncQueuerState<TValue>(): AsyncQueuerState<TValue> {
89
- return {
90
- activeItems: [],
91
- addItemCount: 0,
92
- errorCount: 0,
93
- executeCount: 0,
94
- expirationCount: 0,
95
- isEmpty: true,
96
- isExecuting: false,
97
- isFull: false,
98
- isIdle: true,
99
- isRunning: true,
100
- itemTimestamps: [],
101
- items: [],
102
- lastResult: null,
103
- pendingTick: false,
104
- rejectionCount: 0,
105
- settledCount: 0,
106
- size: 0,
107
- status: 'idle',
108
- successCount: 0,
109
- }
110
- }
111
-
112
- export interface AsyncQueuerOptions<TValue> {
113
- /**
114
- * Options for configuring the underlying async retryer
115
- */
116
- asyncRetryerOptions?: AsyncRetryerOptions<(item: TValue) => Promise<any>>
117
- /**
118
- * Default position to add items to the queuer
119
- * @default 'back'
120
- */
121
- addItemsTo?: QueuePosition
122
- /**
123
- * Maximum number of concurrent tasks to process.
124
- * Can be a number or a function that returns a number.
125
- * @default 1
126
- */
127
- concurrency?: number | ((queuer: AsyncQueuer<TValue>) => number)
128
- /**
129
- * Maximum time in milliseconds that an item can stay in the queue
130
- * If not provided, items will never expire
131
- */
132
- expirationDuration?: number
133
- /**
134
- * Function to determine if an item has expired
135
- * If provided, this overrides the expirationDuration behavior
136
- */
137
- getIsExpired?: (item: TValue, addedAt: number) => boolean
138
- /**
139
- * Default position to get items from during processing
140
- * @default 'front'
141
- */
142
- getItemsFrom?: QueuePosition
143
- /**
144
- * Function to determine priority of items in the queuer
145
- * Higher priority items will be processed first
146
- * If not provided, will use static priority values attached to tasks
147
- */
148
- getPriority?: (item: TValue) => number
149
- /**
150
- * Initial items to populate the queuer with
151
- */
152
- initialItems?: Array<TValue>
153
- /**
154
- * Initial state for the async queuer
155
- */
156
- initialState?: Partial<AsyncQueuerState<TValue>>
157
- /**
158
- * Optional key to identify this async queuer instance.
159
- * If provided, the async queuer will be identified by this key in the devtools and PacerProvider if applicable.
160
- */
161
- key?: string
162
- /**
163
- * Maximum number of items allowed in the queuer
164
- */
165
- maxSize?: number
166
- /**
167
- * Optional error handler for when a task throws.
168
- * If provided, the handler will be called with the error and queuer instance.
169
- * This can be used alongside throwOnError - the handler will be called before any error is thrown.
170
- */
171
- onError?: (error: Error, item: TValue, queuer: AsyncQueuer<TValue>) => void
172
- /**
173
- * Callback fired whenever an item expires in the queuer
174
- */
175
- onExpire?: (item: TValue, queuer: AsyncQueuer<TValue>) => void
176
- /**
177
- * Callback fired whenever an item is added or removed from the queuer
178
- */
179
- onItemsChange?: (queuer: AsyncQueuer<TValue>) => void
180
- /**
181
- * Callback fired whenever an item is rejected from being added to the queuer
182
- */
183
- onReject?: (item: TValue, queuer: AsyncQueuer<TValue>) => void
184
- /**
185
- * Optional callback to call when a task is settled
186
- */
187
- onSettled?: (item: TValue, queuer: AsyncQueuer<TValue>) => void
188
- /**
189
- * Optional callback to call when a task succeeds
190
- */
191
- onSuccess?: (result: any, item: TValue, queuer: AsyncQueuer<TValue>) => void
192
- /**
193
- * Whether the queuer should start processing tasks immediately or not.
194
- */
195
- started?: boolean
196
- /**
197
- * Whether to throw errors when they occur.
198
- * Defaults to true if no onError handler is provided, false if an onError handler is provided.
199
- * Can be explicitly set to override these defaults.
200
- */
201
- throwOnError?: boolean
202
- /**
203
- * Time in milliseconds to wait between processing items.
204
- * Can be a number or a function that returns a number.
205
- * @default 0
206
- */
207
- wait?: number | ((queuer: AsyncQueuer<TValue>) => number)
208
- }
209
-
210
- /**
211
- * Utility function for sharing common `AsyncQueuerOptions` options between different `AsyncQueuer` instances.
212
- */
213
- export function asyncQueuerOptions<
214
- TValue = any,
215
- TOptions extends Partial<AsyncQueuerOptions<TValue>> = Partial<
216
- AsyncQueuerOptions<TValue>
217
- >,
218
- >(options: TOptions): TOptions {
219
- return options
220
- }
221
-
222
- type AsyncQueuerOptionsWithOptionalCallbacks = OptionalKeys<
223
- Required<AsyncQueuerOptions<any>>,
224
- | 'initialState'
225
- | 'throwOnError'
226
- | 'onSuccess'
227
- | 'onSettled'
228
- | 'onReject'
229
- | 'onItemsChange'
230
- | 'onExpire'
231
- | 'onError'
232
- | 'key'
233
- >
234
-
235
- const defaultOptions: AsyncQueuerOptionsWithOptionalCallbacks = {
236
- addItemsTo: 'back',
237
- asyncRetryerOptions: {
238
- maxAttempts: 1,
239
- },
240
- concurrency: 1,
241
- expirationDuration: Infinity,
242
- getIsExpired: () => false,
243
- getItemsFrom: 'front',
244
- getPriority: (item: any) => item?.priority ?? 0,
245
- initialItems: [],
246
- maxSize: Infinity,
247
- started: true,
248
- wait: 0,
249
- }
250
-
251
- /**
252
- * A flexible asynchronous queue for processing tasks with configurable concurrency, priority, and expiration.
253
- *
254
- * Async vs Sync Versions:
255
- * The async version provides advanced features over the sync Queuer:
256
- * - Returns promises that can be awaited for task results
257
- * - Built-in retry support via AsyncRetryer integration for each queued task
258
- * - Abort support to cancel in-flight task executions
259
- * - Comprehensive error handling with onError callbacks and throwOnError control
260
- * - Detailed execution tracking (success/error/settle counts)
261
- * - Concurrent execution support (process multiple items simultaneously)
262
- *
263
- * The sync Queuer is lighter weight and simpler when you don't need async features,
264
- * return values, or execution control.
265
- *
266
- * What is Queuing?
267
- * Queuing is a technique for managing and processing items sequentially or with controlled concurrency.
268
- * Tasks are processed up to the configured concurrency limit. When a task completes,
269
- * the next pending task is processed if the concurrency limit allows.
270
- *
271
- * Key Features:
272
- * - Priority queue support via the getPriority option
273
- * - Configurable concurrency limit
274
- * - Callbacks for task success, error, completion, and queue state changes
275
- * - FIFO (First In First Out) or LIFO (Last In First Out) queue behavior
276
- * - Pause and resume processing
277
- * - Item expiration to remove stale items from the queue
278
- *
279
- * Error Handling:
280
- * - If an `onError` handler is provided, it will be called with the error and queuer instance
281
- * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
282
- * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
283
- * - Both onError and throwOnError can be used together; the handler will be called before any error is thrown
284
- * - The error state can be checked using the AsyncQueuer instance
285
- *
286
- * State Management:
287
- * - Uses TanStack Store for reactive state management
288
- * - Use `initialState` to provide initial state values when creating the async queuer
289
- * - Use `onSuccess` callback to react to successful task execution and implement custom logic
290
- * - Use `onError` callback to react to task execution errors and implement custom error handling
291
- * - Use `onSettled` callback to react to task execution completion (success or error) and implement custom logic
292
- * - Use `onItemsChange` callback to react to items being added or removed from the queue
293
- * - Use `onExpire` callback to react to items expiring and implement custom logic
294
- * - Use `onReject` callback to react to items being rejected when the queue is full
295
- * - The state includes error count, expiration count, rejection count, running status, and success/settle counts
296
- * - State can be accessed via `asyncQueuer.store.state` when using the class directly
297
- * - When using framework adapters (React/Solid), state is accessed from `asyncQueuer.state`
298
- *
299
- * Example usage:
300
- * ```ts
301
- * const asyncQueuer = new AsyncQueuer<string>(async (item) => {
302
- * // process item
303
- * return item.toUpperCase();
304
- * }, {
305
- * concurrency: 2,
306
- * onSuccess: (result) => {
307
- * console.log(result);
308
- * }
309
- * });
310
- *
311
- * asyncQueuer.addItem('hello');
312
- * asyncQueuer.start();
313
- * ```
314
- */
315
- export class AsyncQueuer<TValue> {
316
- readonly store: Store<Readonly<AsyncQueuerState<TValue>>> = new Store<
317
- AsyncQueuerState<TValue>
318
- >(getDefaultAsyncQueuerState<TValue>())
319
- key: string | undefined
320
- options: AsyncQueuerOptions<TValue>
321
- asyncRetryers = new Map<
322
- number,
323
- AsyncRetryer<(item: TValue) => Promise<any>>
324
- >()
325
- #timeoutIds: Set<ReturnType<typeof setTimeout>> = new Set()
326
-
327
- constructor(
328
- public fn: (item: TValue) => Promise<any>,
329
- initialOptions: AsyncQueuerOptions<TValue> = {},
330
- ) {
331
- this.key = initialOptions.key
332
- this.options = {
333
- ...defaultOptions,
334
- ...initialOptions,
335
- throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
336
- }
337
- const isInitiallyRunning =
338
- this.options.initialState?.isRunning ?? this.options.started ?? true
339
- this.#setState({
340
- ...this.options.initialState,
341
- isRunning: isInitiallyRunning,
342
- })
343
-
344
- if (this.options.initialState?.items) {
345
- if (this.store.state.isRunning) {
346
- this.#tick()
347
- }
348
- } else {
349
- for (let i = 0; i < (this.options.initialItems?.length ?? 0); i++) {
350
- const item = this.options.initialItems![i]!
351
- const isLast = i === (this.options.initialItems?.length ?? 0) - 1
352
- this.addItem(item, this.options.addItemsTo ?? 'back', isLast)
353
- }
354
- }
355
-
356
- if (this.key) {
357
- pacerEventClient.on('d-AsyncQueuer', (e) => {
358
- if (e.payload.key !== this.key) return
359
- this.#setState(
360
- e.payload.store.state as Partial<AsyncQueuerState<TValue>>,
361
- )
362
- this.setOptions(
363
- e.payload.options as Partial<AsyncQueuerOptions<TValue>>,
364
- )
365
- })
366
- }
367
- }
368
-
369
- /**
370
- * Updates the queuer options. New options are merged with existing options.
371
- */
372
- setOptions = (newOptions: Partial<AsyncQueuerOptions<TValue>>): void => {
373
- this.options = { ...this.options, ...newOptions }
374
- }
375
-
376
- #setState = (newState: Partial<AsyncQueuerState<TValue>>): void => {
377
- this.store.setState((state) => {
378
- const combinedState = {
379
- ...state,
380
- ...newState,
381
- }
382
-
383
- const { activeItems, items, isRunning } = combinedState
384
-
385
- const size = items.length
386
- const isFull = size >= (this.options.maxSize ?? Infinity)
387
- const isEmpty = size === 0
388
- const isIdle = isRunning && isEmpty && activeItems.length === 0
389
-
390
- const status = isIdle ? 'idle' : isRunning ? 'running' : 'stopped'
391
-
392
- return {
393
- ...combinedState,
394
- isEmpty,
395
- isFull,
396
- isIdle,
397
- size,
398
- status,
399
- }
400
- })
401
- emitChange('AsyncQueuer', this)
402
- }
403
-
404
- /**
405
- * Returns the current wait time (in milliseconds) between processing items.
406
- * If a function is provided, it is called with the queuer instance.
407
- */
408
- #getWait = (): number => {
409
- return parseFunctionOrValue(this.options.wait ?? 0, this)
410
- }
411
-
412
- /**
413
- * Returns the current concurrency limit for processing items.
414
- * If a function is provided, it is called with the queuer instance.
415
- */
416
- #getConcurrency = (): number => {
417
- return parseFunctionOrValue(this.options.concurrency ?? 1, this)
418
- }
419
-
420
- /**
421
- * Processes items in the queue up to the concurrency limit. Internal use only.
422
- */
423
- #tick = () => {
424
- if (!this.store.state.isRunning) {
425
- this.#setState({ pendingTick: false })
426
- return
427
- }
428
- this.#setState({ pendingTick: true })
429
-
430
- // Check for expired items
431
- this.#checkExpiredItems()
432
-
433
- // Process items concurrently up to the concurrency limit
434
- let scheduledAsyncWork = false
435
- const activeItems = [...this.store.state.activeItems]
436
- while (
437
- activeItems.length < this.#getConcurrency() &&
438
- this.store.state.items.length > 0
439
- ) {
440
- const nextItem = this.peekNextItem()
441
- if (nextItem === undefined) {
442
- break
443
- }
444
- activeItems.push(nextItem)
445
- this.#setState({
446
- activeItems: [...activeItems],
447
- })
448
- scheduledAsyncWork = true
449
- ;(async () => {
450
- try {
451
- await this.execute()
452
- } catch {
453
- // errors are already surfaced via onError/errorCount (and rethrown to
454
- // direct execute/flush callers); swallowing here prevents unhandled
455
- // rejections and keeps the processing chain alive
456
- }
457
-
458
- const wait = this.#getWait()
459
- if (wait > 0) {
460
- const timeoutId = setTimeout(() => {
461
- this.#timeoutIds.delete(timeoutId)
462
- this.#tick()
463
- }, wait)
464
- this.#timeoutIds.add(timeoutId)
465
- return
466
- }
467
-
468
- this.#tick()
469
- })()
470
- }
471
-
472
- // pendingTick must stay true while executions or wait timers are pending so
473
- // that addItem does not trigger an extra tick that bypasses the wait period
474
- if (!scheduledAsyncWork) {
475
- this.#setState({ pendingTick: false })
476
- }
477
- }
478
-
479
- /**
480
- * Adds an item to the queue. If the queue is full, the item is rejected and onReject is called.
481
- * Items can be inserted based on priority or at the front/back depending on configuration.
482
- * `undefined` cannot be queued (it is the internal "no item" sentinel) and is always rejected.
483
- *
484
- * @example
485
- * ```ts
486
- * queuer.addItem({ value: 'task', priority: 10 });
487
- * queuer.addItem('task2', 'front');
488
- * ```
489
- */
490
- addItem = (
491
- item: TValue,
492
- position: QueuePosition = this.options.addItemsTo ?? 'back',
493
- runOnItemsChange: boolean = true,
494
- ): boolean => {
495
- this.#setState({
496
- addItemCount: this.store.state.addItemCount + 1,
497
- })
498
-
499
- // undefined is the internal "no item" sentinel (peekNextItem/getNextItem);
500
- // queuing it would wedge the processing loop and block items behind it
501
- if (item === undefined) {
502
- this.#setState({
503
- rejectionCount: this.store.state.rejectionCount + 1,
504
- })
505
- this.options.onReject?.(item, this)
506
- return false
507
- }
508
-
509
- if (this.store.state.items.length >= (this.options.maxSize ?? Infinity)) {
510
- this.#setState({
511
- rejectionCount: this.store.state.rejectionCount + 1,
512
- })
513
- this.options.onReject?.(item, this)
514
- return false
515
- }
516
-
517
- // Get priority either from the function or from getPriority option
518
- const priority =
519
- this.options.getPriority !== defaultOptions.getPriority
520
- ? this.options.getPriority!(item)
521
- : (item as any)?.priority
522
-
523
- const items = this.store.state.items
524
- const itemTimestamps = this.store.state.itemTimestamps
525
-
526
- if (priority !== undefined) {
527
- // Insert based on priority - higher priority items go to front
528
- const insertIndex = items.findIndex((existing) => {
529
- const existingPriority =
530
- this.options.getPriority !== defaultOptions.getPriority
531
- ? this.options.getPriority!(existing)
532
- : (existing as any)?.priority
533
- return existingPriority < priority
534
- })
535
-
536
- if (insertIndex === -1) {
537
- items.push(item)
538
- itemTimestamps.push(Date.now())
539
- } else {
540
- items.splice(insertIndex, 0, item)
541
- itemTimestamps.splice(insertIndex, 0, Date.now())
542
- }
543
- } else {
544
- if (position === 'front') {
545
- // Default FIFO/LIFO behavior
546
- items.unshift(item)
547
- itemTimestamps.unshift(Date.now())
548
- } else {
549
- // LIFO
550
- items.push(item)
551
- itemTimestamps.push(Date.now())
552
- }
553
- }
554
-
555
- this.#setState({
556
- items,
557
- itemTimestamps,
558
- })
559
-
560
- if (runOnItemsChange) {
561
- this.options.onItemsChange?.(this)
562
- }
563
-
564
- if (this.store.state.isRunning && !this.store.state.pendingTick) {
565
- this.#tick()
566
- }
567
-
568
- return true
569
- }
570
-
571
- /**
572
- * Removes and returns the next item from the queue without executing the task function.
573
- * Use for manual queue management. Normally, use execute() to process items.
574
- *
575
- * @example
576
- * ```ts
577
- * // FIFO
578
- * queuer.getNextItem();
579
- * // LIFO
580
- * queuer.getNextItem('back');
581
- * ```
582
- */
583
- getNextItem = (
584
- position: QueuePosition = this.options.getItemsFrom ?? 'front',
585
- ): TValue | undefined => {
586
- const { items, itemTimestamps } = this.store.state
587
- let item: TValue | undefined
588
-
589
- // When priority function is provided or position is 'front', always get from front (highest priority)
590
- // Priority takes precedence over FIFO/LIFO behavior
591
- if (
592
- this.options.getPriority !== defaultOptions.getPriority ||
593
- position === 'front'
594
- ) {
595
- item = items[0]
596
- if (item !== undefined) {
597
- this.#setState({
598
- items: items.slice(1),
599
- itemTimestamps: itemTimestamps.slice(1),
600
- })
601
- }
602
- } else {
603
- item = items[items.length - 1]
604
- if (item !== undefined) {
605
- this.#setState({
606
- items: items.slice(0, -1),
607
- itemTimestamps: itemTimestamps.slice(0, -1),
608
- })
609
- }
610
- }
611
-
612
- if (item !== undefined) {
613
- this.options.onItemsChange?.(this)
614
- }
615
-
616
- return item
617
- }
618
-
619
- #getAllItems = (): Array<TValue> => {
620
- const items = this.peekAllItems()
621
- this.clear()
622
- return items
623
- }
624
-
625
- /**
626
- * Removes and returns the next item from the queue and executes the task function with it.
627
- *
628
- * @example
629
- * ```ts
630
- * queuer.execute();
631
- * // LIFO
632
- * queuer.execute('back');
633
- * ```
634
- */
635
- execute = async (position?: QueuePosition): Promise<any> => {
636
- const item = this.getNextItem(position)
637
-
638
- if (item !== undefined) {
639
- const currentExecuteCount = this.store.state.executeCount + 1
640
- this.#setState({
641
- executeCount: currentExecuteCount,
642
- isExecuting: true,
643
- })
644
- try {
645
- const currentAsyncRetryer = new AsyncRetryer(
646
- this.fn,
647
- this.options.asyncRetryerOptions,
648
- )
649
- this.asyncRetryers.set(currentExecuteCount, currentAsyncRetryer)
650
- const lastResult = await currentAsyncRetryer.execute(item) // EXECUTE!
651
- this.#setState({
652
- successCount: this.store.state.successCount + 1,
653
- lastResult,
654
- })
655
- this.options.onSuccess?.(lastResult, item, this)
656
- } catch (error) {
657
- this.#setState({
658
- errorCount: this.store.state.errorCount + 1,
659
- })
660
- this.options.onError?.(error as Error, item, this)
661
- if (this.options.throwOnError) {
662
- throw error
663
- }
664
- } finally {
665
- this.asyncRetryers.delete(currentExecuteCount) // dispose retryer
666
- // remove only one occurrence so duplicate item values keep accurate
667
- // concurrency accounting
668
- const remainingActiveItems = [...this.store.state.activeItems]
669
- const activeItemIndex = remainingActiveItems.indexOf(item)
670
- if (activeItemIndex !== -1) {
671
- remainingActiveItems.splice(activeItemIndex, 1)
672
- }
673
- this.#setState({
674
- activeItems: remainingActiveItems,
675
- // other executions may still be in flight (concurrency > 1 or flush)
676
- isExecuting: this.asyncRetryers.size > 0,
677
- settledCount: this.store.state.settledCount + 1,
678
- })
679
- this.options.onSettled?.(item, this)
680
- }
681
- }
682
- return item
683
- }
684
-
685
- /**
686
- * Processes a specified number of items to execute immediately with no wait time
687
- * If no numberOfItems is provided, all items will be processed
688
- */
689
- flush = async (
690
- numberOfItems: number = this.store.state.items.length,
691
- position?: QueuePosition,
692
- ): Promise<void> => {
693
- this.#clearTimeouts() // clear any pending timeouts (kills the tick chain)
694
- const results = await Promise.allSettled(
695
- Array.from({ length: numberOfItems }, () => this.execute(position)),
696
- )
697
- // the tick chain was killed above; restart it so remaining and future items
698
- // process even when a flushed task rejected. Remaining items resume with the
699
- // normal wait spacing after the flushed executions.
700
- this.#setState({ pendingTick: false })
701
- if (this.store.state.isRunning && this.store.state.items.length > 0) {
702
- const wait = this.#getWait()
703
- if (wait > 0) {
704
- this.#setState({ pendingTick: true })
705
- const timeoutId = setTimeout(() => {
706
- this.#timeoutIds.delete(timeoutId)
707
- this.#tick()
708
- }, wait)
709
- this.#timeoutIds.add(timeoutId)
710
- } else {
711
- this.#tick()
712
- }
713
- }
714
- const failure = results.find(
715
- (result): result is PromiseRejectedResult => result.status === 'rejected',
716
- )
717
- if (failure) {
718
- throw failure.reason
719
- }
720
- }
721
-
722
- /**
723
- * Processes all items in the queue as a batch using the provided function as an argument
724
- * The queue is cleared after processing
725
- */
726
- flushAsBatch = async (
727
- batchFunction: (items: Array<TValue>) => Promise<any>,
728
- ): Promise<void> => {
729
- this.#clearTimeouts() // clear any pending timeouts (kills the tick chain)
730
- const items = this.#getAllItems()
731
- try {
732
- await batchFunction(items)
733
- } finally {
734
- // restore the tick chain even when the batch function rejects
735
- this.#setState({ pendingTick: false })
736
- if (this.store.state.isRunning && this.store.state.items.length > 0) {
737
- this.#tick()
738
- }
739
- }
740
- }
741
-
742
- /**
743
- * Checks for expired items in the queue and removes them. Calls onExpire for each expired item.
744
- * Internal use only.
745
- */
746
- #checkExpiredItems = (): void => {
747
- if (
748
- (this.options.expirationDuration ?? Infinity) === Infinity &&
749
- this.options.getIsExpired === defaultOptions.getIsExpired
750
- ) {
751
- return
752
- }
753
-
754
- const now = Date.now()
755
- const expiredIndices: Array<number> = []
756
-
757
- // Find indices of expired items
758
- for (let i = 0; i < this.store.state.items.length; i++) {
759
- const timestamp = this.store.state.itemTimestamps[i]
760
- if (timestamp === undefined) continue
761
-
762
- const item = this.store.state.items[i]
763
- if (item === undefined) continue
764
-
765
- const isExpired =
766
- this.options.getIsExpired !== defaultOptions.getIsExpired
767
- ? this.options.getIsExpired!(item, timestamp)
768
- : now - timestamp > (this.options.expirationDuration ?? Infinity)
769
-
770
- if (isExpired) {
771
- expiredIndices.push(i)
772
- }
773
- }
774
-
775
- // Remove expired items from back to front to maintain indices
776
- for (let i = expiredIndices.length - 1; i >= 0; i--) {
777
- const index = expiredIndices[i]
778
- if (index === undefined) continue
779
-
780
- const expiredItem = this.store.state.items[index]
781
- if (expiredItem === undefined) continue
782
-
783
- const newItems = [...this.store.state.items]
784
- const newTimestamps = [...this.store.state.itemTimestamps]
785
- newItems.splice(index, 1)
786
- newTimestamps.splice(index, 1)
787
- this.#setState({
788
- items: newItems,
789
- itemTimestamps: newTimestamps,
790
- expirationCount: this.store.state.expirationCount + 1,
791
- })
792
- this.options.onExpire?.(expiredItem, this)
793
- }
794
-
795
- if (expiredIndices.length > 0) {
796
- this.options.onItemsChange?.(this)
797
- }
798
- }
799
-
800
- /**
801
- * Returns the next item in the queue without removing it.
802
- *
803
- * @example
804
- * ```ts
805
- * queuer.peekNextItem(); // front
806
- * queuer.peekNextItem('back'); // back
807
- * ```
808
- */
809
- peekNextItem = (position: QueuePosition = 'front'): TValue | undefined => {
810
- if (position === 'front') {
811
- return this.store.state.items[0]
812
- }
813
- return this.store.state.items[this.store.state.items.length - 1]
814
- }
815
-
816
- /**
817
- * Returns a copy of all items in the queue, including active and pending items.
818
- */
819
- peekAllItems = (): Array<TValue> => {
820
- return [...this.peekActiveItems(), ...this.peekPendingItems()]
821
- }
822
-
823
- /**
824
- * Returns the items currently being processed (active tasks).
825
- */
826
- peekActiveItems = (): Array<TValue> => {
827
- return [...this.store.state.activeItems]
828
- }
829
-
830
- /**
831
- * Returns the items waiting to be processed (pending tasks).
832
- */
833
- peekPendingItems = (): Array<TValue> => {
834
- return [...this.store.state.items]
835
- }
836
-
837
- /**
838
- * Starts processing items in the queue. If already running, does nothing.
839
- */
840
- start = (): void => {
841
- this.#setState({ isRunning: true })
842
- if (!this.store.state.pendingTick && this.store.state.items.length > 0) {
843
- this.#tick()
844
- }
845
- }
846
-
847
- /**
848
- * Stops processing items in the queue. Does not clear the queue.
849
- */
850
- stop = (): void => {
851
- this.#clearTimeouts()
852
- this.#setState({ isRunning: false, pendingTick: false })
853
- }
854
-
855
- #clearTimeouts = (): void => {
856
- this.#timeoutIds.forEach((timeoutId) => clearTimeout(timeoutId))
857
- this.#timeoutIds.clear()
858
- }
859
-
860
- /**
861
- * Removes all pending items from the queue.
862
- * Does NOT affect active tasks.
863
- */
864
- clear = (): void => {
865
- this.#setState({ items: [], itemTimestamps: [] })
866
- this.options.onItemsChange?.(this)
867
- }
868
-
869
- /**
870
- * Returns the AbortSignal for a specific execution.
871
- * If no executeCount is provided, returns the signal for the most recent execution.
872
- * Returns null if no execution is found or not currently executing.
873
- *
874
- * @param executeCount - Optional specific execution to get signal for
875
- * @example
876
- * ```typescript
877
- * const queuer = new AsyncQueuer(
878
- * async (item: string) => {
879
- * const signal = queuer.getAbortSignal()
880
- * if (signal) {
881
- * const response = await fetch(`/api/process/${item}`, { signal })
882
- * return response.json()
883
- * }
884
- * },
885
- * { concurrency: 2 }
886
- * )
887
- * ```
888
- */
889
- getAbortSignal = (executeCount?: number): AbortSignal | null => {
890
- const count = executeCount ?? this.store.state.executeCount
891
- const retryer = this.asyncRetryers.get(count)
892
- return retryer?.getAbortSignal() ?? null
893
- }
894
-
895
- /**
896
- * Aborts all ongoing executions with the internal abort controllers.
897
- * Does NOT clear out the items.
898
- */
899
- abort = (): void => {
900
- this.asyncRetryers.forEach((retryer) => retryer.abort())
901
- this.asyncRetryers.clear()
902
- this.#setState({
903
- isExecuting: false,
904
- })
905
- }
906
-
907
- /**
908
- * Resets the queuer state to its default values
909
- */
910
- reset = (): void => {
911
- this.#setState(getDefaultAsyncQueuerState<TValue>())
912
- this.options.onItemsChange?.(this)
913
- this.asyncRetryers.forEach((retryer) => retryer.reset())
914
- }
915
- }
916
-
917
- /**
918
- * Creates a new AsyncQueuer instance and returns a bound addItem function for adding tasks.
919
- * The queuer is started automatically and ready to process items.
920
- *
921
- * Async vs Sync Versions:
922
- * The async version provides advanced features over the sync queue function:
923
- * - Returns promises that can be awaited for task results
924
- * - Built-in retry support via AsyncRetryer integration for each queued task
925
- * - Abort support to cancel in-flight task executions
926
- * - Comprehensive error handling with onError callbacks and throwOnError control
927
- * - Detailed execution tracking (success/error/settle counts)
928
- * - Concurrent execution support (process multiple items simultaneously)
929
- *
930
- * The sync queue function is lighter weight and simpler when you don't need async features,
931
- * return values, or execution control.
932
- *
933
- * What is Queuing?
934
- * Queuing is a technique for managing and processing items sequentially or with controlled concurrency.
935
- * Tasks are processed up to the configured concurrency limit. When a task completes,
936
- * the next pending task is processed if the concurrency limit allows.
937
- *
938
- * Configuration Options:
939
- * - `concurrency`: Maximum number of concurrent tasks (default: 1)
940
- * - `wait`: Time to wait between processing items (default: 0)
941
- * - `maxSize`: Maximum number of items allowed in the queue (default: Infinity)
942
- * - `getPriority`: Function to determine item priority
943
- * - `addItemsTo`: Default position to add items ('back' or 'front', default: 'back')
944
- * - `getItemsFrom`: Default position to get items ('front' or 'back', default: 'front')
945
- * - `expirationDuration`: Maximum time items can stay in queue
946
- * - `started`: Whether to start processing immediately (default: true)
947
- * - `asyncRetryerOptions`: Configure retry behavior for task executions
948
- *
949
- * Error Handling:
950
- * - If an `onError` handler is provided, it will be called with the error and queuer instance
951
- * - If `throwOnError` is true (default when no onError handler is provided), the error will be thrown
952
- * - If `throwOnError` is false (default when onError handler is provided), the error will be swallowed
953
- * - Both onError and throwOnError can be used together; the handler will be called before any error is thrown
954
- * - The error state can be checked using the underlying AsyncQueuer instance
955
- *
956
- * State Management:
957
- * - Uses TanStack Store for reactive state management
958
- * - Use `initialState` to provide initial state values when creating the async queuer
959
- * - Use `onSuccess` callback to react to successful task execution and implement custom logic
960
- * - Use `onError` callback to react to task execution errors and implement custom error handling
961
- * - Use `onSettled` callback to react to task execution completion (success or error) and implement custom logic
962
- * - Use `onItemsChange` callback to react to items being added or removed from the queue
963
- * - Use `onExpire` callback to react to items expiring and implement custom logic
964
- * - Use `onReject` callback to react to items being rejected when the queue is full
965
- * - The state includes error count, expiration count, rejection count, running status, and success/settle counts
966
- * - State can be accessed via the underlying AsyncQueuer instance's `store.state` property
967
- * - When using framework adapters (React/Solid), state is accessed from the hook's state property
968
- *
969
- * @example
970
- * ```ts
971
- * const enqueue = asyncQueue<string>(async (item) => {
972
- * return item.toUpperCase();
973
- * }, {
974
- * concurrency: 2,
975
- * wait: 100,
976
- * onSuccess: (result) => console.log('Processed:', result)
977
- * });
978
- *
979
- * enqueue('hello');
980
- * ```
981
- */
982
- export function asyncQueue<TValue>(
983
- fn: (value: TValue) => Promise<any>,
984
- initialOptions: AsyncQueuerOptions<TValue>,
985
- ) {
986
- const asyncQueuer = new AsyncQueuer<TValue>(fn, initialOptions)
987
- return asyncQueuer.addItem
988
- }