@zudojs/scheduler 1.1.1 → 1.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/README.md CHANGED
@@ -41,6 +41,28 @@ scheduler.start();
41
41
  await scheduler.stop({ timeoutMs: 30_000 });
42
42
  ```
43
43
 
44
+ ## Starting and stopping
45
+
46
+ A started scheduler keeps the Node.js process alive until `stop()`, so a
47
+ script whose only work is a scheduler runs its jobs instead of exiting
48
+ straight away. Pass `keepAlive: false` for a scheduler that should never hold
49
+ the process open by itself (the behaviour before 1.2.0).
50
+
51
+ `stop()` is idempotent: calling it on a scheduler that never started, or
52
+ calling it twice, resolves instead of throwing `SchedulerStoppedError`.
53
+ `stop({ drain: true })` lets running jobs finish instead of aborting them.
54
+
55
+ For deterministic tests, pass your own clock. `Clock` is exported from the
56
+ package root:
57
+
58
+ ```typescript
59
+ import { Scheduler } from "@zudojs/scheduler";
60
+ import type { Clock } from "@zudojs/scheduler";
61
+
62
+ const clock: Clock = { now: () => new Date(0), nowMs: () => 0 };
63
+ const scheduler = new Scheduler({ clock });
64
+ ```
65
+
44
66
  ## Scheduling
45
67
 
46
68
  Four entry points, each returning a `ScheduleHandle`:
@@ -104,6 +126,15 @@ A timeout raises `SchedulerJobTimeoutError` and **aborts the handler** via
104
126
  `ctx.signal`; anything else raises `SchedulerJobExecutionError` carrying the
105
127
  original error as `cause`.
106
128
 
129
+ `ctx.attempt` is the attempt in progress, **1-based**: `1` on the first run,
130
+ `2` on the first retry. `ctx.attemptNumber` is the same number under the name
131
+ `@zudojs/queue` uses for its processor context, so both packages count
132
+ attempts the same way.
133
+
134
+ An invalid cron expression throws `CronParseError` with the expression quoted
135
+ first and the reason after it:
136
+ `Invalid cron expression "99 0 * * *": Cron field "minute" value 99 is outside 0-59.`
137
+
107
138
  ## Concurrency and overlap
108
139
 
109
140
  `maxConcurrency` (default 10) bounds executions in flight across all schedules;
@@ -176,6 +207,10 @@ scheduler.getExecutions("cleanup");
176
207
  // duration, attempt, error? }]
177
208
  ```
178
209
 
210
+ `attempt` is the attempt that settled the run — `3` for a run whose retry
211
+ policy let it succeed on its third try — and, while the run is still going,
212
+ the attempt currently in progress.
213
+
179
214
  ## Features
180
215
 
181
216
  - Delay, date, interval and cron triggers
package/dist/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./sche
35
35
  export { parseCron, nextCronDate } from "./scheduler/trigger/index.js";
36
36
  export type { ParsedCron } from "./scheduler/trigger/index.js";
37
37
  export { SystemClock, createSystemClock } from "./scheduler/clock/index.js";
38
+ export type { Clock } from "./scheduler/clock/index.js";
38
39
  export { JobRegistry } from "./scheduler/registry/index.js";
39
40
  export { JobExecutor, retryDelay } from "./scheduler/executor/index.js";
40
41
  export { PriorityQueue } from "./scheduler/priorityQueue/index.js";
