Repository navigation
v2.2.7
This patch makes next() and previous() on rules without COUNT (unbounded or UNTIL-bound, the usual calendar case) 30–120x faster, speeds up time zone setup and sub-daily generation, and fixes HOURLY, MINUTELY, and SECONDLY rules that ignored their INTERVAL cadence, plus two monthly BYSETPOS bugs. There are no API changes.
Faster queries on rules without COUNT
next(), previous(), between(), matches(), and occursOn() used to clone a rule without COUNT and replay it through Temporal on every call. Simple DAILY, WEEKLY, MONTHLY, YEARLY, and fixed-step rules now visit only the recurrence periods around the query, in integer time, and RDATE/EXDATE merge as they do for COUNT rules. Unsupported shapes, DST gaps, and scans the general engine couldn't finish within maxIterations keep the existing path. (#145)
| Query | v2.2.6 | v2.2.7 | Speedup |
|---|---|---|---|
WEEKLY M/W/F next(), 40 EXDATEs, no end, Chicago |
157.5 µs | 1.33 µs | 118x |
WEEKLY M/W/F next(), no end, UTC |
61.25 µs | 0.69 µs | 88.77x |
WEEKLY M/W/F next(), UNTIL 2030, UTC |
64.59 µs | 0.89 µs | 72.57x |
MONTHLY last Friday next(), no end, Chicago |
68.79 µs | 1.39 µs | 49.49x |
YEARLY previous(), no end, Chicago |
79.11 µs | 2.23 µs | 35.48x |
HOURLY every 4 hours next(), no end, UTC |
19.32 µs | 0.61 µs | 31.67x |
WEEKLY M/W/F between() one month, no end, UTC |
36.22 µs | 6.40 µs | 5.66x |
DAILY between() one month, no end, Chicago |
82.59 µs | 35.89 µs | 2.30x |
Faster time zone setup and generation
- Time zone tables are built from the Temporal implementation's own transitions instead of daily Intl probes. The first
all()for a Chicago weekday rule in a fresh process dropped from 23.3 ms to 4.6 ms. (#145) - Long-running processes no longer slow down exponentially. A table used to double toward the past whenever a query landed beyond its end. Creating Chicago rules two years apart, the 15th
all()took 45 seconds; it now takes about 0 ms. (#145) - Fast paths in more cases: UTC WEEKLY, MONTHLY, and expanded DAILY rules keep their fast paths when RDATE/EXDATE are present, and long named-zone DAILY/WEEKLY COUNT rules no longer fall back to the general engine. (#145)
- Sub-daily rules with BYxxx parts run on a new integer engine that constructs only the occurrences it returns. (#146)
| Generation | v2.2.6 | v2.2.7 | Speedup |
|---|---|---|---|
UTC WEEKLY M/W/F with RDATE/EXDATE, all(), COUNT 1,000 |
12.555 ms | 0.697 ms | 18.02x |
HOURLY quarter hours, all(), COUNT 1,000, UTC |
3.967 ms | 1.073 ms | 3.70x |
MINUTELY 9:00–16:45 quarter hours, all(), COUNT 1,000, UTC |
3.964 ms | 1.299 ms | 3.05x |
HOURLY weekdays, all(), COUNT 1,000, Chicago |
3.818 ms | 1.918 ms | 1.99x |
Chicago weekdays, first all() in a fresh process |
23.282 ms | 4.593 ms | 5.07x |
Measured against v2.2.6 on an Apple M2 Max with Node 25.2.1 and the bundled polyfill. Every scenario returned identical results from both builds before it was timed. Queries are warm medians per call; generation uses uncached all() calls, alternating the builds each sample.
These are selected scenarios, not universal gains:
- The 13 existing COUNT-bound query scenarios measured 0.93–2.81x, and the 29 existing generation scenarios 0.93–1.18x. Isolated re-runs of the lowest cases were within 2% of v2.2.6.
- The first UTC call in a fresh process was 0.05 ms slower.
- The bundle grew by 3.1 kB gzipped (6.6%).
Full benchmark evidence includes every run, sample ranges, and the source hash; the README has the updated comparison with rrule.
Correctness fixes
- Sub-daily rules ignored the INTERVAL cadence. HOURLY, MINUTELY, and SECONDLY rules with BYxxx parts jumped to the next BYxxx value instead of the next
DTSTART + k * INTERVALinstant. (#146)- From
DTSTART:20240131T233000Z, the ruleFREQ=MINUTELY;INTERVAL=3;BYMINUTE=0,17,45;UNTIL=20240201T013000Zreturned only 23:45 fromall(). It should return 23:45, 00:00, 00:45 and 01:00. previous(01:29)returned 01:17, which 3-minute steps from 23:30 never reach.- Other rules skipped whole weeks, emitted occurrences before DTSTART, returned fewer than COUNT results, or exceeded
maxIterations. - RFC 5545's own "every 20 minutes from 9:00 to 16:40" example went wrong at its 25th occurrence.
- From
- Monthly BYSETPOS returned a date twice when positive and negative positions selected the same candidate:
BYMONTHDAY=1,2,3;BYSETPOS=1,-3gave Jan 1, Jan 1, Feb 1, Feb 1 instead of Jan 1, Feb 1, Mar 1, Apr 1. (#146) - Named-zone MONTHLY BYSETPOS ranked a time that doesn't exist. In America/Chicago,
FREQ=MONTHLY;BYDAY=SU;BYSETPOS=3;BYHOUR=2counted 02:00 on March 8, 2026, which the switch to daylight saving time skips, and returned March 15 instead of March 22. (#146) - Fast paths agree with the general engine on the polyfill. temporal-polyfill misses some short offset regimes that Intl reports, so
all()andall(callback)could return different instants: 13:00+01:00 vs 12:00+01:00 for a Casablanca daily 12:00 rule. (#145)
Behavior changes
- SECONDLY and MINUTELY rules limited by BYDAY now begin at the matching day's first second or minute, not at DTSTART's time of day.
FREQ=SECONDLY;BYDAY=MOfrom a Wednesday at 12:00 now starts on Monday at 00:00:00, as it does in python-dateutil, rrule.js, and rrule-rust. - Sub-daily rules with BYxxx parts, especially with INTERVAL greater than 1, can return different occurrences than v2.2.6. The new results are the correct ones, so regenerate any occurrences you stored from such rules.
Validation
- 1,567 tests pass on Node 20 and 24 (bundled polyfill) and Node 26 (native Temporal).
- Sub-daily results match python-dateutil for 152 reference rules, and a separate brute-force enumeration agrees on all of them.
- An independent oracle covers DST folds and gaps, 30-minute and :45 transitions, and Samoa's skipped day.
- 377,164 extended seeded fuzz comparisons of the new query plans and sub-daily engine, against the general engine and independent oracles, found no mismatches.
- 1,436 INTERVAL=1 sub-daily rules across 15 DTSTART calendars return the same results as v2.2.6. (#148)
Full changelog: v2.2.6...v2.2.7