@gravito/horizon 3.2.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,15 +4,32 @@ Enterprise-grade distributed task scheduler for the Gravito framework.
4
4
 
5
5
  `@gravito/horizon` provides a robust, fluent, and highly configurable system for managing scheduled tasks (Cron jobs) in a distributed environment. It supports multiple locking mechanisms to prevent duplicate execution, node role filtering, retries, and comprehensive monitoring hooks.
6
6
 
7
- ## Features
8
-
9
- - **Fluent API**: Human-readable syntax for defining task schedules (e.g., `.daily().at('14:00')`).
10
- - **Distributed Locking**: Prevents duplicate task execution across multiple servers (supports Memory, Cache, and Redis).
11
- - **Node Role Awareness**: Restrict tasks to specific nodes (e.g., only run on `worker` nodes) or broadcast maintenance tasks to all matching nodes.
12
- - **Reliability Features**: Built-in support for task timeouts and automatic retries with configurable delays.
13
- - **Shell Command Support**: Schedule raw shell commands alongside TypeScript callbacks.
14
- - **Lazy Cron Parsing**: Lightweight `SimpleCronParser` for standard expressions, with `cron-parser` only loaded when complex logic is required.
15
- - **Comprehensive Hooks**: Lifecycle events for monitoring task success, failure, retries, and scheduler activity.
7
+ ## Features
8
+
9
+ - 🪐 **Galaxy-Ready Distributed Scheduler**: Native integration with PlanetCore for universal task management across all Satellites.
10
+ - 🕒 **Fluent API**: Human-readable syntax for defining task schedules (e.g., `.daily().at('14:00')`).
11
+ - 🔐 **Distributed Locking**: Prevents duplicate task execution across multiple servers (supports Redis/Plasma and SQL).
12
+ - 🧬 **Node-Role Awareness**: Intelligently restrict tasks to specific nodes (e.g., only run on `worker` nodes) or broadcast maintenance tasks.
13
+ - 🛡️ **Reliability Features**: Built-in support for task timeouts and automatic retries with configurable exponential backoff.
14
+ - 🐚 **Shell Command Support**: Schedule raw shell commands alongside TypeScript callbacks (powered by `@gravito/nova`).
15
+ - 📊 **Monitoring Hooks**: Comprehensive lifecycle events for tracking task success, failure, and execution duration.
16
+
17
+ ## 🌌 Role in Galaxy Architecture
18
+
19
+ In the **Gravito Galaxy Architecture**, Horizon acts as the **Clockwork of Jobs (Temporal Coordinator)**.
20
+
21
+ - **System Heartbeat**: Triggers recurring maintenance, cleanup, and synchronization tasks that keep the Galaxy running smoothly over time.
22
+ - **Node Coordination**: Ensures that even if you have 100 instances of a Satellite, a "Daily Report" task only runs exactly once per day across the entire cluster.
23
+ - **Background Orchestration**: Works with `@gravito/stream` to initiate long-running background processes at specific intervals.
24
+
25
+ ```mermaid
26
+ graph TD
27
+ H[Horizon: Scheduler] -->|Time Trigger| L{Distributed Lock}
28
+ L -- "Win Lock" --> T[Task: Daily Cleanup]
29
+ L -- "Lose Lock" --> S[Skip Execution]
30
+ T -->|Call| Sat[Satellite: Admin]
31
+ T -->|Metrics| Mon[Monitor Orbit]
32
+ ```
16
33
 
17
34
  ## Installation
18
35
 
