@holo-js/schedule 0.0.0-stage → 0.5.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Holo-JS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ f30ad13d2d7cb3e68f51eb3c29939783513dbcefc33be1231018bb2bf624a76c
@@ -0,0 +1,127 @@
1
+ type ScheduleCallback = () => unknown;
2
+ interface SchedulableJob<TPayload> {
3
+ dispatch(payload: TPayload): PromiseLike<unknown>;
4
+ }
5
+ type ScheduledTaskAction = {
6
+ readonly kind: 'command';
7
+ readonly command: string;
8
+ readonly argv: readonly string[];
9
+ } | {
10
+ readonly kind: 'job';
11
+ readonly job: SchedulableJob<never>;
12
+ readonly dispatch: () => PromiseLike<unknown>;
13
+ } | {
14
+ readonly kind: 'call';
15
+ readonly callback: ScheduleCallback;
16
+ };
17
+ type ScheduledTaskState = {
18
+ readonly action: ScheduledTaskAction;
19
+ readonly expression: string;
20
+ readonly timezone?: string;
21
+ readonly description?: string;
22
+ readonly withoutOverlapping: boolean;
23
+ readonly expiresAtMinutes: number;
24
+ readonly onOneServer: boolean;
25
+ };
26
+ declare const SCHEDULED_TASK_STATE: unique symbol;
27
+ type CronValue = number | string;
28
+ type CronValues = number | readonly number[];
29
+ declare class ScheduledTask {
30
+ #private;
31
+ constructor(action: ScheduledTaskAction);
32
+ get [SCHEDULED_TASK_STATE](): ScheduledTaskState;
33
+ cron(expression: string): this;
34
+ timezone(timezone: string): this;
35
+ name(description: string): this;
36
+ withoutOverlapping(expiresAtMinutes?: number): this;
37
+ onOneServer(): this;
38
+ everyMinute(): this;
39
+ everyTwoMinutes(): this;
40
+ everyThreeMinutes(): this;
41
+ everyFourMinutes(): this;
42
+ everyFiveMinutes(): this;
43
+ everyTenMinutes(): this;
44
+ everyFifteenMinutes(): this;
45
+ everyThirtyMinutes(): this;
46
+ hourly(): this;
47
+ hourlyAt(offset: CronValues): this;
48
+ everyOddHour(offset?: CronValues): this;
49
+ everyTwoHours(offset?: CronValues): this;
50
+ everyThreeHours(offset?: CronValues): this;
51
+ everyFourHours(offset?: CronValues): this;
52
+ everySixHours(offset?: CronValues): this;
53
+ daily(): this;
54
+ at(time: string): this;
55
+ dailyAt(time: string): this;
56
+ twiceDaily(first?: number, second?: number): this;
57
+ twiceDailyAt(first?: number, second?: number, offset?: number): this;
58
+ weekdays(): this;
59
+ weekends(): this;
60
+ mondays(): this;
61
+ tuesdays(): this;
62
+ wednesdays(): this;
63
+ thursdays(): this;
64
+ fridays(): this;
65
+ saturdays(): this;
66
+ sundays(): this;
67
+ days(...days: ReadonlyArray<CronValue | readonly number[]>): this;
68
+ weekly(): this;
69
+ weeklyOn(dayOfWeek: CronValues, time?: string): this;
70
+ monthly(): this;
71
+ monthlyOn(dayOfMonth?: number, time?: string): this;
72
+ twiceMonthly(first?: number, second?: number, time?: string): this;
73
+ quarterly(): this;
74
+ quarterlyOn(dayOfQuarter?: number, time?: string): this;
75
+ yearly(): this;
76
+ yearlyOn(month?: number, dayOfMonth?: number, time?: string): this;
77
+ private hourBasedSchedule;
78
+ private spliceIntoPosition;
79
+ }
80
+
81
+ declare const SCHEDULE_DEFINITION: unique symbol;
82
+ interface ScheduleDefinition {
83
+ readonly [SCHEDULE_DEFINITION]: true;
84
+ readonly tasks: readonly ScheduledTask[];
85
+ }
86
+ declare class Schedule {
87
+ #private;
88
+ command(command: string): ScheduledTask;
89
+ job<TPayload>(job: SchedulableJob<TPayload>, payload: NoInfer<TPayload>): ScheduledTask;
90
+ call(callback: ScheduleCallback): ScheduledTask;
91
+ get tasks(): readonly ScheduledTask[];
92
+ private add;
93
+ }
94
+ declare function defineSchedule(define: (schedule: Schedule) => void): ScheduleDefinition;
95
+ declare function isScheduleDefinition(value: unknown): value is ScheduleDefinition;
96
+
97
+ declare class CronExpressionError extends Error {
98
+ constructor(expression: string, reason: string);
99
+ }
100
+
101
+ type ScheduledTaskOutcome = 'ran' | 'failed' | 'skipped-overlapping' | 'skipped-other-server';
102
+ interface ScheduledTaskReport {
103
+ readonly summary: string;
104
+ readonly outcome: ScheduledTaskOutcome;
105
+ readonly error?: Error;
106
+ }
107
+ interface ScheduleRunOptions {
108
+ readonly now: Date;
109
+ readonly timezone: string;
110
+ readonly signal?: AbortSignal;
111
+ runCommand(argv: readonly string[]): Promise<number>;
112
+ onTaskFinished?(report: ScheduledTaskReport): void;
113
+ }
114
+ interface ScheduledTaskListing {
115
+ readonly expression: string;
116
+ readonly summary: string;
117
+ readonly timezone: string;
118
+ readonly nextDueAt: Date;
119
+ }
120
+ declare function runDueScheduledTasks(definition: ScheduleDefinition, options: ScheduleRunOptions): Promise<readonly ScheduledTaskReport[]>;
121
+ declare function listScheduledTasks(definition: ScheduleDefinition, options: {
122
+ readonly now: Date;
123
+ readonly timezone: string;
124
+ }): Promise<readonly ScheduledTaskListing[]>;
125
+
126
+ export { CronExpressionError, Schedule, ScheduledTask, defineSchedule, isScheduleDefinition, listScheduledTasks, runDueScheduledTasks };
127
+ export type { SchedulableJob, ScheduleCallback, ScheduleDefinition, ScheduleRunOptions, ScheduledTaskListing, ScheduledTaskOutcome, ScheduledTaskReport };
package/dist/index.mjs ADDED
@@ -0,0 +1,660 @@
1
+ // src/cron.ts
2
+ var MINUTE_MS = 6e4;
3
+ var HOUR_MS = 60 * MINUTE_MS;
4
+ var MAX_SEARCH_DAYS = 366 * 9;
5
+ var MACROS = {
6
+ "@yearly": "0 0 1 1 *",
7
+ "@annually": "0 0 1 1 *",
8
+ "@monthly": "0 0 1 * *",
9
+ "@weekly": "0 0 * * 0",
10
+ "@daily": "0 0 * * *",
11
+ "@midnight": "0 0 * * *",
12
+ "@hourly": "0 * * * *"
13
+ };
14
+ var MONTH_NAMES = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
15
+ var WEEKDAY_NAMES = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
16
+ var ISO_WEEKDAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
17
+ var FIELD_SPECS = [
18
+ { label: "minute", min: 0, max: 59 },
19
+ { label: "hour", min: 0, max: 23 },
20
+ { label: "day of month", min: 1, max: 31 },
21
+ { label: "month", min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1 },
22
+ { label: "day of week", min: 0, max: 7, names: ISO_WEEKDAY_NAMES, nameOffset: 1 }
23
+ ];
24
+ var CronExpressionError = class extends Error {
25
+ constructor(expression, reason) {
26
+ super(`[@holo-js/schedule] Invalid cron expression "${expression}": ${reason}`);
27
+ this.name = "CronExpressionError";
28
+ }
29
+ };
30
+ function parseValue(token, spec, expression) {
31
+ const nameIndex = spec.names?.indexOf(token.toUpperCase()) ?? -1;
32
+ if (nameIndex >= 0) return nameIndex + (spec.nameOffset ?? 0);
33
+ if (!/^\d+$/.test(token)) throw new CronExpressionError(expression, `"${token}" is not a valid ${spec.label}.`);
34
+ const value = Number(token);
35
+ if (value < spec.min || value > spec.max) {
36
+ throw new CronExpressionError(expression, `${spec.label} ${value} is outside ${spec.min}-${spec.max}.`);
37
+ }
38
+ return value;
39
+ }
40
+ function leadingInteger(token) {
41
+ return Number(/^\d+/.exec(token)?.[0] ?? 0);
42
+ }
43
+ function parsePart(part, spec, expression) {
44
+ const [range = "", stepToken, ...extra] = part.split("/");
45
+ if (extra.length > 0 || range === "") throw new CronExpressionError(expression, `"${part}" is not a valid ${spec.label}.`);
46
+ const step = stepToken === void 0 ? 1 : Number(stepToken);
47
+ if (!Number.isInteger(step) || step < 1 || stepToken !== void 0 && !/^\d+$/.test(stepToken)) {
48
+ throw new CronExpressionError(expression, `"${part}" has an invalid step.`);
49
+ }
50
+ if (stepToken !== void 0 && range !== "*" && !range.includes("-")) {
51
+ throw new CronExpressionError(expression, `"${part}" steps over a single value; use a range such as "${range}-${spec.max}/${stepToken}".`);
52
+ }
53
+ let start = spec.min;
54
+ let end = spec.max;
55
+ if (range !== "*") {
56
+ const [startToken = "", endToken, ...rest] = range.split("-");
57
+ if (rest.length > 0) throw new CronExpressionError(expression, `"${part}" is not a valid ${spec.label}.`);
58
+ start = parseValue(startToken, spec, expression);
59
+ end = endToken === void 0 ? stepToken === void 0 ? start : spec.max : parseValue(endToken, spec, expression);
60
+ if (stepToken !== void 0) {
61
+ start = leadingInteger(startToken);
62
+ end = leadingInteger(endToken ?? startToken);
63
+ if (start < spec.min || end > spec.max) {
64
+ throw new CronExpressionError(expression, `"${part}" reads as ${start}-${end}, outside ${spec.min}-${spec.max}; use numbers in stepped ranges.`);
65
+ }
66
+ }
67
+ if (end < start) throw new CronExpressionError(expression, `"${part}" is a descending range.`);
68
+ }
69
+ if (step > spec.max) return [spec.min + step % (spec.max - spec.min + 1)];
70
+ const values = [];
71
+ for (let value = start; value <= end; value += step) values.push(value);
72
+ return values;
73
+ }
74
+ function daysInMonth(year, month) {
75
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
76
+ }
77
+ function isWeekday(date) {
78
+ const weekday = date.getUTCDay();
79
+ return weekday >= 1 && weekday <= 5;
80
+ }
81
+ function nearestWeekday(year, month, target) {
82
+ const rolledOver = new Date(Date.UTC(year, month - 1, target));
83
+ if (isWeekday(rolledOver)) return rolledOver.getUTCDate();
84
+ const lastDay = daysInMonth(rolledOver.getUTCFullYear(), rolledOver.getUTCMonth() + 1);
85
+ for (const offset of [-1, 1, -2, 2]) {
86
+ const day = target + offset;
87
+ if (day < 1 || day > lastDay) continue;
88
+ const candidate = new Date(Date.UTC(year, month - 1, day));
89
+ if (isWeekday(candidate) && candidate.getUTCMonth() + 1 === month) return candidate.getUTCDate();
90
+ }
91
+ return void 0;
92
+ }
93
+ function parseDayOfMonthRule(part, source, spec, expression) {
94
+ const special = part === "L" || part.endsWith("W");
95
+ if (!special) return void 0;
96
+ if (source.includes(",")) throw new CronExpressionError(expression, `"${part}" can't be combined with other days of the month.`);
97
+ if (part === "L") return (clock) => clock.day === daysInMonth(clock.year, clock.month);
98
+ const prefix = part.slice(0, -1);
99
+ const target = prefix === "L" ? 0 : parseValue(prefix, spec, expression);
100
+ return (clock) => nearestWeekday(clock.year, clock.month, target) === clock.day;
101
+ }
102
+ function parseDayOfWeekRule(part, spec, expression) {
103
+ const last = /^(\w+)L$/i.exec(part);
104
+ if (last) {
105
+ const weekday2 = parseValue(last[1], spec, expression) % 7;
106
+ return (clock) => clock.weekday === weekday2 && daysInMonth(clock.year, clock.month) - clock.day < 7;
107
+ }
108
+ const nth = /^(\w+)#(\d+)$/.exec(part);
109
+ if (!nth) return void 0;
110
+ const weekday = parseValue(nth[1], spec, expression) % 7;
111
+ const occurrence = Number(nth[2]);
112
+ if (occurrence < 1 || occurrence > 5) throw new CronExpressionError(expression, `"${part}" must name the 1st to 5th weekday of the month.`);
113
+ return (clock) => clock.weekday === weekday && Math.ceil(clock.day / 7) === occurrence;
114
+ }
115
+ function parseRule(part, source, spec, expression) {
116
+ if (spec.label === "day of month") return parseDayOfMonthRule(part, source, spec, expression);
117
+ if (spec.label === "day of week") return parseDayOfWeekRule(part, spec, expression);
118
+ return void 0;
119
+ }
120
+ function parseDayOfWeekPart(part, spec, expression) {
121
+ const literal = spec.names?.indexOf(part.toUpperCase()) ?? -1;
122
+ const bounds = (literal >= 0 ? String(literal + (spec.nameOffset ?? 0)) : part).split("-");
123
+ if (bounds[0] === "7") bounds[0] = "0";
124
+ else if (bounds[1] === "0") bounds[1] = "7";
125
+ const normalized = bounds.join("-");
126
+ const sundayIsSeven = normalized.includes("7");
127
+ return parsePart(normalized, spec, expression).flatMap((value) => {
128
+ if (sundayIsSeven) return value === 0 ? [] : [value % 7];
129
+ return value === 7 ? [] : [value];
130
+ });
131
+ }
132
+ function parseField(source, spec, expression) {
133
+ if (source === "?") return { values: new Set(parsePart("*", spec, expression)), rules: [], restricted: false };
134
+ const values = /* @__PURE__ */ new Set();
135
+ const rules = [];
136
+ for (const part of source.split(",")) {
137
+ const rule = parseRule(part, source, spec, expression);
138
+ if (rule) {
139
+ rules.push(rule);
140
+ continue;
141
+ }
142
+ const parsed = spec.label === "day of week" ? parseDayOfWeekPart(part, spec, expression) : parsePart(part, spec, expression);
143
+ for (const value of parsed) values.add(value);
144
+ }
145
+ return { values, rules, restricted: source !== "*" };
146
+ }
147
+ function normalizeCronExpression(expression) {
148
+ return MACROS[expression.trim().toLowerCase()] ?? expression.trim();
149
+ }
150
+ function parseCronExpression(expression) {
151
+ const fields = normalizeCronExpression(expression).split(/\s+/);
152
+ if (fields.length !== 5) throw new CronExpressionError(expression, "expected 5 fields.");
153
+ if ([fields[0], fields[1], fields[3]].includes("?") || fields[2] === "?" && fields[4] === "?") {
154
+ throw new CronExpressionError(expression, '"?" is only allowed in one of the day-of-month and day-of-week fields.');
155
+ }
156
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields.map((field, index) => parseField(field, FIELD_SPECS[index], expression));
157
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
158
+ }
159
+ var formatters = /* @__PURE__ */ new Map();
160
+ function wallClock(date, timezone) {
161
+ let formatter = formatters.get(timezone);
162
+ if (!formatter) {
163
+ formatter = new Intl.DateTimeFormat("en-US", {
164
+ timeZone: timezone,
165
+ hourCycle: "h23",
166
+ year: "numeric",
167
+ month: "numeric",
168
+ day: "numeric",
169
+ hour: "numeric",
170
+ minute: "numeric",
171
+ weekday: "short"
172
+ });
173
+ formatters.set(timezone, formatter);
174
+ }
175
+ const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
176
+ return {
177
+ year: Number(parts.year),
178
+ month: Number(parts.month),
179
+ day: Number(parts.day),
180
+ hour: Number(parts.hour),
181
+ minute: Number(parts.minute),
182
+ weekday: WEEKDAY_NAMES.indexOf(String(parts.weekday).toUpperCase())
183
+ };
184
+ }
185
+ function dayMatches(schedule, clock) {
186
+ if (!schedule.month.values.has(clock.month)) return false;
187
+ const dayOfMonth = schedule.dayOfMonth.values.has(clock.day) || schedule.dayOfMonth.rules.some((rule) => rule(clock));
188
+ const dayOfWeek = schedule.dayOfWeek.values.has(clock.weekday) || schedule.dayOfWeek.rules.some((rule) => rule(clock));
189
+ if (schedule.dayOfMonth.restricted && schedule.dayOfWeek.restricted) return dayOfMonth || dayOfWeek;
190
+ return dayOfMonth && dayOfWeek;
191
+ }
192
+ function assertTimezone(timezone) {
193
+ try {
194
+ return new Intl.DateTimeFormat("en-US", { timeZone: timezone }).resolvedOptions().timeZone;
195
+ } catch {
196
+ throw new RangeError(`[@holo-js/schedule] Invalid timezone: ${timezone}`);
197
+ }
198
+ }
199
+ function formatHourMinute(date, timezone) {
200
+ const clock = wallClock(date, timezone);
201
+ return `${String(clock.hour).padStart(2, "0")}${String(clock.minute).padStart(2, "0")}`;
202
+ }
203
+ function transitionHourShift(time, timezone) {
204
+ const before = offsetAt(time - HOUR_MS, timezone);
205
+ if (before === offsetAt(time, timezone)) return void 0;
206
+ const change = offsetAt(time + HOUR_MS, timezone) - before;
207
+ if (change >= HOUR_MS) return -1;
208
+ if (change <= -HOUR_MS) return 1;
209
+ return void 0;
210
+ }
211
+ function hourMatches(schedule, time, clock, timezone) {
212
+ if (schedule.hour.values.has(clock.hour)) return true;
213
+ const shift = transitionHourShift(time, timezone);
214
+ return shift !== void 0 && schedule.hour.values.has(clock.hour + shift);
215
+ }
216
+ function cronMatches(schedule, date, timezone) {
217
+ const clock = wallClock(date, timezone);
218
+ return dayMatches(schedule, clock) && schedule.minute.values.has(clock.minute) && hourMatches(schedule, date.getTime(), clock, timezone);
219
+ }
220
+ function offsetAt(time, timezone) {
221
+ const clock = wallClock(new Date(time), timezone);
222
+ return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hour, clock.minute) - Math.floor(time / MINUTE_MS) * MINUTE_MS;
223
+ }
224
+ function nextLocalMidnight(time, clock, timezone) {
225
+ const midnight = Date.UTC(clock.year, clock.month - 1, clock.day + 1);
226
+ const estimate = midnight - offsetAt(midnight, timezone);
227
+ const candidate = Math.min(estimate, midnight - offsetAt(estimate, timezone));
228
+ return Math.max(time + MINUTE_MS, candidate);
229
+ }
230
+ function nextCronDate(schedule, after, timezone, expression) {
231
+ let time = Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
232
+ const limit = time + MAX_SEARCH_DAYS * 24 * 60 * MINUTE_MS;
233
+ while (time <= limit) {
234
+ const clock = wallClock(new Date(time), timezone);
235
+ if (!dayMatches(schedule, clock)) {
236
+ time = nextLocalMidnight(time, clock, timezone);
237
+ continue;
238
+ }
239
+ if (!hourMatches(schedule, time, clock, timezone)) {
240
+ time += transitionHourShift(time, timezone) === void 0 ? (60 - clock.minute) * MINUTE_MS : MINUTE_MS;
241
+ continue;
242
+ }
243
+ if (schedule.minute.values.has(clock.minute)) return new Date(time);
244
+ time += MINUTE_MS;
245
+ }
246
+ throw new CronExpressionError(expression, "it never matches a date.");
247
+ }
248
+
249
+ // src/task.ts
250
+ var SCHEDULED_TASK_STATE = /* @__PURE__ */ Symbol.for("holo-js.schedule.task-state");
251
+ var MONDAY = 1;
252
+ var FRIDAY = 5;
253
+ var SATURDAY = 6;
254
+ var SUNDAY = 0;
255
+ function joinValues(values) {
256
+ return typeof values === "number" ? String(values) : values.join(",");
257
+ }
258
+ function parseTime(time) {
259
+ const match = /^(\d{1,2})(?::(\d{1,2}))?$/.exec(time.trim());
260
+ const hour = Number(match?.[1]);
261
+ const minute = Number(match?.[2] ?? 0);
262
+ if (!match || hour > 23 || minute > 59) {
263
+ throw new RangeError(`[@holo-js/schedule] Invalid time "${time}". Expected HH:MM.`);
264
+ }
265
+ return { hour, minute };
266
+ }
267
+ var ScheduledTask = class {
268
+ #state;
269
+ constructor(action) {
270
+ this.#state = {
271
+ action,
272
+ expression: "* * * * *",
273
+ withoutOverlapping: false,
274
+ expiresAtMinutes: 1440,
275
+ onOneServer: false
276
+ };
277
+ }
278
+ get [SCHEDULED_TASK_STATE]() {
279
+ return this.#state;
280
+ }
281
+ cron(expression) {
282
+ parseCronExpression(expression);
283
+ this.#state = { ...this.#state, expression: normalizeCronExpression(expression) };
284
+ return this;
285
+ }
286
+ timezone(timezone) {
287
+ this.#state = { ...this.#state, timezone: assertTimezone(timezone) };
288
+ return this;
289
+ }
290
+ name(description) {
291
+ if (!description.trim()) throw new Error("[@holo-js/schedule] Scheduled task names must not be empty.");
292
+ this.#state = { ...this.#state, description };
293
+ return this;
294
+ }
295
+ withoutOverlapping(expiresAtMinutes = 1440) {
296
+ if (!Number.isInteger(expiresAtMinutes) || expiresAtMinutes < 1) {
297
+ throw new RangeError("[@holo-js/schedule] withoutOverlapping() expects a positive whole number of minutes.");
298
+ }
299
+ this.#state = { ...this.#state, withoutOverlapping: true, expiresAtMinutes };
300
+ return this;
301
+ }
302
+ onOneServer() {
303
+ this.#state = { ...this.#state, onOneServer: true };
304
+ return this;
305
+ }
306
+ everyMinute() {
307
+ return this.spliceIntoPosition(1, "*");
308
+ }
309
+ everyTwoMinutes() {
310
+ return this.spliceIntoPosition(1, "*/2");
311
+ }
312
+ everyThreeMinutes() {
313
+ return this.spliceIntoPosition(1, "*/3");
314
+ }
315
+ everyFourMinutes() {
316
+ return this.spliceIntoPosition(1, "*/4");
317
+ }
318
+ everyFiveMinutes() {
319
+ return this.spliceIntoPosition(1, "*/5");
320
+ }
321
+ everyTenMinutes() {
322
+ return this.spliceIntoPosition(1, "*/10");
323
+ }
324
+ everyFifteenMinutes() {
325
+ return this.spliceIntoPosition(1, "*/15");
326
+ }
327
+ everyThirtyMinutes() {
328
+ return this.spliceIntoPosition(1, "*/30");
329
+ }
330
+ hourly() {
331
+ return this.spliceIntoPosition(1, 0);
332
+ }
333
+ hourlyAt(offset) {
334
+ return this.hourBasedSchedule(joinValues(offset), "*");
335
+ }
336
+ everyOddHour(offset = 0) {
337
+ return this.hourBasedSchedule(joinValues(offset), "1-23/2");
338
+ }
339
+ everyTwoHours(offset = 0) {
340
+ return this.hourBasedSchedule(joinValues(offset), "*/2");
341
+ }
342
+ everyThreeHours(offset = 0) {
343
+ return this.hourBasedSchedule(joinValues(offset), "*/3");
344
+ }
345
+ everyFourHours(offset = 0) {
346
+ return this.hourBasedSchedule(joinValues(offset), "*/4");
347
+ }
348
+ everySixHours(offset = 0) {
349
+ return this.hourBasedSchedule(joinValues(offset), "*/6");
350
+ }
351
+ daily() {
352
+ return this.hourBasedSchedule(0, 0);
353
+ }
354
+ at(time) {
355
+ return this.dailyAt(time);
356
+ }
357
+ dailyAt(time) {
358
+ const { hour, minute } = parseTime(time);
359
+ return this.hourBasedSchedule(minute, hour);
360
+ }
361
+ twiceDaily(first = 1, second = 13) {
362
+ return this.twiceDailyAt(first, second, 0);
363
+ }
364
+ twiceDailyAt(first = 1, second = 13, offset = 0) {
365
+ return this.hourBasedSchedule(offset, `${first},${second}`);
366
+ }
367
+ weekdays() {
368
+ return this.days(`${MONDAY}-${FRIDAY}`);
369
+ }
370
+ weekends() {
371
+ return this.days(`${SATURDAY},${SUNDAY}`);
372
+ }
373
+ mondays() {
374
+ return this.days(1);
375
+ }
376
+ tuesdays() {
377
+ return this.days(2);
378
+ }
379
+ wednesdays() {
380
+ return this.days(3);
381
+ }
382
+ thursdays() {
383
+ return this.days(4);
384
+ }
385
+ fridays() {
386
+ return this.days(5);
387
+ }
388
+ saturdays() {
389
+ return this.days(6);
390
+ }
391
+ sundays() {
392
+ return this.days(0);
393
+ }
394
+ days(...days) {
395
+ return this.spliceIntoPosition(5, days.flat().join(","));
396
+ }
397
+ weekly() {
398
+ return this.spliceIntoPosition(1, 0).spliceIntoPosition(2, 0).spliceIntoPosition(5, 0);
399
+ }
400
+ weeklyOn(dayOfWeek, time = "0:0") {
401
+ return this.dailyAt(time).days(joinValues(dayOfWeek));
402
+ }
403
+ monthly() {
404
+ return this.spliceIntoPosition(1, 0).spliceIntoPosition(2, 0).spliceIntoPosition(3, 1);
405
+ }
406
+ monthlyOn(dayOfMonth = 1, time = "0:0") {
407
+ return this.dailyAt(time).spliceIntoPosition(3, dayOfMonth);
408
+ }
409
+ twiceMonthly(first = 1, second = 16, time = "0:0") {
410
+ return this.dailyAt(time).spliceIntoPosition(3, `${first},${second}`);
411
+ }
412
+ quarterly() {
413
+ return this.monthly().spliceIntoPosition(4, "1-12/3");
414
+ }
415
+ quarterlyOn(dayOfQuarter = 1, time = "0:0") {
416
+ return this.dailyAt(time).spliceIntoPosition(3, dayOfQuarter).spliceIntoPosition(4, "1-12/3");
417
+ }
418
+ yearly() {
419
+ return this.monthly().spliceIntoPosition(4, 1);
420
+ }
421
+ yearlyOn(month = 1, dayOfMonth = 1, time = "0:0") {
422
+ return this.dailyAt(time).spliceIntoPosition(3, dayOfMonth).spliceIntoPosition(4, month);
423
+ }
424
+ hourBasedSchedule(minutes, hours) {
425
+ return this.spliceIntoPosition(1, minutes).spliceIntoPosition(2, hours);
426
+ }
427
+ spliceIntoPosition(position, value) {
428
+ const segments = this.#state.expression.split(/\s+/);
429
+ segments[position - 1] = String(value);
430
+ return this.cron(segments.join(" "));
431
+ }
432
+ };
433
+
434
+ // src/schedule.ts
435
+ var SCHEDULE_DEFINITION = /* @__PURE__ */ Symbol.for("holo-js.schedule.definition");
436
+ var DOUBLE_QUOTE_ESCAPES = /* @__PURE__ */ new Set(["$", "`", '"', "\\", "\n"]);
437
+ function tokenizeCommand(command) {
438
+ const tokens = [];
439
+ let token;
440
+ let quote;
441
+ for (let index = 0; index < command.length; index += 1) {
442
+ const character = command[index];
443
+ const next = command[index + 1];
444
+ if (quote === "'") {
445
+ if (character === quote) quote = void 0;
446
+ else token += character;
447
+ continue;
448
+ }
449
+ if (character === "\\" && next !== void 0 && (quote === void 0 || DOUBLE_QUOTE_ESCAPES.has(next))) {
450
+ index += 1;
451
+ if (next !== "\n") token = (token ?? "") + next;
452
+ continue;
453
+ }
454
+ if (quote === '"') {
455
+ if (character === quote) quote = void 0;
456
+ else token += character;
457
+ continue;
458
+ }
459
+ if (/\s/.test(character)) {
460
+ if (token !== void 0) tokens.push(token);
461
+ token = void 0;
462
+ continue;
463
+ }
464
+ token ??= "";
465
+ if (character === '"' || character === "'") quote = character;
466
+ else token += character;
467
+ }
468
+ if (quote) throw new Error(`[@holo-js/schedule] Scheduled command has an unterminated ${quote} quote: ${command}`);
469
+ if (token !== void 0) tokens.push(token);
470
+ return tokens;
471
+ }
472
+ var Schedule = class {
473
+ #tasks = [];
474
+ command(command) {
475
+ const argv = tokenizeCommand(command);
476
+ if (argv.length === 0) throw new Error("[@holo-js/schedule] Scheduled commands must name a command.");
477
+ return this.add(new ScheduledTask({ kind: "command", command: command.trim(), argv }));
478
+ }
479
+ job(job, payload) {
480
+ return this.add(new ScheduledTask({ kind: "job", job, dispatch: () => job.dispatch(payload) }));
481
+ }
482
+ call(callback) {
483
+ return this.add(new ScheduledTask({ kind: "call", callback }));
484
+ }
485
+ get tasks() {
486
+ return [...this.#tasks];
487
+ }
488
+ add(task) {
489
+ this.#tasks.push(task);
490
+ return task;
491
+ }
492
+ };
493
+ function isPromiseLike(value) {
494
+ return typeof value === "object" && value !== null && typeof value.then === "function";
495
+ }
496
+ function defineSchedule(define) {
497
+ let tasks;
498
+ return Object.freeze({
499
+ [SCHEDULE_DEFINITION]: true,
500
+ get tasks() {
501
+ if (!tasks) {
502
+ const schedule = new Schedule();
503
+ const result = define(schedule);
504
+ if (isPromiseLike(result)) {
505
+ Promise.resolve(result).catch(() => void 0);
506
+ throw new Error("[@holo-js/schedule] defineSchedule() callbacks must define tasks synchronously.");
507
+ }
508
+ tasks = Object.freeze(schedule.tasks);
509
+ }
510
+ return tasks;
511
+ }
512
+ });
513
+ }
514
+ function isScheduleDefinition(value) {
515
+ if (typeof value !== "object" || value === null || value[SCHEDULE_DEFINITION] !== true) return false;
516
+ const tasks = Object.getOwnPropertyDescriptor(value, "tasks");
517
+ return typeof tasks?.get === "function" || Array.isArray(tasks?.value);
518
+ }
519
+
520
+ // src/runner.ts
521
+ import { createHash } from "crypto";
522
+ var ONE_SERVER_LOCK_SECONDS = 3600;
523
+ function sha1(value) {
524
+ return createHash("sha1").update(value).digest("hex");
525
+ }
526
+ function stateOf(task) {
527
+ return task[SCHEDULED_TASK_STATE];
528
+ }
529
+ async function resolveJobName(job) {
530
+ try {
531
+ const { queueJobInternals } = await import("@holo-js/queue");
532
+ return queueJobInternals.resolveQueueJobDefinitionName(job);
533
+ } catch {
534
+ return void 0;
535
+ }
536
+ }
537
+ async function requireJobName(state, job) {
538
+ const name = state.description ?? await resolveJobName(job);
539
+ if (name === void 0) {
540
+ throw new Error("[@holo-js/schedule] Name this job with name() to use withoutOverlapping() or onOneServer(); it has no registered job name.");
541
+ }
542
+ return name;
543
+ }
544
+ async function describeTask(state) {
545
+ if (state.description) return state.description;
546
+ switch (state.action.kind) {
547
+ case "command":
548
+ return `holo ${state.action.command}`;
549
+ case "job":
550
+ return await resolveJobName(state.action.job) ?? "Job";
551
+ case "call":
552
+ return "Closure";
553
+ }
554
+ }
555
+ async function mutexName(state) {
556
+ switch (state.action.kind) {
557
+ case "command":
558
+ return `framework/schedule-${sha1(state.expression + state.action.command)}`;
559
+ case "job":
560
+ return `framework/schedule-${sha1(await requireJobName(state, state.action.job))}`;
561
+ case "call":
562
+ return `framework/schedule-${sha1(state.description ?? state.expression + state.action.callback.toString())}`;
563
+ }
564
+ }
565
+ async function cacheLock(name, seconds) {
566
+ const { cacheFacade } = await import("@holo-js/cache");
567
+ return cacheFacade.lock(name, seconds);
568
+ }
569
+ async function execute(state, summary, options) {
570
+ switch (state.action.kind) {
571
+ case "command": {
572
+ const exitCode = await options.runCommand(state.action.argv);
573
+ if (exitCode !== 0) throw new Error(`Scheduled command [${summary}] failed with exit code [${exitCode}].`);
574
+ return;
575
+ }
576
+ case "job":
577
+ await state.action.dispatch();
578
+ return;
579
+ case "call":
580
+ if (await state.action.callback() === false) {
581
+ throw new Error(`Scheduled task [${summary}] failed with exit code [1].`);
582
+ }
583
+ }
584
+ }
585
+ async function isLockFree(name, seconds) {
586
+ const probe = await cacheLock(name, seconds);
587
+ return await probe.get(() => true) === true;
588
+ }
589
+ async function acquireServer(name, options, decisions) {
590
+ let decision = decisions.get(name);
591
+ if (!decision) {
592
+ decision = cacheLock(`${name}${formatHourMinute(options.now, options.timezone)}`, ONE_SERVER_LOCK_SECONDS).then(async (lock) => await lock.get() === true);
593
+ decisions.set(name, decision);
594
+ }
595
+ return await decision;
596
+ }
597
+ async function runTask(state, options, serverDecisions) {
598
+ const summary = await describeTask(state);
599
+ if (!state.withoutOverlapping && !state.onOneServer) {
600
+ if (options.signal?.aborted) return void 0;
601
+ await execute(state, summary, options);
602
+ return { summary, outcome: "ran" };
603
+ }
604
+ const name = await mutexName(state);
605
+ if (state.withoutOverlapping && !await isLockFree(name, state.expiresAtMinutes * 60)) {
606
+ return { summary, outcome: "skipped-overlapping" };
607
+ }
608
+ if (state.onOneServer && !await acquireServer(name, options, serverDecisions)) {
609
+ return { summary, outcome: "skipped-other-server" };
610
+ }
611
+ const overlapLock = state.withoutOverlapping ? await cacheLock(name, state.expiresAtMinutes * 60) : void 0;
612
+ if (overlapLock && await overlapLock.get() !== true) return { summary, outcome: "skipped-overlapping" };
613
+ try {
614
+ if (options.signal?.aborted) return void 0;
615
+ await execute(state, summary, options);
616
+ return { summary, outcome: "ran" };
617
+ } finally {
618
+ await overlapLock?.release();
619
+ }
620
+ }
621
+ function isDue(state, now, timezone) {
622
+ return cronMatches(parseCronExpression(state.expression), now, state.timezone ?? timezone);
623
+ }
624
+ async function runDueScheduledTasks(definition, options) {
625
+ const due = definition.tasks.map(stateOf).filter((state) => isDue(state, options.now, options.timezone));
626
+ const reports = [];
627
+ const serverDecisions = /* @__PURE__ */ new Map();
628
+ for (const state of due) {
629
+ if (options.signal?.aborted) break;
630
+ const report = await runTask(state, options, serverDecisions).catch(async (error) => ({
631
+ summary: await describeTask(state).catch(() => state.action.kind),
632
+ outcome: "failed",
633
+ error: error instanceof Error ? error : new Error(String(error))
634
+ }));
635
+ if (!report) break;
636
+ reports.push(report);
637
+ options.onTaskFinished?.(report);
638
+ }
639
+ return reports;
640
+ }
641
+ async function listScheduledTasks(definition, options) {
642
+ return await Promise.all(definition.tasks.map(stateOf).map(async (state) => {
643
+ const timezone = state.timezone ?? options.timezone;
644
+ return {
645
+ expression: state.expression,
646
+ summary: await describeTask(state),
647
+ timezone,
648
+ nextDueAt: nextCronDate(parseCronExpression(state.expression), options.now, timezone, state.expression)
649
+ };
650
+ }));
651
+ }
652
+ export {
653
+ CronExpressionError,
654
+ Schedule,
655
+ ScheduledTask,
656
+ defineSchedule,
657
+ isScheduleDefinition,
658
+ listScheduledTasks,
659
+ runDueScheduledTasks
660
+ };
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@holo-js/schedule",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "Holo-JS Framework - task scheduling",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cobraprojects/holo-js.git",
10
+ "directory": "packages/schedule"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs",
16
+ "default": "./dist/index.mjs"
17
+ }
18
+ },
19
+ "main": "./dist/index.mjs",
20
+ "types": "./dist/index.d.ts",
21
+ "files": [
22
+ "dist",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "stub": "tsup",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test": "vitest --run"
30
+ },
31
+ "peerDependencies": {
32
+ "@holo-js/cache": ">=0.4.0",
33
+ "@holo-js/queue": ">=0.4.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@holo-js/cache": {
37
+ "optional": true
38
+ },
39
+ "@holo-js/queue": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@holo-js/cache": "^0.5.0",
45
+ "@holo-js/queue": "^0.5.0",
46
+ "@types/node": "^22.20.5",
47
+ "tsup": "^9.0.0",
48
+ "typescript": "^6.0.3",
49
+ "vitest": "^5.0.5"
50
+ },
51
+ "engines": {
52
+ "node": "^22.18.0 || ^24.0.0 || ^26.0.0"
53
+ }
54
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.