diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index c3130c51d6..e477d0c146 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -18,7 +18,6 @@ import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - FormLabel_Shadcn_, Input_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, @@ -33,13 +32,14 @@ import { Admonition } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { CRONJOB_DEFINITIONS } from './CronJobs.constants' import { buildCronQuery, buildHttpRequestCommand, cronPattern, - secondsPattern, parseCronJobCommand, + secondsPattern, } from './CronJobs.utils' import { CronJobScheduleSection } from './CronJobScheduleSection' import { EdgeFunctionSection } from './EdgeFunctionSection' @@ -48,10 +48,10 @@ import { HTTPParameterFieldsSection } from './HttpParameterFieldsSection' import { HttpRequestSection } from './HttpRequestSection' import { SqlFunctionSection } from './SqlFunctionSection' import { SqlSnippetSection } from './SqlSnippetSection' -import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' export interface CreateCronJobSheetProps { selectedCronJob?: Pick + supportsSeconds: boolean isClosing: boolean setIsClosing: (v: boolean) => void onClose: () => void @@ -90,32 +90,45 @@ const sqlSnippetSchema = z.object({ snippet: z.string().trim().min(1), }) -const FormSchema = z.object({ - name: z.string().trim().min(1, 'Please provide a name for your cron job'), - schedule: z - .string() - .trim() - .min(1) - .refine((value) => { - if (cronPattern.test(value)) { - try { - CronToString(value) +const FormSchema = z + .object({ + name: z.string().trim().min(1, 'Please provide a name for your cron job'), + supportsSeconds: z.boolean(), + schedule: z + .string() + .trim() + .min(1) + .refine((value) => { + if (cronPattern.test(value)) { + try { + CronToString(value) + return true + } catch { + return false + } + } else if (secondsPattern.test(value)) { return true - } catch { - return false } - } else if (secondsPattern.test(value)) { - return true + return false + }, 'Invalid Cron format'), + values: z.discriminatedUnion('type', [ + edgeFunctionSchema, + httpRequestSchema, + sqlFunctionSchema, + sqlSnippetSchema, + ]), + }) + .superRefine((data, ctx) => { + if (!cronPattern.test(data.schedule)) { + if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.', + path: ['schedule'], + }) } - return false - }, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'), - values: z.discriminatedUnion('type', [ - edgeFunctionSchema, - httpRequestSchema, - sqlFunctionSchema, - sqlSnippetSchema, - ]), -}) + } + }) export type CreateCronJobForm = z.infer export type CronJobType = CreateCronJobForm['values'] @@ -124,11 +137,14 @@ const FORM_ID = 'create-cron-job-sidepanel' export const CreateCronJobSheet = ({ selectedCronJob, + supportsSeconds, isClosing, setIsClosing, onClose, }: CreateCronJobSheetProps) => { + const { project } = useProjectContext() const isEditing = !!selectedCronJob?.jobname + const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) const { mutate: upsertCronJob, isLoading } = useDatabaseCronJobCreateMutation() @@ -144,11 +160,11 @@ export const CreateCronJobSheet = ({ defaultValues: { name: selectedCronJob?.jobname || '', schedule: selectedCronJob?.schedule || '*/5 * * * *', + supportsSeconds, values: cronJobValues, }, }) - const { project } = useProjectContext() const isEdited = form.formState.isDirty // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet @@ -244,16 +260,15 @@ export const CreateCronJobSheet = ({ - - + Cron jobs cannot be renamed once created - + )} /> - + + supportsSeconds: boolean } -const PRESETS = [ - { name: 'Every minute', expression: '* * * * *' }, - { name: 'Every 5 minutes', expression: '*/5 * * * *' }, - { name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' }, - { name: 'Every night at midnight', expression: '0 0 * * *' }, - { name: 'Every Monday at 2 AM', expression: '0 2 * * 1' }, - { name: 'Every 30 seconds', expression: '30 seconds' }, -] as const - -export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => { +export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => { const { project } = useProjectContext() const initialValue = form.getValues('schedule') - const { schedule } = form.watch() + const schedule = form.watch('schedule') const [presetValue, setPresetValue] = useState(initialValue) const [inputValue, setInputValue] = useState(initialValue) @@ -52,6 +45,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => const [useNaturalLanguage, setUseNaturalLanguage] = useState(false) const [scheduleString, setScheduleString] = useState('') + const PRESETS = [ + ...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []), + { name: 'Every minute', expression: '* * * * *' }, + { name: 'Every 5 minutes', expression: '*/5 * * * *' }, + { name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' }, + { name: 'Every night at midnight', expression: '0 0 * * *' }, + { name: 'Every Monday at 2 AM', expression: '0 2 * * 1' }, + ] as const + const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({ api: `${BASE_PATH}/api/ai/sql/cron`, onResponse: async (response) => { @@ -102,10 +104,18 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => } try { + // Don't allow seconds-based schedules if seconds aren't supported + if (!supportsSeconds && secondsPattern.test(schedule)) { + setScheduleString('Invalid cron expression') + return + } + setScheduleString(CronToString(schedule)) } catch (error) { + setScheduleString('Invalid cron expression') console.error('Error converting cron expression to string:', error) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [schedule]) return ( @@ -116,10 +126,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => render={({ field }) => { return ( - Schedule - - {useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'} - +
+ Schedule + + {useNaturalLanguage + ? 'Describe your schedule in words' + : 'Enter a cron expression'} + +
+
{useNaturalLanguage ? ( @@ -146,6 +161,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => }} /> )} +
onClick={() => { setUseNaturalLanguage(false) form.setValue('schedule', preset.expression) + form.trigger('schedule') setPresetValue(preset.expression) }} > @@ -218,20 +235,8 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {isGeneratingCron ? ( - ) : scheduleString === '' ? ( // set a min length before showing invalid message - 'Enter a valid cron expression above' - ) : scheduleString.includes('Invalid cron expression') ? ( - 'Invalid cron expression' ) : ( - <> - The cron will be run{' '} - {secondsPattern.test(schedule) - ? 'every ' + schedule - : scheduleString - .split(' ') - .map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s)) - .join(' ') + '.'} - + getScheduleMessage(scheduleString, schedule) )} )} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts index 8ad110fa62..4ebe44ebe8 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts @@ -29,7 +29,7 @@ export const buildHttpRequestCommand = ( $$` } -export const DEFAULT_CRONJOB_COMMAND = { +const DEFAULT_CRONJOB_COMMAND = { type: 'sql_snippet', snippet: '', } as const @@ -136,11 +136,33 @@ export function formatDate(dateString: string): string { return date.toLocaleString(undefined, options) } -// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *" -export const secondsPattern = /^\d+\s+seconds$/ export const cronPattern = /^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/ +// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *" +export const secondsPattern = /^\d+\s+seconds$/ + export function isSecondsFormat(schedule: string): boolean { return secondsPattern.test(schedule.trim()) } + +export function getScheduleMessage(scheduleString: string, schedule: string) { + if (!scheduleString) { + return 'Enter a valid cron expression above' + } + + if (secondsPattern.test(schedule)) { + return `The cron will be run every ${schedule}` + } + + if (scheduleString.includes('Invalid cron expression')) { + return scheduleString + } + + const readableSchedule = scheduleString + .split(' ') + .map((s, i) => (i === 0 ? s.toLowerCase() : s)) + .join(' ') + + return `The cron will be run ${readableSchedule}.` +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 8136e14a37..2c103c4302 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -9,6 +9,7 @@ import { parseAsString, useQueryState } from 'nuqs' import { Button, Input, Sheet, SheetContent } from 'ui' import { CronJobCard } from '../CronJobs/CronJobCard' import DeleteCronJob from '../CronJobs/DeleteCronJob' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' export const CronjobsTab = () => { const { project } = useProjectContext() @@ -26,6 +27,17 @@ export const CronjobsTab = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + + const { data: extensions } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + // check pg_cron version to see if it supports seconds + const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron') + const installedVersion = pgCronExtension?.installed_version + const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false + if (isLoading) return (
@@ -125,6 +137,7 @@ export const CronjobsTab = () => { { setIsClosingCreateCronJobSheet(false) setCreateCronJobSheetShown(undefined) diff --git a/apps/studio/package.json b/apps/studio/package.json index 44208f8399..62e41e2834 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -59,6 +59,7 @@ "config": "*", "configcat-js": "^7.0.0", "cronstrue": "^2.50.0", + "cron-parser": "^4.9.0", "dayjs": "^1.11.10", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", diff --git a/package-lock.json b/package-lock.json index 05a306abd3..0dfcff6546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1818,6 +1818,7 @@ "common-tags": "^1.8.2", "config": "*", "configcat-js": "^7.0.0", + "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", "dayjs": "^1.11.10", "dnd-core": "^16.0.1", @@ -20618,6 +20619,17 @@ "optional": true, "peer": true }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cronstrue": { "version": "2.50.0", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.50.0.tgz", @@ -29146,6 +29158,14 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "dev": true, diff --git a/packages/ai-commands/src/sql/cron.ts b/packages/ai-commands/src/sql/cron.ts index 27df01ec0f..26f756e8c1 100644 --- a/packages/ai-commands/src/sql/cron.ts +++ b/packages/ai-commands/src/sql/cron.ts @@ -12,22 +12,22 @@ export async function generateCron(openai: OpenAI, prompt: string) { You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron. Rules for responses: - - Output cron expressions in the 5-field format supported by pg_cron + - For standard intervals (minutes and above), output cron expressions in the 5-field format supported by pg_cron + - For second-based intervals, use the special pg_cron "x seconds" syntax - Do not provide any explanation of what the cron expression does - Format output as markdown with the cron expression in a code block - Do not ask for clarification if you need it. Just output the cron expression. Example input: "Every Monday at 3am" Example output: - This cron expression runs every Monday at 3:00:00 AM: \`\`\` 0 3 * * 1 \`\`\` - - This cron expression runs every minute: + Example input: "Every 30 seconds" + Example output: \`\`\` - * * * * * + 30 seconds \`\`\` Additional examples: @@ -36,14 +36,19 @@ export async function generateCron(openai: OpenAI, prompt: string) { - Every first of the month, at 00:00: \`0 0 1 * *\` - Every night at midnight: \`0 0 * * *\` - Every Monday at 2am: \`0 2 * * 1\` + - Every 15 seconds: \`15 seconds\` + - Every 45 seconds: \`45 seconds\` - Field order: + Field order for standard cron: - minute (0-59) - hour (0-23) - day (1-31) - month (1-12) - weekday (0-6, Sunday=0) + Important: pg_cron uses "x seconds" for second-based intervals, not "x * * * *". + If the user asks for seconds, do not use the 5-field format, instead use "x seconds". + Here is the user's prompt: ${prompt} `,