@mrjacket/ahko 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.
package/dist/index.js CHANGED
@@ -240,7 +240,7 @@ var DebounceCoordinator = class {
240
240
  * Cancels all pending debounced entries and clears the map.
241
241
  */
242
242
  clear() {
243
- for (const [key, entry] of this.entries) {
243
+ for (const entry of this.entries.values()) {
244
244
  clearTimeout(entry.timerId);
245
245
  if (entry.options?.signal && entry.abortListener) {
246
246
  entry.options.signal.removeEventListener("abort", entry.abortListener);
@@ -412,7 +412,7 @@ var ThrottleCoordinator = class {
412
412
  * Clears all throttled entries and timers.
413
413
  */
414
414
  clear() {
415
- for (const [key, entry] of this.entries) {
415
+ for (const entry of this.entries.values()) {
416
416
  if (entry.windowTimerId !== void 0) {
417
417
  clearTimeout(entry.windowTimerId);
418
418
  }
@@ -424,6 +424,74 @@ var ThrottleCoordinator = class {
424
424
  }
425
425
  };
426
426
 
427
+ // src/events/event-emitter.ts
428
+ var AhkoEventEmitter = class {
429
+ listeners = /* @__PURE__ */ new Map();
430
+ /**
431
+ * Subscribes a listener to a specific Ahko lifecycle event.
432
+ *
433
+ * @param event - The event name to subscribe to.
434
+ * @param handler - The callback function to invoke when the event is emitted.
435
+ * @returns An unsubscribe function to remove the listener.
436
+ */
437
+ on(event, handler) {
438
+ let set = this.listeners.get(event);
439
+ if (!set) {
440
+ set = /* @__PURE__ */ new Set();
441
+ this.listeners.set(event, set);
442
+ }
443
+ set.add(handler);
444
+ return () => {
445
+ this.off(event, handler);
446
+ };
447
+ }
448
+ /**
449
+ * Unsubscribes a listener from a specific Ahko lifecycle event.
450
+ *
451
+ * @param event - The event name.
452
+ * @param handler - The callback function to remove.
453
+ */
454
+ off(event, handler) {
455
+ const set = this.listeners.get(event);
456
+ if (set) {
457
+ set.delete(handler);
458
+ if (set.size === 0) {
459
+ this.listeners.delete(event);
460
+ }
461
+ }
462
+ }
463
+ /**
464
+ * Emits an event with the corresponding typed payload to all subscribed listeners.
465
+ * Listener invocations are safely isolated in try/catch to protect scheduler integrity.
466
+ *
467
+ * @param event - The event name to emit.
468
+ * @param payload - The event-specific payload data.
469
+ */
470
+ emit(event, payload) {
471
+ const set = this.listeners.get(event);
472
+ if (!set || set.size === 0) {
473
+ return;
474
+ }
475
+ const handlers = Array.from(set);
476
+ for (const handler of handlers) {
477
+ try {
478
+ const result = handler(payload);
479
+ if (result && typeof result.catch === "function") {
480
+ result.catch(() => {
481
+ });
482
+ }
483
+ } catch {
484
+ }
485
+ }
486
+ }
487
+ /**
488
+ * Removes all registered event listeners.
489
+ */
490
+ clear() {
491
+ this.listeners.clear();
492
+ }
493
+ };
494
+
427
495
  // src/scheduler/task-queue.ts
428
496
  var TaskQueue = class {
429
497
  /** Maximum concurrent active tasks */
@@ -448,6 +516,10 @@ var TaskQueue = class {
448
516
  debounceCoordinator = new DebounceCoordinator();
449
517
  /** Coordinator for throttled tasks with leading/trailing coalescing */
450
518
  throttleCoordinator = new ThrottleCoordinator();
519
+ /** Lifecycle event emitter for task and scheduler events */
520
+ emitter = new AhkoEventEmitter();
521
+ /** Set of pending resolvers awaiting scheduler idle transition */
522
+ idleResolvers = /* @__PURE__ */ new Set();
451
523
  /** WeakMap associating task runners with their scheduling options */
452
524
  runnerOptions = /* @__PURE__ */ new WeakMap();
453
525
  /** Cumulative completed tasks counter */
@@ -458,6 +530,10 @@ var TaskQueue = class {
458
530
  cancelledTasks = 0;
459
531
  /** Cumulative timed out tasks counter */
460
532
  timedOutTasks = 0;
533
+ /** Cumulative count of retry attempts triggered */
534
+ retriedTasks = 0;
535
+ /** Cumulative count of tasks dispatched to concurrency slots */
536
+ totalDispatched = 0;
461
537
  /**
462
538
  * Creates a new TaskQueue.
463
539
  *
@@ -563,6 +639,11 @@ var TaskQueue = class {
563
639
  if (index !== -1) {
564
640
  this.queue.splice(index, 1);
565
641
  this.cancelledTasks++;
642
+ this.emitter.emit("task:cancel", {
643
+ taskId: runner.taskId,
644
+ reason: "Task cancelled while queued"
645
+ });
646
+ this.checkIdle();
566
647
  }
567
648
  };
568
649
  this.queue.push(runner);
@@ -586,6 +667,11 @@ var TaskQueue = class {
586
667
  if (index !== -1) {
587
668
  this.queue.splice(index, 1);
588
669
  this.cancelledTasks++;
670
+ this.emitter.emit("task:cancel", {
671
+ taskId: runner.taskId,
672
+ reason: "Task cancelled while queued"
673
+ });
674
+ this.checkIdle();
589
675
  }
590
676
  };
591
677
  this.queue.push(runner);
@@ -598,6 +684,11 @@ var TaskQueue = class {
598
684
  clearTimeout(delayedEntry.timerId);
599
685
  this.delayedEntries.delete(delayedEntry);
600
686
  this.cancelledTasks++;
687
+ this.emitter.emit("task:cancel", {
688
+ taskId: runner.taskId,
689
+ reason: "Task cancelled while waiting in delay"
690
+ });
691
+ this.checkIdle();
601
692
  }
602
693
  };
603
694
  }
@@ -617,6 +708,11 @@ var TaskQueue = class {
617
708
  if (index !== -1) {
618
709
  this.queue.splice(index, 1);
619
710
  this.cancelledTasks++;
711
+ this.emitter.emit("task:cancel", {
712
+ taskId: runner.taskId,
713
+ reason: "Task cancelled while queued"
714
+ });
715
+ this.checkIdle();
620
716
  }
621
717
  };
622
718
  this.queue.push(runner);
@@ -629,6 +725,11 @@ var TaskQueue = class {
629
725
  handle.cancel();
630
726
  this.idleEntries.delete(idleEntry);
631
727
  this.cancelledTasks++;
728
+ this.emitter.emit("task:cancel", {
729
+ taskId: runner.taskId,
730
+ reason: "Task cancelled while waiting for idle"
731
+ });
732
+ this.checkIdle();
632
733
  }
633
734
  };
634
735
  }
@@ -697,36 +798,69 @@ var TaskQueue = class {
697
798
  */
698
799
  async executeRunner(runner) {
699
800
  const options = this.runnerOptions.get(runner);
801
+ this.totalDispatched++;
802
+ this.emitter.emit("task:start", {
803
+ taskId: runner.taskId,
804
+ attempt: runner.attempt
805
+ });
700
806
  try {
701
807
  const result = await runner.run();
702
808
  this.completedTasks++;
703
809
  this.activeRunners.delete(runner);
704
810
  this.runnerOptions.delete(runner);
811
+ this.emitter.emit("task:complete", {
812
+ taskId: runner.taskId,
813
+ attempt: runner.attempt,
814
+ durationMs: runner.lastDurationMs,
815
+ result
816
+ });
705
817
  runner.resolve(result);
706
818
  } catch (error) {
707
819
  if (runner.state === "cancelled" /* CANCELLED */) {
708
820
  this.cancelledTasks++;
709
821
  this.activeRunners.delete(runner);
710
822
  this.runnerOptions.delete(runner);
823
+ this.emitter.emit("task:cancel", {
824
+ taskId: runner.taskId,
825
+ reason: error
826
+ });
711
827
  runner.reject(error);
712
828
  return;
713
829
  }
714
830
  const shouldRetry = await runner.canRetry(error, options?.retry);
715
831
  if (shouldRetry) {
832
+ this.retriedTasks++;
716
833
  this.activeRunners.delete(runner);
834
+ this.emitter.emit("task:fail", {
835
+ taskId: runner.taskId,
836
+ attempt: runner.attempt - 1,
837
+ error,
838
+ willRetry: true
839
+ });
717
840
  this.scheduleRetry(runner, options);
718
841
  return;
719
842
  }
720
843
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
721
844
  this.timedOutTasks++;
845
+ this.emitter.emit("task:timeout", {
846
+ taskId: runner.taskId,
847
+ timeoutMs: runner.timeoutMs
848
+ });
722
849
  } else {
723
850
  this.failedTasks++;
724
851
  }
725
852
  this.activeRunners.delete(runner);
726
853
  this.runnerOptions.delete(runner);
854
+ this.emitter.emit("task:fail", {
855
+ taskId: runner.taskId,
856
+ attempt: runner.attempt,
857
+ error,
858
+ willRetry: false
859
+ });
727
860
  runner.reject(error);
728
861
  } finally {
729
862
  this.pump();
863
+ this.checkIdle();
730
864
  }
731
865
  }
732
866
  /**
@@ -741,6 +875,11 @@ var TaskQueue = class {
741
875
  if (index !== -1) {
742
876
  this.queue.splice(index, 1);
743
877
  this.cancelledTasks++;
878
+ this.emitter.emit("task:cancel", {
879
+ taskId: runner.taskId,
880
+ reason: "Task cancelled while queued"
881
+ });
882
+ this.checkIdle();
744
883
  }
745
884
  };
746
885
  this.queue.push(runner);
@@ -759,6 +898,11 @@ var TaskQueue = class {
759
898
  if (index !== -1) {
760
899
  this.queue.splice(index, 1);
761
900
  this.cancelledTasks++;
901
+ this.emitter.emit("task:cancel", {
902
+ taskId: runner.taskId,
903
+ reason: "Task cancelled while queued"
904
+ });
905
+ this.checkIdle();
762
906
  }
763
907
  };
764
908
  this.queue.push(runner);
@@ -771,9 +915,91 @@ var TaskQueue = class {
771
915
  clearTimeout(retryEntry.timerId);
772
916
  this.retryEntries.delete(retryEntry);
773
917
  this.cancelledTasks++;
918
+ this.emitter.emit("task:cancel", {
919
+ taskId: runner.taskId,
920
+ reason: "Task cancelled during retry backoff"
921
+ });
922
+ this.checkIdle();
774
923
  }
775
924
  };
776
925
  }
926
+ /**
927
+ * Checks whether the scheduler has transitioned to idle and notifies listeners/resolvers.
928
+ */
929
+ checkIdle() {
930
+ if (this.isIdle()) {
931
+ if (this.idleResolvers.size > 0) {
932
+ for (const resolve of this.idleResolvers) {
933
+ resolve();
934
+ }
935
+ this.idleResolvers.clear();
936
+ }
937
+ this.emitter.emit("idle", { timestamp: Date.now() });
938
+ }
939
+ }
940
+ /**
941
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
942
+ *
943
+ * @returns True if completely idle, false otherwise.
944
+ */
945
+ isIdle() {
946
+ return this.activeRunners.size === 0 && this.queue.length === 0 && this.delayedEntries.size === 0 && this.idleEntries.size === 0 && this.retryEntries.size === 0 && this.debounceCoordinator.size === 0 && this.throttleCoordinator.size === 0;
947
+ }
948
+ /**
949
+ * Returns a promise that resolves once the scheduler has processed all tasks and is idle.
950
+ *
951
+ * @returns Promise resolving when idle.
952
+ */
953
+ onIdle() {
954
+ if (this.isIdle()) {
955
+ return Promise.resolve();
956
+ }
957
+ return new Promise((resolve) => {
958
+ this.idleResolvers.add(resolve);
959
+ });
960
+ }
961
+ /**
962
+ * Clears all pending and waiting tasks from the scheduler, cancelling their runners.
963
+ * Active tasks currently in flight will continue to run to completion or abort via signal.
964
+ */
965
+ clear() {
966
+ while (this.queue.length > 0) {
967
+ const runner = this.queue.shift();
968
+ if (runner && runner.state !== "cancelled" /* CANCELLED */) {
969
+ runner.cancel("Scheduler cleared");
970
+ this.cancelledTasks++;
971
+ this.emitter.emit("task:cancel", { taskId: runner.taskId, reason: "Scheduler cleared" });
972
+ }
973
+ }
974
+ for (const entry of this.delayedEntries.values()) {
975
+ clearTimeout(entry.timerId);
976
+ entry.runner.cancel("Scheduler cleared");
977
+ this.cancelledTasks++;
978
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
979
+ }
980
+ this.delayedEntries.clear();
981
+ for (const entry of this.idleEntries.values()) {
982
+ entry.handle.cancel();
983
+ entry.runner.cancel("Scheduler cleared");
984
+ this.cancelledTasks++;
985
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
986
+ }
987
+ this.idleEntries.clear();
988
+ for (const entry of this.retryEntries.values()) {
989
+ clearTimeout(entry.timerId);
990
+ entry.runner.cancel("Scheduler cleared");
991
+ this.cancelledTasks++;
992
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
993
+ }
994
+ this.retryEntries.clear();
995
+ this.debounceCoordinator.clear();
996
+ this.throttleCoordinator.clear();
997
+ if (this.rateLimitTimer !== void 0) {
998
+ clearTimeout(this.rateLimitTimer);
999
+ this.rateLimitTimer = void 0;
1000
+ }
1001
+ this.checkIdle();
1002
+ }
777
1003
  /**
778
1004
  * Returns telemetry snapshot for the scheduler.
779
1005
  *
@@ -787,6 +1013,8 @@ var TaskQueue = class {
787
1013
  failedTasks: this.failedTasks,
788
1014
  cancelledTasks: this.cancelledTasks,
789
1015
  timedOutTasks: this.timedOutTasks,
1016
+ retriedTasks: this.retriedTasks,
1017
+ totalDispatched: this.totalDispatched,
790
1018
  capacity: this.concurrency
791
1019
  });
792
1020
  }
@@ -821,6 +1049,8 @@ var TaskRunner = class {
821
1049
  onCancel;
822
1050
  /** Current execution attempt count (1-indexed) */
823
1051
  attempt = 1;
1052
+ /** Duration of the most recent execution attempt in milliseconds */
1053
+ lastDurationMs = 0;
824
1054
  /**
825
1055
  * Creates a new TaskRunner instance.
826
1056
  *
@@ -979,9 +1209,11 @@ var TaskRunner = class {
979
1209
  if (timeoutPromise) {
980
1210
  racePromises.push(timeoutPromise);
981
1211
  }
1212
+ const startTime = Date.now();
982
1213
  try {
983
1214
  const result = await Promise.race(racePromises);
984
1215
  this.clearTimeoutTimer();
1216
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
985
1217
  if (abortListener) {
986
1218
  this.abortController.signal.removeEventListener("abort", abortListener);
987
1219
  }
@@ -998,6 +1230,7 @@ var TaskRunner = class {
998
1230
  return result;
999
1231
  } catch (error) {
1000
1232
  this.clearTimeoutTimer();
1233
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1001
1234
  if (abortListener) {
1002
1235
  this.abortController.signal.removeEventListener("abort", abortListener);
1003
1236
  }
@@ -1239,10 +1472,87 @@ var Ahko = class {
1239
1472
  stats() {
1240
1473
  return this.queue.getStats();
1241
1474
  }
1475
+ /**
1476
+ * Subscribes to a scheduler lifecycle event.
1477
+ *
1478
+ * @param event - Event name to listen for.
1479
+ * @param handler - Callback function invoked when the event is emitted.
1480
+ * @returns Unsubscribe function to remove the listener.
1481
+ *
1482
+ * @example
1483
+ * ```typescript
1484
+ * const unsubscribe = ahko.on("task:start", ({ taskId, attempt }) => {
1485
+ * console.log(`Task ${taskId} started attempt ${attempt}`);
1486
+ * });
1487
+ * ```
1488
+ */
1489
+ on(event, handler) {
1490
+ return this.queue.emitter.on(event, handler);
1491
+ }
1492
+ /**
1493
+ * Unsubscribes an event listener from a scheduler lifecycle event.
1494
+ *
1495
+ * @param event - Event name.
1496
+ * @param handler - The exact listener callback to remove.
1497
+ */
1498
+ off(event, handler) {
1499
+ this.queue.emitter.off(event, handler);
1500
+ }
1501
+ /**
1502
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
1503
+ *
1504
+ * @returns True if completely idle, false otherwise.
1505
+ */
1506
+ isIdle() {
1507
+ return this.queue.isIdle();
1508
+ }
1509
+ /**
1510
+ * Returns a promise that resolves once the scheduler has completed all tasks and is idle.
1511
+ *
1512
+ * @returns Promise resolving when the scheduler is idle.
1513
+ *
1514
+ * @example
1515
+ * ```typescript
1516
+ * ahko.schedule(doWork);
1517
+ * await ahko.onIdle();
1518
+ * console.log("All work finished!");
1519
+ * ```
1520
+ */
1521
+ onIdle() {
1522
+ return this.queue.onIdle();
1523
+ }
1524
+ /**
1525
+ * Clears all pending, delayed, and throttled/debounced tasks from the scheduler.
1526
+ * In-flight active tasks will continue executing to completion or abort via signal.
1527
+ */
1528
+ clear() {
1529
+ this.queue.clear();
1530
+ }
1531
+ /**
1532
+ * Returns the delightful Ahko mascot battery telemetry status.
1533
+ *
1534
+ * Low energy, completely chill.
1535
+ */
1536
+ battery() {
1537
+ return {
1538
+ level: 3,
1539
+ chill: true,
1540
+ status: "low-energy",
1541
+ quote: "Mwee... my battery is low, but all your tasks are handled completely chill."
1542
+ };
1543
+ }
1544
+ /**
1545
+ * Delightful alias for `onIdle()`: wait for all tasks to settle chill and relaxed.
1546
+ *
1547
+ * @returns Promise resolving when all tasks have finished.
1548
+ */
1549
+ chill() {
1550
+ return this.onIdle();
1551
+ }
1242
1552
  };
1243
1553
 
1244
1554
  // src/version.ts
1245
- var VERSION = "0.5.0";
1555
+ var VERSION = "0.6.0";
1246
1556
 
1247
1557
  // src/errors/queue.error.ts
1248
1558
  var AhkoQueueError = class extends AhkoError {