@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.cjs CHANGED
@@ -278,7 +278,7 @@ var DebounceCoordinator = class {
278
278
  * Cancels all pending debounced entries and clears the map.
279
279
  */
280
280
  clear() {
281
- for (const [key, entry] of this.entries) {
281
+ for (const entry of this.entries.values()) {
282
282
  clearTimeout(entry.timerId);
283
283
  if (entry.options?.signal && entry.abortListener) {
284
284
  entry.options.signal.removeEventListener("abort", entry.abortListener);
@@ -450,7 +450,7 @@ var ThrottleCoordinator = class {
450
450
  * Clears all throttled entries and timers.
451
451
  */
452
452
  clear() {
453
- for (const [key, entry] of this.entries) {
453
+ for (const entry of this.entries.values()) {
454
454
  if (entry.windowTimerId !== void 0) {
455
455
  clearTimeout(entry.windowTimerId);
456
456
  }
@@ -462,6 +462,74 @@ var ThrottleCoordinator = class {
462
462
  }
463
463
  };
464
464
 
465
+ // src/events/event-emitter.ts
466
+ var AhkoEventEmitter = class {
467
+ listeners = /* @__PURE__ */ new Map();
468
+ /**
469
+ * Subscribes a listener to a specific Ahko lifecycle event.
470
+ *
471
+ * @param event - The event name to subscribe to.
472
+ * @param handler - The callback function to invoke when the event is emitted.
473
+ * @returns An unsubscribe function to remove the listener.
474
+ */
475
+ on(event, handler) {
476
+ let set = this.listeners.get(event);
477
+ if (!set) {
478
+ set = /* @__PURE__ */ new Set();
479
+ this.listeners.set(event, set);
480
+ }
481
+ set.add(handler);
482
+ return () => {
483
+ this.off(event, handler);
484
+ };
485
+ }
486
+ /**
487
+ * Unsubscribes a listener from a specific Ahko lifecycle event.
488
+ *
489
+ * @param event - The event name.
490
+ * @param handler - The callback function to remove.
491
+ */
492
+ off(event, handler) {
493
+ const set = this.listeners.get(event);
494
+ if (set) {
495
+ set.delete(handler);
496
+ if (set.size === 0) {
497
+ this.listeners.delete(event);
498
+ }
499
+ }
500
+ }
501
+ /**
502
+ * Emits an event with the corresponding typed payload to all subscribed listeners.
503
+ * Listener invocations are safely isolated in try/catch to protect scheduler integrity.
504
+ *
505
+ * @param event - The event name to emit.
506
+ * @param payload - The event-specific payload data.
507
+ */
508
+ emit(event, payload) {
509
+ const set = this.listeners.get(event);
510
+ if (!set || set.size === 0) {
511
+ return;
512
+ }
513
+ const handlers = Array.from(set);
514
+ for (const handler of handlers) {
515
+ try {
516
+ const result = handler(payload);
517
+ if (result && typeof result.catch === "function") {
518
+ result.catch(() => {
519
+ });
520
+ }
521
+ } catch {
522
+ }
523
+ }
524
+ }
525
+ /**
526
+ * Removes all registered event listeners.
527
+ */
528
+ clear() {
529
+ this.listeners.clear();
530
+ }
531
+ };
532
+
465
533
  // src/scheduler/task-queue.ts
466
534
  var TaskQueue = class {
467
535
  /** Maximum concurrent active tasks */
@@ -486,6 +554,10 @@ var TaskQueue = class {
486
554
  debounceCoordinator = new DebounceCoordinator();
487
555
  /** Coordinator for throttled tasks with leading/trailing coalescing */
488
556
  throttleCoordinator = new ThrottleCoordinator();
557
+ /** Lifecycle event emitter for task and scheduler events */
558
+ emitter = new AhkoEventEmitter();
559
+ /** Set of pending resolvers awaiting scheduler idle transition */
560
+ idleResolvers = /* @__PURE__ */ new Set();
489
561
  /** WeakMap associating task runners with their scheduling options */
490
562
  runnerOptions = /* @__PURE__ */ new WeakMap();
491
563
  /** Cumulative completed tasks counter */
@@ -496,6 +568,10 @@ var TaskQueue = class {
496
568
  cancelledTasks = 0;
497
569
  /** Cumulative timed out tasks counter */
498
570
  timedOutTasks = 0;
571
+ /** Cumulative count of retry attempts triggered */
572
+ retriedTasks = 0;
573
+ /** Cumulative count of tasks dispatched to concurrency slots */
574
+ totalDispatched = 0;
499
575
  /**
500
576
  * Creates a new TaskQueue.
501
577
  *
@@ -601,6 +677,11 @@ var TaskQueue = class {
601
677
  if (index !== -1) {
602
678
  this.queue.splice(index, 1);
603
679
  this.cancelledTasks++;
680
+ this.emitter.emit("task:cancel", {
681
+ taskId: runner.taskId,
682
+ reason: "Task cancelled while queued"
683
+ });
684
+ this.checkIdle();
604
685
  }
605
686
  };
606
687
  this.queue.push(runner);
@@ -624,6 +705,11 @@ var TaskQueue = class {
624
705
  if (index !== -1) {
625
706
  this.queue.splice(index, 1);
626
707
  this.cancelledTasks++;
708
+ this.emitter.emit("task:cancel", {
709
+ taskId: runner.taskId,
710
+ reason: "Task cancelled while queued"
711
+ });
712
+ this.checkIdle();
627
713
  }
628
714
  };
629
715
  this.queue.push(runner);
@@ -636,6 +722,11 @@ var TaskQueue = class {
636
722
  clearTimeout(delayedEntry.timerId);
637
723
  this.delayedEntries.delete(delayedEntry);
638
724
  this.cancelledTasks++;
725
+ this.emitter.emit("task:cancel", {
726
+ taskId: runner.taskId,
727
+ reason: "Task cancelled while waiting in delay"
728
+ });
729
+ this.checkIdle();
639
730
  }
640
731
  };
641
732
  }
@@ -655,6 +746,11 @@ var TaskQueue = class {
655
746
  if (index !== -1) {
656
747
  this.queue.splice(index, 1);
657
748
  this.cancelledTasks++;
749
+ this.emitter.emit("task:cancel", {
750
+ taskId: runner.taskId,
751
+ reason: "Task cancelled while queued"
752
+ });
753
+ this.checkIdle();
658
754
  }
659
755
  };
660
756
  this.queue.push(runner);
@@ -667,6 +763,11 @@ var TaskQueue = class {
667
763
  handle.cancel();
668
764
  this.idleEntries.delete(idleEntry);
669
765
  this.cancelledTasks++;
766
+ this.emitter.emit("task:cancel", {
767
+ taskId: runner.taskId,
768
+ reason: "Task cancelled while waiting for idle"
769
+ });
770
+ this.checkIdle();
670
771
  }
671
772
  };
672
773
  }
@@ -735,36 +836,69 @@ var TaskQueue = class {
735
836
  */
736
837
  async executeRunner(runner) {
737
838
  const options = this.runnerOptions.get(runner);
839
+ this.totalDispatched++;
840
+ this.emitter.emit("task:start", {
841
+ taskId: runner.taskId,
842
+ attempt: runner.attempt
843
+ });
738
844
  try {
739
845
  const result = await runner.run();
740
846
  this.completedTasks++;
741
847
  this.activeRunners.delete(runner);
742
848
  this.runnerOptions.delete(runner);
849
+ this.emitter.emit("task:complete", {
850
+ taskId: runner.taskId,
851
+ attempt: runner.attempt,
852
+ durationMs: runner.lastDurationMs,
853
+ result
854
+ });
743
855
  runner.resolve(result);
744
856
  } catch (error) {
745
857
  if (runner.state === "cancelled" /* CANCELLED */) {
746
858
  this.cancelledTasks++;
747
859
  this.activeRunners.delete(runner);
748
860
  this.runnerOptions.delete(runner);
861
+ this.emitter.emit("task:cancel", {
862
+ taskId: runner.taskId,
863
+ reason: error
864
+ });
749
865
  runner.reject(error);
750
866
  return;
751
867
  }
752
868
  const shouldRetry = await runner.canRetry(error, options?.retry);
753
869
  if (shouldRetry) {
870
+ this.retriedTasks++;
754
871
  this.activeRunners.delete(runner);
872
+ this.emitter.emit("task:fail", {
873
+ taskId: runner.taskId,
874
+ attempt: runner.attempt - 1,
875
+ error,
876
+ willRetry: true
877
+ });
755
878
  this.scheduleRetry(runner, options);
756
879
  return;
757
880
  }
758
881
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
759
882
  this.timedOutTasks++;
883
+ this.emitter.emit("task:timeout", {
884
+ taskId: runner.taskId,
885
+ timeoutMs: runner.timeoutMs
886
+ });
760
887
  } else {
761
888
  this.failedTasks++;
762
889
  }
763
890
  this.activeRunners.delete(runner);
764
891
  this.runnerOptions.delete(runner);
892
+ this.emitter.emit("task:fail", {
893
+ taskId: runner.taskId,
894
+ attempt: runner.attempt,
895
+ error,
896
+ willRetry: false
897
+ });
765
898
  runner.reject(error);
766
899
  } finally {
767
900
  this.pump();
901
+ this.checkIdle();
768
902
  }
769
903
  }
770
904
  /**
@@ -779,6 +913,11 @@ var TaskQueue = class {
779
913
  if (index !== -1) {
780
914
  this.queue.splice(index, 1);
781
915
  this.cancelledTasks++;
916
+ this.emitter.emit("task:cancel", {
917
+ taskId: runner.taskId,
918
+ reason: "Task cancelled while queued"
919
+ });
920
+ this.checkIdle();
782
921
  }
783
922
  };
784
923
  this.queue.push(runner);
@@ -797,6 +936,11 @@ var TaskQueue = class {
797
936
  if (index !== -1) {
798
937
  this.queue.splice(index, 1);
799
938
  this.cancelledTasks++;
939
+ this.emitter.emit("task:cancel", {
940
+ taskId: runner.taskId,
941
+ reason: "Task cancelled while queued"
942
+ });
943
+ this.checkIdle();
800
944
  }
801
945
  };
802
946
  this.queue.push(runner);
@@ -809,9 +953,91 @@ var TaskQueue = class {
809
953
  clearTimeout(retryEntry.timerId);
810
954
  this.retryEntries.delete(retryEntry);
811
955
  this.cancelledTasks++;
956
+ this.emitter.emit("task:cancel", {
957
+ taskId: runner.taskId,
958
+ reason: "Task cancelled during retry backoff"
959
+ });
960
+ this.checkIdle();
812
961
  }
813
962
  };
814
963
  }
964
+ /**
965
+ * Checks whether the scheduler has transitioned to idle and notifies listeners/resolvers.
966
+ */
967
+ checkIdle() {
968
+ if (this.isIdle()) {
969
+ if (this.idleResolvers.size > 0) {
970
+ for (const resolve of this.idleResolvers) {
971
+ resolve();
972
+ }
973
+ this.idleResolvers.clear();
974
+ }
975
+ this.emitter.emit("idle", { timestamp: Date.now() });
976
+ }
977
+ }
978
+ /**
979
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
980
+ *
981
+ * @returns True if completely idle, false otherwise.
982
+ */
983
+ isIdle() {
984
+ 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;
985
+ }
986
+ /**
987
+ * Returns a promise that resolves once the scheduler has processed all tasks and is idle.
988
+ *
989
+ * @returns Promise resolving when idle.
990
+ */
991
+ onIdle() {
992
+ if (this.isIdle()) {
993
+ return Promise.resolve();
994
+ }
995
+ return new Promise((resolve) => {
996
+ this.idleResolvers.add(resolve);
997
+ });
998
+ }
999
+ /**
1000
+ * Clears all pending and waiting tasks from the scheduler, cancelling their runners.
1001
+ * Active tasks currently in flight will continue to run to completion or abort via signal.
1002
+ */
1003
+ clear() {
1004
+ while (this.queue.length > 0) {
1005
+ const runner = this.queue.shift();
1006
+ if (runner && runner.state !== "cancelled" /* CANCELLED */) {
1007
+ runner.cancel("Scheduler cleared");
1008
+ this.cancelledTasks++;
1009
+ this.emitter.emit("task:cancel", { taskId: runner.taskId, reason: "Scheduler cleared" });
1010
+ }
1011
+ }
1012
+ for (const entry of this.delayedEntries.values()) {
1013
+ clearTimeout(entry.timerId);
1014
+ entry.runner.cancel("Scheduler cleared");
1015
+ this.cancelledTasks++;
1016
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1017
+ }
1018
+ this.delayedEntries.clear();
1019
+ for (const entry of this.idleEntries.values()) {
1020
+ entry.handle.cancel();
1021
+ entry.runner.cancel("Scheduler cleared");
1022
+ this.cancelledTasks++;
1023
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1024
+ }
1025
+ this.idleEntries.clear();
1026
+ for (const entry of this.retryEntries.values()) {
1027
+ clearTimeout(entry.timerId);
1028
+ entry.runner.cancel("Scheduler cleared");
1029
+ this.cancelledTasks++;
1030
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1031
+ }
1032
+ this.retryEntries.clear();
1033
+ this.debounceCoordinator.clear();
1034
+ this.throttleCoordinator.clear();
1035
+ if (this.rateLimitTimer !== void 0) {
1036
+ clearTimeout(this.rateLimitTimer);
1037
+ this.rateLimitTimer = void 0;
1038
+ }
1039
+ this.checkIdle();
1040
+ }
815
1041
  /**
816
1042
  * Returns telemetry snapshot for the scheduler.
817
1043
  *
@@ -825,6 +1051,8 @@ var TaskQueue = class {
825
1051
  failedTasks: this.failedTasks,
826
1052
  cancelledTasks: this.cancelledTasks,
827
1053
  timedOutTasks: this.timedOutTasks,
1054
+ retriedTasks: this.retriedTasks,
1055
+ totalDispatched: this.totalDispatched,
828
1056
  capacity: this.concurrency
829
1057
  });
830
1058
  }
@@ -859,6 +1087,8 @@ var TaskRunner = class {
859
1087
  onCancel;
860
1088
  /** Current execution attempt count (1-indexed) */
861
1089
  attempt = 1;
1090
+ /** Duration of the most recent execution attempt in milliseconds */
1091
+ lastDurationMs = 0;
862
1092
  /**
863
1093
  * Creates a new TaskRunner instance.
864
1094
  *
@@ -1017,9 +1247,11 @@ var TaskRunner = class {
1017
1247
  if (timeoutPromise) {
1018
1248
  racePromises.push(timeoutPromise);
1019
1249
  }
1250
+ const startTime = Date.now();
1020
1251
  try {
1021
1252
  const result = await Promise.race(racePromises);
1022
1253
  this.clearTimeoutTimer();
1254
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1023
1255
  if (abortListener) {
1024
1256
  this.abortController.signal.removeEventListener("abort", abortListener);
1025
1257
  }
@@ -1036,6 +1268,7 @@ var TaskRunner = class {
1036
1268
  return result;
1037
1269
  } catch (error) {
1038
1270
  this.clearTimeoutTimer();
1271
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1039
1272
  if (abortListener) {
1040
1273
  this.abortController.signal.removeEventListener("abort", abortListener);
1041
1274
  }
@@ -1277,10 +1510,87 @@ var Ahko = class {
1277
1510
  stats() {
1278
1511
  return this.queue.getStats();
1279
1512
  }
1513
+ /**
1514
+ * Subscribes to a scheduler lifecycle event.
1515
+ *
1516
+ * @param event - Event name to listen for.
1517
+ * @param handler - Callback function invoked when the event is emitted.
1518
+ * @returns Unsubscribe function to remove the listener.
1519
+ *
1520
+ * @example
1521
+ * ```typescript
1522
+ * const unsubscribe = ahko.on("task:start", ({ taskId, attempt }) => {
1523
+ * console.log(`Task ${taskId} started attempt ${attempt}`);
1524
+ * });
1525
+ * ```
1526
+ */
1527
+ on(event, handler) {
1528
+ return this.queue.emitter.on(event, handler);
1529
+ }
1530
+ /**
1531
+ * Unsubscribes an event listener from a scheduler lifecycle event.
1532
+ *
1533
+ * @param event - Event name.
1534
+ * @param handler - The exact listener callback to remove.
1535
+ */
1536
+ off(event, handler) {
1537
+ this.queue.emitter.off(event, handler);
1538
+ }
1539
+ /**
1540
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
1541
+ *
1542
+ * @returns True if completely idle, false otherwise.
1543
+ */
1544
+ isIdle() {
1545
+ return this.queue.isIdle();
1546
+ }
1547
+ /**
1548
+ * Returns a promise that resolves once the scheduler has completed all tasks and is idle.
1549
+ *
1550
+ * @returns Promise resolving when the scheduler is idle.
1551
+ *
1552
+ * @example
1553
+ * ```typescript
1554
+ * ahko.schedule(doWork);
1555
+ * await ahko.onIdle();
1556
+ * console.log("All work finished!");
1557
+ * ```
1558
+ */
1559
+ onIdle() {
1560
+ return this.queue.onIdle();
1561
+ }
1562
+ /**
1563
+ * Clears all pending, delayed, and throttled/debounced tasks from the scheduler.
1564
+ * In-flight active tasks will continue executing to completion or abort via signal.
1565
+ */
1566
+ clear() {
1567
+ this.queue.clear();
1568
+ }
1569
+ /**
1570
+ * Returns the delightful Ahko mascot battery telemetry status.
1571
+ *
1572
+ * Low energy, completely chill.
1573
+ */
1574
+ battery() {
1575
+ return {
1576
+ level: 3,
1577
+ chill: true,
1578
+ status: "low-energy",
1579
+ quote: "Mwee... my battery is low, but all your tasks are handled completely chill."
1580
+ };
1581
+ }
1582
+ /**
1583
+ * Delightful alias for `onIdle()`: wait for all tasks to settle chill and relaxed.
1584
+ *
1585
+ * @returns Promise resolving when all tasks have finished.
1586
+ */
1587
+ chill() {
1588
+ return this.onIdle();
1589
+ }
1280
1590
  };
1281
1591
 
1282
1592
  // src/version.ts
1283
- var VERSION = "0.5.0";
1593
+ var VERSION = "0.6.0";
1284
1594
 
1285
1595
  // src/errors/queue.error.ts
1286
1596
  var AhkoQueueError = class extends AhkoError {