CreateCronJobSheet.constants.ts149 lines · main
1import { toString as CronToString } from 'cronstrue'
2import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation'
3import z from 'zod'
4
5import { cronPattern, secondsPattern } from '../CronJobs.constants'
6import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
7
8const 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
19const httpHeadersSchema = z.array(z.object({ name: z.string().trim(), value: z.string().trim() }))
20
21const 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
41const 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
64const 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
91const 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
99const sqlSnippetSchema = z.object({
100 type: z.literal('sql_snippet'),
101 snippet: z.string().trim().min(1),
102})
103
104export 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
148export type CreateCronJobForm = z.infer<typeof FormSchema>
149export type CronJobType = CreateCronJobForm['values']