From d80182060c2ee945d7e0e4728812abf9465a0d6a Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 10 Dec 2024 22:09:57 +0100 Subject: [PATCH] added `Cron.unsafeParse` and allow passing the `tz` parameter as `string` (#4106) --- .changeset/polite-trainers-give.md | 5 ++++ packages/effect/src/Cron.ts | 35 ++++++++++++++++++++++-- packages/effect/src/Schedule.ts | 6 +++- packages/effect/src/internal/schedule.ts | 8 ++++-- packages/effect/test/Cron.test.ts | 18 +++++++----- 5 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 .changeset/polite-trainers-give.md diff --git a/.changeset/polite-trainers-give.md b/.changeset/polite-trainers-give.md new file mode 100644 index 00000000000..547ef0f1dd1 --- /dev/null +++ b/.changeset/polite-trainers-give.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added `Cron.unsafeParse` and allow passing the `Cron.parse` time zone parameter as `string`. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 1e07f61ddc5..153c50d0f66 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -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 => { +export const parse = (cron: string, tz?: DateTime.TimeZone | string): Either.Either => { 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)) @@ -267,16 +267,47 @@ export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either 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. * diff --git a/packages/effect/src/Schedule.ts b/packages/effect/src/Schedule.ts index 2edc7e36f7c..94b1c2a45aa 100644 --- a/packages/effect/src/Schedule.ts +++ b/packages/effect/src/Schedule.ts @@ -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" @@ -403,7 +404,10 @@ export const count: Schedule = 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 diff --git a/packages/effect/src/internal/schedule.ts b/packages/effect/src/internal/schedule.ts index c5704c5d1f2..a10da36df9a 100644 --- a/packages/effect/src/internal/schedule.ts +++ b/packages/effect/src/internal/schedule.ts @@ -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" @@ -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]) => { diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index dab58822351..5b11a28f8df 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -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) => @@ -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]") @@ -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.