@mrjacket/ahko 0.4.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
@@ -33,6 +33,8 @@ var EScheduleStrategy = /* @__PURE__ */ ((EScheduleStrategy2) => {
33
33
  EScheduleStrategy2["IMMEDIATE"] = "immediate";
34
34
  EScheduleStrategy2["DELAY"] = "delay";
35
35
  EScheduleStrategy2["IDLE"] = "idle";
36
+ EScheduleStrategy2["THROTTLE"] = "throttle";
37
+ EScheduleStrategy2["DEBOUNCE"] = "debounce";
36
38
  return EScheduleStrategy2;
37
39
  })(EScheduleStrategy || {});
38
40
 
@@ -92,6 +94,163 @@ function calculateBackoff(attempt, options, randomFn = Math.random) {
92
94
  return Math.floor(cappedDelay);
93
95
  }
94
96
 
97
+ // src/errors/cancellation.error.ts
98
+ var AhkoCancellationError = class extends AhkoError {
99
+ /**
100
+ * Creates a new AhkoCancellationError.
101
+ *
102
+ * @param message - Reason for cancellation.
103
+ * @param options - Standard Error options including cause.
104
+ */
105
+ constructor(message = "Task was cancelled", options) {
106
+ super(message, options);
107
+ this.name = "AhkoCancellationError";
108
+ Object.setPrototypeOf(this, new.target.prototype);
109
+ }
110
+ };
111
+
112
+ // src/scheduler/debounce-coordinator.ts
113
+ var DebounceCoordinator = class {
114
+ entries = /* @__PURE__ */ new Map();
115
+ /**
116
+ * Schedules a task under the debounce strategy.
117
+ *
118
+ * @param key - Explicit identity key.
119
+ * @param task - Work to execute once calls stop arriving.
120
+ * @param waitMs - Quiet window duration in milliseconds.
121
+ * @param options - Scheduling options.
122
+ * @param dispatchFn - Callback invoked when the debounce window expires to dispatch the task to the queue.
123
+ * @returns Shared promise that resolves/rejects with the final execution outcome.
124
+ */
125
+ schedule(key, task, waitMs, options, dispatchFn) {
126
+ const existing = this.entries.get(key);
127
+ if (existing) {
128
+ clearTimeout(existing.timerId);
129
+ if (existing.options?.signal && existing.abortListener) {
130
+ existing.options.signal.removeEventListener("abort", existing.abortListener);
131
+ }
132
+ existing.task = task;
133
+ existing.options = options;
134
+ if (options?.signal?.aborted) {
135
+ this.entries.delete(key);
136
+ const err = new AhkoCancellationError(
137
+ typeof options.signal.reason === "string" ? options.signal.reason : "Debounced task was cancelled prior to execution",
138
+ { cause: options.signal.reason instanceof Error ? options.signal.reason : void 0 }
139
+ );
140
+ existing.reject(err);
141
+ return existing.promise;
142
+ }
143
+ if (options?.signal) {
144
+ const listener = () => {
145
+ this.cancel(key, options.signal?.reason);
146
+ };
147
+ existing.abortListener = listener;
148
+ options.signal.addEventListener("abort", listener, { once: true });
149
+ }
150
+ existing.timerId = setTimeout(() => {
151
+ void this.flush(key, dispatchFn);
152
+ }, waitMs);
153
+ return existing.promise;
154
+ }
155
+ let resolvePromise;
156
+ let rejectPromise;
157
+ const promise = new Promise((resolve, reject) => {
158
+ resolvePromise = resolve;
159
+ rejectPromise = reject;
160
+ });
161
+ if (options?.signal?.aborted) {
162
+ const err = new AhkoCancellationError(
163
+ typeof options.signal.reason === "string" ? options.signal.reason : "Debounced task was cancelled prior to execution",
164
+ { cause: options.signal.reason instanceof Error ? options.signal.reason : void 0 }
165
+ );
166
+ rejectPromise(err);
167
+ return promise;
168
+ }
169
+ let abortListener;
170
+ if (options?.signal) {
171
+ abortListener = () => {
172
+ this.cancel(key, options.signal?.reason);
173
+ };
174
+ options.signal.addEventListener("abort", abortListener, { once: true });
175
+ }
176
+ const timerId = setTimeout(() => {
177
+ void this.flush(key, dispatchFn);
178
+ }, waitMs);
179
+ const entry = {
180
+ key,
181
+ task,
182
+ options,
183
+ timerId,
184
+ resolve: resolvePromise,
185
+ reject: rejectPromise,
186
+ promise,
187
+ abortListener
188
+ };
189
+ this.entries.set(key, entry);
190
+ return promise;
191
+ }
192
+ /**
193
+ * Dispatches the coalesced task when the quiet window expires.
194
+ */
195
+ async flush(key, dispatchFn) {
196
+ const entry = this.entries.get(key);
197
+ if (!entry) {
198
+ return;
199
+ }
200
+ this.entries.delete(key);
201
+ if (entry.options?.signal && entry.abortListener) {
202
+ entry.options.signal.removeEventListener("abort", entry.abortListener);
203
+ }
204
+ try {
205
+ const result = await dispatchFn(entry.task, entry.options);
206
+ entry.resolve(result);
207
+ } catch (error) {
208
+ entry.reject(error);
209
+ }
210
+ }
211
+ /**
212
+ * Cancels a pending debounced task by key.
213
+ *
214
+ * @param key - Identity key to cancel.
215
+ * @param reason - Optional cancellation reason.
216
+ */
217
+ cancel(key, reason) {
218
+ const entry = this.entries.get(key);
219
+ if (!entry) {
220
+ return;
221
+ }
222
+ clearTimeout(entry.timerId);
223
+ this.entries.delete(key);
224
+ if (entry.options?.signal && entry.abortListener) {
225
+ entry.options.signal.removeEventListener("abort", entry.abortListener);
226
+ }
227
+ const cancelError = new AhkoCancellationError(
228
+ typeof reason === "string" ? reason : "Debounced task was cancelled prior to execution",
229
+ { cause: reason instanceof Error ? reason : void 0 }
230
+ );
231
+ entry.reject(cancelError);
232
+ }
233
+ /**
234
+ * Number of pending debounced tasks waiting for quiet window expiry.
235
+ */
236
+ get size() {
237
+ return this.entries.size;
238
+ }
239
+ /**
240
+ * Cancels all pending debounced entries and clears the map.
241
+ */
242
+ clear() {
243
+ for (const entry of this.entries.values()) {
244
+ clearTimeout(entry.timerId);
245
+ if (entry.options?.signal && entry.abortListener) {
246
+ entry.options.signal.removeEventListener("abort", entry.abortListener);
247
+ }
248
+ entry.reject(new AhkoCancellationError("Debounced tasks cleared"));
249
+ }
250
+ this.entries.clear();
251
+ }
252
+ };
253
+
95
254
  // src/scheduler/idle-scheduler.ts
