@gravito/horizon 3.0.1 → 3.2.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
@@ -3,7 +3,18 @@ import "./chunk-MCKGQKYU.js";
3
3
  // src/SimpleCronParser.ts
4
4
  var SimpleCronParser = class {
5
5
  /**
6
- * Check if a cron expression is due at the given date/time
6
+ * Evaluates if a cron expression matches the specified date and timezone.
7
+ *
8
+ * @param expression - Standard 5-part cron expression.
9
+ * @param timezone - Target timezone for comparison (default: "UTC").
10
+ * @param date - Reference date to check (default: now).
11
+ * @returns True if the expression is due at the given minute.
12
+ * @throws {Error} If the expression is malformed or the timezone is invalid.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const isDue = SimpleCronParser.isDue('0 * * * *', 'Asia/Taipei');
17
+ * ```
7
18
  */
8
19
  static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
9
20
  const parts = expression.trim().split(/\s+/);
@@ -18,8 +29,22 @@ var SimpleCronParser = class {
18
29
  const dayOfWeek = targetDate.getDay();
19
30
  return this.match(parts[0], minutes, 0, 59) && this.match(parts[1], hours, 0, 23) && this.match(parts[2], dayOfMonth, 1, 31) && this.match(parts[3], month, 1, 12) && this.match(parts[4], dayOfWeek, 0, 6, true);
20
31
  }
32
+ /**
33
+ * Internal pattern matching logic for individual cron fields.
34
+ *
35
+ * @param pattern - Cron sub-expression (e.g., "1-5").
36
+ * @param value - Extracted date component value.
37
+ * @param _min - Field minimum boundary.
38
+ * @param _max - Field maximum boundary.
39
+ * @param isDayOfWeek - Special handling for Sunday (0 and 7).
40
+ * @returns Boolean indicating a match.
41
+ *
42
+ * @internal
43
+ */
21
44
  static match(pattern, value, _min, _max, isDayOfWeek = false) {
22
- if (pattern === "*") return true;
45
+ if (pattern === "*") {
46
+ return true;
47
+ }
23
48
  if (pattern.includes(",")) {
24
49
  return pattern.split(",").some((p) => this.match(p, value, _min, _max, isDayOfWeek));
25
50
  }
@@ -27,7 +52,9 @@ var SimpleCronParser = class {
27
52
  if (stepMatch) {
28
53
  const range = stepMatch[1];
29
54
  const step = parseInt(stepMatch[3], 10);
30
- if (range === "*") return value % step === 0;
55
+ if (range === "*") {
56
+ return value % step === 0;
57
+ }
31
58
  const [rMin, rMax] = range.split("-").map((n) => parseInt(n, 10));
32
59
  return value >= rMin && value <= rMax && (value - rMin) % step === 0;
33
60
  }
@@ -36,13 +63,27 @@ var SimpleCronParser = class {
36
63
  return value >= rMin && value <= rMax;
37
64
  }
38
65
  const patternVal = parseInt(pattern, 10);
39
- if (isDayOfWeek && patternVal === 7 && value === 0) return true;
66
+ if (isDayOfWeek && patternVal === 7 && value === 0) {
67
+ return true;
68
+ }
40
69
  return patternVal === value;
41
70
  }
71
+ /**
72
+ * Resolves a Date object to the specified timezone.
73
+ *
74
+ * @param date - Source UTC date.
75
+ * @param timezone - Target timezone string.
76
+ * @returns Localized Date object.
77
+ * @throws {Error} If the timezone is invalid.
78
+ *
79
+ * @internal
80
+ */
42
81
  static getDateInTimezone(date, timezone) {
43
82
  try {
44
83
  const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
45
- if (isNaN(tzDate.getTime())) throw new Error();
84
+ if (Number.isNaN(tzDate.getTime())) {
85
+ throw new Error();
86
+ }
46
87
  return tzDate;
47
88
  } catch {
48
89
  throw new Error(`Invalid timezone: ${timezone}`);
@@ -52,8 +93,26 @@ var SimpleCronParser = class {
52
93
 
53
94
  // src/CronParser.ts
54
95
  var CronParser = class {
96
+ static cache = /* @__PURE__ */ new Map();
97
+ /** Cache duration in milliseconds (1 minute). */
98
+ static CACHE_TTL = 6e4;
99
+ /** Maximum number of unique expression/timezone combinations to store. */
100
+ static MAX_CACHE_SIZE = 500;
55
101
  /**
56
- * Get the next execution date based on a cron expression.
102
+ * Calculates the next occurrence of a cron expression.
103
+ *
104
+ * Dynamically loads `cron-parser` to minimize initial bundle size and memory footprint.
105
+ *
106
+ * @param expression - Valid 5-part cron expression.
107
+ * @param timezone - Target timezone for evaluation (default: "UTC").
108
+ * @param currentDate - Reference time to calculate from (default: now).
109
+ * @returns Resolves to the next execution Date object.
110
+ * @throws {Error} If the expression format is invalid.
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const next = await CronParser.nextDate('0 0 * * *');
115
+ * ```
57
116
  */
58
117
  static async nextDate(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
59
118
  try {
@@ -68,9 +127,47 @@ var CronParser = class {
68
127
  }
69
128
  }
70
129
  /**
71
- * Check if the cron expression is due to run at the current time (minute precision).
130
+ * Determines if a task is due for execution at the specified time.
131
+ *
132
+ * Uses minute-precision caching to optimize repeated evaluations within
133
+ * the same scheduling window. Implements LRU eviction to maintain memory efficiency.
134
+ *
135
+ * @param expression - Cron expression to evaluate.
136
+ * @param timezone - Execution timezone.
137
+ * @param currentDate - Reference time for the check.
138
+ * @returns True if the expression matches the reference time.
72
139
  */
73
140
  static async isDue(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
141
+ const minuteKey = `${expression}:${timezone}:${Math.floor(currentDate.getTime() / 6e4)}`;
142
+ const now = Date.now();
143
+ const cached = this.cache.get(minuteKey);
144
+ if (cached) {
145
+ if (now - cached.timestamp < this.CACHE_TTL) {
146
+ this.cache.delete(minuteKey);
147
+ this.cache.set(minuteKey, cached);
148
+ return cached.result;
149
+ }
150
+ this.cache.delete(minuteKey);
151
+ }
152
+ const result = await this.computeIsDue(expression, timezone, currentDate);
153
+ this.cache.set(minuteKey, {
154
+ result,
155
+ timestamp: now
156
+ });
157
+ this.cleanupCache();
158
+ return result;
159
+ }
160
+ /**
161
+ * Internal logic for tiered cron evaluation.
162
+ *
163
+ * @param expression - Cron expression.
164
+ * @param timezone - Target timezone.
165
+ * @param currentDate - Current time.
166
+ * @returns Boolean indicating if the task is due.
167
+ *
168
+ * @internal
169
+ */
170
+ static async computeIsDue(expression, timezone, currentDate) {
74
171
  try {
75
172
  return SimpleCronParser.isDue(expression, timezone, currentDate);
76
173
  } catch (_e) {
@@ -88,6 +185,33 @@ var CronParser = class {
88
185
  return false;
89
186
  }
90
187
  }
188
+ /**
189
+ * Evicts the oldest entry from the cache when capacity is reached.
190
+ *
191
+ * @internal
192
+ */
193
+ static cleanupCache() {
194
+ if (this.cache.size <= this.MAX_CACHE_SIZE) {
195
+ return;
196
+ }
197
+ const iterator = this.cache.keys();
198
+ const oldestKey = iterator.next().value;
199
+ if (oldestKey) {
200
+ this.cache.delete(oldestKey);
201
+ }
202
+ }
203
+ /**
204
+ * Purges all entries from the internal cache.
205
+ * Useful for testing or when global timezone settings change.
206
+ */
207
+ static clearCache() {
208
+ this.cache.clear();
209
+ }
210
+ /**
211
+ * Compares two dates with minute precision.
212
+ *
213
+ * @internal
214
+ */
91
215
  static minuteMatches(date1, date2) {
92
216
  return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() && date1.getHours() === date2.getHours() && date1.getMinutes() === date2.getMinutes();
93
217
  }
@@ -95,22 +219,55 @@ var CronParser = class {
95
219
 
96
220
  // src/locks/CacheLockStore.ts
97
221
  var CacheLockStore = class {
222
+ /**
223
+ * Initializes the store with a cache manager.
224
+ *
225
+ * @param cache - The Stasis cache instance.
226
+ * @param prefix - Key prefix to avoid collisions in the shared namespace.
227
+ */
98
228
  constructor(cache, prefix = "scheduler:lock:") {
99
229
  this.cache = cache;
100
230
  this.prefix = prefix;
101
231
  }
232
+ /**
233
+ * Computes the fully qualified cache key.
234
+ *
235
+ * @internal
236
+ */
102
237
  getKey(key) {
103
238
  return this.prefix + key;
104
239
  }
240
+ /**
241
+ * Atomic 'add' operation ensures only one node succeeds.
242
+ *
243
+ * @param key - Lock key.
244
+ * @param ttlSeconds - Expiration.
245
+ */
105
246
  async acquire(key, ttlSeconds) {
106
247
  return this.cache.add(this.getKey(key), "LOCKED", ttlSeconds);
107
248
  }
249
+ /**
250
+ * Removes the lock key from cache.
251
+ *
252
+ * @param key - Lock key.
253
+ */
108
254
  async release(key) {
109
255
  await this.cache.forget(this.getKey(key));
110
256
  }
257
+ /**
258
+ * Overwrites the lock key, effectively resetting the TTL.
259
+ *
260
+ * @param key - Lock key.
261
+ * @param ttlSeconds - New expiration.
262
+ */
111
263
  async forceAcquire(key, ttlSeconds) {
112
264
  await this.cache.put(this.getKey(key), "LOCKED", ttlSeconds);
113
265
  }
266
+ /**
267
+ * Validates if the lock key is present in the cache.
268
+ *
269
+ * @param key - Lock key.
270
+ */
114
271
  async exists(key) {
115
272
  return this.cache.has(this.getKey(key));
116
273
  }
@@ -118,7 +275,14 @@ var CacheLockStore = class {
118
275
 
119
276
  // src/locks/MemoryLockStore.ts
120
277
  var MemoryLockStore = class {
278
+ /** Map of lock keys to their expiration timestamps (ms). */
121
279
  locks = /* @__PURE__ */ new Map();
280
+ /**
281
+ * Acquires a local lock if the key is not already active.
282
+ *
283
+ * @param key - Lock identifier.
284
+ * @param ttlSeconds - Expiration duration.
285
+ */
122
286
  async acquire(key, ttlSeconds) {
123
287
  const NOW = Date.now();
124
288
  const expiresAt = this.locks.get(key);
@@ -128,12 +292,30 @@ var MemoryLockStore = class {
128
292
  this.locks.set(key, NOW + ttlSeconds * 1e3);
129
293
  return true;
130
294
  }
295
+ /**
296
+ * Deletes the lock from local memory.
297
+ *
298
+ * @param key - Lock identifier.
299
+ */
131
300
  async release(key) {
132
301
  this.locks.delete(key);
133
302
  }
303
+ /**
304
+ * Sets or overwrites a local lock.
305
+ *
306
+ * @param key - Lock identifier.
307
+ * @param ttlSeconds - Expiration.
308
+ */
134
309
  async forceAcquire(key, ttlSeconds) {
135
310
  this.locks.set(key, Date.now() + ttlSeconds * 1e3);
136
311
  }
312
+ /**
313
+ * Checks if a local lock is present and hasn't expired.
314
+ *
315
+ * Automatically purges expired locks upon checking.
316
+ *
317
+ * @param key - Lock identifier.
318
+ */
137
319
  async exists(key) {
138
320
  const expiresAt = this.locks.get(key);
139
321
  if (!expiresAt) {
@@ -150,6 +332,13 @@ var MemoryLockStore = class {
150
332
  // src/locks/LockManager.ts
151
333
  var LockManager = class {
152
334
  store;
335
+ /**
336
+ * Initializes the manager with a specific storage driver.
337
+ *
338
+ * @param driver - Strategy identifier or a custom `LockStore` implementation.
339
+ * @param context - Dependencies required by certain drivers (e.g., CacheManager).
340
+ * @throws {Error} If 'cache' driver is selected but no `CacheManager` is provided.
341
+ */
153
342
  constructor(driver, context) {
154
343
  if (typeof driver === "object") {
155
344
  this.store = driver;
@@ -164,15 +353,43 @@ var LockManager = class {
164
353
  this.store = new MemoryLockStore();
165
354
  }
166
355
  }
356
+ /**
357
+ * Attempts to acquire a lock. Fails if the key is already locked.
358
+ *
359
+ * @param key - Unique identifier for the lock.
360
+ * @param ttlSeconds - Time-to-live in seconds before the lock expires.
361
+ * @returns True if the lock was successfully acquired.
362
+ */
167
363
  async acquire(key, ttlSeconds) {
168
364
  return this.store.acquire(key, ttlSeconds);
169
365
  }
366
+ /**
367
+ * Explicitly releases a held lock.
368
+ *
369
+ * @param key - The lock identifier to remove.
370
+ * @returns Resolves when the lock is deleted.
371
+ */
170
372
  async release(key) {
171
373
  return this.store.release(key);
172
374
  }
375
+ /**
376
+ * Forcibly acquires or overwrites an existing lock.
377
+ *
378
+ * Used for execution locks where the latest attempt should take precedence
379
+ * if the previous one is deemed expired.
380
+ *
381
+ * @param key - Lock identifier.
382
+ * @param ttlSeconds - Expiration duration.
383
+ */
173
384
  async forceAcquire(key, ttlSeconds) {
174
385
  return this.store.forceAcquire(key, ttlSeconds);
175
386
  }
387
+ /**
388
+ * Checks if a specific lock currently exists and is not expired.
389
+ *
390
+ * @param key - Lock identifier.
391
+ * @returns True if the key is locked and active.
392
+ */
176
393
  async exists(key) {
177
394
  return this.store.exists(key);
178
395
  }
@@ -199,19 +416,55 @@ async function runProcess(command) {
199
416
  };
200
417
  }
201
418
  var Process = class {
419
+ /**
420
+ * Static alias for `runProcess`.
421
+ *
422
+ * @param command - Command to execute.
423
+ * @returns Process outcome.
424
+ */
202
425
  static async run(command) {
203
426
  return runProcess(command);
204
427
  }
205
428
  };
206
429
 
430
+ // src/utils/validation.ts
431
+ function parseTime(time) {
432
+ const timePattern = /^([0-2]\d):([0-5]\d)$/;
433
+ const match = time.match(timePattern);
434
+ if (!match) {
435
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
436
+ }
437
+ const hour = Number.parseInt(match[1], 10);
438
+ const minute = Number.parseInt(match[2], 10);
439
+ if (hour > 23) {
440
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
441
+ }
442
+ return { hour, minute };
443
+ }
444
+ function validateMinute(minute) {
445
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
446
+ throw new Error(`Invalid minute: ${minute}. Expected integer 0-59`);
447
+ }
448
+ }
449
+ function validateDayOfWeek(dayOfWeek) {
450
+ if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
451
+ throw new Error(`Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`);
452
+ }
453
+ }
454
+ function validateDayOfMonth(dayOfMonth) {
455
+ if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
456
+ throw new Error(`Invalid day of month: ${dayOfMonth}. Expected 1-31`);
457
+ }
458
+ }
459
+
207
460
  // src/TaskSchedule.ts
208
461
  var TaskSchedule = class {
209
462
  task;
210
463
  /**
211
- * Create a new TaskSchedule instance.
464
+ * Initializes a new schedule instance with default settings.
212
465
  *
213
- * @param name - The unique name of the task.
214
- * @param callback - The function to execute.
466
+ * @param name - Unique task name used for identification and locking.
467
+ * @param callback - Logic to execute on schedule.
215
468
  */
216
469
  constructor(name, callback) {
217
470
  this.task = {
@@ -225,168 +478,217 @@ var TaskSchedule = class {
225
478
  // 5 minutes default
226
479
  background: false,
227
480
  // Wait for task to finish by default
481
+ preventOverlapping: false,
482
+ overlappingExpiresAt: 3600,
483
+ // 1 hour default
228
484
  onSuccessCallbacks: [],
229
485
  onFailureCallbacks: []
230
486
  };
231
487
  }
232
488
  // --- Frequency Methods ---
233
489
  /**
234
- * Set a custom cron expression.
490
+ * Configures a raw 5-part cron expression.
491
+ *
492
+ * @param expression - Cron string (e.g., "0 0 * * *").
493
+ * @returns The TaskSchedule instance for chaining.
494
+ * @throws {Error} If expression format is invalid or contains forbidden characters.
235
495
  *
236
- * @param expression - Standard cron expression (e.g., "* * * * *")
237
- * @returns The TaskSchedule instance.
496
+ * @example
497
+ * ```typescript
498
+ * schedule.cron('0 9-17 * * 1-5'); // 9-5 on weekdays
499
+ * ```
238
500
  */
239
501
  cron(expression) {
502
+ const parts = expression.trim().split(/\s+/);
503
+ if (parts.length !== 5) {
504
+ throw new Error(
505
+ `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
506
+ );
507
+ }
508
+ const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
509
+ for (let i = 0; i < 5; i++) {
510
+ if (!pattern.test(parts[i])) {
511
+ throw new Error(
512
+ `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
513
+ );
514
+ }
515
+ }
240
516
  this.task.expression = expression;
241
517
  return this;
242
518
  }
243
519
  /**
244
- * Run the task every minute.
520
+ * Schedules execution every minute.
245
521
  *
246
- * @returns The TaskSchedule instance.
522
+ * @returns The TaskSchedule instance for chaining.
247
523
  */
248
524
  everyMinute() {
249
525
  return this.cron("* * * * *");
250
526
  }
251
527
  /**
252
- * Run the task every 5 minutes.
528
+ * Schedules execution every five minutes.
253
529
  *
254
- * @returns The TaskSchedule instance.
530
+ * @returns The TaskSchedule instance for chaining.
255
531
  */
256
532
  everyFiveMinutes() {
257
533
  return this.cron("*/5 * * * *");
258
534
  }
259
535
  /**
260
- * Run the task every 10 minutes.
536
+ * Schedules execution every ten minutes.
261
537
  *
262
- * @returns The TaskSchedule instance.
538
+ * @returns The TaskSchedule instance for chaining.
263
539
  */
264
540
  everyTenMinutes() {
265
541
  return this.cron("*/10 * * * *");
266
542
  }
267
543
  /**
268
- * Run the task every 15 minutes.
544
+ * Schedules execution every fifteen minutes.
269
545
  *
270
- * @returns The TaskSchedule instance.
546
+ * @returns The TaskSchedule instance for chaining.
271
547
  */
272
548
  everyFifteenMinutes() {
273
549
  return this.cron("*/15 * * * *");
274
550
  }
275
551
  /**
276
- * Run the task every 30 minutes.
552
+ * Schedules execution every thirty minutes.
277
553
  *
278
- * @returns The TaskSchedule instance.
554
+ * @returns The TaskSchedule instance for chaining.
279
555
  */
280
556
  everyThirtyMinutes() {
281
557
  return this.cron("0,30 * * * *");
282
558
  }
283
559
  /**
284
- * Run the task hourly (at minute 0).
560
+ * Schedules execution hourly at the top of the hour.
285
561
  *
286
- * @returns The TaskSchedule instance.
562
+ * @returns The TaskSchedule instance for chaining.
287
563
  */
288
564
  hourly() {
289
565
  return this.cron("0 * * * *");
290
566
  }
291
567
  /**
292
- * Run the task hourly at a specific minute.
568
+ * Schedules execution hourly at a specific minute.
293
569
  *
294
- * @param minute - Minute (0-59)
295
- * @returns The TaskSchedule instance.
570
+ * @param minute - Target minute (0-59).
571
+ * @returns The TaskSchedule instance for chaining.
572
+ * @throws {Error} If minute is outside 0-59 range.
296
573
  */
297
574
  hourlyAt(minute) {
575
+ validateMinute(minute);
298
576
  return this.cron(`${minute} * * * *`);
299
577
  }
300
578
  /**
301
- * Run the task daily at midnight (00:00).
579
+ * Schedules execution daily at midnight.
302
580
  *
303
- * @returns The TaskSchedule instance.
581
+ * @returns The TaskSchedule instance for chaining.
304
582
  */
305
583
  daily() {
306
584
  return this.cron("0 0 * * *");
307
585
  }
308
586
  /**
309
- * Run the task daily at a specific time.
587
+ * Schedules execution daily at a specific time.
588
+ *
589
+ * @param time - 24-hour time string in "HH:mm" format.
590
+ * @returns The TaskSchedule instance for chaining.
591
+ * @throws {Error} If time format is invalid or values are out of range.
310
592
  *
311
- * @param time - Time in "HH:mm" format (24h)
312
- * @returns The TaskSchedule instance.
593
+ * @example
594
+ * ```typescript
595
+ * schedule.dailyAt('14:30');
596
+ * ```
313
597
  */
314
598
  dailyAt(time) {
315
- const [hour, minute] = time.split(":");
316
- return this.cron(`${Number(minute)} ${Number(hour)} * * *`);
599
+ const { hour, minute } = parseTime(time);
600
+ return this.cron(`${minute} ${hour} * * *`);
317
601
  }
318
602
  /**
319
- * Run the task weekly on Sunday at midnight.
603
+ * Schedules execution weekly on Sunday at midnight.
320
604
  *
321
- * @returns The TaskSchedule instance.
605
+ * @returns The TaskSchedule instance for chaining.
322
606
  */
323
607
  weekly() {
324
608
  return this.cron("0 0 * * 0");
325
609
  }
326
610
  /**
327
- * Run the task weekly on a specific day and time.
611
+ * Schedules execution weekly on a specific day and time.
328
612
  *
329
- * @param day - Day of week (0-7, 0 or 7 is Sunday)
330
- * @param time - Time in "HH:mm" format (default "00:00")
331
- * @returns The TaskSchedule instance.
613
+ * @param day - Day index (0-6, where 0 is Sunday).
614
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
615
+ * @returns The TaskSchedule instance for chaining.
616
+ * @throws {Error} If day index or time format is invalid.
617
+ *
618
+ * @example
619
+ * ```typescript
620
+ * schedule.weeklyOn(1, '09:00'); // Mondays at 9 AM
621
+ * ```
332
622
  */
333
623
  weeklyOn(day, time = "00:00") {
334
- const [hour, minute] = time.split(":");
335
- return this.cron(`${Number(minute)} ${Number(hour)} * * ${day}`);
624
+ validateDayOfWeek(day);
625
+ const { hour, minute } = parseTime(time);
626
+ return this.cron(`${minute} ${hour} * * ${day}`);
336
627
  }
337
628
  /**
338
- * Run the task monthly on the 1st day at midnight.
629
+ * Schedules execution monthly on the first day at midnight.
339
630
  *
340
- * @returns The TaskSchedule instance.
631
+ * @returns The TaskSchedule instance for chaining.
341
632
  */
342
633
  monthly() {
343
634
  return this.cron("0 0 1 * *");
344
635
  }
345
636
  /**
346
- * Run the task monthly on a specific day and time.
637
+ * Schedules execution monthly on a specific day and time.
347
638
  *
348
- * @param day - Day of month (1-31)
349
- * @param time - Time in "HH:mm" format (default "00:00")
350
- * @returns The TaskSchedule instance.
639
+ * @param day - Day of month (1-31).
640
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
641
+ * @returns The TaskSchedule instance for chaining.
642
+ * @throws {Error} If day or time format is invalid.
351
643
  */
352
644
  monthlyOn(day, time = "00:00") {
353
- const [hour, minute] = time.split(":");
354
- return this.cron(`${Number(minute)} ${Number(hour)} ${day} * *`);
645
+ validateDayOfMonth(day);
646
+ const { hour, minute } = parseTime(time);
647
+ return this.cron(`${minute} ${hour} ${day} * *`);
355
648
  }
356
649
  // --- Constraints ---
357
650
  /**
358
- * Set the timezone for the task execution.
651
+ * Specifies the timezone for evaluating schedule frequency.
359
652
  *
360
- * @param timezone - Timezone identifier (e.g., "Asia/Taipei", "UTC")
361
- * @returns The TaskSchedule instance.
653
+ * @param timezone - IANA timezone identifier (e.g., "America/New_York").
654
+ * @returns The TaskSchedule instance for chaining.
655
+ * @throws {Error} If the timezone identifier is not recognized by the system.
362
656
  */
363
657
  timezone(timezone) {
658
+ try {
659
+ (/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
660
+ } catch {
661
+ throw new Error(
662
+ `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
663
+ );
664
+ }
364
665
  this.task.timezone = timezone;
365
666
  return this;
366
667
  }
367
668
  /**
368
- * Set the time of execution for the current frequency.
369
- * Useful when chaining with daily(), weekly(), etc.
669
+ * Modifies the execution time of the existing frequency.
670
+ * Typically used after frequency methods like daily() or weekly().
370
671
  *
371
- * @param time - Time in "HH:mm" format
372
- * @returns The TaskSchedule instance.
672
+ * @param time - 24-hour time string in "HH:mm" format.
673
+ * @returns The TaskSchedule instance for chaining.
674
+ * @throws {Error} If time format is invalid.
373
675
  */
374
676
  at(time) {
375
- const [hour, minute] = time.split(":");
677
+ const { hour, minute } = parseTime(time);
376
678
  const parts = this.task.expression.split(" ");
377
679
  if (parts.length >= 5) {
378
- parts[0] = String(Number(minute));
379
- parts[1] = String(Number(hour));
680
+ parts[0] = String(minute);
681
+ parts[1] = String(hour);
380
682
  this.task.expression = parts.join(" ");
381
683
  }
382
684
  return this;
383
685
  }
384
686
  /**
385
- * Ensure task runs on only one server at a time (Distributed Locking).
386
- * Requires a configured LockStore (Cache or Redis).
687
+ * Enables distributed locking to ensure only one instance runs globally.
688
+ * Prevents duplicate execution when multiple servers are polling the same schedule.
387
689
  *
388
- * @param lockTtlSeconds - Time in seconds to hold the lock (default 300)
389
- * @returns The TaskSchedule instance.
690
+ * @param lockTtlSeconds - Duration in seconds to hold the window lock (default 300).
691
+ * @returns The TaskSchedule instance for chaining.
390
692
  */
391
693
  onOneServer(lockTtlSeconds = 300) {
392
694
  this.task.shouldRunOnOneServer = true;
@@ -394,41 +696,85 @@ var TaskSchedule = class {
394
696
  return this;
395
697
  }
396
698
  /**
397
- * Alias for onOneServer.
398
- * Prevents overlapping executions of the same task.
699
+ * Prevents a new task instance from starting if the previous one is still running.
700
+ *
701
+ * Unlike `onOneServer()` which uses a time-window lock, this uses an execution lock
702
+ * to handle long-running tasks that might span multiple scheduling intervals.
399
703
  *
400
- * @param expiresAt - Lock TTL in seconds
401
- * @returns The TaskSchedule instance.
704
+ * @param expiresAt - Max duration in seconds to keep the execution lock (default 3600).
705
+ * @returns The TaskSchedule instance for chaining.
706
+ *
707
+ * @example
708
+ * ```typescript
709
+ * // Prevents overlapping if the heavy operation takes > 1 minute
710
+ * scheduler.task('data-sync', heavySync)
711
+ * .everyMinute()
712
+ * .withoutOverlapping();
713
+ * ```
402
714
  */
403
- withoutOverlapping(expiresAt = 300) {
404
- return this.onOneServer(expiresAt);
715
+ withoutOverlapping(expiresAt = 3600) {
716
+ this.task.preventOverlapping = true;
717
+ this.task.overlappingExpiresAt = expiresAt;
718
+ return this;
405
719
  }
406
720
  /**
407
- * Run task in background (do not wait for completion in the loop).
408
- * Note: In Node.js non-blocking environment this is largely semantic,
409
- * but affects how we handle error catching and lock release.
721
+ * Executes the task in background mode.
722
+ * The scheduler will not wait for this task to finish before proceeding to the next.
410
723
  *
411
- * @returns The TaskSchedule instance.
724
+ * @returns The TaskSchedule instance for chaining.
412
725
  */
413
726
  runInBackground() {
414
727
  this.task.background = true;
415
728
  return this;
416
729
  }
417
730
  /**
418
- * Restrict task execution to a specific node role.
731
+ * Restricts task execution to nodes with a specific role.
419
732
  *
420
- * @param role - The required node role (e.g., 'api', 'worker')
421
- * @returns The TaskSchedule instance.
733
+ * @param role - Target role identifier (e.g., "worker").
734
+ * @returns The TaskSchedule instance for chaining.
422
735
  */
423
736
  onNode(role) {
424
737
  this.task.nodeRole = role;
425
738
  return this;
426
739
  }
427
740
  /**
428
- * Set the command string for exec tasks.
741
+ * Defines a maximum execution time for the task.
742
+ *
743
+ * @param ms - Timeout duration in milliseconds.
744
+ * @returns The TaskSchedule instance for chaining.
745
+ * @throws {Error} If timeout is not a positive number.
746
+ */
747
+ timeout(ms) {
748
+ if (ms <= 0) {
749
+ throw new Error("Timeout must be a positive number");
750
+ }
751
+ this.task.timeout = ms;
752
+ return this;
753
+ }
754
+ /**
755
+ * Configures automatic retry behavior for failed executions.
756
+ *
757
+ * @param attempts - Max number of retries (default 3).
758
+ * @param delayMs - Wait time between retries in milliseconds (default 1000).
759
+ * @returns The TaskSchedule instance for chaining.
760
+ * @throws {Error} If attempts or delay are negative.
761
+ */
762
+ retry(attempts = 3, delayMs = 1e3) {
763
+ if (attempts < 0) {
764
+ throw new Error("Retry attempts must be non-negative");
765
+ }
766
+ if (delayMs < 0) {
767
+ throw new Error("Retry delay must be non-negative");
768
+ }
769
+ this.task.retries = attempts;
770
+ this.task.retryDelay = delayMs;
771
+ return this;
772
+ }
773
+ /**
774
+ * Sets the shell command for execution-based tasks.
429
775
  *
430
776
  * @param command - The command string.
431
- * @returns The TaskSchedule instance.
777
+ * @returns The TaskSchedule instance for chaining.
432
778
  * @internal
433
779
  */
434
780
  setCommand(command) {
@@ -437,39 +783,39 @@ var TaskSchedule = class {
437
783
  }
438
784
  // --- Hooks ---
439
785
  /**
440
- * Register a callback to run on task success.
786
+ * Registers a callback to execute upon successful task completion.
441
787
  *
442
- * @param callback - The callback function.
443
- * @returns The TaskSchedule instance.
788
+ * @param callback - Function to run on success.
789
+ * @returns The TaskSchedule instance for chaining.
444
790
  */
445
791
  onSuccess(callback) {
446
792
  this.task.onSuccessCallbacks.push(callback);
447
793
  return this;
448
794
  }
449
795
  /**
450
- * Register a callback to run on task failure.
796
+ * Registers a callback to execute when the task fails.
451
797
  *
452
- * @param callback - The callback function.
453
- * @returns The TaskSchedule instance.
798
+ * @param callback - Function to run on failure.
799
+ * @returns The TaskSchedule instance for chaining.
454
800
  */
455
801
  onFailure(callback) {
456
802
  this.task.onFailureCallbacks.push(callback);
457
803
  return this;
458
804
  }
459
805
  /**
460
- * Set a description for the task (useful for listing).
806
+ * Attaches a human-readable description to the task.
461
807
  *
462
- * @param _text - The description text.
463
- * @returns The TaskSchedule instance.
808
+ * @param _text - Description text.
809
+ * @returns The TaskSchedule instance for chaining.
464
810
  */
465
811
  description(_text) {
466
812
  return this;
467
813
  }
468
814
  // --- Accessor ---
469
815
  /**
470
- * Get the underlying task configuration.
816
+ * Returns the final task configuration.
471
817
  *
472
- * @returns The ScheduledTask object.
818
+ * @returns The constructed ScheduledTask object.
473
819
  */
474
820
  getTask() {
475
821
  return this.task;
@@ -478,6 +824,14 @@ var TaskSchedule = class {
478
824
 
479
825
  // src/SchedulerManager.ts
480
826
  var SchedulerManager = class {
827
+ /**
828
+ * Initializes the scheduler engine.
829
+ *
830
+ * @param lockManager - Backend for distributed locking.
831
+ * @param logger - Optional logger for operational visibility.
832
+ * @param hooks - Optional manager for lifecycle event hooks.
833
+ * @param currentNodeRole - Role identifier for the local node (used for filtering).
834
+ */
481
835
  constructor(lockManager, logger, hooks, currentNodeRole) {
482
836
  this.lockManager = lockManager;
483
837
  this.logger = logger;
@@ -486,11 +840,18 @@ var SchedulerManager = class {
486
840
  }
487
841
  tasks = [];
488
842
  /**
489
- * Define a new scheduled task.
843
+ * Registers a new callback-based scheduled task.
490
844
  *
491
- * @param name - Unique name for the task
492
- * @param callback - Function to execute
493
- * @returns The newly created TaskSchedule.
845
+ * @param name - Unique task name used for identification and locking.
846
+ * @param callback - Asynchronous function containing the task logic.
847
+ * @returns A fluent TaskSchedule instance for further configuration.
848
+ *
849
+ * @example
850
+ * ```typescript
851
+ * scheduler.task('process-queues', async () => {
852
+ * await queue.process();
853
+ * }).everyMinute();
854
+ * ```
494
855
  */
495
856
  task(name, callback) {
496
857
  const task = new TaskSchedule(name, callback);
@@ -498,11 +859,21 @@ var SchedulerManager = class {
498
859
  return task;
499
860
  }
500
861
  /**
501
- * Define a new scheduled command execution task.
862
+ * Registers a shell command as a scheduled task.
863
+ *
864
+ * Executes the command via `sh -c` on matching nodes.
865
+ *
866
+ * @param name - Unique identifier for the command task.
867
+ * @param command - Raw shell command string.
868
+ * @returns A fluent TaskSchedule instance.
869
+ * @throws {Error} If the shell command returns a non-zero exit code during execution.
502
870
  *
503
- * @param name - Unique name for the task
504
- * @param command - Shell command to execute
505
- * @returns The newly created TaskSchedule.
871
+ * @example
872
+ * ```typescript
873
+ * scheduler.exec('log-rotate', 'logrotate /etc/logrotate.conf')
874
+ * .daily()
875
+ * .onNode('worker');
876
+ * ```
506
877
  */
507
878
  exec(name, command) {
508
879
  const task = new TaskSchedule(name, async () => {
@@ -516,27 +887,29 @@ var SchedulerManager = class {
516
887
  return task;
517
888
  }
518
889
  /**
519
- * Add a pre-configured task schedule object.
890
+ * Injects a pre-configured TaskSchedule instance into the registry.
520
891
  *
521
- * @param schedule - The task schedule to add.
892
+ * @param schedule - Configured task schedule to register.
522
893
  */
523
894
  add(schedule) {
524
895
  this.tasks.push(schedule);
525
896
  }
526
897
  /**
527
- * Get all registered task definitions.
898
+ * Exports all registered tasks for external inspection or serialization.
528
899
  *
529
- * @returns An array of scheduled tasks.
900
+ * @returns An array of raw task configurations.
530
901
  */
531
902
  getTasks() {
532
903
  return this.tasks.map((t) => t.getTask());
533
904
  }
534
905
  /**
535
- * Trigger the scheduler to check and run due tasks.
536
- * This is typically called every minute by a system cron or worker loop.
906
+ * Main evaluation loop that triggers tasks due for execution.
537
907
  *
538
- * @param date - The current reference date (default: now)
539
- * @returns A promise that resolves when the scheduler run is complete.
908
+ * Should be invoked every minute by a system timer (systemd/cron) or daemon.
909
+ * Performs frequency checks, role filtering, and parallel execution.
910
+ *
911
+ * @param date - Reference time for cron evaluation (default: current time).
912
+ * @returns Resolves when all due tasks have been initiated.
540
913
  */
541
914
  async run(date = /* @__PURE__ */ new Date()) {
542
915
  await this.hooks?.doAction("scheduler:run:start", { date });
@@ -548,6 +921,14 @@ var SchedulerManager = class {
548
921
  }
549
922
  }
550
923
  if (dueTasks.length > 0) {
924
+ this.logger?.debug(`[Horizon] Found ${dueTasks.length} due task(s) to execute`, {
925
+ tasks: dueTasks.map((t) => ({
926
+ name: t.name,
927
+ expression: t.expression,
928
+ background: t.background,
929
+ oneServer: t.shouldRunOnOneServer
930
+ }))
931
+ });
551
932
  }
552
933
  for (const task of dueTasks) {
553
934
  this.runTask(task, date).catch((err) => {
@@ -557,21 +938,45 @@ var SchedulerManager = class {
557
938
  await this.hooks?.doAction("scheduler:run:complete", { date, dueCount: dueTasks.length });
558
939
  }
559
940
  /**
560
- * Execute a specific task with locking logic.
941
+ * Executes an individual task after validating execution constraints.
942
+ *
943
+ * Evaluates node roles, overlapping prevention, and distributed time-window locks
944
+ * before initiating the actual task logic.
945
+ *
946
+ * @param task - Target task configuration.
947
+ * @param date - Reference time used for lock key generation.
561
948
  *
562
- * @param task - The task to execute.
563
949
  * @internal
564
950
  */
565
951
  async runTask(task, date = /* @__PURE__ */ new Date()) {
566
952
  if (task.nodeRole && this.currentNodeRole && task.nodeRole !== this.currentNodeRole) {
567
953
  return;
568
954
  }
569
- let acquiredLock = false;
955
+ const runningLockKey = `task:running:${task.name}`;
956
+ if (task.preventOverlapping) {
957
+ const isRunning = await this.lockManager.exists(runningLockKey);
958
+ if (isRunning) {
959
+ this.logger?.debug(
960
+ `[Horizon] Task "${task.name}" is still running, skipping this execution`
961
+ );
962
+ await this.hooks?.doAction("scheduler:task:skipped", {
963
+ name: task.name,
964
+ reason: "overlapping",
965
+ timestamp: date
966
+ });
967
+ return;
968
+ }
969
+ await this.lockManager.forceAcquire(runningLockKey, task.overlappingExpiresAt);
970
+ }
971
+ let acquiredTimeLock = false;
570
972
  const timestamp = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, "0")}${date.getDate().toString().padStart(2, "0")}${date.getHours().toString().padStart(2, "0")}${date.getMinutes().toString().padStart(2, "0")}`;
571
- const lockKey = `task:${task.name}:${timestamp}`;
973
+ const timeLockKey = `task:${task.name}:${timestamp}`;
572
974
  if (task.shouldRunOnOneServer) {
573
- acquiredLock = await this.lockManager.acquire(lockKey, task.lockTtl);
574
- if (!acquiredLock) {
975
+ acquiredTimeLock = await this.lockManager.acquire(timeLockKey, task.lockTtl);
976
+ if (!acquiredTimeLock) {
977
+ if (task.preventOverlapping) {
978
+ await this.lockManager.release(runningLockKey);
979
+ }
575
980
  return;
576
981
  }
577
982
  }
@@ -579,60 +984,118 @@ var SchedulerManager = class {
579
984
  if (task.background) {
580
985
  this.executeTask(task).catch((err) => {
581
986
  this.logger?.error(`Background task ${task.name} failed`, err);
987
+ }).finally(async () => {
988
+ if (task.preventOverlapping) {
989
+ await this.lockManager.release(runningLockKey);
990
+ }
582
991
  });
583
992
  } else {
584
993
  await this.executeTask(task);
994
+ if (task.preventOverlapping) {
995
+ await this.lockManager.release(runningLockKey);
996
+ }
585
997
  }
586
998
  } catch (err) {
587
- if (acquiredLock) {
588
- await this.lockManager.release(lockKey);
999
+ if (task.preventOverlapping) {
1000
+ await this.lockManager.release(runningLockKey);
1001
+ }
1002
+ if (acquiredTimeLock) {
1003
+ await this.lockManager.release(timeLockKey);
589
1004
  }
590
1005
  throw err;
591
1006
  }
592
1007
  }
593
1008
  /**
594
- * Execute the task callback and handle hooks.
1009
+ * Internal wrapper for executing task logic with reliability controls.
1010
+ *
1011
+ * Handles timeouts, retries, and emits lifecycle hooks for monitoring.
595
1012
  *
596
- * @param task - The task to execute.
1013
+ * @param task - The scheduled task to execute.
1014
+ * @returns Resolves when the task (and its retries) completes or fails permanently.
1015
+ *
1016
+ * @internal
597
1017
  */
598
1018
  async executeTask(task) {
599
1019
  const startTime = Date.now();
600
1020
  await this.hooks?.doAction("scheduler:task:start", { name: task.name, startTime });
601
- try {
602
- await task.callback();
603
- const duration = Date.now() - startTime;
604
- await this.hooks?.doAction("scheduler:task:success", { name: task.name, duration });
605
- for (const cb of task.onSuccessCallbacks) {
606
- try {
607
- await cb({ name: task.name });
608
- } catch {
609
- }
1021
+ const timeout = task.timeout || 36e5;
1022
+ const maxRetries = task.retries ?? 0;
1023
+ const retryDelay = task.retryDelay ?? 1e3;
1024
+ let lastError;
1025
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1026
+ if (attempt > 0) {
1027
+ this.logger?.info(
1028
+ `[Horizon] Retrying task "${task.name}" (attempt ${attempt}/${maxRetries})...`
1029
+ );
1030
+ await this.hooks?.doAction("scheduler:task:retry", {
1031
+ name: task.name,
1032
+ attempt,
1033
+ maxRetries,
1034
+ error: lastError,
1035
+ delay: retryDelay
1036
+ });
1037
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
610
1038
  }
611
- } catch (err) {
612
- const duration = Date.now() - startTime;
613
- this.logger?.error(`Task ${task.name} failed`, err);
614
- await this.hooks?.doAction("scheduler:task:failure", {
615
- name: task.name,
616
- error: err,
617
- duration
1039
+ let timeoutId;
1040
+ const timeoutPromise = new Promise((_, reject) => {
1041
+ timeoutId = setTimeout(() => {
1042
+ reject(new Error(`Task "${task.name}" timed out after ${timeout}ms`));
1043
+ }, timeout);
618
1044
  });
619
- for (const cb of task.onFailureCallbacks) {
620
- try {
621
- await cb(err);
622
- } catch {
1045
+ try {
1046
+ await Promise.race([task.callback(), timeoutPromise]);
1047
+ const duration2 = Date.now() - startTime;
1048
+ await this.hooks?.doAction("scheduler:task:success", {
1049
+ name: task.name,
1050
+ duration: duration2,
1051
+ attempts: attempt + 1
1052
+ });
1053
+ for (const cb of task.onSuccessCallbacks) {
1054
+ try {
1055
+ await cb({ name: task.name });
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ return;
1060
+ } catch (err) {
1061
+ lastError = err;
1062
+ this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
1063
+ } finally {
1064
+ if (timeoutId) {
1065
+ clearTimeout(timeoutId);
623
1066
  }
624
1067
  }
625
1068
  }
1069
+ const duration = Date.now() - startTime;
1070
+ await this.hooks?.doAction("scheduler:task:failure", {
1071
+ name: task.name,
1072
+ error: lastError,
1073
+ duration,
1074
+ attempts: maxRetries + 1
1075
+ });
1076
+ for (const cb of task.onFailureCallbacks) {
1077
+ try {
1078
+ await cb(lastError);
1079
+ } catch {
1080
+ }
1081
+ }
626
1082
  }
627
1083
  };
628
1084
 
629
1085
  // src/OrbitHorizon.ts
630
1086
  var OrbitHorizon = class {
631
1087
  /**
632
- * Install the Horizon Orbit into PlanetCore.
633
- * Registers the SchedulerManager and configures the distributed lock driver.
1088
+ * Integrates the Horizon scheduler into the PlanetCore lifecycle.
1089
+ *
1090
+ * Orchestrates the initialization sequence:
1091
+ * 1. Resolves lock backend (memory or cache-based).
1092
+ * 2. Instantiates SchedulerManager with global dependencies (logger, hooks).
1093
+ * 3. Registers the scheduler in the IoC container for CLI and global access.
1094
+ * 4. Injects the scheduler into the request context via middleware.
1095
+ *
1096
+ * @param core - PlanetCore instance providing configuration and service container.
634
1097
  *
635
- * @param core - The PlanetCore instance.
1098
+ * @throws {Error} If the cache driver is explicitly requested but `CacheManager` is unavailable.
636
1099
  */
637
1100
  install(core) {
638
1101
  const config = core.config.get("scheduler", {});