@ultimat3/time 1.0.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 +98 -0
- package/package.json +35 -0
- package/src/business.d.ts +37 -0
- package/src/business.d.ts.map +1 -0
- package/src/business.js +68 -0
- package/src/business.js.map +1 -0
- package/src/business.ts +90 -0
- package/src/context.d.ts +40 -0
- package/src/context.d.ts.map +1 -0
- package/src/context.js +61 -0
- package/src/context.js.map +1 -0
- package/src/context.ts +94 -0
- package/src/cron-describe.ts +159 -0
- package/src/cron-occurrence.ts +203 -0
- package/src/cron-parse.ts +193 -0
- package/src/cron.d.ts +60 -0
- package/src/cron.d.ts.map +1 -0
- package/src/cron.js +390 -0
- package/src/cron.js.map +1 -0
- package/src/cron.ts +16 -0
- package/src/duration.d.ts +32 -0
- package/src/duration.d.ts.map +1 -0
- package/src/duration.js +134 -0
- package/src/duration.js.map +1 -0
- package/src/duration.ts +156 -0
- package/src/errors.d.ts +26 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +78 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +121 -0
- package/src/format.d.ts +54 -0
- package/src/format.d.ts.map +1 -0
- package/src/format.js +133 -0
- package/src/format.js.map +1 -0
- package/src/format.ts +175 -0
- package/src/index.d.ts +12 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +137 -0
- package/src/instant.d.ts +38 -0
- package/src/instant.d.ts.map +1 -0
- package/src/instant.js +76 -0
- package/src/instant.js.map +1 -0
- package/src/instant.ts +94 -0
- package/src/schedule.d.ts +24 -0
- package/src/schedule.d.ts.map +1 -0
- package/src/schedule.js +67 -0
- package/src/schedule.js.map +1 -0
- package/src/schedule.ts +90 -0
- package/src/zoned.d.ts +80 -0
- package/src/zoned.d.ts.map +1 -0
- package/src/zoned.js +146 -0
- package/src/zoned.js.map +1 -0
- package/src/zoned.ts +268 -0
- package/src/zones.d.ts +40 -0
- package/src/zones.d.ts.map +1 -0
- package/src/zones.js +116 -0
- package/src/zones.js.map +1 -0
- package/src/zones.ts +167 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable cron descriptions (`describeCron`): month and weekday names from `Intl`, every
|
|
3
|
+
* connective phrase supplied by the caller. Tier 1 cannot reach `t()`, and a default set would
|
|
4
|
+
* ship English to every locale that forgot the argument — so injection is mandatory, not opt-in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type CronExpression, parseCronOnce } from './cron-parse';
|
|
8
|
+
import { localeInvalid } from './errors';
|
|
9
|
+
|
|
10
|
+
export interface CronPhrases {
|
|
11
|
+
everyMinute: string;
|
|
12
|
+
everyNMinutes: string;
|
|
13
|
+
everyHour: string;
|
|
14
|
+
everyNHours: string;
|
|
15
|
+
at: string;
|
|
16
|
+
/** Closes a capped clock-time list, e.g. `and {n} more`. */
|
|
17
|
+
andMore: string;
|
|
18
|
+
onDaysOfMonth: string;
|
|
19
|
+
onWeekdays: string;
|
|
20
|
+
inMonths: string;
|
|
21
|
+
everyDay: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `1-59 * * * *` expands to 1416 clock times; a summary that long is not a summary. */
|
|
25
|
+
const MAX_LISTED_TIMES = 6;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `describeCron('0 3 * * MON-FRI', 'en', phrases)` → `at 03:00 on Monday–Friday`.
|
|
29
|
+
* Every phrase comes from the caller's `t('time.cron.*')`; only the names are `Intl`'s.
|
|
30
|
+
*/
|
|
31
|
+
export function describeCron(
|
|
32
|
+
expression: string | CronExpression,
|
|
33
|
+
locale: string,
|
|
34
|
+
phrases: CronPhrases,
|
|
35
|
+
): string {
|
|
36
|
+
assertLocale(locale);
|
|
37
|
+
const cron = parseCronOnce(expression);
|
|
38
|
+
const list = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' });
|
|
39
|
+
const segments: string[] = [];
|
|
40
|
+
|
|
41
|
+
const minuteStep = uniformStep(cron.minutes, 60);
|
|
42
|
+
const hourStep = uniformStep(cron.hours, 24);
|
|
43
|
+
// Only a fixed clock time reads as "at 03:00 every day"; an interval already says it.
|
|
44
|
+
let explicitTime = false;
|
|
45
|
+
|
|
46
|
+
if (minuteStep !== undefined && cron.hours.length === 24) {
|
|
47
|
+
segments.push(
|
|
48
|
+
minuteStep === 1 ? phrases.everyMinute : fill(phrases.everyNMinutes, { n: minuteStep }),
|
|
49
|
+
);
|
|
50
|
+
} else if (cron.minutes.length === 1 && cron.minutes[0] === 0 && hourStep !== undefined) {
|
|
51
|
+
// Only an on-the-hour minute is a bare interval: `15 */6 * * *` must keep its :15 offset.
|
|
52
|
+
segments.push(hourStep === 1 ? phrases.everyHour : fill(phrases.everyNHours, { n: hourStep }));
|
|
53
|
+
} else {
|
|
54
|
+
explicitTime = true;
|
|
55
|
+
segments.push(fill(phrases.at, { time: clockTimes(cron, phrases, locale, list) }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cron.dayOfMonthRestricted) {
|
|
59
|
+
const days = list.format(cron.daysOfMonth.map(String));
|
|
60
|
+
segments.push(fill(phrases.onDaysOfMonth, { days }));
|
|
61
|
+
}
|
|
62
|
+
if (cron.dayOfWeekRestricted) {
|
|
63
|
+
const days = list.format(cron.daysOfWeek.map((day) => weekdayName(day, locale)));
|
|
64
|
+
segments.push(fill(phrases.onWeekdays, { days }));
|
|
65
|
+
}
|
|
66
|
+
if (cron.months.length < 12) {
|
|
67
|
+
const months = list.format(cron.months.map((month) => monthName(month, locale)));
|
|
68
|
+
segments.push(fill(phrases.inMonths, { months }));
|
|
69
|
+
}
|
|
70
|
+
if (explicitTime && segments.length === 1) segments.push(phrases.everyDay);
|
|
71
|
+
return segments.join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The clock times, capped — the overflow is counted out loud so a cut list never reads whole. */
|
|
75
|
+
function clockTimes(
|
|
76
|
+
cron: CronExpression,
|
|
77
|
+
phrases: CronPhrases,
|
|
78
|
+
locale: string,
|
|
79
|
+
list: Intl.ListFormat,
|
|
80
|
+
): string {
|
|
81
|
+
const times = cron.hours.flatMap((hour) =>
|
|
82
|
+
cron.minutes.map((minute) => `${pad2(hour)}:${pad2(minute)}`),
|
|
83
|
+
);
|
|
84
|
+
if (times.length <= MAX_LISTED_TIMES) return list.format(times);
|
|
85
|
+
// A cut list is not a closed one, so it drops the conjunction: `… and 09:25` would promise
|
|
86
|
+
// 09:25 is the last time there is. `unit` is ICU's comma-only list for exactly that reason.
|
|
87
|
+
const open = new Intl.ListFormat(locale, { style: 'long', type: 'unit' });
|
|
88
|
+
const shown = open.format(times.slice(0, MAX_LISTED_TIMES));
|
|
89
|
+
return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point. */
|
|
93
|
+
function assertLocale(locale: string): void {
|
|
94
|
+
try {
|
|
95
|
+
Intl.DateTimeFormat.supportedLocalesOf([locale]);
|
|
96
|
+
} catch {
|
|
97
|
+
throw localeInvalid(locale);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
|
|
102
|
+
function uniformStep(values: readonly number[], size: number): number | undefined {
|
|
103
|
+
const first = values[0];
|
|
104
|
+
if (values.length < 2 || first !== 0) return undefined;
|
|
105
|
+
const step = (values[1] ?? 0) - first;
|
|
106
|
+
if (step <= 0 || values.length !== Math.ceil(size / step)) return undefined;
|
|
107
|
+
for (let index = 1; index < values.length; index += 1) {
|
|
108
|
+
if ((values[index] ?? -1) - (values[index - 1] ?? -1) !== step) return undefined;
|
|
109
|
+
}
|
|
110
|
+
return step;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fill(template: string, vars: Readonly<Record<string, string | number>>): string {
|
|
114
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
115
|
+
const value = vars[name];
|
|
116
|
+
return value === undefined ? match : String(value);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Hard-capped rather than key-normalised, because `locale` can arrive from an Accept-Language
|
|
122
|
+
* header: `supportedLocalesOf` collapses unknown *tags*, but still returns a distinct string for
|
|
123
|
+
* every unknown `-u-` extension value, so only a bound keeps the key space finite. FIFO — a `Map`
|
|
124
|
+
* iterates in insertion order — and a miss costs one `Intl` construction, not a wrong answer.
|
|
125
|
+
*/
|
|
126
|
+
const MAX_CACHED_LOCALES = 32;
|
|
127
|
+
const monthFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
128
|
+
const weekdayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
129
|
+
|
|
130
|
+
function formatterFor(
|
|
131
|
+
cache: Map<string, Intl.DateTimeFormat>,
|
|
132
|
+
locale: string,
|
|
133
|
+
options: Intl.DateTimeFormatOptions,
|
|
134
|
+
): Intl.DateTimeFormat {
|
|
135
|
+
const hit = cache.get(locale);
|
|
136
|
+
if (hit !== undefined) return hit;
|
|
137
|
+
const formatter = new Intl.DateTimeFormat(locale, options);
|
|
138
|
+
if (cache.size >= MAX_CACHED_LOCALES) {
|
|
139
|
+
const oldest = cache.keys().next().value;
|
|
140
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
141
|
+
}
|
|
142
|
+
cache.set(locale, formatter);
|
|
143
|
+
return formatter;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function monthName(month: number, locale: string): string {
|
|
147
|
+
const formatter = formatterFor(monthFormatters, locale, { month: 'long', timeZone: 'UTC' });
|
|
148
|
+
return formatter.format(new Date(Date.UTC(2026, month - 1, 1)));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function weekdayName(isoDay: number, locale: string): string {
|
|
152
|
+
const formatter = formatterFor(weekdayFormatters, locale, { weekday: 'long', timeZone: 'UTC' });
|
|
153
|
+
// 2026-06-01 is a Monday, so ISO day N is that date + (N - 1).
|
|
154
|
+
return formatter.format(new Date(Date.UTC(2026, 5, isoDay)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pad2(value: number): string {
|
|
158
|
+
return String(value).padStart(2, '0');
|
|
159
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron occurrence math: walking the local wall clock forward to the next matching instant,
|
|
3
|
+
* converted once with `fromZoned` so DST gaps and overlaps resolve correctly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CronExpression, matchesDay, parseCronOnce } from './cron-parse';
|
|
7
|
+
import { cronInvalid } from './errors';
|
|
8
|
+
import { fromEpochMs, type Instant } from './instant';
|
|
9
|
+
import { fromZoned, toZoned } from './zoned';
|
|
10
|
+
import { assertTimeZone, type TimeZone, utcEpoch } from './zones';
|
|
11
|
+
|
|
12
|
+
/** True when `at` matches the expression in `zone`, to the second. */
|
|
13
|
+
export function matchesCron(
|
|
14
|
+
expression: string | CronExpression,
|
|
15
|
+
at: Instant,
|
|
16
|
+
zone: TimeZone,
|
|
17
|
+
): boolean {
|
|
18
|
+
const cron = parseCronOnce(expression);
|
|
19
|
+
const zoned = toZoned(at, zone);
|
|
20
|
+
return (
|
|
21
|
+
cron.seconds.includes(zoned.second) &&
|
|
22
|
+
cron.minutes.includes(zoned.minute) &&
|
|
23
|
+
cron.hours.includes(zoned.hour) &&
|
|
24
|
+
cron.months.includes(zoned.month) &&
|
|
25
|
+
matchesDay(cron, zoned.day, zoned.weekday)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Iteration guard, counted in field advancements — not in minutes, and not in years. Each step
|
|
31
|
+
* moves whichever field rejected first, so one step is worth a second or a whole month depending
|
|
32
|
+
* on the expression; the budget only has to outlast every schedule that can actually match.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_STEPS = 200_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The next instant strictly after `after` that matches, computed on the zone's wall clock
|
|
38
|
+
* and converted with `fromZoned({ gap: 'next', overlap: 'first' })`:
|
|
39
|
+
* - spring forward: a 02:30 daily job runs once, at the first existing local time after
|
|
40
|
+
* the gap, instead of being silently skipped;
|
|
41
|
+
* - fall back: a repeated local time runs once, on its first occurrence.
|
|
42
|
+
*/
|
|
43
|
+
export function nextCronOccurrence(
|
|
44
|
+
expression: string | CronExpression,
|
|
45
|
+
zone: TimeZone,
|
|
46
|
+
after: Instant,
|
|
47
|
+
): Instant {
|
|
48
|
+
const cron = parseCronOnce(expression);
|
|
49
|
+
assertTimeZone(zone);
|
|
50
|
+
|
|
51
|
+
const start = toZoned(after, zone);
|
|
52
|
+
const cursor: Cursor = {
|
|
53
|
+
year: start.year,
|
|
54
|
+
month: start.month,
|
|
55
|
+
day: start.day,
|
|
56
|
+
hour: start.hour,
|
|
57
|
+
minute: start.minute,
|
|
58
|
+
second: start.second + 1,
|
|
59
|
+
};
|
|
60
|
+
carry(cursor);
|
|
61
|
+
|
|
62
|
+
for (let step = 0; step < MAX_STEPS; step += 1) {
|
|
63
|
+
if (!cron.months.includes(cursor.month)) {
|
|
64
|
+
cursor.month += 1;
|
|
65
|
+
cursor.day = 1;
|
|
66
|
+
resetTime(cursor);
|
|
67
|
+
carry(cursor);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (cursor.day > daysInMonth(cursor.year, cursor.month)) {
|
|
71
|
+
cursor.month += 1;
|
|
72
|
+
cursor.day = 1;
|
|
73
|
+
resetTime(cursor);
|
|
74
|
+
carry(cursor);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!matchesDay(cron, cursor.day, isoWeekday(cursor))) {
|
|
78
|
+
cursor.day += 1;
|
|
79
|
+
resetTime(cursor);
|
|
80
|
+
carry(cursor);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!cron.hours.includes(cursor.hour)) {
|
|
84
|
+
cursor.hour += 1;
|
|
85
|
+
cursor.minute = 0;
|
|
86
|
+
cursor.second = 0;
|
|
87
|
+
carry(cursor);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!cron.minutes.includes(cursor.minute)) {
|
|
91
|
+
cursor.minute += 1;
|
|
92
|
+
cursor.second = 0;
|
|
93
|
+
carry(cursor);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!cron.seconds.includes(cursor.second)) {
|
|
97
|
+
cursor.second += 1;
|
|
98
|
+
carry(cursor);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const candidate = fromZoned({ ...cursor }, zone, { gap: 'next', overlap: 'first' });
|
|
103
|
+
if (candidate.getTime() > after.getTime()) return candidate;
|
|
104
|
+
// The DST gap can push a candidate onto an instant we have already passed; step on.
|
|
105
|
+
cursor.second += 1;
|
|
106
|
+
carry(cursor);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw cronInvalid(
|
|
110
|
+
typeof expression === 'string' ? expression : cron.source,
|
|
111
|
+
`no occurrence after ${MAX_STEPS} search steps — the date fields can never all match (e.g. "0 0 30 2 *", a 30th of February)`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The next `count` occurrences, each strictly after the previous one. */
|
|
116
|
+
export function nextCronOccurrences(
|
|
117
|
+
expression: string | CronExpression,
|
|
118
|
+
zone: TimeZone,
|
|
119
|
+
after: Instant,
|
|
120
|
+
count: number,
|
|
121
|
+
): Instant[] {
|
|
122
|
+
const cron = parseCronOnce(expression);
|
|
123
|
+
const results: Instant[] = [];
|
|
124
|
+
let cursor = after;
|
|
125
|
+
for (let index = 0; index < count; index += 1) {
|
|
126
|
+
cursor = nextCronOccurrence(cron, zone, cursor);
|
|
127
|
+
results.push(cursor);
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Exported for the scheduler's leader loop: has this expression fired since `since`? */
|
|
133
|
+
export function firedSince(
|
|
134
|
+
expression: string | CronExpression,
|
|
135
|
+
zone: TimeZone,
|
|
136
|
+
since: Instant,
|
|
137
|
+
until: Instant,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (until.getTime() <= since.getTime()) return false;
|
|
140
|
+
const next = nextCronOccurrence(expression, zone, since);
|
|
141
|
+
return next.getTime() <= until.getTime();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Epoch-ms helper used by the jobs package when it only has a number. */
|
|
145
|
+
export function nextCronOccurrenceMs(
|
|
146
|
+
expression: string | CronExpression,
|
|
147
|
+
zone: TimeZone,
|
|
148
|
+
afterMs: number,
|
|
149
|
+
): number {
|
|
150
|
+
return nextCronOccurrence(expression, zone, fromEpochMs(afterMs)).getTime();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface Cursor {
|
|
154
|
+
year: number;
|
|
155
|
+
month: number;
|
|
156
|
+
day: number;
|
|
157
|
+
hour: number;
|
|
158
|
+
minute: number;
|
|
159
|
+
second: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isoWeekday(cursor: Cursor): number {
|
|
163
|
+
const day = new Date(utcEpoch(cursor.year, cursor.month, cursor.day)).getUTCDay();
|
|
164
|
+
return ((day + 6) % 7) + 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function daysInMonth(year: number, month: number): number {
|
|
168
|
+
return new Date(utcEpoch(year, month + 1, 0)).getUTCDate();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resetTime(cursor: Cursor): void {
|
|
172
|
+
cursor.hour = 0;
|
|
173
|
+
cursor.minute = 0;
|
|
174
|
+
cursor.second = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Carry overflow up the fields so the cursor stays a real calendar date. */
|
|
178
|
+
function carry(cursor: Cursor): void {
|
|
179
|
+
if (cursor.second > 59) {
|
|
180
|
+
cursor.minute += Math.floor(cursor.second / 60);
|
|
181
|
+
cursor.second %= 60;
|
|
182
|
+
}
|
|
183
|
+
if (cursor.minute > 59) {
|
|
184
|
+
cursor.hour += Math.floor(cursor.minute / 60);
|
|
185
|
+
cursor.minute %= 60;
|
|
186
|
+
}
|
|
187
|
+
if (cursor.hour > 23) {
|
|
188
|
+
cursor.day += Math.floor(cursor.hour / 24);
|
|
189
|
+
cursor.hour %= 24;
|
|
190
|
+
}
|
|
191
|
+
while (cursor.month > 12) {
|
|
192
|
+
cursor.month -= 12;
|
|
193
|
+
cursor.year += 1;
|
|
194
|
+
}
|
|
195
|
+
while (cursor.day > daysInMonth(cursor.year, cursor.month)) {
|
|
196
|
+
cursor.day -= daysInMonth(cursor.year, cursor.month);
|
|
197
|
+
cursor.month += 1;
|
|
198
|
+
if (cursor.month > 12) {
|
|
199
|
+
cursor.month = 1;
|
|
200
|
+
cursor.year += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron expression parsing: field grammar (lists, ranges, steps, names, macros) into the
|
|
3
|
+
* normalized `CronExpression` shape the occurrence and description modules consume.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cronInvalid } from './errors';
|
|
7
|
+
|
|
8
|
+
export interface CronExpression {
|
|
9
|
+
/** The normalized source text. */
|
|
10
|
+
source: string;
|
|
11
|
+
seconds: readonly number[];
|
|
12
|
+
minutes: readonly number[];
|
|
13
|
+
hours: readonly number[];
|
|
14
|
+
daysOfMonth: readonly number[];
|
|
15
|
+
months: readonly number[];
|
|
16
|
+
/** ISO weekdays, 1 = Monday … 7 = Sunday (cron's 0 and 7 both mean Sunday). */
|
|
17
|
+
daysOfWeek: readonly number[];
|
|
18
|
+
/** Vixie semantics: when both day fields are restricted, either one matching is a hit. */
|
|
19
|
+
dayOfMonthRestricted: boolean;
|
|
20
|
+
dayOfWeekRestricted: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MACROS: Readonly<Record<string, string>> = {
|
|
24
|
+
'@yearly': '0 0 1 1 *',
|
|
25
|
+
'@annually': '0 0 1 1 *',
|
|
26
|
+
'@monthly': '0 0 1 * *',
|
|
27
|
+
'@weekly': '0 0 * * 0',
|
|
28
|
+
'@daily': '0 0 * * *',
|
|
29
|
+
'@midnight': '0 0 * * *',
|
|
30
|
+
'@hourly': '0 * * * *',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MONTH_NAMES = [
|
|
34
|
+
'jan',
|
|
35
|
+
'feb',
|
|
36
|
+
'mar',
|
|
37
|
+
'apr',
|
|
38
|
+
'may',
|
|
39
|
+
'jun',
|
|
40
|
+
'jul',
|
|
41
|
+
'aug',
|
|
42
|
+
'sep',
|
|
43
|
+
'oct',
|
|
44
|
+
'nov',
|
|
45
|
+
'dec',
|
|
46
|
+
];
|
|
47
|
+
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
48
|
+
|
|
49
|
+
/** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
|
|
50
|
+
export function parseCron(expression: string): CronExpression {
|
|
51
|
+
const trimmed = expression.trim().toLowerCase();
|
|
52
|
+
const expanded = MACROS[trimmed] ?? trimmed;
|
|
53
|
+
const fields = expanded.split(/\s+/).filter((field) => field !== '');
|
|
54
|
+
|
|
55
|
+
if (fields.length !== 5 && fields.length !== 6) {
|
|
56
|
+
throw cronInvalid(expression, `expected 5 or 6 fields, got ${fields.length}`);
|
|
57
|
+
}
|
|
58
|
+
const withSeconds = fields.length === 6;
|
|
59
|
+
const [secondField, minuteField, hourField, domField, monthField, dowField] = withSeconds
|
|
60
|
+
? fields
|
|
61
|
+
: ['0', ...fields];
|
|
62
|
+
|
|
63
|
+
const seconds = parseField(expression, secondField ?? '0', 0, 59);
|
|
64
|
+
const minutes = parseField(expression, minuteField ?? '*', 0, 59);
|
|
65
|
+
const hours = parseField(expression, hourField ?? '*', 0, 23);
|
|
66
|
+
const daysOfMonth = parseField(expression, domField ?? '*', 1, 31);
|
|
67
|
+
const months = parseField(expression, monthField ?? '*', 1, 12, MONTH_NAMES, 1);
|
|
68
|
+
const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0);
|
|
69
|
+
|
|
70
|
+
// 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
|
|
71
|
+
const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
source: expanded,
|
|
75
|
+
seconds,
|
|
76
|
+
minutes,
|
|
77
|
+
hours,
|
|
78
|
+
daysOfMonth,
|
|
79
|
+
months,
|
|
80
|
+
daysOfWeek,
|
|
81
|
+
dayOfMonthRestricted: isRestricted(domField ?? '*'),
|
|
82
|
+
dayOfWeekRestricted: isRestricted(dowField ?? '*'),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isValidCron(expression: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
parseCron(expression);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseCronOnce(expression: string | CronExpression): CronExpression {
|
|
96
|
+
return typeof expression === 'string' ? parseCron(expression) : expression;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when both day-of-month and day-of-week fields matter and either matching is a hit. */
|
|
100
|
+
export function matchesDay(cron: CronExpression, day: number, weekday: number): boolean {
|
|
101
|
+
const domHit = cron.daysOfMonth.includes(day);
|
|
102
|
+
const dowHit = cron.daysOfWeek.includes(weekday);
|
|
103
|
+
if (cron.dayOfMonthRestricted && cron.dayOfWeekRestricted) return domHit || dowHit;
|
|
104
|
+
if (cron.dayOfMonthRestricted) return domHit;
|
|
105
|
+
if (cron.dayOfWeekRestricted) return dowHit;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseField(
|
|
110
|
+
expression: string,
|
|
111
|
+
field: string,
|
|
112
|
+
min: number,
|
|
113
|
+
max: number,
|
|
114
|
+
names: readonly string[] = [],
|
|
115
|
+
nameOffset = 0,
|
|
116
|
+
): number[] {
|
|
117
|
+
const values = new Set<number>();
|
|
118
|
+
for (const part of field.split(',')) {
|
|
119
|
+
if (part === '') throw cronInvalid(expression, `empty list item in "${field}"`);
|
|
120
|
+
const [rangePart, stepPart] = part.split('/');
|
|
121
|
+
if (rangePart === undefined || (part.includes('/') && stepPart === undefined)) {
|
|
122
|
+
throw cronInvalid(expression, `malformed step in "${part}"`);
|
|
123
|
+
}
|
|
124
|
+
const step = stepPart === undefined ? 1 : parseInteger(stepPart);
|
|
125
|
+
if (step === undefined || step < 1) {
|
|
126
|
+
throw cronInvalid(expression, `step must be a positive integer in "${part}"`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let from: number;
|
|
130
|
+
let to: number;
|
|
131
|
+
if (isWildcard(rangePart)) {
|
|
132
|
+
from = min;
|
|
133
|
+
to = max;
|
|
134
|
+
} else if (rangePart.includes('-')) {
|
|
135
|
+
const [left, right] = rangePart.split('-');
|
|
136
|
+
from = toNumber(expression, left, min, max, names, nameOffset);
|
|
137
|
+
to = toNumber(expression, right, min, max, names, nameOffset);
|
|
138
|
+
} else {
|
|
139
|
+
from = toNumber(expression, rangePart, min, max, names, nameOffset);
|
|
140
|
+
to = stepPart === undefined ? from : max;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (from > to) {
|
|
144
|
+
// Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom.
|
|
145
|
+
for (let value = from; value <= max; value += step) values.add(value);
|
|
146
|
+
for (let value = min; value <= to; value += step) values.add(value);
|
|
147
|
+
} else {
|
|
148
|
+
for (let value = from; value <= to; value += step) values.add(value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (values.size === 0) throw cronInvalid(expression, `field "${field}" matches nothing`);
|
|
152
|
+
return [...values].sort((a, b) => a - b);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toNumber(
|
|
156
|
+
expression: string,
|
|
157
|
+
token: string | undefined,
|
|
158
|
+
min: number,
|
|
159
|
+
max: number,
|
|
160
|
+
names: readonly string[],
|
|
161
|
+
nameOffset: number,
|
|
162
|
+
): number {
|
|
163
|
+
if (token === undefined || token === '') throw cronInvalid(expression, 'missing value');
|
|
164
|
+
// Names match on their first three letters, so `mon` and `monday` both work — but a token
|
|
165
|
+
// carrying anything that is not a letter is a typo, not a name, and falls through to digits.
|
|
166
|
+
const named = /^[a-z]+$/.test(token) ? names.indexOf(token.slice(0, 3)) : -1;
|
|
167
|
+
const value = named === -1 ? parseInteger(token) : named + nameOffset;
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
throw cronInvalid(expression, `"${token}" is not a number or a name`);
|
|
170
|
+
}
|
|
171
|
+
if (value < min || value > max) {
|
|
172
|
+
throw cronInvalid(expression, `"${token}" is out of range ${min}-${max}`);
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Digits only. `Number.parseInt('5x')` is 5, which would let `* * * * 5x` validate — a
|
|
179
|
+
* validator that accepts what the scheduler cannot mean hides the typo until it misfires.
|
|
180
|
+
*/
|
|
181
|
+
function parseInteger(token: string): number | undefined {
|
|
182
|
+
return /^\d+$/.test(token) ? Number.parseInt(token, 10) : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isWildcard(field: string): boolean {
|
|
186
|
+
const [head] = field.split('/');
|
|
187
|
+
return head === '*' || head === '?';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `*` and `?` are "any"; a step like every-2nd-day restricts, and Vixie's OR rule sees that. */
|
|
191
|
+
function isRestricted(field: string): boolean {
|
|
192
|
+
return field !== '*' && field !== '?';
|
|
193
|
+
}
|
package/src/cron.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real cron parser and occurrence calculator. Timezone-aware through `fromZoned`, so
|
|
3
|
+
* `0 3 * * *` in `Europe/Berlin` fires at 03:00 local on both sides of a DST change
|
|
4
|
+
* instead of drifting to 02:00 or 04:00 for half the year.
|
|
5
|
+
*/
|
|
6
|
+
import { type Instant } from './instant';
|
|
7
|
+
import { type TimeZone } from './zones';
|
|
8
|
+
export interface CronExpression {
|
|
9
|
+
/** The normalized source text. */
|
|
10
|
+
source: string;
|
|
11
|
+
seconds: readonly number[];
|
|
12
|
+
minutes: readonly number[];
|
|
13
|
+
hours: readonly number[];
|
|
14
|
+
daysOfMonth: readonly number[];
|
|
15
|
+
months: readonly number[];
|
|
16
|
+
/** ISO weekdays, 1 = Monday … 7 = Sunday (cron's 0 and 7 both mean Sunday). */
|
|
17
|
+
daysOfWeek: readonly number[];
|
|
18
|
+
/** Vixie semantics: when both day fields are restricted, either one matching is a hit. */
|
|
19
|
+
dayOfMonthRestricted: boolean;
|
|
20
|
+
dayOfWeekRestricted: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
|
|
23
|
+
export declare function parseCron(expression: string): CronExpression;
|
|
24
|
+
export declare function isValidCron(expression: string): boolean;
|
|
25
|
+
/** True when `at` matches the expression in `zone`, to the second. */
|
|
26
|
+
export declare function matchesCron(expression: string | CronExpression, at: Instant, zone: TimeZone): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The next instant strictly after `after` that matches, computed on the zone's wall clock
|
|
29
|
+
* and converted with `fromZoned({ gap: 'next', overlap: 'first' })`:
|
|
30
|
+
* - spring forward: a 02:30 daily job runs once, at the first existing local time after
|
|
31
|
+
* the gap, instead of being silently skipped;
|
|
32
|
+
* - fall back: a repeated local time runs once, on its first occurrence.
|
|
33
|
+
*/
|
|
34
|
+
export declare function nextCronOccurrence(expression: string | CronExpression, zone: TimeZone, after: Instant): Instant;
|
|
35
|
+
/** The next `count` occurrences, each strictly after the previous one. */
|
|
36
|
+
export declare function nextCronOccurrences(expression: string | CronExpression, zone: TimeZone, after: Instant, count: number): Instant[];
|
|
37
|
+
export interface CronPhrases {
|
|
38
|
+
everyMinute: string;
|
|
39
|
+
everyNMinutes: string;
|
|
40
|
+
everyHour: string;
|
|
41
|
+
everyNHours: string;
|
|
42
|
+
at: string;
|
|
43
|
+
onDaysOfMonth: string;
|
|
44
|
+
onWeekdays: string;
|
|
45
|
+
inMonths: string;
|
|
46
|
+
everyDay: string;
|
|
47
|
+
}
|
|
48
|
+
/** English defaults; the admin dashboard passes `t('time.cron.*')` instead. */
|
|
49
|
+
export declare const DEFAULT_CRON_PHRASES: CronPhrases;
|
|
50
|
+
/**
|
|
51
|
+
* `describeCron('0 3 * * MON-FRI', 'en')` → `at 03:00 on Monday–Friday`.
|
|
52
|
+
* Month and weekday names come from `Intl`; the connective phrases are injected so this
|
|
53
|
+
* package never hardcodes a user-facing English string.
|
|
54
|
+
*/
|
|
55
|
+
export declare function describeCron(expression: string | CronExpression, locale: string, phrases?: Partial<CronPhrases>): string;
|
|
56
|
+
/** Exported for the scheduler's leader loop: has this expression fired since `since`? */
|
|
57
|
+
export declare function firedSince(expression: string | CronExpression, zone: TimeZone, since: Instant, until: Instant): boolean;
|
|
58
|
+
/** Epoch-ms helper used by the jobs package when it only has a number. */
|
|
59
|
+
export declare function nextCronOccurrenceMs(expression: string | CronExpression, zone: TimeZone, afterMs: number): number;
|
|
60
|
+
//# sourceMappingURL=cron.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["cron.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAe,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAEtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,+EAA+E;IAC/E,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,0FAA0F;IAC1F,oBAAoB,EAAE,OAAO,CAAC;IAC9B,mBAAmB,EAAE,OAAO,CAAC;CAC9B;AA4BD,4EAA4E;AAC5E,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc,CAkC5D;AAED,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAOvD;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,QAAQ,GACb,OAAO,CAUT;AAKD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,OAAO,GACb,OAAO,CAkET;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,MAAM,GACZ,OAAO,EAAE,CASX;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,+EAA+E;AAC/E,eAAO,MAAM,oBAAoB,EAAE,WAUlC,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,OAAO,CAAC,WAAW,CAAM,GACjC,MAAM,CAuCR;AAyLD,yFAAyF;AACzF,wBAAgB,UAAU,CACxB,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,OAAO,GACb,OAAO,CAIT;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,GAAG,cAAc,EACnC,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,MAAM,GACd,MAAM,CAER"}
|