Skip to content

Commit

Permalink
added Cron.unsafeParse and allow passing the tz parameter as `str…
Browse files Browse the repository at this point in the history
…ing` (#4106)
  • Loading branch information
fubhy authored and tim-smart committed Dec 12, 2024
1 parent 73d2bfb commit 1840339
Show file tree
Hide file tree
Showing 5 changed files with 60 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-trainers-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Added `Cron.unsafeParse` and allow passing the `Cron.parse` time zone parameter as `string`.
35 changes: 33 additions & 2 deletions packages/effect/src/Cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars
* @since 2.0.0
* @category constructors
*/
export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either<Cron, ParseError> => {
export const parse = (cron: string, tz?: DateTime.TimeZone | string): Either.Either<Cron, ParseError> => {
const segments = cron.split(" ").filter(String.isNonEmpty)
if (segments.length !== 5 && segments.length !== 6) {
return Either.left(ParseError(`Invalid number of segments in cron expression`, cron))
Expand All @@ -267,16 +267,47 @@ export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either<Cron,
}

const [seconds, minutes, hours, days, months, weekdays] = segments
const zone = tz === undefined || dateTime.isTimeZone(tz) ?
Either.right(tz) :
Either.fromOption(dateTime.zoneFromString(tz), () => ParseError(`Invalid time zone in cron expression`, tz))

return Either.all({
tz: zone,
seconds: parseSegment(seconds, secondOptions),
minutes: parseSegment(minutes, minuteOptions),
hours: parseSegment(hours, hourOptions),
days: parseSegment(days, dayOptions),
months: parseSegment(months, monthOptions),
weekdays: parseSegment(weekdays, weekdayOptions)
}).pipe(Either.map((segments) => make({ ...segments, tz })))
}).pipe(Either.map(make))
}

/**
* Parses a cron expression into a `Cron` instance.
*
* Throws on failure.
*
* @param cron - The cron expression to parse.
*
* @example
* ```ts
* import { Cron } from "effect"
*
* // At 04:00 on every day-of-month from 8 through 14.
* assert.deepStrictEqual(Cron.unsafeParse("0 4 8-14 * *"), Cron.make({
* minutes: [0],
* hours: [4],
* days: [8, 9, 10, 11, 12, 13, 14],
* months: [],
* weekdays: []
* }))
* ```
*
* @since 2.0.0
* @category constructors
*/
export const unsafeParse = (cron: string, tz?: DateTime.TimeZone | string): Cron => Either.getOrThrow(parse(cron, tz))

/**
* Checks if a given `Date` falls within an active `Cron` time window.
*
Expand Down
6 changes: 5 additions & 1 deletion packages/effect/src/Schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type * as Cause from "./Cause.js"
import type * as Chunk from "./Chunk.js"
import type * as Context from "./Context.js"
import type * as Cron from "./Cron.js"
import type * as DateTime from "./DateTime.js"
import type * as Duration from "./Duration.js"
import type * as Effect from "./Effect.js"
import type * as Either from "./Either.js"
Expand Down Expand Up @@ -403,7 +404,10 @@ export const count: Schedule<number> = internal.count
* @since 2.0.0
* @category constructors
*/
export const cron: (expression: string | Cron.Cron) => Schedule<[number, number]> = internal.cron
export const cron: {
(cron: Cron.Cron): Schedule<[number, number]>
(expression: string, tz?: DateTime.TimeZone | string): Schedule<[number, number]>
} = internal.cron

/**
* Cron-like schedule that recurs every specified `day` of month. Won't recur
Expand Down
8 changes: 6 additions & 2 deletions packages/effect/src/internal/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Chunk from "../Chunk.js"
import * as Clock from "../Clock.js"
import * as Context from "../Context.js"
import * as Cron from "../Cron.js"
import type * as DateTime from "../DateTime.js"
import * as Duration from "../Duration.js"
import type * as Effect from "../Effect.js"
import * as Either from "../Either.js"
Expand Down Expand Up @@ -413,8 +414,11 @@ export const mapInputEffect = dual<
)))

/** @internal */
export const cron = (expression: string | Cron.Cron): Schedule.Schedule<[number, number]> => {
const parsed = Cron.isCron(expression) ? Either.right(expression) : Cron.parse(expression)
export const cron: {
(expression: Cron.Cron): Schedule.Schedule<[number, number]>
(expression: string, tz?: DateTime.TimeZone | string): Schedule.Schedule<[number, number]>
} = (expression: string | Cron.Cron, tz?: DateTime.TimeZone | string): Schedule.Schedule<[number, number]> => {
const parsed = Cron.isCron(expression) ? Either.right(expression) : Cron.parse(expression, tz)
return makeWithState<[boolean, [number, number, number]], unknown, [number, number]>(
[true, [Number.MIN_SAFE_INTEGER, 0, 0]],
(now, _, [initial, previous]) => {
Expand Down
18 changes: 11 additions & 7 deletions packages/effect/test/Cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as Option from "effect/Option"
import { assertFalse, assertTrue, deepStrictEqual } from "effect/test/util"
import { describe, it } from "vitest"

const parse = (input: string, tz?: DateTime.TimeZone) => Either.getOrThrowWith(Cron.parse(input, tz), identity)
const parse = (input: string, tz?: DateTime.TimeZone | string) => Either.getOrThrowWith(Cron.parse(input, tz), identity)
const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) =>
Cron.match(Cron.isCron(input) ? input : parse(input), date)
const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
Expand Down Expand Up @@ -151,10 +151,12 @@ describe("Cron", () => {
})

it("handles transition into daylight savings time", () => {
const berlin = DateTime.zoneUnsafeMakeNamed("Europe/Berlin")
const make = (date: string) => DateTime.makeZonedFromString(date).pipe(Option.getOrThrow)
const sequence = Cron.sequence(parse("30 * * * *", berlin), make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]"))
const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: berlin })
const sequence = Cron.sequence(
parse("30 * * * *", "Europe/Berlin"),
make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]")
)
const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: "Europe/Berlin" })

const a = make("2024-03-31T00:30:00.000+01:00[Europe/Berlin]")
const b = make("2024-03-31T01:30:00.000+01:00[Europe/Berlin]")
Expand All @@ -168,10 +170,12 @@ describe("Cron", () => {
})

it("handles transition out of daylight savings time", () => {
const berlin = DateTime.zoneUnsafeMakeNamed("Europe/Berlin")
const make = (date: string) => DateTime.makeZonedFromString(date).pipe(Option.getOrThrow)
const sequence = Cron.sequence(parse("30 * * * *", berlin), make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]"))
const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: berlin })
const sequence = Cron.sequence(
parse("30 * * * *", "Europe/Berlin"),
make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]")
)
const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: "Europe/Berlin" })

const a = make("2024-10-27T00:30:00.000+02:00[Europe/Berlin]")
// const x = make("2024-10-27T01:30:00.000+02:00[Europe/Berlin]") // TODO: Our implementation skips this.
Expand Down

0 comments on commit 1840339

Please sign in to comment.