Skip to content

Commit 34d8472

Browse files
fix(studio): support pg_cron $ syntax for last day of month in schedule parsing (supabase#42340)
Bug fix ## What is the current behavior? Cron jobs using pg_cron's `$` syntax (representing 'last day of month') show 'Unable to parse next run for job' in the dashboard's Next run column, even though these are valid pg_cron schedules. ## What is the new behavior? The dashboard now correctly parses schedules using `$` and displays the proper next run time. ## Root Cause pg_cron uses `$` for 'last day of month', but the `cron-parser` library used by Studio uses `L` for the same purpose. The `$` character was causing the parsing to fail. ## Fix Normalize pg_cron's `$` syntax to cron-parser's `L` syntax before parsing. Both represent 'last day of month' - it's just a syntax difference between the two systems. Fixes supabase#42176 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved cron job scheduling to correctly calculate next-run times for pg_cron-compatible schedule expressions. --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
1 parent 636dcaa commit 34d8472

File tree

5 files changed

+31
-8
lines changed

5 files changed

+31
-8
lines changed

apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
66

77
const convertCronToString = (schedule: string) => {
88
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
9-
// original schedule when cronstrue throws
9+
// original schedule when cronstrue throws.
10+
// pg_cron uses '$' for "last day of month"; cronstrue uses 'L' — normalize before parsing.
1011
try {
11-
return CronToString(schedule)
12+
return CronToString(schedule.replace(/\$/g, 'L'))
1213
} catch (error) {
1314
return schedule
1415
}

apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const CronJobPage = () => {
7070
<span className="cursor-pointer underline decoration-dotted lowercase">
7171
{isSecondsFormat(job.schedule)
7272
? job.schedule.toLowerCase()
73-
: CronToString(job.schedule.toLowerCase())}
73+
: CronToString(job.schedule.toLowerCase().replace(/\$/g, 'L'))}
7474
</span>
7575
</TooltipTrigger>
7676
<TooltipContent side="bottom" align="center">

apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ const getNextRun = (schedule: string, lastRun?: string) => {
4646
// cron-parser can only deal with the traditional cron syntax but technically users can also
4747
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
4848
// (can't guarantee as scope is quite big)
49-
if (schedule.includes('*')) {
49+
if (schedule.includes('*') || schedule.includes('$')) {
5050
try {
51-
const interval = parser.parseExpression(schedule, { tz: 'UTC' })
51+
// pg_cron uses '$' for "last day of month", but cron-parser uses 'L'
52+
// Convert pg_cron syntax to cron-parser syntax before parsing
53+
const normalizedSchedule = schedule.replace(/\$/g, 'L')
54+
const interval = parser.parseExpression(normalizedSchedule, { tz: 'UTC' })
5255
return interval.next().getTime()
5356
} catch (error) {
5457
return undefined
@@ -89,7 +92,6 @@ export const CronJobTableCell = ({
8992
const [showToggleModal, setShowToggleModal] = useState(false)
9093

9194
const value = row?.[col.id]
92-
const hasValue = col.id in row
9395
const { jobid, schedule, latest_run, status, active, jobname } = row
9496

9597
const formattedValue =
@@ -103,6 +105,8 @@ export const CronJobTableCell = ({
103105
? getNextRun(schedule, latest_run)
104106
: value
105107

108+
const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row
109+
106110
const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
107111
onSuccess: () => {
108112
toast.success(`Command from "${jobname}" ran successfully`)

apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { EdgeFunctions, RESTApi, SqlEditor } from 'icons'
22
import { ScrollText } from 'lucide-react'
33

4-
export const cronPattern =
5-
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
4+
const cronField = /(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)/
5+
const cronDayOfMonth = /(\*|\$|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)/
6+
export const cronPattern = new RegExp(
7+
`^${cronField.source}\\s+${cronField.source}\\s+${cronDayOfMonth.source}\\s+${cronField.source}\\s+${cronField.source}$`
8+
)
69

710
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
811
export const secondsPattern = /^\d+\s+seconds*$/

apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,23 @@ describe('parseCronJobCommand', () => {
270270
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
271271
{ description: 'every quarter hour', command: '*/15 * * * *' },
272272
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
273+
{ description: 'last day of every month at midnight (pg_cron $ syntax)', command: '0 0 $ * *' },
274+
{ description: 'last day of every month at noon (pg_cron $ syntax)', command: '0 12 $ * *' },
273275
]
274276

277+
const cronPatternRejectTests = [
278+
{ description: '$ in minute field', command: '$ * * * *' },
279+
{ description: '$ in hour field', command: '* $ * * *' },
280+
{ description: '$ in month field', command: '* * * $ *' },
281+
{ description: '$ in day-of-week field', command: '* * * * $' },
282+
]
283+
284+
cronPatternRejectTests.forEach(({ description, command }) => {
285+
it(`should not match the regex for a cronPattern with "${description}"`, () => {
286+
expect(command).not.toMatch(cronPattern)
287+
})
288+
})
289+
275290
// Replace the single cronPattern test with forEach
276291
cronPatternTests.forEach(({ description, command }) => {
277292
it(`should match the regex for a cronPattern with "${description}"`, () => {

0 commit comments

Comments
 (0)