@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 +6 -0
- package/dist/cron.d.ts +25 -0
- package/dist/cron.js +72 -0
- package/dist/index.d.ts +8 -35
- package/dist/index.js +250 -322
- package/package.json +11 -12
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
|
|
2
|
-
|
|
3
|
-
|
|
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:
|
|
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):
|
|
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
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return this.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
} catch (error) {
|
|
236
|
-
errors.push(error);
|
|
106
|
+
await this.task();
|
|
237
107
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
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.
|
|
18
|
-
"@basaltkit/queue": "^1.2.
|
|
17
|
+
"@basaltkit/core": "^1.1.2",
|
|
18
|
+
"@basaltkit/queue": "^1.2.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
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/
|
|
32
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
34
33
|
"directory": "packages/scheduler"
|
|
35
34
|
},
|
|
36
|
-
"homepage": "https://github.com/
|
|
37
|
-
"bugs": "https://github.com/
|
|
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": "
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
45
|
"test": "vitest run",
|
|
47
46
|
"typecheck": "tsc --noEmit"
|
|
48
47
|
}
|