@ls-stack/utils 3.38.0 → 3.39.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,6 +1,11 @@
1
1
  import {
2
2
  defer
3
3
  } from "./chunk-DFXNVEH6.js";
4
+ import {
5
+ durationObjToMs
6
+ } from "./chunk-5MNYPLZI.js";
7
+ import "./chunk-HTCYUMDR.js";
8
+ import "./chunk-II4R3VVX.js";
4
9
 
5
10
  // src/asyncQueue.ts
6
11
  import { evtmitter } from "evtmitter";
@@ -10,7 +15,7 @@ import {
10
15
  resultify,
11
16
  unknownToError
12
17
  } from "t-result";
13
- var AsyncQueue = class {
18
+ var AsyncQueue = class _AsyncQueue {
14
19
  #queue = [];
15
20
  #pending = 0;
16
21
  #size = 0;
@@ -18,19 +23,61 @@ var AsyncQueue = class {
18
23
  #completed = 0;
19
24
  #failed = 0;
20
25
  #idleResolvers = [];
26
+ #sizeLessThanWaiters = [];
27
+ /**
28
+ * Event emitter for tracking task lifecycle
29
+ *
30
+ * @example Listening to Events
31
+ * ```typescript
32
+ * const queue = createAsyncQueue<string>();
33
+ *
34
+ * queue.events.on('start', (event) => {
35
+ * console.log('Task started:', event.payload.meta);
36
+ * });
37
+ *
38
+ * queue.events.on('complete', (event) => {
39
+ * console.log('Task completed:', event.payload.value);
40
+ * });
41
+ *
42
+ * queue.events.on('error', (event) => {
43
+ * console.error('Task failed:', event.payload.error);
44
+ * });
45
+ * ```
46
+ */
21
47
  events = evtmitter();
22
48
  #signal;
23
49
  #taskTimeout;
50
+ #stopped = false;
51
+ #paused = false;
52
+ #started = false;
53
+ #stopOnError = false;
54
+ #rejectPendingOnError = false;
55
+ #autoStart = true;
56
+ #stoppedReason;
57
+ #rateLimit;
58
+ #taskExecutionTimes = [];
59
+ #rateLimitTimeouts = /* @__PURE__ */ new Set();
60
+ /** Array of all task failures with metadata for debugging and analysis */
24
61
  failures = [];
62
+ /** Array of all task completions with metadata for debugging and analysis */
25
63
  completions = [];
26
64
  constructor({
27
65
  concurrency = 1,
28
66
  signal,
29
- timeout: taskTimeout
67
+ timeout: taskTimeout,
68
+ stopOnError = false,
69
+ rejectPendingOnError = false,
70
+ autoStart = true,
71
+ rateLimit
30
72
  } = {}) {
31
73
  this.#concurrency = concurrency;
32
74
  this.#signal = signal;
33
75
  this.#taskTimeout = taskTimeout;
76
+ this.#stopOnError = stopOnError;
77
+ this.#rejectPendingOnError = rejectPendingOnError;
78
+ this.#autoStart = autoStart;
79
+ this.#started = autoStart;
80
+ this.#rateLimit = rateLimit;
34
81
  this.events.on("error", (e) => {
35
82
  this.failures.push(e.payload);
36
83
  });
@@ -38,14 +85,113 @@ var AsyncQueue = class {
38
85
  this.completions.push(e.payload);
39
86
  });
40
87
  }
88
+ #getRateLimitIntervalMs() {
89
+ if (!this.#rateLimit) return 0;
90
+ return typeof this.#rateLimit.interval === "number" ? this.#rateLimit.interval : durationObjToMs(this.#rateLimit.interval);
91
+ }
92
+ #cleanupExpiredExecutionTimes(now) {
93
+ if (!this.#rateLimit) return;
94
+ const intervalMs = this.#getRateLimitIntervalMs();
95
+ const cutoff = now - intervalMs;
96
+ this.#taskExecutionTimes = this.#taskExecutionTimes.filter(
97
+ (time) => time > cutoff
98
+ );
99
+ }
100
+ #isRateLimited() {
101
+ if (!this.#rateLimit) return false;
102
+ const now = Date.now();
103
+ this.#cleanupExpiredExecutionTimes(now);
104
+ return this.#taskExecutionTimes.length >= this.#rateLimit.maxTasks;
105
+ }
106
+ #getRateLimitDelay() {
107
+ if (!this.#rateLimit || this.#taskExecutionTimes.length === 0) return 0;
108
+ const oldestExecution = this.#taskExecutionTimes[0];
109
+ if (oldestExecution === void 0) return 0;
110
+ const intervalMs = this.#getRateLimitIntervalMs();
111
+ const timeUntilSlotOpens = oldestExecution + intervalMs - Date.now();
112
+ return Math.max(0, timeUntilSlotOpens);
113
+ }
114
+ #recordTaskExecution() {
115
+ if (!this.#rateLimit) return;
116
+ const now = Date.now();
117
+ this.#taskExecutionTimes.push(now);
118
+ this.#cleanupExpiredExecutionTimes(now);
119
+ }
41
120
  #enqueue(task) {
42
121
  this.#queue.push(task);
43
122
  this.#size++;
44
123
  }
