CreateCronJobSheet.constants.ts 4.7 KB

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