@mrjacket/ahko 0.3.0 → 0.5.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,9 +33,31 @@ 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
 
41
+ // src/errors/timeout.error.ts
42
+ var AhkoTimeoutError = class extends AhkoError {
43
+ /**
44
+ * The timeout threshold in milliseconds that was exceeded, if configured.
45
+ */
46
+ timeoutMs;
47
+ /**
48
+ * Creates a new AhkoTimeoutError.
49
+ *
50
+ * @param message - Explanation of timeout expiry.
51
+ * @param options - Standard Error options including optional timeoutMs and cause.
52
+ */
53
+ constructor(message = "Task execution timed out", options) {
54
+ super(message, options);
55
+ this.name = "AhkoTimeoutError";
56
+ this.timeoutMs = options?.timeoutMs;
57
+ Object.setPrototypeOf(this, new.target.prototype);
58
+ }
59
+ };
60
+
39
61
  // src/models/state.model.ts
40
62
  var ETaskState = /* @__PURE__ */ ((ETaskState2) => {
41
63
  ETaskState2["PENDING"] = "pending";
@@ -72,6 +94,163 @@ function calculateBackoff(attempt, options, randomFn = Math.random) {
72
94
  return Math.floor(cappedDelay);
73
95
  }
74
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 [key, entry] of this.entries) {
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
+
75
254
  // src/scheduler/idle-scheduler.ts
76
255
  var IdleScheduler = class {
77
256
  /**
@@ -111,10 +290,150 @@ var IdleScheduler = class {
111
290
  }
112
291
  };
113
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 [key, entry] of this.entries) {
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
+
114
427
  // src/scheduler/task-queue.ts
115
428
  var TaskQueue = class {
116
429
  /** Maximum concurrent active tasks */
117
430
  concurrency;
431
+ /** Minimum interval in milliseconds between consecutive task starts */
432
+ minIntervalMs;
433
+ /** Timestamp of the most recent task start */
434
+ lastTaskStartTime = 0;
435
+ /** Active rate limit timer for pacing consecutive tasks */
436
+ rateLimitTimer;
118
437
  /** Queue of pending task runners waiting for a concurrency slot */
119
438
  queue = [];
120
439
  /** Set of task runners currently executing */
@@ -125,6 +444,10 @@ var TaskQueue = class {
125
444
  idleEntries = /* @__PURE__ */ new Set();
126
445
  /** Set of tasks currently awaiting a retry backoff timer */
127
446
  retryEntries = /* @__PURE__ */ new Set();
447
+ /** Coordinator for debounced tasks with key coalescing */
448
+ debounceCoordinator = new DebounceCoordinator();
449
+ /** Coordinator for throttled tasks with leading/trailing coalescing */
450
+ throttleCoordinator = new ThrottleCoordinator();
128
451
  /** WeakMap associating task runners with their scheduling options */
129
452
  runnerOptions = /* @__PURE__ */ new WeakMap();
130
453
  /** Cumulative completed tasks counter */
@@ -139,15 +462,22 @@ var TaskQueue = class {
139
462
  * Creates a new TaskQueue.
140
463
  *
141
464
  * @param concurrency - Maximum concurrent tasks (defaults to Infinity).
142
- * @throws {AhkoConfigurationError} If concurrency is less than 1 or not a valid number.
465
+ * @param minIntervalMs - Minimum interval in milliseconds between task dispatches.
466
+ * @throws {AhkoConfigurationError} If concurrency is less than 1 or minIntervalMs is invalid.
143
467
  */
144
- constructor(concurrency = Infinity) {
468
+ constructor(concurrency = Infinity, minIntervalMs = 0) {
145
469
  if (Number.isNaN(concurrency) || concurrency < 1) {
146
470
  throw new AhkoConfigurationError(
147
471
  `Invalid concurrency "${concurrency}". Must be a number greater than or equal to 1.`
148
472
  );
149
473
  }
474
+ if (typeof minIntervalMs !== "number" || Number.isNaN(minIntervalMs) || !Number.isFinite(minIntervalMs) || minIntervalMs < 0) {
475
+ throw new AhkoConfigurationError(
476
+ `Invalid minIntervalMs "${minIntervalMs}". minIntervalMs must be a non-negative finite number.`
477
+ );
478
+ }
150
479
  this.concurrency = concurrency;
480
+ this.minIntervalMs = minIntervalMs;
151
481
  }
152
482
  /**
153
483
  * Enqueues a task runner according to the specified schedule options.
@@ -160,11 +490,24 @@ var TaskQueue = class {
160
490
  */
161
491
  enqueue(runner, options) {
162
492
  const strategy = options?.strategy ?? "immediate" /* IMMEDIATE */;
163
- if (strategy !== "immediate" /* IMMEDIATE */ && strategy !== "delay" /* DELAY */ && strategy !== "idle" /* IDLE */) {
493
+ if (strategy !== "immediate" /* IMMEDIATE */ && strategy !== "delay" /* DELAY */ && strategy !== "idle" /* IDLE */ && strategy !== "throttle" /* THROTTLE */ && strategy !== "debounce" /* DEBOUNCE */) {
164
494
  throw new AhkoConfigurationError(
165
- `Unsupported schedule strategy "${String(strategy)}". Supported strategies: "immediate", "delay", "idle".`
495
+ `Unsupported schedule strategy "${String(strategy)}". Supported strategies: "immediate", "delay", "idle", "throttle", "debounce".`
166
496
  );
167
497
  }
498
+ if (strategy === "throttle" /* THROTTLE */ || strategy === "debounce" /* DEBOUNCE */) {
499
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
500
+ throw new AhkoConfigurationError(
501
+ `Strategy "${strategy}" requires a valid "key" of type string or symbol.`
502
+ );
503
+ }
504
+ const waitMs = options.waitMs ?? options.delay;
505
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
506
+ throw new AhkoConfigurationError(
507
+ `Strategy "${strategy}" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
508
+ );
509
+ }
510
+ }
168
511
  if (options?.retry) {
169
512
  if (typeof options.retry.attempts !== "number" || Number.isNaN(options.retry.attempts) || options.retry.attempts < 1 || !Number.isInteger(options.retry.attempts)) {
170
513
  throw new AhkoConfigurationError(
@@ -182,6 +525,13 @@ var TaskQueue = class {
182
525
  );
183
526
  }
184
527
  }
528
+ if (options?.timeoutMs !== void 0) {
529
+ if (typeof options.timeoutMs !== "number" || Number.isNaN(options.timeoutMs) || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
530
+ throw new AhkoConfigurationError(
531
+ `Invalid timeoutMs "${options.timeoutMs}". timeoutMs must be a positive finite number greater than 0.`
532
+ );
533
+ }
534
+ }
185
535
  if (options) {
186
536
  this.runnerOptions.set(runner, options);
187
537
  }
@@ -284,10 +634,41 @@ var TaskQueue = class {
284
634
  }
285
635
  /**
286
636
  * Pumps the queue by picking pending tasks and executing them
287
- * as long as concurrency capacity is available.
637
+ * as long as concurrency capacity is available and minIntervalMs is respected.
288
638
  */
289
639
  pump() {
640
+ if (this.queue.length === 0 || this.activeRunners.size >= this.concurrency) {
641
+ return;
642
+ }
643
+ if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
644
+ const now = Date.now();
645
+ const elapsed = now - this.lastTaskStartTime;
646
+ if (elapsed < this.minIntervalMs) {
647
+ if (this.rateLimitTimer === void 0) {
648
+ const delay = this.minIntervalMs - elapsed;
649
+ this.rateLimitTimer = setTimeout(() => {
650
+ this.rateLimitTimer = void 0;
651
+ this.pump();
652
+ }, delay);
653
+ }
654
+ return;
655
+ }
656
+ }
290
657
  while (this.activeRunners.size < this.concurrency && this.queue.length > 0) {
658
+ if (this.minIntervalMs > 0 && this.lastTaskStartTime > 0) {
659
+ const now = Date.now();
660
+ const elapsed = now - this.lastTaskStartTime;
661
+ if (elapsed < this.minIntervalMs) {
662
+ if (this.rateLimitTimer === void 0) {
663
+ const delay = this.minIntervalMs - elapsed;
664
+ this.rateLimitTimer = setTimeout(() => {
665
+ this.rateLimitTimer = void 0;
666
+ this.pump();
667
+ }, delay);
668
+ }
669
+ break;
670
+ }
671
+ }
291
672
  const runner = this.queue.shift();
292
673
  if (!runner) {
293
674
  break;
@@ -296,7 +677,19 @@ var TaskQueue = class {
296
677
  continue;
297
678
  }
298
679
  this.activeRunners.add(runner);
680
+ this.lastTaskStartTime = Date.now();
299
681
  void this.executeRunner(runner);
682
+ if (this.minIntervalMs > 0) {
683
+ if (this.queue.length > 0 && this.activeRunners.size < this.concurrency) {
684
+ if (this.rateLimitTimer === void 0) {
685
+ this.rateLimitTimer = setTimeout(() => {
686
+ this.rateLimitTimer = void 0;
687
+ this.pump();
688
+ }, this.minIntervalMs);
689
+ }
690
+ }
691
+ break;
692
+ }
300
693
  }
301
694
  }
302
695
  /**
@@ -324,7 +717,7 @@ var TaskQueue = class {
324
717
  this.scheduleRetry(runner, options);
325
718
  return;
326
719
  }
327
- if (runner.state === "timed_out" /* TIMED_OUT */) {
720
+ if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
328
721
  this.timedOutTasks++;
329
722
  } else {
330
723
  this.failedTasks++;
@@ -389,7 +782,7 @@ var TaskQueue = class {
389
782
  getStats() {
390
783
  return Object.freeze({
391
784
  activeTasks: this.activeRunners.size,
392
- pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size + this.retryEntries.size,
785
+ pendingTasks: this.queue.length + this.delayedEntries.size + this.idleEntries.size + this.retryEntries.size + this.debounceCoordinator.size + this.throttleCoordinator.size,
393
786
  completedTasks: this.completedTasks,
394
787
  failedTasks: this.failedTasks,
395
788
  cancelledTasks: this.cancelledTasks,
@@ -399,21 +792,6 @@ var TaskQueue = class {
399
792
  }
400
793
  };
401
794
 
402
- // src/errors/cancellation.error.ts
403
- var AhkoCancellationError = class extends AhkoError {
404
- /**
405
- * Creates a new AhkoCancellationError.
406
- *
407
- * @param message - Reason for cancellation.
408
- * @param options - Standard Error options including cause.
409
- */
410
- constructor(message = "Task was cancelled", options) {
411
- super(message, options);
412
- this.name = "AhkoCancellationError";
413
- Object.setPrototypeOf(this, new.target.prototype);
414
- }
415
- };
416
-
417
795
  // src/scheduler/task-runner.ts
418
796
  var taskIdCounter = 0;
419
797
  var TaskRunner = class {
@@ -427,6 +805,10 @@ var TaskRunner = class {
427
805
  task;
428
806
  /** User-supplied AbortSignal for external cancellation */
429
807
  externalSignal;
808
+ /** Maximum execution duration allowed in milliseconds */
809
+ timeoutMs;
810
+ /** Active timeout timer identifier */
811
+ timeoutTimerId;
430
812
  /** Abort event listener reference for clean detachment */
431
813
  abortListener;
432
814
  /** Promise resolve handler */
@@ -437,16 +819,20 @@ var TaskRunner = class {
437
819
  promise;
438
820
  /** Callback invoked when runner is cancelled while pending */
439
821
  onCancel;
822
+ /** Current execution attempt count (1-indexed) */
823
+ attempt = 1;
440
824
  /**
441
825
  * Creates a new TaskRunner instance.
442
826
  *
443
827
  * @param task - The asynchronous work unit to run.
444
828
  * @param externalSignal - Optional external AbortSignal to propagate.
829
+ * @param timeoutMs - Optional maximum execution time in milliseconds.
445
830
  */
446
- constructor(task, externalSignal) {
831
+ constructor(task, externalSignal, timeoutMs) {
447
832
  this.taskId = `task_${Date.now().toString(36)}_${(++taskIdCounter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
448
833
  this.task = task;
449
834
  this.externalSignal = externalSignal;
835
+ this.timeoutMs = timeoutMs;
450
836
  this.abortController = new AbortController();
451
837
  this.promise = new Promise((resolve, reject) => {
452
838
  this.resolvePromise = resolve;
@@ -460,6 +846,7 @@ var TaskRunner = class {
460
846
  typeof reason === "string" ? reason : "Task was cancelled prior to execution",
461
847
  { cause: reason instanceof Error ? reason : void 0 }
462
848
  );
849
+ this.abortController.abort(cancelError);
463
850
  this.rejectPromise(cancelError);
464
851
  } else {
465
852
  this.abortListener = () => {
@@ -475,8 +862,6 @@ var TaskRunner = class {
475
862
  get state() {
476
863
  return this._state;
477
864
  }
478
- /** Current execution attempt count (1-indexed) */
479
- attempt = 1;
480
865
  /**
481
866
  * Resolves the deferred promise.
482
867
  *
@@ -496,14 +881,14 @@ var TaskRunner = class {
496
881
  this.rejectPromise(reason);
497
882
  }
498
883
  /**
499
- * Evaluates if the task should be retried following an execution failure.
884
+ * Evaluates if the task should be retried following an execution failure or timeout.
500
885
  *
501
886
  * @param error - The error encountered during the attempt.
502
887
  * @param retryOptions - Configured retry policy.
503
888
  * @returns A promise resolving to true if retry should proceed, false otherwise.
504
889
  */
505
890
  async canRetry(error, retryOptions) {
506
- if (this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted) {
891
+ if (this._state === "cancelled" /* CANCELLED */ || (this.externalSignal?.aborted ?? false)) {
507
892
  return false;
508
893
  }
509
894
  if (!retryOptions || typeof retryOptions.attempts !== "number") {
@@ -524,12 +909,13 @@ var TaskRunner = class {
524
909
  }
525
910
  this.attempt++;
526
911
  this._state = "pending" /* PENDING */;
912
+ this.abortController = new AbortController();
527
913
  return true;
528
914
  }
529
915
  /**
530
916
  * Executes the task within an allocated concurrency slot.
531
917
  *
532
- * @returns A promise resolving to the task result or rejecting on failure/cancellation.
918
+ * @returns A promise resolving to the task result or rejecting on failure/cancellation/timeout.
533
919
  */
534
920
  async run() {
535
921
  if (this._state === "cancelled" /* CANCELLED */) {
@@ -540,14 +926,100 @@ var TaskRunner = class {
540
926
  signal: this.abortController.signal,
541
927
  taskId: this.taskId
542
928
  };
929
+ let abortListener;
930
+ const abortPromise = new Promise((_, reject) => {
931
+ abortListener = () => {
932
+ if (this._state === "timed_out" /* TIMED_OUT */) {
933
+ reject(
934
+ new AhkoTimeoutError(
935
+ `Task execution timed out after ${this.timeoutMs}ms`,
936
+ { timeoutMs: this.timeoutMs }
937
+ )
938
+ );
939
+ } else {
940
+ const reason = this.abortController.signal.reason;
941
+ reject(
942
+ new AhkoCancellationError("Task was cancelled during execution", {
943
+ cause: reason instanceof Error ? reason : void 0
944
+ })
945
+ );
946
+ }
947
+ };
948
+ this.abortController.signal.addEventListener("abort", abortListener, { once: true });
949
+ });
950
+ let timeoutPromise;
951
+ if (this.timeoutMs !== void 0) {
952
+ timeoutPromise = new Promise((_, reject) => {
953
+ this.timeoutTimerId = setTimeout(() => {
954
+ if (this._state !== "running" /* RUNNING */) {
955
+ return;
956
+ }
957
+ this._state = "timed_out" /* TIMED_OUT */;
958
+ const timeoutError = new AhkoTimeoutError(
959
+ `Task execution timed out after ${this.timeoutMs}ms`,
960
+ { timeoutMs: this.timeoutMs }
961
+ );
962
+ this.abortController.abort(timeoutError);
963
+ reject(timeoutError);
964
+ }, this.timeoutMs);
965
+ });
966
+ }
967
+ let taskExecutionPromise;
968
+ try {
969
+ taskExecutionPromise = Promise.resolve(this.task(context));
970
+ } catch (syncError) {
971
+ taskExecutionPromise = Promise.reject(syncError);
972
+ }
973
+ taskExecutionPromise.catch(() => {
974
+ });
975
+ const racePromises = [
976
+ taskExecutionPromise,
977
+ abortPromise
978
+ ];
979
+ if (timeoutPromise) {
980
+ racePromises.push(timeoutPromise);
981
+ }
543
982
  try {
544
- const result = await this.task(context);
983
+ const result = await Promise.race(racePromises);
984
+ this.clearTimeoutTimer();
985
+ if (abortListener) {
986
+ this.abortController.signal.removeEventListener("abort", abortListener);
987
+ }
988
+ if (this._state === "timed_out" /* TIMED_OUT */) {
989
+ throw new AhkoTimeoutError(
990
+ `Task execution timed out after ${this.timeoutMs}ms`,
991
+ { timeoutMs: this.timeoutMs }
992
+ );
993
+ }
994
+ if (this._state === "cancelled" /* CANCELLED */) {
995
+ throw new AhkoCancellationError("Task was cancelled during execution");
996
+ }
545
997
  this._state = "completed" /* COMPLETED */;
546
998
  return result;
547
999
  } catch (error) {
548
- const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted;
1000
+ this.clearTimeoutTimer();
1001
+ if (abortListener) {
1002
+ this.abortController.signal.removeEventListener("abort", abortListener);
1003
+ }
1004
+ if (this._state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
1005
+ this._state = "timed_out" /* TIMED_OUT */;
1006
+ if (error instanceof AhkoTimeoutError) {
1007
+ throw error;
1008
+ }
1009
+ throw new AhkoTimeoutError(
1010
+ `Task execution timed out after ${this.timeoutMs}ms`,
1011
+ {
1012
+ timeoutMs: this.timeoutMs,
1013
+ cause: error instanceof Error ? error : void 0
1014
+ }
1015
+ );
1016
+ }
1017
+ const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted || (this.externalSignal?.aborted ?? false);
549
1018
  if (isCancelled) {
550
1019
  this._state = "cancelled" /* CANCELLED */;
1020
+ if (error instanceof AhkoCancellationError) {
1021
+ throw error;
1022
+ }
551
1023
  throw new AhkoCancellationError("Task was cancelled during execution", {
552
1024
  cause: error instanceof Error ? error : void 0
553
1025
  });
@@ -556,6 +1028,15 @@ var TaskRunner = class {
556
1028
  throw error;
557
1029
  }
558
1030
  }
1031
+ /**
1032
+ * Clears the active timeout timer.
1033
+ */
1034
+ clearTimeoutTimer() {
1035
+ if (this.timeoutTimerId !== void 0) {
1036
+ clearTimeout(this.timeoutTimerId);
1037
+ this.timeoutTimerId = void 0;
1038
+ }
1039
+ }
559
1040
  /**
560
1041
  * Cancels the task, aborting pending or running execution.
561
1042
  *
@@ -567,6 +1048,7 @@ var TaskRunner = class {
567
1048
  }
568
1049
  const wasPending = this._state === "pending" /* PENDING */;
569
1050
  this._state = "cancelled" /* CANCELLED */;
1051
+ this.clearTimeoutTimer();
570
1052
  this.abortController.abort(reason);
571
1053
  this.cleanup();
572
1054
  if (wasPending) {
@@ -588,6 +1070,7 @@ var TaskRunner = class {
588
1070
  * Detaches event listeners from external signal to guarantee memory safety.
589
1071
  */
590
1072
  cleanup() {
1073
+ this.clearTimeoutTimer();
591
1074
  if (this.externalSignal && this.abortListener) {
592
1075
  this.externalSignal.removeEventListener("abort", this.abortListener);
593
1076
  }
@@ -610,7 +1093,7 @@ var Ahko = class {
610
1093
  * ```
611
1094
  */
612
1095
  constructor(options) {
613
- this.queue = new TaskQueue(options?.concurrency);
1096
+ this.queue = new TaskQueue(options?.concurrency, options?.minIntervalMs);
614
1097
  }
615
1098
  /**
616
1099
  * Schedules a task for execution with full return type inference.
@@ -622,6 +1105,7 @@ var Ahko = class {
622
1105
  *
623
1106
  * @throws {AhkoConfigurationError} If the task is not a function or options are invalid.
624
1107
  * @throws {AhkoCancellationError} If the task is cancelled prior to or during execution.
1108
+ * @throws {AhkoTimeoutError} If task execution exceeds timeoutMs.
625
1109
  *
626
1110
  * @example
627
1111
  * ```typescript
@@ -639,7 +1123,48 @@ var Ahko = class {
639
1123
  if (typeof task !== "function") {
640
1124
  throw new AhkoConfigurationError("Task must be a valid function.");
641
1125
  }
642
- const runner = new TaskRunner(task, options?.signal);
1126
+ const strategy = options?.strategy ?? "immediate" /* IMMEDIATE */;
1127
+ if (strategy === "debounce" /* DEBOUNCE */) {
1128
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
1129
+ throw new AhkoConfigurationError(
1130
+ `Strategy "debounce" requires a valid "key" of type string or symbol.`
1131
+ );
1132
+ }
1133
+ const waitMs = options.waitMs ?? options.delay;
1134
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
1135
+ throw new AhkoConfigurationError(
1136
+ `Strategy "debounce" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
1137
+ );
1138
+ }
1139
+ return this.queue.debounceCoordinator.schedule(
1140
+ options.key,
1141
+ task,
1142
+ waitMs,
1143
+ options,
1144
+ (t, opts) => this.schedule(t, { ...opts, strategy: "immediate" /* IMMEDIATE */ })
1145
+ );
1146
+ }
1147
+ if (strategy === "throttle" /* THROTTLE */) {
1148
+ if (!options?.key || typeof options.key !== "string" && typeof options.key !== "symbol") {
1149
+ throw new AhkoConfigurationError(
1150
+ `Strategy "throttle" requires a valid "key" of type string or symbol.`
1151
+ );
1152
+ }
1153
+ const waitMs = options.waitMs ?? options.delay;
1154
+ if (typeof waitMs !== "number" || Number.isNaN(waitMs) || !Number.isFinite(waitMs) || waitMs < 0) {
1155
+ throw new AhkoConfigurationError(
1156
+ `Strategy "throttle" requires a non-negative finite "waitMs" or "delay" in milliseconds.`
1157
+ );
1158
+ }
1159
+ return this.queue.throttleCoordinator.schedule(
1160
+ options.key,
1161
+ task,
1162
+ waitMs,
1163
+ options,
1164
+ (t, opts) => this.schedule(t, { ...opts, strategy: "immediate" /* IMMEDIATE */ })
1165
+ );
1166
+ }
1167
+ const runner = new TaskRunner(task, options?.signal, options?.timeoutMs);
643
1168
  return this.queue.enqueue(runner, options);
644
1169
  }
645
1170
  /**
@@ -657,13 +1182,6 @@ var Ahko = class {
657
1182
  *
658
1183
  * @throws {AhkoConfigurationError} If the task is not a function or options are invalid.
659
1184
  * @throws {AhkoCancellationError} If the task is cancelled prior to or during execution.
660
- *
661
- * @example
662
- * ```typescript
663
- * const result = await ahko.idle(async ({ signal }) => {
664
- * return computeAnalytics();
665
- * });
666
- * ```
667
1185
  */
668
1186
  idle(task, options) {
669
1187
  return this.schedule(task, {
@@ -671,6 +1189,42 @@ var Ahko = class {
671
1189
  strategy: "idle" /* IDLE */
672
1190
  });
673
1191
  }
1192
+ /**
1193
+ * Convenience method to schedule a debounced task with key-based Promise coalescing.
1194
+ *
1195
+ * @template T - Inferred return type of the task.
1196
+ * @param key - Explicit identity key.
1197
+ * @param task - Work to execute once calls stop arriving.
1198
+ * @param waitMs - Quiet window duration in milliseconds.
1199
+ * @param options - Additional schedule options.
1200
+ * @returns Shared promise resolving with the final execution outcome.
1201
+ */
1202
+ debounce(key, task, waitMs, options) {
1203
+ return this.schedule(task, {
1204
+ ...options,
1205
+ strategy: "debounce" /* DEBOUNCE */,
1206
+ key,
1207
+ waitMs
1208
+ });
1209
+ }
1210
+ /**
1211
+ * Convenience method to schedule a throttled task with leading execution and coalesced trailing run.
1212
+ *
1213
+ * @template T - Inferred return type of the task.
1214
+ * @param key - Explicit identity key.
1215
+ * @param task - Work to execute.
1216
+ * @param waitMs - Throttle interval duration in milliseconds.
1217
+ * @param options - Additional schedule options.
1218
+ * @returns Promise resolving with the leading or coalesced trailing result.
1219
+ */
1220
+ throttle(key, task, waitMs, options) {
1221
+ return this.schedule(task, {
1222
+ ...options,
1223
+ strategy: "throttle" /* THROTTLE */,
1224
+ key,
1225
+ waitMs
1226
+ });
1227
+ }
674
1228
  /**
675
1229
  * Retrieves real-time telemetry metrics from the scheduler.
676
1230
  *
@@ -688,7 +1242,7 @@ var Ahko = class {
688
1242
  };
689
1243
 
690
1244
  // src/version.ts
691
- var VERSION = "0.3.0";
1245
+ var VERSION = "0.5.0";
692
1246
 
693
1247
  // src/errors/queue.error.ts
694
1248
  var AhkoQueueError = class extends AhkoError {
@@ -705,20 +1259,60 @@ var AhkoQueueError = class extends AhkoError {
705
1259
  }
706
1260
  };
707
1261
 
708
- // src/errors/timeout.error.ts
709
- var AhkoTimeoutError = class extends AhkoError {
710
- /**
711
- * Creates a new AhkoTimeoutError.
712
- *
713
- * @param message - Explanation of timeout expiry.
714
- * @param options - Standard Error options including cause.
715
- */
716
- constructor(message = "Task execution timed out", options) {
717
- super(message, options);
718
- this.name = "AhkoTimeoutError";
719
- Object.setPrototypeOf(this, new.target.prototype);
1262
+ // src/scheduler/signal.ts
1263
+ function combineSignals(signals) {
1264
+ const activeSignals = signals.filter(
1265
+ (signal) => signal !== void 0
1266
+ );
1267
+ if (activeSignals.length === 0) {
1268
+ const controller2 = new AbortController();
1269
+ return {
1270
+ signal: controller2.signal,
1271
+ cleanup: () => {
1272
+ }
1273
+ };
720
1274
  }
721
- };
1275
+ const alreadyAborted = activeSignals.find((s) => s.aborted);
1276
+ if (alreadyAborted) {
1277
+ const controller2 = new AbortController();
1278
+ controller2.abort(alreadyAborted.reason);
1279
+ return {
1280
+ signal: controller2.signal,
1281
+ cleanup: () => {
1282
+ }
1283
+ };
1284
+ }
1285
+ if (activeSignals.length === 1) {
1286
+ return {
1287
+ signal: activeSignals[0],
1288
+ cleanup: () => {
1289
+ }
1290
+ };
1291
+ }
1292
+ const controller = new AbortController();
1293
+ const cleanupFns = [];
1294
+ const onAbort = (event) => {
1295
+ const target = event.target;
1296
+ cleanup();
1297
+ controller.abort(target.reason);
1298
+ };
1299
+ for (const sig of activeSignals) {
1300
+ sig.addEventListener("abort", onAbort, { once: true });
1301
+ cleanupFns.push(() => {
1302
+ sig.removeEventListener("abort", onAbort);
1303
+ });
1304
+ }
1305
+ const cleanup = () => {
1306
+ for (const fn of cleanupFns) {
1307
+ fn();
1308
+ }
1309
+ cleanupFns.length = 0;
1310
+ };
1311
+ return {
1312
+ signal: controller.signal,
1313
+ cleanup
1314
+ };
1315
+ }
722
1316
  export {
723
1317
  Ahko,
724
1318
  AhkoCancellationError,
@@ -731,6 +1325,7 @@ export {
731
1325
  EScheduleStrategy,
732
1326
  ETaskState,
733
1327
  VERSION,
734
- calculateBackoff
1328
+ calculateBackoff,
1329
+ combineSignals
735
1330
  };
736
1331
  //# sourceMappingURL=index.js.map