diff --git a/.changeset/add-cron-prev.md b/.changeset/add-cron-prev.md new file mode 100644 index 00000000000..b66f96fe8ec --- /dev/null +++ b/.changeset/add-cron-prev.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add `Cron.prev` and reverse iteration support, aligning next/prev lookup tables, fixing DST handling symmetry, and expanding cron backward/forward test coverage. diff --git a/package.json b/package.json index 5d3c1af2681..0f75c166b91 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,10 @@ "workerd" ], "onlyBuiltDependencies": [ - "better-sqlite3" + "@parcel/watcher", + "better-sqlite3", + "sharp", + "unrs-resolver" ] } } diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 99569d56a4f..43b16e9d28e 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -53,6 +53,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable { readonly weekday: number } /** @internal */ + readonly last: { + readonly second: number + readonly minute: number + readonly hour: number + readonly day: number + readonly month: number + readonly weekday: number + } + /** @internal */ readonly next: { readonly second: ReadonlyArray readonly minute: ReadonlyArray @@ -61,6 +70,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable { readonly month: ReadonlyArray readonly weekday: ReadonlyArray } + /** @internal */ + readonly prev: { + readonly second: ReadonlyArray + readonly minute: ReadonlyArray + readonly hour: ReadonlyArray + readonly day: ReadonlyArray + readonly month: ReadonlyArray + readonly weekday: ReadonlyArray + } } const CronProto = { @@ -151,31 +169,64 @@ export const make = (values: { weekday: weekdays[0] ?? 0 } + o.last = { + second: seconds[seconds.length - 1] ?? 59, + minute: minutes[minutes.length - 1] ?? 59, + hour: hours[hours.length - 1] ?? 23, + day: days[days.length - 1] ?? 31, + month: (months[months.length - 1] ?? 12) - 1, + weekday: weekdays[weekdays.length - 1] ?? 6 + } + o.next = { - second: nextLookupTable(seconds, 60), - minute: nextLookupTable(minutes, 60), - hour: nextLookupTable(hours, 24), - day: nextLookupTable(days, 32), - month: nextLookupTable(months, 13), - weekday: nextLookupTable(weekdays, 7) + second: lookupTable(seconds, 60, "next"), + minute: lookupTable(minutes, 60, "next"), + hour: lookupTable(hours, 24, "next"), + day: lookupTable(days, 32, "next"), + month: lookupTable(months, 13, "next"), + weekday: lookupTable(weekdays, 7, "next") + } + + o.prev = { + second: lookupTable(seconds, 60, "prev"), + minute: lookupTable(minutes, 60, "prev"), + hour: lookupTable(hours, 24, "prev"), + day: lookupTable(days, 32, "prev"), + month: lookupTable(months, 13, "prev"), + weekday: lookupTable(weekdays, 7, "prev") } return o } -const nextLookupTable = (values: ReadonlyArray, size: number): Array => { +const lookupTable = ( + values: ReadonlyArray, + size: number, + dir: "next" | "prev" +): Array => { const result = new Array(size).fill(undefined) if (values.length === 0) { return result } let current: number | undefined = undefined - let index = values.length - 1 - for (let i = size - 1; i >= 0; i--) { - while (index >= 0 && values[index] >= i) { - current = values[index--] + + if (dir === "next") { + let index = values.length - 1 + for (let i = size - 1; i >= 0; i--) { + while (index >= 0 && values[index] >= i) { + current = values[index--] + } + result[i] = current + } + } else { + let index = 0 + for (let i = 0; i < size; i++) { + while (index < values.length && values[index] <= i) { + current = values[index++] + } + result[i] = current } - result[i] = current } return result @@ -376,7 +427,7 @@ const daysInMonth = (date: Date): number => /** * Returns the next run `Date` for the given `Cron` instance. * - * Uses the current time as a starting point if no value is provided for `now`. + * Uses the current time as a starting point if no value is provided for `startFrom`. * * @example * ```ts @@ -394,38 +445,76 @@ const daysInMonth = (date: Date): number => * @since 2.0.0 */ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => { + return stepCron(cron, startFrom, "next") +} + +/** + * Returns the previous run `Date` for the given `Cron` instance. + * + * Uses the current time as a starting point if no value is provided for `startFrom`. + * + * @example + * ```ts + * import * as assert from "node:assert" + * import { Cron, Either } from "effect" + * + * const before = new Date("2021-01-15 00:00:00") + * const cron = Either.getOrThrow(Cron.parse("0 4 8-14 * *")) + * assert.deepStrictEqual(Cron.prev(cron, before), new Date("2021-01-14 04:00:00")) + * ``` + * + * @throws `IllegalArgumentException` if the given `DateTime.Input` is invalid. + * @throws `Error` if the previous run date cannot be found within 10,000 iterations. + * + * @since 3.20.0 + */ +export const prev = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => { + return stepCron(cron, startFrom, "prev") +} + +/** @internal */ +const stepCron = (cron: Cron, startFrom: DateTime.DateTime.Input | undefined, direction: "next" | "prev"): Date => { const tz = Option.getOrUndefined(cron.tz) const zoned = dateTime.unsafeMakeZoned(startFrom ?? new Date(), { timeZone: tz }) + const prev = direction === "prev" + const tick = prev ? -1 : 1 + const table = cron[direction] + const boundary = prev ? cron.last : cron.first + + const needsStep = prev + ? (next: number, current: number) => next < current + : (next: number, current: number) => next > current + const utc = tz !== undefined && dateTime.isTimeZoneNamed(tz) && tz.id === "UTC" const adjustDst = utc ? constVoid : (current: Date) => { const adjusted = dateTime.unsafeMakeZoned(current, { timeZone: zoned.zone, - adjustForTimeZone: true + adjustForTimeZone: true, + disambiguation: prev ? "later" : undefined }).pipe(dateTime.toDate) - // TODO: This implementation currently only skips forward when transitioning into daylight savings time. const drift = current.getTime() - adjusted.getTime() - if (drift > 0) { - current.setTime(current.getTime() + drift) + if (prev ? drift !== 0 : drift > 0) { + current.setTime(adjusted.getTime()) } } const result = dateTime.mutate(zoned, (current) => { - current.setUTCSeconds(current.getUTCSeconds() + 1, 0) + current.setUTCSeconds(current.getUTCSeconds() + tick, 0) for (let i = 0; i < 10_000; i++) { if (cron.seconds.size !== 0) { const currentSecond = current.getUTCSeconds() - const nextSecond = cron.next.second[currentSecond] + const nextSecond = table.second[currentSecond] if (nextSecond === undefined) { - current.setUTCMinutes(current.getUTCMinutes() + 1, cron.first.second) + current.setUTCMinutes(current.getUTCMinutes() + tick, boundary.second) adjustDst(current) continue } - if (nextSecond > currentSecond) { + if (needsStep(nextSecond, currentSecond)) { current.setUTCSeconds(nextSecond) adjustDst(current) continue @@ -434,14 +523,14 @@ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => { if (cron.minutes.size !== 0) { const currentMinute = current.getUTCMinutes() - const nextMinute = cron.next.minute[currentMinute] + const nextMinute = table.minute[currentMinute] if (nextMinute === undefined) { - current.setUTCHours(current.getUTCHours() + 1, cron.first.minute, cron.first.second) + current.setUTCHours(current.getUTCHours() + tick, boundary.minute, boundary.second) adjustDst(current) continue } - if (nextMinute > currentMinute) { - current.setUTCMinutes(nextMinute, cron.first.second) + if (needsStep(nextMinute, currentMinute)) { + current.setUTCMinutes(nextMinute, boundary.second) adjustDst(current) continue } @@ -449,40 +538,61 @@ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => { if (cron.hours.size !== 0) { const currentHour = current.getUTCHours() - const nextHour = cron.next.hour[currentHour] + const nextHour = table.hour[currentHour] if (nextHour === undefined) { - current.setUTCDate(current.getUTCDate() + 1) - current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) + current.setUTCDate(current.getUTCDate() + tick) + current.setUTCHours(boundary.hour, boundary.minute, boundary.second) adjustDst(current) continue } - if (nextHour > currentHour) { - current.setUTCHours(nextHour, cron.first.minute, cron.first.second) + if (needsStep(nextHour, currentHour)) { + current.setUTCHours(nextHour, boundary.minute, boundary.second) adjustDst(current) continue } } if (cron.weekdays.size !== 0 || cron.days.size !== 0) { - let a: number = Infinity - let b: number = Infinity + let a: number = prev ? -Infinity : Infinity + let b: number = prev ? -Infinity : Infinity if (cron.weekdays.size !== 0) { const currentWeekday = current.getUTCDay() - const nextWeekday = cron.next.weekday[currentWeekday] - a = nextWeekday === undefined ? 7 - currentWeekday + cron.first.weekday : nextWeekday - currentWeekday + const nextWeekday = table.weekday[currentWeekday] + if (nextWeekday === undefined) { + a = prev + ? currentWeekday - 7 + boundary.weekday + : 7 - currentWeekday + boundary.weekday + } else { + a = nextWeekday - currentWeekday + } } + // Only check day-of-month if weekday constraint not already satisfied (they're OR'd) if (cron.days.size !== 0 && a !== 0) { const currentDay = current.getUTCDate() - const nextDay = cron.next.day[currentDay] - b = nextDay === undefined ? daysInMonth(current) - currentDay + cron.first.day : nextDay - currentDay + const nextDay = table.day[currentDay] + if (nextDay === undefined) { + if (prev) { + // When wrapping to previous month, calculate days back: + // Current day offset + gap from end of prev month to target day + // Example: June 3 → May 20 with boundary.day=20: -(3 + (31 - 20)) = -14 + const prevMonthDays = daysInMonth( + new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 0)) + ) + b = -(currentDay + (prevMonthDays - boundary.day)) + } else { + b = daysInMonth(current) - currentDay + boundary.day + } + } else { + b = nextDay - currentDay + } } - const addDays = Math.min(a, b) + const addDays = prev ? Math.max(a, b) : Math.min(a, b) if (addDays !== 0) { current.setUTCDate(current.getUTCDate() + addDays) - current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) + current.setUTCHours(boundary.hour, boundary.minute, boundary.second) adjustDst(current) continue } @@ -490,17 +600,25 @@ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => { if (cron.months.size !== 0) { const currentMonth = current.getUTCMonth() + 1 - const nextMonth = cron.next.month[currentMonth] + const nextMonth = table.month[currentMonth] + const clampBoundaryDay = (targetMonthIndex: number): number => { + if (cron.days.size !== 0) { + return boundary.day + } + const maxDayInMonth = daysInMonth(new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex, 1))) + return Math.min(boundary.day, maxDayInMonth) + } if (nextMonth === undefined) { - current.setUTCFullYear(current.getUTCFullYear() + 1) - current.setUTCMonth(cron.first.month, cron.first.day) - current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) + current.setUTCFullYear(current.getUTCFullYear() + tick) + current.setUTCMonth(boundary.month, clampBoundaryDay(boundary.month)) + current.setUTCHours(boundary.hour, boundary.minute, boundary.second) adjustDst(current) continue } - if (nextMonth > currentMonth) { - current.setUTCMonth(nextMonth - 1, cron.first.day) - current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) + if (needsStep(nextMonth, currentMonth)) { + const targetMonthIndex = nextMonth - 1 + current.setUTCMonth(targetMonthIndex, clampBoundaryDay(targetMonthIndex)) + current.setUTCHours(boundary.hour, boundary.minute, boundary.second) adjustDst(current) continue } @@ -526,6 +644,18 @@ export const sequence = function*(cron: Cron, startFrom?: DateTime.DateTime.Inpu } } +/** + * Returns an `IterableIterator` which yields the sequence of `Date`s that match the `Cron` instance, + * in reverse direction. + * + * @since 3.20.0 + */ +export const sequenceReverse = function*(cron: Cron, startFrom?: DateTime.DateTime.Input): IterableIterator { + while (true) { + yield startFrom = prev(cron, startFrom) + } +} + /** * @category instances * @since 2.0.0 diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 0452ea792b3..8448aa6cb2b 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -8,6 +8,9 @@ const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) => const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) => Cron.next(Cron.isCron(input) ? input : Cron.unsafeParse(input), after) +const prev = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) => + Cron.prev(Cron.isCron(input) ? input : Cron.unsafeParse(input), after) + describe("Cron", () => { it("parse", () => { // At 04:00 on every day-of-month from 8 through 14. @@ -126,6 +129,136 @@ describe("Cron", () => { deepStrictEqual(next(Cron.unsafeParse("5 0 8 2 *", london), after), DateTime.toDateUtc(amsterdamTime)) }) + it("prev", () => { + const utc = DateTime.zoneUnsafeMakeNamed("UTC") + const before = new Date("2024-01-04T16:21:00Z") + deepStrictEqual(prev(Cron.unsafeParse("5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:00.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("15 14 1 * *", utc), before), new Date("2024-01-01T14:15:00.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("23 0-20/2 * * * 0", utc), before), new Date("2023-12-31T23:20:23.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("5 4 * * SUN", utc), before), new Date("2023-12-31T04:05:00.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("5 4 * DEC SUN", utc), before), new Date("2023-12-31T04:05:00.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("30 5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:30.000Z")) + + const wednesday = new Date("2025-10-22T01:00:00.000Z") + deepStrictEqual(prev(Cron.unsafeParse("0 1 * * MON", utc), wednesday), new Date("2025-10-20T01:00:00.000Z")) + deepStrictEqual(next(Cron.unsafeParse("0 1 * * MON", utc), wednesday), new Date("2025-10-27T01:00:00.000Z")) + deepStrictEqual(prev(Cron.unsafeParse("0 1 * * TUE", utc), wednesday), new Date("2025-10-21T01:00:00.000Z")) + deepStrictEqual(next(Cron.unsafeParse("0 1 * * TUE", utc), wednesday), new Date("2025-10-28T01:00:00.000Z")) + }) + + it("returns the latest second when rolling back a minute", () => { + const utc = DateTime.zoneUnsafeMakeNamed("UTC") + const expr = Cron.unsafeParse("10,30 * * * * *", utc) + const before = new Date("2024-01-01T00:00:05.000Z") + deepStrictEqual(prev(expr, before), new Date("2023-12-31T23:59:30.000Z")) + }) + + it("forward and reverse sequences stay aligned", () => { + const cases = [ + ["5 2 * * 1", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"], + ["0 12 1 * *", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"], + ["10,30 * * * * *", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"] + ] as const + + const gather = ( + generator: IterableIterator, + lower: Date, + upper: Date, + direction: "forward" | "reverse" + ) => { + const res: Array = [] + for (const date of generator) { + if (direction === "forward" ? date >= upper : date <= lower) { + break + } + res.push(date) + } + return res + } + + for (const [expr, lowerStr, upperStr] of cases) { + const lower = new Date(lowerStr) + const upper = new Date(upperStr) + const cron = Cron.unsafeParse(expr, DateTime.zoneUnsafeMakeNamed("UTC")) + + const forward = gather(Cron.sequence(cron, lower), lower, upper, "forward") + const reverse = gather(Cron.sequenceReverse(cron, upper), lower, upper, "reverse").reverse() + + deepStrictEqual(forward, reverse) + } + }) + + it("prev prefers the latest matching day within the previous month", () => { + const cron = Cron.unsafeParse("0 0 8 5,20 * *", DateTime.zoneUnsafeMakeNamed("UTC")) + const before = new Date("2024-06-03T00:00:00.000Z") + deepStrictEqual(prev(cron, before), new Date("2024-05-20T08:00:00.000Z")) + }) + + it("prev wraps weekday using the last allowed value", () => { + const cron = Cron.unsafeParse("0 1 * * MON,FRI", DateTime.zoneUnsafeMakeNamed("UTC")) + const sunday = new Date("2025-10-19T12:00:00.000Z") // Sunday + deepStrictEqual(prev(cron, sunday), new Date("2025-10-17T01:00:00.000Z")) // Friday + }) + + it("prev chooses the later occurrence in DST fall-back", () => { + const make = (s: string) => DateTime.makeZonedFromString(s).pipe(Option.getOrThrow) + const tz = "Europe/Berlin" + const cron = Cron.unsafeParse("0 30 2 * * *", tz) + const before = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]") + const result = DateTime.unsafeMakeZoned(prev(cron, before), { timeZone: tz }) + deepStrictEqual(result.pipe(DateTime.formatIsoZoned), "2024-10-27T02:30:00.000+02:00[Europe/Berlin]") + }) + + it("prev respects combined day-of-month and weekday constraints", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 0 9 1,15 * MON", tz) + const before = new Date("2024-04-02T12:00:00.000Z") // Tue after a matching Monday the 1st + deepStrictEqual(prev(cron, before), new Date("2024-04-01T09:00:00.000Z")) + }) + + it("prev handles step expressions across day boundary", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 */7 8-10 * * *", tz) + const before = new Date("2024-01-01T08:01:00.000Z") + deepStrictEqual(prev(cron, before), new Date("2024-01-01T08:00:00.000Z")) + }) + + it("prev works with fixed offset time zones", () => { + const offset = DateTime.zoneMakeOffset(2 * 60 * 60 * 1000) // UTC+2 + const cron = Cron.unsafeParse("0 0 10 * * *", offset) + const before = new Date("2024-05-01T07:00:00.000Z") // before 10:00 local (08:00Z) + deepStrictEqual(prev(cron, before), new Date("2024-04-30T08:00:00.000Z")) + }) + + it("prev wraps across year boundary", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 0 1 1 *", tz) + const from = new Date("2024-01-01T00:00:00.000Z") + deepStrictEqual(prev(cron, from), new Date("2023-01-01T00:00:00.000Z")) + }) + + it("prev handles day 31 skipping months without it", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 0 31 * *", tz) + const from = new Date("2024-03-01T00:00:00.000Z") + // Should skip Feb (no day 31) and go to Jan 31 + deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z")) + }) + + it("prev clamps to the last valid day when rolling back a month with only month constraints", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 0 0 * FEB *", tz) + const from = new Date("2024-03-31T12:00:00.000Z") + deepStrictEqual(prev(cron, from), new Date("2024-02-29T00:00:00.000Z")) + }) + + it("prev with multiple months specified", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("0 0 15 1,4,7,10 *", tz) // Quarterly on 15th + const from = new Date("2024-05-01T00:00:00.000Z") + deepStrictEqual(prev(cron, from), new Date("2024-04-15T00:00:00.000Z")) + }) + it("sequence", () => { const start = new Date("2024-01-01 00:00:00") const generator = Cron.sequence(Cron.unsafeParse("23 0-20/2 * * 0"), start) @@ -136,6 +269,17 @@ describe("Cron", () => { deepStrictEqual(generator.next().value, new Date("2024-01-07 08:23:00")) }) + it("sequenceReverse", () => { + const start = new Date("2024-01-01 00:00:00Z") + const utc = DateTime.zoneUnsafeMakeNamed("UTC") + const generator = Cron.sequenceReverse(Cron.unsafeParse("23 0-20/2 * * 0", utc), start) + deepStrictEqual(generator.next().value, new Date("2023-12-31 20:23:00Z")) + deepStrictEqual(generator.next().value, new Date("2023-12-31 18:23:00Z")) + deepStrictEqual(generator.next().value, new Date("2023-12-31 16:23:00Z")) + deepStrictEqual(generator.next().value, new Date("2023-12-31 14:23:00Z")) + deepStrictEqual(generator.next().value, new Date("2023-12-31 12:23:00Z")) + }) + it("equal", () => { const cron = Cron.unsafeParse("23 0-20/2 * * 0") assertTrue(Equal.equals(cron, cron))