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