@@ -65,13 +82,22 @@ scheduler.task('daily-cleanup', async () => {
65
82
  .dailyAt('02:00')
66
83
  .onOneServer() // Distributed lock
67
84
 
68
- // Shell command execution
85
+ // Shell command execution (type-safe via @gravito/nova)
69
86
  scheduler.exec('sync-storage', 'aws s3 sync ./local s3://bucket')
70
87
  .everyFiveMinutes()
71
88
  .onNode('worker')
72
89
  .retry(3, 5000) // Retry 3 times with 5s delay
90
+ .timeout(300000) // 5 minute timeout
73
91
  ```
74
92
 
93
+ ## 📚 Documentation
94
+
95
+ Detailed guides and references for the Galaxy Architecture:
96
+
97
+ - [🏗️ **Architecture Overview**](./README.md) — Distributed task scheduler core.
98
+ - [🕒 **Distributed Scheduling**](./doc/DISTRIBUTED_SCHEDULING.md) — **NEW**: Multi-server coordination and locking.
99
+ - [🐚 **Shell Execution**](#shell-execution-with-nova) — Type-safe shell commands via Nova.
100
+
75
101
  ## Scheduling API
76
102
 
77
103
  ### Frequency Methods
@@ -135,6 +161,39 @@ Poll every minute in a long-running process (ideal for Docker):
135
161
  bun run gravito schedule:work
136
162
  ```
137
163
 
164
+ ## Shell Execution with Nova
165
+
166
+ Horizon uses [@gravito/nova](../nova) for shell command execution, which provides:
167
+
168
+ - **Type-Safe Execution**: Template literal-based API prevents shell injection
169
+ - **Automatic Escaping**: All command arguments are automatically escaped
170
+ - **Consistent API**: Same Shell API used across Gravito framework
171
+ - **Error Handling**: Comprehensive error capture with stdout/stderr
172
+
173
+ Example with advanced shell operations:
174
+
175
+ ```typescript
176
+ import { Shell } from '@gravito/nova'
177
+
178
+ scheduler.task('backup-database', async () => {
179
+ // Use nova Shell API for custom commands
180
+ const result = await Shell.run`mysqldump -u ${dbUser} -p${dbPassword} ${dbName}`
181
+ .nothrow()
182
+ .run()
183
+
184
+ if (result.success) {
185
+ // Upload backup to S3
186
+ await Shell.run`aws s3 cp - s3://backups/${Date.now()}.sql`
187
+ .nothrow()
188
+ .run()
189
+ }
190
+ })
191
+ .dailyAt('03:00')
192
+ .onOneServer()
193
+ ```
194
+
195
+ ---
196
+
138
197
  ## Monitoring Hooks
139
198
 
140
199
  Subscribe to hooks via `core.hooks` to build monitoring dashboards or alerts:
package/dist/index.cjs CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -7821,6 +7820,35 @@ __export(index_exports, {
7821
7820
  });
7822
7821
  module.exports = __toCommonJS(index_exports);
7823
7822
 
7823
+ // src/errors/HorizonError.ts
7824
+ var import_core = require("@gravito/core");
7825
+ var HorizonError = class extends import_core.SystemException {
7826
+ constructor(status, code, options = {}) {
7827
+ super(status, code, options);
7828
+ this.name = "HorizonError";
7829
+ Object.setPrototypeOf(this, new.target.prototype);
7830
+ }
7831
+ };
7832
+
7833
+ // src/errors/codes.ts
7834
+ var HorizonErrorCodes = {
7835
+ // Scheduling errors
7836
+ CRON_PARSE_ERROR: "horizon.cron_parse_error",
7837
+ SCHEDULE_FAILED: "horizon.schedule_failed",
7838
+ COMMAND_FAILED: "horizon.command_failed",
7839
+ // Validation errors
7840
+ INVALID_TIME_FORMAT: "horizon.invalid_time_format",
7841
+ INVALID_MINUTE: "horizon.invalid_minute",
7842
+ INVALID_DAY_OF_WEEK: "horizon.invalid_day_of_week",
7843
+ INVALID_DAY_OF_MONTH: "horizon.invalid_day_of_month",
7844
+ INVALID_TIMEOUT: "horizon.invalid_timeout",
7845
+ INVALID_RETRY_ATTEMPTS: "horizon.invalid_retry_attempts",
7846
+ INVALID_RETRY_DELAY: "horizon.invalid_retry_delay",
7847
+ INVALID_TIMEZONE: "horizon.invalid_timezone",
7848
+ // Lock errors
7849
+ LOCK_DRIVER_REQUIRED: "horizon.lock_driver_required"
7850
+ };
7851
+
7824
7852
  // src/SimpleCronParser.ts
7825
7853
  var SimpleCronParser = class {
7826
7854
  /**
@@ -7840,7 +7868,9 @@ var SimpleCronParser = class {
7840
7868
  static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
7841
7869
  const parts = expression.trim().split(/\s+/);
7842
7870
  if (parts.length !== 5) {
7843
- throw new Error(`Invalid cron expression: ${expression}`);
7871
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
7872
+ message: `Invalid cron expression: ${expression}`
7873
+ });
7844
7874
  }
7845
7875
  const targetDate = this.getDateInTimezone(date, timezone);
7846
7876
  const minutes = targetDate.getMinutes();
@@ -7903,11 +7933,16 @@ var SimpleCronParser = class {
7903
7933
  try {
7904
7934
  const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
7905
7935
  if (Number.isNaN(tzDate.getTime())) {
7906
- throw new Error();
7936
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
7937
+ message: `Invalid timezone: ${timezone}`
7938
+ });
7907
7939
  }
7908
7940
  return tzDate;
7909
- } catch {
7910
- throw new Error(`Invalid timezone: ${timezone}`);
7941
+ } catch (err) {
7942
+ if (err instanceof HorizonError) throw err;
7943
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
7944
+ message: `Invalid timezone: ${timezone}`
7945
+ });
7911
7946
  }
7912
7947
  }
7913
7948
  };
@@ -7944,7 +7979,9 @@ var CronParser = class {
7944
7979
  });
7945
7980
  return interval.next().toDate();
7946
7981
  } catch (_err) {
7947
- throw new Error(`Invalid cron expression: ${expression}`);
7982
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
7983
+ message: `Invalid cron expression: ${expression}`
7984
+ });
7948
7985
  }
7949
7986
  }
7950
7987
  /**
@@ -8167,7 +8204,9 @@ var LockManager = class {
8167
8204
  this.store = new MemoryLockStore();
8168
8205
  } else if (driver === "cache") {
8169
8206
  if (!context?.cache) {
8170
- throw new Error("CacheManager is required for cache lock driver");
8207
+ throw new HorizonError(422, HorizonErrorCodes.LOCK_DRIVER_REQUIRED, {
8208
+ message: "CacheManager is required for cache lock driver"
8209
+ });
8171
8210
  }
8172
8211
  this.store = new CacheLockStore(context.cache);
8173
8212
  } else {
@@ -8217,24 +8256,19 @@ var LockManager = class {
8217
8256
  };
8218
8257
 
8219
8258
  // src/process/Process.ts
8220
- var import_core = require("@gravito/core");
8259
+ var import_nova = require("@gravito/nova");
8221
8260
  async function runProcess(command) {
8222
- const runtime = (0, import_core.getRuntimeAdapter)();
8223
- const proc = runtime.spawn(["sh", "-c", command], {
8224
- stdout: "pipe",
8225
- stderr: "pipe"
8226
- });
8227
- const [stdout, stderr] = await Promise.all([
8228
- new Response(proc.stdout ?? null).text(),
8229
- new Response(proc.stderr ?? null).text()
8230
- ]);
8231
- const exitCode = await proc.exited;
8232
- return {
8233
- exitCode,
8234
- stdout,
8235
- stderr,
8236
- success: exitCode === 0
8237
- };
8261
+ try {
8262
+ const result = await import_nova.Shell.run`bash -c ${command}`.nothrow().run();
8263
+ return result;
8264
+ } catch (error) {
8265
+ return {
8266
+ exitCode: 1,
8267
+ stdout: "",
8268
+ stderr: error instanceof Error ? error.message : String(error),
8269
+ success: false
8270
+ };
8271
+ }
8238
8272
  }
8239
8273
  var Process = class {
8240
8274
  /**
@@ -8253,28 +8287,38 @@ function parseTime(time) {
8253
8287
  const timePattern = /^([0-2]\d):([0-5]\d)$/;
8254
8288
  const match = time.match(timePattern);
8255
8289
  if (!match) {
8256
- throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
8290
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
8291
+ message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
8292
+ });
8257
8293
  }
8258
8294
  const hour = Number.parseInt(match[1], 10);
8259
8295
  const minute = Number.parseInt(match[2], 10);
8260
8296
  if (hour > 23) {
8261
- throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
8297
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
8298
+ message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
8299
+ });
8262
8300
  }
8263
8301
  return { hour, minute };
8264
8302
  }
8265
8303
  function validateMinute(minute) {
8266
8304
  if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
8267
- throw new Error(`Invalid minute: ${minute}. Expected integer 0-59`);
8305
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_MINUTE, {
8306
+ message: `Invalid minute: ${minute}. Expected integer 0-59`
8307
+ });
8268
8308
  }
8269
8309
  }
8270
8310
  function validateDayOfWeek(dayOfWeek) {
8271
8311
  if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
8272
- throw new Error(`Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`);
8312
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_WEEK, {
8313
+ message: `Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`
8314
+ });
8273
8315
  }
8274
8316
  }
8275
8317
  function validateDayOfMonth(dayOfMonth) {
8276
8318
  if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
8277
- throw new Error(`Invalid day of month: ${dayOfMonth}. Expected 1-31`);
8319
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_MONTH, {
8320
+ message: `Invalid day of month: ${dayOfMonth}. Expected 1-31`
8321
+ });
8278
8322
  }
8279
8323
  }
8280
8324
 
@@ -8322,16 +8366,16 @@ var TaskSchedule = class {
8322
8366
  cron(expression) {
8323
8367
  const parts = expression.trim().split(/\s+/);
8324
8368
  if (parts.length !== 5) {
8325
- throw new Error(
8326
- `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
8327
- );
8369
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
8370
+ message: `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
8371
+ });
8328
8372
  }
8329
8373
  const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
8330
8374
  for (let i = 0; i < 5; i++) {
8331
8375
  if (!pattern.test(parts[i])) {
8332
- throw new Error(
8333
- `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
8334
- );
8376
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
8377
+ message: `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
8378
+ });
8335
8379
  }
8336
8380
  }
8337
8381
  this.task.expression = expression;
@@ -8479,9 +8523,9 @@ var TaskSchedule = class {
8479
8523
  try {
8480
8524
  (/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
8481
8525
  } catch {
8482
- throw new Error(
8483
- `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
8484
- );
8526
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
8527
+ message: `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
8528
+ });
8485
8529
  }
8486
8530
  this.task.timezone = timezone;
8487
8531
  return this;
@@ -8567,7 +8611,9 @@ var TaskSchedule = class {
8567
8611
  */
8568
8612
  timeout(ms) {
8569
8613
  if (ms <= 0) {
8570
- throw new Error("Timeout must be a positive number");
8614
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEOUT, {
8615
+ message: "Timeout must be a positive number"
8616
+ });
8571
8617
  }
8572
8618
  this.task.timeout = ms;
8573
8619
  return this;
@@ -8582,10 +8628,14 @@ var TaskSchedule = class {
8582
8628
  */
8583
8629
  retry(attempts = 3, delayMs = 1e3) {
8584
8630
  if (attempts < 0) {
8585
- throw new Error("Retry attempts must be non-negative");
8631
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_ATTEMPTS, {
8632
+ message: "Retry attempts must be non-negative"
8633
+ });
8586
8634
  }
8587
8635
  if (delayMs < 0) {
8588
- throw new Error("Retry delay must be non-negative");
8636
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_DELAY, {
8637
+ message: "Retry delay must be non-negative"
8638
+ });
8589
8639
  }
8590
8640
  this.task.retries = attempts;
8591
8641
  this.task.retryDelay = delayMs;
@@ -8700,7 +8750,9 @@ var SchedulerManager = class {
8700
8750
  const task = new TaskSchedule(name, async () => {
8701
8751
  const result = await Process.run(command);
8702
8752
  if (!result.success) {
8703
- throw new Error(`Command failed: ${result.stderr || result.stdout}`);
8753
+ throw new HorizonError(500, HorizonErrorCodes.COMMAND_FAILED, {
8754
+ message: `Command failed: ${result.stderr || result.stdout}`
8755
+ });
8704
8756
  }
8705
8757
  });
8706
8758
  task.setCommand(command);
@@ -8805,14 +8857,15 @@ var SchedulerManager = class {
8805
8857
  if (task.background) {
8806
8858
  this.executeTask(task).catch((err) => {
8807
8859
  this.logger?.error(`Background task ${task.name} failed`, err);
8808
- }).finally(async () => {
8809
- if (task.preventOverlapping) {
8860
+ return { success: false, timedOut: false };
8861
+ }).then(async (result) => {
8862
+ if (task.preventOverlapping && !result.timedOut) {
8810
8863
  await this.lockManager.release(runningLockKey);
8811
8864
  }
8812
8865
  });
8813
8866
  } else {
8814
- await this.executeTask(task);
8815
- if (task.preventOverlapping) {
8867
+ const result = await this.executeTask(task);
8868
+ if (task.preventOverlapping && !result.timedOut) {
8816
8869
  await this.lockManager.release(runningLockKey);
8817
8870
  }
8818
8871
  }
@@ -8877,7 +8930,7 @@ var SchedulerManager = class {
8877
8930
  } catch {
8878
8931
  }
8879
8932
  }
8880
- return;
8933
+ return { success: true, timedOut: false };
8881
8934
  } catch (err) {
8882
8935
  lastError = err;
8883
8936
  this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
@@ -8900,6 +8953,10 @@ var SchedulerManager = class {
8900
8953
  } catch {
8901
8954
  }
8902
8955
  }
8956
+ return {
8957
+ success: false,
8958
+ timedOut: lastError?.message.includes("timed out after") ?? false
8959
+ };
8903
8960
  }
8904
8961
  };
8905
8962
 
package/dist/index.d.cts CHANGED
@@ -674,7 +674,7 @@ declare class SchedulerManager {
674
674
  * @param hooks - Optional manager for lifecycle event hooks.
675
675
  * @param currentNodeRole - Role identifier for the local node (used for filtering).
676
676
  */
677
- constructor(lockManager: LockManager, logger?: Logger | undefined, hooks?: HookManager | undefined, currentNodeRole?: string | undefined);
677
+ constructor(lockManager: LockManager, logger?: Logger, hooks?: HookManager, currentNodeRole?: string);
678
678
  /**
679
679
  * Registers a new callback-based scheduled task.
680
680
  *
@@ -844,9 +844,9 @@ interface ProcessResult {
844
844
  /**
845
845
  * Spawns a shell command and asynchronously captures its full output.
846
846
  *
847
- * Leverages the Gravito runtime adapter to ensure compatibility across different
848
- * JavaScript runtimes (Bun, Node.js). Executes commands within a shell (`sh -c`)
849
- * to support pipes, redirects, and environment variables.
847
+ * Leverages the Nova Shell orchestration engine to ensure type-safe,
848
+ * shell-injection-resistant command execution. Supports pipes, redirects,
849
+ * and environment variables.
850
850
  *
851
851
  * @param command - Raw shell command string to execute.
852
852
  * @returns Resolves to a detailed `ProcessResult` object.
package/dist/index.d.ts CHANGED
@@ -674,7 +674,7 @@ declare class SchedulerManager {
674
674
  * @param hooks - Optional manager for lifecycle event hooks.
675
675
  * @param currentNodeRole - Role identifier for the local node (used for filtering).
676
676
  */
677
- constructor(lockManager: LockManager, logger?: Logger | undefined, hooks?: HookManager | undefined, currentNodeRole?: string | undefined);
677
+ constructor(lockManager: LockManager, logger?: Logger, hooks?: HookManager, currentNodeRole?: string);
678
678
  /**
679
679
  * Registers a new callback-based scheduled task.
680
680
  *
@@ -844,9 +844,9 @@ interface ProcessResult {
844
844
  /**
845
845
  * Spawns a shell command and asynchronously captures its full output.
846
846
  *
847
- * Leverages the Gravito runtime adapter to ensure compatibility across different
848
- * JavaScript runtimes (Bun, Node.js). Executes commands within a shell (`sh -c`)
849
- * to support pipes, redirects, and environment variables.
847
+ * Leverages the Nova Shell orchestration engine to ensure type-safe,
848
+ * shell-injection-resistant command execution. Supports pipes, redirects,
849
+ * and environment variables.
850
850
  *
851
851
  * @param command - Raw shell command string to execute.
852
852
  * @returns Resolves to a detailed `ProcessResult` object.
package/dist/index.js CHANGED
@@ -1,5 +1,34 @@
1
1
  import "./chunk-MCKGQKYU.js";
2
2
 
3
+ // src/errors/HorizonError.ts
4
+ import { SystemException } from "@gravito/core";
5
+ var HorizonError = class extends SystemException {
6
+ constructor(status, code, options = {}) {
7
+ super(status, code, options);
8
+ this.name = "HorizonError";
9
+ Object.setPrototypeOf(this, new.target.prototype);
10
+ }
11
+ };
12
+
13
+ // src/errors/codes.ts
14
+ var HorizonErrorCodes = {
15
+ // Scheduling errors
16
+ CRON_PARSE_ERROR: "horizon.cron_parse_error",
17
+ SCHEDULE_FAILED: "horizon.schedule_failed",
18
+ COMMAND_FAILED: "horizon.command_failed",
19
+ // Validation errors
20
+ INVALID_TIME_FORMAT: "horizon.invalid_time_format",
21
+ INVALID_MINUTE: "horizon.invalid_minute",
22
+ INVALID_DAY_OF_WEEK: "horizon.invalid_day_of_week",
23
+ INVALID_DAY_OF_MONTH: "horizon.invalid_day_of_month",
24
+ INVALID_TIMEOUT: "horizon.invalid_timeout",
25
+ INVALID_RETRY_ATTEMPTS: "horizon.invalid_retry_attempts",
26
+ INVALID_RETRY_DELAY: "horizon.invalid_retry_delay",
27
+ INVALID_TIMEZONE: "horizon.invalid_timezone",
28
+ // Lock errors
29
+ LOCK_DRIVER_REQUIRED: "horizon.lock_driver_required"
30
+ };
31
+
3
32
  // src/SimpleCronParser.ts
4
33
  var SimpleCronParser = class {
5
34
  /**
@@ -19,7 +48,9 @@ var SimpleCronParser = class {
19
48
  static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
20
49
  const parts = expression.trim().split(/\s+/);
21
50
  if (parts.length !== 5) {
22
- throw new Error(`Invalid cron expression: ${expression}`);
51
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
52
+ message: `Invalid cron expression: ${expression}`
53
+ });
23
54
  }
24
55
  const targetDate = this.getDateInTimezone(date, timezone);
25
56
  const minutes = targetDate.getMinutes();
@@ -82,11 +113,16 @@ var SimpleCronParser = class {
82
113
  try {
83
114
  const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
84
115
  if (Number.isNaN(tzDate.getTime())) {
85
- throw new Error();
116
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
117
+ message: `Invalid timezone: ${timezone}`
118
+ });
86
119
  }
87
120
  return tzDate;
88
- } catch {
89
- throw new Error(`Invalid timezone: ${timezone}`);
121
+ } catch (err) {
122
+ if (err instanceof HorizonError) throw err;
123
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
124
+ message: `Invalid timezone: ${timezone}`
125
+ });
90
126
  }
91
127
  }
92
128
  };
@@ -123,7 +159,9 @@ var CronParser = class {
123
159
  });
124
160
  return interval.next().toDate();
125
161
  } catch (_err) {
126
- throw new Error(`Invalid cron expression: ${expression}`);
162
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
163
+ message: `Invalid cron expression: ${expression}`
164
+ });
127
165
  }
128
166
  }
129
167
  /**
@@ -346,7 +384,9 @@ var LockManager = class {
346
384
  this.store = new MemoryLockStore();
347
385
  } else if (driver === "cache") {
348
386
  if (!context?.cache) {
349
- throw new Error("CacheManager is required for cache lock driver");
387
+ throw new HorizonError(422, HorizonErrorCodes.LOCK_DRIVER_REQUIRED, {
388
+ message: "CacheManager is required for cache lock driver"
389
+ });
350
390
  }
351
391
  this.store = new CacheLockStore(context.cache);
352
392
  } else {
@@ -396,24 +436,19 @@ var LockManager = class {
396
436
  };
397
437
 
398
438
  // src/process/Process.ts
399
- import { getRuntimeAdapter } from "@gravito/core";
439
+ import { Shell } from "@gravito/nova";
400
440
  async function runProcess(command) {
401
- const runtime = getRuntimeAdapter();
402
- const proc = runtime.spawn(["sh", "-c", command], {
403
- stdout: "pipe",
404
- stderr: "pipe"
405
- });
406
- const [stdout, stderr] = await Promise.all([
407
- new Response(proc.stdout ?? null).text(),
408
- new Response(proc.stderr ?? null).text()
409
- ]);
410
- const exitCode = await proc.exited;
411
- return {
412
- exitCode,
413
- stdout,
414
- stderr,
415
- success: exitCode === 0
416
- };
441
+ try {
442
+ const result = await Shell.run`bash -c ${command}`.nothrow().run();
443
+ return result;
444
+ } catch (error) {
445
+ return {
446
+ exitCode: 1,
447
+ stdout: "",
448
+ stderr: error instanceof Error ? error.message : String(error),
449
+ success: false
450
+ };
451
+ }
417
452
  }
418
453
  var Process = class {
419
454
  /**
@@ -432,28 +467,38 @@ function parseTime(time) {
432
467
  const timePattern = /^([0-2]\d):([0-5]\d)$/;
433
468
  const match = time.match(timePattern);
434
469
  if (!match) {
435
- throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
470
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
471
+ message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
472
+ });
436
473
  }
437
474
  const hour = Number.parseInt(match[1], 10);
438
475
  const minute = Number.parseInt(match[2], 10);
439
476
  if (hour > 23) {
440
- throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
477
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
478
+ message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
479
+ });
441
480
  }
442
481
  return { hour, minute };
443
482
  }
444
483
  function validateMinute(minute) {
445
484
  if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
446
- throw new Error(`Invalid minute: ${minute}. Expected integer 0-59`);
485
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_MINUTE, {
486
+ message: `Invalid minute: ${minute}. Expected integer 0-59`
487
+ });
447
488
  }
448
489
  }
449
490
  function validateDayOfWeek(dayOfWeek) {
450
491
  if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
451
- throw new Error(`Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`);
492
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_WEEK, {
493
+ message: `Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`
494
+ });
452
495
  }
453
496
  }
454
497
  function validateDayOfMonth(dayOfMonth) {
455
498
  if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
456
- throw new Error(`Invalid day of month: ${dayOfMonth}. Expected 1-31`);
499
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_MONTH, {
500
+ message: `Invalid day of month: ${dayOfMonth}. Expected 1-31`
501
+ });
457
502
  }
458
503
  }
459
504
 
@@ -501,16 +546,16 @@ var TaskSchedule = class {
501
546
  cron(expression) {
502
547
  const parts = expression.trim().split(/\s+/);
503
548
  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
- );
549
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
550
+ message: `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
551
+ });
507
552
  }
