@geekmidas/manifest 9.2.0 → 9.3.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/CHANGELOG.md +22 -0
- package/dist/cron-CjhmidCo.d.cts +104 -0
- package/dist/cron-CjhmidCo.d.cts.map +1 -0
- package/dist/cron-DC4hQ1oa.mjs +396 -0
- package/dist/cron-DC4hQ1oa.mjs.map +1 -0
- package/dist/cron-DXiVSeD6.cjs +438 -0
- package/dist/cron-DXiVSeD6.cjs.map +1 -0
- package/dist/cron-DlCX5PWN.d.mts +104 -0
- package/dist/cron-DlCX5PWN.d.mts.map +1 -0
- package/dist/cron.cjs +9 -0
- package/dist/cron.d.cts +2 -0
- package/dist/cron.d.mts +2 -0
- package/dist/cron.mjs +3 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/cron.spec.ts +253 -0
- package/src/cron.ts +502 -0
- package/src/index.ts +2 -0
package/src/cron.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron expressions in the two dialects a schedule runs under:
|
|
3
|
+
*
|
|
4
|
+
* - **Unix**, five fields: `cron(min hour day-of-month month day-of-week)`,
|
|
5
|
+
* day-of-week 0–7 with SUN as 0 or 7. What `cron-parser` and pg-boss read.
|
|
6
|
+
* - **AWS**, six fields: `cron(min hour day-of-month month day-of-week year)`,
|
|
7
|
+
* day-of-week 1–7 with SUN as 1, `?` in exactly one day field, and `L`, `W`
|
|
8
|
+
* and `#`. What EventBridge reads.
|
|
9
|
+
*
|
|
10
|
+
* The field count tells them apart. A seconds field is never accepted, so a
|
|
11
|
+
* six-field expression is always AWS.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CronDialect = 'unix' | 'aws';
|
|
15
|
+
|
|
16
|
+
export type CronField =
|
|
17
|
+
| 'minute'
|
|
18
|
+
| 'hour'
|
|
19
|
+
| 'dayOfMonth'
|
|
20
|
+
| 'month'
|
|
21
|
+
| 'dayOfWeek'
|
|
22
|
+
| 'year';
|
|
23
|
+
|
|
24
|
+
/** A validated cron expression, split into its fields. */
|
|
25
|
+
export interface ParsedCron {
|
|
26
|
+
dialect: CronDialect;
|
|
27
|
+
minute: string;
|
|
28
|
+
hour: string;
|
|
29
|
+
dayOfMonth: string;
|
|
30
|
+
month: string;
|
|
31
|
+
dayOfWeek: string;
|
|
32
|
+
/** AWS only. */
|
|
33
|
+
year?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A best-effort conversion that changed when the cron runs. */
|
|
37
|
+
export interface CronConversionWarning {
|
|
38
|
+
code: 'dayOfMonthDropped' | 'yearDropped';
|
|
39
|
+
message: string;
|
|
40
|
+
/** The token that was dropped. */
|
|
41
|
+
token: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CronConversion {
|
|
45
|
+
expression: string;
|
|
46
|
+
warnings: CronConversionWarning[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The expression is not valid in its own dialect. `field` is absent when the
|
|
51
|
+
* expression isn't `cron(…)` with five or six fields at all.
|
|
52
|
+
*/
|
|
53
|
+
export class InvalidCronExpressionError extends Error {
|
|
54
|
+
constructor(
|
|
55
|
+
readonly expression: string,
|
|
56
|
+
readonly token: string,
|
|
57
|
+
readonly field?: CronField,
|
|
58
|
+
) {
|
|
59
|
+
super('Invalid cron expression', { cause: { expression, field, token } });
|
|
60
|
+
this.name = 'InvalidCronExpressionError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The expression is valid, but has no equivalent in the target dialect. */
|
|
65
|
+
export class UnconvertibleCronExpressionError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
readonly expression: string,
|
|
68
|
+
readonly field: CronField,
|
|
69
|
+
readonly token: string,
|
|
70
|
+
) {
|
|
71
|
+
super('Cron expression cannot be converted', {
|
|
72
|
+
cause: { expression, field, token },
|
|
73
|
+
});
|
|
74
|
+
this.name = 'UnconvertibleCronExpressionError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MONTHS = [
|
|
79
|
+
'JAN',
|
|
80
|
+
'FEB',
|
|
81
|
+
'MAR',
|
|
82
|
+
'APR',
|
|
83
|
+
'MAY',
|
|
84
|
+
'JUN',
|
|
85
|
+
'JUL',
|
|
86
|
+
'AUG',
|
|
87
|
+
'SEP',
|
|
88
|
+
'OCT',
|
|
89
|
+
'NOV',
|
|
90
|
+
'DEC',
|
|
91
|
+
];
|
|
92
|
+
const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
|
93
|
+
|
|
94
|
+
interface FieldRule {
|
|
95
|
+
min: number;
|
|
96
|
+
max: number;
|
|
97
|
+
/** Names in order, the first being `min`. */
|
|
98
|
+
names?: readonly string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const RULES = {
|
|
102
|
+
minute: { min: 0, max: 59 },
|
|
103
|
+
hour: { min: 0, max: 23 },
|
|
104
|
+
dayOfMonth: { min: 1, max: 31 },
|
|
105
|
+
month: { min: 1, max: 12, names: MONTHS },
|
|
106
|
+
unixDayOfWeek: { min: 0, max: 7, names: DAYS },
|
|
107
|
+
awsDayOfWeek: { min: 1, max: 7, names: DAYS },
|
|
108
|
+
} satisfies Record<string, FieldRule>;
|
|
109
|
+
|
|
110
|
+
/** A number in range (one or two digits), or one of the field's names. */
|
|
111
|
+
const toValue = (token: string, rule: FieldRule): number | undefined => {
|
|
112
|
+
const named = rule.names?.indexOf(token) ?? -1;
|
|
113
|
+
if (named >= 0) return rule.min + named;
|
|
114
|
+
if (!/^\d{1,2}$/.test(token)) return undefined;
|
|
115
|
+
const value = Number(token);
|
|
116
|
+
return value >= rule.min && value <= rule.max ? value : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** `*`, `v` or `a-b`, with an optional `/step`. */
|
|
120
|
+
const isAtom = (atom: string, rule: FieldRule): boolean => {
|
|
121
|
+
const [base = '', step, ...rest] = atom.split('/');
|
|
122
|
+
if (rest.length > 0) return false;
|
|
123
|
+
if (step !== undefined && !/^\d{1,2}$/.test(step)) return false;
|
|
124
|
+
if (base === '*') return true;
|
|
125
|
+
const [from = '', to, ...more] = base.split('-');
|
|
126
|
+
if (more.length > 0 || toValue(from, rule) === undefined) return false;
|
|
127
|
+
return to === undefined || toValue(to, rule) !== undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** The first atom of a comma-separated field that is invalid. */
|
|
131
|
+
const invalidAtom = (
|
|
132
|
+
field: string,
|
|
133
|
+
isValid: (atom: string) => boolean,
|
|
134
|
+
): string | undefined => field.split(',').find((atom) => !isValid(atom));
|
|
135
|
+
|
|
136
|
+
const isYear = (token: string) =>
|
|
137
|
+
/^\d{4}$/.test(token) && Number(token) >= 1970 && Number(token) <= 2199;
|
|
138
|
+
|
|
139
|
+
const isYearAtom = (atom: string): boolean => {
|
|
140
|
+
const [base = '', step, ...rest] = atom.split('/');
|
|
141
|
+
if (rest.length > 0) return false;
|
|
142
|
+
if (step !== undefined && !/^\d{1,2}$/.test(step)) return false;
|
|
143
|
+
if (base === '*') return true;
|
|
144
|
+
const [from = '', to, ...more] = base.split('-');
|
|
145
|
+
return more.length === 0 && isYear(from) && (to === undefined || isYear(to));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** `L`, `L-n`, `LW` and `nW`, AWS day-of-month only, alone in the field. */
|
|
149
|
+
const isAwsDayOfMonthSpecial = (field: string) =>
|
|
150
|
+
field === 'L' ||
|
|
151
|
+
field === 'LW' ||
|
|
152
|
+
/^L-\d{1,2}$/.test(field) ||
|
|
153
|
+
(field.endsWith('W') &&
|
|
154
|
+
toValue(field.slice(0, -1), RULES.dayOfMonth) !== undefined);
|
|
155
|
+
|
|
156
|
+
/** `nL` and `n#k`, AWS day-of-week only, alone in the field. */
|
|
157
|
+
const isAwsDayOfWeekSpecial = (field: string) => {
|
|
158
|
+
if (field.endsWith('L')) {
|
|
159
|
+
return toValue(field.slice(0, -1), RULES.awsDayOfWeek) !== undefined;
|
|
160
|
+
}
|
|
161
|
+
const [day = '', nth, ...rest] = field.split('#');
|
|
162
|
+
return (
|
|
163
|
+
nth !== undefined &&
|
|
164
|
+
rest.length === 0 &&
|
|
165
|
+
toValue(day, RULES.awsDayOfWeek) !== undefined &&
|
|
166
|
+
/^[1-5]$/.test(nth)
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Splits and validates a `cron(…)` expression in either dialect.
|
|
172
|
+
*
|
|
173
|
+
* @throws {InvalidCronExpressionError} naming the field and token that failed.
|
|
174
|
+
*/
|
|
175
|
+
export function parseCron(expression: string): ParsedCron {
|
|
176
|
+
const inner = /^cron\((.*)\)$/.exec(expression)?.[1];
|
|
177
|
+
const fields = inner?.split(' ') ?? [];
|
|
178
|
+
if (fields.length !== 5 && fields.length !== 6) {
|
|
179
|
+
throw new InvalidCronExpressionError(expression, inner ?? expression);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const [minute = '', hour = '', dayOfMonth = '', month = '', dayOfWeek = ''] =
|
|
183
|
+
fields;
|
|
184
|
+
const year = fields[5];
|
|
185
|
+
const dialect: CronDialect = year === undefined ? 'unix' : 'aws';
|
|
186
|
+
const fail = (field: CronField, token: string): never => {
|
|
187
|
+
throw new InvalidCronExpressionError(expression, token, field);
|
|
188
|
+
};
|
|
189
|
+
const check = (field: CronField, value: string, rule: FieldRule) => {
|
|
190
|
+
const atom = invalidAtom(value, (a) => isAtom(a, rule));
|
|
191
|
+
if (atom !== undefined) fail(field, atom);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
check('minute', minute, RULES.minute);
|
|
195
|
+
check('hour', hour, RULES.hour);
|
|
196
|
+
check('month', month, RULES.month);
|
|
197
|
+
|
|
198
|
+
if (year === undefined) {
|
|
199
|
+
check('dayOfMonth', dayOfMonth, RULES.dayOfMonth);
|
|
200
|
+
check('dayOfWeek', dayOfWeek, RULES.unixDayOfWeek);
|
|
201
|
+
return { dialect, minute, hour, dayOfMonth, month, dayOfWeek };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (dayOfMonth === '?' && dayOfWeek === '?') fail('dayOfWeek', '?');
|
|
205
|
+
if (dayOfMonth !== '?' && dayOfWeek !== '?') fail('dayOfWeek', dayOfWeek);
|
|
206
|
+
if (dayOfMonth !== '?' && !isAwsDayOfMonthSpecial(dayOfMonth)) {
|
|
207
|
+
check('dayOfMonth', dayOfMonth, RULES.dayOfMonth);
|
|
208
|
+
}
|
|
209
|
+
if (dayOfWeek !== '?' && !isAwsDayOfWeekSpecial(dayOfWeek)) {
|
|
210
|
+
check('dayOfWeek', dayOfWeek, RULES.awsDayOfWeek);
|
|
211
|
+
}
|
|
212
|
+
const yearAtom = invalidAtom(year, isYearAtom);
|
|
213
|
+
if (yearAtom !== undefined) fail('year', yearAtom);
|
|
214
|
+
|
|
215
|
+
return { dialect, minute, hour, dayOfMonth, month, dayOfWeek, year };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The values a plain field allows: values, ranges, steps and lists. */
|
|
219
|
+
const fieldValues = (field: string, rule: FieldRule): Set<number> => {
|
|
220
|
+
const values = new Set<number>();
|
|
221
|
+
for (const atom of field.split(',')) {
|
|
222
|
+
const [base = '', step] = atom.split('/');
|
|
223
|
+
const [from = '', to] = base.split('-');
|
|
224
|
+
const start = base === '*' ? rule.min : (toValue(from, rule) as number);
|
|
225
|
+
const end =
|
|
226
|
+
to !== undefined
|
|
227
|
+
? (toValue(to, rule) as number)
|
|
228
|
+
: base === '*' || step !== undefined
|
|
229
|
+
? rule.max
|
|
230
|
+
: start;
|
|
231
|
+
for (let value = start; value <= end; value += Number(step ?? 1)) {
|
|
232
|
+
values.add(value);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return values;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The days a day-of-week field runs on, as Unix numbers 0–6 (SUN = 0).
|
|
240
|
+
* Only for plain fields: values, ranges, steps and lists.
|
|
241
|
+
*/
|
|
242
|
+
const unixDays = (field: string, dialect: CronDialect): Set<number> => {
|
|
243
|
+
const rule = dialect === 'unix' ? RULES.unixDayOfWeek : RULES.awsDayOfWeek;
|
|
244
|
+
const days = new Set<number>();
|
|
245
|
+
for (const day of fieldValues(field, rule)) {
|
|
246
|
+
days.add(dialect === 'unix' ? day % 7 : day - 1);
|
|
247
|
+
}
|
|
248
|
+
return days;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** Days as a list, with runs of three or more written as ranges. */
|
|
252
|
+
const formatDays = (days: number[]): string => {
|
|
253
|
+
const parts: string[] = [];
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < days.length) {
|
|
256
|
+
let j = i;
|
|
257
|
+
while (j + 1 < days.length && days[j + 1] === (days[j] as number) + 1) {
|
|
258
|
+
j++;
|
|
259
|
+
}
|
|
260
|
+
parts.push(
|
|
261
|
+
j - i >= 2 ? `${days[i]}-${days[j]}` : days.slice(i, j + 1).join(','),
|
|
262
|
+
);
|
|
263
|
+
i = j + 1;
|
|
264
|
+
}
|
|
265
|
+
return parts.join(',');
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Rewrites a day-of-week field's numbers into the target dialect. Fields
|
|
270
|
+
* written only with names and `*` mean the same in both and pass through.
|
|
271
|
+
*/
|
|
272
|
+
const convertDayOfWeek = (field: string, from: CronDialect): string => {
|
|
273
|
+
if (!/\d/.test(field)) return field;
|
|
274
|
+
const days = unixDays(field, from);
|
|
275
|
+
if (days.size === 7) return '*';
|
|
276
|
+
if (from === 'aws') return formatDays([...days].sort((a, b) => a - b));
|
|
277
|
+
// SUN goes from first to last, so a week from MON reads in order.
|
|
278
|
+
return formatDays([...days].map((day) => day + 1).sort((a, b) => a - b));
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const format = (fields: string[]) => `cron(${fields.join(' ')})`;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Converts a cron expression to the six-field AWS form EventBridge reads. An
|
|
285
|
+
* AWS expression is returned unchanged.
|
|
286
|
+
*
|
|
287
|
+
* Unix runs on either day field when both are restricted, and AWS can't
|
|
288
|
+
* express that, so day-of-week is kept and day-of-month dropped with a
|
|
289
|
+
* warning.
|
|
290
|
+
*
|
|
291
|
+
* @throws {InvalidCronExpressionError} when the expression is invalid.
|
|
292
|
+
*/
|
|
293
|
+
export function toAwsCron(expression: string): CronConversion {
|
|
294
|
+
const cron = parseCron(expression);
|
|
295
|
+
if (cron.dialect === 'aws') return { expression, warnings: [] };
|
|
296
|
+
|
|
297
|
+
const { minute, hour, dayOfMonth, month, dayOfWeek } = cron;
|
|
298
|
+
const warnings: CronConversionWarning[] = [];
|
|
299
|
+
if (dayOfMonth !== '*' && dayOfWeek !== '*') {
|
|
300
|
+
warnings.push({
|
|
301
|
+
code: 'dayOfMonthDropped',
|
|
302
|
+
message:
|
|
303
|
+
'Both day fields are restricted, so day-of-month was dropped and the cron runs on fewer days',
|
|
304
|
+
token: dayOfMonth,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const [awsDayOfMonth, awsDayOfWeek] =
|
|
309
|
+
dayOfWeek === '*'
|
|
310
|
+
? [dayOfMonth, '?']
|
|
311
|
+
: ['?', convertDayOfWeek(dayOfWeek, 'unix')];
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
expression: format([minute, hour, awsDayOfMonth, month, awsDayOfWeek, '*']),
|
|
315
|
+
warnings,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Converts a cron expression to the five-field Unix form `cron-parser` reads.
|
|
321
|
+
* A Unix expression is returned unchanged.
|
|
322
|
+
*
|
|
323
|
+
* A specific year is dropped with a warning, so the cron runs every year.
|
|
324
|
+
*
|
|
325
|
+
* @throws {InvalidCronExpressionError} when the expression is invalid.
|
|
326
|
+
* @throws {UnconvertibleCronExpressionError} for `W` and `L-n`, which Unix
|
|
327
|
+
* has no equivalent for.
|
|
328
|
+
*/
|
|
329
|
+
export function toUnixCron(expression: string): CronConversion {
|
|
330
|
+
const cron = parseCron(expression);
|
|
331
|
+
if (cron.dialect === 'unix') return { expression, warnings: [] };
|
|
332
|
+
|
|
333
|
+
const { minute, hour, dayOfMonth, month, dayOfWeek, year = '*' } = cron;
|
|
334
|
+
const warnings: CronConversionWarning[] = [];
|
|
335
|
+
if (year !== '*') {
|
|
336
|
+
warnings.push({
|
|
337
|
+
code: 'yearDropped',
|
|
338
|
+
message: 'The year was dropped, so the cron runs every year',
|
|
339
|
+
token: year,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (dayOfMonth.includes('W') || dayOfMonth.startsWith('L-')) {
|
|
344
|
+
throw new UnconvertibleCronExpressionError(
|
|
345
|
+
expression,
|
|
346
|
+
'dayOfMonth',
|
|
347
|
+
dayOfMonth,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
expression: format([
|
|
353
|
+
minute,
|
|
354
|
+
hour,
|
|
355
|
+
dayOfMonth === '?' ? '*' : dayOfMonth,
|
|
356
|
+
month,
|
|
357
|
+
convertAwsDayOfWeek(dayOfWeek),
|
|
358
|
+
]),
|
|
359
|
+
warnings,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `?` becomes `*`; `nL` and `n#k` keep their suffix with `n` shifted. */
|
|
364
|
+
const convertAwsDayOfWeek = (field: string): string => {
|
|
365
|
+
if (field === '?') return '*';
|
|
366
|
+
const special = /^(\w+?)(L|#[1-5])$/.exec(field);
|
|
367
|
+
if (special) {
|
|
368
|
+
const [, day = '', suffix = ''] = special;
|
|
369
|
+
return `${convertDayOfWeek(day, 'aws')}${suffix}`;
|
|
370
|
+
}
|
|
371
|
+
return convertDayOfWeek(field, 'aws');
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** The expression runs less than twice in the window, so has no period. */
|
|
375
|
+
export class CronPeriodError extends Error {
|
|
376
|
+
constructor(readonly expression: string) {
|
|
377
|
+
super('Cron expression runs less than twice in eight years', {
|
|
378
|
+
cause: { expression },
|
|
379
|
+
});
|
|
380
|
+
this.name = 'CronPeriodError';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const MINUTE = 60;
|
|
385
|
+
const DAY = 24 * 60 * MINUTE;
|
|
386
|
+
|
|
387
|
+
/** 2024 to 2031: every weekday and month combination, and two leap years. */
|
|
388
|
+
const PERIOD_START = Date.UTC(2024, 0, 1);
|
|
389
|
+
const PERIOD_DAYS = 8 * 365 + 2;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Whether a date in UTC matches an AWS day-of-month field, including `L`,
|
|
393
|
+
* `L-n`, `LW` and `nW`.
|
|
394
|
+
*/
|
|
395
|
+
const dayOfMonthMatcher = (field: string): ((date: Date) => boolean) => {
|
|
396
|
+
if (field === '?') return () => true;
|
|
397
|
+
const lastDay = (date: Date) =>
|
|
398
|
+
new Date(
|
|
399
|
+
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
|
|
400
|
+
).getUTCDate();
|
|
401
|
+
// The weekday nearest `day`, without leaving the month.
|
|
402
|
+
const nearestWeekday = (date: Date, day: number) => {
|
|
403
|
+
const last = lastDay(date);
|
|
404
|
+
const weekday = new Date(
|
|
405
|
+
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), day),
|
|
406
|
+
).getUTCDay();
|
|
407
|
+
if (weekday === 6) return day === 1 ? 3 : day - 1;
|
|
408
|
+
if (weekday === 0) return day === last ? day - 2 : day + 1;
|
|
409
|
+
return day;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (field === 'L') return (date) => date.getUTCDate() === lastDay(date);
|
|
413
|
+
if (field.startsWith('L-')) {
|
|
414
|
+
const offset = Number(field.slice(2));
|
|
415
|
+
return (date) => date.getUTCDate() === lastDay(date) - offset;
|
|
416
|
+
}
|
|
417
|
+
if (field === 'LW') {
|
|
418
|
+
return (date) => date.getUTCDate() === nearestWeekday(date, lastDay(date));
|
|
419
|
+
}
|
|
420
|
+
if (field.endsWith('W')) {
|
|
421
|
+
const day = Number(field.slice(0, -1));
|
|
422
|
+
return (date) =>
|
|
423
|
+
day <= lastDay(date) && date.getUTCDate() === nearestWeekday(date, day);
|
|
424
|
+
}
|
|
425
|
+
const days = fieldValues(field, RULES.dayOfMonth);
|
|
426
|
+
return (date) => days.has(date.getUTCDate());
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/** Whether a date in UTC matches an AWS day-of-week field, including `nL` and `n#k`. */
|
|
430
|
+
const dayOfWeekMatcher = (field: string): ((date: Date) => boolean) => {
|
|
431
|
+
if (field === '?') return () => true;
|
|
432
|
+
const special = /^(\w+?)(L|#([1-5]))$/.exec(field);
|
|
433
|
+
if (special) {
|
|
434
|
+
const [, day = '', , nth] = special;
|
|
435
|
+
const weekday = (toValue(day, RULES.awsDayOfWeek) as number) - 1;
|
|
436
|
+
return (date) => {
|
|
437
|
+
if (date.getUTCDay() !== weekday) return false;
|
|
438
|
+
if (nth !== undefined) {
|
|
439
|
+
return Math.ceil(date.getUTCDate() / 7) === Number(nth);
|
|
440
|
+
}
|
|
441
|
+
// The last one: a week later is next month.
|
|
442
|
+
return new Date(date.getTime() + 7 * DAY * 1000).getUTCDate() <= 7;
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const days = unixDays(field, 'aws');
|
|
446
|
+
return (date) => days.has(date.getUTCDay());
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The longest gap between two runs of a cron expression, in seconds: the
|
|
451
|
+
* period a heartbeat should expect. A weekdays-only cron has the Friday to
|
|
452
|
+
* Monday gap, so its heartbeat doesn't alert at the weekend.
|
|
453
|
+
*
|
|
454
|
+
* The expression is first converted to the AWS form, so the period is of
|
|
455
|
+
* what EventBridge runs. Runs are worked out in UTC over 2024–2031, and the
|
|
456
|
+
* year field is ignored.
|
|
457
|
+
*
|
|
458
|
+
* @throws {InvalidCronExpressionError} when the expression is invalid.
|
|
459
|
+
* @throws {CronPeriodError} when it runs less than twice in that time.
|
|
460
|
+
*/
|
|
461
|
+
export function cronPeriod(expression: string): number {
|
|
462
|
+
const cron = parseCron(toAwsCron(expression).expression);
|
|
463
|
+
const minutes = [...fieldValues(cron.minute, RULES.minute)];
|
|
464
|
+
const hours = [...fieldValues(cron.hour, RULES.hour)];
|
|
465
|
+
const months = fieldValues(cron.month, RULES.month);
|
|
466
|
+
const matchesDayOfMonth = dayOfMonthMatcher(cron.dayOfMonth);
|
|
467
|
+
const matchesDayOfWeek = dayOfWeekMatcher(cron.dayOfWeek);
|
|
468
|
+
|
|
469
|
+
// Every matching day runs at the same times, so only the gaps within a
|
|
470
|
+
// day and from one day's last run to the next day's first can differ.
|
|
471
|
+
const times = hours
|
|
472
|
+
.flatMap((hour) => minutes.map((minute) => (hour * 60 + minute) * MINUTE))
|
|
473
|
+
.sort((a, b) => a - b);
|
|
474
|
+
const first = times[0] as number;
|
|
475
|
+
const last = times[times.length - 1] as number;
|
|
476
|
+
|
|
477
|
+
let longest = 0;
|
|
478
|
+
for (let i = 1; i < times.length; i++) {
|
|
479
|
+
longest = Math.max(
|
|
480
|
+
longest,
|
|
481
|
+
(times[i] as number) - (times[i - 1] as number),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let runs = 0;
|
|
486
|
+
let previousLast: number | undefined;
|
|
487
|
+
for (let day = 0; day < PERIOD_DAYS; day++) {
|
|
488
|
+
const date = new Date(PERIOD_START + day * DAY * 1000);
|
|
489
|
+
if (!months.has(date.getUTCMonth() + 1)) continue;
|
|
490
|
+
if (!matchesDayOfMonth(date) || !matchesDayOfWeek(date)) continue;
|
|
491
|
+
|
|
492
|
+
const start = date.getTime() / 1000;
|
|
493
|
+
if (previousLast !== undefined) {
|
|
494
|
+
longest = Math.max(longest, start + first - previousLast);
|
|
495
|
+
}
|
|
496
|
+
previousLast = start + last;
|
|
497
|
+
runs += times.length;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (runs < 2) throw new CronPeriodError(expression);
|
|
501
|
+
return longest;
|
|
502
|
+
}
|