96
255
  var IdleScheduler = class {
97
256
  /**
@@ -131,10 +290,218 @@ var IdleScheduler = class {
131
290
  }
132
291
  };
133
292
 
293
+ // src/scheduler/throttle-coordinator.ts
294
+ var ThrottleCoordinator = class {
295
+ entries = /* @__PURE__ */ new Map();
296
+ /**
297
+ * Schedules a task under the throttle strategy.
298
+ *
299
+ * @param key - Explicit identity key.
300
+ * @param task - Work to execute.
301
+ * @param waitMs - Throttle interval duration in milliseconds.
302
+ * @param options - Scheduling options.
303
+ * @param dispatchFn - Callback invoked to dispatch task execution into the queue.
304
+ * @returns Promise resolving with the leading execution or coalesced trailing result.
305
+ */
306
+ schedule(key, task, waitMs, options, dispatchFn) {
307
+ const existing = this.entries.get(key);
308
+ if (!existing) {
309
+ const entry = {
310
+ key
311
+ };
312
+ entry.windowTimerId = setTimeout(() => {
313
+ void this.onWindowExpire(key, waitMs, dispatchFn);
314
+ }, waitMs);
315
+ this.entries.set(key, entry);
316
+ return dispatchFn(task, options);
317
+ }
318
+ existing.trailingTask = task;
319
+ existing.trailingOptions = options;
320
+ if (existing.trailingPromise) {
321
+ return existing.trailingPromise;
322
+ }
323
+ let resolvePromise;
324
+ let rejectPromise;
325
+ existing.trailingPromise = new Promise((resolve, reject) => {
326
+ resolvePromise = resolve;
327
+ rejectPromise = reject;
328
+ });
329
+ existing.trailingResolve = resolvePromise;
330
+ existing.trailingReject = rejectPromise;
331
+ if (options?.signal) {
332
+ const listener = () => {
333
+ if (existing.trailingReject) {
334
+ existing.trailingReject(
335
+ new AhkoCancellationError("Throttled trailing task was cancelled", {
336
+ cause: options.signal?.reason instanceof Error ? options.signal.reason : void 0
337
+ })
338
+ );
339
+ existing.trailingTask = void 0;
340
+ existing.trailingOptions = void 0;
341
+ existing.trailingPromise = void 0;
342
+ existing.trailingResolve = void 0;
343
+ existing.trailingReject = void 0;
344
+ }
345
+ };
346
+ existing.abortListener = listener;
347
+ options.signal.addEventListener("abort", listener, { once: true });
348
+ }
349
+ return existing.trailingPromise;
350
+ }
351
+ /**
352
+ * Invoked when the throttle interval window timer expires.
353
+ */
354
+ async onWindowExpire(key, waitMs, dispatchFn) {
355
+ const entry = this.entries.get(key);
356
+ if (!entry) {
357
+ return;
358
+ }
359
+ if (entry.trailingTask) {
360
+ const task = entry.trailingTask;
361
+ const options = entry.trailingOptions;
362
+ const resolve = entry.trailingResolve;
363
+ const reject = entry.trailingReject;
364
+ entry.trailingTask = void 0;
365
+ entry.trailingOptions = void 0;
366
+ entry.trailingPromise = void 0;
367
+ entry.trailingResolve = void 0;
368
+ entry.trailingReject = void 0;
369
+ entry.windowTimerId = setTimeout(() => {
370
+ void this.onWindowExpire(key, waitMs, dispatchFn);
371
+ }, waitMs);
372
+ try {
373
+ const result = await dispatchFn(task, options);
374
+ resolve?.(result);
375
+ } catch (error) {
376
+ reject?.(error);
377
+ }
378
+ return;
379
+ }
380
+ this.entries.delete(key);
381
+ }
382
+ /**
383
+ * Cancels any pending trailing throttled task for a given key.
384
+ *
385
+ * @param key - Identity key to cancel.
386
+ * @param reason - Optional cancellation reason.
387
+ */
388
+ cancel(key, reason) {
389
+ const entry = this.entries.get(key);
390
+ if (!entry) {
391
+ return;
392
+ }
393
+ if (entry.windowTimerId !== void 0) {
394
+ clearTimeout(entry.windowTimerId);
395
+ }
396
+ this.entries.delete(key);
397
+ if (entry.trailingReject) {
398
+ const cancelError = new AhkoCancellationError(
399
+ typeof reason === "string" ? reason : "Throttled task was cancelled",
400
+ { cause: reason instanceof Error ? reason : void 0 }
401
+ );
402
+ entry.trailingReject(cancelError);
403
+ }
404
+ }
405
+ /**
406
+ * Number of keys currently actively throttled.
407
+ */
408
+ get size() {
409
+ return this.entries.size;
410
+ }
411
+ /**
412
+ * Clears all throttled entries and timers.
413
+ */
414
+ clear() {
415
+ for (const entry of this.entries.values()) {
416
+ if (entry.windowTimerId !== void 0) {
417
+ clearTimeout(entry.windowTimerId);
418
+ }
419
+ if (entry.trailingReject) {
420
+ entry.trailingReject(new AhkoCancellationError("Throttled tasks cleared"));
421
+ }
422
+ }
423
+ this.entries.clear();
424
+ }
425
+ };
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
+
134
495
  // src/scheduler/task-queue.ts
