···11{
22 "name": "@kbilkis/cron-fast",
33- "version": "0.1.2",
33+ "version": "0.1.3",
44 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.",
55 "keywords": [
66 "javascript",
+2-3
package.json
···11{
22 "name": "cron-fast",
33- "version": "0.1.2",
33+ "version": "0.1.3",
44 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.",
55 "keywords": [
66 "browser",
···6565 "fmt": "oxfmt",
6666 "fmt:check": "oxfmt --check",
6767 "typecheck": "tsc --noEmit",
6868- "bundle-size": "tsx scripts/bundle-size.ts",
6969- "prepublishOnly": "pnpm run build"
6868+ "bundle-size": "tsx scripts/bundle-size.ts"
7069 },
7170 "devDependencies": {
7271 "@cloudflare/vitest-pool-workers": "^0.12.10",
+23-9
src/matcher.ts
···2020}
21212222/**
2323+ * Check if we're in OR mode (both day and weekday are restricted, not wildcards)
2424+ * In OR mode, we must check every day because any day might match via weekday
2525+ */
2626+export function isOrMode(parsed: ParsedCron): boolean {
2727+ return !parsed.dayIsWildcard && !parsed.weekdayIsWildcard;
2828+}
2929+3030+/**
2331 * Day-of-month and day-of-week use OR logic by default
2432 * If both are restricted (not *), match either one
3333+ *
3434+ * @param daysInMonth - Optional validation that day is valid for the month (used by scheduler)
2535 */
2626-function matchesDayOrWeekday(parsed: ParsedCron, day: number, weekday: number): boolean {
2727- const dayMatches = parsed.day.includes(day);
3636+export function matchesDayOrWeekday(
3737+ parsed: ParsedCron,
3838+ day: number,
3939+ weekday: number,
4040+ daysInMonth?: number,
4141+): boolean {
4242+ const dayMatches =
4343+ daysInMonth !== undefined
4444+ ? parsed.day.includes(day) && day <= daysInMonth
4545+ : parsed.day.includes(day);
2846 const weekdayMatches = parsed.weekday.includes(weekday);
29473030- // If both are wildcards (all values), both match
3131- const dayIsWildcard = parsed.day.length === 31;
3232- const weekdayIsWildcard = parsed.weekday.length === 7;
3333-3448 // If both are restricted, use OR logic (standard cron behavior)
3535- if (!dayIsWildcard && !weekdayIsWildcard) {
4949+ if (isOrMode(parsed)) {
3650 return dayMatches || weekdayMatches;
3751 }
38523953 // If only one is restricted, it must match
4040- if (!dayIsWildcard) {
5454+ if (!parsed.dayIsWildcard) {
4155 return dayMatches;
4256 }
4343- if (!weekdayIsWildcard) {
5757+ if (!parsed.weekdayIsWildcard) {
4458 return weekdayMatches;
4559 }
4660
+3-1
src/parser.ts
···6161 day: parseField(dayStr, 1, 31),
6262 month: parseField(monthStr, 1, 12, MONTH_NAMES).map((m) => m - 1), // Convert to 0-indexed (0 = Jan, 11 = Dec)
6363 weekday: Array.from(new Set(weekdays)).sort((a, b) => a - b), // Dedupe and sort
6464+ dayIsWildcard: dayStr.trim() === "*",
6565+ weekdayIsWildcard: weekdayStr.trim() === "*",
6466 };
65676668 // Validate day/month combinations
···7577 */
7678function validateDayMonthCombinations(parsed: ParsedCron): void {
7779 // If day or month is wildcard, no validation needed
7878- const dayIsWildcard = parsed.day.length === 31;
8080+ const dayIsWildcard = parsed.dayIsWildcard;
7981 const monthIsWildcard = parsed.month.length === 12;
80828183 if (dayIsWildcard || monthIsWildcard) {
+56-12
src/scheduler.ts
···11import type { ParsedCron, CronOptions } from "./types.js";
22import { parse } from "./parser.js";
33-import { matches, findNext, findPrevious, getDaysInMonth } from "./matcher.js";
33+import {
44+ matches,
55+ findNext,
66+ findPrevious,
77+ getDaysInMonth,
88+ isOrMode,
99+ matchesDayOrWeekday,
1010+} from "./matcher.js";
411import { convertToTimezone, convertFromTimezone } from "./timezone.js";
512613const MAX_ITERATIONS = 1000;
···119126 const day = date.getUTCDate();
120127 const month = date.getUTCMonth();
121128 const year = date.getUTCFullYear();
129129+ const weekday = date.getUTCDay();
122130 const daysInMonth = getDaysInMonth(year, month);
123131124132 // Month mismatch
···127135 return;
128136 }
129137130130- // Day mismatch
131131- if (!parsed.day.includes(day) || day > daysInMonth) {
138138+ // Day/Weekday mismatch - use OR logic like matches()
139139+ if (!matchesDayOrWeekday(parsed, day, weekday, daysInMonth)) {
132140 moveToDay(parsed, date, dir, day, month, year, daysInMonth);
133141 return;
134142 }
···167175 return;
168176 }
169177170170- // Weekday mismatch: all fields match but wrong day-of-week.
171171- // Skip directly to next/prev day since no hour/minute on this day can match.
178178+ // All fields match but we still need to advance (called from findMatch loop)
179179+ // This happens when matches() returns false due to day/weekday mismatch
180180+ // Move to next/prev day
172181 moveToDay(parsed, date, dir, day, month, year, daysInMonth);
173182}
174183···190199 }
191200}
192201202202+/**
203203+ * Find the next candidate day to check
204204+ * In OR mode: advance by 1 day (must check every day)
205205+ * Normal mode: jump to next valid day-of-month
206206+ */
207207+function findCandidateDay(
208208+ parsed: ParsedCron,
209209+ currentDay: number,
210210+ dir: Direction,
211211+ daysInMonth: number,
212212+): number | null {
213213+ const d = DIR[dir];
214214+ const inOrMode = isOrMode(parsed);
215215+216216+ if (inOrMode) {
217217+ // In OR mode, we must check every day (can't skip ahead)
218218+ // because any day might match via weekday even if day-of-month doesn't match
219219+ const targetDay = currentDay + d.offset;
220220+ if (dir === "next" && targetDay > daysInMonth) {
221221+ return null;
222222+ }
223223+ if (dir === "prev" && targetDay < 1) {
224224+ return null;
225225+ }
226226+ return targetDay;
227227+ }
228228+229229+ // Normal mode: jump to next valid day-of-month
230230+ return d.find(parsed.day, currentDay + d.offset);
231231+}
232232+193233function moveToDay(
194234 parsed: ParsedCron,
195235 date: Date,
···200240 daysInMonth: number,
201241): void {
202242 const d = DIR[dir];
203203- const targetDay = d.find(parsed.day, currentDay + d.offset);
243243+ const targetDay = findCandidateDay(parsed, currentDay, dir, daysInMonth);
204244 const dayIsValid =
205245 dir === "next" ? targetDay !== null && targetDay <= daysInMonth : targetDay !== null;
206246···227267228268 const daysInMonth = getDaysInMonth(year, month);
229269270270+ // Check if we're in OR mode (both day and weekday restricted)
271271+ const inOrMode = isOrMode(parsed);
272272+230273 if (dir === "next") {
231231- const validDay = findNext(parsed.day, 1);
232232- date.setUTCDate(validDay !== null && validDay <= daysInMonth ? validDay : parsed.day[0]);
274274+ // In OR mode: start from day 1. Normal mode: jump to first valid day
275275+ const startDay = inOrMode ? 1 : (findNext(parsed.day, 1) ?? parsed.day[0]);
276276+ date.setUTCDate(Math.min(startDay, daysInMonth));
233277 } else {
234234- const prevDay = findPrevious(parsed.day, daysInMonth);
235235- if (prevDay !== null) {
236236- date.setUTCDate(prevDay);
237237- } else {
278278+ // In OR mode: start from last day. Normal mode: jump to last valid day
279279+ const startDay = inOrMode ? daysInMonth : findPrevious(parsed.day, daysInMonth);
280280+ if (startDay === null) {
238281 // No valid day in this month, move to previous month
239282 moveToMonth(parsed, date, dir, month, year);
240283 return;
241284 }
285285+ date.setUTCDate(startDay);
242286 }
243287244288 date.setUTCHours(d.hour(parsed));
+2
src/types.ts
···2121 day: number[]; // 1-31
2222 month: number[]; // 0-11 (0 = January, 11 = December)
2323 weekday: number[]; // 0-6 (0 = Sunday, 6 = Saturday)
2424+ dayIsWildcard: boolean; // true if day field was * (not explicitly listed)
2525+ weekdayIsWildcard: boolean; // true if weekday field was * (not explicitly listed)
2426}
+249-3
test/scheduler.test.ts
···953953 });
954954 });
955955956956+ describe("Field mismatches and rollovers", () => {
957957+ describe("OR mode month boundaries", () => {
958958+ it("should return null in OR mode next when day exceeds daysInMonth", () => {
959959+ const from = new Date("2026-01-31T23:59:00Z");
960960+ const next = nextRun("0 0 15 * 1", { from });
961961+ expect(next.getUTCDate()).toBe(2);
962962+ expect(next.getUTCMonth()).toBe(1);
963963+ expect(next.getUTCDay()).toBe(1);
964964+ });
965965+966966+ it("should return null in OR mode prev when day goes below 1", () => {
967967+ const from = new Date("2026-02-01T00:01:00Z");
968968+ const prev = previousRun("0 0 15 * 1", { from });
969969+ expect(prev.getUTCDate()).toBe(26);
970970+ expect(prev.getUTCMonth()).toBe(0);
971971+ expect(prev.getUTCDay()).toBe(1);
972972+ });
973973+974974+ it("should trigger month rollover in OR mode next", () => {
975975+ const from = new Date("2026-01-31T10:00:00Z");
976976+ const next = nextRun("0 9 15 * 1", { from });
977977+ expect(next.getUTCMonth()).toBe(1);
978978+ });
979979+980980+ it("should trigger month rollover in OR mode prev", () => {
981981+ const from = new Date("2026-03-01T08:00:00Z");
982982+ const prev = previousRun("0 10 15 * 1", { from });
983983+ expect(prev.getUTCMonth()).toBe(1);
984984+ });
985985+986986+ it("should handle OR mode crossing month end with next", () => {
987987+ const from = new Date("2026-04-30T23:59:00Z");
988988+ const next = nextRun("0 0 * * 1", { from });
989989+ expect(next.getUTCDate()).toBeGreaterThan(0);
990990+ });
991991+992992+ it("should handle OR mode crossing month start with prev", () => {
993993+ const from = new Date("2026-05-01T00:01:00Z");
994994+ const prev = previousRun("0 0 * * 1", { from });
995995+ expect(prev.getUTCDate()).toBeGreaterThan(0);
996996+ });
997997+ });
998998+999999+ describe("field match but advance needed", () => {
10001000+ it("should call moveToDay when matches returns false despite matching fields", () => {
10011001+ const from = new Date("2026-03-15T09:00:00Z");
10021002+ const next = nextRun("0 9 15 * *", { from });
10031003+ expect(next.getTime()).toBeGreaterThan(from.getTime());
10041004+ });
10051005+ });
10061006+10071007+ describe("minute field mismatch", () => {
10081008+ it("should set valid minute in same hour for next direction", () => {
10091009+ const from = new Date("2026-03-15T10:05:00Z");
10101010+ const next = nextRun("15,30,45 10 * * *", { from });
10111011+ expect(next.getUTCMinutes()).toBe(15);
10121012+ expect(next.getUTCHours()).toBe(10);
10131013+ });
10141014+10151015+ it("should set valid minute in same hour for prev direction", () => {
10161016+ const from = new Date("2026-03-15T10:35:00Z");
10171017+ const prev = previousRun("0,15,30 10 * * *", { from });
10181018+ expect(prev.getUTCMinutes()).toBe(30);
10191019+ expect(prev.getUTCHours()).toBe(10);
10201020+ });
10211021+ });
10221022+10231023+ describe("hour field mismatch", () => {
10241024+ it("should set hours and minutes when valid hour found in same day", () => {
10251025+ const from = new Date("2026-03-15T08:30:00Z");
10261026+ const next = nextRun("0 10,14 * * *", { from });
10271027+ expect(next.getUTCHours()).toBe(10);
10281028+ expect(next.getUTCMinutes()).toBe(0);
10291029+ });
10301030+10311031+ it("should set hours and minutes for prev direction", () => {
10321032+ const from = new Date("2026-03-15T20:30:00Z");
10331033+ const prev = previousRun("0 10,14 * * *", { from });
10341034+ expect(prev.getUTCHours()).toBe(14);
10351035+ expect(prev.getUTCMinutes()).toBe(0);
10361036+ });
10371037+ });
10381038+10391039+ describe("day field mismatch", () => {
10401040+ it("should moveToDay when day mismatch but month matches", () => {
10411041+ const from = new Date("2026-03-10T10:00:00Z");
10421042+ const next = nextRun("0 9 15 * *", { from });
10431043+ expect(next.getUTCDate()).toBe(15);
10441044+ });
10451045+10461046+ it("should moveToDay when weekday mismatch", () => {
10471047+ const from = new Date("2026-03-11T10:00:00Z");
10481048+ const next = nextRun("0 9 * * 1", { from });
10491049+ expect(next.getUTCDay()).toBe(1);
10501050+ });
10511051+10521052+ it("should moveToDay in prev direction when day mismatch", () => {
10531053+ const from = new Date("2026-03-20T08:00:00Z");
10541054+ const prev = previousRun("0 9 15 * *", { from });
10551055+ expect(prev.getUTCDate()).toBe(15);
10561056+ });
10571057+ });
10581058+10591059+ describe("without timezone option", () => {
10601060+ it("should return date directly when no timezone specified", () => {
10611061+ const from = new Date("2026-03-15T10:30:00Z");
10621062+ const next = nextRun("0 15 * * *", { from });
10631063+ expect(next.getUTCHours()).toBe(15);
10641064+ expect(next.getUTCMinutes()).toBe(0);
10651065+ });
10661066+10671067+ it("should work with previousRun without timezone", () => {
10681068+ const from = new Date("2026-03-15T15:30:00Z");
10691069+ const prev = previousRun("0 9 * * *", { from });
10701070+ expect(prev.getUTCHours()).toBe(9);
10711071+ });
10721072+ });
10731073+ });
10741074+9561075 describe("Edge case coverage", () => {
9571076 describe("minute rollover to next hour (scheduler.ts:160-161)", () => {
9581077 it("should rollover to next hour when no valid minute exists in current hour", () => {
···10951214 describe("MAX_ITERATIONS safety (lines 93-94)", () => {
10961215 it("should find matches efficiently without hitting iteration limit", () => {
10971216 const from = new Date("2026-01-01T00:00:00Z");
12171217+ // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb)
12181218+ // Standard cron uses OR logic when both day and weekday are specified
12191219+ // Next match: 2026-02-02 (first Monday in Feb after start date)
10981220 const sparseCron = "0 0 29 2 1";
1099122111001222 const next = nextRun(sparseCron, { from });
1101122311021102- expect(next.getUTCDate()).toBe(29);
11031103- expect(next.getUTCMonth()).toBe(1);
11041104- expect(next.getUTCFullYear()).toBe(2028);
12241224+ expect(next.getUTCDate()).toBe(2);
12251225+ expect(next.getUTCMonth()).toBe(1); // February
12261226+ expect(next.getUTCFullYear()).toBe(2026);
12271227+ expect(next.getUTCDay()).toBe(1); // Monday
12281228+ });
12291229+12301230+ it("should handle OR logic in prev direction (day 29 OR Monday)", () => {
12311231+ const from = new Date("2028-03-01T00:00:00Z");
12321232+ // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb)
12331233+ // Previous match should be Feb 29, 2028 (Tuesday, but matches day 29)
12341234+ const sparseCron = "0 0 29 2 1";
12351235+12361236+ const prev = previousRun(sparseCron, { from });
12371237+12381238+ expect(prev.getUTCDate()).toBe(29);
12391239+ expect(prev.getUTCMonth()).toBe(1); // February
12401240+ expect(prev.getUTCFullYear()).toBe(2028);
12411241+ });
12421242+12431243+ it("should handle OR logic in prev direction crossing month boundary", () => {
12441244+ // Start from Feb 1, go backwards with OR logic
12451245+ const from = new Date("2026-02-01T00:00:00Z");
12461246+ // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb)
12471247+ // Previous match should be in February 2025 (last Monday)
12481248+ const sparseCron = "0 0 29 2 1";
12491249+12501250+ const prev = previousRun(sparseCron, { from });
12511251+12521252+ // Should find last Monday in February 2025
12531253+ expect(prev.getUTCMonth()).toBe(1); // February
12541254+ expect(prev.getUTCFullYear()).toBe(2025);
12551255+ expect(prev.getUTCDay()).toBe(1); // Monday
11051256 });
1106125711071258 it("should handle sparse cron expressions within iteration limit", () => {
···11111262 expect(next.getUTCDate()).toBe(31);
11121263 expect(next.getUTCMonth()).toBe(11);
11131264 expect(next.getUTCFullYear()).toBe(2026);
12651265+ });
12661266+ });
12671267+12681268+ describe("Day and weekday OR logic", () => {
12691269+ it("should match only day when weekday is wildcard (0 0 15 * *)", () => {
12701270+ // Day 15, weekday wildcard - should match ONLY day 15
12711271+ const cron = "0 0 15 * *";
12721272+ const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15
12731273+ const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16
12741274+12751275+ expect(isMatch(cron, feb15)).toBe(true); // Matches day 15
12761276+ expect(isMatch(cron, feb16)).toBe(false); // Doesn't match (not day 15)
12771277+ });
12781278+12791279+ it("should match only weekday when day is wildcard (0 0 * * 1)", () => {
12801280+ // Day wildcard, Monday - should match ONLY Mondays
12811281+ const cron = "0 0 * * 1";
12821282+ const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15
12831283+ const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16
12841284+12851285+ expect(isMatch(cron, feb15)).toBe(false); // Doesn't match (not Monday)
12861286+ expect(isMatch(cron, feb16)).toBe(true); // Matches Monday
12871287+ });
12881288+12891289+ it("should use OR logic when both day and weekday are restricted (0 0 15 * 1)", () => {
12901290+ // Day 15 OR Monday - should match either condition
12911291+ const cron = "0 0 15 * 1";
12921292+ const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15
12931293+ const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16
12941294+ const feb17 = new Date("2026-02-17T00:00:00Z"); // Tuesday, day 17
12951295+12961296+ expect(isMatch(cron, feb15)).toBe(true); // Matches day 15 (even though not Monday)
12971297+ expect(isMatch(cron, feb16)).toBe(true); // Matches Monday (even though not day 15)
12981298+ expect(isMatch(cron, feb17)).toBe(false); // Doesn't match (neither day 15 nor Monday)
12991299+ });
13001300+13011301+ it("should find next occurrence with day-only restriction", () => {
13021302+ // Day 15, weekday wildcard - should find next day 15
13031303+ const from = new Date("2026-02-01T00:00:00Z");
13041304+ const next = nextRun("0 0 15 * *", { from });
13051305+13061306+ expect(next.getUTCDate()).toBe(15);
13071307+ expect(next.getUTCMonth()).toBe(1); // February
13081308+ expect(next.getUTCFullYear()).toBe(2026);
13091309+ });
13101310+13111311+ it("should find next occurrence with weekday-only restriction", () => {
13121312+ // Day wildcard, Monday - should find next Monday
13131313+ const from = new Date("2026-02-15T00:00:00Z"); // Sunday
13141314+ const next = nextRun("0 0 * * 1", { from });
13151315+13161316+ expect(next.getUTCDay()).toBe(1); // Monday
13171317+ expect(next.getUTCDate()).toBe(16);
13181318+ expect(next.getUTCMonth()).toBe(1); // February
13191319+ });
13201320+13211321+ it("should find next occurrence with OR logic (day 15 OR Monday)", () => {
13221322+ // Day 15 OR Monday - should find next occurrence of either
13231323+ const from = new Date("2026-02-14T00:00:00Z"); // Saturday, day 14
13241324+ const next = nextRun("0 0 15 * 1", { from });
13251325+13261326+ // Next match is Feb 15 (Sunday, day 15) - matches day condition
13271327+ expect(next.getUTCDate()).toBe(15);
13281328+ expect(next.getUTCMonth()).toBe(1); // February
13291329+ expect(next.getUTCFullYear()).toBe(2026);
13301330+ });
13311331+13321332+ it("should find multiple occurrences with OR logic", () => {
13331333+ // Day 15 OR Monday - get next 5 occurrences
13341334+ const from = new Date("2026-02-01T00:00:00Z");
13351335+ const runs = nextRuns("0 0 15 * 1", 5, { from });
13361336+13371337+ // Should get: Feb 2 (Mon), Feb 9 (Mon), Feb 15 (Sun, day 15), Feb 16 (Mon), Feb 23 (Mon)
13381338+ expect(runs[0].toDateString()).toBe("Mon Feb 02 2026");
13391339+ expect(runs[1].toDateString()).toBe("Mon Feb 09 2026");
13401340+ expect(runs[2].toDateString()).toBe("Sun Feb 15 2026"); // Day 15
13411341+ expect(runs[3].toDateString()).toBe("Mon Feb 16 2026");
13421342+ expect(runs[4].toDateString()).toBe("Mon Feb 23 2026");
13431343+ });
13441344+13451345+ it("should treat explicit all weekdays (0-6) as restricted, not wildcard", () => {
13461346+ // Day 15, weekdays 0-6 (all days explicitly listed)
13471347+ // According to cron standard: * is wildcard, but 0-6 is restricted
13481348+ // When both day and weekday are restricted, use OR logic
13491349+ // Result: day 15 OR (Sun OR Mon OR ... OR Sat) = matches every day
13501350+ const cron = "0 0 15 * 0-6";
13511351+ const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15
13521352+ const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16
13531353+13541354+ // Feb 15: matches day 15 OR weekday (Sunday is in 0-6) = true
13551355+ expect(isMatch(cron, feb15)).toBe(true);
13561356+13571357+ // Feb 16: doesn't match day 15, but matches weekday (Monday is in 0-6) = true
13581358+ // This is the key difference from wildcard behavior
13591359+ expect(isMatch(cron, feb16)).toBe(true);
11141360 });
11151361 });
11161362 });