508
553
  const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
509
554
  for (let i = 0; i < 5; i++) {
510
555
  if (!pattern.test(parts[i])) {
511
- throw new Error(
512
- `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
513
- );
556
+ throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
557
+ message: `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
558
+ });
514
559
  }
515
560
  }
516
561
  this.task.expression = expression;
@@ -658,9 +703,9 @@ var TaskSchedule = class {
658
703
  try {
659
704
  (/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
660
705
  } 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
- );
706
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
707
+ message: `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
708
+ });
664
709
  }
665
710
  this.task.timezone = timezone;
666
711
  return this;
@@ -746,7 +791,9 @@ var TaskSchedule = class {
746
791
  */
747
792
  timeout(ms) {
748
793
  if (ms <= 0) {
749
- throw new Error("Timeout must be a positive number");
794
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEOUT, {
795
+ message: "Timeout must be a positive number"
796
+ });
750
797
  }
751
798
  this.task.timeout = ms;
752
799
  return this;
@@ -761,10 +808,14 @@ var TaskSchedule = class {
761
808
  */
762
809
  retry(attempts = 3, delayMs = 1e3) {
763
810
  if (attempts < 0) {
764
- throw new Error("Retry attempts must be non-negative");
811
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_ATTEMPTS, {
812
+ message: "Retry attempts must be non-negative"
813
+ });
765
814
  }
766
815
  if (delayMs < 0) {
767
- throw new Error("Retry delay must be non-negative");
816
+ throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_DELAY, {
817
+ message: "Retry delay must be non-negative"
818
+ });
768
819
  }
769
820
  this.task.retries = attempts;
770
821
  this.task.retryDelay = delayMs;
@@ -879,7 +930,9 @@ var SchedulerManager = class {
879
930
  const task = new TaskSchedule(name, async () => {
880
931
  const result = await Process.run(command);
881
932
  if (!result.success) {
882
- throw new Error(`Command failed: ${result.stderr || result.stdout}`);
933
+ throw new HorizonError(500, HorizonErrorCodes.COMMAND_FAILED, {
934
+ message: `Command failed: ${result.stderr || result.stdout}`
935
+ });
883
936
  }
884
937
  });
885
938
  task.setCommand(command);
@@ -984,14 +1037,15 @@ var SchedulerManager = class {
984
1037
  if (task.background) {
985
1038
  this.executeTask(task).catch((err) => {
986
1039
  this.logger?.error(`Background task ${task.name} failed`, err);
987
- }).finally(async () => {
988
- if (task.preventOverlapping) {
1040
+ return { success: false, timedOut: false };
1041
+ }).then(async (result) => {
1042
+ if (task.preventOverlapping && !result.timedOut) {
989
1043
  await this.lockManager.release(runningLockKey);
990
1044
  }
991
1045
  });
992
1046
  } else {
993
- await this.executeTask(task);
994
- if (task.preventOverlapping) {
1047
+ const result = await this.executeTask(task);
1048
+ if (task.preventOverlapping && !result.timedOut) {
995
1049
  await this.lockManager.release(runningLockKey);
996
1050
  }
997
1051
  }
@@ -1056,7 +1110,7 @@ var SchedulerManager = class {
1056
1110
  } catch {
1057
1111
  }
1058
1112
  }
1059
- return;
1113
+ return { success: true, timedOut: false };
1060
1114
  } catch (err) {
1061
1115
  lastError = err;
1062
1116
  this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
@@ -1079,6 +1133,10 @@ var SchedulerManager = class {
1079
1133
  } catch {
1080
1134
  }
1081
1135
  }
1136
+ return {
1137
+ success: false,
1138
+ timedOut: lastError?.message.includes("timed out after") ?? false
1139
+ };
1082
1140
  }
1083
1141
  };
1084
1142
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@gravito/horizon",
3
- "version": "3.2.1",
3
+ "sideEffects": false,
4
+ "version": "4.0.0",
4
5
  "description": "Distributed task scheduler for Gravito framework",
5
6
  "main": "./dist/index.cjs",
6
7
  "module": "./dist/index.js",
@@ -20,6 +21,7 @@
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "bun run build.ts",
24
+ "build:dts": "bun run build.ts --dts-only",
23
25
  "test": "bun test --timeout=10000",
24
26
  "lint": "biome lint ./src",
25
27
  "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
@@ -37,18 +39,20 @@
37
39
  ],
38
40
  "author": "Carl Lee <carllee0520@gmail.com>",
39
41
  "license": "MIT",
40
- "dependencies": {},
42
+ "dependencies": {
43
+ "@gravito/nova": "^1.0.2"
44
+ },
41
45
  "optionalDependencies": {
42
46
  "cron-parser": "^4.9.0"
43
47
  },
44
48
  "peerDependencies": {
45
- "@gravito/core": "^1.6.1",
46
- "@gravito/stasis": "^3.1.1"
49
+ "@gravito/core": "^3.0.0",
50
+ "@gravito/stasis": "^4.0.0"
47
51
  },
48
52
  "devDependencies": {
49
- "@gravito/stasis": "^3.1.1",
53
+ "@gravito/stasis": "workspace:*",
50
54
  "bun-types": "latest",
51
- "@gravito/core": "^1.6.1",
55
+ "@gravito/core": "workspace:*",
52
56
  "tsup": "^8.5.1",
53
57
  "typescript": "^5.9.3"
54
58
  },