@arkstack/scheduler 0.17.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/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/commands/ScheduleListCommand.d.ts +14 -0
- package/dist/commands/ScheduleListCommand.js +28 -0
- package/dist/commands/ScheduleRunCommand.d.ts +18 -0
- package/dist/commands/ScheduleRunCommand.js +33 -0
- package/dist/commands/ScheduleWorkCommand.d.ts +15 -0
- package/dist/commands/ScheduleWorkCommand.js +28 -0
- package/dist/index.d.ts +326 -0
- package/dist/index.js +3 -0
- package/dist/loader-zz_XJXwZ.js +666 -0
- package/dist/runner-DFEYebtg.js +26 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +21 -0
- package/package.json +57 -0
- package/stubs/routes/console.ts.stub +16 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { Arkstack } from "@arkstack/contract";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { env, importFile, outputDir } from "@arkstack/common";
|
|
5
|
+
import path, { join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
//#region src/locks.ts
|
|
9
|
+
/**
|
|
10
|
+
* Mutual-exclusion helpers backing `withoutOverlapping()` and `onOneServer()`.
|
|
11
|
+
*
|
|
12
|
+
* Locks are stored in `@arkstack/cache` (an optional peer) so they coordinate
|
|
13
|
+
* across processes and servers. When cache is unavailable the scheduler degrades
|
|
14
|
+
* to a process-local lock — enough to prevent overlap within one process, but it
|
|
15
|
+
* cannot coordinate across servers.
|
|
16
|
+
*/
|
|
17
|
+
const memoryLocks = /* @__PURE__ */ new Map();
|
|
18
|
+
/** Resolve the cache manager, or `null` when `@arkstack/cache` isn't installed. */
|
|
19
|
+
const cache = async () => {
|
|
20
|
+
try {
|
|
21
|
+
return (await import("@arkstack/cache")).Cache ?? null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Try to acquire a lock. Returns `true` when acquired, `false` when already held.
|
|
28
|
+
*
|
|
29
|
+
* @param key The lock key.
|
|
30
|
+
* @param ttlSeconds How long the lock is held before it auto-expires.
|
|
31
|
+
*/
|
|
32
|
+
const acquireLock = async (key, ttlSeconds) => {
|
|
33
|
+
const store = await cache();
|
|
34
|
+
if (store) try {
|
|
35
|
+
return await store.add(key, (/* @__PURE__ */ new Date()).toISOString(), ttlSeconds);
|
|
36
|
+
} catch {}
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const expiry = memoryLocks.get(key);
|
|
39
|
+
if (expiry && expiry > now) return false;
|
|
40
|
+
memoryLocks.set(key, now + ttlSeconds * 1e3);
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Release a previously acquired lock.
|
|
45
|
+
*
|
|
46
|
+
* @param key The lock key.
|
|
47
|
+
*/
|
|
48
|
+
const releaseLock = async (key) => {
|
|
49
|
+
const store = await cache();
|
|
50
|
+
if (store) try {
|
|
51
|
+
await store.forget(key);
|
|
52
|
+
return;
|
|
53
|
+
} catch {}
|
|
54
|
+
memoryLocks.delete(key);
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/cron.ts
|
|
58
|
+
/** Truncate a date to its minute (scheduler resolution is one minute). */
|
|
59
|
+
const toMinute = (date) => Math.floor(date.getTime() / 6e4);
|
|
60
|
+
/**
|
|
61
|
+
* Whether a cron expression is due at the given moment (minute resolution).
|
|
62
|
+
*
|
|
63
|
+
* Uses croner to find the next run at/after one second before `date`; the
|
|
64
|
+
* expression is due when that run falls in the same minute as `date`.
|
|
65
|
+
*
|
|
66
|
+
* @param expression A 5-field cron expression.
|
|
67
|
+
* @param date The moment to test (defaults to now).
|
|
68
|
+
* @param timezone IANA timezone the expression is evaluated in.
|
|
69
|
+
*/
|
|
70
|
+
const isDue = (expression, date = /* @__PURE__ */ new Date(), timezone) => {
|
|
71
|
+
try {
|
|
72
|
+
const next = new Cron(expression, timezone ? { timezone } : {}).nextRun(/* @__PURE__ */ new Date(date.getTime() - 1e3));
|
|
73
|
+
return !!next && toMinute(next) === toMinute(date);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The next time a cron expression will run after `from`.
|
|
80
|
+
*
|
|
81
|
+
* @param expression A 5-field cron expression.
|
|
82
|
+
* @param from The reference moment (defaults to now).
|
|
83
|
+
* @param timezone IANA timezone the expression is evaluated in.
|
|
84
|
+
*/
|
|
85
|
+
const nextRun = (expression, from = /* @__PURE__ */ new Date(), timezone) => {
|
|
86
|
+
try {
|
|
87
|
+
return new Cron(expression, timezone ? { timezone } : {}).nextRun(from);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/ScheduledEvent.ts
|
|
94
|
+
const DAYS = {
|
|
95
|
+
sunday: 0,
|
|
96
|
+
monday: 1,
|
|
97
|
+
tuesday: 2,
|
|
98
|
+
wednesday: 3,
|
|
99
|
+
thursday: 4,
|
|
100
|
+
friday: 5,
|
|
101
|
+
saturday: 6
|
|
102
|
+
};
|
|
103
|
+
/** Parse `HH:MM` into `[hour, minute]`. */
|
|
104
|
+
const parseTime = (time) => {
|
|
105
|
+
const [h, m] = time.split(":");
|
|
106
|
+
return [Number(h ?? 0), Number(m ?? 0)];
|
|
107
|
+
};
|
|
108
|
+
/** Minutes-from-midnight for `date`, in the given timezone. */
|
|
109
|
+
const minutesOfDay = (date, timezone) => {
|
|
110
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
111
|
+
hour: "2-digit",
|
|
112
|
+
minute: "2-digit",
|
|
113
|
+
hour12: false,
|
|
114
|
+
timeZone: timezone
|
|
115
|
+
}).formatToParts(date);
|
|
116
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value ?? 0) % 24;
|
|
117
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value ?? 0);
|
|
118
|
+
return hour * 60 + minute;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* A single scheduled task with a fluent builder for its frequency, constraints,
|
|
122
|
+
* overlap behaviour and lifecycle hooks. Created via the {@link Schedule} facade
|
|
123
|
+
* (`command`/`call`/`job`/`exec`) and evaluated once a minute by `schedule:run`.
|
|
124
|
+
*/
|
|
125
|
+
var ScheduledEvent = class {
|
|
126
|
+
type;
|
|
127
|
+
segments = [
|
|
128
|
+
"*",
|
|
129
|
+
"*",
|
|
130
|
+
"*",
|
|
131
|
+
"*",
|
|
132
|
+
"*"
|
|
133
|
+
];
|
|
134
|
+
timezoneValue;
|
|
135
|
+
descriptionValue;
|
|
136
|
+
filters = [];
|
|
137
|
+
rejects = [];
|
|
138
|
+
environmentsValue;
|
|
139
|
+
windows = [];
|
|
140
|
+
beforeHooks = [];
|
|
141
|
+
afterHooks = [];
|
|
142
|
+
successHooks = [];
|
|
143
|
+
failureHooks = [];
|
|
144
|
+
withoutOverlappingValue = false;
|
|
145
|
+
overlapExpiresMinutes = 1440;
|
|
146
|
+
onOneServerValue = false;
|
|
147
|
+
runInBackgroundValue = false;
|
|
148
|
+
mutexNameValue;
|
|
149
|
+
callTask;
|
|
150
|
+
jobPayload;
|
|
151
|
+
processCommand;
|
|
152
|
+
processArgs = [];
|
|
153
|
+
constructor(type, target) {
|
|
154
|
+
this.type = type;
|
|
155
|
+
this.descriptionValue = target;
|
|
156
|
+
}
|
|
157
|
+
setCall(task) {
|
|
158
|
+
this.callTask = task;
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
setJob(job) {
|
|
162
|
+
this.jobPayload = job;
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
setProcess(command, args = []) {
|
|
166
|
+
this.processCommand = command;
|
|
167
|
+
this.processArgs = args;
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
/** The 5-field cron expression this event resolves to. */
|
|
171
|
+
get expression() {
|
|
172
|
+
return this.segments.join(" ");
|
|
173
|
+
}
|
|
174
|
+
splice(position, value) {
|
|
175
|
+
this.segments[position - 1] = String(value);
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
cron(expression) {
|
|
179
|
+
this.segments = expression.trim().split(/\s+/).slice(0, 5);
|
|
180
|
+
while (this.segments.length < 5) this.segments.push("*");
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
everyMinute() {
|
|
184
|
+
return this.splice(1, "*");
|
|
185
|
+
}
|
|
186
|
+
everyTwoMinutes() {
|
|
187
|
+
return this.splice(1, "*/2");
|
|
188
|
+
}
|
|
189
|
+
everyThreeMinutes() {
|
|
190
|
+
return this.splice(1, "*/3");
|
|
191
|
+
}
|
|
192
|
+
everyFourMinutes() {
|
|
193
|
+
return this.splice(1, "*/4");
|
|
194
|
+
}
|
|
195
|
+
everyFiveMinutes() {
|
|
196
|
+
return this.splice(1, "*/5");
|
|
197
|
+
}
|
|
198
|
+
everyTenMinutes() {
|
|
199
|
+
return this.splice(1, "*/10");
|
|
200
|
+
}
|
|
201
|
+
everyFifteenMinutes() {
|
|
202
|
+
return this.splice(1, "*/15");
|
|
203
|
+
}
|
|
204
|
+
everyThirtyMinutes() {
|
|
205
|
+
return this.splice(1, "0,30");
|
|
206
|
+
}
|
|
207
|
+
hourly() {
|
|
208
|
+
return this.splice(1, 0);
|
|
209
|
+
}
|
|
210
|
+
hourlyAt(minute) {
|
|
211
|
+
return this.splice(1, Array.isArray(minute) ? minute.join(",") : minute);
|
|
212
|
+
}
|
|
213
|
+
everyTwoHours(minute = 0) {
|
|
214
|
+
return this.splice(1, minute).splice(2, "*/2");
|
|
215
|
+
}
|
|
216
|
+
everyOddHour(minute = 0) {
|
|
217
|
+
return this.splice(1, minute).splice(2, "1-23/2");
|
|
218
|
+
}
|
|
219
|
+
daily() {
|
|
220
|
+
return this.splice(1, 0).splice(2, 0);
|
|
221
|
+
}
|
|
222
|
+
at(time) {
|
|
223
|
+
return this.dailyAt(time);
|
|
224
|
+
}
|
|
225
|
+
dailyAt(time) {
|
|
226
|
+
const [hour, minute] = parseTime(time);
|
|
227
|
+
return this.splice(1, minute).splice(2, hour);
|
|
228
|
+
}
|
|
229
|
+
twiceDaily(first = 1, second = 13, minute = 0) {
|
|
230
|
+
return this.splice(1, minute).splice(2, `${first},${second}`);
|
|
231
|
+
}
|
|
232
|
+
weekly() {
|
|
233
|
+
return this.splice(1, 0).splice(2, 0).splice(5, 0);
|
|
234
|
+
}
|
|
235
|
+
weeklyOn(day, time = "0:0") {
|
|
236
|
+
this.dailyAt(time);
|
|
237
|
+
return this.splice(5, Array.isArray(day) ? day.join(",") : day);
|
|
238
|
+
}
|
|
239
|
+
monthly() {
|
|
240
|
+
return this.splice(1, 0).splice(2, 0).splice(3, 1);
|
|
241
|
+
}
|
|
242
|
+
monthlyOn(day = 1, time = "0:0") {
|
|
243
|
+
this.dailyAt(time);
|
|
244
|
+
return this.splice(3, day);
|
|
245
|
+
}
|
|
246
|
+
quarterly() {
|
|
247
|
+
return this.splice(1, 0).splice(2, 0).splice(3, 1).splice(4, "1-12/3");
|
|
248
|
+
}
|
|
249
|
+
yearly() {
|
|
250
|
+
return this.splice(1, 0).splice(2, 0).splice(3, 1).splice(4, 1);
|
|
251
|
+
}
|
|
252
|
+
days(day) {
|
|
253
|
+
return this.splice(5, Array.isArray(day) ? day.join(",") : day);
|
|
254
|
+
}
|
|
255
|
+
weekdays() {
|
|
256
|
+
return this.splice(5, "1-5");
|
|
257
|
+
}
|
|
258
|
+
weekends() {
|
|
259
|
+
return this.splice(5, "0,6");
|
|
260
|
+
}
|
|
261
|
+
sundays() {
|
|
262
|
+
return this.splice(5, DAYS.sunday);
|
|
263
|
+
}
|
|
264
|
+
mondays() {
|
|
265
|
+
return this.splice(5, DAYS.monday);
|
|
266
|
+
}
|
|
267
|
+
tuesdays() {
|
|
268
|
+
return this.splice(5, DAYS.tuesday);
|
|
269
|
+
}
|
|
270
|
+
wednesdays() {
|
|
271
|
+
return this.splice(5, DAYS.wednesday);
|
|
272
|
+
}
|
|
273
|
+
thursdays() {
|
|
274
|
+
return this.splice(5, DAYS.thursday);
|
|
275
|
+
}
|
|
276
|
+
fridays() {
|
|
277
|
+
return this.splice(5, DAYS.friday);
|
|
278
|
+
}
|
|
279
|
+
saturdays() {
|
|
280
|
+
return this.splice(5, DAYS.saturday);
|
|
281
|
+
}
|
|
282
|
+
timezone(timezone) {
|
|
283
|
+
this.timezoneValue = timezone;
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Only run when every registered `when` predicate is truthy.
|
|
288
|
+
*
|
|
289
|
+
* @param callback
|
|
290
|
+
* @returns
|
|
291
|
+
*/
|
|
292
|
+
when(callback) {
|
|
293
|
+
this.filters.push(callback);
|
|
294
|
+
return this;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Skip when any registered `skip` predicate is truthy.
|
|
298
|
+
*
|
|
299
|
+
* @param callback
|
|
300
|
+
* @returns
|
|
301
|
+
*/
|
|
302
|
+
skip(callback) {
|
|
303
|
+
this.rejects.push(callback);
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Only run in these `APP_ENV` environments.
|
|
308
|
+
*
|
|
309
|
+
* @param callback
|
|
310
|
+
* @returns
|
|
311
|
+
*/
|
|
312
|
+
environments(...environments) {
|
|
313
|
+
this.environmentsValue = environments.flat();
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Only run when the current time is within `[start, end]` (HH:MM, event tz).
|
|
318
|
+
*
|
|
319
|
+
* @param start
|
|
320
|
+
* @param end
|
|
321
|
+
* @returns
|
|
322
|
+
*/
|
|
323
|
+
between(start, end) {
|
|
324
|
+
this.windows.push({
|
|
325
|
+
start: this.toMinutes(start),
|
|
326
|
+
end: this.toMinutes(end),
|
|
327
|
+
negate: false
|
|
328
|
+
});
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Only run when the current time is outside `[start, end]`.
|
|
333
|
+
*
|
|
334
|
+
* @param callback
|
|
335
|
+
* @returns
|
|
336
|
+
*/
|
|
337
|
+
unlessBetween(start, end) {
|
|
338
|
+
this.windows.push({
|
|
339
|
+
start: this.toMinutes(start),
|
|
340
|
+
end: this.toMinutes(end),
|
|
341
|
+
negate: true
|
|
342
|
+
});
|
|
343
|
+
return this;
|
|
344
|
+
}
|
|
345
|
+
toMinutes(time) {
|
|
346
|
+
const [h, m] = parseTime(time);
|
|
347
|
+
return h * 60 + m;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Prevent the task from overlapping itself; the lock expires after `expiresMinutes`.
|
|
351
|
+
*
|
|
352
|
+
* @param expiresMinutes
|
|
353
|
+
* @returns
|
|
354
|
+
*/
|
|
355
|
+
withoutOverlapping(expiresMinutes = 1440) {
|
|
356
|
+
this.withoutOverlappingValue = true;
|
|
357
|
+
this.overlapExpiresMinutes = expiresMinutes;
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Run on only one server per due minute (requires a shared cache store).
|
|
362
|
+
*
|
|
363
|
+
* @param callback
|
|
364
|
+
* @returns
|
|
365
|
+
*/
|
|
366
|
+
onOneServer() {
|
|
367
|
+
this.onOneServerValue = true;
|
|
368
|
+
return this;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Run the task in a detached background process (command/exec only).
|
|
372
|
+
*
|
|
373
|
+
* @param callback
|
|
374
|
+
* @returns
|
|
375
|
+
*/
|
|
376
|
+
runInBackground() {
|
|
377
|
+
this.runInBackgroundValue = true;
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* A human description shown by `schedule:list`.
|
|
382
|
+
*
|
|
383
|
+
* @param callback
|
|
384
|
+
* @returns
|
|
385
|
+
*/
|
|
386
|
+
description(description) {
|
|
387
|
+
this.descriptionValue = description;
|
|
388
|
+
return this;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* An explicit mutex name (otherwise derived from the expression + description).
|
|
392
|
+
*
|
|
393
|
+
* @param name
|
|
394
|
+
* @returns
|
|
395
|
+
*/
|
|
396
|
+
name(name) {
|
|
397
|
+
this.mutexNameValue = name;
|
|
398
|
+
return this;
|
|
399
|
+
}
|
|
400
|
+
before(hook) {
|
|
401
|
+
this.beforeHooks.push(hook);
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
after(hook) {
|
|
405
|
+
this.afterHooks.push(hook);
|
|
406
|
+
return this;
|
|
407
|
+
}
|
|
408
|
+
onSuccess(hook) {
|
|
409
|
+
this.successHooks.push(hook);
|
|
410
|
+
return this;
|
|
411
|
+
}
|
|
412
|
+
onFailure(hook) {
|
|
413
|
+
this.failureHooks.push(hook);
|
|
414
|
+
return this;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Whether the cron expression is due at `date`.
|
|
418
|
+
*
|
|
419
|
+
* @param date
|
|
420
|
+
* @returns
|
|
421
|
+
*/
|
|
422
|
+
isDue(date = /* @__PURE__ */ new Date()) {
|
|
423
|
+
return isDue(this.expression, date, this.timezoneValue);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The next time this event will run after `from`.
|
|
427
|
+
*
|
|
428
|
+
* @param date
|
|
429
|
+
* @returns
|
|
430
|
+
*/
|
|
431
|
+
nextRunAt(from = /* @__PURE__ */ new Date()) {
|
|
432
|
+
return nextRun(this.expression, from, this.timezoneValue);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Whether environment, time-window, `when` and `skip` constraints all pass.
|
|
436
|
+
*
|
|
437
|
+
* @param date
|
|
438
|
+
* @returns
|
|
439
|
+
*/
|
|
440
|
+
async filtersPass(date = /* @__PURE__ */ new Date()) {
|
|
441
|
+
if (this.environmentsValue) {
|
|
442
|
+
const current = String(env("APP_ENV", "") || "");
|
|
443
|
+
if (!this.environmentsValue.includes(current)) return false;
|
|
444
|
+
}
|
|
445
|
+
for (const window of this.windows) {
|
|
446
|
+
const now = minutesOfDay(date, this.timezoneValue);
|
|
447
|
+
if ((window.start <= window.end ? now >= window.start && now <= window.end : now >= window.start || now <= window.end) === window.negate) return false;
|
|
448
|
+
}
|
|
449
|
+
for (const filter of this.filters) if (!await filter()) return false;
|
|
450
|
+
for (const reject of this.rejects) if (await reject()) return false;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* A stable mutex key derived from the expression + description (or an explicit name).
|
|
455
|
+
*
|
|
456
|
+
* @returns
|
|
457
|
+
*/
|
|
458
|
+
mutexName() {
|
|
459
|
+
if (this.mutexNameValue) return `arkstack-schedule-${this.mutexNameValue}`;
|
|
460
|
+
return `arkstack-schedule-${createHash("sha1").update(`${this.type}:${this.expression}:${this.descriptionValue}`).digest("hex")}`;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Run the task now, honouring overlap/one-server locks and lifecycle hooks.
|
|
464
|
+
* Assumes the event is already due and its filters pass.
|
|
465
|
+
*
|
|
466
|
+
* @param date The reference moment (used for the one-server per-minute key).
|
|
467
|
+
*/
|
|
468
|
+
async run(date = /* @__PURE__ */ new Date()) {
|
|
469
|
+
const base = {
|
|
470
|
+
description: this.descriptionValue,
|
|
471
|
+
expression: this.expression
|
|
472
|
+
};
|
|
473
|
+
if (this.onOneServerValue) {
|
|
474
|
+
const minute = Math.floor(date.getTime() / 6e4);
|
|
475
|
+
if (!await acquireLock(`${this.mutexName()}-server-${minute}`, 60)) return {
|
|
476
|
+
...base,
|
|
477
|
+
ran: false,
|
|
478
|
+
skipped: "one-server"
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
let overlapKey;
|
|
482
|
+
if (this.withoutOverlappingValue) {
|
|
483
|
+
overlapKey = this.mutexName();
|
|
484
|
+
if (!await acquireLock(overlapKey, this.overlapExpiresMinutes * 60)) return {
|
|
485
|
+
...base,
|
|
486
|
+
ran: false,
|
|
487
|
+
skipped: "overlapping"
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
let error;
|
|
491
|
+
try {
|
|
492
|
+
await this.callHooks(this.beforeHooks);
|
|
493
|
+
await this.executeTask();
|
|
494
|
+
await this.callHooks(this.successHooks);
|
|
495
|
+
} catch (caught) {
|
|
496
|
+
error = caught;
|
|
497
|
+
await this.callHooks(this.failureHooks, caught);
|
|
498
|
+
} finally {
|
|
499
|
+
await this.callHooks(this.afterHooks, error);
|
|
500
|
+
if (overlapKey && !this.runInBackgroundValue) await releaseLock(overlapKey);
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
...base,
|
|
504
|
+
ran: !error,
|
|
505
|
+
error
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async callHooks(hooks, error) {
|
|
509
|
+
for (const hook of hooks) await hook(error);
|
|
510
|
+
}
|
|
511
|
+
async executeTask() {
|
|
512
|
+
switch (this.type) {
|
|
513
|
+
case "call":
|
|
514
|
+
await this.callTask?.();
|
|
515
|
+
return;
|
|
516
|
+
case "job": {
|
|
517
|
+
const { dispatch } = await import("@arkstack/jobs");
|
|
518
|
+
await dispatch(this.jobPayload);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
case "command": {
|
|
522
|
+
const consoleEntry = join(Arkstack.rootDir(), "node_modules", "@arkstack", "console", "dist", "index.js");
|
|
523
|
+
await this.spawnProcess(process.execPath, [
|
|
524
|
+
consoleEntry,
|
|
525
|
+
...this.processCommand ? [this.processCommand] : [],
|
|
526
|
+
...this.processArgs
|
|
527
|
+
]);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
case "exec":
|
|
531
|
+
await this.spawnProcess(this.processCommand ?? "", this.processArgs, true);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
spawnProcess(command, args, shell = false) {
|
|
536
|
+
return new Promise((resolve, reject) => {
|
|
537
|
+
const child = spawn(command, args, {
|
|
538
|
+
cwd: Arkstack.rootDir(),
|
|
539
|
+
shell,
|
|
540
|
+
detached: this.runInBackgroundValue,
|
|
541
|
+
stdio: this.runInBackgroundValue ? "ignore" : "inherit"
|
|
542
|
+
});
|
|
543
|
+
if (this.runInBackgroundValue) {
|
|
544
|
+
child.unref();
|
|
545
|
+
resolve();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
child.on("error", reject);
|
|
549
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Scheduled process exited with code ${code}`)));
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/Schedule.ts
|
|
555
|
+
const REGISTRY = Symbol.for("arkstack.scheduler.events");
|
|
556
|
+
const registry = () => {
|
|
557
|
+
const store = globalThis;
|
|
558
|
+
return store[REGISTRY] ??= [];
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* The scheduling facade. Define tasks in `src/routes/console.ts`:
|
|
562
|
+
*
|
|
563
|
+
* ```ts
|
|
564
|
+
* import { Schedule } from '@arkstack/scheduler'
|
|
565
|
+
*
|
|
566
|
+
* Schedule.command('report:send').dailyAt('13:00')
|
|
567
|
+
* Schedule.call(() => prune()).hourly().withoutOverlapping()
|
|
568
|
+
* Schedule.job(new Heartbeat()).everyFiveMinutes()
|
|
569
|
+
* Schedule.exec('backup.sh').daily().onOneServer()
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
572
|
+
var Schedule = class {
|
|
573
|
+
/**
|
|
574
|
+
* Run an Arkstack CLI command (`ark <name>`) on the schedule.
|
|
575
|
+
*
|
|
576
|
+
* @param name
|
|
577
|
+
* @param args
|
|
578
|
+
* @returns
|
|
579
|
+
*/
|
|
580
|
+
static command(name, args = []) {
|
|
581
|
+
const event = new ScheduledEvent("command", `ark ${name}`).setProcess(name, args);
|
|
582
|
+
return this.add(event);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Run a callback on the schedule.
|
|
586
|
+
*
|
|
587
|
+
* @param callback
|
|
588
|
+
* @returns
|
|
589
|
+
*/
|
|
590
|
+
static call(callback) {
|
|
591
|
+
const event = new ScheduledEvent("call", "Closure").setCall(callback);
|
|
592
|
+
return this.add(event);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Dispatch a queued job on the schedule (requires `@arkstack/jobs`).
|
|
596
|
+
*
|
|
597
|
+
* @param callback
|
|
598
|
+
* @returns
|
|
599
|
+
*/
|
|
600
|
+
static job(job) {
|
|
601
|
+
const event = new ScheduledEvent("job", job?.constructor?.name ?? "Job").setJob(job);
|
|
602
|
+
return this.add(event);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Run a shell command on the schedule.
|
|
606
|
+
*
|
|
607
|
+
* @param callback
|
|
608
|
+
* @returns
|
|
609
|
+
*/
|
|
610
|
+
static exec(command, args = []) {
|
|
611
|
+
const event = new ScheduledEvent("exec", [command, ...args].join(" ")).setProcess(command, args);
|
|
612
|
+
return this.add(event);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* All registered events.
|
|
616
|
+
*
|
|
617
|
+
* @param callback
|
|
618
|
+
* @returns
|
|
619
|
+
*/
|
|
620
|
+
static events() {
|
|
621
|
+
return registry();
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Events whose cron expression is due at `date`.
|
|
625
|
+
*
|
|
626
|
+
* @param callback
|
|
627
|
+
* @returns
|
|
628
|
+
*/
|
|
629
|
+
static dueEvents(date = /* @__PURE__ */ new Date()) {
|
|
630
|
+
return registry().filter((event) => event.isDue(date));
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Remove all registered events (used in tests).
|
|
634
|
+
*
|
|
635
|
+
* @param callback
|
|
636
|
+
* @returns
|
|
637
|
+
*/
|
|
638
|
+
static clear() {
|
|
639
|
+
registry().length = 0;
|
|
640
|
+
}
|
|
641
|
+
static add(event) {
|
|
642
|
+
registry().push(event);
|
|
643
|
+
return event;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/loader.ts
|
|
648
|
+
/**
|
|
649
|
+
* Load the application's schedule definitions from `src/routes/console.ts`.
|
|
650
|
+
* The TypeScript source is preferred (loaded via jiti, so no build is needed),
|
|
651
|
+
* falling back to the built `<outDir>/routes/console.js`.
|
|
652
|
+
*
|
|
653
|
+
* @returns `true` when a console route file was found and loaded.
|
|
654
|
+
*/
|
|
655
|
+
const loadSchedule = async () => {
|
|
656
|
+
const root = Arkstack.rootDir();
|
|
657
|
+
const dist = path.relative(root, outputDir());
|
|
658
|
+
const candidates = [join(root, "src", "routes", "console.ts"), join(root, dist, "routes", "console.js")];
|
|
659
|
+
for (const file of candidates) if (existsSync(file)) {
|
|
660
|
+
await importFile(file);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
};
|
|
665
|
+
//#endregion
|
|
666
|
+
export { nextRun as a, isDue as i, Schedule as n, acquireLock as o, ScheduledEvent as r, releaseLock as s, loadSchedule as t };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { n as Schedule } from "./loader-zz_XJXwZ.js";
|
|
2
|
+
//#region src/runner.ts
|
|
3
|
+
/**
|
|
4
|
+
* Run every event that is due at `now` and whose filters pass, collecting a
|
|
5
|
+
* result per event (including the ones skipped by filters or locks).
|
|
6
|
+
*
|
|
7
|
+
* @param now The reference moment (defaults to now).
|
|
8
|
+
*/
|
|
9
|
+
const runDueEvents = async (now = /* @__PURE__ */ new Date()) => {
|
|
10
|
+
const results = [];
|
|
11
|
+
for (const event of Schedule.dueEvents(now)) {
|
|
12
|
+
if (!await event.filtersPass(now)) {
|
|
13
|
+
results.push({
|
|
14
|
+
description: event.descriptionValue,
|
|
15
|
+
expression: event.expression,
|
|
16
|
+
ran: false,
|
|
17
|
+
skipped: "filtered"
|
|
18
|
+
});
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
results.push(await event.run(now));
|
|
22
|
+
}
|
|
23
|
+
return results;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { runDueEvents as t };
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Publisher } from "@arkstack/common";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region src/setup.ts
|
|
5
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
/**
|
|
7
|
+
* Register the artifacts `@arkstack/scheduler` publishes into the application.
|
|
8
|
+
*
|
|
9
|
+
* Run `ark publish --package @arkstack/scheduler` to copy the starter
|
|
10
|
+
* `src/routes/console.ts` schedule file into the app.
|
|
11
|
+
*/
|
|
12
|
+
Publisher.publishes({
|
|
13
|
+
package: "@arkstack/scheduler",
|
|
14
|
+
tag: "scheduler-routes",
|
|
15
|
+
entries: [{
|
|
16
|
+
from: join(root, "stubs/routes/console.ts.stub"),
|
|
17
|
+
to: "src/routes/console.ts"
|
|
18
|
+
}]
|
|
19
|
+
});
|
|
20
|
+
//#endregion
|
|
21
|
+
export {};
|