@mrjacket/ahko 0.5.0 → 1.0.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
@@ -150,6 +150,8 @@ var AhkoCancellationError = class extends AhkoError {
150
150
  // src/scheduler/debounce-coordinator.ts
151
151
  var DebounceCoordinator = class {
152
152
  entries = /* @__PURE__ */ new Map();
153
+ /** Optional callback invoked whenever entries are settled or removed from coordinator */
154
+ onSettled;
153
155
  /**
154
156
  * Schedules a task under the debounce strategy.
155
157
  *
@@ -236,6 +238,7 @@ var DebounceCoordinator = class {
236
238
  return;
237
239
  }
238
240
  this.entries.delete(key);
241
+ this.onSettled?.();
239
242
  if (entry.options?.signal && entry.abortListener) {
240
243
  entry.options.signal.removeEventListener("abort", entry.abortListener);
241
244
  }
@@ -259,6 +262,7 @@ var DebounceCoordinator = class {
259
262
  }
260
263
  clearTimeout(entry.timerId);
261
264
  this.entries.delete(key);
265
+ this.onSettled?.();
262
266
  if (entry.options?.signal && entry.abortListener) {
263
267
  entry.options.signal.removeEventListener("abort", entry.abortListener);
264
268
  }
@@ -278,7 +282,7 @@ var DebounceCoordinator = class {
278
282
  * Cancels all pending debounced entries and clears the map.
279
283
  */
280
284
  clear() {
281
- for (const [key, entry] of this.entries) {
285
+ for (const entry of this.entries.values()) {
282
286
  clearTimeout(entry.timerId);
283
287
  if (entry.options?.signal && entry.abortListener) {
284
288
  entry.options.signal.removeEventListener("abort", entry.abortListener);
@@ -286,6 +290,7 @@ var DebounceCoordinator = class {
286
290
  entry.reject(new AhkoCancellationError("Debounced tasks cleared"));
287
291
  }
288
292
  this.entries.clear();
293
+ this.onSettled?.();
289
294
  }
290
295
  };
291
296
 
@@ -331,6 +336,8 @@ var IdleScheduler = class {
331
336
  // src/scheduler/throttle-coordinator.ts
332
337
  var ThrottleCoordinator = class {
333
338
  entries = /* @__PURE__ */ new Map();
339
+ /** Optional callback invoked whenever entries are settled or removed from coordinator */
340
+ onSettled;
334
341
  /**
335
342
  * Schedules a task under the throttle strategy.
336
343
  *
@@ -416,6 +423,7 @@ var ThrottleCoordinator = class {
416
423
  return;
417
424
  }
418
425
  this.entries.delete(key);
426
+ this.onSettled?.();
419
427
  }
420
428
  /**
421
429
  * Cancels any pending trailing throttled task for a given key.
@@ -432,6 +440,7 @@ var ThrottleCoordinator = class {
432
440
  clearTimeout(entry.windowTimerId);
433
441
  }
434
442
  this.entries.delete(key);
443
+ this.onSettled?.();
435
444
  if (entry.trailingReject) {
436
445
  const cancelError = new AhkoCancellationError(
437
446
  typeof reason === "string" ? reason : "Throttled task was cancelled",
@@ -450,7 +459,7 @@ var ThrottleCoordinator = class {
450
459
  * Clears all throttled entries and timers.
451
460
  */
452
461
  clear() {
453
- for (const [key, entry] of this.entries) {
462
+ for (const entry of this.entries.values()) {
454
463
  if (entry.windowTimerId !== void 0) {
455
464
  clearTimeout(entry.windowTimerId);
456
465
  }
@@ -459,6 +468,75 @@ var ThrottleCoordinator = class {
459
468
  }
460
469
  }
461
470
  this.entries.clear();
471
+ this.onSettled?.();
472
+ }
473
+ };
474
+
475
+ // src/events/event-emitter.ts
476
+ var AhkoEventEmitter = class {
477
+ listeners = /* @__PURE__ */ new Map();
478
+ /**
479
+ * Subscribes a listener to a specific Ahko lifecycle event.
480
+ *
481
+ * @param event - The event name to subscribe to.
482
+ * @param handler - The callback function to invoke when the event is emitted.
483
+ * @returns An unsubscribe function to remove the listener.
484
+ */
485
+ on(event, handler) {
486
+ let set = this.listeners.get(event);
487
+ if (!set) {
488
+ set = /* @__PURE__ */ new Set();
489
+ this.listeners.set(event, set);
490
+ }
491
+ set.add(handler);
492
+ return () => {
493
+ this.off(event, handler);
494
+ };
495
+ }
496
+ /**
497
+ * Unsubscribes a listener from a specific Ahko lifecycle event.
498
+ *
499
+ * @param event - The event name.
500
+ * @param handler - The callback function to remove.
501
+ */
502
+ off(event, handler) {
503
+ const set = this.listeners.get(event);
504
+ if (set) {
505
+ set.delete(handler);
506
+ if (set.size === 0) {
507
+ this.listeners.delete(event);
508
+ }
509
+ }
510
+ }
511
+ /**
512
+ * Emits an event with the corresponding typed payload to all subscribed listeners.
513
+ * Listener invocations are safely isolated in try/catch to protect scheduler integrity.
514
+ *
515
+ * @param event - The event name to emit.
516
+ * @param payload - The event-specific payload data.
517
+ */
518
+ emit(event, payload) {
519
+ const set = this.listeners.get(event);
520
+ if (!set || set.size === 0) {
521
+ return;
522
+ }
523
+ const handlers = Array.from(set);
524
+ for (const handler of handlers) {
525
+ try {
526
+ const result = handler(payload);
527
+ if (result && typeof result.catch === "function") {
528
+ result.catch(() => {
529
+ });
530
+ }
531
+ } catch {
532
+ }
533
+ }
534
+ }
535
+ /**
536
+ * Removes all registered event listeners.
537
+ */
538
+ clear() {
539
+ this.listeners.clear();
462
540
  }
463
541
  };
464
542
 
@@ -486,6 +564,10 @@ var TaskQueue = class {
486
564
  debounceCoordinator = new DebounceCoordinator();
487
565
  /** Coordinator for throttled tasks with leading/trailing coalescing */
488
566
  throttleCoordinator = new ThrottleCoordinator();
567
+ /** Lifecycle event emitter for task and scheduler events */
568
+ emitter = new AhkoEventEmitter();
569
+ /** Set of pending resolvers awaiting scheduler idle transition */
570
+ idleResolvers = /* @__PURE__ */ new Set();
489
571
  /** WeakMap associating task runners with their scheduling options */
490
572
  runnerOptions = /* @__PURE__ */ new WeakMap();
491
573
  /** Cumulative completed tasks counter */
@@ -496,6 +578,10 @@ var TaskQueue = class {
496
578
  cancelledTasks = 0;
497
579
  /** Cumulative timed out tasks counter */
498
580
  timedOutTasks = 0;
581
+ /** Cumulative count of retry attempts triggered */
582
+ retriedTasks = 0;
583
+ /** Cumulative count of tasks dispatched to concurrency slots */
584
+ totalDispatched = 0;
499
585
  /**
500
586
  * Creates a new TaskQueue.
501
587
  *
@@ -516,6 +602,8 @@ var TaskQueue = class {
516
602
  }
517
603
  this.concurrency = concurrency;
518
604
  this.minIntervalMs = minIntervalMs;
605
+ this.debounceCoordinator.onSettled = () => this.checkIdle();
606
+ this.throttleCoordinator.onSettled = () => this.checkIdle();
519
607
  }
520
608
  /**
521
609
  * Enqueues a task runner according to the specified schedule options.
@@ -601,6 +689,11 @@ var TaskQueue = class {
601
689
  if (index !== -1) {
602
690
  this.queue.splice(index, 1);
603
691
  this.cancelledTasks++;
692
+ this.emitter.emit("task:cancel", {
693
+ taskId: runner.taskId,
694
+ reason: "Task cancelled while queued"
695
+ });
696
+ this.checkIdle();
604
697
  }
605
698
  };
606
699
  this.queue.push(runner);
@@ -624,6 +717,11 @@ var TaskQueue = class {
624
717
  if (index !== -1) {
625
718
  this.queue.splice(index, 1);
626
719
  this.cancelledTasks++;
720
+ this.emitter.emit("task:cancel", {
721
+ taskId: runner.taskId,
722
+ reason: "Task cancelled while queued"
723
+ });
724
+ this.checkIdle();
627
725
  }
628
726
  };
629
727
  this.queue.push(runner);
@@ -636,6 +734,11 @@ var TaskQueue = class {
636
734
  clearTimeout(delayedEntry.timerId);
637
735
  this.delayedEntries.delete(delayedEntry);
638
736
  this.cancelledTasks++;
737
+ this.emitter.emit("task:cancel", {
738
+ taskId: runner.taskId,
739
+ reason: "Task cancelled while waiting in delay"
740
+ });
741
+ this.checkIdle();
639
742
  }
640
743
  };
641
744
  }
@@ -655,6 +758,11 @@ var TaskQueue = class {
655
758
  if (index !== -1) {
656
759
  this.queue.splice(index, 1);
657
760
  this.cancelledTasks++;
761
+ this.emitter.emit("task:cancel", {
762
+ taskId: runner.taskId,
763
+ reason: "Task cancelled while queued"
764
+ });
765
+ this.checkIdle();
658
766
  }
659
767
  };
660
768
  this.queue.push(runner);
@@ -667,6 +775,11 @@ var TaskQueue = class {
667
775
  handle.cancel();
668
776
  this.idleEntries.delete(idleEntry);
669
777
  this.cancelledTasks++;
778
+ this.emitter.emit("task:cancel", {
779
+ taskId: runner.taskId,
780
+ reason: "Task cancelled while waiting for idle"
781
+ });
782
+ this.checkIdle();
670
783
  }
671
784
  };
672
785
  }
@@ -735,36 +848,69 @@ var TaskQueue = class {
735
848
  */
736
849
  async executeRunner(runner) {
737
850
  const options = this.runnerOptions.get(runner);
851
+ this.totalDispatched++;
852
+ this.emitter.emit("task:start", {
853
+ taskId: runner.taskId,
854
+ attempt: runner.attempt
855
+ });
738
856
  try {
739
857
  const result = await runner.run();
740
858
  this.completedTasks++;
741
859
  this.activeRunners.delete(runner);
742
860
  this.runnerOptions.delete(runner);
861
+ this.emitter.emit("task:complete", {
862
+ taskId: runner.taskId,
863
+ attempt: runner.attempt,
864
+ durationMs: runner.lastDurationMs,
865
+ result
866
+ });
743
867
  runner.resolve(result);
744
868
  } catch (error) {
745
869
  if (runner.state === "cancelled" /* CANCELLED */) {
746
870
  this.cancelledTasks++;
747
871
  this.activeRunners.delete(runner);
748
872
  this.runnerOptions.delete(runner);
873
+ this.emitter.emit("task:cancel", {
874
+ taskId: runner.taskId,
875
+ reason: error
876
+ });
749
877
  runner.reject(error);
750
878
  return;
751
879
  }
752
880
  const shouldRetry = await runner.canRetry(error, options?.retry);
753
881
  if (shouldRetry) {
882
+ this.retriedTasks++;
754
883
  this.activeRunners.delete(runner);
884
+ this.emitter.emit("task:fail", {
885
+ taskId: runner.taskId,
886
+ attempt: runner.attempt - 1,
887
+ error,
888
+ willRetry: true
889
+ });
755
890
  this.scheduleRetry(runner, options);
756
891
  return;
757
892
  }
758
893
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
759
894
  this.timedOutTasks++;
895
+ this.emitter.emit("task:timeout", {
896
+ taskId: runner.taskId,
897
+ timeoutMs: runner.timeoutMs
898
+ });
760
899
  } else {
761
900
  this.failedTasks++;
762
901
  }
763
902
  this.activeRunners.delete(runner);
764
903
  this.runnerOptions.delete(runner);
904
+ this.emitter.emit("task:fail", {
905
+ taskId: runner.taskId,
906
+ attempt: runner.attempt,
907
+ error,
908
+ willRetry: false
909
+ });
765
910
  runner.reject(error);
766
911
  } finally {
767
912
  this.pump();
913
+ this.checkIdle();
768
914
  }
769
915
  }
770
916
  /**
@@ -779,6 +925,11 @@ var TaskQueue = class {
779
925
  if (index !== -1) {
780
926
  this.queue.splice(index, 1);
781
927
  this.cancelledTasks++;
928
+ this.emitter.emit("task:cancel", {
929
+ taskId: runner.taskId,
930
+ reason: "Task cancelled while queued"
931
+ });
932
+ this.checkIdle();
782
933
  }
783
934
  };
784
935
  this.queue.push(runner);
@@ -797,6 +948,11 @@ var TaskQueue = class {
797
948
  if (index !== -1) {
798
949
  this.queue.splice(index, 1);
799
950
  this.cancelledTasks++;
951
+ this.emitter.emit("task:cancel", {
952
+ taskId: runner.taskId,
953
+ reason: "Task cancelled while queued"
954
+ });
955
+ this.checkIdle();
800
956
  }
801
957
  };
802
958
  this.queue.push(runner);
@@ -809,9 +965,91 @@ var TaskQueue = class {
809
965
  clearTimeout(retryEntry.timerId);
810
966
  this.retryEntries.delete(retryEntry);
811
967
  this.cancelledTasks++;
968
+ this.emitter.emit("task:cancel", {
969
+ taskId: runner.taskId,
970
+ reason: "Task cancelled during retry backoff"
971
+ });
972
+ this.checkIdle();
812
973
  }
813
974
  };
814
975
  }
976
+ /**
977
+ * Checks whether the scheduler has transitioned to idle and notifies listeners/resolvers.
978
+ */
979
+ checkIdle() {
980
+ if (this.isIdle()) {
981
+ if (this.idleResolvers.size > 0) {
982
+ for (const resolve of this.idleResolvers) {
983
+ resolve();
984
+ }
985
+ this.idleResolvers.clear();
986
+ }
987
+ this.emitter.emit("idle", { timestamp: Date.now() });
988
+ }
989
+ }
990
+ /**
991
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
992
+ *
993
+ * @returns True if completely idle, false otherwise.
994
+ */
995
+ isIdle() {
996
+ 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;
997
+ }
998
+ /**
999
+ * Returns a promise that resolves once the scheduler has processed all tasks and is idle.
1000
+ *
1001
+ * @returns Promise resolving when idle.
1002
+ */
1003
+ onIdle() {
1004
+ if (this.isIdle()) {
1005
+ return Promise.resolve();
1006
+ }
1007
+ return new Promise((resolve) => {
1008
+ this.idleResolvers.add(resolve);
1009
+ });
1010
+ }
1011
+ /**
1012
+ * Clears all pending and waiting tasks from the scheduler, cancelling their runners.
1013
+ * Active tasks currently in flight will continue to run to completion or abort via signal.
1014
+ */
1015
+ clear() {
1016
+ while (this.queue.length > 0) {
1017
+ const runner = this.queue.shift();
1018
+ if (runner && runner.state !== "cancelled" /* CANCELLED */) {
1019
+ runner.cancel("Scheduler cleared");
1020
+ this.cancelledTasks++;
1021
+ this.emitter.emit("task:cancel", { taskId: runner.taskId, reason: "Scheduler cleared" });
1022
+ }
1023
+ }
1024
+ for (const entry of this.delayedEntries.values()) {
1025
+ clearTimeout(entry.timerId);
1026
+ entry.runner.cancel("Scheduler cleared");
1027
+ this.cancelledTasks++;
1028
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1029
+ }
1030
+ this.delayedEntries.clear();
1031
+ for (const entry of this.idleEntries.values()) {
1032
+ entry.handle.cancel();
1033
+ entry.runner.cancel("Scheduler cleared");
1034
+ this.cancelledTasks++;
1035
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1036
+ }
1037
+ this.idleEntries.clear();
1038
+ for (const entry of this.retryEntries.values()) {
1039
+ clearTimeout(entry.timerId);
1040
+ entry.runner.cancel("Scheduler cleared");
1041
+ this.cancelledTasks++;
1042
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1043
+ }
1044
+ this.retryEntries.clear();
1045
+ this.debounceCoordinator.clear();
1046
+ this.throttleCoordinator.clear();
1047
+ if (this.rateLimitTimer !== void 0) {
1048
+ clearTimeout(this.rateLimitTimer);
1049
+ this.rateLimitTimer = void 0;
1050
+ }
1051
+ this.checkIdle();
1052
+ }
815
1053
  /**
816
1054
  * Returns telemetry snapshot for the scheduler.
817
1055
  *
@@ -825,6 +1063,8 @@ var TaskQueue = class {
825
1063
  failedTasks: this.failedTasks,
826
1064
  cancelledTasks: this.cancelledTasks,
827
1065
  timedOutTasks: this.timedOutTasks,
1066
+ retriedTasks: this.retriedTasks,
1067
+ totalDispatched: this.totalDispatched,
828
1068
  capacity: this.concurrency
829
1069
  });
830
1070
  }
@@ -859,6 +1099,8 @@ var TaskRunner = class {
859
1099
  onCancel;
860
1100
  /** Current execution attempt count (1-indexed) */
861
1101
  attempt = 1;
1102
+ /** Duration of the most recent execution attempt in milliseconds */
1103
+ lastDurationMs = 0;
862
1104
  /**
863
1105
  * Creates a new TaskRunner instance.
864
1106
  *
@@ -1017,9 +1259,11 @@ var TaskRunner = class {
1017
1259
  if (timeoutPromise) {
1018
1260
  racePromises.push(timeoutPromise);
1019
1261
  }
1262
+ const startTime = Date.now();
1020
1263
  try {
1021
1264
  const result = await Promise.race(racePromises);
1022
1265
  this.clearTimeoutTimer();
1266
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1023
1267
  if (abortListener) {
1024
1268
  this.abortController.signal.removeEventListener("abort", abortListener);
1025
1269
  }
@@ -1036,6 +1280,7 @@ var TaskRunner = class {
1036
1280
  return result;
1037
1281
  } catch (error) {
1038
1282
  this.clearTimeoutTimer();
1283
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1039
1284
  if (abortListener) {
1040
1285
  this.abortController.signal.removeEventListener("abort", abortListener);
1041
1286
  }
@@ -1277,10 +1522,87 @@ var Ahko = class {
1277
1522
  stats() {
1278
1523
  return this.queue.getStats();
1279
1524
  }
1525
+ /**
1526
+ * Subscribes to a scheduler lifecycle event.
1527
+ *
1528
+ * @param event - Event name to listen for.
1529
+ * @param handler - Callback function invoked when the event is emitted.
1530
+ * @returns Unsubscribe function to remove the listener.
1531
+ *
1532
+ * @example
1533
+ * ```typescript
1534
+ * const unsubscribe = ahko.on("task:start", ({ taskId, attempt }) => {
1535
+ * console.log(`Task ${taskId} started attempt ${attempt}`);
1536
+ * });
1537
+ * ```
1538
+ */
1539
+ on(event, handler) {
1540
+ return this.queue.emitter.on(event, handler);
1541
+ }
1542
+ /**
1543
+ * Unsubscribes an event listener from a scheduler lifecycle event.
1544
+ *
1545
+ * @param event - Event name.
1546
+ * @param handler - The exact listener callback to remove.
1547
+ */
1548
+ off(event, handler) {
1549
+ this.queue.emitter.off(event, handler);
1550
+ }
1551
+ /**
1552
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
1553
+ *
1554
+ * @returns True if completely idle, false otherwise.
1555
+ */
1556
+ isIdle() {
1557
+ return this.queue.isIdle();
1558
+ }
1559
+ /**
1560
+ * Returns a promise that resolves once the scheduler has completed all tasks and is idle.
1561
+ *
1562
+ * @returns Promise resolving when the scheduler is idle.
1563
+ *
1564
+ * @example
1565
+ * ```typescript
1566
+ * ahko.schedule(doWork);
1567
+ * await ahko.onIdle();
1568
+ * console.log("All work finished!");
1569
+ * ```
1570
+ */
1571
+ onIdle() {
1572
+ return this.queue.onIdle();
1573
+ }
1574
+ /**
1575
+ * Clears all pending, delayed, and throttled/debounced tasks from the scheduler.
1576
+ * In-flight active tasks will continue executing to completion or abort via signal.
1577
+ */
1578
+ clear() {
1579
+ this.queue.clear();
1580
+ }
1581
+ /**
1582
+ * Returns the delightful Ahko mascot battery telemetry status.
1583
+ *
1584
+ * Low energy, completely chill.
1585
+ */
1586
+ battery() {
1587
+ return {
1588
+ level: 3,
1589
+ chill: true,
1590
+ status: "low-energy",
1591
+ quote: "Mwee... my battery is low, but all your tasks are handled completely chill."
1592
+ };
1593
+ }
1594
+ /**
1595
+ * Delightful alias for `onIdle()`: wait for all tasks to settle chill and relaxed.
1596
+ *
1597
+ * @returns Promise resolving when all tasks have finished.
1598
+ */
1599
+ chill() {
1600
+ return this.onIdle();
1601
+ }
1280
1602
  };
1281
1603
 
1282
1604
  // src/version.ts
1283
- var VERSION = "0.5.0";
1605
+ var VERSION = "1.0.0";
1284
1606
 
1285
1607
  // src/errors/queue.error.ts
1286
1608
  var AhkoQueueError = class extends AhkoError {