@basaltkit/scheduler 1.2.0 → 1.2.1

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.
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,72 @@
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
+ export function parseCron(expression) {
8
+ const parts = expression.trim().split(/\s+/);
9
+ if (parts.length !== 5) {
10
+ throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
11
+ }
12
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
13
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
14
+ }
15
+ export function cronToString(fields) {
16
+ return [fields.minute, fields.hour, fields.dayOfMonth, fields.month, fields.dayOfWeek].join(' ');
17
+ }
18
+ /** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
19
+ export function fieldMatches(field, value) {
20
+ if (field === '*')
21
+ return true;
22
+ return field.split(',').some((part) => {
23
+ const step = /^\*\/(\d+)$/.exec(part);
24
+ if (step)
25
+ return value % Number(step[1]) === 0;
26
+ const range = /^(\d+)-(\d+)$/.exec(part);
27
+ if (range)
28
+ return value >= Number(range[1]) && value <= Number(range[2]);
29
+ return Number(part) === value;
30
+ });
31
+ }
32
+ const WEEKDAYS = {
33
+ Sun: 0,
34
+ Mon: 1,
35
+ Tue: 2,
36
+ Wed: 3,
37
+ Thu: 4,
38
+ Fri: 5,
39
+ Sat: 6,
40
+ };
41
+ /** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
42
+ export function zonedParts(date, timeZone = 'UTC') {
43
+ const formatter = new Intl.DateTimeFormat('en-US', {
44
+ timeZone,
45
+ minute: 'numeric',
46
+ hour: 'numeric',
47
+ day: 'numeric',
48
+ month: 'numeric',
49
+ weekday: 'short',
50
+ hour12: false,
51
+ });
52
+ const parts = {};
53
+ for (const part of formatter.formatToParts(date)) {
54
+ parts[part.type] = part.value;
55
+ }
56
+ return {
57
+ minute: Number(parts['minute']),
58
+ // Intl with hour12:false may emit 24 for midnight
59
+ hour: Number(parts['hour']) % 24,
60
+ dayOfMonth: Number(parts['day']),
61
+ month: Number(parts['month']),
62
+ dayOfWeek: WEEKDAYS[parts['weekday']],
63
+ };
64
+ }
65
+ export function cronMatches(fields, date, timeZone) {
66
+ const parts = zonedParts(date, timeZone);
67
+ return (fieldMatches(fields.minute, parts.minute) &&
68
+ fieldMatches(fields.hour, parts.hour) &&
69
+ fieldMatches(fields.dayOfMonth, parts.dayOfMonth) &&
70
+ fieldMatches(fields.month, parts.month) &&
71
+ fieldMatches(fields.dayOfWeek, parts.dayOfWeek));
72
+ }
package/dist/index.d.ts CHANGED
@@ -1,31 +1,6 @@
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>;
30
5
  /**
31
6
  * A scheduled entry, built fluently:
@@ -33,7 +8,7 @@ type Task = () => void | Promise<void>;
33
8
  * schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
34
9
  * schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
35
10
  */
