Files
Gildas Garcia d0abc7a64a fix: cron job has no default for timeout input (#48103)
The cron job form has no default value for the timeout input. However,
if left empty, the zod schema default it to 0 which fails validation.

By setting a default value on the input we ensure:
- a value is actually provided
- validation triggers if users clear the input

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Cron job forms now use a consistent 1-second timeout default across
function and HTTP request types.
* Changing the cron job “Type” clears any previously generated snippet
and resets the timeout back to the shared default.
* Form initialization and reset behavior were improved to prevent stale
timeout/snippet state when creating or editing cron jobs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 16:06:16 +02:00

152 lines
4.7 KiB
TypeScript

import { toString as CronToString } from 'cronstrue'
import { getKeyValueFieldArrayValidationIssues } from 'ui-patterns/form/KeyValueFieldArray/validation'
import z from 'zod'
import { cronPattern, secondsPattern } from '../CronJobs.constants'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
const convertCronToString = (schedule: string) => {
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
// original schedule when cronstrue throws.
// pg_cron uses '$' for "last day of month"; cronstrue uses 'L' — normalize before parsing.
try {
return CronToString(schedule.replace(/\$/g, 'L'))
} catch (error) {
return schedule
}
}
const httpHeadersSchema = z.array(z.object({ name: z.string().trim(), value: z.string().trim() }))
const addHttpHeaderIssues = (
rows: z.infer<typeof httpHeadersSchema>,
ctx: z.RefinementCtx,
pathPrefix: string[]
) => {
getKeyValueFieldArrayValidationIssues({
rows,
keyFieldName: 'name',
valueFieldName: 'value',
keyRequiredMessage: 'Header name is required',
valueRequiredMessage: 'Header value is required',
}).forEach((issue) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: [...pathPrefix, ...issue.path],
})
})
}
export const DEFAULT_TIMEOUT = 1000
const edgeFunctionSchema = z.object({
type: z.literal('edge_function'),
method: z.enum(['GET', 'POST']),
edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(DEFAULT_TIMEOUT),
httpHeaders: httpHeadersSchema,
httpBody: z
.string()
.trim()
.optional()
.refine((value) => {
if (!value) return true
try {
JSON.parse(value)
return true
} catch {
return false
}
}, 'Input must be valid JSON'),
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
snippet: z.string().trim(),
})
const httpRequestSchema = z.object({
type: z.literal('http_request'),
method: z.enum(['GET', 'POST']),
endpoint: httpEndpointUrlSchema({
requiredMessage: 'Please provide a URL',
invalidMessage: 'Please provide a valid URL',
prefixMessage: 'Please prefix your URL with http:// or https://',
}),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(DEFAULT_TIMEOUT),
httpHeaders: httpHeadersSchema,
httpBody: z
.string()
.trim()
.optional()
.refine((value) => {
if (!value) return true
try {
JSON.parse(value)
return true
} catch {
return false
}
}, 'Input must be valid JSON'),
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
snippet: z.string().trim(),
})
const sqlFunctionSchema = z.object({
type: z.literal('sql_function'),
schema: z.string().trim().min(1, 'Please select one of the listed database schemas'),
functionName: z.string().trim().min(1, 'Please select one of the listed database functions'),
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
snippet: z.string().trim(),
})
const sqlSnippetSchema = z.object({
type: z.literal('sql_snippet'),
snippet: z.string().trim().min(1),
})
export const FormSchema = z
.object({
name: z.string().trim(),
supportsSeconds: z.boolean(),
schedule: z
.string()
.trim()
.min(1)
.refine((value) => {
if (cronPattern.test(value)) {
try {
convertCronToString(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'],
})
}
}
if (data.values.type === 'edge_function' || data.values.type === 'http_request') {
addHttpHeaderIssues(data.values.httpHeaders, ctx, ['values', 'httpHeaders'])
}
})
export type CreateCronJobForm = z.infer<typeof FormSchema>
export type CronJobType = CreateCronJobForm['values']