@basaltkit/scheduler 1.2.0 → 1.3.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
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/scheduler
2
8
 
3
9
  Task scheduler for Basalt applications: define, with a fluent, readable API (`daily().at('03:00')`), tasks that run automatically at set times — backups, reports, cleanups, billing.
@@ -251,6 +257,15 @@ Exported for tooling and tests; you don't usually need them:
251
257
 
252
258
  - `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
253
259
 
260
+ ## Multiple replicas — `.onOneServer()`
261
+
262
+ `withoutOverlapping()` guards one process only. On N replicas, mark an entry
263
+ `.onOneServer()` and pass `schedulerPlugin({ lock })` an atomic cross-replica
264
+ lock (`ScheduleLock`: `acquire(key, ttlMs)` — e.g. Redis `SET key v PX ttl NX`).
265
+ Exactly one replica runs the entry per tick; using `.onOneServer()` without a
266
+ `lock` fails loud at boot. Cron expressions are validated at definition time
267
+ (`CronParseError` on unsupported syntax like `MON` or out-of-range values).
268
+
254
269
  ## Common errors and solutions (FAQ)
255
270
 
256
271
  **My `daily().at('03:00')` task runs at the wrong time.**
package/dist/cron.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { BasaltError } from '@basaltkit/core';
2
+ export declare class CronParseError extends BasaltError {
3
+ constructor(expression: string, detail: string);
4
+ }
5
+ export interface CronFields {
6
+ minute: string;
7
+ hour: string;
8
+ dayOfMonth: string;
9
+ month: string;
10
+ dayOfWeek: string;
11
+ }
12
+ export declare function parseCron(expression: string): CronFields;
13
+ export declare function cronToString(fields: CronFields): string;
14
+ /** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
15
+ export declare function fieldMatches(field: string, value: number): boolean;
16
+ export interface ZonedParts {
17
+ minute: number;
18
+ hour: number;
19
+ dayOfMonth: number;
20
+ month: number;
21
+ dayOfWeek: number;
22
+ }
23
+ /** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
24
+ export declare function zonedParts(date: Date, timeZone?: string): ZonedParts;
25
+ export declare function cronMatches(fields: CronFields, date: Date, timeZone?: string): boolean;
package/dist/cron.js ADDED
@@ -0,0 +1,118 @@
1
+ import { BasaltError } from '@basaltkit/core';
2
+ export class CronParseError extends BasaltError {
3
+ constructor(expression, detail) {
4
+ super('CRON_INVALID', `Invalid cron expression "${expression}": ${detail}`);
5
+ }
6
+ }
7
+ const FIELD_BOUNDS = [
8
+ ['minute', 0, 59],
9
+ ['hour', 0, 23],
10
+ ['day-of-month', 1, 31],
11
+ ['month', 1, 12],
12
+ ['day-of-week', 0, 6],
13
+ ];
14
+ /**
15
+ * Validates one cron field against the syntax {@link fieldMatches} actually
16
+ * supports: asterisk, asterisk-slash-n steps, single values, `a-b` ranges and comma lists.
17
+ * Anything else (names like MON, out-of-range values, `5-1`) previously became
18
+ * NaN comparisons — a job that silently NEVER fires. Fail at parse time instead.
19
+ */
20
+ function assertField(expression, field, name, min, max) {
21
+ const invalid = (detail) => {
22
+ throw new CronParseError(expression, `${name} field "${field}": ${detail}`);
23
+ };
24
+ if (field === '*')
25
+ return;
26
+ for (const part of field.split(',')) {
27
+ const step = /^\*\/(\d+)$/.exec(part);
28
+ if (step) {
29
+ if (Number(step[1]) < 1)
30
+ invalid('step must be >= 1');
31
+ continue;
32
+ }
33
+ const range = /^(\d+)-(\d+)$/.exec(part);
34
+ if (range) {
35
+ const [from, to] = [Number(range[1]), Number(range[2])];
36
+ if (from > to)
37
+ invalid(`range ${from}-${to} is reversed`);
38
+ if (from < min || to > max)
39
+ invalid(`range ${from}-${to} outside ${min}-${max}`);
40
+ continue;
41
+ }
42
+ if (!/^\d+$/.test(part))
43
+ invalid(`"${part}" is not supported (use *, */n, n, a-b or comma lists; names like MON are not)`);
44
+ const value = Number(part);
45
+ if (value < min || value > max)
46
+ invalid(`${value} outside ${min}-${max}`);
47
+ }
48
+ }
49
+ export function parseCron(expression) {
50
+ const parts = expression.trim().split(/\s+/);
51
+ if (parts.length !== 5) {
52
+ throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
53
+ }
54
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
55
+ parts.forEach((field, i) => {
56
+ const [name, min, max] = FIELD_BOUNDS[i];
57
+ assertField(expression, field, name, min, max);
58
+ });
59
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
60
+ }
61
+ export function cronToString(fields) {
62
+ return [fields.minute, fields.hour, fields.dayOfMonth, fields.month, fields.dayOfWeek].join(' ');
63
+ }
64
+ /** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
65
+ export function fieldMatches(field, value) {
66
+ if (field === '*')
67
+ return true;
68
+ return field.split(',').some((part) => {
69
+ const step = /^\*\/(\d+)$/.exec(part);
70
+ if (step)
71
+ return value % Number(step[1]) === 0;
72
+ const range = /^(\d+)-(\d+)$/.exec(part);
73
+ if (range)
74
+ return value >= Number(range[1]) && value <= Number(range[2]);
75
+ return Number(part) === value;
76
+ });
77
+ }
78
+ const WEEKDAYS = {
79
+ Sun: 0,
80
+ Mon: 1,
81
+ Tue: 2,
82
+ Wed: 3,
83
+ Thu: 4,
84
+ Fri: 5,
85
+ Sat: 6,
86
+ };
87
+ /** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
88
+ export function zonedParts(date, timeZone = 'UTC') {
89
+ const formatter = new Intl.DateTimeFormat('en-US', {
90
+ timeZone,
91
+ minute: 'numeric',
92
+ hour: 'numeric',
93
+ day: 'numeric',
94
+ month: 'numeric',
95
+ weekday: 'short',
96
+ hour12: false,
97
+ });
98
+ const parts = {};
99
+ for (const part of formatter.formatToParts(date)) {
100
+ parts[part.type] = part.value;
101
+ }
102
+ return {
103
+ minute: Number(parts['minute']),
104
+ // Intl with hour12:false may emit 24 for midnight
105
+ hour: Number(parts['hour']) % 24,
106
+ dayOfMonth: Number(parts['day']),
107
+ month: Number(parts['month']),
108
+ dayOfWeek: WEEKDAYS[parts['weekday']],
109
+ };
110
+ }
111
+ export function cronMatches(fields, date, timeZone) {
112
+ const parts = zonedParts(date, timeZone);
113
+ return (fieldMatches(fields.minute, parts.minute) &&
114
+ fieldMatches(fields.hour, parts.hour) &&
115
+ fieldMatches(fields.dayOfMonth, parts.dayOfMonth) &&
116
+ fieldMatches(fields.month, parts.month) &&
117
+ fieldMatches(fields.dayOfWeek, parts.dayOfWeek));
118
+ }
package/dist/index.d.ts CHANGED
@@ -1,44 +1,38 @@
1
- import * as _basaltkit_core from '@basaltkit/core';
2
- import { BasaltError } from '@basaltkit/core';
3
- import { JobDefinition } from '@basaltkit/queue';
4
-
5
- declare class CronParseError extends BasaltError {
6
- constructor(expression: string, detail: string);
7
- }
8
- interface CronFields {
9
- minute: string;
10
- hour: string;
11
- dayOfMonth: string;
12
- month: string;
13
- dayOfWeek: string;
14
- }
15
- declare function parseCron(expression: string): CronFields;
16
- /** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
17
- declare function fieldMatches(field: string, value: number): boolean;
18
- interface ZonedParts {
19
- minute: number;
20
- hour: number;
21
- dayOfMonth: number;
22
- month: number;
23
- dayOfWeek: number;
24
- }
25
- /** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
26
- declare function zonedParts(date: Date, timeZone?: string): ZonedParts;
27
- declare function cronMatches(fields: CronFields, date: Date, timeZone?: string): boolean;
28
-
1
+ import type { JobDefinition } from '@basaltkit/queue';
2
+ export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
3
+ export type { CronFields, ZonedParts } from './cron.js';
29
4
  type Task = () => void | Promise<void>;
5
+ /**
6
+ * Cross-replica mutex for `.onOneServer()` entries. `acquire` must be ATOMIC
7
+ * across processes (e.g. Redis `SET key value PX ttl NX`): it returns true for
8
+ * exactly one caller per key until the TTL expires. There is deliberately no
9
+ * `release` — the key covers the tick window, so a fast first run cannot be
10
+ * followed by a late replica re-acquiring and running the same minute again.
11
+ *
12
+ * ioredis example:
13
+ *
14
+ * const lock: ScheduleLock = {
15
+ * async acquire(key, ttlMs) {
16
+ * return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
17
+ * },
18
+ * }
19
+ */
20
+ export interface ScheduleLock {
21
+ acquire(key: string, ttlMs: number): Promise<boolean>;
22
+ }
30
23
  /**
31
24
  * A scheduled entry, built fluently:
32
25
  *
33
26
  * schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
34
27
  * schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
35
28
  */
36
- declare class ScheduleEntry {
29
+ export declare class ScheduleEntry {
37
30
  readonly name: string;
38
31
  private readonly task;
39
32
  private fields;
40
33
  private tz;
41
34
  private noOverlap;
35
+ private oneServer;
42
36
  private failureHandler;
43
37
  private running;
44
38
  /** count of executions skipped due to overlap — visible for observability/tests */
@@ -64,6 +58,16 @@ declare class ScheduleEntry {
64
58
  timezone(tz: string): this;
65
59
  /** If the previous execution is still running, the new one is skipped. */
66
60
  withoutOverlapping(): this;
61
+ /**
62
+ * On a horizontally-scaled deployment, run this entry on ONE replica per tick
63
+ * instead of on every pod. Requires a `lock` on the Scheduler (see
64
+ * {@link ScheduleLock}) — without one, boot fails loud rather than silently
65
+ * running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
66
+ * trigger is deliberate).
67
+ */
68
+ onOneServer(): this;
69
+ /** @internal whether this entry asked for cross-replica locking. */
70
+ get wantsOneServer(): boolean;
67
71
  onFailure(handler: (error: unknown) => void): this;
68
72
  /** Entry description — consumed by `basalt schedule list`. */
69
73
  describe(): {
@@ -76,10 +80,28 @@ declare class ScheduleEntry {
76
80
  run(): Promise<void>;
77
81
  private onDayOfWeek;
78
82
  }
79
- declare class Scheduler {
83
+ export interface SchedulerOptions {
84
+ /** Cross-replica lock for `.onOneServer()` entries. */
85
+ lock?: ScheduleLock;
86
+ /**
87
+ * TTL for each per-entry, per-tick lock key. Default 60_000 (one tick window
88
+ * — the key embeds the minute, so it only needs to outlive clock skew).
89
+ */
90
+ lockTtlMs?: number;
91
+ }
92
+ export declare class Scheduler {
80
93
  private readonly entries;
81
94
  private timer;
82
95
  private interval;
96
+ private readonly lock;
97
+ private readonly lockTtlMs;
98
+ /** ticks skipped because another replica held the lock — for observability/tests */
99
+ skippedByLock: number;
100
+ constructor(options?: SchedulerOptions);
101
+ /** @internal true when any entry requested `.onOneServer()`. */
102
+ get needsLock(): boolean;
103
+ /** @internal whether a lock was configured. */
104
+ get hasLock(): boolean;
83
105
  /** Schedules the dispatch of a @basaltkit/queue job. */
84
106
  job<T>(job: JobDefinition<T>, ...payload: T extends void ? [] : [T]): ScheduleEntry;
85
107
  /** Schedules a named function. */
@@ -109,13 +131,11 @@ declare class Scheduler {
109
131
  private safeTick;
110
132
  private add;
111
133
  }
112
- declare const SCHEDULER: _basaltkit_core.Token<Scheduler>;
113
- interface SchedulerPluginOptions {
134
+ export declare const SCHEDULER: import("@basaltkit/core").Token<Scheduler>;
135
+ export interface SchedulerPluginOptions extends SchedulerOptions {
114
136
  /** Callback that defines the schedules — receives the Scheduler at boot. */
115
137
  define?: (schedule: Scheduler) => void;
116
138
  /** Starts the timer at boot. Default: true (turn off in tests). */
117
139
  autostart?: boolean;
118
140
  }
119
- declare function schedulerPlugin(options?: SchedulerPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
120
-
121
- export { type CronFields, CronParseError, SCHEDULER, ScheduleEntry, Scheduler, type SchedulerPluginOptions, type ZonedParts, cronMatches, fieldMatches, parseCron, schedulerPlugin, zonedParts };
141
+ export declare function schedulerPlugin(options?: SchedulerPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
package/dist/index.js CHANGED
@@ -1,332 +1,314 @@
1
- // src/index.ts
2
- import { createToken, definePlugin, ensureMetadata } from "@basaltkit/core";
3
-
4
- // src/cron.ts
5
- import { BasaltError } from "@basaltkit/core";
6
- var CronParseError = class extends BasaltError {
7
- constructor(expression, detail) {
8
- super("CRON_INVALID", `Invalid cron expression "${expression}": ${detail}`);
9
- }
10
- };
11
- function parseCron(expression) {
12
- const parts = expression.trim().split(/\s+/);
13
- if (parts.length !== 5) {
14
- throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
15
- }
16
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
17
- return { minute, hour, dayOfMonth, month, dayOfWeek };
18
- }
19
- function cronToString(fields) {
20
- return [fields.minute, fields.hour, fields.dayOfMonth, fields.month, fields.dayOfWeek].join(" ");
21
- }
22
- function fieldMatches(field, value) {
23
- if (field === "*") return true;
24
- return field.split(",").some((part) => {
25
- const step = /^\*\/(\d+)$/.exec(part);
26
- if (step) return value % Number(step[1]) === 0;
27
- const range = /^(\d+)-(\d+)$/.exec(part);
28
- if (range) return value >= Number(range[1]) && value <= Number(range[2]);
29
- return Number(part) === value;
30
- });
31
- }
32
- var WEEKDAYS = {
33
- Sun: 0,
34
- Mon: 1,
35
- Tue: 2,
36
- Wed: 3,
37
- Thu: 4,
38
- Fri: 5,
39
- Sat: 6
40
- };
41
- function zonedParts(date, timeZone = "UTC") {
42
- const formatter = new Intl.DateTimeFormat("en-US", {
43
- timeZone,
44
- minute: "numeric",
45
- hour: "numeric",
46
- day: "numeric",
47
- month: "numeric",
48
- weekday: "short",
49
- hour12: false
50
- });
51
- const parts = {};
52
- for (const part of formatter.formatToParts(date)) {
53
- parts[part.type] = part.value;
54
- }
55
- return {
56
- minute: Number(parts["minute"]),
57
- // Intl with hour12:false may emit 24 for midnight
58
- hour: Number(parts["hour"]) % 24,
59
- dayOfMonth: Number(parts["day"]),
60
- month: Number(parts["month"]),
61
- dayOfWeek: WEEKDAYS[parts["weekday"]]
62
- };
63
- }
64
- function cronMatches(fields, date, timeZone) {
65
- const parts = zonedParts(date, timeZone);
66
- return fieldMatches(fields.minute, parts.minute) && fieldMatches(fields.hour, parts.hour) && fieldMatches(fields.dayOfMonth, parts.dayOfMonth) && fieldMatches(fields.month, parts.month) && fieldMatches(fields.dayOfWeek, parts.dayOfWeek);
67
- }
68
-
69
- // src/index.ts
70
- var ScheduleEntry = class {
71
- constructor(name, task) {
72
- this.name = name;
73
- this.task = task;
74
- }
75
- name;
76
- task;
77
- fields = {
78
- minute: "*",
79
- hour: "*",
80
- dayOfMonth: "*",
81
- month: "*",
82
- dayOfWeek: "*"
83
- };
84
- tz = "UTC";
85
- noOverlap = false;
86
- failureHandler;
87
- running = false;
88
- /** count of executions skipped due to overlap — visible for observability/tests */
89
- skippedOverlaps = 0;
90
- everyMinute() {
91
- this.fields = { minute: "*", hour: "*", dayOfMonth: "*", month: "*", dayOfWeek: "*" };
92
- return this;
93
- }
94
- everyMinutes(n) {
95
- this.everyMinute();
96
- this.fields.minute = `*/${n}`;
97
- return this;
98
- }
99
- hourly() {
100
- this.everyMinute();
101
- this.fields.minute = "0";
102
- return this;
103
- }
104
- daily() {
105
- this.hourly();
106
- this.fields.hour = "0";
107
- return this;
108
- }
109
- weekly() {
110
- this.daily();
111
- this.fields.dayOfWeek = "0";
112
- return this;
113
- }
114
- monthly() {
115
- this.daily();
116
- this.fields.dayOfMonth = "1";
117
- return this;
118
- }
119
- /** 'HH:mm' time — combines with daily/weekly/monthly. */
120
- at(time) {
121
- const [hour, minute] = time.split(":");
122
- this.fields.hour = String(Number(hour));
123
- this.fields.minute = String(Number(minute ?? 0));
124
- return this;
125
- }
126
- /** Raw cron expression (5 fields) — escape hatch. */
127
- cron(expression) {
128
- this.fields = parseCron(expression);
129
- return this;
130
- }
131
- sundays() {
132
- return this.onDayOfWeek(0);
133
- }
134
- mondays() {
135
- return this.onDayOfWeek(1);
136
- }
137
- tuesdays() {
138
- return this.onDayOfWeek(2);
139
- }
140
- wednesdays() {
141
- return this.onDayOfWeek(3);
142
- }
143
- thursdays() {
144
- return this.onDayOfWeek(4);
145
- }
146
- fridays() {
147
- return this.onDayOfWeek(5);
148
- }
149
- saturdays() {
150
- return this.onDayOfWeek(6);
151
- }
152
- timezone(tz) {
153
- this.tz = tz;
154
- return this;
155
- }
156
- /** If the previous execution is still running, the new one is skipped. */
157
- withoutOverlapping() {
158
- this.noOverlap = true;
159
- return this;
160
- }
161
- onFailure(handler) {
162
- this.failureHandler = handler;
163
- return this;
164
- }
165
- /** Entry description — consumed by `basalt schedule list`. */
166
- describe() {
167
- return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
168
- }
169
- isDue(date) {
170
- return cronMatches(this.fields, date, this.tz);
171
- }
172
- /** @internal runs the task with the overlap guard and failure handling. */
173
- async run() {
174
- if (this.noOverlap && this.running) {
175
- this.skippedOverlaps++;
176
- return;
1
+ import { createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
2
+ import { cronMatches, cronToString, parseCron } from './cron.js';
3
+ export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
4
+ /**
5
+ * A scheduled entry, built fluently:
6
+ *
7
+ * schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
8
+ * schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
9
+ */
10
+ export class ScheduleEntry {
11
+ name;
12
+ task;
13
+ fields = {
14
+ minute: '*',
15
+ hour: '*',
16
+ dayOfMonth: '*',
17
+ month: '*',
18
+ dayOfWeek: '*',
19
+ };
20
+ tz = 'UTC';
21
+ noOverlap = false;
22
+ oneServer = false;
23
+ failureHandler;
24
+ running = false;
25
+ /** count of executions skipped due to overlap — visible for observability/tests */
26
+ skippedOverlaps = 0;
27
+ constructor(name, task) {
28
+ this.name = name;
29
+ this.task = task;
30
+ }
31
+ everyMinute() {
32
+ this.fields = { minute: '*', hour: '*', dayOfMonth: '*', month: '*', dayOfWeek: '*' };
33
+ return this;
34
+ }
35
+ everyMinutes(n) {
36
+ this.everyMinute();
37
+ this.fields.minute = `*/${n}`;
38
+ return this;
39
+ }
40
+ hourly() {
41
+ this.everyMinute();
42
+ this.fields.minute = '0';
43
+ return this;
44
+ }
45
+ daily() {
46
+ this.hourly();
47
+ this.fields.hour = '0';
48
+ return this;
49
+ }
50
+ weekly() {
51
+ this.daily();
52
+ this.fields.dayOfWeek = '0';
53
+ return this;
54
+ }
55
+ monthly() {
56
+ this.daily();
57
+ this.fields.dayOfMonth = '1';
58
+ return this;
177
59
  }
178
- this.running = true;
179
- try {
180
- await this.task();
181
- } catch (error) {
182
- if (!this.failureHandler) throw error;
183
- this.failureHandler(error);
184
- } finally {
185
- this.running = false;
60
+ /** 'HH:mm' time — combines with daily/weekly/monthly. */
61
+ at(time) {
62
+ const [hour, minute] = time.split(':');
63
+ this.fields.hour = String(Number(hour));
64
+ this.fields.minute = String(Number(minute ?? 0));
65
+ return this;
186
66
  }
187
- }
188
- onDayOfWeek(day) {
189
- this.fields.dayOfWeek = String(day);
190
- return this;
191
- }
192
- };
193
- var Scheduler = class {
194
- entries = [];
195
- timer;
196
- interval;
197
- /** Schedules the dispatch of a @basaltkit/queue job. */
198
- job(job, ...payload) {
199
- return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
200
- }
201
- /** Schedules a named function. */
202
- call(name, task) {
203
- return this.add(new ScheduleEntry(name, task));
204
- }
205
- list() {
206
- return this.entries.map((entry) => entry.describe());
207
- }
208
- /** Names of every scheduled entry — for CLI validation/listing. */
209
- names() {
210
- return this.entries.map((entry) => entry.name);
211
- }
212
- /**
213
- * Runs a single entry by name on demand, ignoring its cron (for `schedule:run`
214
- * and manual triggers). Returns false if no entry has that name. The entry's
215
- * own overlap guard and failure handler still apply.
216
- */
217
- async runNow(name) {
218
- const entry = this.entries.find((candidate) => candidate.name === name);
219
- if (!entry) return false;
220
- await entry.run();
221
- return true;
222
- }
223
- /**
224
- * Runs the entries due at the given instant. Deterministic — this is what
225
- * the tests call directly and what the timer calls every minute.
226
- * Failures (without onFailure) are aggregated; all due entries run.
227
- */
228
- async tick(date = /* @__PURE__ */ new Date()) {
229
- const due = this.entries.filter((entry) => entry.isDue(date));
230
- const errors = [];
231
- await Promise.all(
232
- due.map(async (entry) => {
67
+ /** Raw cron expression (5 fields) — escape hatch. */
68
+ cron(expression) {
69
+ this.fields = parseCron(expression);
70
+ return this;
71
+ }
72
+ sundays() { return this.onDayOfWeek(0); }
73
+ mondays() { return this.onDayOfWeek(1); }
74
+ tuesdays() { return this.onDayOfWeek(2); }
75
+ wednesdays() { return this.onDayOfWeek(3); }
76
+ thursdays() { return this.onDayOfWeek(4); }
77
+ fridays() { return this.onDayOfWeek(5); }
78
+ saturdays() { return this.onDayOfWeek(6); }
79
+ timezone(tz) {
80
+ this.tz = tz;
81
+ return this;
82
+ }
83
+ /** If the previous execution is still running, the new one is skipped. */
84
+ withoutOverlapping() {
85
+ this.noOverlap = true;
86
+ return this;
87
+ }
88
+ /**
89
+ * On a horizontally-scaled deployment, run this entry on ONE replica per tick
90
+ * instead of on every pod. Requires a `lock` on the Scheduler (see
91
+ * {@link ScheduleLock}) — without one, boot fails loud rather than silently
92
+ * running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
93
+ * trigger is deliberate).
94
+ */
95
+ onOneServer() {
96
+ this.oneServer = true;
97
+ return this;
98
+ }
99
+ /** @internal whether this entry asked for cross-replica locking. */
100
+ get wantsOneServer() {
101
+ return this.oneServer;
102
+ }
103
+ onFailure(handler) {
104
+ this.failureHandler = handler;
105
+ return this;
106
+ }
107
+ /** Entry description — consumed by `basalt schedule list`. */
108
+ describe() {
109
+ return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
110
+ }
111
+ isDue(date) {
112
+ return cronMatches(this.fields, date, this.tz);
113
+ }
114
+ /** @internal runs the task with the overlap guard and failure handling. */
115
+ async run() {
116
+ if (this.noOverlap && this.running) {
117
+ this.skippedOverlaps++;
118
+ return;
119
+ }
120
+ this.running = true;
233
121
  try {
234
- await entry.run();
235
- } catch (error) {
236
- errors.push(error);
122
+ await this.task();
123
+ }
124
+ catch (error) {
125
+ if (!this.failureHandler)
126
+ throw error;
127
+ this.failureHandler(error);
237
128
  }
238
- })
239
- );
240
- if (errors.length > 0) {
241
- throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
129
+ finally {
130
+ this.running = false;
131
+ }
132
+ }
133
+ onDayOfWeek(day) {
134
+ this.fields.dayOfWeek = String(day);
135
+ return this;
136
+ }
137
+ }
138
+ export class Scheduler {
139
+ entries = [];
140
+ timer;
141
+ interval;
142
+ lock;
143
+ lockTtlMs;
144
+ /** ticks skipped because another replica held the lock — for observability/tests */
145
+ skippedByLock = 0;
146
+ constructor(options = {}) {
147
+ this.lock = options.lock;
148
+ this.lockTtlMs = options.lockTtlMs ?? 60_000;
149
+ }
150
+ /** @internal true when any entry requested `.onOneServer()`. */
151
+ get needsLock() {
152
+ return this.entries.some((entry) => entry.wantsOneServer);
153
+ }
154
+ /** @internal whether a lock was configured. */
155
+ get hasLock() {
156
+ return this.lock !== undefined;
157
+ }
158
+ /** Schedules the dispatch of a @basaltkit/queue job. */
159
+ job(job, ...payload) {
160
+ return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
161
+ }
162
+ /** Schedules a named function. */
163
+ call(name, task) {
164
+ return this.add(new ScheduleEntry(name, task));
165
+ }
166
+ list() {
167
+ return this.entries.map((entry) => entry.describe());
168
+ }
169
+ /** Names of every scheduled entry — for CLI validation/listing. */
170
+ names() {
171
+ return this.entries.map((entry) => entry.name);
242
172
  }
243
- }
244
- /** Aligns to the next minute and then runs tick() every 60s. */
245
- start() {
246
- if (this.timer || this.interval) return;
247
- const msToNextMinute = 6e4 - Date.now() % 6e4;
248
- this.timer = setTimeout(() => {
249
- void this.safeTick();
250
- this.interval = setInterval(() => void this.safeTick(), 6e4);
251
- this.interval.unref?.();
252
- }, msToNextMinute);
253
- this.timer.unref?.();
254
- }
255
- stop() {
256
- if (this.timer) clearTimeout(this.timer);
257
- if (this.interval) clearInterval(this.interval);
258
- this.timer = void 0;
259
- this.interval = void 0;
260
- }
261
- async safeTick() {
262
- try {
263
- await this.tick();
264
- } catch {
173
+ /**
174
+ * Runs a single entry by name on demand, ignoring its cron (for `schedule:run`
175
+ * and manual triggers). Returns false if no entry has that name. The entry's
176
+ * own overlap guard and failure handler still apply.
177
+ */
178
+ async runNow(name) {
179
+ const entry = this.entries.find((candidate) => candidate.name === name);
180
+ if (!entry)
181
+ return false;
182
+ await entry.run();
183
+ return true;
265
184
  }
266
- }
267
- add(entry) {
268
- this.entries.push(entry);
269
- return entry;
270
- }
271
- };
272
- var SCHEDULER = createToken("scheduler");
273
- function schedulerPlugin(options = {}) {
274
- return definePlugin({
275
- name: "basalt:scheduler",
276
- register({ container }) {
277
- container.singleton(SCHEDULER, () => new Scheduler());
278
- registerScheduleRunCommand(container);
279
- },
280
- boot({ container }) {
281
- const scheduler = container.get(SCHEDULER);
282
- options.define?.(scheduler);
283
- const metadata = ensureMetadata(container);
284
- for (const entry of scheduler.list()) metadata.add("schedule:entries", entry);
285
- if (options.autostart !== false) scheduler.start();
286
- },
287
- shutdown({ container }) {
288
- container.get(SCHEDULER).stop();
185
+ /**
186
+ * Runs the entries due at the given instant. Deterministic — this is what
187
+ * the tests call directly and what the timer calls every minute.
188
+ * Failures (without onFailure) are aggregated; all due entries run.
189
+ */
190
+ async tick(date = new Date()) {
191
+ const due = this.entries.filter((entry) => entry.isDue(date));
192
+ const errors = [];
193
+ await Promise.all(due.map(async (entry) => {
194
+ try {
195
+ if (entry.wantsOneServer && this.lock) {
196
+ // One key per entry per tick window: exactly one replica acquires
197
+ // it; the others skip this minute's run. A lock-store failure is
198
+ // treated as a task failure (visible), not as permission to run on
199
+ // every replica at once.
200
+ const minute = new Date(date);
201
+ minute.setSeconds(0, 0);
202
+ const key = `basalt:schedule:${entry.name}:${minute.toISOString()}`;
203
+ if (!(await this.lock.acquire(key, this.lockTtlMs))) {
204
+ this.skippedByLock++;
205
+ return;
206
+ }
207
+ }
208
+ await entry.run();
209
+ }
210
+ catch (error) {
211
+ errors.push(error);
212
+ }
213
+ }));
214
+ if (errors.length > 0) {
215
+ throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
216
+ }
217
+ }
218
+ /** Aligns to the next minute and then runs tick() every 60s. */
219
+ start() {
220
+ if (this.timer || this.interval)
221
+ return;
222
+ const msToNextMinute = 60_000 - (Date.now() % 60_000);
223
+ this.timer = setTimeout(() => {
224
+ void this.safeTick();
225
+ this.interval = setInterval(() => void this.safeTick(), 60_000);
226
+ this.interval.unref?.();
227
+ }, msToNextMinute);
228
+ this.timer.unref?.();
229
+ }
230
+ stop() {
231
+ if (this.timer)
232
+ clearTimeout(this.timer);
233
+ if (this.interval)
234
+ clearInterval(this.interval);
235
+ this.timer = undefined;
236
+ this.interval = undefined;
237
+ }
238
+ async safeTick() {
239
+ try {
240
+ await this.tick();
241
+ }
242
+ catch {
243
+ // failures without onFailure were already aggregated; here we only avoid
244
+ // bringing down the process — each entry must handle its own failure
245
+ }
246
+ }
247
+ add(entry) {
248
+ this.entries.push(entry);
249
+ return entry;
289
250
  }
290
- });
291
251
  }
252
+ export const SCHEDULER = createToken('scheduler');
253
+ export function schedulerPlugin(options = {}) {
254
+ return definePlugin({
255
+ name: 'basalt:scheduler',
256
+ register({ container }) {
257
+ container.singleton(SCHEDULER, () => new Scheduler({
258
+ ...(options.lock ? { lock: options.lock } : {}),
259
+ ...(options.lockTtlMs !== undefined ? { lockTtlMs: options.lockTtlMs } : {}),
260
+ }));
261
+ registerScheduleRunCommand(container);
262
+ },
263
+ boot({ container }) {
264
+ const scheduler = container.get(SCHEDULER);
265
+ options.define?.(scheduler);
266
+ if (scheduler.needsLock && !scheduler.hasLock) {
267
+ // Fail closed at boot: silently running the entry on every replica is
268
+ // exactly the failure mode .onOneServer() exists to prevent.
269
+ throw new Error('schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured. ' +
270
+ 'Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.');
271
+ }
272
+ // Expose entries to tooling (CLI `basalt schedule:list`).
273
+ const metadata = ensureMetadata(container);
274
+ for (const entry of scheduler.list())
275
+ metadata.add('schedule:entries', entry);
276
+ if (options.autostart !== false)
277
+ scheduler.start();
278
+ },
279
+ shutdown({ container }) {
280
+ container.get(SCHEDULER).stop();
281
+ },
282
+ });
283
+ }
284
+ /**
285
+ * Registers `schedule:run` into the CLI command bucket. Runs a scheduled task on
286
+ * demand by name (ignoring its cron), or `--due` to run everything due right now.
287
+ * Registered structurally to avoid a hard @basaltkit/cli dependency.
288
+ */
292
289
  function registerScheduleRunCommand(container) {
293
- ensureMetadata(container).add("commands", {
294
- name: "schedule:run",
295
- description: "Run a scheduled task on demand (by name), or --due for all due now",
296
- async handle({
297
- io,
298
- args,
299
- flags
300
- }) {
301
- const scheduler = container.get(SCHEDULER);
302
- if (flags["due"] === true) {
303
- await scheduler.tick();
304
- io.log("Ran all due scheduled tasks.");
305
- return;
306
- }
307
- const name = args[0];
308
- if (!name) {
309
- io.error("Usage: basalt schedule:run <name> | --due");
310
- return 1;
311
- }
312
- const ran = await scheduler.runNow(name);
313
- if (!ran) {
314
- const available = scheduler.names().join(", ") || "(none)";
315
- io.error(`Unknown scheduled task "${name}". Available: ${available}.`);
316
- return 1;
317
- }
318
- io.log(`Ran scheduled task "${name}".`);
319
- }
320
- });
290
+ ensureMetadata(container).add('commands', {
291
+ name: 'schedule:run',
292
+ description: 'Run a scheduled task on demand (by name), or --due for all due now',
293
+ async handle({ io, args, flags, }) {
294
+ const scheduler = container.get(SCHEDULER);
295
+ if (flags['due'] === true) {
296
+ await scheduler.tick();
297
+ io.log('Ran all due scheduled tasks.');
298
+ return;
299
+ }
300
+ const name = args[0];
301
+ if (!name) {
302
+ io.error('Usage: basalt schedule:run <name> | --due');
303
+ return 1;
304
+ }
305
+ const ran = await scheduler.runNow(name);
306
+ if (!ran) {
307
+ const available = scheduler.names().join(', ') || '(none)';
308
+ io.error(`Unknown scheduled task "${name}". Available: ${available}.`);
309
+ return 1;
310
+ }
311
+ io.log(`Ran scheduled task "${name}".`);
312
+ },
313
+ });
321
314
  }
322
- export {
323
- CronParseError,
324
- SCHEDULER,
325
- ScheduleEntry,
326
- Scheduler,
327
- cronMatches,
328
- fieldMatches,
329
- parseCron,
330
- schedulerPlugin,
331
- zonedParts
332
- };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/scheduler",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,15 +14,14 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.0.0",
18
- "@basaltkit/queue": "^1.2.0"
17
+ "@basaltkit/core": "^1.3.0",
18
+ "@basaltkit/queue": "^1.3.1"
19
19
  },
20
20
  "devDependencies": {
21
- "@types/node": "^22.15.0",
22
- "tsup": "^8.4.0",
23
- "typescript": "^5.8.0",
24
- "vitest": "^3.1.0",
25
- "zod": "^3.24.0",
21
+ "@types/node": "^26.3.0",
22
+ "typescript": "^7.0.2",
23
+ "vitest": "^4.1.11",
24
+ "zod": "^3.24.0 || ^4.0.0",
26
25
  "@basaltkit/tsconfig": "^0.24.0"
27
26
  },
28
27
  "publishConfig": {
@@ -30,11 +29,11 @@
30
29
  },
31
30
  "repository": {
32
31
  "type": "git",
33
- "url": "git+https://github.com/Zebedeu/basalt.git",
32
+ "url": "git+https://github.com/basaltkit/basalt.git",
34
33
  "directory": "packages/scheduler"
35
34
  },
36
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/scheduler#readme",
37
- "bugs": "https://github.com/Zebedeu/basalt/issues",
35
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/scheduler#readme",
36
+ "bugs": "https://github.com/basaltkit/basalt/issues",
38
37
  "keywords": [
39
38
  "basalt",
40
39
  "typescript",
@@ -42,7 +41,7 @@
42
41
  "cron"
43
42
  ],
44
43
  "scripts": {
45
- "build": "tsup src/index.ts --format esm --dts --clean",
44
+ "build": "tsc -p tsconfig.build.json",
46
45
  "test": "vitest run",
47
46
  "typecheck": "tsc --noEmit"
48
47
  }