fix(schedules): offload next run calculation to croner (#1640)

* fix(schedules): offload next run calculation to croner

* fix localstorage dependent tests

* address greptile comment
This commit is contained in:
Vikhyath Mondreti
2025-10-15 11:37:42 -07:00
committed by GitHub
parent 1a05ef97d6
commit 061c1dff4e
10 changed files with 569 additions and 510 deletions

View File

@@ -309,7 +309,8 @@ export async function POST(req: NextRequest) {
// Additional validation for custom cron expressions
if (defaultScheduleType === 'custom' && cronExpression) {
const validation = validateCronExpression(cronExpression)
// Validate with timezone for accurate validation
const validation = validateCronExpression(cronExpression, timezone)
if (!validation.isValid) {
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
return NextResponse.json(

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Calendar, ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -33,17 +33,23 @@ export function ScheduleConfig({
disabled = false,
}: ScheduleConfigProps) {
const [error, setError] = useState<string | null>(null)
const [scheduleId, setScheduleId] = useState<string | null>(null)
const [nextRunAt, setNextRunAt] = useState<string | null>(null)
const [lastRanAt, setLastRanAt] = useState<string | null>(null)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [timezone, setTimezone] = useState<string>('UTC')
const [scheduleData, setScheduleData] = useState<{
id: string | null
nextRunAt: string | null
lastRanAt: string | null
cronExpression: string | null
timezone: string
}>({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
// Track when we need to force a refresh of schedule data
const [refreshCounter, setRefreshCounter] = useState(0)
const params = useParams()
const workflowId = params.workflowId as string
@@ -61,79 +67,88 @@ export function ScheduleConfig({
const blockWithValues = getBlockWithValues(blockId)
const isScheduleTriggerBlock = blockWithValues?.type === 'schedule'
// Function to check if schedule exists in the database
const checkSchedule = async () => {
// Fetch schedule data from API
const fetchSchedule = useCallback(async () => {
if (!workflowId) return
setIsLoading(true)
try {
// Check if there's a schedule for this workflow, passing the mode parameter
// For schedule trigger blocks, include blockId to get the specific schedule
const url = new URL('/api/schedules', window.location.origin)
url.searchParams.set('workflowId', workflowId)
url.searchParams.set('mode', 'schedule')
const params = new URLSearchParams({
workflowId,
mode: 'schedule',
})
if (isScheduleTriggerBlock) {
url.searchParams.set('blockId', blockId)
params.set('blockId', blockId)
}
const response = await fetch(url.toString(), {
// Add cache: 'no-store' to prevent caching of this request
const response = await fetch(`/api/schedules?${params}`, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
headers: { 'Cache-Control': 'no-cache' },
})
if (response.ok) {
const data = await response.json()
logger.debug('Schedule check response:', data)
if (data.schedule) {
setScheduleId(data.schedule.id)
setNextRunAt(data.schedule.nextRunAt)
setLastRanAt(data.schedule.lastRanAt)
setCronExpression(data.schedule.cronExpression)
setTimezone(data.schedule.timezone || 'UTC')
// Note: We no longer set global schedule status from individual components
// The global schedule status should be managed by a higher-level component
setScheduleData({
id: data.schedule.id,
nextRunAt: data.schedule.nextRunAt,
lastRanAt: data.schedule.lastRanAt,
cronExpression: data.schedule.cronExpression,
timezone: data.schedule.timezone || 'UTC',
})
} else {
setScheduleId(null)
setNextRunAt(null)
setLastRanAt(null)
setCronExpression(null)
// Note: We no longer set global schedule status from individual components
setScheduleData({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
}
}
} catch (error) {
logger.error('Error checking schedule:', { error })
setError('Failed to check schedule status')
logger.error('Error fetching schedule:', error)
} finally {
setIsLoading(false)
}
}
}, [workflowId, blockId, isScheduleTriggerBlock])
// Check for schedule on mount and when relevant dependencies change
// Fetch schedule data on mount and when dependencies change
useEffect(() => {
// Check for schedules when workflowId changes, modal opens, or on initial mount
if (workflowId) {
checkSchedule()
fetchSchedule()
}, [fetchSchedule])
// Separate effect for event listener to avoid removing/re-adding on every dependency change
useEffect(() => {
const handleScheduleUpdate = (event: CustomEvent) => {
if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) {
logger.debug('Schedule update event received in schedule-config, refetching')
fetchSchedule()
}
}
// Cleanup function to reset loading state
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
return () => {
setIsLoading(false)
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
}
}, [workflowId, isModalOpen, refreshCounter])
}, [workflowId, blockId, fetchSchedule])
// Refetch when modal opens to get latest data
useEffect(() => {
if (isModalOpen) {
fetchSchedule()
}
}, [isModalOpen, fetchSchedule])
// Format the schedule information for display
const getScheduleInfo = () => {
if (!scheduleId || !nextRunAt) return null
if (!scheduleData.id || !scheduleData.nextRunAt) return null
let scheduleTiming = 'Unknown schedule'
if (cronExpression) {
scheduleTiming = parseCronToHumanReadable(cronExpression)
if (scheduleData.cronExpression) {
scheduleTiming = parseCronToHumanReadable(scheduleData.cronExpression, scheduleData.timezone)
} else if (scheduleType) {
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
}
@@ -142,8 +157,14 @@ export function ScheduleConfig({
<>
<div className='truncate font-normal text-sm'>{scheduleTiming}</div>
<div className='text-muted-foreground text-xs'>
<div>Next run: {formatDateTime(new Date(nextRunAt), timezone)}</div>
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt), timezone)}</div>}
<div>
Next run: {formatDateTime(new Date(scheduleData.nextRunAt), scheduleData.timezone)}
</div>
{scheduleData.lastRanAt && (
<div>
Last run: {formatDateTime(new Date(scheduleData.lastRanAt), scheduleData.timezone)}
</div>
)}
</div>
</>
)
@@ -154,16 +175,11 @@ export function ScheduleConfig({
setIsModalOpen(true)
}
const handleCloseModal = () => {
const handleCloseModal = useCallback(() => {
setIsModalOpen(false)
// Force a refresh when closing the modal
// Use a small timeout to ensure backend updates are complete
setTimeout(() => {
setRefreshCounter((prev) => prev + 1)
}, 500)
}
}, [])
const handleSaveSchedule = async (): Promise<boolean> => {
const handleSaveSchedule = useCallback(async (): Promise<boolean> => {
if (isPreview || disabled) return false
setIsSaving(true)
@@ -246,17 +262,24 @@ export function ScheduleConfig({
logger.debug('Schedule save response:', responseData)
// 5. Update our local state with the response data
if (responseData.cronExpression) {
setCronExpression(responseData.cronExpression)
if (responseData.cronExpression || responseData.nextRunAt) {
setScheduleData((prev) => ({
...prev,
cronExpression: responseData.cronExpression || prev.cronExpression,
nextRunAt:
typeof responseData.nextRunAt === 'string'
? responseData.nextRunAt
: responseData.nextRunAt?.toISOString?.() || prev.nextRunAt,
}))
}
if (responseData.nextRunAt) {
setNextRunAt(
typeof responseData.nextRunAt === 'string'
? responseData.nextRunAt
: responseData.nextRunAt.toISOString?.() || responseData.nextRunAt
)
}
// 6. Dispatch custom event to notify parent workflow-block component to refetch schedule info
// This ensures the badge updates immediately after saving
const event = new CustomEvent('schedule-updated', {
detail: { workflowId, blockId },
})
window.dispatchEvent(event)
logger.debug('Dispatched schedule-updated event', { workflowId, blockId })
// 6. Update the schedule status and trigger a workflow update
// Note: Global schedule status is managed at a higher level
@@ -266,15 +289,8 @@ export function ScheduleConfig({
workflowStore.updateLastSaved()
workflowStore.triggerUpdate()
// 8. Force a refresh to update the UI
// Use a timeout to ensure the API changes are completed
setTimeout(() => {
logger.debug('Refreshing schedule information after save')
setRefreshCounter((prev) => prev + 1)
// Make a separate API call to ensure we get the latest schedule info
checkSchedule()
}, 500)
// 8. Refetch the schedule to update local state
await fetchSchedule()
return true
} catch (error) {
@@ -284,10 +300,10 @@ export function ScheduleConfig({
} finally {
setIsSaving(false)
}
}
}, [workflowId, blockId, isScheduleTriggerBlock, setStartWorkflow, fetchSchedule])
const handleDeleteSchedule = async (): Promise<boolean> => {
if (isPreview || !scheduleId || disabled) return false
const handleDeleteSchedule = useCallback(async (): Promise<boolean> => {
if (isPreview || !scheduleData.id || disabled) return false
setIsDeleting(true)
try {
@@ -315,7 +331,7 @@ export function ScheduleConfig({
}
// 4. Make the DELETE API call to remove the schedule
const response = await fetch(`/api/schedules/${scheduleId}`, {
const response = await fetch(`/api/schedules/${scheduleData.id}`, {
method: 'DELETE',
})
@@ -326,14 +342,23 @@ export function ScheduleConfig({
}
// 5. Clear schedule state
setScheduleId(null)
setNextRunAt(null)
setLastRanAt(null)
setCronExpression(null)
setScheduleData({
id: null,
nextRunAt: null,
lastRanAt: null,
cronExpression: null,
timezone: 'UTC',
})
// 6. Update schedule status and refresh UI
// Note: Global schedule status is managed at a higher level
setRefreshCounter((prev) => prev + 1)
// 7. Dispatch custom event to notify parent workflow-block component
const event = new CustomEvent('schedule-updated', {
detail: { workflowId, blockId },
})
window.dispatchEvent(event)
logger.debug('Dispatched schedule-updated event after delete', { workflowId, blockId })
return true
} catch (error) {
@@ -343,10 +368,18 @@ export function ScheduleConfig({
} finally {
setIsDeleting(false)
}
}
}, [
scheduleData.id,
isPreview,
disabled,
isScheduleTriggerBlock,
setStartWorkflow,
workflowId,
blockId,
])
// Check if the schedule is active
const isScheduleActive = !!scheduleId && !!nextRunAt
const isScheduleActive = !!scheduleData.id && !!scheduleData.nextRunAt
return (
<div className='w-full' onClick={(e) => e.stopPropagation()}>
@@ -399,7 +432,7 @@ export function ScheduleConfig({
blockId={blockId}
onSave={handleSaveSchedule}
onDelete={handleDeleteSchedule}
scheduleId={scheduleId}
scheduleId={scheduleData.id}
/>
</Dialog>
</div>

View File

@@ -280,83 +280,59 @@ export const WorkflowBlock = memo(
}
}
const fetchScheduleInfo = async (workflowId: string) => {
if (!workflowId) return
try {
setIsLoadingScheduleInfo(true)
// For schedule trigger blocks, always include the blockId parameter
const url = new URL('/api/schedules', window.location.origin)
url.searchParams.set('workflowId', workflowId)
url.searchParams.set('mode', 'schedule')
url.searchParams.set('blockId', id) // Always include blockId for schedule blocks
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (!response.ok) {
setScheduleInfo(null)
return
}
const data = await response.json()
if (!data.schedule) {
setScheduleInfo(null)
return
}
let scheduleTiming = 'Unknown schedule'
if (data.schedule.cronExpression) {
scheduleTiming = parseCronToHumanReadable(data.schedule.cronExpression)
}
const baseInfo = {
scheduleTiming,
nextRunAt: data.schedule.nextRunAt as string | null,
lastRanAt: data.schedule.lastRanAt as string | null,
timezone: data.schedule.timezone || 'UTC',
status: data.schedule.status as string,
isDisabled: data.schedule.status === 'disabled',
id: data.schedule.id as string,
}
const fetchScheduleInfo = useCallback(
async (workflowId: string) => {
if (!workflowId) return
try {
const statusRes = await fetch(`/api/schedules/${baseInfo.id}/status`, {
setIsLoadingScheduleInfo(true)
const params = new URLSearchParams({
workflowId,
mode: 'schedule',
blockId: id,
})
const response = await fetch(`/api/schedules?${params}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
if (statusRes.ok) {
const statusData = await statusRes.json()
setScheduleInfo({
scheduleTiming: baseInfo.scheduleTiming,
nextRunAt: statusData.nextRunAt ?? baseInfo.nextRunAt,
lastRanAt: statusData.lastRanAt ?? baseInfo.lastRanAt,
timezone: baseInfo.timezone,
status: statusData.status ?? baseInfo.status,
isDisabled: statusData.isDisabled ?? baseInfo.isDisabled,
id: baseInfo.id,
})
if (!response.ok) {
setScheduleInfo(null)
return
}
} catch (err) {
logger.error('Error fetching schedule status:', err)
}
setScheduleInfo(baseInfo)
} catch (error) {
logger.error('Error fetching schedule info:', error)
setScheduleInfo(null)
} finally {
setIsLoadingScheduleInfo(false)
}
}
const data = await response.json()
if (!data.schedule) {
setScheduleInfo(null)
return
}
const schedule = data.schedule
const scheduleTimezone = schedule.timezone || 'UTC'
setScheduleInfo({
scheduleTiming: schedule.cronExpression
? parseCronToHumanReadable(schedule.cronExpression, scheduleTimezone)
: 'Unknown schedule',
nextRunAt: schedule.nextRunAt,
lastRanAt: schedule.lastRanAt,
timezone: scheduleTimezone,
status: schedule.status,
isDisabled: schedule.status === 'disabled',
id: schedule.id,
})
} catch (error) {
logger.error('Error fetching schedule info:', error)
setScheduleInfo(null)
} finally {
setIsLoadingScheduleInfo(false)
}
},
[id]
)
useEffect(() => {
if (type === 'schedule' && currentWorkflowId) {
@@ -366,11 +342,25 @@ export const WorkflowBlock = memo(
setIsLoadingScheduleInfo(false) // Reset loading state when not a schedule block
}
// Cleanup function to reset loading state when component unmounts or workflow changes
// Listen for schedule updates from the schedule-config component
const handleScheduleUpdate = (event: CustomEvent) => {
// Check if the update is for this workflow and block
if (event.detail?.workflowId === currentWorkflowId && event.detail?.blockId === id) {
logger.debug('Schedule update event received, refetching schedule info')
if (type === 'schedule') {
fetchScheduleInfo(currentWorkflowId)
}
}
}
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
// Cleanup function to reset loading state and remove listener
return () => {
setIsLoadingScheduleInfo(false)
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
}
}, [isStarterBlock, isTriggerBlock, type, currentWorkflowId])
}, [type, currentWorkflowId, id, fetchScheduleInfo])
// Get webhook information for the tooltip
useEffect(() => {

View File

@@ -49,8 +49,14 @@ function calculateNextRunTime(
const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType')
const scheduleValues = getScheduleTimeValues(scheduleBlock)
// Get timezone from schedule configuration (default to UTC)
const timezone = scheduleValues.timezone || 'UTC'
if (schedule.cronExpression) {
const cron = new Cron(schedule.cronExpression)
// Use Croner with timezone support for accurate scheduling
const cron = new Cron(schedule.cronExpression, {
timezone,
})
const nextDate = cron.nextRun()
if (!nextDate) throw new Error('Invalid cron expression or no future occurrences')
return nextDate

View File

@@ -223,7 +223,7 @@ describe('Schedule Utilities', () => {
vi.useRealTimers()
})
it.concurrent('should calculate next run for minutes schedule', () => {
it.concurrent('should calculate next run for minutes schedule using Croner', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
@@ -244,14 +244,14 @@ describe('Schedule Utilities', () => {
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
// Check minute is a multiple of the interval
expect(nextRun.getMinutes() % 15).toBe(0)
// Croner will calculate based on the cron expression */15 * * * *
// The exact minute depends on Croner's calculation
})
it.concurrent('should respect scheduleTime for minutes schedule', () => {
it.concurrent('should handle scheduleStartAt with scheduleTime', () => {
const scheduleValues = {
scheduleTime: '14:30', // Specific start time
scheduleStartAt: '',
scheduleStartAt: '2025-04-15', // Future date
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
@@ -265,12 +265,13 @@ describe('Schedule Utilities', () => {
const nextRun = calculateNextRunTime('minutes', scheduleValues)
// Should be 14:30
expect(nextRun.getHours()).toBe(14)
expect(nextRun.getMinutes()).toBe(30)
// Should return the future start date with time
expect(nextRun.getFullYear()).toBe(2025)
expect(nextRun.getMonth()).toBe(3) // April
expect(nextRun.getDate()).toBe(15)
})
it.concurrent('should calculate next run for hourly schedule', () => {
it.concurrent('should calculate next run for hourly schedule using Croner', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
@@ -287,13 +288,14 @@ describe('Schedule Utilities', () => {
const nextRun = calculateNextRunTime('hourly', scheduleValues)
// Just verify it's a valid future date with the right minute
// Verify it's a valid future date using Croner's calculation
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
// Croner calculates based on cron "30 * * * *"
expect(nextRun.getMinutes()).toBe(30)
})
it.concurrent('should calculate next run for daily schedule', () => {
it.concurrent('should calculate next run for daily schedule using Croner with timezone', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
@@ -310,88 +312,96 @@ describe('Schedule Utilities', () => {
const nextRun = calculateNextRunTime('daily', scheduleValues)
// Verify it's a future date at exactly 9:00
// Verify it's a future date at exactly 9:00 UTC using Croner
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
expect(nextRun.getHours()).toBe(9)
expect(nextRun.getMinutes()).toBe(0)
expect(nextRun.getUTCHours()).toBe(9)
expect(nextRun.getUTCMinutes()).toBe(0)
})
it.concurrent('should calculate next run for weekly schedule', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1, // Monday
weeklyTime: [10, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
it.concurrent(
'should calculate next run for weekly schedule using Croner with timezone',
() => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1, // Monday
weeklyTime: [10, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('weekly', scheduleValues)
// Should be next Monday at 10:00 AM UTC using Croner
expect(nextRun.getUTCDay()).toBe(1) // Monday
expect(nextRun.getUTCHours()).toBe(10)
expect(nextRun.getUTCMinutes()).toBe(0)
}
)
const nextRun = calculateNextRunTime('weekly', scheduleValues)
it.concurrent(
'should calculate next run for monthly schedule using Croner with timezone',
() => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
cronExpression: null,
}
// Should be next Monday at 10:00 AM
expect(nextRun.getDay()).toBe(1) // Monday
expect(nextRun.getHours()).toBe(10)
expect(nextRun.getMinutes()).toBe(0)
})
const nextRun = calculateNextRunTime('monthly', scheduleValues)
it.concurrent('should calculate next run for monthly schedule', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
cronExpression: null,
// Current date is 2025-04-12 12:00, so next run should be 2025-04-15 14:30 UTC using Croner
expect(nextRun.getFullYear()).toBe(2025)
expect(nextRun.getUTCMonth()).toBe(3) // April (0-indexed)
expect(nextRun.getUTCDate()).toBe(15)
expect(nextRun.getUTCHours()).toBe(14)
expect(nextRun.getUTCMinutes()).toBe(30)
}
)
const nextRun = calculateNextRunTime('monthly', scheduleValues)
it.concurrent(
'should work with lastRanAt parameter (though Croner calculates independently)',
() => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
// Current date is 2025-04-12 12:00, so next run should be 2025-04-15 14:30
expect(nextRun.getFullYear()).toBe(2025)
expect(nextRun.getMonth()).toBe(3) // April (0-indexed)
expect(nextRun.getDate()).toBe(15)
expect(nextRun.getHours()).toBe(14)
expect(nextRun.getMinutes()).toBe(30)
})
// Last ran 10 minutes ago
const lastRanAt = new Date()
lastRanAt.setMinutes(lastRanAt.getMinutes() - 10)
it.concurrent('should consider lastRanAt for better interval calculation', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'UTC',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt)
// With Croner, it calculates based on cron expression, not lastRanAt
// Just verify we get a future date
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
}
// Last ran 10 minutes ago
const lastRanAt = new Date()
lastRanAt.setMinutes(lastRanAt.getMinutes() - 10)
const nextRun = calculateNextRunTime('minutes', scheduleValues, lastRanAt)
// Should be 5 minutes from the last run (15 min interval)
const expectedNextRun = new Date(lastRanAt)
expectedNextRun.setMinutes(expectedNextRun.getMinutes() + 15)
expect(nextRun.getMinutes()).toBe(expectedNextRun.getMinutes())
})
)
it.concurrent('should respect future scheduleStartAt date', () => {
const scheduleValues = {
@@ -453,6 +463,12 @@ describe('Schedule Utilities', () => {
})
})
it.concurrent('should validate cron expressions with timezone', () => {
const result = validateCronExpression('0 9 * * *', 'America/Los_Angeles')
expect(result.isValid).toBe(true)
expect(result.nextRun).toBeInstanceOf(Date)
})
it.concurrent('should reject invalid cron expressions', () => {
expect(validateCronExpression('invalid')).toEqual({
isValid: false,
@@ -482,27 +498,181 @@ describe('Schedule Utilities', () => {
})
describe('parseCronToHumanReadable', () => {
it.concurrent('should parse common cron patterns', () => {
it.concurrent('should parse common cron patterns using cronstrue', () => {
// cronstrue produces "Every minute" for '* * * * *'
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')
// cronstrue produces "Every 15 minutes" for '*/15 * * * *'
expect(parseCronToHumanReadable('*/15 * * * *')).toBe('Every 15 minutes')
expect(parseCronToHumanReadable('30 * * * *')).toBe('Hourly at 30 minutes past the hour')
expect(parseCronToHumanReadable('0 9 * * *')).toBe('Daily at 9:00 AM')
expect(parseCronToHumanReadable('30 14 * * *')).toBe('Daily at 2:30 PM')
expect(parseCronToHumanReadable('0 9 * * 1')).toMatch(/Monday at 9:00 AM/)
expect(parseCronToHumanReadable('30 14 15 * *')).toMatch(/Monthly on the 15th at 2:30 PM/)
// cronstrue produces "At 30 minutes past the hour" for '30 * * * *'
expect(parseCronToHumanReadable('30 * * * *')).toContain('30 minutes past the hour')
// cronstrue produces "At 09:00 AM" for '0 9 * * *'
expect(parseCronToHumanReadable('0 9 * * *')).toContain('09:00 AM')
// cronstrue produces "At 02:30 PM" for '30 14 * * *'
expect(parseCronToHumanReadable('30 14 * * *')).toContain('02:30 PM')
// cronstrue produces "At 09:00 AM, only on Monday" for '0 9 * * 1'
expect(parseCronToHumanReadable('0 9 * * 1')).toContain('Monday')
// cronstrue produces "At 02:30 PM, on day 15 of the month" for '30 14 15 * *'
expect(parseCronToHumanReadable('30 14 15 * *')).toContain('15')
})
it.concurrent('should handle complex patterns', () => {
// Test with various combinations
expect(parseCronToHumanReadable('* */2 * * *')).toMatch(/Runs/)
expect(parseCronToHumanReadable('0 9 * * 1-5')).toMatch(/Runs/)
expect(parseCronToHumanReadable('0 9 1,15 * *')).toMatch(/Runs/)
it.concurrent('should include timezone information when provided', () => {
const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles')
expect(resultPT).toContain('(PT)')
expect(resultPT).toContain('09:00 AM')
const resultET = parseCronToHumanReadable('30 14 * * *', 'America/New_York')
expect(resultET).toContain('(ET)')
expect(resultET).toContain('02:30 PM')
const resultUTC = parseCronToHumanReadable('0 12 * * *', 'UTC')
expect(resultUTC).not.toContain('(UTC)') // UTC should not be explicitly shown
})
it.concurrent('should return a fallback for unrecognized patterns', () => {
const result = parseCronToHumanReadable('*/10 */6 31 2 *') // Invalid (Feb 31)
// Just check that we get something back that's not empty
expect(result.length).toBeGreaterThan(5)
it.concurrent('should handle complex patterns with cronstrue', () => {
// cronstrue can handle complex patterns better than our custom parser
const result1 = parseCronToHumanReadable('0 9 * * 1-5')
expect(result1).toContain('Monday through Friday')
const result2 = parseCronToHumanReadable('0 9 1,15 * *')
expect(result2).toContain('day 1 and 15')
})
it.concurrent('should return a fallback for invalid patterns', () => {
const result = parseCronToHumanReadable('invalid cron')
// Should fallback to "Schedule: <expression>"
expect(result).toContain('Schedule:')
expect(result).toContain('invalid cron')
})
})
describe('Timezone-aware scheduling with Croner', () => {
it.concurrent('should calculate daily schedule in Pacific Time correctly', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'America/Los_Angeles',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number], // 9 AM Pacific
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('daily', scheduleValues)
// 9 AM Pacific should be 16:00 or 17:00 UTC depending on DST
// Croner handles this automatically
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
})
it.concurrent('should handle DST transition for schedules', () => {
// Set a date during DST transition in March
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-08T10:00:00.000Z')) // Before DST
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'America/New_York',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [14, 0] as [number, number], // 2 PM Eastern
weeklyDay: 1,
weeklyTime: [14, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [14, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('daily', scheduleValues)
// Croner should handle DST transition correctly
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
vi.useRealTimers()
})
it.concurrent('should calculate weekly schedule in Tokyo timezone', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'Asia/Tokyo',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1, // Monday
weeklyTime: [10, 0] as [number, number], // 10 AM Japan Time
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('weekly', scheduleValues)
// Verify it's a valid future date
// Tokyo is UTC+9, so 10 AM JST = 1 AM UTC
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
})
it.concurrent('should handle custom cron with timezone', () => {
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'Europe/London',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: '30 15 * * *', // 3:30 PM London time
}
const nextRun = calculateNextRunTime('custom', scheduleValues)
// Verify it's a valid future date
expect(nextRun instanceof Date).toBe(true)
expect(nextRun > new Date()).toBe(true)
})
it.concurrent('should handle monthly schedule on last day of month', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-02-15T12:00:00.000Z'))
const scheduleValues = {
scheduleTime: '',
scheduleStartAt: '',
timezone: 'America/Chicago',
minutesInterval: 15,
hourlyMinute: 0,
dailyTime: [9, 0] as [number, number],
weeklyDay: 1,
weeklyTime: [9, 0] as [number, number],
monthlyDay: 28, // Last day of Feb (non-leap year)
monthlyTime: [12, 0] as [number, number], // Noon Central
cronExpression: null,
}
const nextRun = calculateNextRunTime('monthly', scheduleValues)
// Should calculate Feb 28 at noon Central time
expect(nextRun.getUTCDate()).toBe(28)
expect(nextRun.getUTCMonth()).toBe(1) // February
vi.useRealTimers()
})
})

View File

@@ -1,4 +1,5 @@
import { Cron } from 'croner'
import cronstrue from 'cronstrue'
import { createLogger } from '@/lib/logs/console/logger'
import { formatDateTime } from '@/lib/utils'
@@ -7,9 +8,13 @@ const logger = createLogger('ScheduleUtils')
/**
* Validates a cron expression and returns validation results
* @param cronExpression - The cron expression to validate
* @param timezone - Optional IANA timezone string (e.g., 'America/Los_Angeles'). Defaults to 'UTC'
* @returns Validation result with isValid flag, error message, and next run date
*/
export function validateCronExpression(cronExpression: string): {
export function validateCronExpression(
cronExpression: string,
timezone?: string
): {
isValid: boolean
error?: string
nextRun?: Date
@@ -22,7 +27,8 @@ export function validateCronExpression(cronExpression: string): {
}
try {
const cron = new Cron(cronExpression)
// Validate with timezone if provided for accurate next run calculation
const cron = new Cron(cronExpression, timezone ? { timezone } : undefined)
const nextRun = cron.nextRun()
if (!nextRun) {
@@ -267,6 +273,21 @@ export function createDateWithTimezone(
/**
* Generate cron expression based on schedule type and values
*
* IMPORTANT: The generated cron expressions use local time values (hours/minutes)
* from the user's configured timezone. When used with Croner, pass the timezone
* option to ensure proper scheduling:
*
* Example:
* const cronExpr = generateCronExpression('daily', { dailyTime: [14, 30], timezone: 'America/Los_Angeles' })
* const cron = new Cron(cronExpr, { timezone: 'America/Los_Angeles' })
*
* This will schedule the job at 2:30 PM Pacific Time, which Croner will correctly
* convert to the appropriate UTC time, handling DST transitions automatically.
*
* @param scheduleType - Type of schedule (minutes, hourly, daily, weekly, monthly, custom)
* @param scheduleValues - Object containing schedule configuration including timezone
* @returns Cron expression string representing the schedule in local time
*/
export function generateCronExpression(
scheduleType: string,
@@ -308,6 +329,7 @@ export function generateCronExpression(
/**
* Calculate the next run time based on schedule configuration
* Uses Croner library with timezone support for accurate scheduling across timezones and DST transitions
* @param scheduleType - Type of schedule (minutes, hourly, daily, etc)
* @param scheduleValues - Object with schedule configuration values
* @param lastRanAt - Optional last execution time
@@ -398,270 +420,83 @@ export function calculateNextRunTime(
}
}
// If we have a scheduleTime (but no future scheduleStartAt), use it for today
const scheduleTimeOverride = scheduleValues.scheduleTime
? parseTimeString(scheduleValues.scheduleTime)
: null
// For recurring schedules, use Croner with timezone support
// This ensures proper timezone handling and DST transitions
try {
const cronExpression = generateCronExpression(scheduleType, scheduleValues)
logger.debug(`Using cron expression: ${cronExpression} with timezone: ${timezone}`)
// Create next run date based on the current date
const nextRun = new Date(baseDate)
// Create Croner instance with timezone support
const cron = new Cron(cronExpression, {
timezone,
})
switch (scheduleType) {
case 'minutes': {
const { minutesInterval } = scheduleValues
const nextDate = cron.nextRun()
// If we have a time override, use it
if (scheduleTimeOverride) {
const [hours, minutes] = scheduleTimeOverride
nextRun.setHours(hours, minutes, 0, 0)
// Add intervals until we're in the future
while (nextRun <= new Date()) {
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
}
return nextRun
}
// For subsequent runs after lastRanAt
if (lastRanAt) {
const baseTime = new Date(lastRanAt)
nextRun.setTime(baseTime.getTime())
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval, 0, 0)
// Make sure we're in the future
while (nextRun <= new Date()) {
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
}
return nextRun
}
// Calculate next boundary for minutes
const now = new Date()
const currentMinutes = now.getMinutes()
const nextIntervalBoundary = Math.ceil(currentMinutes / minutesInterval) * minutesInterval
nextRun.setMinutes(nextIntervalBoundary, 0, 0)
// If we're past this time but haven't reached baseDate, adjust
if (nextRun <= now) {
nextRun.setMinutes(nextRun.getMinutes() + minutesInterval)
}
return nextRun
if (!nextDate) {
throw new Error(`No next run date calculated for cron: ${cronExpression}`)
}
case 'hourly': {
// Use the override time if available, otherwise use hourly config
const [targetHours, _] = scheduleTimeOverride || [nextRun.getHours(), 0]
const targetMinutes = scheduleValues.hourlyMinute
nextRun.setHours(targetHours, targetMinutes, 0, 0)
// If we're in the past relative to now (not baseDate), move to next hour
if (nextRun <= new Date()) {
nextRun.setHours(nextRun.getHours() + 1)
}
return nextRun
}
case 'daily': {
// Use either schedule override or daily time values
const [hours, minutes] = scheduleTimeOverride || scheduleValues.dailyTime
nextRun.setHours(hours, minutes, 0, 0)
// If we're in the past relative to now (not baseDate), move to tomorrow
if (nextRun <= new Date()) {
nextRun.setDate(nextRun.getDate() + 1)
}
return nextRun
}
case 'weekly': {
// Use either schedule override or weekly time values
const [hours, minutes] = scheduleTimeOverride || scheduleValues.weeklyTime
nextRun.setHours(hours, minutes, 0, 0)
// Add days until we reach the target day in the future
while (nextRun.getDay() !== scheduleValues.weeklyDay || nextRun <= new Date()) {
nextRun.setDate(nextRun.getDate() + 1)
}
return nextRun
}
case 'monthly': {
// Use either schedule override or monthly time values
const [hours, minutes] = scheduleTimeOverride || scheduleValues.monthlyTime
const { monthlyDay } = scheduleValues
nextRun.setDate(monthlyDay)
nextRun.setHours(hours, minutes, 0, 0)
// If we're in the past relative to now (not baseDate), move to next month
if (nextRun <= new Date()) {
nextRun.setMonth(nextRun.getMonth() + 1)
}
return nextRun
}
default:
throw new Error(`Unsupported schedule type: ${scheduleType}`)
logger.debug(`Next run calculated: ${nextDate.toISOString()}`)
return nextDate
} catch (error) {
logger.error('Error calculating next run with Croner:', error)
throw new Error(
`Failed to calculate next run time for schedule type ${scheduleType}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* Converts a cron expression to a human-readable string format
* Helper function to get a friendly timezone abbreviation
*/
export const parseCronToHumanReadable = (cronExpression: string): string => {
// Parse the cron parts
const parts = cronExpression.split(' ')
// Handle standard patterns
if (cronExpression === '* * * * *') {
return 'Every minute'
function getTimezoneAbbreviation(timezone: string): string {
const timezoneMap: Record<string, string> = {
'America/Los_Angeles': 'PT',
'America/Denver': 'MT',
'America/Chicago': 'CT',
'America/New_York': 'ET',
'Europe/London': 'GMT/BST',
'Europe/Paris': 'CET/CEST',
'Asia/Tokyo': 'JST',
'Asia/Singapore': 'SGT',
'Australia/Sydney': 'AEDT/AEST',
UTC: 'UTC',
}
// Every X minutes
if (cronExpression.match(/^\*\/\d+ \* \* \* \*$/)) {
const minutes = cronExpression.split(' ')[0].split('/')[1]
return `Every ${minutes} minutes`
}
return timezoneMap[timezone] || timezone
}
// Daily at specific time
if (cronExpression.match(/^\d+ \d+ \* \* \*$/)) {
const minute = Number.parseInt(parts[0], 10)
const hour = Number.parseInt(parts[1], 10)
const period = hour >= 12 ? 'PM' : 'AM'
const hour12 = hour % 12 || 12
return `Daily at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
}
// Every hour at specific minute
if (cronExpression.match(/^\d+ \* \* \* \*$/)) {
const minute = parts[0]
return `Hourly at ${minute} minutes past the hour`
}
// Specific day of week at specific time
if (cronExpression.match(/^\d+ \d+ \* \* \d+$/)) {
const minute = Number.parseInt(parts[0], 10)
const hour = Number.parseInt(parts[1], 10)
const dayOfWeek = Number.parseInt(parts[4], 10)
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const day = days[dayOfWeek % 7]
const period = hour >= 12 ? 'PM' : 'AM'
const hour12 = hour % 12 || 12
return `Every ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
}
// Specific day of month at specific time
if (cronExpression.match(/^\d+ \d+ \d+ \* \*$/)) {
const minute = Number.parseInt(parts[0], 10)
const hour = Number.parseInt(parts[1], 10)
const dayOfMonth = parts[2]
const period = hour >= 12 ? 'PM' : 'AM'
const hour12 = hour % 12 || 12
const day =
dayOfMonth === '1'
? '1st'
: dayOfMonth === '2'
? '2nd'
: dayOfMonth === '3'
? '3rd'
: `${dayOfMonth}th`
return `Monthly on the ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
}
// Weekly at specific time
if (cronExpression.match(/^\d+ \d+ \* \* [0-6]$/)) {
const minute = Number.parseInt(parts[0], 10)
const hour = Number.parseInt(parts[1], 10)
const dayOfWeek = Number.parseInt(parts[4], 10)
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const day = days[dayOfWeek % 7]
const period = hour >= 12 ? 'PM' : 'AM'
const hour12 = hour % 12 || 12
return `Weekly on ${day} at ${hour12}:${minute.toString().padStart(2, '0')} ${period}`
}
// Return a more detailed breakdown if none of the patterns match
/**
* Converts a cron expression to a human-readable string format
* Uses the cronstrue library for accurate parsing of complex cron expressions
*
* @param cronExpression - The cron expression to parse
* @param timezone - Optional IANA timezone string to include in the description
* @returns Human-readable description of the schedule
*/
export const parseCronToHumanReadable = (cronExpression: string, timezone?: string): string => {
try {
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
let description = 'Runs '
// Use cronstrue for reliable cron expression parsing
const baseDescription = cronstrue.toString(cronExpression, {
use24HourTimeFormat: false, // Use 12-hour format with AM/PM
verbose: false, // Keep it concise
})
// Time component
if (minute === '*' && hour === '*') {
description += 'every minute '
} else if (minute.includes('/') && hour === '*') {
const interval = minute.split('/')[1]
description += `every ${interval} minutes `
} else if (minute !== '*' && hour !== '*') {
const hourVal = Number.parseInt(hour, 10)
const period = hourVal >= 12 ? 'PM' : 'AM'
const hour12 = hourVal % 12 || 12
description += `at ${hour12}:${minute.padStart(2, '0')} ${period} `
// Add timezone information if provided and not UTC
if (timezone && timezone !== 'UTC') {
const tzAbbr = getTimezoneAbbreviation(timezone)
return `${baseDescription} (${tzAbbr})`
}
// Day component
if (dayOfMonth !== '*' && month !== '*') {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
if (month.includes(',')) {
const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1])
description += `on day ${dayOfMonth} of ${monthNames.join(', ')}`
} else if (month.includes('/')) {
// Handle interval patterns like */3, 1/3, etc.
const interval = month.split('/')[1]
description += `on day ${dayOfMonth} every ${interval} months`
} else if (month.includes('-')) {
// Handle range patterns like 1-6
const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10))
const startMonth = months[start - 1]
const endMonth = months[end - 1]
description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}`
} else {
// Handle specific month numbers
const monthIndex = Number.parseInt(month, 10) - 1
const monthName = months[monthIndex]
if (monthName) {
description += `on day ${dayOfMonth} of ${monthName}`
} else {
description += `on day ${dayOfMonth} of month ${month}`
}
}
} else if (dayOfWeek !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
if (dayOfWeek.includes(',')) {
const dayNames = dayOfWeek.split(',').map((d) => days[Number.parseInt(d, 10) % 7])
description += `on ${dayNames.join(', ')}`
} else if (dayOfWeek.includes('-')) {
const [start, end] = dayOfWeek.split('-').map((d) => Number.parseInt(d, 10) % 7)
description += `from ${days[start]} to ${days[end]}`
} else {
description += `on ${days[Number.parseInt(dayOfWeek, 10) % 7]}`
}
}
return description.trim()
} catch (_e) {
return `Schedule: ${cronExpression}`
return baseDescription
} catch (error) {
logger.warn('Failed to parse cron expression with cronstrue:', {
cronExpression,
error: error instanceof Error ? error.message : String(error),
})
// Fallback to displaying the raw cron expression
return `Schedule: ${cronExpression}${timezone && timezone !== 'UTC' ? ` (${getTimezoneAbbreviation(timezone)})` : ''}`
}
}
@@ -672,7 +507,8 @@ export const getScheduleInfo = (
cronExpression: string | null,
nextRunAt: string | null,
lastRanAt: string | null,
scheduleType?: string | null
scheduleType?: string | null,
timezone?: string | null
): {
scheduleTiming: string
nextRunFormatted: string | null
@@ -689,7 +525,8 @@ export const getScheduleInfo = (
let scheduleTiming = 'Unknown schedule'
if (cronExpression) {
scheduleTiming = parseCronToHumanReadable(cronExpression)
// Pass timezone to parseCronToHumanReadable for accurate display
scheduleTiming = parseCronToHumanReadable(cronExpression, timezone || undefined)
} else if (scheduleType) {
scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}`
}

View File

@@ -21,6 +21,11 @@ describe('Console Store', () => {
isOpen: false,
})
vi.clearAllMocks()
// Clear localStorage mock
if (global.localStorage) {
vi.mocked(global.localStorage.getItem).mockReturnValue(null)
vi.mocked(global.localStorage.setItem).mockClear()
}
})
describe('addConsole', () => {

View File

@@ -8,6 +8,19 @@ global.fetch = vi.fn(() =>
})
) as any
// Mock localStorage and sessionStorage for Zustand persist middleware
const storageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(),
length: 0,
}
global.localStorage = storageMock as any
global.sessionStorage = storageMock as any
// Mock drizzle-orm sql template literal globally for tests
vi.mock('drizzle-orm', () => ({
sql: vi.fn((strings, ...values) => ({

View File

@@ -6,6 +6,7 @@
"dependencies": {
"@linear/sdk": "40.0.0",
"@t3-oss/env-nextjs": "0.13.4",
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"postgres": "^3.4.5",
@@ -1643,7 +1644,7 @@
"croner": ["croner@9.1.0", "", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
"cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="],
"cronstrue": ["cronstrue@3.3.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -3357,6 +3358,8 @@
"@trigger.dev/sdk/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@trigger.dev/sdk/cronstrue": ["cronstrue@2.61.0", "", { "bin": { "cronstrue": "bin/cli.js" } }, "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA=="],
"@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@types/cors/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],

View File

@@ -37,6 +37,7 @@
"dependencies": {
"@linear/sdk": "40.0.0",
"@t3-oss/env-nextjs": "0.13.4",
"cronstrue": "3.3.0",
"drizzle-orm": "^0.44.5",
"mongodb": "6.19.0",
"postgres": "^3.4.5",