Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

cron.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

cron.tsBlame227 lines · 1 contributor
665c8bfClaude1/**
2 * Tiny cron-expression parser + matcher.
3 *
4 * Standard 5-field UNIX cron:
5 *
6 * minute hour day-of-month month day-of-week
7 * 0-59 0-23 1-31 1-12 0-6 (Sun=0; Sat=6; 7 also accepted as Sun)
8 *
9 * Supports: literal numbers, `*`, ranges (`a-b`), step (`*\/n` or `a-b/n`),
10 * and comma-lists (`1,3,5`). No named months / weekdays, no `@hourly`,
11 * no `L`/`W`/`#` — those raise a parse error so callers can surface a
12 * useful message rather than silently never firing.
13 *
14 * Day-of-month and day-of-week interact via OR (POSIX semantics): if both
15 * are restricted, the schedule fires when EITHER matches. If one is `*`
16 * we conjoin with the other (the practical common case).
17 *
18 * Pure module — no DB, no clock side effects. Callers pass a Date.
19 */
20
21export type CronField = number[]; // sorted, deduped
22
23export type ParsedCron = {
24 minute: CronField;
25 hour: CronField;
26 dom: CronField;
27 month: CronField;
28 dow: CronField;
29 /** Original expression after trimming + collapsing whitespace. */
30 raw: string;
31};
32
33export type CronParseResult =
34 | { ok: true; cron: ParsedCron }
35 | { ok: false; error: string };
36
37const FIELD_BOUNDS: Record<string, [number, number]> = {
38 minute: [0, 59],
39 hour: [0, 23],
40 dom: [1, 31],
41 month: [1, 12],
42 dow: [0, 7], // 7 normalised to 0 below
43};
44
45/**
46 * Parse one field of a cron expression against [lo, hi]. Returns a sorted,
47 * de-duplicated list of valid integers, or `null` if the field is
48 * malformed. Supports `*`, `a`, `a-b`, `*\/n`, `a-b/n`, `a,b,c`.
49 */
50function parseField(
51 raw: string,
52 lo: number,
53 hi: number
54): CronField | null {
55 const out = new Set<number>();
56 const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
57 if (parts.length === 0) return null;
58
59 for (const part of parts) {
60 let baseLo = lo;
61 let baseHi = hi;
62 let step = 1;
63 let body = part;
64
65 const slash = body.indexOf("/");
66 if (slash >= 0) {
67 const stepStr = body.slice(slash + 1);
68 const stepN = Number.parseInt(stepStr, 10);
69 if (!Number.isInteger(stepN) || stepN <= 0) return null;
70 step = stepN;
71 const before = body.slice(0, slash);
72 // Empty before-slash (e.g. "/5") is a syntax error — must be either
73 // "*" or a literal range. Don't silently default to "*".
74 if (before === "") return null;
75 body = before;
76 }
77
78 if (body === "*") {
79 // baseLo / baseHi already span the full field.
80 } else if (body.includes("-")) {
81 const [aS, bS] = body.split("-");
82 const a = Number.parseInt(aS, 10);
83 const b = Number.parseInt(bS, 10);
84 if (!Number.isInteger(a) || !Number.isInteger(b)) return null;
85 if (a < lo || b > hi || a > b) return null;
86 baseLo = a;
87 baseHi = b;
88 } else {
89 const n = Number.parseInt(body, 10);
90 if (!Number.isInteger(n) || n < lo || n > hi) return null;
91 if (step !== 1) return null; // n/step makes no sense without a range
92 out.add(n);
93 continue;
94 }
95
96 for (let v = baseLo; v <= baseHi; v += step) {
97 out.add(v);
98 }
99 }
100
101 return [...out].sort((a, b) => a - b);
102}
103
104/**
105 * Parse a 5-field cron expression. Returns `{ok:true, cron}` or
106 * `{ok:false, error}`. Whitespace-tolerant; rejects unsupported syntax.
107 */
108export function parseCron(expr: string): CronParseResult {
109 const raw = (expr || "").replace(/\s+/g, " ").trim();
110 if (!raw) return { ok: false, error: "empty cron expression" };
111
112 // Fail fast on syntax we don't yet support so the user gets a clear
113 // error instead of a schedule that silently never fires.
114 if (/^@/.test(raw)) {
115 return {
116 ok: false,
117 error: `cron alias '${raw.split(" ")[0]}' is not supported (use 5-field expression)`,
118 };
119 }
120 if (/[LW#?]/i.test(raw)) {
121 return {
122 ok: false,
123 error: "cron contains unsupported characters (L, W, #, ?)",
124 };
125 }
126
127 const fields = raw.split(" ");
128 if (fields.length !== 5) {
129 return {
130 ok: false,
131 error: `cron must have 5 space-separated fields, got ${fields.length}`,
132 };
133 }
134
135 const [mField, hField, domField, monField, dowField] = fields;
136
137 const minute = parseField(mField, ...FIELD_BOUNDS.minute);
138 const hour = parseField(hField, ...FIELD_BOUNDS.hour);
139 const dom = parseField(domField, ...FIELD_BOUNDS.dom);
140 const month = parseField(monField, ...FIELD_BOUNDS.month);
141 const dowRaw = parseField(dowField, ...FIELD_BOUNDS.dow);
142
143 if (!minute) return { ok: false, error: `invalid minute field: ${mField}` };
144 if (!hour) return { ok: false, error: `invalid hour field: ${hField}` };
145 if (!dom) return { ok: false, error: `invalid day-of-month field: ${domField}` };
146 if (!month) return { ok: false, error: `invalid month field: ${monField}` };
147 if (!dowRaw) return { ok: false, error: `invalid day-of-week field: ${dowField}` };
148
149 // Normalise dow so 7 → 0 (both mean Sunday).
150 const dow = [...new Set(dowRaw.map((d) => (d === 7 ? 0 : d)))].sort(
151 (a, b) => a - b
152 );
153
154 return {
155 ok: true,
156 cron: {
157 raw,
158 minute,
159 hour,
160 dom,
161 month,
162 dow,
163 },
164 };
165}
166
167/**
168 * Does the cron fire on this exact minute (UTC)? `date` is rounded down
169 * to the start of its minute internally so callers can pass any Date.
170 */
171export function cronMatches(cron: ParsedCron, date: Date): boolean {
172 if (!cron) return false;
173 const m = date.getUTCMinutes();
174 const h = date.getUTCHours();
175 const d = date.getUTCDate();
176 const mo = date.getUTCMonth() + 1; // JS is 0-indexed
177 const dw = date.getUTCDay(); // 0..6, Sun=0
178
179 if (!cron.minute.includes(m)) return false;
180 if (!cron.hour.includes(h)) return false;
181 if (!cron.month.includes(mo)) return false;
182
183 // POSIX OR semantics for dom & dow: if either is restricted (not full
184 // wildcard), match if EITHER matches. If both unrestricted, both pass.
185 const domRestricted = cron.dom.length !== 31;
186 const dowRestricted = cron.dow.length !== 7;
187 const domMatch = cron.dom.includes(d);
188 const dowMatch = cron.dow.includes(dw);
189
190 if (!domRestricted && !dowRestricted) return true;
191 if (domRestricted && dowRestricted) return domMatch || dowMatch;
192 if (domRestricted) return domMatch;
193 return dowMatch;
194}
195
196/**
197 * Did the cron fire at any minute in the half-open interval (since, until]?
198 * Useful for "did this schedule trip in the last tick?" checks where
199 * `since` is the prior tick wall and `until` is the current wall. Caps
200 * the loop at 1 day so a misconfigured `since` from 2010 can't blow up.
201 */
202export function cronFiredBetween(
203 cron: ParsedCron,
204 since: Date,
205 until: Date
206): boolean {
207 if (!cron) return false;
208 const startMs = Math.floor(since.getTime() / 60000) * 60000 + 60000;
209 const endMs = Math.floor(until.getTime() / 60000) * 60000;
210 if (endMs < startMs) return false;
211
212 const ONE_DAY_MIN = 24 * 60;
213 const minuteCount = (endMs - startMs) / 60000 + 1;
214 const cap = Math.min(minuteCount, ONE_DAY_MIN);
215
216 for (let i = 0; i < cap; i++) {
217 const t = new Date(startMs + i * 60000);
218 if (cronMatches(cron, t)) return true;
219 }
220 return false;
221}
222
223/**
224 * Test-only export of the field parser so unit tests can pin parsing
225 * edge cases without going through parseCron's plumbing.
226 */
227export const __test = { parseField };