import { keyword, literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { toString as CronToString } from 'cronstrue' import { Column } from 'react-data-grid' import { cn } from 'ui' import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants' import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants' import { CronJobTableCell } from './CronJobTableCell' import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' const unescapeSqlLiteral = (value = '', isEscapeString = false) => { const unescaped = value.replaceAll("''", "'") return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped } export function buildCronQuery(name: string, schedule: string, command: string): SafeSqlFragment { return safeSql`select cron.schedule(${literal(name)}, ${literal(schedule)}, ${literal(command)});` } export const buildHttpRequestCommand = ( method: 'GET' | 'POST', url: string, headers: HTTPHeader[] = [], body: string | undefined, timeout: number ): SafeSqlFragment => { const funcName = keyword(method === 'GET' ? 'http_get' : 'http_post') const headersJson = JSON.stringify( Object.fromEntries(headers.filter((v) => v.name && v.value).map((v) => [v.name, v.value])) ) const bodyPart = method === 'POST' && body ? safeSql`\n body:=${literal(body)},` : safeSql`` return safeSql` select net.${funcName}( url:=${literal(url)}, headers:=${literal(headersJson)}::jsonb, ${bodyPart} timeout_milliseconds:=${literal(timeout)} );` } const DEFAULT_CRONJOB_COMMAND = { type: 'sql_snippet', snippet: '', // add default values for the other command types. Even though they don't exist in sql_snippet, they'll still work as default values. method: 'POST', timeoutMs: 1000, httpBody: '', } as const export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => { const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim() if (command.toLocaleLowerCase().match(/^select\s+net\./)) { const methodMatch = command.match(/select\s+net\.([^']+)\(\s*url:=/i) const method = methodMatch?.[1] || '' const urlMatch = command.match(/url:=(E)?'((?:''|[^'])*)'/i) const url = unescapeSqlLiteral(urlMatch?.[2], Boolean(urlMatch?.[1])) const bodyMatch = command.match(/body:=(E)?'((?:''|[^'])*)'/i) const body = unescapeSqlLiteral(bodyMatch?.[2], Boolean(bodyMatch?.[1])) const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i) const timeout = timeoutMatch?.[1] || '' const headersJsonBuildObjectMatch = command.match(/headers:=jsonb_build_object\(([^)]*)/i) const headersJsonBuildObject = headersJsonBuildObjectMatch?.[1] || '' let headersObjs: { name: string; value: string }[] = [] if (headersJsonBuildObject) { const headers = headersJsonBuildObject .split(',') .map((s) => unescapeSqlLiteral(s.trim().replace(/^'|'$/g, ''))) for (let i = 0; i < headers.length; i += 2) { if (headers[i] && headers[i].length > 0) { headersObjs.push({ name: headers[i], value: headers[i + 1] }) } } } else { const headersStringMatch = command.match(/headers:=(E)?'((?:''|[^'])*)'/i) const headersString = unescapeSqlLiteral(headersStringMatch?.[2], Boolean(headersStringMatch?.[1])) || '{}' try { const parsedHeaders = JSON.parse(headersString) headersObjs = Object.entries(parsedHeaders).map(([name, value]) => ({ name, value: value as string, })) } catch (error) { console.error('Error parsing headers:', error) } } // If there's a search param or hash in the edge function URL, let it be handled by the HTTP Request case. // Otherwise, the params/hash may be lost during editing of the cron job. let searchParams = '' let urlHash = '' try { const urlObject = new URL(url) searchParams = urlObject.search urlHash = urlObject.hash } catch {} if ( url.includes(`${projectRef}.briven.`) && url.includes('/functions/v1/') && searchParams.length === 0 && urlHash.length === 0 ) { return { type: 'edge_function', method: method === 'http_get' ? 'GET' : 'POST', edgeFunctionName: url, httpHeaders: headersObjs, httpBody: body, timeoutMs: Number(timeout ?? 1000), snippet: originalCommand, } } if (url !== '') { return { type: 'http_request', method: method === 'http_get' ? 'GET' : 'POST', endpoint: url, httpHeaders: headersObjs, httpBody: body, timeoutMs: Number(timeout ?? 1000), snippet: originalCommand, } } } const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g if (command.toLocaleLowerCase().match(regexDBFunction)) { const [schemaName, functionName] = command .replace(/^select\s+/i, '') .replace(/\(.*\);*/, '') .trim() .split('.') return { type: 'sql_function', schema: schemaName, functionName: functionName, snippet: originalCommand, } } if (command.length > 0) { return { type: 'sql_snippet', snippet: originalCommand, } } return DEFAULT_CRONJOB_COMMAND } export function calculateDuration(start: string, end: string): string { const startTime = new Date(start).getTime() const endTime = new Date(end).getTime() const duration = endTime - startTime if (isNaN(duration)) return 'Invalid Date' if (duration < 1000) return `${duration}ms` if (duration < 60000) return `${(duration / 1000).toFixed(1)}s` return `${(duration / 60000).toFixed(1)}m` } export function formatDate(dateString: string): string { const date = new Date(dateString) if (isNaN(date.getTime())) { return 'Invalid Date' } const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', // Use 'long' for full month name day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, // Use 12-hour format if preferred timeZoneName: 'short', // Optional: to include timezone } return date.toLocaleString(undefined, options) } export function isSecondsFormat(schedule: string): boolean { return secondsPattern.test(schedule.trim().toLocaleLowerCase()) } export function getScheduleMessage(scheduleString: string) { if (!scheduleString) { return 'Enter a valid cron expression above' } // if the schedule is in seconds format, scheduleString is same as the schedule if (secondsPattern.test(scheduleString)) { return `The cron will run every ${scheduleString}` } 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 run ${readableSchedule}.` } export const formatScheduleString = (value: string) => { try { if (secondsPattern.test(value)) { return value } else { return CronToString(value) } } catch (error) { return '' } } export const formatCronJobColumns = ({ onSelectEdit, onSelectDelete, }: { onSelectEdit: (job: CronJob) => void onSelectDelete: (job: CronJob) => void }): Array> => { return CRON_TABLE_COLUMNS.map((col) => { const res: Column = { key: col.id, name: col.name, minWidth: col.minWidth ?? 100, maxWidth: col.maxWidth, width: col.width, resizable: col.resizable ?? false, sortable: false, draggable: false, headerCellClass: undefined, renderHeaderCell: () => { return (

{col.name}

) }, renderCell: ({ row }) => ( ), } return res }) }