import { Ref } from "@confect/core"; import type { CronJob as ConvexCronJob } from "convex/server"; import { cronJobs as makeConvexCrons, type Crons } from "convex/server"; import { pipe } from "effect/Function"; import * as Array from "effect/Array"; import * as Cron from "effect/Cron"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Predicate from "effect/Predicate"; import * as Record from "effect/Record"; import type * as CronJob from "./CronJob"; export const TypeId = "~@confect/server/CronJobs"; export type TypeId = typeof TypeId; export interface CronJobs { readonly [TypeId]: TypeId; readonly cronJobs: Record; readonly convexCronJobs: Crons; add(cron: CronJob.CronJob): CronJobs; } export const isCronJobs = (u: unknown): u is CronJobs => Predicate.hasProperty(u, TypeId); const Proto = { [TypeId]: TypeId, add(this: CronJobs, cronJob: CronJob.CronJob) { const newConvexCrons = Object.assign(makeConvexCrons(), { crons: { ...this.convexCronJobs.crons }, }); const functionReference = Ref.getFunctionReference(cronJob.ref); const encodedArgs = Ref.encodeArgsSync(cronJob.ref, cronJob.args); Match.value(cronJob.schedule).pipe( Match.when(Cron.isCron, (cron) => { newConvexCrons.cron( cronJob.identifier, cronToConvexCronString(cron), functionReference, encodedArgs, ); }), Match.when(Duration.isDuration, (duration) => { newConvexCrons.interval( cronJob.identifier, durationToConvexIntervalSchedule(duration), functionReference, encodedArgs, ); }), Match.exhaustive, ); return makeProto( Record.set(this.cronJobs, cronJob.identifier, cronJob), newConvexCrons, ); }, }; const makeProto = ( cronJobs: Record, convexCronJobs: Crons, ): CronJobs => Object.assign(Object.create(Proto), { cronJobs, convexCronJobs, }); export const make = (): CronJobs => makeProto({}, makeConvexCrons()); export const cronToConvexCronString = (cron: Cron.Cron): string => { pipe( cron.tz, Option.filter((tz) => pipe( tz, Match.value, Match.tag("Offset", ({ offset }) => offset !== 0), Match.tag("Named", ({ id }) => id !== "UTC" && id !== "Etc/UTC"), Match.exhaustive, ), ), Option.map((tz) => { throw new Error( `Convex cron expressions are always evaluated in UTC, but this cron ` + `specifies the time zone "${DateTime.zoneToString(tz)}". ` + `Either omit the timezone or specify UTC.`, ); }), ); const hasNonDefaultSeconds = cron.seconds.size !== 1 || !cron.seconds.has(0); if (hasNonDefaultSeconds) { throw new Error( "Convex cron expressions do not support a seconds field. " + "The seconds field must be the default {0}. " + "Sub-minute scheduling is supported only by interval schedules defined using a Duration.", ); } return Array.join( [ setToField(cron.minutes), setToField(cron.hours), setToField(cron.days), setToField(cron.months), setToField(cron.weekdays), ], " ", ); }; const setToField = (set: ReadonlySet): string => { if (set.size === 0) return "*"; return pipe( set, Array.sort(Order.Number), Array.map((n) => n.toString()), Array.join(","), ); }; export const durationToConvexIntervalSchedule = ( duration: Duration.Duration, ): IntervalSchedule => { const millis = Duration.toMillis(duration); if (millis <= 0) { throw new Error("Interval must be a positive duration."); } const oneHourInMillis = Duration.hours(1).pipe(Duration.toMillis); const hours = millis / oneHourInMillis; if (Number.isInteger(hours)) { return { type: "interval", hours }; } const oneMinuteInMillis = Duration.minutes(1).pipe(Duration.toMillis); const minutes = millis / oneMinuteInMillis; if (Number.isInteger(minutes)) { return { type: "interval", minutes }; } const oneSecondInMillis = Duration.seconds(1).pipe(Duration.toMillis); const seconds = millis / oneSecondInMillis; if (Number.isInteger(seconds)) { return { type: "interval", seconds }; } throw new Error( "Interval must be a whole number of seconds, minutes, or hours.", ); }; type IntervalSchedule = Extract< ConvexCronJob["schedule"], { type: "interval" } >;