diff --git a/packages/core/package.json b/packages/core/package.json index 7378e983d8..4df4c19002 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,6 @@ "devDependencies": { "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", - "@types/cron": "~1.7.1", "@types/express": "^4.17.21", "@types/lodash": "^4.14.195", "@types/mime-types": "^2.1.0", @@ -40,7 +39,7 @@ "aws4": "1.11.0", "axios": "1.6.7", "concat-stream": "2.0.0", - "cron": "1.7.2", + "cron": "3.1.7", "fast-glob": "3.2.12", "file-type": "16.5.4", "form-data": "4.0.0", diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index bc2b3a4895..bfc6319626 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -1,11 +1,9 @@ import { Service } from 'typedi'; -import { CronJob } from 'cron'; import type { IGetExecutePollFunctions, IGetExecuteTriggerFunctions, INode, - IPollResponse, ITriggerResponse, IWorkflowExecuteAdditionalData, TriggerTime, @@ -23,10 +21,13 @@ import { WorkflowDeactivationError, } from 'n8n-workflow'; +import { ScheduledTaskManager } from './ScheduledTaskManager'; import type { IWorkflowData } from './Interfaces'; @Service() export class ActiveWorkflows { + constructor(private readonly scheduledTaskManager: ScheduledTaskManager) {} + private activeWorkflows: { [workflowId: string]: IWorkflowData } = {}; /** @@ -102,20 +103,15 @@ export class ActiveWorkflows { if (pollingNodes.length === 0) return; - this.activeWorkflows[workflowId].pollResponses = []; - for (const pollNode of pollingNodes) { try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - this.activeWorkflows[workflowId].pollResponses!.push( - await this.activatePolling( - pollNode, - workflow, - additionalData, - getPollFunctions, - mode, - activation, - ), + await this.activatePolling( + pollNode, + workflow, + additionalData, + getPollFunctions, + mode, + activation, ); } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); @@ -138,7 +134,7 @@ export class ActiveWorkflows { getPollFunctions: IGetExecutePollFunctions, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, - ): Promise { + ): Promise { const pollFunctions = getPollFunctions(workflow, node, additionalData, mode, activation); const pollTimes = pollFunctions.getNodeParameter('pollTimes') as unknown as { @@ -161,7 +157,7 @@ export class ActiveWorkflows { pollFunctions.__emit(pollResponse); } } catch (error) { - // If the poll function failes in the first activation + // If the poll function fails in the first activation // throw the error back so we let the user know there is // an issue with the trigger. if (testingTrigger) { @@ -174,11 +170,6 @@ export class ActiveWorkflows { // Execute the trigger directly to be able to know if it works await executeTrigger(true); - const timezone = pollFunctions.getTimezone(); - - // Start the cron-jobs - const cronJobs: CronJob[] = []; - for (const cronTime of cronTimes) { const cronTimeParts = cronTime.split(' '); if (cronTimeParts.length > 0 && cronTimeParts[0].includes('*')) { @@ -187,19 +178,8 @@ export class ActiveWorkflows { ); } - cronJobs.push(new CronJob(cronTime, executeTrigger, undefined, true, timezone)); + this.scheduledTaskManager.registerCron(workflow, cronTime, executeTrigger); } - - // Stop the cron-jobs - async function closeFunction() { - for (const cronJob of cronJobs) { - cronJob.stop(); - } - } - - return { - closeFunction, - }; } /** @@ -211,14 +191,11 @@ export class ActiveWorkflows { return false; } + this.scheduledTaskManager.deregisterCrons(workflowId); + const w = this.activeWorkflows[workflowId]; - for (const r of w.triggerResponses ?? []) { - await this.close(r, workflowId, 'trigger'); - } - - for (const r of w.pollResponses ?? []) { - await this.close(r, workflowId, 'poller'); + await this.closeTrigger(r, workflowId); } delete this.activeWorkflows[workflowId]; @@ -232,11 +209,7 @@ export class ActiveWorkflows { } } - private async close( - response: ITriggerResponse | IPollResponse, - workflowId: string, - target: 'trigger' | 'poller', - ) { + private async closeTrigger(response: ITriggerResponse, workflowId: string) { if (!response.closeFunction) return; try { @@ -246,14 +219,14 @@ export class ActiveWorkflows { Logger.error( `There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`, ); - ErrorReporter.error(e, { extra: { target, workflowId } }); + ErrorReporter.error(e, { extra: { workflowId } }); return; } const error = e instanceof Error ? e : new Error(`${e}`); throw new WorkflowDeactivationError( - `Failed to deactivate ${target} of workflow ID "${workflowId}": "${error.message}"`, + `Failed to deactivate trigger of workflow ID "${workflowId}": "${error.message}"`, { cause: error, workflowId }, ); } diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 66162ae171..2963e46185 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -1,5 +1,4 @@ import type { - IPollResponse, ITriggerResponse, IWorkflowSettings as IWorkflowSettingsWorkflow, ValidationResult, @@ -18,7 +17,6 @@ export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { } export interface IWorkflowData { - pollResponses?: IPollResponse[]; triggerResponses?: ITriggerResponse[]; } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index d5e0653bb6..3e0b1dfb3b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -102,6 +102,7 @@ import type { INodeParameters, EnsureTypeOptions, SSHTunnelFunctions, + SchedulingFunctions, } from 'n8n-workflow'; import { ExpressionError, @@ -114,7 +115,6 @@ import { createDeferredPromise, deepCopy, fileTypeFromMimeType, - getGlobalState, isObjectEmpty, isResourceMapperValue, validateFieldType, @@ -157,6 +157,7 @@ import Container from 'typedi'; import type { BinaryData } from './BinaryData/types'; import merge from 'lodash/merge'; import { InstanceSettings } from './InstanceSettings'; +import { ScheduledTaskManager } from './ScheduledTaskManager'; import { SSHClientsManager } from './SSHClientsManager'; import { binaryToBuffer } from './BinaryData/utils'; @@ -2585,13 +2586,6 @@ export function getNodeWebhookUrl( return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath); } -/** - * Returns the timezone for the workflow - */ -export function getTimezone(workflow: Workflow): string { - return workflow.settings.timezone ?? getGlobalState().defaultTimezone; -} - /** * Returns the full webhook description of the webhook with the given name * @@ -2957,7 +2951,7 @@ const getCommonWorkflowFunctions = ( getRestApiUrl: () => additionalData.restApiUrl, getInstanceBaseUrl: () => additionalData.instanceBaseUrl, getInstanceId: () => Container.get(InstanceSettings).instanceId, - getTimezone: () => getTimezone(workflow), + getTimezone: () => workflow.timezone, getCredentialsProperties: (type: string) => additionalData.credentialsHelper.getCredentialsProperties(type), prepareOutputData: async (outputData) => [outputData], @@ -3286,6 +3280,14 @@ const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({ await Container.get(SSHClientsManager).getClient(credentials), }); +const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => { + const scheduledTaskManager = Container.get(ScheduledTaskManager); + return { + registerCron: (cronExpression, onTick) => + scheduledTaskManager.registerCron(workflow, cronExpression, onTick), + }; +}; + const getAllowedPaths = () => { const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO]; if (!restrictFileAccessTo) { @@ -3489,6 +3491,7 @@ export function getExecutePollFunctions( createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData, workflow.id), + ...getSchedulingFunctions(workflow), returnJsonArray, }, }; @@ -3553,6 +3556,7 @@ export function getExecuteTriggerFunctions( ...getSSHTunnelFunctions(), ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData, workflow.id), + ...getSchedulingFunctions(workflow), returnJsonArray, }, }; diff --git a/packages/core/src/ScheduledTaskManager.ts b/packages/core/src/ScheduledTaskManager.ts new file mode 100644 index 0000000000..ce656f3716 --- /dev/null +++ b/packages/core/src/ScheduledTaskManager.ts @@ -0,0 +1,31 @@ +import { Service } from 'typedi'; +import { CronJob } from 'cron'; +import type { CronExpression, Workflow } from 'n8n-workflow'; + +@Service() +export class ScheduledTaskManager { + readonly cronJobs = new Map(); + + registerCron(workflow: Workflow, cronExpression: CronExpression, onTick: () => void) { + const cronJob = new CronJob(cronExpression, onTick, undefined, true, workflow.timezone); + const cronJobsForWorkflow = this.cronJobs.get(workflow.id); + if (cronJobsForWorkflow) { + cronJobsForWorkflow.push(cronJob); + } else { + this.cronJobs.set(workflow.id, [cronJob]); + } + } + + deregisterCrons(workflowId: string) { + const cronJobs = this.cronJobs.get(workflowId) ?? []; + for (const cronJob of cronJobs) { + cronJob.stop(); + } + } + + deregisterAllCrons() { + for (const workflowId of Object.keys(this.cronJobs)) { + this.deregisterCrons(workflowId); + } + } +} diff --git a/packages/core/test/ScheduledTaskManager.test.ts b/packages/core/test/ScheduledTaskManager.test.ts new file mode 100644 index 0000000000..df7fb9b77e --- /dev/null +++ b/packages/core/test/ScheduledTaskManager.test.ts @@ -0,0 +1,54 @@ +import type { Workflow } from 'n8n-workflow'; +import { mock } from 'jest-mock-extended'; + +import { ScheduledTaskManager } from '@/ScheduledTaskManager'; + +describe('ScheduledTaskManager', () => { + const workflow = mock({ timezone: 'GMT' }); + const everyMinute = '0 * * * * *'; + const onTick = jest.fn(); + + let scheduledTaskManager: ScheduledTaskManager; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + scheduledTaskManager = new ScheduledTaskManager(); + }); + + it('should throw when workflow timezone is invalid', () => { + expect(() => + scheduledTaskManager.registerCron( + mock({ timezone: 'somewhere' }), + everyMinute, + onTick, + ), + ).toThrow('Invalid timezone.'); + }); + + it('should throw when cron expression is invalid', () => { + expect(() => + //@ts-expect-error invalid cron expression is a type-error + scheduledTaskManager.registerCron(workflow, 'invalid-cron-expression', onTick), + ).toThrow(); + }); + + it('should register valid CronJobs', async () => { + scheduledTaskManager.registerCron(workflow, everyMinute, onTick); + + expect(onTick).not.toHaveBeenCalled(); + jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes + expect(onTick).toHaveBeenCalledTimes(10); + }); + + it('should deregister CronJobs for a workflow', async () => { + scheduledTaskManager.registerCron(workflow, everyMinute, onTick); + scheduledTaskManager.registerCron(workflow, everyMinute, onTick); + scheduledTaskManager.registerCron(workflow, everyMinute, onTick); + scheduledTaskManager.deregisterCrons(workflow.id); + + expect(onTick).not.toHaveBeenCalled(); + jest.advanceTimersByTime(10 * 60 * 1000); // 10 minutes + expect(onTick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Cron/Cron.node.ts b/packages/nodes-base/nodes/Cron/Cron.node.ts index 7430e6c07a..62f0c595f3 100644 --- a/packages/nodes-base/nodes/Cron/Cron.node.ts +++ b/packages/nodes-base/nodes/Cron/Cron.node.ts @@ -7,8 +7,6 @@ import type { } from 'n8n-workflow'; import { NodeHelpers, toCronExpression } from 'n8n-workflow'; -import { CronJob } from 'cron'; - export class Cron implements INodeType { description: INodeTypeDescription = { displayName: 'Cron', @@ -66,27 +64,11 @@ export class Cron implements INodeType { this.emit([this.helpers.returnJsonArray([{}])]); }; - const timezone = this.getTimezone(); - - // Start the cron-jobs - const cronJobs = cronTimes.map( - (cronTime) => new CronJob(cronTime, executeTrigger, undefined, true, timezone), - ); - - // Stop the cron-jobs - async function closeFunction() { - for (const cronJob of cronJobs) { - cronJob.stop(); - } - } - - async function manualTriggerFunction() { - executeTrigger(); - } + // Register the cron-jobs + cronTimes.forEach((cronTime) => this.helpers.registerCron(cronTime, executeTrigger)); return { - closeFunction, - manualTriggerFunction, + manualTriggerFunction: async () => executeTrigger(), }; } } diff --git a/packages/nodes-base/nodes/Schedule/GenericFunctions.ts b/packages/nodes-base/nodes/Schedule/GenericFunctions.ts index 97cec57241..42d31bd582 100644 --- a/packages/nodes-base/nodes/Schedule/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Schedule/GenericFunctions.ts @@ -1,93 +1,128 @@ -import type { IDataObject } from 'n8n-workflow'; import moment from 'moment-timezone'; -import type { IRecurencyRule } from './SchedulerInterface'; +import { type CronExpression, randomInt } from 'n8n-workflow'; +import type { IRecurrenceRule, ScheduleInterval } from './SchedulerInterface'; -export function recurencyCheck( - recurrency: IRecurencyRule, - recurrencyRules: number[], +export function recurrenceCheck( + recurrence: IRecurrenceRule, + recurrenceRules: number[], timezone: string, ): boolean { - const recurrencyRuleIndex = recurrency.index; - const intervalSize = recurrency.intervalSize; - const typeInterval = recurrency.typeInterval; + if (!recurrence.activated) return true; - const lastExecution = - recurrencyRuleIndex !== undefined ? recurrencyRules[recurrencyRuleIndex] : undefined; + const intervalSize = recurrence.intervalSize; + if (!intervalSize) return false; - if ( - intervalSize && - recurrencyRuleIndex !== undefined && - (typeInterval === 'weeks' || typeInterval === 'undefined') - ) { + const index = recurrence.index; + const typeInterval = recurrence.typeInterval; + const lastExecution = recurrenceRules[index]; + + const momentTz = moment.tz(timezone); + if (typeInterval === 'hours') { + const hour = momentTz.hour(); + if (lastExecution === undefined || hour === (intervalSize + lastExecution) % 24) { + recurrenceRules[index] = hour; + return true; + } + } else if (typeInterval === 'days') { + const dayOfYear = momentTz.dayOfYear(); + if (lastExecution === undefined || dayOfYear === (intervalSize + lastExecution) % 365) { + recurrenceRules[index] = dayOfYear; + return true; + } + } else if (typeInterval === 'weeks') { + const week = momentTz.week(); if ( lastExecution === undefined || // First time executing this rule - moment.tz(timezone).week() === (intervalSize + lastExecution) % 52 || // not first time, but minimum interval has passed - moment.tz(timezone).week() === lastExecution // Trigger on multiple days in the same week + week === (intervalSize + lastExecution) % 52 || // not first time, but minimum interval has passed + week === lastExecution // Trigger on multiple days in the same week ) { - recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).week(); + recurrenceRules[index] = week; return true; } - } else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'days') { - if ( - lastExecution === undefined || - moment.tz(timezone).dayOfYear() === (intervalSize + lastExecution) % 365 - ) { - recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).dayOfYear(); + } else if (typeInterval === 'months') { + const month = momentTz.month(); + if (lastExecution === undefined || month === (intervalSize + lastExecution) % 12) { + recurrenceRules[index] = month; return true; } - } else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'hours') { - if ( - lastExecution === undefined || - moment.tz(timezone).hour() === (intervalSize + lastExecution) % 24 - ) { - recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).hour(); - return true; - } - } else if (intervalSize && recurrencyRuleIndex !== undefined && typeInterval === 'months') { - if ( - lastExecution === undefined || - moment.tz(timezone).month() === (intervalSize + lastExecution) % 12 - ) { - recurrencyRules[recurrencyRuleIndex] = moment.tz(timezone).month(); - return true; - } - } else { - return true; } return false; } -export function convertMonthToUnix(expression: string): string { - if (!isNaN(parseInt(expression)) || expression.includes('-') || expression.includes(',')) { - let matches = expression.match(/([0-9])+/g) as string[]; - if (matches) { - matches = matches.map((match) => - parseInt(match) !== 0 ? String(parseInt(match) - 1) : match, - ); - } - expression = matches?.join(expression.includes('-') ? '-' : ',') || ''; - } - return expression; -} +export const toCronExpression = (interval: ScheduleInterval): CronExpression => { + if (interval.field === 'cronExpression') return interval.expression; + if (interval.field === 'seconds') return `*/${interval.secondsInterval} * * * * *`; -export function convertToUnixFormat(interval: IDataObject) { - const expression = (interval.expression as string).split(' '); - if (expression.length === 5) { - expression[3] = convertMonthToUnix(expression[3]); - expression[4] = expression[4].replace('7', '0'); - } else if (expression.length === 6) { - expression[4] = convertMonthToUnix(expression[4]); - expression[5] = expression[5].replace('7', '0'); - } - interval.expression = expression.join(' '); -} + const randomSecond = randomInt(0, 60); + if (interval.field === 'minutes') return `${randomSecond} */${interval.minutesInterval} * * * *`; -export const addFallbackValue = (enabled: boolean, fallback: T) => { - if (enabled) { - return (value: T) => { - if (!value) return fallback; - return value; - }; + const minute = interval.triggerAtMinute ?? randomInt(0, 60); + if (interval.field === 'hours') + return `${randomSecond} ${minute} */${interval.hoursInterval} * * *`; + + // Since Cron does not support `*/` for days or weeks, all following expressions trigger more often, but are then filtered by `recurrenceCheck` + const hour = interval.triggerAtHour ?? randomInt(0, 24); + if (interval.field === 'days') return `${randomSecond} ${minute} ${hour} * * *`; + if (interval.field === 'weeks') { + const days = interval.triggerAtDay; + const daysOfWeek = days.length === 0 ? '*' : days.join(','); + return `${randomSecond} ${minute} ${hour} * * ${daysOfWeek}` as CronExpression; } - return (value: T) => value; + + const dayOfMonth = interval.triggerAtDayOfMonth ?? randomInt(0, 31); + return `${randomSecond} ${minute} ${hour} ${dayOfMonth} */${interval.monthsInterval} *`; }; + +export function intervalToRecurrence(interval: ScheduleInterval, index: number) { + let recurrence: IRecurrenceRule = { activated: false }; + + if (interval.field === 'hours') { + const { hoursInterval } = interval; + if (hoursInterval !== 1) { + recurrence = { + activated: true, + index, + intervalSize: hoursInterval, + typeInterval: 'hours', + }; + } + } + + if (interval.field === 'days') { + const { daysInterval } = interval; + if (daysInterval !== 1) { + recurrence = { + activated: true, + index, + intervalSize: daysInterval, + typeInterval: 'days', + }; + } + } + + if (interval.field === 'weeks') { + const { weeksInterval } = interval; + if (weeksInterval !== 1) { + recurrence = { + activated: true, + index, + intervalSize: weeksInterval, + typeInterval: 'weeks', + }; + } + } + + if (interval.field === 'months') { + const { monthsInterval } = interval; + if (monthsInterval !== 1) { + recurrence = { + activated: true, + index, + intervalSize: monthsInterval, + typeInterval: 'months', + }; + } + } + + return recurrence; +} diff --git a/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts b/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts index 09627e8d4b..8808664af3 100644 --- a/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts +++ b/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts @@ -1,16 +1,15 @@ import type { ITriggerFunctions, - IDataObject, INodeType, INodeTypeDescription, ITriggerResponse, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; - -import { CronJob } from 'cron'; import moment from 'moment-timezone'; -import type { IRecurencyRule } from './SchedulerInterface'; -import { addFallbackValue, convertToUnixFormat, recurencyCheck } from './GenericFunctions'; +import { sendAt } from 'cron'; + +import type { IRecurrenceRule, Rule } from './SchedulerInterface'; +import { intervalToRecurrence, recurrenceCheck, toCronExpression } from './GenericFunctions'; export class ScheduleTrigger implements INodeType { description: INodeTypeDescription = { @@ -402,7 +401,7 @@ export class ScheduleTrigger implements INodeType { field: ['cronExpression'], }, }, - hint: 'Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]', + hint: 'Format: [Second] [Minute] [Hour] [Day of Month] [Month] [Day of Week]', }, ], }, @@ -412,239 +411,74 @@ export class ScheduleTrigger implements INodeType { }; async trigger(this: ITriggerFunctions): Promise { - const rule = this.getNodeParameter('rule', []) as IDataObject; - const interval = rule.interval as IDataObject[]; + const { interval: intervals } = this.getNodeParameter('rule', []) as Rule; const timezone = this.getTimezone(); - const nodeVersion = this.getNode().typeVersion; - const cronJobs: CronJob[] = []; - const intervalArr: NodeJS.Timeout[] = []; const staticData = this.getWorkflowStaticData('node') as { - recurrencyRules: number[]; + recurrenceRules: number[]; }; - if (!staticData.recurrencyRules) { - staticData.recurrencyRules = []; + if (!staticData.recurrenceRules) { + staticData.recurrenceRules = []; } - const fallbackToZero = addFallbackValue(nodeVersion >= 1.2, '0'); - const executeTrigger = async (recurency: IRecurencyRule) => { + + const executeTrigger = (recurrence: IRecurrenceRule) => { + const shouldTrigger = recurrenceCheck(recurrence, staticData.recurrenceRules, timezone); + if (!shouldTrigger) return; + + const momentTz = moment.tz(timezone); const resultData = { - timestamp: moment.tz(timezone).toISOString(true), - 'Readable date': moment.tz(timezone).format('MMMM Do YYYY, h:mm:ss a'), - 'Readable time': moment.tz(timezone).format('h:mm:ss a'), - 'Day of week': moment.tz(timezone).format('dddd'), - Year: moment.tz(timezone).format('YYYY'), - Month: moment.tz(timezone).format('MMMM'), - 'Day of month': moment.tz(timezone).format('DD'), - Hour: moment.tz(timezone).format('HH'), - Minute: moment.tz(timezone).format('mm'), - Second: moment.tz(timezone).format('ss'), - Timezone: moment.tz(timezone).format('z Z'), + timestamp: momentTz.toISOString(true), + 'Readable date': momentTz.format('MMMM Do YYYY, h:mm:ss a'), + 'Readable time': momentTz.format('h:mm:ss a'), + 'Day of week': momentTz.format('dddd'), + Year: momentTz.format('YYYY'), + Month: momentTz.format('MMMM'), + 'Day of month': momentTz.format('DD'), + Hour: momentTz.format('HH'), + Minute: momentTz.format('mm'), + Second: momentTz.format('ss'), + Timezone: `${timezone} (UTC${momentTz.format('Z')})`, }; - if (!recurency.activated) { - this.emit([this.helpers.returnJsonArray([resultData])]); - } else { - if (recurencyCheck(recurency, staticData.recurrencyRules, timezone)) { - this.emit([this.helpers.returnJsonArray([resultData])]); - } - } + this.emit([this.helpers.returnJsonArray([resultData])]); }; - for (let i = 0; i < interval.length; i++) { - let intervalValue = 1000; - if (interval[i].field === 'cronExpression') { - if (nodeVersion > 1) { - // ! Remove this part if we use a cron library that follows unix cron expression - convertToUnixFormat(interval[i]); - } - const cronExpression = interval[i].expression as string; + const rules = intervals.map((interval, i) => ({ + interval, + cronExpression: toCronExpression(interval), + recurrence: intervalToRecurrence(interval, i), + })); + + if (this.getMode() !== 'manual') { + for (const { interval, cronExpression, recurrence } of rules) { try { - const cronJob = new CronJob( - cronExpression, - async () => await executeTrigger({ activated: false } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); + this.helpers.registerCron(cronExpression, () => executeTrigger(recurrence)); } catch (error) { - throw new NodeOperationError(this.getNode(), 'Invalid cron expression', { - description: 'More information on how to build them at https://crontab.guru/', - }); + if (interval.field === 'cronExpression') { + throw new NodeOperationError(this.getNode(), 'Invalid cron expression', { + description: 'More information on how to build them at https://crontab.guru/', + }); + } else { + throw error; + } } } - - if (interval[i].field === 'seconds') { - const seconds = interval[i].secondsInterval as number; - intervalValue *= seconds; - const intervalObj = setInterval( - async () => await executeTrigger({ activated: false } as IRecurencyRule), - intervalValue, - ) as NodeJS.Timeout; - intervalArr.push(intervalObj); - } - - if (interval[i].field === 'minutes') { - const minutes = interval[i].minutesInterval as number; - intervalValue *= 60 * minutes; - const intervalObj = setInterval( - async () => await executeTrigger({ activated: false } as IRecurencyRule), - intervalValue, - ) as NodeJS.Timeout; - intervalArr.push(intervalObj); - } - - if (interval[i].field === 'hours') { - const hour = interval[i].hoursInterval as number; - const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string); - - const cronTimes: string[] = [minute, '*', '*', '*', '*']; - const cronExpression: string = cronTimes.join(' '); - if (hour === 1) { - const cronJob = new CronJob( - cronExpression, - async () => await executeTrigger({ activated: false } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } else { - const cronJob = new CronJob( - cronExpression, - async () => - await executeTrigger({ - activated: true, - index: i, - intervalSize: hour, - typeInterval: 'hours', - } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); + return {}; + } else { + const manualTriggerFunction = async () => { + const { interval, cronExpression, recurrence } = rules[0]; + if (interval.field === 'cronExpression') { + try { + sendAt(cronExpression); + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Invalid cron expression', { + description: 'More information on how to build them at https://crontab.guru/', + }); + } } - } + executeTrigger(recurrence); + }; - if (interval[i].field === 'days') { - const day = interval[i].daysInterval as number; - const hour = interval[i].triggerAtHour?.toString() as string; - const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string); - const cronTimes: string[] = [minute, hour, '*', '*', '*']; - const cronExpression: string = cronTimes.join(' '); - if (day === 1) { - const cronJob = new CronJob( - cronExpression, - async () => await executeTrigger({ activated: false } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } else { - const cronJob = new CronJob( - cronExpression, - async () => - await executeTrigger({ - activated: true, - index: i, - intervalSize: day, - typeInterval: 'days', - } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } - } - - if (interval[i].field === 'weeks') { - const hour = interval[i].triggerAtHour?.toString() as string; - const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string); - const week = interval[i].weeksInterval as number; - const days = interval[i].triggerAtDay as IDataObject[]; - const day = days.length === 0 ? '*' : days.join(','); - const cronTimes: string[] = [minute, hour, '*', '*', day]; - const cronExpression = cronTimes.join(' '); - if (week === 1) { - const cronJob = new CronJob( - cronExpression, - async () => await executeTrigger({ activated: false } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } else { - const cronJob = new CronJob( - cronExpression, - async () => - await executeTrigger({ - activated: true, - index: i, - intervalSize: week, - typeInterval: 'weeks', - } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } - } - - if (interval[i].field === 'months') { - const month = interval[i].monthsInterval; - const day = interval[i].triggerAtDayOfMonth?.toString() as string; - const hour = interval[i].triggerAtHour?.toString() as string; - const minute = fallbackToZero(interval[i].triggerAtMinute?.toString() as string); - const cronTimes: string[] = [minute, hour, day, '*', '*']; - const cronExpression: string = cronTimes.join(' '); - if (month === 1) { - const cronJob = new CronJob( - cronExpression, - async () => await executeTrigger({ activated: false } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } else { - const cronJob = new CronJob( - cronExpression, - async () => - await executeTrigger({ - activated: true, - index: i, - intervalSize: month, - typeInterval: 'months', - } as IRecurencyRule), - undefined, - true, - timezone, - ); - cronJobs.push(cronJob); - } - } + return { manualTriggerFunction }; } - - async function closeFunction() { - for (const cronJob of cronJobs) { - cronJob.stop(); - } - for (const entry of intervalArr) { - clearInterval(entry); - } - } - - async function manualTriggerFunction() { - void executeTrigger({ activated: false } as IRecurencyRule); - } - - return { - closeFunction, - manualTriggerFunction, - }; } } diff --git a/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts b/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts index b2aec0d748..1acc9ee9ed 100644 --- a/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts +++ b/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts @@ -1,6 +1,53 @@ -export interface IRecurencyRule { - activated: boolean; - index?: number; - intervalSize?: number; - typeInterval?: string; +import type { CronExpression } from 'n8n-workflow'; + +export type IRecurrenceRule = + | { activated: false } + | { + activated: true; + index: number; + intervalSize: number; + typeInterval: 'hours' | 'days' | 'weeks' | 'months'; + }; + +export type ScheduleInterval = + | { + field: 'cronExpression'; + expression: CronExpression; + } + | { + field: 'seconds'; + secondsInterval: number; + } + | { + field: 'minutes'; + minutesInterval: number; + } + | { + field: 'hours'; + hoursInterval: number; + triggerAtMinute?: number; + } + | { + field: 'days'; + daysInterval: number; + triggerAtHour?: number; + triggerAtMinute?: number; + } + | { + field: 'weeks'; + weeksInterval: number; + triggerAtDay: number[]; + triggerAtHour?: number; + triggerAtMinute?: number; + } + | { + field: 'months'; + monthsInterval: number; + triggerAtDayOfMonth?: number; + triggerAtHour?: number; + triggerAtMinute?: number; + }; + +export interface Rule { + interval: ScheduleInterval[]; } diff --git a/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts new file mode 100644 index 0000000000..d6c124cc4c --- /dev/null +++ b/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts @@ -0,0 +1,257 @@ +import * as n8nWorkflow from 'n8n-workflow'; +import { intervalToRecurrence, recurrenceCheck, toCronExpression } from '../GenericFunctions'; +import type { IRecurrenceRule } from '../SchedulerInterface'; + +describe('toCronExpression', () => { + Object.defineProperty(n8nWorkflow, 'randomInt', { + value: (min: number, max: number) => Math.floor((min + max) / 2), + }); + + it('should return cron expression for cronExpression field', () => { + const result = toCronExpression({ + field: 'cronExpression', + expression: '1 2 3 * * *', + }); + expect(result).toEqual('1 2 3 * * *'); + }); + + it('should return cron expression for seconds interval', () => { + const result = toCronExpression({ + field: 'seconds', + secondsInterval: 10, + }); + expect(result).toEqual('*/10 * * * * *'); + }); + + it('should return cron expression for minutes interval', () => { + const result = toCronExpression({ + field: 'minutes', + minutesInterval: 30, + }); + expect(result).toEqual('30 */30 * * * *'); + }); + + it('should return cron expression for hours interval', () => { + const result = toCronExpression({ + field: 'hours', + hoursInterval: 3, + triggerAtMinute: 22, + }); + expect(result).toEqual('30 22 */3 * * *'); + + const result1 = toCronExpression({ + field: 'hours', + hoursInterval: 3, + }); + expect(result1).toEqual('30 30 */3 * * *'); + }); + + it('should return cron expression for days interval', () => { + const result = toCronExpression({ + field: 'days', + daysInterval: 4, + triggerAtMinute: 30, + triggerAtHour: 10, + }); + expect(result).toEqual('30 30 10 * * *'); + + const result1 = toCronExpression({ + field: 'days', + daysInterval: 4, + }); + expect(result1).toEqual('30 30 12 * * *'); + }); + + it('should return cron expression for weeks interval', () => { + const result = toCronExpression({ + field: 'weeks', + weeksInterval: 2, + triggerAtMinute: 0, + triggerAtHour: 9, + triggerAtDay: [1, 3, 5], + }); + expect(result).toEqual('30 0 9 * * 1,3,5'); + const result1 = toCronExpression({ + field: 'weeks', + weeksInterval: 2, + triggerAtDay: [1, 3, 5], + }); + expect(result1).toEqual('30 30 12 * * 1,3,5'); + }); + + it('should return cron expression for months interval', () => { + const result = toCronExpression({ + field: 'months', + monthsInterval: 3, + triggerAtMinute: 0, + triggerAtHour: 0, + triggerAtDayOfMonth: 1, + }); + expect(result).toEqual('30 0 0 1 */3 *'); + const result1 = toCronExpression({ + field: 'months', + monthsInterval: 3, + }); + expect(result1).toEqual('30 30 12 15 */3 *'); + }); +}); + +describe('recurrenceCheck', () => { + it('should return true if activated=false', () => { + const result = recurrenceCheck({ activated: false }, [], 'UTC'); + expect(result).toBe(true); + }); + + it('should return false if intervalSize is falsey', () => { + const result = recurrenceCheck( + { + activated: true, + index: 0, + intervalSize: 0, + typeInterval: 'days', + }, + [], + 'UTC', + ); + expect(result).toBe(false); + }); + + it('should return true only once for a day cron', () => { + const recurrence: IRecurrenceRule = { + activated: true, + index: 0, + intervalSize: 2, + typeInterval: 'days', + }; + const recurrenceRules: number[] = []; + const result1 = recurrenceCheck(recurrence, recurrenceRules, 'UTC'); + expect(result1).toBe(true); + const result2 = recurrenceCheck(recurrence, recurrenceRules, 'UTC'); + expect(result2).toBe(false); + }); +}); + +describe('intervalToRecurrence', () => { + it('should return recurrence rule for seconds interval', () => { + const result = intervalToRecurrence( + { + field: 'seconds', + secondsInterval: 10, + }, + 0, + ); + expect(result.activated).toBe(false); + }); + + it('should return recurrence rule for minutes interval', () => { + const result = intervalToRecurrence( + { + field: 'minutes', + minutesInterval: 30, + }, + 1, + ); + expect(result.activated).toBe(false); + }); + + it('should return recurrence rule for hours interval', () => { + const result = intervalToRecurrence( + { + field: 'hours', + hoursInterval: 3, + triggerAtMinute: 22, + }, + 2, + ); + expect(result).toEqual({ + activated: true, + index: 2, + intervalSize: 3, + typeInterval: 'hours', + }); + + const result1 = intervalToRecurrence( + { + field: 'hours', + hoursInterval: 3, + }, + 3, + ); + expect(result1).toEqual({ + activated: true, + index: 3, + intervalSize: 3, + typeInterval: 'hours', + }); + }); + + it('should return recurrence rule for days interval', () => { + const result = intervalToRecurrence( + { + field: 'days', + daysInterval: 4, + triggerAtMinute: 30, + triggerAtHour: 10, + }, + 4, + ); + expect(result).toEqual({ + activated: true, + index: 4, + intervalSize: 4, + typeInterval: 'days', + }); + + const result1 = intervalToRecurrence( + { + field: 'days', + daysInterval: 4, + }, + 5, + ); + expect(result1).toEqual({ + activated: true, + index: 5, + intervalSize: 4, + typeInterval: 'days', + }); + }); + + it('should return recurrence rule for weeks interval', () => { + const result = intervalToRecurrence( + { + field: 'weeks', + weeksInterval: 2, + triggerAtMinute: 0, + triggerAtHour: 9, + triggerAtDay: [1, 3, 5], + }, + 6, + ); + expect(result).toEqual({ + activated: true, + index: 6, + intervalSize: 2, + typeInterval: 'weeks', + }); + }); + + it('should return recurrence rule for months interval', () => { + const result = intervalToRecurrence( + { + field: 'months', + monthsInterval: 3, + triggerAtMinute: 0, + triggerAtHour: 0, + triggerAtDayOfMonth: 1, + }, + 8, + ); + expect(result).toEqual({ + activated: true, + index: 8, + intervalSize: 3, + typeInterval: 'months', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts b/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts new file mode 100644 index 0000000000..fa1d2cd615 --- /dev/null +++ b/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts @@ -0,0 +1,83 @@ +import * as n8nWorkflow from 'n8n-workflow'; +import type { INode, ITriggerFunctions, Workflow } from 'n8n-workflow'; +import { returnJsonArray } from 'n8n-core'; +import { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager'; +import { mock } from 'jest-mock-extended'; +import { ScheduleTrigger } from '../ScheduleTrigger.node'; + +describe('ScheduleTrigger', () => { + Object.defineProperty(n8nWorkflow, 'randomInt', { + value: (min: number, max: number) => Math.floor((min + max) / 2), + }); + + const HOUR = 60 * 60 * 1000; + const mockDate = new Date('2023-12-28 12:34:56.789Z'); + const timezone = 'Europe/Berlin'; + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + + const node = mock({ typeVersion: 1 }); + const workflow = mock({ timezone }); + const scheduledTaskManager = new ScheduledTaskManager(); + const helpers = mock({ + returnJsonArray, + registerCron: (cronExpression, onTick) => + scheduledTaskManager.registerCron(workflow, cronExpression, onTick), + }); + + const triggerFunctions = mock({ + helpers, + getTimezone: () => timezone, + getNode: () => node, + getMode: () => 'trigger', + }); + + const scheduleTrigger = new ScheduleTrigger(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('trigger', () => { + it('should emit on defined schedule', async () => { + triggerFunctions.getNodeParameter.calledWith('rule', expect.anything()).mockReturnValueOnce({ + interval: [{ field: 'hours', hoursInterval: 3 }], + }); + triggerFunctions.getWorkflowStaticData.mockReturnValueOnce({ recurrenceRules: [] }); + + const result = await scheduleTrigger.trigger.call(triggerFunctions); + // Assert that no manualTriggerFunction is returned + expect(result).toEqual({}); + + expect(triggerFunctions.emit).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(HOUR); + expect(triggerFunctions.emit).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2 * HOUR); + expect(triggerFunctions.emit).toHaveBeenCalledTimes(1); + + const firstTriggerData = triggerFunctions.emit.mock.calls[0][0][0][0]; + expect(firstTriggerData.json).toEqual({ + 'Day of month': '28', + 'Day of week': 'Thursday', + Hour: '15', + Minute: '30', + Month: 'December', + 'Readable date': 'December 28th 2023, 3:30:30 pm', + 'Readable time': '3:30:30 pm', + Second: '30', + Timezone: 'Europe/Berlin (UTC+01:00)', + Year: '2023', + timestamp: '2023-12-28T15:30:30.000+01:00', + }); + + jest.setSystemTime(new Date(firstTriggerData.json.timestamp as string)); + + jest.advanceTimersByTime(2 * HOUR); + expect(triggerFunctions.emit).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(HOUR); + expect(triggerFunctions.emit).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1953005379..885cb40a8f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -804,7 +804,6 @@ "@types/aws4": "^1.5.1", "@types/basic-auth": "^1.1.3", "@types/cheerio": "^0.22.15", - "@types/cron": "~1.7.1", "@types/eventsource": "^1.1.2", "@types/express": "^4.17.21", "@types/html-to-text": "^9.0.1", @@ -838,7 +837,7 @@ "change-case": "4.1.2", "cheerio": "1.0.0-rc.6", "chokidar": "3.5.2", - "cron": "1.7.2", + "cron": "3.1.7", "csv-parse": "5.5.0", "currency-codes": "2.1.0", "eventsource": "2.0.2", diff --git a/packages/workflow/src/Cron.ts b/packages/workflow/src/Cron.ts index 7f41e9ccd5..ad83eec682 100644 --- a/packages/workflow/src/Cron.ts +++ b/packages/workflow/src/Cron.ts @@ -1,10 +1,10 @@ +import type { CronExpression } from './Interfaces'; import { randomInt } from './utils'; interface BaseTriggerTime { mode: T; } -type CronExpression = string; interface CustomTrigger extends BaseTriggerTime<'custom'> { cronExpression: CronExpression; } @@ -49,22 +49,24 @@ export type TriggerTime = | EveryWeek | EveryMonth; -const randomSecond = () => randomInt(60).toString(); - export const toCronExpression = (item: TriggerTime): CronExpression => { - if (item.mode === 'everyMinute') return `${randomSecond()} * * * * *`; - if (item.mode === 'everyHour') return `${randomSecond()} ${item.minute} * * * *`; + const randomSecond = randomInt(60); + + if (item.mode === 'everyMinute') return `${randomSecond} * * * * *`; + if (item.mode === 'everyHour') return `${randomSecond} ${item.minute} * * * *`; if (item.mode === 'everyX') { - if (item.unit === 'minutes') return `${randomSecond()} */${item.value} * * * *`; - if (item.unit === 'hours') return `${randomSecond()} 0 */${item.value} * * *`; + if (item.unit === 'minutes') return `${randomSecond} */${item.value} * * * *`; + + const randomMinute = randomInt(60); + if (item.unit === 'hours') return `${randomSecond} ${randomMinute} */${item.value} * * *`; } - if (item.mode === 'everyDay') return `${randomSecond()} ${item.minute} ${item.hour} * * *`; + if (item.mode === 'everyDay') return `${randomSecond} ${item.minute} ${item.hour} * * *`; if (item.mode === 'everyWeek') - return `${randomSecond()} ${item.minute} ${item.hour} * * ${item.weekday}`; + return `${randomSecond} ${item.minute} ${item.hour} * * ${item.weekday}`; if (item.mode === 'everyMonth') - return `${randomSecond()} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`; + return `${randomSecond} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`; - return item.cronExpression.trim(); + return item.cronExpression.trim() as CronExpression; }; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 5d3b573e63..3a3aab1165 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -842,6 +842,14 @@ export interface SSHTunnelFunctions { getSSHClient(credentials: SSHCredentials): Promise; } +type CronUnit = number | '*' | `*/${number}`; +export type CronExpression = + `${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit} ${CronUnit}`; + +export interface SchedulingFunctions { + registerCron(cronExpression: CronExpression, onTick: () => void): void; +} + export type NodeTypeAndVersion = { name: string; type: string; @@ -994,6 +1002,7 @@ export interface IPollFunctions helpers: RequestHelperFunctions & BaseHelperFunctions & BinaryHelperFunctions & + SchedulingFunctions & JsonHelperFunctions; } @@ -1014,6 +1023,7 @@ export interface ITriggerFunctions BaseHelperFunctions & BinaryHelperFunctions & SSHTunnelFunctions & + SchedulingFunctions & JsonHelperFunctions; } @@ -1436,14 +1446,10 @@ export type IParameterLabel = { size?: 'small' | 'medium'; }; -export interface IPollResponse { - closeFunction?: CloseFunction; -} - export interface ITriggerResponse { closeFunction?: CloseFunction; // To manually trigger the run - manualTriggerFunction?: CloseFunction; + manualTriggerFunction?: () => Promise; // Gets added automatically at manual workflow runs resolves with // the first emitted data manualTriggerResponse?: Promise; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 29d789562a..3c74480b38 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -58,6 +58,7 @@ import { STARTING_NODE_TYPES, } from './Constants'; import { ApplicationError } from './errors/application.error'; +import { getGlobalState } from './GlobalState'; function dedupe(arr: T[]): T[] { return [...new Set(arr)]; @@ -94,6 +95,8 @@ export class Workflow { settings: IWorkflowSettings; + readonly timezone: string; + // To save workflow specific static data like for example // ids of registered webhooks of nodes staticData: IDataObject; @@ -151,6 +154,7 @@ export class Workflow { }); this.settings = parameters.settings || {}; + this.timezone = this.settings.timezone ?? getGlobalState().defaultTimezone; this.expression = new Expression(this); } diff --git a/packages/workflow/test/Cron.test.ts b/packages/workflow/test/Cron.test.ts index d1e48f9f85..0b8c45fd33 100644 --- a/packages/workflow/test/Cron.test.ts +++ b/packages/workflow/test/Cron.test.ts @@ -1,4 +1,5 @@ import { toCronExpression } from '@/Cron'; +import type { CronExpression } from '@/Interfaces'; describe('Cron', () => { describe('toCronExpression', () => { @@ -6,7 +7,7 @@ describe('Cron', () => { const expression = toCronExpression({ mode: 'everyMinute', }); - expect(expression).toMatch(/^[1-6]?[0-9] \* \* \* \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] \* \* \* \* \*$/); }); test('should generate a valid cron for `everyHour` triggers', () => { @@ -14,7 +15,7 @@ describe('Cron', () => { mode: 'everyHour', minute: 11, }); - expect(expression).toMatch(/^[1-6]?[0-9] 11 \* \* \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] 11 \* \* \* \*$/); }); test('should generate a valid cron for `everyX[minutes]` triggers', () => { @@ -23,7 +24,7 @@ describe('Cron', () => { unit: 'minutes', value: 42, }); - expect(expression).toMatch(/^[1-6]?[0-9] \*\/42 \* \* \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] \*\/42 \* \* \* \*$/); }); test('should generate a valid cron for `everyX[hours]` triggers', () => { @@ -32,7 +33,7 @@ describe('Cron', () => { unit: 'hours', value: 3, }); - expect(expression).toMatch(/^[1-6]?[0-9] 0 \*\/3 \* \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] [1-5]?[0-9] \*\/3 \* \* \*$/); }); test('should generate a valid cron for `everyDay` triggers', () => { @@ -41,7 +42,7 @@ describe('Cron', () => { hour: 13, minute: 17, }); - expect(expression).toMatch(/^[1-6]?[0-9] 17 13 \* \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* \*$/); }); test('should generate a valid cron for `everyWeek` triggers', () => { @@ -51,7 +52,7 @@ describe('Cron', () => { minute: 17, weekday: 4, }); - expect(expression).toMatch(/^[1-6]?[0-9] 17 13 \* \* 4$/); + expect(expression).toMatch(/^[1-5]?[0-9] 17 13 \* \* 4$/); }); test('should generate a valid cron for `everyMonth` triggers', () => { @@ -61,13 +62,13 @@ describe('Cron', () => { minute: 17, dayOfMonth: 12, }); - expect(expression).toMatch(/^[1-6]?[0-9] 17 13 12 \* \*$/); + expect(expression).toMatch(/^[1-5]?[0-9] 17 13 12 \* \*$/); }); test('should trim custom cron expressions', () => { const expression = toCronExpression({ mode: 'custom', - cronExpression: ' 0 9-17 * * * ', + cronExpression: ' 0 9-17 * * * ' as CronExpression, }); expect(expression).toEqual('0 9-17 * * *'); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ea0b8989a..096f6fcfdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,8 +903,8 @@ importers: specifier: 2.0.0 version: 2.0.0 cron: - specifier: 1.7.2 - version: 1.7.2 + specifier: 3.1.7 + version: 3.1.7 fast-glob: specifier: 3.2.12 version: 3.2.12 @@ -957,9 +957,6 @@ importers: '@types/concat-stream': specifier: ^2.0.0 version: 2.0.0 - '@types/cron': - specifier: ~1.7.1 - version: 1.7.3 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -1357,8 +1354,8 @@ importers: specifier: 3.5.2 version: 3.5.2 cron: - specifier: 1.7.2 - version: 1.7.2 + specifier: 3.1.7 + version: 3.1.7 csv-parse: specifier: 5.5.0 version: 5.5.0 @@ -1528,9 +1525,6 @@ importers: '@types/cheerio': specifier: ^0.22.15 version: 0.22.31 - '@types/cron': - specifier: ~1.7.1 - version: 1.7.3 '@types/eventsource': specifier: ^1.1.2 version: 1.1.9 @@ -5168,9 +5162,6 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/cron@1.7.3': - resolution: {integrity: sha512-iPmUXyIJG1Js+ldPYhOQcYU3kCAQ2FWrSkm1FJPoii2eYSn6wEW6onPukNTT0bfiflexNSRPl6KWmAIqS+36YA==} - '@types/cross-spawn@6.0.2': resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==} @@ -5306,6 +5297,9 @@ packages: '@types/luxon@3.2.0': resolution: {integrity: sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/mailparser@3.4.4': resolution: {integrity: sha512-C6Znp2QVS25JqtuPyxj38Qh+QoFcLycdxsvcc6IZCGekhaMBzbdTXzwGzhGoYb3TfKu8IRCNV0sV1o3Od97cEQ==} @@ -6807,8 +6801,8 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} - cron@1.7.2: - resolution: {integrity: sha512-+SaJ2OfeRvfQqwXQ2kgr0Y5pzBR/lijf5OpnnaruwWnmI799JfWr2jN2ItOV9s3A/+TFOt6mxvKzQq5F0Jp6VQ==} + cron@3.1.7: + resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==} cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} @@ -18434,11 +18428,6 @@ snapshots: '@types/cookiejar@2.1.5': {} - '@types/cron@1.7.3': - dependencies: - '@types/node': 18.16.16 - moment: 2.29.4 - '@types/cross-spawn@6.0.2': dependencies: '@types/node': 18.16.16 @@ -18581,6 +18570,8 @@ snapshots: '@types/luxon@3.2.0': {} + '@types/luxon@3.4.2': {} + '@types/mailparser@3.4.4': dependencies: '@types/node': 18.16.16 @@ -20373,9 +20364,10 @@ snapshots: dependencies: luxon: 3.4.4 - cron@1.7.2: + cron@3.1.7: dependencies: - moment-timezone: 0.5.37 + '@types/luxon': 3.4.2 + luxon: 3.4.4 cross-env@7.0.3: dependencies: