CreateCronJobSheet.constants.ts149 lines · main
| 1 | import { toString as CronToString } from 'cronstrue' |
| 2 | import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation' |
| 3 | import z from 'zod' |
| 4 | |
| 5 | import { cronPattern, secondsPattern } from '../CronJobs.constants' |
| 6 | import { httpEndpointUrlSchema } from '@/lib/validation/http-url' |
| 7 | |
| 8 | const convertCronToString = (schedule: string) => { |
| 9 | // pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the |
| 10 | // original schedule when cronstrue throws. |
| 11 | // pg_cron uses '$' for "last day of month"; cronstrue uses 'L' — normalize before parsing. |
| 12 | try { |
| 13 | return CronToString(schedule.replace(/\$/g, 'L')) |
| 14 | } catch (error) { |
| 15 | return schedule |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | const httpHeadersSchema = z.array(z.object({ name: z.string().trim(), value: z.string().trim() })) |
| 20 | |
| 21 | const addHttpHeaderIssues = ( |
| 22 | rows: z.infer<typeof httpHeadersSchema>, |
| 23 | ctx: z.RefinementCtx, |
| 24 | pathPrefix: string[] |
| 25 | ) => { |
| 26 | getKeyValueFieldArrayValidationIssues({ |
| 27 | rows, |
| 28 | keyFieldName: 'name', |
| 29 | valueFieldName: 'value', |
| 30 | keyRequiredMessage: 'Header name is required', |
| 31 | valueRequiredMessage: 'Header value is required', |
| 32 | }).forEach((issue) => { |
| 33 | ctx.addIssue({ |
| 34 | code: z.ZodIssueCode.custom, |
| 35 | message: issue.message, |
| 36 | path: [...pathPrefix, ...issue.path], |
| 37 | }) |
| 38 | }) |
| 39 | } |
| 40 | |
| 41 | const edgeFunctionSchema = z.object({ |
| 42 | type: z.literal('edge_function'), |
| 43 | method: z.enum(['GET', 'POST']), |
| 44 | edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'), |
| 45 | timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), |
| 46 | httpHeaders: httpHeadersSchema, |
| 47 | httpBody: z |
| 48 | .string() |
| 49 | .trim() |
| 50 | .optional() |
| 51 | .refine((value) => { |
| 52 | if (!value) return true |
| 53 | try { |
| 54 | JSON.parse(value) |
| 55 | return true |
| 56 | } catch { |
| 57 | return false |
| 58 | } |
| 59 | }, 'Input must be valid JSON'), |
| 60 | // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it |
| 61 | snippet: z.string().trim(), |
| 62 | }) |
| 63 | |
| 64 | const httpRequestSchema = z.object({ |
| 65 | type: z.literal('http_request'), |
| 66 | method: z.enum(['GET', 'POST']), |
| 67 | endpoint: httpEndpointUrlSchema({ |
| 68 | requiredMessage: 'Please provide a URL', |
| 69 | invalidMessage: 'Please provide a valid URL', |
| 70 | prefixMessage: 'Please prefix your URL with http:// or https://', |
| 71 | }), |
| 72 | timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), |
| 73 | httpHeaders: httpHeadersSchema, |
| 74 | httpBody: z |
| 75 | .string() |
| 76 | .trim() |
| 77 | .optional() |
| 78 | .refine((value) => { |
| 79 | if (!value) return true |
| 80 | try { |
| 81 | JSON.parse(value) |
| 82 | return true |
| 83 | } catch { |
| 84 | return false |
| 85 | } |
| 86 | }, 'Input must be valid JSON'), |
| 87 | // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it |
| 88 | snippet: z.string().trim(), |
| 89 | }) |
| 90 | |
| 91 | const sqlFunctionSchema = z.object({ |
| 92 | type: z.literal('sql_function'), |
| 93 | schema: z.string().trim().min(1, 'Please select one of the listed database schemas'), |
| 94 | functionName: z.string().trim().min(1, 'Please select one of the listed database functions'), |
| 95 | // When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it |
| 96 | snippet: z.string().trim(), |
| 97 | }) |
| 98 | |
| 99 | const sqlSnippetSchema = z.object({ |
| 100 | type: z.literal('sql_snippet'), |
| 101 | snippet: z.string().trim().min(1), |
| 102 | }) |
| 103 | |
| 104 | export const FormSchema = z |
| 105 | .object({ |
| 106 | name: z.string().trim().min(1, 'Please provide a name for your cron job'), |
| 107 | supportsSeconds: z.boolean(), |
| 108 | schedule: z |
| 109 | .string() |
| 110 | .trim() |
| 111 | .min(1) |
| 112 | .refine((value) => { |
| 113 | if (cronPattern.test(value)) { |
| 114 | try { |
| 115 | convertCronToString(value) |
| 116 | return true |
| 117 | } catch { |
| 118 | return false |
| 119 | } |
| 120 | } else if (secondsPattern.test(value)) { |
| 121 | return true |
| 122 | } |
| 123 | return false |
| 124 | }, 'Invalid Cron format'), |
| 125 | values: z.discriminatedUnion('type', [ |
| 126 | edgeFunctionSchema, |
| 127 | httpRequestSchema, |
| 128 | sqlFunctionSchema, |
| 129 | sqlSnippetSchema, |
| 130 | ]), |
| 131 | }) |
| 132 | .superRefine((data, ctx) => { |
| 133 | if (!cronPattern.test(data.schedule)) { |
| 134 | if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) { |
| 135 | ctx.addIssue({ |
| 136 | code: z.ZodIssueCode.custom, |
| 137 | message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.', |
| 138 | path: ['schedule'], |
| 139 | }) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | if (data.values.type === 'edge_function' || data.values.type === 'http_request') { |
| 144 | addHttpHeaderIssues(data.values.httpHeaders, ctx, ['values', 'httpHeaders']) |
| 145 | } |
| 146 | }) |
| 147 | |
| 148 | export type CreateCronJobForm = z.infer<typeof FormSchema> |
| 149 | export type CronJobType = CreateCronJobForm['values'] |