@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.js CHANGED
@@ -112,6 +112,8 @@ var AhkoCancellationError = class extends AhkoError {
112
112
  // src/scheduler/debounce-coordinator.ts
113
113
  var DebounceCoordinator = class {
114
114
  entries = /* @__PURE__ */ new Map();
115
+ /** Optional callback invoked whenever entries are settled or removed from coordinator */
116
+ onSettled;
115
117
  /**
116
118
  * Schedules a task under the debounce strategy.
117
119
  *
@@ -198,6 +200,7 @@ var DebounceCoordinator = class {
198
200
  return;
199
201
  }
200
202
  this.entries.delete(key);
203
+ this.onSettled?.();
201
204
  if (entry.options?.signal && entry.abortListener) {
202
205
  entry.options.signal.removeEventListener("abort", entry.abortListener);
203
206
  }
@@ -221,6 +224,7 @@ var DebounceCoordinator = class {
221
224
  }
222
225
  clearTimeout(entry.timerId);
223
226
  this.entries.delete(key);
227
+ this.onSettled?.();
224
228
  if (entry.options?.signal && entry.abortListener) {
225
229
  entry.options.signal.removeEventListener("abort", entry.abortListener);
226
230
  }
@@ -240,7 +244,7 @@ var DebounceCoordinator = class {
240
244
  * Cancels all pending debounced entries and clears the map.
241
245
  */
242
246
  clear() {
243
- for (const [key, entry] of this.entries) {
247
+ for (const entry of this.entries.values()) {
244
248
  clearTimeout(entry.timerId);
245
249
  if (entry.options?.signal && entry.abortListener) {
246
250
  entry.options.signal.removeEventListener("abort", entry.abortListener);
@@ -248,6 +252,7 @@ var DebounceCoordinator = class {
248
252
  entry.reject(new AhkoCancellationError("Debounced tasks cleared"));
249
253
  }
250
254
  this.entries.clear();
255
+ this.onSettled?.();
251
256
  }
252
257
  };
253
258
 
@@ -293,6 +298,8 @@ var IdleScheduler = class {
293
298
  // src/scheduler/throttle-coordinator.ts
294
299
  var ThrottleCoordinator = class {
295
300
  entries = /* @__PURE__ */ new Map();
301
+ /** Optional callback invoked whenever entries are settled or removed from coordinator */
302
+ onSettled;
296
303
  /**
297
304
  * Schedules a task under the throttle strategy.
298
305
  *
@@ -378,6 +385,7 @@ var ThrottleCoordinator = class {
378
385
  return;
379
386
  }
380
387
  this.entries.delete(key);
388
+ this.onSettled?.();
381
389
  }
382
390
  /**
383
391
  * Cancels any pending trailing throttled task for a given key.
@@ -394,6 +402,7 @@ var ThrottleCoordinator = class {
394
402
  clearTimeout(entry.windowTimerId);
395
403
  }
396
404
  this.entries.delete(key);
405
+ this.onSettled?.();
397
406
  if (entry.trailingReject) {
398
407
  const cancelError = new AhkoCancellationError(
399
408
  typeof reason === "string" ? reason : "Throttled task was cancelled",
@@ -412,7 +421,7 @@ var ThrottleCoordinator = class {
412
421
  * Clears all throttled entries and timers.
413
422
  */
414
423
  clear() {
415
- for (const [key, entry] of this.entries) {
424
+ for (const entry of this.entries.values()) {
416
425
  if (entry.windowTimerId !== void 0) {
417
426
  clearTimeout(entry.windowTimerId);
418
427
  }
@@ -421,6 +430,75 @@ var ThrottleCoordinator = class {
421
430
  }
422
431
  }
423
432
  this.entries.clear();
433
+ this.onSettled?.();
434
+ }
435
+ };
436
+
437
+ // src/events/event-emitter.ts
438
+ var AhkoEventEmitter = class {
439
+ listeners = /* @__PURE__ */ new Map();
440
+ /**
441
+ * Subscribes a listener to a specific Ahko lifecycle event.
442
+ *
443
+ * @param event - The event name to subscribe to.
444
+ * @param handler - The callback function to invoke when the event is emitted.
445
+ * @returns An unsubscribe function to remove the listener.
446
+ */
447
+ on(event, handler) {
448
+ let set = this.listeners.get(event);
449
+ if (!set) {
450
+ set = /* @__PURE__ */ new Set();
451
+ this.listeners.set(event, set);
452
+ }
453
+ set.add(handler);
454
+ return () => {
455
+ this.off(event, handler);
456
+ };
457
+ }
458
+ /**
459
+ * Unsubscribes a listener from a specific Ahko lifecycle event.
460
+ *
461
+ * @param event - The event name.
462
+ * @param handler - The callback function to remove.
463
+ */
464
+ off(event, handler) {
465
+ const set = this.listeners.get(event);
466
+ if (set) {
467
+ set.delete(handler);
468
+ if (set.size === 0) {
469
+ this.listeners.delete(event);
470
+ }
471
+ }
472
+ }
473
+ /**
474
+ * Emits an event with the corresponding typed payload to all subscribed listeners.
475
+ * Listener invocations are safely isolated in try/catch to protect scheduler integrity.
476
+ *
477
+ * @param event - The event name to emit.
478
+ * @param payload - The event-specific payload data.
479
+ */
480
+ emit(event, payload) {
481
+ const set = this.listeners.get(event);
482
+ if (!set || set.size === 0) {
483
+ return;
484
+ }
485
+ const handlers = Array.from(set);
486
+ for (const handler of handlers) {
487
+ try {
488
+ const result = handler(payload);
489
+ if (result && typeof result.catch === "function") {
490
+ result.catch(() => {
491
+ });
492
+ }
493
+ } catch {
494
+ }
495
+ }
496
+ }
497
+ /**
498
+ * Removes all registered event listeners.
499
+ */
500
+ clear() {
501
+ this.listeners.clear();
424
502
  }
425
503
  };
426
504
 
@@ -448,6 +526,10 @@ var TaskQueue = class {
448
526
  debounceCoordinator = new DebounceCoordinator();
449
527
  /** Coordinator for throttled tasks with leading/trailing coalescing */
450
528
  throttleCoordinator = new ThrottleCoordinator();
529
+ /** Lifecycle event emitter for task and scheduler events */
530
+ emitter = new AhkoEventEmitter();
531
+ /** Set of pending resolvers awaiting scheduler idle transition */
532
+ idleResolvers = /* @__PURE__ */ new Set();
451
533
  /** WeakMap associating task runners with their scheduling options */
452
534
  runnerOptions = /* @__PURE__ */ new WeakMap();
453
535
  /** Cumulative completed tasks counter */
@@ -458,6 +540,10 @@ var TaskQueue = class {
458
540
  cancelledTasks = 0;
459
541
  /** Cumulative timed out tasks counter */
460
542
  timedOutTasks = 0;
543
+ /** Cumulative count of retry attempts triggered */
544
+ retriedTasks = 0;
545
+ /** Cumulative count of tasks dispatched to concurrency slots */
546
+ totalDispatched = 0;
461
547
  /**
462
548
  * Creates a new TaskQueue.
463
549
  *
@@ -478,6 +564,8 @@ var TaskQueue = class {
478
564
  }
479
565
  this.concurrency = concurrency;
480
566
  this.minIntervalMs = minIntervalMs;
567
+ this.debounceCoordinator.onSettled = () => this.checkIdle();
568
+ this.throttleCoordinator.onSettled = () => this.checkIdle();
481
569
  }
482
570
  /**
483
571
  * Enqueues a task runner according to the specified schedule options.
@@ -563,6 +651,11 @@ var TaskQueue = class {
563
651
  if (index !== -1) {
564
652
  this.queue.splice(index, 1);
565
653
  this.cancelledTasks++;
654
+ this.emitter.emit("task:cancel", {
655
+ taskId: runner.taskId,
656
+ reason: "Task cancelled while queued"
657
+ });
658
+ this.checkIdle();
566
659
  }
567
660
  };
568
661
  this.queue.push(runner);
@@ -586,6 +679,11 @@ var TaskQueue = class {
586
679
  if (index !== -1) {
587
680
  this.queue.splice(index, 1);
588
681
  this.cancelledTasks++;
682
+ this.emitter.emit("task:cancel", {
683
+ taskId: runner.taskId,
684
+ reason: "Task cancelled while queued"
685
+ });
686
+ this.checkIdle();
589
687
  }
590
688
  };
591
689
  this.queue.push(runner);
@@ -598,6 +696,11 @@ var TaskQueue = class {
598
696
  clearTimeout(delayedEntry.timerId);
599
697
  this.delayedEntries.delete(delayedEntry);
600
698
  this.cancelledTasks++;
699
+ this.emitter.emit("task:cancel", {
700
+ taskId: runner.taskId,
701
+ reason: "Task cancelled while waiting in delay"
702
+ });
703
+ this.checkIdle();
601
704
  }
602
705
  };
603
706
  }
@@ -617,6 +720,11 @@ var TaskQueue = class {
617
720
  if (index !== -1) {
618
721
  this.queue.splice(index, 1);
619
722
  this.cancelledTasks++;
723
+ this.emitter.emit("task:cancel", {
724
+ taskId: runner.taskId,
725
+ reason: "Task cancelled while queued"
726
+ });
727
+ this.checkIdle();
620
728
  }
621
729
  };
622
730
  this.queue.push(runner);
@@ -629,6 +737,11 @@ var TaskQueue = class {
629
737
  handle.cancel();
630
738
  this.idleEntries.delete(idleEntry);
631
739
  this.cancelledTasks++;
740
+ this.emitter.emit("task:cancel", {
741
+ taskId: runner.taskId,
742
+ reason: "Task cancelled while waiting for idle"
743
+ });
744
+ this.checkIdle();
632
745
  }
633
746
  };
634
747
  }
@@ -697,36 +810,69 @@ var TaskQueue = class {
697
810
  */
698
811
  async executeRunner(runner) {
699
812
  const options = this.runnerOptions.get(runner);
813
+ this.totalDispatched++;
814
+ this.emitter.emit("task:start", {
815
+ taskId: runner.taskId,
816
+ attempt: runner.attempt
817
+ });
700
818
  try {
701
819
  const result = await runner.run();
702
820
  this.completedTasks++;
703
821
  this.activeRunners.delete(runner);
704
822
  this.runnerOptions.delete(runner);
823
+ this.emitter.emit("task:complete", {
824
+ taskId: runner.taskId,
825
+ attempt: runner.attempt,
826
+ durationMs: runner.lastDurationMs,
827
+ result
828
+ });
705
829
  runner.resolve(result);
706
830
  } catch (error) {
707
831
  if (runner.state === "cancelled" /* CANCELLED */) {
708
832
  this.cancelledTasks++;
709
833
  this.activeRunners.delete(runner);
710
834
  this.runnerOptions.delete(runner);
835
+ this.emitter.emit("task:cancel", {
836
+ taskId: runner.taskId,
837
+ reason: error
838
+ });
711
839
  runner.reject(error);
712
840
  return;
713
841
  }
714
842
  const shouldRetry = await runner.canRetry(error, options?.retry);
715
843
  if (shouldRetry) {
844
+ this.retriedTasks++;
716
845
  this.activeRunners.delete(runner);
846
+ this.emitter.emit("task:fail", {
847
+ taskId: runner.taskId,
848
+ attempt: runner.attempt - 1,
849
+ error,
850
+ willRetry: true
851
+ });
717
852
  this.scheduleRetry(runner, options);
718
853
  return;
719
854
  }
720
855
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
721
856
  this.timedOutTasks++;
857
+ this.emitter.emit("task:timeout", {
858
+ taskId: runner.taskId,
859
+ timeoutMs: runner.timeoutMs
860
+ });
722
861
  } else {
723
862
  this.failedTasks++;
724
863
  }
725
864
  this.activeRunners.delete(runner);
726
865
  this.runnerOptions.delete(runner);
866
+ this.emitter.emit("task:fail", {
867
+ taskId: runner.taskId,
868
+ attempt: runner.attempt,
869
+ error,
870
+ willRetry: false
871
+ });
727
872
  runner.reject(error);
728
873
  } finally {
729
874
  this.pump();
875
+ this.checkIdle();
730
876
  }
731
877
  }
732
878
  /**
@@ -741,6 +887,11 @@ var TaskQueue = class {
741
887
  if (index !== -1) {
742
888
  this.queue.splice(index, 1);
743
889
  this.cancelledTasks++;
890
+ this.emitter.emit("task:cancel", {
891
+ taskId: runner.taskId,
892
+ reason: "Task cancelled while queued"
893
+ });
894
+ this.checkIdle();
744
895
  }
745
896
  };
746
897
  this.queue.push(runner);
@@ -759,6 +910,11 @@ var TaskQueue = class {
759
910
  if (index !== -1) {
760
911
  this.queue.splice(index, 1);
761
912
  this.cancelledTasks++;
913
+ this.emitter.emit("task:cancel", {
914
+ taskId: runner.taskId,
915
+ reason: "Task cancelled while queued"
916
+ });
917
+ this.checkIdle();
762
918
  }
763
919
  };
764
920
  this.queue.push(runner);
@@ -771,9 +927,91 @@ var TaskQueue = class {
771
927
  clearTimeout(retryEntry.timerId);
772
928
  this.retryEntries.delete(retryEntry);
773
929
  this.cancelledTasks++;
930
+ this.emitter.emit("task:cancel", {
931
+ taskId: runner.taskId,
932
+ reason: "Task cancelled during retry backoff"
933
+ });
934
+ this.checkIdle();
774
935
  }
775
936
  };
776
937
  }
938
+ /**
939
+ * Checks whether the scheduler has transitioned to idle and notifies listeners/resolvers.
940
+ */
941
+ checkIdle() {
942
+ if (this.isIdle()) {
943
+ if (this.idleResolvers.size > 0) {
944
+ for (const resolve of this.idleResolvers) {
945
+ resolve();
946
+ }
947
+ this.idleResolvers.clear();
948
+ }
949
+ this.emitter.emit("idle", { timestamp: Date.now() });
950
+ }
951
+ }
952
+ /**
953
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
954
+ *
955
+ * @returns True if completely idle, false otherwise.
956
+ */
957
+ isIdle() {
958
+ 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;
959
+ }
960
+ /**
961
+ * Returns a promise that resolves once the scheduler has processed all tasks and is idle.
962
+ *
963
+ * @returns Promise resolving when idle.
964
+ */
965
+ onIdle() {
966
+ if (this.isIdle()) {
967
+ return Promise.resolve();
968
+ }
969
+ return new Promise((resolve) => {
970
+ this.idleResolvers.add(resolve);
971
+ });
972
+ }
973
+ /**
974
+ * Clears all pending and waiting tasks from the scheduler, cancelling their runners.
975
+ * Active tasks currently in flight will continue to run to completion or abort via signal.
976
+ */
977
+ clear() {
978
+ while (this.queue.length > 0) {
979
+ const runner = this.queue.shift();
980
+ if (runner && runner.state !== "cancelled" /* CANCELLED */) {
981
+ runner.cancel("Scheduler cleared");
982
+ this.cancelledTasks++;
983
+ this.emitter.emit("task:cancel", { taskId: runner.taskId, reason: "Scheduler cleared" });
984
+ }
985
+ }
986
+ for (const entry of this.delayedEntries.values()) {
987
+ clearTimeout(entry.timerId);
988
+ entry.runner.cancel("Scheduler cleared");
989
+ this.cancelledTasks++;
990
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
991
+ }
992
+ this.delayedEntries.clear();
993
+ for (const entry of this.idleEntries.values()) {
994
+ entry.handle.cancel();
995
+ entry.runner.cancel("Scheduler cleared");
996
+ this.cancelledTasks++;
997
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
998
+ }
999
+ this.idleEntries.clear();
1000
+ for (const entry of this.retryEntries.values()) {
1001
+ clearTimeout(entry.timerId);
1002
+ entry.runner.cancel("Scheduler cleared");
1003
+ this.cancelledTasks++;
1004
+ this.emitter.emit("task:cancel", { taskId: entry.runner.taskId, reason: "Scheduler cleared" });
1005
+ }
1006
+ this.retryEntries.clear();
1007
+ this.debounceCoordinator.clear();
1008
+ this.throttleCoordinator.clear();
1009
+ if (this.rateLimitTimer !== void 0) {
1010
+ clearTimeout(this.rateLimitTimer);
1011
+ this.rateLimitTimer = void 0;
1012
+ }
1013
+ this.checkIdle();
1014
+ }
777
1015
  /**
778
1016
  * Returns telemetry snapshot for the scheduler.
779
1017
  *
@@ -787,6 +1025,8 @@ var TaskQueue = class {
787
1025
  failedTasks: this.failedTasks,
788
1026
  cancelledTasks: this.cancelledTasks,
789
1027
  timedOutTasks: this.timedOutTasks,
1028
+ retriedTasks: this.retriedTasks,
1029
+ totalDispatched: this.totalDispatched,
790
1030
  capacity: this.concurrency
791
1031
  });
792
1032
  }
@@ -821,6 +1061,8 @@ var TaskRunner = class {
821
1061
  onCancel;
822
1062
  /** Current execution attempt count (1-indexed) */
823
1063
  attempt = 1;
1064
+ /** Duration of the most recent execution attempt in milliseconds */
1065
+ lastDurationMs = 0;
824
1066
  /**
825
1067
  * Creates a new TaskRunner instance.
826
1068
  *
@@ -979,9 +1221,11 @@ var TaskRunner = class {
979
1221
  if (timeoutPromise) {
980
1222
  racePromises.push(timeoutPromise);
981
1223
  }
1224
+ const startTime = Date.now();
982
1225
  try {
983
1226
  const result = await Promise.race(racePromises);
984
1227
  this.clearTimeoutTimer();
1228
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
985
1229
  if (abortListener) {
986
1230
  this.abortController.signal.removeEventListener("abort", abortListener);
987
1231
  }
@@ -998,6 +1242,7 @@ var TaskRunner = class {
998
1242
  return result;
999
1243
  } catch (error) {
1000
1244
  this.clearTimeoutTimer();
1245
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
1001
1246
  if (abortListener) {
1002
1247
  this.abortController.signal.removeEventListener("abort", abortListener);
1003
1248
  }
@@ -1239,10 +1484,87 @@ var Ahko = class {
1239
1484
  stats() {
1240
1485
  return this.queue.getStats();
1241
1486
  }
1487
+ /**
1488
+ * Subscribes to a scheduler lifecycle event.
1489
+ *
1490
+ * @param event - Event name to listen for.
1491
+ * @param handler - Callback function invoked when the event is emitted.
1492
+ * @returns Unsubscribe function to remove the listener.
1493
+ *
1494
+ * @example
1495
+ * ```typescript
1496
+ * const unsubscribe = ahko.on("task:start", ({ taskId, attempt }) => {
1497
+ * console.log(`Task ${taskId} started attempt ${attempt}`);
1498
+ * });
1499
+ * ```
1500
+ */
1501
+ on(event, handler) {
1502
+ return this.queue.emitter.on(event, handler);
1503
+ }
1504
+ /**
1505
+ * Unsubscribes an event listener from a scheduler lifecycle event.
1506
+ *
1507
+ * @param event - Event name.
1508
+ * @param handler - The exact listener callback to remove.
1509
+ */
1510
+ off(event, handler) {
1511
+ this.queue.emitter.off(event, handler);
1512
+ }
1513
+ /**
1514
+ * Checks whether the scheduler is currently idle (no active runners and no pending tasks).
1515
+ *
1516
+ * @returns True if completely idle, false otherwise.
1517
+ */
1518
+ isIdle() {
1519
+ return this.queue.isIdle();
1520
+ }
1521
+ /**
1522
+ * Returns a promise that resolves once the scheduler has completed all tasks and is idle.
1523
+ *
1524
+ * @returns Promise resolving when the scheduler is idle.
1525
+ *
1526
+ * @example
1527
+ * ```typescript
1528
+ * ahko.schedule(doWork);
1529
+ * await ahko.onIdle();
1530
+ * console.log("All work finished!");
1531
+ * ```
1532
+ */
1533
+ onIdle() {
1534
+ return this.queue.onIdle();
1535
+ }
1536
+ /**
1537
+ * Clears all pending, delayed, and throttled/debounced tasks from the scheduler.
1538
+ * In-flight active tasks will continue executing to completion or abort via signal.
1539
+ */
1540
+ clear() {
1541
+ this.queue.clear();
1542
+ }
1543
+ /**
1544
+ * Returns the delightful Ahko mascot battery telemetry status.
1545
+ *
1546
+ * Low energy, completely chill.
1547
+ */
1548
+ battery() {
1549
+ return {
1550
+ level: 3,
1551
+ chill: true,
1552
+ status: "low-energy",
1553
+ quote: "Mwee... my battery is low, but all your tasks are handled completely chill."
1554
+ };
1555
+ }
1556
+ /**
1557
+ * Delightful alias for `onIdle()`: wait for all tasks to settle chill and relaxed.
1558
+ *
1559
+ * @returns Promise resolving when all tasks have finished.
1560
+ */
1561
+ chill() {
1562
+ return this.onIdle();
1563
+ }
1242
1564
  };
1243
1565
 
1244
1566
  // src/version.ts
1245
- var VERSION = "0.5.0";
1567
+ var VERSION = "1.0.0";
1246
1568
 
1247
1569
  // src/errors/queue.error.ts
1248
1570
  var AhkoQueueError = class extends AhkoError {