124
+ static #createTimeoutSignal(ms) {
125
+ const controller = new AbortController();
126
+ const id = setTimeout(() => {
127
+ controller.abort(
128
+ new DOMException(
129
+ "The operation was aborted due to timeout",
130
+ "TimeoutError"
131
+ )
132
+ );
133
+ }, ms);
134
+ controller.signal.addEventListener(
135
+ "abort",
136
+ () => {
137
+ clearTimeout(id);
138
+ },
139
+ { once: true }
140
+ );
141
+ return controller.signal;
142
+ }
143
+ // removed: onEmpty-related waiters
144
+ #resolveSizeLessThanWaiters() {
145
+ if (this.#sizeLessThanWaiters.length === 0) return;
146
+ const remaining = [];
147
+ for (const waiter of this.#sizeLessThanWaiters) {
148
+ if (this.#size < waiter.limit) {
149
+ waiter.resolve();
150
+ } else {
151
+ remaining.push(waiter);
152
+ }
153
+ }
154
+ this.#sizeLessThanWaiters = remaining;
155
+ }
156
+ /**
157
+ * Add a task that returns a Result to the queue
158
+ *
159
+ * Use this method when your task function already returns a Result type.
160
+ * For functions that throw errors or return plain values, use `resultifyAdd` instead.
161
+ *
162
+ * @param fn - Task function that returns a Result
163
+ * @param options - Optional configuration for this task
164
+ * @returns Promise that resolves with the task result
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * const queue = createAsyncQueue<string>();
169
+ *
170
+ * const result = await queue.add(async () => {
171
+ * try {
172
+ * const data = await fetchData();
173
+ * return Result.ok(data);
174
+ * } catch (error) {
175
+ * return Result.err(error);
176
+ * }
177
+ * });
178
+ *
179
+ * if (result.ok) {
180
+ * console.log('Success:', result.value);
181
+ * } else {
182
+ * console.log('Error:', result.error);
183
+ * }
184
+ * ```
185
+ */
45
186
  async add(fn, options) {
46
187
  if (this.#signal?.aborted) {
47
188
  return Result.err(
48
- this.#signal.reason instanceof Error ? this.#signal.reason : new DOMException("Queue aborted", "AbortError")
189
+ this.#signal.reason instanceof Error ? this.#signal.reason : new DOMException("This operation was aborted", "AbortError")
190
+ );
191
+ }
192
+ if (this.#stopped) {
193
+ return Result.err(
194
+ this.#stoppedReason ?? new Error("Queue has been stopped")
49
195
  );
50
196
  }
51
197
  const deferred = defer();
@@ -61,7 +207,9 @@ var AsyncQueue = class {
61
207
  timeout: taskTimeout
62
208
  };
63
209
  this.#enqueue(task);
64
- this.#processQueue();
210
+ if (this.#autoStart && this.#started) {
211
+ this.#processQueue();
212
+ }
65
213
  const r = await deferred.promise;
66
214
  if (options?.onComplete) {
67
215
  r.onOk(options.onComplete);
@@ -71,6 +219,44 @@ var AsyncQueue = class {
71
219
  }
72
220
  return r;
73
221
  }
222
+ /**
223
+ * Add a task that returns a plain value or throws errors to the queue
224
+ *
225
+ * This is the most commonly used method. It automatically wraps your function
226
+ * to handle errors and convert them to Result types.
227
+ *
228
+ * @param fn - Task function that returns a value or throws
229
+ * @param options - Optional configuration for this task
230
+ * @returns Promise that resolves with the task result wrapped in Result
231
+ *
232
+ * @example Basic Usage
233
+ * ```typescript
234
+ * const queue = createAsyncQueue<string>();
235
+ *
236
+ * queue.resultifyAdd(async () => {
237
+ * const response = await fetch('/api/data');
238
+ * return response.json();
239
+ * }).then(result => {
240
+ * if (result.ok) {
241
+ * console.log('Data:', result.value);
242
+ * } else {
243
+ * console.error('Failed:', result.error);
244
+ * }
245
+ * });
246
+ * ```
247
+ *
248
+ * @example With Callbacks
249
+ * ```typescript
250
+ * queue.resultifyAdd(
251
+ * async () => processData(),
252
+ * {
253
+ * onComplete: (data) => console.log('Processed:', data),
254
+ * onError: (error) => console.error('Failed:', error),
255
+ * timeout: 5000
256
+ * }
257
+ * );
258
+ * ```
259
+ */
74
260
  resultifyAdd(fn, options) {
75
261
  return this.add(
76
262
  (ctx) => resultify(async () => {
@@ -84,15 +270,31 @@ var AsyncQueue = class {
84
270
  this.clear();
85
271
  return;
86
272
  }
273
+ if (this.#stopped || this.#paused || !this.#started) {
274
+ return;
275
+ }
87
276
  if (this.#pending >= this.#concurrency || this.#queue.length === 0) {
88
277
  return;
89
278
  }
279
+ if (this.#isRateLimited()) {
280
+ const delay = this.#getRateLimitDelay();
281
+ if (delay > 0) {
282
+ const timeoutId = setTimeout(() => {
283
+ this.#rateLimitTimeouts.delete(timeoutId);
284
+ this.#processQueue();
285
+ }, delay);
286
+ this.#rateLimitTimeouts.add(timeoutId);
287
+ return;
288
+ }
289
+ }
90
290
  const task = this.#queue.shift();
91
291
  if (!task) {
92
292
  return;
93
293
  }
94
294
  this.#pending++;
95
295
  this.#size--;
296
+ this.#resolveSizeLessThanWaiters();
297
+ this.#recordTaskExecution();
96
298
  const signals = [];
97
299
  if (task.signal) {
98
300
  signals.push(task.signal);
@@ -100,8 +302,8 @@ var AsyncQueue = class {
100
302
  if (this.#signal) {
101
303
  signals.push(this.#signal);
102
304
  }
103
- if (task.timeout) {
104
- signals.push(AbortSignal.timeout(task.timeout));
305
+ if (task.timeout !== void 0) {
306
+ signals.push(_AsyncQueue.#createTimeoutSignal(task.timeout));
105
307
  }
106
308
  const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
107
309
  let abortListener;
@@ -112,10 +314,11 @@ var AsyncQueue = class {
112
314
  }
113
315
  const signalAbortPromise = new Promise((_, reject) => {
114
316
  if (signal) {
115
- const error = signal.reason instanceof Error ? signal.reason : new DOMException("This operation was aborted", "AbortError");
116
317
  abortListener = () => {
318
+ const reason = signal.reason;
319
+ const err = reason instanceof Error ? reason : new DOMException("This operation was aborted", "AbortError");
117
320
  setTimeout(() => {
118
- reject(error);
321
+ reject(err);
119
322
  }, 0);
120
323
  };
121
324
  signal.addEventListener("abort", abortListener, { once: true });
@@ -126,12 +329,13 @@ var AsyncQueue = class {
126
329
  const result = await Promise.race([taskRunPromise, signalAbortPromise]);
127
330
  if (isResult(result)) {
128
331
  task.resolve(result);
129
- if (result.error) {
332
+ if (!result.ok) {
130
333
  this.#failed++;
131
334
  this.events.emit("error", {
132
335
  meta: task.meta,
133
336
  error: result.error
134
337
  });
338
+ this.#stopOnErrorAndRejectPending(unknownToError(result.error));
135
339
  } else {
136
340
  this.#completed++;
137
341
  this.events.emit("complete", {
@@ -147,21 +351,24 @@ var AsyncQueue = class {
147
351
  meta: task.meta,
148
352
  error
149
353
  });
354
+ this.#stopOnErrorAndRejectPending(error);
150
355
  }
151
356
  } catch (error) {
152
- task.resolve(Result.err(error));
357
+ const processedError = unknownToError(error);
358
+ task.resolve(Result.err(processedError));
153
359
  this.#failed++;
154
360
  this.events.emit("error", {
155
361
  meta: task.meta,
156
- error: unknownToError(error)
362
+ error: processedError
157
363
  });
364
+ this.#stopOnErrorAndRejectPending(processedError);
158
365
  } finally {
159
366
  if (signal && abortListener) {
160
367
  signal.removeEventListener("abort", abortListener);
161
368
  }
162
369
  this.#pending--;
163
370
  this.#processQueue();
164
- if (this.#pending === 0 && this.#size === 0) {
371
+ if (this.#pending === 0 && this.#size === 0 && this.#rateLimitTimeouts.size === 0) {
165
372
  this.#resolveIdleWaiters();
166
373
  }
167
374
  }
@@ -174,33 +381,227 @@ var AsyncQueue = class {
174
381
  }
175
382
  }
176
383
  }
384
+ #stopOnErrorAndRejectPending(error) {
385
+ if (!this.#stopOnError) {
386
+ return;
387
+ }
388
+ this.#stopped = true;
389
+ this.#stoppedReason = error;
390
+ if (this.#rejectPendingOnError) {
391
+ while (this.#queue.length > 0) {
392
+ const task = this.#queue.shift();
393
+ if (task) {
394
+ task.resolve(Result.err(error));
395
+ }
396
+ }
397
+ this.#size = 0;
398
+ this.#resolveSizeLessThanWaiters();
399
+ }
400
+ this.#resolveIdleWaiters();
401
+ }
402
+ /**
403
+ * Wait for the queue to become idle (no pending tasks, no queued tasks, and no rate-limit timers)
404
+ *
405
+ * This method resolves when:
406
+ * - All tasks have completed (success or failure)
407
+ * - The queue is stopped due to error (stopOnError), even with remaining tasks
408
+ * - There are no queued tasks, no running tasks, and no pending rate-limit timers
409
+ *
410
+ * @returns Promise that resolves when the queue is idle
411
+ *
412
+ * @example
413
+ * ```typescript
414
+ * const queue = createAsyncQueue<string>();
415
+ *
416
+ * // Add multiple tasks
417
+ * for (let i = 0; i < 10; i++) {
418
+ * queue.resultifyAdd(async () => `task ${i}`);
419
+ * }
420
+ *
421
+ * // Wait for all tasks to complete
422
+ * await queue.onIdle();
423
+ *
424
+ * console.log(`Completed: ${queue.completed}, Failed: ${queue.failed}`);
425
+ * ```
426
+ */
177
427
  async onIdle() {
178
- if (this.#pending === 0 && this.#size === 0) {
428
+ if (this.#stopped || this.#pending === 0 && this.#size === 0 && this.#rateLimitTimeouts.size === 0) {
179
429
  return Promise.resolve();
180
430
  }
181
431
  return new Promise((resolve) => {
182
432
  this.#idleResolvers.push(resolve);
183
433
  });
184
434
  }
435
+ // removed: onEmpty()
436
+ /**
437
+ * Wait until the queued task count is below a limit
438
+ *
439
+ * Resolves immediately if `size < limit` at the moment of calling. This only
440
+ * considers queued (not yet started) tasks; running tasks are tracked by
441
+ * `pending`.
442
+ *
443
+ * @param limit Threshold that `size` must be below to resolve
444
+ */
445
+ onSizeLessThan(limit) {
446
+ if (this.#size < limit) {
447
+ return Promise.resolve();
448
+ }
449
+ return new Promise((resolve) => {
450
+ this.#sizeLessThanWaiters.push({ limit, resolve });
451
+ });
452
+ }
453
+ /**
454
+ * Clear all queued tasks (does not affect currently running tasks)
455
+ *
456
+ * This removes all tasks waiting in the queue but allows currently
457
+ * executing tasks to complete normally.
458
+ *
459
+ * @example
460
+ * ```typescript
461
+ * const queue = createAsyncQueue({ concurrency: 1 });
462
+ *
463
+ * // Add multiple tasks
464
+ * queue.resultifyAdd(async () => longRunningTask()); // Will start immediately
465
+ * queue.resultifyAdd(async () => task2()); // Queued
466
+ * queue.resultifyAdd(async () => task3()); // Queued
467
+ *
468
+ * // Clear remaining queued tasks
469
+ * queue.clear();
470
+ *
471
+ * // Only the first task will complete
472
+ * await queue.onIdle();
473
+ * ```
474
+ */
185
475
  clear() {
186
476
  this.#queue = [];
187
477
  this.#size = 0;
478
+ for (const timeoutId of this.#rateLimitTimeouts) {
479
+ clearTimeout(timeoutId);
480
+ }
481
+ this.#rateLimitTimeouts.clear();
188
482
  if (this.#pending === 0) {
189
483
  this.#resolveIdleWaiters();
190
484
  }
485
+ this.#resolveSizeLessThanWaiters();
191
486
  }
487
+ /** Number of tasks that have completed successfully */
192
488
  get completed() {
193
489
  return this.#completed;
194
490
  }
491
+ /** Number of tasks that have failed */
195
492
  get failed() {
196
493
  return this.#failed;
197
494
  }
495
+ /** Number of tasks currently being processed */
198
496
  get pending() {
199
497
  return this.#pending;
200
498
  }
499
+ /** Number of tasks waiting in the queue to be processed */
201
500
  get size() {
202
501
  return this.#size;
203
502
  }
503
+ /**
504
+ * Manually start processing tasks (only needed if autoStart: false)
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const queue = createAsyncQueue({ autoStart: false });
509
+ *
510
+ * // Add tasks without starting processing
511
+ * queue.resultifyAdd(async () => 'task1');
512
+ * queue.resultifyAdd(async () => 'task2');
513
+ *
514
+ * // Start processing when ready
515
+ * queue.start();
516
+ * await queue.onIdle();
517
+ * ```
518
+ */
519
+ start() {
520
+ if (this.#stopped) {
521
+ return;
522
+ }
523
+ this.#started = true;
524
+ this.#processQueue();
525
+ }
526
+ /**
527
+ * Pause processing new tasks (currently running tasks continue)
528
+ *
529
+ * @example
530
+ * ```typescript
531
+ * const queue = createAsyncQueue();
532
+ *
533
+ * // Start some tasks
534
+ * queue.resultifyAdd(async () => longRunningTask1());
535
+ * queue.resultifyAdd(async () => longRunningTask2());
536
+ *
537
+ * // Pause before more tasks are picked up
538
+ * queue.pause();
539
+ *
540
+ * // Later, resume processing
541
+ * queue.resume();
542
+ * ```
543
+ */
544
+ pause() {
545
+ this.#paused = true;
546
+ }
547
+ /**
548
+ * Resume processing tasks after pause
549
+ */
550
+ resume() {
551
+ this.#paused = false;
552
+ if (this.#started && !this.#stopped) {
553
+ this.#processQueue();
554
+ }
555
+ }
556
+ /**
557
+ * Reset the queue after being stopped, allowing new tasks to be processed
558
+ *
559
+ * This clears the stopped state and error reason, and resumes processing
560
+ * any remaining queued tasks if autoStart was enabled.
561
+ *
562
+ * @example
563
+ * ```typescript
564
+ * const queue = createAsyncQueue({ stopOnError: true });
565
+ *
566
+ * // Add tasks that will cause the queue to stop
567
+ * queue.resultifyAdd(async () => { throw new Error('fail'); });
568
+ * queue.resultifyAdd(async () => 'remaining task');
569
+ *
570
+ * await queue.onIdle();
571
+ *
572
+ * if (queue.isStopped) {
573
+ * console.log(`Queue stopped, ${queue.size} tasks remaining`);
574
+ *
575
+ * // Reset and process remaining tasks
576
+ * queue.reset();
577
+ * await queue.onIdle();
578
+ * }
579
+ * ```
580
+ */
581
+ reset() {
582
+ this.#stopped = false;
583
+ this.#stoppedReason = void 0;
584
+ if (this.#autoStart) {
585
+ this.#started = true;
586
+ this.#processQueue();
587
+ }
588
+ }
589
+ /** Whether the queue is stopped due to an error */
590
+ get isStopped() {
591
+ return this.#stopped;
592
+ }
593
+ /** Whether the queue is currently paused */
594
+ get isPaused() {
595
+ return this.#paused;
596
+ }
597
+ /** Whether the queue has been started (relevant for autoStart: false) */
598
+ get isStarted() {
599
+ return this.#started;
600
+ }
601
+ /** The error that caused the queue to stop (if any) */
602
+ get stoppedReason() {
603
+ return this.#stoppedReason;
604
+ }
204
605
  };
205
606
  var AsyncQueueWithMeta = class extends AsyncQueue {
206
607
  constructor(options) {