@@ -17,9 +17,13 @@ export declare class JobExecutor {
17
17
  * @param attempt - The attempt number to start from (1-based).
18
18
  * @param signal - Signal that aborts the job and stops further retries.
19
19
  * @param data - Optional payload handed to the handler.
20
+ * @param hooks - `onAttempt` is told the 1-based number of each attempt as
21
+ * it starts, so a caller can record the attempt that settled the run.
20
22
  * @returns The execution result.
21
23
  */
22
- execute(job: JobDefinition, executionId: string, scheduledAt: Date, attempt: number, signal: AbortSignal, data?: unknown): Promise<JobExecutionResult>;
24
+ execute(job: JobDefinition, executionId: string, scheduledAt: Date, attempt: number, signal: AbortSignal, data?: unknown, hooks?: {
25
+ readonly onAttempt?: (attempt: number) => void;
26
+ }): Promise<JobExecutionResult>;
23
27
  /** Runs the handler once, under a timeout that also aborts it. */
24
28
  private runOnce;
25
29
  /** Maps a thrown value onto the scheduler's error taxonomy. */
@@ -20,9 +20,11 @@ export class JobExecutor {
20
20
  * @param attempt - The attempt number to start from (1-based).
21
21
  * @param signal - Signal that aborts the job and stops further retries.
22
22
  * @param data - Optional payload handed to the handler.
23
+ * @param hooks - `onAttempt` is told the 1-based number of each attempt as
24
+ * it starts, so a caller can record the attempt that settled the run.
23
25
  * @returns The execution result.
24
26
  */
25
- async execute(job, executionId, scheduledAt, attempt, signal, data) {
27
+ async execute(job, executionId, scheduledAt, attempt, signal, data, hooks) {
26
28
  const retry = job.options?.retry;
27
29
  const maxAttempts = Math.max(1, retry?.attempts ?? 1);
28
30
  let currentAttempt = attempt;
@@ -31,6 +33,7 @@ export class JobExecutor {
31
33
  if (signal.aborted) {
32
34
  throw new SchedulerJobCancelledError("Job was cancelled via signal.", job.id);
33
35
  }
36
+ hooks?.onAttempt?.(currentAttempt);
34
37
  try {
35
38
  await this.runOnce(job, executionId, scheduledAt, currentAttempt, signal, data);
36
39
  return { success: true };
@@ -6,7 +6,16 @@ export interface JobContext<T = unknown> {
6
6
  readonly executionId: string;
7
7
  readonly scheduledAt: Date;
8
8
  readonly startedAt: Date;
9
+ /**
10
+ * The attempt in progress, 1-based: `1` on the first run, `2` on the first
11
+ * retry. Counts attempts within one execution, per the job's retry policy.
12
+ */
9
13
  readonly attempt: number;
14
+ /**
15
+ * Alias of {@link JobContext.attempt}, named as in `@zudojs/queue`, whose
16
+ * processor context carries the same 1-based `attemptNumber`.
17
+ */
18
+ readonly attemptNumber: number;
10
19
  readonly data: T;
11
20
  readonly signal: AbortSignal;
12
21
  }
@@ -8,6 +8,7 @@ export function createJobContext(jobId, executionId, scheduledAt, startedAt, att
8
8
  scheduledAt,
9
9
  startedAt,
10
10
  attempt,
11
+ attemptNumber: attempt,
11
12
  data,
12
13
  signal,
13
14
  });
@@ -11,6 +11,10 @@ import { PriorityQueue } from "./priorityQueue/schedulerPriorityQueue.core.js";
11
11
  export interface SchedulerErrorEvent {
12
12
  readonly scheduleId: string;
13
13
  readonly jobId: string;
14
+ /**
15
+ * The execution that failed. Empty when the failure happened before any
16
+ * execution could start — a schedule whose job is no longer registered.
17
+ */
14
18
  readonly executionId: string;
15
19
  readonly error: unknown;
16
20
  }
@@ -24,6 +28,12 @@ export interface SchedulerOptions {
24
28
  readonly maxConcurrency?: number;
25
29
  /** Called when a job execution fails. */
26
30
  readonly onError?: (event: SchedulerErrorEvent) => void;
31
+ /**
32
+ * Whether a started scheduler keeps the Node.js process alive until
33
+ * `stop()` (default: true). Pass `false` for a scheduler that should never
34
+ * by itself hold the process open.
35
+ */
36
+ readonly keepAlive?: boolean;
27
37
  }
28
38
  /**
29
39
  * Scheduler for time-based job execution.
@@ -46,6 +56,7 @@ export declare class Scheduler {
46
56
  private readonly schedules;
47
57
  private readonly maxConcurrency;
48
58
  private readonly onError;
59
+ private readonly keepAlive;
49
60
  /** Executions currently in flight, across all schedules. */
50
61
  private readonly inFlight;
51
62
  /**
@@ -57,6 +68,16 @@ export declare class Scheduler {
57
68
  * to abort.
58
69
  */
59
70
  private readonly runningControllers;
71
+ /**
72
+ * In-flight controllers per schedule id.
73
+ *
74
+ * Indexed by id for the same reason {@link runningControllers} exists: a
75
+ * one-shot is retired at dispatch time, so `cancel()` arrives after its
76
+ * record has already been deleted and the record's own `running` set is
77
+ * unreachable. Without this, `handle.cancel()` aborted a recurring
78
+ * schedule's run and silently did nothing for a one-shot's.
79
+ */
80
+ private readonly runningByScheduleId;
60
81
  /** Executions in flight per job id, for the per-job concurrency ceiling. */
61
82
  private readonly runningByJob;
62
83
  /** Records with `pendingRuns > 0`, drained as executions finish. */
@@ -87,6 +108,9 @@ export declare class Scheduler {
87
108
  /**
88
109
  * Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
89
110
  *
111
+ * Idempotent: on a scheduler that never started, or one already stopped, it
112
+ * only waits for any executions still settling.
113
+ *
90
114
  * @param options - `drain` waits for running jobs to finish instead of
91
115
  * aborting them; `timeoutMs` bounds the wait either way.
92
116
  */
@@ -137,7 +161,13 @@ export declare class Scheduler {
137
161
  * misfire policy that says skip, discards them.
138
162
  */
139
163
  private retire;
140
- /** Aborts every in-flight execution of one schedule. */
164
+ /**
165
+ * Aborts every in-flight execution of one schedule.
166
+ *
167
+ * Resolved through {@link runningByScheduleId} rather than the schedule
168
+ * map: a one-shot is retired as soon as it is dispatched, so its record is
169
+ * already gone while its execution is still running.
170
+ */
141
171
  private abortSchedule;
142
172
  /**
143
173
  * Internal tick method for processing due jobs.
@@ -166,6 +196,8 @@ export declare class Scheduler {
166
196
  private drainPending;
167
197
  /** Records a started execution, evicting the oldest beyond the cap. */
168
198
  private beginExecution;
199
+ /** Records the attempt an execution is on, if it is still in the history. */
200
+ private recordAttempt;
169
201
  /** Completes the record for an execution, if it is still in the history. */
170
202
  private finishExecution;
171
203
  /**
@@ -1,6 +1,6 @@
1
1
  import { ScheduleHandleImpl } from "./scheduleHandle/scheduleHandle.type.js";
2
2
  import { createSchedule } from "./schedule/schedule.type.js";
3
- import { SchedulerAlreadyStartedError, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
3
+ import { SchedulerAlreadyStartedError, SchedulerJobCancelledError, SchedulerJobNotFoundError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
4
4
  import { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./trigger/schedulerTrigger.core.js";
5
5
  import { SystemClock } from "./clock/schedulerClock.type.js";
6
6
  import { JobRegistry } from "./registry/jobRegistry.core.js";
@@ -35,6 +35,7 @@ export class Scheduler {
35
35
  schedules = new Map();
36
36
  maxConcurrency;
37
37
  onError;
38
+ keepAlive;
38
39
  /** Executions currently in flight, across all schedules. */
39
40
  inFlight = new Set();
40
41
  /**
@@ -46,6 +47,16 @@ export class Scheduler {
46
47
  * to abort.
47
48
  */
48
49
  runningControllers = new Set();
50
+ /**
51
+ * In-flight controllers per schedule id.
52
+ *
53
+ * Indexed by id for the same reason {@link runningControllers} exists: a
54
+ * one-shot is retired at dispatch time, so `cancel()` arrives after its
55
+ * record has already been deleted and the record's own `running` set is
56
+ * unreachable. Without this, `handle.cancel()` aborted a recurring
57
+ * schedule's run and silently did nothing for a one-shot's.
58
+ */
59
+ runningByScheduleId = new Map();
49
60
  /** Executions in flight per job id, for the per-job concurrency ceiling. */
50
61
  runningByJob = new Map();
51
62
  /** Records with `pendingRuns > 0`, drained as executions finish. */
@@ -76,6 +87,7 @@ export class Scheduler {
76
87
  this.queue = opts.queue ?? new PriorityQueue();
77
88
  this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
78
89
  this.onError = opts.onError;
90
+ this.keepAlive = opts.keepAlive ?? true;
79
91
  }
80
92
  /** Whether the scheduler is currently running. */
81
93
  get isRunning() {
@@ -98,12 +110,16 @@ export class Scheduler {
98
110
  /**
99
111
  * Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
100
112
  *
113
+ * Idempotent: on a scheduler that never started, or one already stopped, it
114
+ * only waits for any executions still settling.
115
+ *
101
116
  * @param options - `drain` waits for running jobs to finish instead of
102
117
  * aborting them; `timeoutMs` bounds the wait either way.
103
118
  */
104
119
  async stop(options) {
105
120
  if (!this.running) {
106
- throw new SchedulerStoppedError();
121
+ await this.settle(options?.timeoutMs);
122
+ return;
107
123
  }
108
124
  this.running = false;
109
125
  if (this.timer) {
@@ -260,6 +276,7 @@ export class Scheduler {
260
276
  const record = this.schedules.get(scheduleId);
261
277
  if (!record)
262
278
  return;
279
+ const previous = record.schedule.state;
263
280
  record.schedule = { ...record.schedule, state };
264
281
  if (state === "cancelled" || state === "completed") {
265
282
  // Held-back runs belong to a schedule that no longer exists.
@@ -269,6 +286,11 @@ export class Scheduler {
269
286
  this.queue.remove(scheduleId);
270
287
  }
271
288
  else if (state === "active") {
289
+ // Only a paused schedule resumes. `resume()` on a running one used to
290
+ // recompute its next fire time from now, so a supervisor calling it
291
+ // idempotently postponed the schedule indefinitely.
292
+ if (previous !== "paused")
293
+ return;
272
294
  // Resuming: recompute from now so a schedule paused across its fire time
273
295
  // does not immediately fire for every occurrence it missed.
274
296
  const now = this.clock.now();
@@ -319,12 +341,18 @@ export class Scheduler {
319
341
  this.retiredStates.delete(oldest);
320
342
  }
321
343
  }
322
- /** Aborts every in-flight execution of one schedule. */
344
+ /**
345
+ * Aborts every in-flight execution of one schedule.
346
+ *
347
+ * Resolved through {@link runningByScheduleId} rather than the schedule
348
+ * map: a one-shot is retired as soon as it is dispatched, so its record is
349
+ * already gone while its execution is still running.
350
+ */
323
351
  abortSchedule(scheduleId) {
324
- const record = this.schedules.get(scheduleId);
325
- if (!record)
352
+ const controllers = this.runningByScheduleId.get(scheduleId);
353
+ if (!controllers)
326
354
  return;
327
- for (const controller of record.running) {
355
+ for (const controller of [...controllers]) {
328
356
  controller.abort(new Error("Schedule cancelled"));
329
357
  }
330
358
  }
@@ -353,7 +381,11 @@ export class Scheduler {
353
381
  break;
354
382
  }
355
383
  this.dispatch(record);
356
- this.reschedule(record);
384
+ // `dispatch` retires the schedule when its job is no longer
385
+ // registered; rescheduling a retired record would put a dead entry
386
+ // back on the queue.
387
+ if (this.schedules.has(record.schedule.id))
388
+ this.reschedule(record);
357
389
  }
358
390
  this.rearm();
359
391
  }
@@ -365,8 +397,11 @@ export class Scheduler {
365
397
  clearTimeout(this.timer);
366
398
  const delay = this.calculateDelay();
367
399
  this.timer = setTimeout(() => this.tick(), delay);
368
- if (this.timer.unref)
369
- this.timer.unref();
400
+ // Referenced by default: a started scheduler is the process's reason to
401
+ // stay alive until stop(). An unreferenced timer let a script that only
402
+ // ran a scheduler exit 0 before anything fired.
403
+ if (!this.keepAlive)
404
+ this.timer.unref?.();
370
405
  }
371
406
  /**
372
407
  * Computes the next fire time and puts the schedule back on the queue.
@@ -404,8 +439,20 @@ export class Scheduler {
404
439
  /** Starts one execution of a schedule and tracks it. */
405
440
  dispatch(record) {
406
441
  const job = this.jobs.get(record.schedule.jobId);
407
- if (!job)
442
+ if (!job) {
443
+ // The job was unregistered under a live schedule. Returning silently
444
+ // left the schedule re-arming its timer forever, dispatching nothing
445
+ // and reporting nothing, with `handle.state` still "active".
446
+ const error = new SchedulerJobNotFoundError(record.schedule.jobId);
447
+ this.retire(record, "cancelled", { dropPending: true });
448
+ this.reportError({
449
+ scheduleId: record.schedule.id,
450
+ jobId: record.schedule.jobId,
451
+ executionId: "",
452
+ error,
453
+ });
408
454
  return;
455
+ }
409
456
  // A schedule cancelled or paused while a run was held back must not fire.
410
457
  if (record.schedule.state === "cancelled" ||
411
458
  record.schedule.state === "paused") {
@@ -442,6 +489,12 @@ export class Scheduler {
442
489
  const controller = new AbortController();
443
490
  record.running.add(controller);
444
491
  this.runningControllers.add(controller);
492
+ let byScheduleId = this.runningByScheduleId.get(record.schedule.id);
493
+ if (!byScheduleId) {
494
+ byScheduleId = new Set();
495
+ this.runningByScheduleId.set(record.schedule.id, byScheduleId);
496
+ }
497
+ byScheduleId.add(controller);
445
498
  this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
446
499
  const scheduleId = record.schedule.id;
447
500
  const scheduledAt = record.schedule.nextRunAt;
@@ -456,7 +509,7 @@ export class Scheduler {
456
509
  attempt: 1,
457
510
  });
458
511
  const execution = this.executor
459
- .execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data)
512
+ .execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data, { onAttempt: (attempt) => this.recordAttempt(executionId, attempt) })
460
513
  .then(() => {
461
514
  this.finishExecution(executionId, "completed", startedAt);
462
515
  })
@@ -475,6 +528,12 @@ export class Scheduler {
475
528
  .finally(() => {
476
529
  record.running.delete(controller);
477
530
  this.runningControllers.delete(controller);
531
+ const live = this.runningByScheduleId.get(scheduleId);
532
+ if (live) {
533
+ live.delete(controller);
534
+ if (live.size === 0)
535
+ this.runningByScheduleId.delete(scheduleId);
536
+ }
478
537
  this.inFlight.delete(execution);
479
538
  const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
480
539
  if (remaining <= 0)
@@ -526,6 +585,16 @@ export class Scheduler {
526
585
  this.executionHistory.shift();
527
586
  }
528
587
  }
588
+ /** Records the attempt an execution is on, if it is still in the history. */
589
+ recordAttempt(executionId, attempt) {
590
+ const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
591
+ if (index === -1)
592
+ return;
593
+ this.executionHistory[index] = {
594
+ ...this.executionHistory[index],
595
+ attempt,
596
+ };
597
+ }
529
598
  /** Completes the record for an execution, if it is still in the history. */
530
599
  finishExecution(executionId, status, startedAt, error) {
531
600
  const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
@@ -64,12 +64,12 @@ const MAX_SEARCH_YEARS = 5;
64
64
  export function parseCron(expression) {
65
65
  const trimmed = expression.trim().toLowerCase();
66
66
  if (trimmed.length === 0) {
67
- throw new CronParseError("Cron expression cannot be empty", expression);
67
+ throw new CronParseError(expression, "Cron expression cannot be empty");
68
68
  }
69
69
  const expanded = MACROS[trimmed] ?? trimmed;
70
70
  const fields = expanded.split(/\s+/);
71
71
  if (fields.length !== 5) {
72
- throw new CronParseError(`Cron expression must have 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}`, expression);
72
+ throw new CronParseError(expression, `Cron expression must have 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}`);
73
73
  }
74
74
  const sets = FIELD_BOUNDS.map((bounds, index) => parseField(fields[index] ?? "", bounds, expression));
75
75
  return {
@@ -85,17 +85,18 @@ export function parseCron(expression) {
85
85
  /** Parses a single cron field into the set of values it permits. */
86
86
  function parseField(field, bounds, expression) {
87
87
  if (field.length === 0) {
88
- throw new CronParseError(`Cron field "${bounds.name}" is empty`, expression);
88
+ throw new CronParseError(expression, `Cron field "${bounds.name}" is empty`);
89
89
  }
90
90
  const values = new Set();
91
+ const isDayOfWeek = bounds.name === "dayOfWeek";
91
92
  for (const part of field.split(",")) {
92
93
  const [rangePart, stepPart] = part.split("/");
93
94
  if (stepPart !== undefined && !/^\d+$/.test(stepPart)) {
94
- throw new CronParseError(`Cron field "${bounds.name}" has an invalid step: "${part}"`, expression);
95
+ throw new CronParseError(expression, `Cron field "${bounds.name}" has an invalid step: "${part}"`);
95
96
  }
96
97
  const step = stepPart === undefined ? 1 : Number(stepPart);
97
98
  if (step === 0) {
98
- throw new CronParseError(`Cron field "${bounds.name}" has a zero step: "${part}"`, expression);
99
+ throw new CronParseError(expression, `Cron field "${bounds.name}" has a zero step: "${part}"`);
99
100
  }
100
101
  let start;
101
102
  let end;
@@ -105,10 +106,18 @@ function parseField(field, bounds, expression) {
105
106
  }
106
107
  else if (rangePart.includes("-")) {
107
108
  const [from, to] = rangePart.split("-");
108
- start = resolveValue(from ?? "", bounds, expression);
109
- end = resolveValue(to ?? "", bounds, expression);
109
+ // Range endpoints keep their raw day-of-week spelling: normalising 7
110
+ // to 0 before the endpoints are compared turned "0-7" into Sunday
111
+ // alone and made "1-7" an inverted range.
112
+ start = resolveValue(from ?? "", bounds, expression, isDayOfWeek);
113
+ end = resolveValue(to ?? "", bounds, expression, isDayOfWeek);
114
+ // "mon-sun" names Monday through Sunday: the end is the week's last
115
+ // day, not its first. "sun-sat" is left alone — it already ascends.
116
+ if (isDayOfWeek && end === 0 && start > end) {
117
+ end = 7;
118
+ }
110
119
  if (start > end) {
111
- throw new CronParseError(`Cron field "${bounds.name}" has an inverted range: "${rangePart}"`, expression);
120
+ throw new CronParseError(expression, `Cron field "${bounds.name}" has an inverted range: "${rangePart}"`);
112
121
  }
113
122
  }
114
123
  else {
@@ -117,21 +126,30 @@ function parseField(field, bounds, expression) {
117
126
  end = stepPart === undefined ? start : bounds.max;
118
127
  }
119
128
  for (let v = start; v <= end; v += step) {
120
- values.add(v);
129
+ // Both 0 and 7 are Sunday; the set only ever holds 0.
130
+ values.add(isDayOfWeek && v === 7 ? 0 : v);
121
131
  }
122
132
  }
123
133
  if (values.size === 0) {
124
- throw new CronParseError(`Cron field "${bounds.name}" matches no values: "${field}"`, expression);
134
+ throw new CronParseError(expression, `Cron field "${bounds.name}" matches no values: "${field}"`);
125
135
  }
126
136
  return values;
127
137
  }
128
- /** Resolves a numeric or named field value, checking it against the bounds. */
129
- function resolveValue(raw, bounds, expression) {
138
+ /**
139
+ * Resolves a numeric or named field value, checking it against the bounds.
140
+ *
141
+ * @param raw - The literal from the expression.
142
+ * @param bounds - The field being parsed.
143
+ * @param expression - The whole expression, for error reporting.
144
+ * @param rawDayOfWeek - Keeps day-of-week `7` as 7 and accepts it as a bound,
145
+ * for a range endpoint that the caller expands and normalises itself.
146
+ */
147
+ function resolveValue(raw, bounds, expression, rawDayOfWeek = false) {
130
148
  let value;
131
149
  if (/^\d+$/.test(raw)) {
132
150
  value = Number(raw);
133
151
  // Both 0 and 7 are Sunday in common cron dialects.
134
- if (bounds.name === "dayOfWeek" && value === 7) {
152
+ if (bounds.name === "dayOfWeek" && value === 7 && !rawDayOfWeek) {
135
153
  value = 0;
136
154
  }
137
155
  }
@@ -142,10 +160,11 @@ function resolveValue(raw, bounds, expression) {
142
160
  value = DAY_NAMES[raw];
143
161
  }
144
162
  else {
145
- throw new CronParseError(`Cron field "${bounds.name}" has an invalid value: "${raw}"`, expression);
163
+ throw new CronParseError(expression, `Cron field "${bounds.name}" has an invalid value: "${raw}"`);
146
164
  }
147
- if (value < bounds.min || value > bounds.max) {
148
- throw new CronParseError(`Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}`, expression);
165
+ const max = rawDayOfWeek && bounds.name === "dayOfWeek" ? 7 : bounds.max;
166
+ if (value < bounds.min || value > max) {
167
+ throw new CronParseError(expression, `Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}`);
149
168
  }
150
169
  return value;
151
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/scheduler",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Scheduled task and job infrastructure with cron-like scheduling, persistence, and worker management.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,13 +27,13 @@
27
27
  "node": ">=24.0.0"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.1.0",
31
- "@zudojs/constants": "1.1.0",
32
- "@zudojs/types": "1.1.0"
30
+ "@zudojs/errors": "1.3.0",
31
+ "@zudojs/constants": "1.1.2",
32
+ "@zudojs/types": "1.2.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "7.0.2",
36
- "vitest": "^4.1.11"
36
+ "vitest": "^5.0.1"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
@@ -44,7 +44,7 @@
44
44
  "cron",
45
45
  "jobs"
46
46
  ],
47
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
47
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-scheduler",
48
48
  "bugs": {
49
49
  "url": "https://github.com/oyinlola-tech/zudo/issues"
50
50
  },