mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
* fix(schedules): match either restricted cron day field * refactor(schedules): name the combined day match conditions Keep wildcard days conjunctive so weekday-only and date-only schedules retain their meaning, while restricted day fields follow cron's union rule. Preserve lexical wildcard identity rather than inferring it from allowed values, since an explicit full range is still restricted. Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com> --------- Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
141 lines
4.1 KiB
TypeScript
141 lines
4.1 KiB
TypeScript
interface CronFieldBounds {
|
|
min: number;
|
|
max: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface CronFieldMatcher {
|
|
isWildcard: boolean;
|
|
matches(value: number): boolean;
|
|
}
|
|
|
|
export interface ParsedCronExpression {
|
|
minute: CronFieldMatcher;
|
|
hour: CronFieldMatcher;
|
|
dayOfMonth: CronFieldMatcher;
|
|
month: CronFieldMatcher;
|
|
dayOfWeek: CronFieldMatcher;
|
|
}
|
|
|
|
const CRON_FIELD_BOUNDS: CronFieldBounds[] = [
|
|
{ min: 0, max: 59, name: "minute" },
|
|
{ min: 0, max: 23, name: "hour" },
|
|
{ min: 1, max: 31, name: "day-of-month" },
|
|
{ min: 1, max: 12, name: "month" },
|
|
{ min: 0, max: 6, name: "day-of-week" },
|
|
];
|
|
|
|
function createRange(start: number, end: number, step: number): number[] {
|
|
const values: number[] = [];
|
|
for (let value = start; value <= end; value += step) {
|
|
values.push(value);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function parseStepPart(part: string, bounds: CronFieldBounds): { base: string; step: number } {
|
|
const stepParts = part.split("/");
|
|
if (stepParts.length > 2) {
|
|
throw new Error(`Invalid cron ${bounds.name} step`);
|
|
}
|
|
|
|
const [base, stepSource] = stepParts;
|
|
const normalizedStep = stepSource?.trim();
|
|
const step = normalizedStep === undefined ? 1 : Number.parseInt(normalizedStep, 10);
|
|
if (
|
|
!Number.isInteger(step) ||
|
|
step <= 0 ||
|
|
(normalizedStep !== undefined && String(step) !== normalizedStep)
|
|
) {
|
|
throw new Error(`Invalid cron ${bounds.name} step`);
|
|
}
|
|
|
|
return { base, step };
|
|
}
|
|
|
|
function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher {
|
|
const trimmed = source.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`Invalid cron ${bounds.name} field`);
|
|
}
|
|
|
|
const allowed = new Set<number>();
|
|
for (const rawPart of trimmed.split(",")) {
|
|
const part = rawPart.trim();
|
|
if (!part) {
|
|
throw new Error(`Invalid cron ${bounds.name} field`);
|
|
}
|
|
|
|
const { base, step } = parseStepPart(part, bounds);
|
|
|
|
if (base === "*") {
|
|
for (const value of createRange(bounds.min, bounds.max, step)) {
|
|
allowed.add(value);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const rangeMatch = base.match(/^(\d+)-(\d+)$/);
|
|
if (rangeMatch) {
|
|
const start = Number.parseInt(rangeMatch[1], 10);
|
|
const end = Number.parseInt(rangeMatch[2], 10);
|
|
if (start > end || start < bounds.min || end > bounds.max) {
|
|
throw new Error(`Invalid cron ${bounds.name} range`);
|
|
}
|
|
for (const value of createRange(start, end, step)) {
|
|
allowed.add(value);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!/^\d+$/.test(base)) {
|
|
throw new Error(`Invalid cron ${bounds.name} value`);
|
|
}
|
|
// COMPAT(cronNumericSteps): added in v0.12.0, remove after 2027-04-17 once
|
|
// persisted numeric steps are migrated. Validation rejects new numeric steps;
|
|
// existing schedules retain their historical singleton match.
|
|
const value = Number.parseInt(base, 10);
|
|
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
|
|
throw new Error(`Invalid cron ${bounds.name} value`);
|
|
}
|
|
allowed.add(value);
|
|
}
|
|
|
|
return {
|
|
isWildcard: trimmed.startsWith("*"),
|
|
matches(value: number): boolean {
|
|
return allowed.has(value);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function parseCronExpression(expression: string): ParsedCronExpression {
|
|
const parts = expression.trim().split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
throw new Error("Cron expressions must have 5 fields");
|
|
}
|
|
|
|
return {
|
|
minute: parseField(parts[0], CRON_FIELD_BOUNDS[0]),
|
|
hour: parseField(parts[1], CRON_FIELD_BOUNDS[1]),
|
|
dayOfMonth: parseField(parts[2], CRON_FIELD_BOUNDS[2]),
|
|
month: parseField(parts[3], CRON_FIELD_BOUNDS[3]),
|
|
dayOfWeek: parseField(parts[4], CRON_FIELD_BOUNDS[4]),
|
|
};
|
|
}
|
|
|
|
export function validateCronExpression(expression: string): string | null {
|
|
try {
|
|
parseCronExpression(expression);
|
|
const fields = expression.trim().split(/\s+/);
|
|
for (const [index, field] of fields.entries()) {
|
|
if (field.split(",").some((part) => /^\d+\//.test(part))) {
|
|
return `Invalid cron ${CRON_FIELD_BOUNDS[index].name} step: use * or a range before /`;
|
|
}
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : "Invalid cron expression";
|
|
}
|
|
}
|