36
- declare class ScheduleEntry {
11
+ export declare class ScheduleEntry {
37
12
  readonly name: string;
38
13
  private readonly task;
39
14
  private fields;
@@ -76,7 +51,7 @@ declare class ScheduleEntry {
76
51
  run(): Promise<void>;
77
52
  private onDayOfWeek;
78
53
  }
79
- declare class Scheduler {
54
+ export declare class Scheduler {
80
55
  private readonly entries;
81
56
  private timer;
82
57
  private interval;
@@ -109,13 +84,11 @@ declare class Scheduler {
109
84
  private safeTick;
110
85
  private add;
111
86
  }
112
- declare const SCHEDULER: _basaltkit_core.Token<Scheduler>;
113
- interface SchedulerPluginOptions {
87
+ export declare const SCHEDULER: import("@basaltkit/core").Token<Scheduler>;
88
+ export interface SchedulerPluginOptions {
114
89
  /** Callback that defines the schedules — receives the Scheduler at boot. */
115
90
  define?: (schedule: Scheduler) => void;
116
91
  /** Starts the timer at boot. Default: true (turn off in tests). */
117
92
  autostart?: boolean;
118
93
  }
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 };
94
+ export declare function schedulerPlugin(options?: SchedulerPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
package/dist/index.js CHANGED
@@ -1,332 +1,260 @@
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
+ failureHandler;
23
+ running = false;
24
+ /** count of executions skipped due to overlap — visible for observability/tests */
25
+ skippedOverlaps = 0;
26
+ constructor(name, task) {
27
+ this.name = name;
28
+ this.task = task;
29
+ }
30
+ everyMinute() {
31
+ this.fields = { minute: '*', hour: '*', dayOfMonth: '*', month: '*', dayOfWeek: '*' };
32
+ return this;
33
+ }
34
+ everyMinutes(n) {
35
+ this.everyMinute();
36
+ this.fields.minute = `*/${n}`;
37
+ return this;
38
+ }
39
+ hourly() {
40
+ this.everyMinute();
41
+ this.fields.minute = '0';
42
+ return this;
43
+ }
44
+ daily() {
45
+ this.hourly();
46
+ this.fields.hour = '0';
47
+ return this;
177
48
  }
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;
49
+ weekly() {
50
+ this.daily();
51
+ this.fields.dayOfWeek = '0';
52
+ return this;
186
53
  }
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) => {
54
+ monthly() {
55
+ this.daily();
56
+ this.fields.dayOfMonth = '1';
57
+ return this;
58
+ }
59
+ /** 'HH:mm' time — combines with daily/weekly/monthly. */
60
+ at(time) {
61
+ const [hour, minute] = time.split(':');
62
+ this.fields.hour = String(Number(hour));
63
+ this.fields.minute = String(Number(minute ?? 0));
64
+ return this;
65
+ }
66
+ /** Raw cron expression (5 fields) escape hatch. */
67
+ cron(expression) {
68
+ this.fields = parseCron(expression);
69
+ return this;
70
+ }
71
+ sundays() { return this.onDayOfWeek(0); }
72
+ mondays() { return this.onDayOfWeek(1); }
73
+ tuesdays() { return this.onDayOfWeek(2); }
74
+ wednesdays() { return this.onDayOfWeek(3); }
75
+ thursdays() { return this.onDayOfWeek(4); }
76
+ fridays() { return this.onDayOfWeek(5); }
77
+ saturdays() { return this.onDayOfWeek(6); }
78
+ timezone(tz) {
79
+ this.tz = tz;
80
+ return this;
81
+ }
82
+ /** If the previous execution is still running, the new one is skipped. */
83
+ withoutOverlapping() {
84
+ this.noOverlap = true;
85
+ return this;
86
+ }
87
+ onFailure(handler) {
88
+ this.failureHandler = handler;
89
+ return this;
90
+ }
91
+ /** Entry description consumed by `basalt schedule list`. */
92
+ describe() {
93
+ return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
94
+ }
95
+ isDue(date) {
96
+ return cronMatches(this.fields, date, this.tz);
97
+ }
98
+ /** @internal runs the task with the overlap guard and failure handling. */
99
+ async run() {
100
+ if (this.noOverlap && this.running) {
101
+ this.skippedOverlaps++;
102
+ return;
103
+ }
104
+ this.running = true;
233
105
  try {
234
- await entry.run();
235
- } catch (error) {
236
- errors.push(error);
106
+ await this.task();
237
107
  }
238
- })
239
- );
240
- if (errors.length > 0) {
241
- throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
108
+ catch (error) {
109
+ if (!this.failureHandler)
110
+ throw error;
111
+ this.failureHandler(error);
112
+ }
113
+ finally {
114
+ this.running = false;
115
+ }
116
+ }
117
+ onDayOfWeek(day) {
118
+ this.fields.dayOfWeek = String(day);
119
+ return this;
120
+ }
121
+ }
122
+ export class Scheduler {
123
+ entries = [];
124
+ timer;
125
+ interval;
126
+ /** Schedules the dispatch of a @basaltkit/queue job. */
127
+ job(job, ...payload) {
128
+ return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
129
+ }
130
+ /** Schedules a named function. */
131
+ call(name, task) {
132
+ return this.add(new ScheduleEntry(name, task));
133
+ }
134
+ list() {
135
+ return this.entries.map((entry) => entry.describe());
136
+ }
137
+ /** Names of every scheduled entry — for CLI validation/listing. */
138
+ names() {
139
+ return this.entries.map((entry) => entry.name);
140
+ }
141
+ /**
142
+ * Runs a single entry by name on demand, ignoring its cron (for `schedule:run`
143
+ * and manual triggers). Returns false if no entry has that name. The entry's
144
+ * own overlap guard and failure handler still apply.
145
+ */
146
+ async runNow(name) {
147
+ const entry = this.entries.find((candidate) => candidate.name === name);
148
+ if (!entry)
149
+ return false;
150
+ await entry.run();
151
+ return true;
242
152
  }
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 {
153
+ /**
154
+ * Runs the entries due at the given instant. Deterministic this is what
155
+ * the tests call directly and what the timer calls every minute.
156
+ * Failures (without onFailure) are aggregated; all due entries run.
157
+ */
158
+ async tick(date = new Date()) {
159
+ const due = this.entries.filter((entry) => entry.isDue(date));
160
+ const errors = [];
161
+ await Promise.all(due.map(async (entry) => {
162
+ try {
163
+ await entry.run();
164
+ }
165
+ catch (error) {
166
+ errors.push(error);
167
+ }
168
+ }));
169
+ if (errors.length > 0) {
170
+ throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
171
+ }
172
+ }
173
+ /** Aligns to the next minute and then runs tick() every 60s. */
174
+ start() {
175
+ if (this.timer || this.interval)
176
+ return;
177
+ const msToNextMinute = 60_000 - (Date.now() % 60_000);
178
+ this.timer = setTimeout(() => {
179
+ void this.safeTick();
180
+ this.interval = setInterval(() => void this.safeTick(), 60_000);
181
+ this.interval.unref?.();
182
+ }, msToNextMinute);
183
+ this.timer.unref?.();
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
+ stop() {
186
+ if (this.timer)
187
+ clearTimeout(this.timer);
188
+ if (this.interval)
189
+ clearInterval(this.interval);
190
+ this.timer = undefined;
191
+ this.interval = undefined;
192
+ }
193
+ async safeTick() {
194
+ try {
195
+ await this.tick();
196
+ }
197
+ catch {
198
+ // failures without onFailure were already aggregated; here we only avoid
199
+ // bringing down the process — each entry must handle its own failure
200
+ }
201
+ }
202
+ add(entry) {
203
+ this.entries.push(entry);
204
+ return entry;
289
205
  }
290
- });
291
206
  }
207
+ export const SCHEDULER = createToken('scheduler');
208
+ export function schedulerPlugin(options = {}) {
209
+ return definePlugin({
210
+ name: 'basalt:scheduler',
211
+ register({ container }) {
212
+ container.singleton(SCHEDULER, () => new Scheduler());
213
+ registerScheduleRunCommand(container);
214
+ },
215
+ boot({ container }) {
216
+ const scheduler = container.get(SCHEDULER);
217
+ options.define?.(scheduler);
218
+ // Expose entries to tooling (CLI `basalt schedule:list`).
219
+ const metadata = ensureMetadata(container);
220
+ for (const entry of scheduler.list())
221
+ metadata.add('schedule:entries', entry);
222
+ if (options.autostart !== false)
223
+ scheduler.start();
224
+ },
225
+ shutdown({ container }) {
226
+ container.get(SCHEDULER).stop();
227
+ },
228
+ });
229
+ }
230
+ /**
231
+ * Registers `schedule:run` into the CLI command bucket. Runs a scheduled task on
232
+ * demand by name (ignoring its cron), or `--due` to run everything due right now.
233
+ * Registered structurally to avoid a hard @basaltkit/cli dependency.
234
+ */
292
235
  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
- });
236
+ ensureMetadata(container).add('commands', {
237
+ name: 'schedule:run',
238
+ description: 'Run a scheduled task on demand (by name), or --due for all due now',
239
+ async handle({ io, args, flags, }) {
240
+ const scheduler = container.get(SCHEDULER);
241
+ if (flags['due'] === true) {
242
+ await scheduler.tick();
243
+ io.log('Ran all due scheduled tasks.');
244
+ return;
245
+ }
246
+ const name = args[0];
247
+ if (!name) {
248
+ io.error('Usage: basalt schedule:run <name> | --due');
249
+ return 1;
250
+ }
251
+ const ran = await scheduler.runNow(name);
252
+ if (!ran) {
253
+ const available = scheduler.names().join(', ') || '(none)';
254
+ io.error(`Unknown scheduled task "${name}". Available: ${available}.`);
255
+ return 1;
256
+ }
257
+ io.log(`Ran scheduled task "${name}".`);
258
+ },
259
+ });
321
260
  }
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.2.1",
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.1.2",
18
+ "@basaltkit/queue": "^1.2.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
  }