135
496
  var TaskQueue = class {
136
497
  /** Maximum concurrent active tasks */
137
498
  concurrency;
499
+ /** Minimum interval in milliseconds between consecutive task starts */
500
+ minIntervalMs;
501
+ /** Timestamp of the most recent task start */
502
+ lastTaskStartTime = 0;
503
+ /** Active rate limit timer for pacing consecutive tasks */
504
+ rateLimitTimer;
138
505
  /** Queue of pending task runners waiting for a concurrency slot */
139
506
  queue = [];
140
507
  /** Set of task runners currently executing */
@@ -145,6 +512,14 @@ var TaskQueue = class {
145
512
  idleEntries = /* @__PURE__ */ new Set();
146
513
  /** Set of tasks currently awaiting a retry backoff timer */
147
514
  retryEntries = /* @__PURE__ */ new Set();
515
+ /** Coordinator for debounced tasks with key coalescing */
516
+ debounceCoordinator = new DebounceCoordinator();
517
+ /** Coordinator for throttled tasks with leading/trailing coalescing */
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();
148
523
  /** WeakMap associating task runners with their scheduling options */
149
524
  runnerOptions = /* @__PURE__ */ new WeakMap();
150
525
  /** Cumulative completed tasks counter */
@@ -155,19 +530,30 @@ var TaskQueue = class {
155
530
  cancelledTasks = 0;
156
531
  /** Cumulative timed out tasks counter */
157
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;
158
537
  /**
159
538
  * Creates a new TaskQueue.
160
539
  *
161
540
  * @param concurrency - Maximum concurrent tasks (defaults to Infinity).
162
- * @throws {AhkoConfigurationError} If concurrency is less than 1 or not a valid number.
541
+ * @param minIntervalMs - Minimum interval in milliseconds between task dispatches.
542
+ * @throws {AhkoConfigurationError} If concurrency is less than 1 or minIntervalMs is invalid.
163
543
  */
164
- constructor(concurrency = Infinity) {
544
+ constructor(concurrency = Infinity, minIntervalMs = 0) {
165
545
  if (Number.isNaN(concurrency) || concurrency < 1) {
166
546
  throw new AhkoConfigurationError(
167
547
  `Invalid concurrency "${concurrency}". Must be a number greater than or equal to 1.`
168
548
  );
169
549
  }
550
+ if (typeof minIntervalMs !== "number" || Number.isNaN(minIntervalMs) || !Number.isFinite(minIntervalMs) || minIntervalMs < 0) {
551
+ throw new AhkoConfigurationError(
552
+ `Invalid minIntervalMs "${minIntervalMs}". minIntervalMs must be a non-negative finite number.`
553
+ );
554
+ }
170
555
  this.concurrency = concurrency;
556
+ this.minIntervalMs = minIntervalMs;
171
557
  }
172
558
  /**
173
559
  * Enqueues a task runner according to the specified schedule options.
@@ -180,11 +566,24 @@ var TaskQueue = class {
180
566
  */
181
567
  enqueue(runner, options) {
182
568
  const strategy = options?.strategy ?? "immediate" /* IMMEDIATE */;
183
- if (strategy !== "immediate" /* IMMEDIATE */ && strategy !== "delay" /* DELAY */ && strategy !== "idle" /* IDLE */) {
569
+ if (strategy !== "immediate" /* IMMEDIATE */ && strategy !== "delay" /* DELAY */ && strategy !== "idle" /* IDLE */ && strategy !== "throttle" /* THROTTLE */ && strategy !== "debounce" /* DEBOUNCE */) {
184
570
  throw new AhkoConfigurationError(
185
- `Unsupported schedule strategy "${String(strategy)}". Supported strategies: "immediate", "delay", "idle".`
571
+ `Unsupported schedule strategy "${String(strategy)}". Supported strategies: "immediate", "delay", "idle", "throttle", "debounce".`
186
572
  );
187
573
  }
574
+ if (strategy === "throttle" /* THROTTLE */ || strategy === "debounce" /* DEBOUNCE */) {
575
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
576
+ throw new AhkoConfigurationError(
577
+ `Strategy "${strategy}" requires a valid "key" of type string or symbol.`
578
+ );
579
+ }
580
+ const waitMs = options.waitMs ?? options.delay;
581
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
582
+ throw new AhkoConfigurationError(
583
+ `Strategy "${strategy}" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
584
+ );
585
+ }
586
+ }
188
587
  if (options?.retry) {
189
588
  if (typeof options.retry.attempts !== "number" || Number.isNaN(options.retry.attempts) || options.retry.attempts < 1 || !Number.isInteger(options.retry.attempts)) {
190
589
  throw new AhkoConfigurationError(
@@ -240,6 +639,11 @@ var TaskQueue = class {
240
639
  if (index !== -1) {
241
640
  this.queue.splice(index, 1);
242
641
  this.cancelledTasks++;
642
+ this.emitter.emit("task:cancel", {
643
+ taskId: runner.taskId,
644
+ reason: "Task cancelled while queued"
645
+ });
646
+ this.checkIdle();
243
647
  }
244
648
  };
245
649
  this.queue.push(runner);
@@ -263,6 +667,11 @@ var TaskQueue = class {
263
667
  if (index !== -1) {
264
668
  this.queue.splice(index, 1);
265
669
  this.cancelledTasks++;
670
+ this.emitter.emit("task:cancel", {
671
+ taskId: runner.taskId,
672
+ reason: "Task cancelled while queued"
673
+ });
674
+ this.checkIdle();
266
675
  }
267
676
  };
268
677
  this.queue.push(runner);
@@ -275,6 +684,11 @@ var TaskQueue = class {
275
684
  clearTimeout(delayedEntry.timerId);
276
685
  this.delayedEntries.delete(delayedEntry);
277
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();
278
692
  }
279
693
  };
280
694
  }
@@ -294,6 +708,11 @@ var TaskQueue = class {
294
708
  if (index !== -1) {
295
709
  this.queue.splice(index, 1);
296
710
  this.cancelledTasks++;
711
+ this.emitter.emit("task:cancel", {
712
+ taskId: runner.taskId,
713
+ reason: "Task cancelled while queued"
714
+ });
715
+ this.checkIdle();
297
716
  }
298
717
  };
299
718
  this.queue.push(runner);
@@ -306,15 +725,51 @@ var TaskQueue = class {
306
725
  handle.cancel();
307
726
  this.idleEntries.delete(idleEntry);
308
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();
309
733
  }
310
734
  };
311
735
  }
312
736
  /**
313
737
  * Pumps the queue by picking pending tasks and executing them
314
- * as long as concurrency capacity is available.
738
+ * as long as concurrency capacity is available and minIntervalMs is respected.
315
739
  */
316
740
  pump() {
741
+ if (this.queue.length === 0 || this.activeRunners.size >= this.concurrency) {
742
+ return;
743
+ }
744
+ if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
745
+ const now = Date.now();
746
+ const elapsed = now - this.lastTaskStartTime;
747
+ if (elapsed < this.minIntervalMs) {
748
+ if (this.rateLimitTimer === void 0) {
749
+ const delay = this.minIntervalMs - elapsed;
750
+ this.rateLimitTimer = setTimeout(() => {
751
+ this.rateLimitTimer = void 0;
752
+ this.pump();
753
+ }, delay);
754
+ }
755
+ return;
756
+ }
757
+ }
317
758
  while (this.activeRunners.size < this.concurrency && this.queue.length > 0) {
759
+ if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
760
+ const now = Date.now();
761
+ const elapsed = now - this.lastTaskStartTime;
762
+ if (elapsed < this.minIntervalMs) {
763
+ if (this.rateLimitTimer === void 0) {
764
+ const delay = this.minIntervalMs - elapsed;
765
+ this.rateLimitTimer = setTimeout(() => {
766
+ this.rateLimitTimer = void 0;
767
+ this.pump();
768
+ }, delay);
769
+ }
770
+ break;
771
+ }
772
+ }
318
773
  const runner = this.queue.shift();
319
774
  if (!runner) {
320
775
  break;
@@ -323,7 +778,19 @@ var TaskQueue = class {
323
778
  continue;
324
779
  }
325
780
  this.activeRunners.add(runner);
781
+ this.lastTaskStartTime = Date.now();
326
782
  void this.executeRunner(runner);
783
+ if (this.minIntervalMs > 0) {
784
+ if (this.queue.length > 0 && this.activeRunners.size < this.concurrency) {
785
+ if (this.rateLimitTimer === void 0) {
786
+ this.rateLimitTimer = setTimeout(() => {
787
+ this.rateLimitTimer = void 0;
788
+ this.pump();
789
+ }, this.minIntervalMs);
790
+ }
791
+ }
792
+ break;
793
+ }
327
794
  }
328
795
  }
329
796
  /**
@@ -331,36 +798,69 @@ var TaskQueue = class {
331
798
  */
332
799
  async executeRunner(runner) {
333
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
+ });
334
806
  try {
335
807
  const result = await runner.run();
336
808
  this.completedTasks++;
337
809
  this.activeRunners.delete(runner);
338
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
+ });
339
817
  runner.resolve(result);
340
818
  } catch (error) {
341
819
  if (runner.state === "cancelled" /* CANCELLED */) {
342
820
  this.cancelledTasks++;
343
821
  this.activeRunners.delete(runner);
344
822
  this.runnerOptions.delete(runner);
823
+ this.emitter.emit("task:cancel", {
824
+ taskId: runner.taskId,
825
+ reason: error
826
+ });
345
827
  runner.reject(error);
346
828
  return;
347
829
  }
348
830
  const shouldRetry = await runner.canRetry(error, options?.retry);
349
831
  if (shouldRetry) {
832
+ this.retriedTasks++;
350
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
+ });
351
840
  this.scheduleRetry(runner, options);
352
841
  return;
353
842
  }
354
843
  if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
355
844
  this.timedOutTasks++;
845
+ this.emitter.emit("task:timeout", {
846
+ taskId: runner.taskId,
847
+ timeoutMs: runner.timeoutMs
848
+ });
356
849
  } else {
357
850
  this.failedTasks++;
358
851
  }
359
852
  this.activeRunners.delete(runner);
360
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
+ });
361
860
  runner.reject(error);
362
861
  } finally {
363
862
  this.pump();
863
+ this.checkIdle();
364
864
  }
365
865
  }
366
866
  /**
@@ -375,6 +875,11 @@ var TaskQueue = class {
375
875
  if (index !== -1) {
376
876
  this.queue.splice(index, 1);
377
877
  this.cancelledTasks++;
878
+ this.emitter.emit("task:cancel", {
879
+ taskId: runner.taskId,
880
+ reason: "Task cancelled while queued"
881
+ });
882
+ this.checkIdle();
378
883
  }
379
884
  };
380
885
  this.queue.push(runner);
@@ -393,6 +898,11 @@ var TaskQueue = class {
393
898
  if (index !== -1) {
394
899
  this.queue.splice(index, 1);
395
900
  this.cancelledTasks++;
901
+ this.emitter.emit("task:cancel", {
902
+ taskId: runner.taskId,
903
+ reason: "Task cancelled while queued"
904
+ });
905
+ this.checkIdle();
396
906
  }
397
907
  };
398
908
  this.queue.push(runner);
@@ -405,9 +915,91 @@ var TaskQueue = class {
405
915
  clearTimeout(retryEntry.timerId);
406
916
  this.retryEntries.delete(retryEntry);
407
917
  this.cancelledTasks++;
918
+ this.emitter.emit("task:cancel", {
919
+ taskId: runner.taskId,
920
+ reason: "Task cancelled during retry backoff"
921
+ });
922
+ this.checkIdle();
408
923
  }
409
924
  };
410
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
+ }
411
1003
  /**
412
1004
  * Returns telemetry snapshot for the scheduler.
413
1005
  *
@@ -416,31 +1008,18 @@ var TaskQueue = class {
416
1008
  getStats() {
417
1009
  return Object.freeze({
418
1010
  activeTasks: this.activeRunners.size,
419
- pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size + this.retryEntries.size,
1011
+ pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size + this.retryEntries.size + this.debounceCoordinator.size + this.throttleCoordinator.size,
420
1012
  completedTasks: this.completedTasks,
421
1013
  failedTasks: this.failedTasks,
422
1014
  cancelledTasks: this.cancelledTasks,
423
1015
  timedOutTasks: this.timedOutTasks,
1016
+ retriedTasks: this.retriedTasks,
1017
+ totalDispatched: this.totalDispatched,
424
1018
  capacity: this.concurrency
425
1019
  });
426
1020
  }
427
1021
  };
428
1022
 
429
- // src/errors/cancellation.error.ts
430
- var AhkoCancellationError = class extends AhkoError {
431
- /**
432
- * Creates a new AhkoCancellationError.
433
- *
434
- * @param message - Reason for cancellation.
435
- * @param options - Standard Error options including cause.
436
- */
437
- constructor(message = "Task was cancelled", options) {
438
- super(message, options);
439
- this.name = "AhkoCancellationError";
440
- Object.setPrototypeOf(this, new.target.prototype);
441
- }
442
- };
443
-
444
1023
  // src/scheduler/task-runner.ts
445
1024
  var taskIdCounter = 0;
446
1025
  var TaskRunner = class {
@@ -470,6 +1049,8 @@ var TaskRunner = class {
470
1049
  onCancel;
471
1050
  /** Current execution attempt count (1-indexed) */
472
1051
  attempt = 1;
1052
+ /** Duration of the most recent execution attempt in milliseconds */
1053
+ lastDurationMs = 0;
473
1054
  /**
474
1055
  * Creates a new TaskRunner instance.
475
1056
  *
@@ -628,9 +1209,11 @@ var TaskRunner = class {
628
1209
  if (timeoutPromise) {
629
1210
  racePromises.push(timeoutPromise);
630
1211
  }
1212
+ const startTime = Date.now();
631
1213
  try {
632
1214
  const result = await Promise.race(racePromises);
633
1215
  this.clearTimeoutTimer();
1216
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
634
1217
  if (abortListener) {
635
1218
  this.abortController.signal.removeEventListener("abort", abortListener);
636
1219
  }
@@ -647,6 +1230,7 @@ var TaskRunner = class {
647
1230
  return result;
648
1231
  } catch (error) {
649
1232
  this.clearTimeoutTimer();
1233
+ this.lastDurationMs = Math.max(0, Date.now() - startTime);
650
1234
  if (abortListener) {
651
1235
  this.abortController.signal.removeEventListener("abort", abortListener);
652
1236
  }
@@ -742,7 +1326,7 @@ var Ahko = class {
742
1326
  * ```
743
1327
  */
744
1328
  constructor(options) {
745
- this.queue = new TaskQueue(options?.concurrency);
1329
+ this.queue = new TaskQueue(options?.concurrency, options?.minIntervalMs);
746
1330
  }
747
1331
  /**
748
1332
  * Schedules a task for execution with full return type inference.
@@ -754,6 +1338,7 @@ var Ahko = class {
754
1338
  *
755
1339
  * @throws {AhkoConfigurationError} If the task is not a function or options are invalid.
756
1340
  * @throws {AhkoCancellationError} If the task is cancelled prior to or during execution.
1341
+ * @throws {AhkoTimeoutError} If task execution exceeds timeoutMs.
757
1342
  *
758
1343
  * @example
759
1344
  * ```typescript
@@ -771,6 +1356,47 @@ var Ahko = class {
771
1356
  if (typeof task !== "function") {
772
1357
  throw new AhkoConfigurationError("Task must be a valid function.");
773
1358
  }
1359
+ const strategy = options?.strategy ?? "immediate" /* IMMEDIATE */;
1360
+ if (strategy === "debounce" /* DEBOUNCE */) {
1361
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
1362
+ throw new AhkoConfigurationError(
1363
+ `Strategy "debounce" requires a valid "key" of type string or symbol.`
1364
+ );
1365
+ }
1366
+ const waitMs = options.waitMs ?? options.delay;
1367
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
1368
+ throw new AhkoConfigurationError(
1369
+ `Strategy "debounce" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
1370
+ );
1371
+ }
1372
+ return this.queue.debounceCoordinator.schedule(
1373
+ options.key,
1374
+ task,
1375
+ waitMs,
1376
+ options,
1377
+ (t, opts) => this.schedule(t, { ...opts, strategy: "immediate" /* IMMEDIATE */ })
1378
+ );
1379
+ }
1380
+ if (strategy === "throttle" /* THROTTLE */) {
1381
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
1382
+ throw new AhkoConfigurationError(
1383
+ `Strategy "throttle" requires a valid "key" of type string or symbol.`
1384
+ );
1385
+ }
1386
+ const waitMs = options.waitMs ?? options.delay;
1387
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
1388
+ throw new AhkoConfigurationError(
1389
+ `Strategy "throttle" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
1390
+ );
1391
+ }
1392
+ return this.queue.throttleCoordinator.schedule(
1393
+ options.key,
1394
+ task,
1395
+ waitMs,
1396
+ options,
1397
+ (t, opts) => this.schedule(t, { ...opts, strategy: "immediate" /* IMMEDIATE */ })
1398
+ );
1399
+ }
774
1400
  const runner = new TaskRunner(task, options?.signal, options?.timeoutMs);
775
1401
  return this.queue.enqueue(runner, options);
776
1402
  }
@@ -789,13 +1415,6 @@ var Ahko = class {
789
1415
  *
790
1416
  * @throws {AhkoConfigurationError} If the task is not a function or options are invalid.
791
1417
  * @throws {AhkoCancellationError} If the task is cancelled prior to or during execution.
792
- *
793
- * @example
794
- * ```typescript
795
- * const result = await ahko.idle(async ({ signal }) => {
796
- * return computeAnalytics();
797
- * });
798
- * ```
799
1418
  */
800
1419
  idle(task, options) {
801
1420
  return this.schedule(task, {
@@ -803,6 +1422,42 @@ var Ahko = class {
803
1422
  strategy: "idle" /* IDLE */
804
1423
  });
805
1424
  }
1425
+ /**
1426
+ * Convenience method to schedule a debounced task with key-based Promise coalescing.
1427
+ *
1428
+ * @template T - Inferred return type of the task.
1429
+ * @param key - Explicit identity key.
1430
+ * @param task - Work to execute once calls stop arriving.
1431
+ * @param waitMs - Quiet window duration in milliseconds.
1432
+ * @param options - Additional schedule options.
1433
+ * @returns Shared promise resolving with the final execution outcome.
1434
+ */
1435
+ debounce(key, task, waitMs, options) {
1436
+ return this.schedule(task, {
1437
+ ...options,
1438
+ strategy: "debounce" /* DEBOUNCE */,
1439
+ key,
1440
+ waitMs
1441
+ });
1442
+ }
1443
+ /**
1444
+ * Convenience method to schedule a throttled task with leading execution and coalesced trailing run.
1445
+ *
1446
+ * @template T - Inferred return type of the task.
1447
+ * @param key - Explicit identity key.
1448
+ * @param task - Work to execute.
1449
+ * @param waitMs - Throttle interval duration in milliseconds.
1450
+ * @param options - Additional schedule options.
1451
+ * @returns Promise resolving with the leading or coalesced trailing result.
1452
+ */
1453
+ throttle(key, task, waitMs, options) {
1454
+ return this.schedule(task, {
1455
+ ...options,
1456
+ strategy: "throttle" /* THROTTLE */,
1457
+ key,
1458
+ waitMs
1459
+ });
1460
+ }
806
1461
  /**
807
1462
  * Retrieves real-time telemetry metrics from the scheduler.
808
1463
  *
@@ -817,10 +1472,87 @@ var Ahko = class {
817
1472
  stats() {
818
1473
  return this.queue.getStats();
819
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
+ }
820
1552
  };
821
1553
 
822
1554
  // src/version.ts
823
- var VERSION = "0.4.0";
1555
+ var VERSION = "0.6.0";
824
1556
 
825
1557
  // src/errors/queue.error.ts
826
1558
  var AhkoQueueError = class extends AhkoError {