From 72d3d36c47b037cf4ce489c6b57b0a8b4e000a04 Mon Sep 17 00:00:00 2001 From: Pierre Cavin Date: Sat, 30 Sep 2023 00:52:54 +0200 Subject: [PATCH] feat!: rework utcOffset parameter (#699) ## Breaking changes BREAKING CHANGE: `utcOffset` parameter no longer accepts a string BREAKING CHANGE: `utcOffset` values between -60 and 60 are no longer treated as hours BREAKING CHANGE: providing both `timeZone` and `utcOffset` parameters now throws an error --- README.md | 4 +- src/errors.ts | 5 +++ src/job.ts | 91 +++++++++++++++++++++++++++++++++++------ src/time.ts | 74 +++++++++++++++++---------------- src/types/cron.types.ts | 17 ++++++-- tests/cron.test.ts | 86 +++++++++++++++----------------------- tests/crontime.test.ts | 19 ++++----- 7 files changed, 177 insertions(+), 119 deletions(-) create mode 100644 src/errors.ts diff --git a/README.md b/README.md index 0b58b334..b2f482cb 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ Parameter Based - `onTick` - [REQUIRED] - The function to fire at the specified time. If an `onComplete` callback was provided, `onTick` will receive it as an argument. `onTick` may call `onComplete` when it has finished its work. - `onComplete` - [OPTIONAL] - A function that will fire when the job is stopped with `job.stop()`, and may also be called by `onTick` at the end of each run. - `start` - [OPTIONAL] - Specifies whether to start the job just before exiting the constructor. By default this is set to false. If left at default you will need to call `job.start()` in order to start the job (assuming `job` is the variable you set the cronjob to). This does not immediately fire your `onTick` function, it just gives you more control over the behavior of your jobs. - - `timeZone` - [OPTIONAL] - Specify the time zone for the execution. This will modify the actual time relative to your time zone. If the time zone is invalid, an error is thrown. By default (if this is omitted) the local time zone will be used. You can check the various time zones format accepted in the [Luxon documentation](https://github.com/moment/luxon/blob/master/docs/zones.md#specifying-a-zone). Note: This parameter supports minutes offsets, e.g. `UTC+5:30`. **Warning**: Probably don't use both `timeZone` and `utcOffset` together or weird things may happen. + - `timeZone` - [OPTIONAL] - Specify the time zone for the execution. This will modify the actual time relative to your time zone. If the time zone is invalid, an error is thrown. By default (if this is omitted) the local time zone will be used. You can check the various time zones format accepted in the [Luxon documentation](https://github.com/moment/luxon/blob/master/docs/zones.md#specifying-a-zone). Note: This parameter supports minutes offsets, e.g. `UTC+5:30`. **Note**: Cannot be used together with `utcOffset`. - `context` - [OPTIONAL] - The context within which to execute the onTick method. This defaults to the cronjob itself allowing you to call `this.stop()`. However, if you change this you'll have access to the functions and values within your context object. - `runOnInit` - [OPTIONAL] - This will immediately fire your `onTick` function as soon as the requisite initialization has happened. This option is set to `false` by default for backwards compatibility. - - `utcOffset` - [OPTIONAL] - This allows you to specify the offset of your time zone rather than using the `timeZone` param. This should be an integer representing the number of minutes offset (like `120` for +2 hours or `-90` for -1.5 hours). **Warning**: Minutes offsets < 60 and >-60 will be treated as an offset in hours. This means a minute offset of `30` means an offset of +30 hours. Use the `timeZone` param in this case. This behavior [is planned to be removed in V3](https://github.com/kelektiv/node-cron/pull/685#issuecomment-1676417917). **Warning**: Probably don't use both `timeZone` and `utcOffset` together or weird things may happen. + - `utcOffset` - [OPTIONAL] - This allows you to specify the offset of your time zone rather than using the `timeZone` param. This should be an integer representing the number of minutes offset (like `120` for +2 hours or `-90` for -1.5 hours). **Note**: Cannot be used together with `timeZone`. - `unrefTimeout` - [OPTIONAL] - If you have code that keeps the event loop running and want to stop the node process when that finishes regardless of the state of your cronjob, you can do so making use of this parameter. This is off by default and cron will run as if it needs to control the event loop. For more information take a look at [timers#timers_timeout_unref](https://nodejs.org/api/timers.html#timers_timeout_unref) from the NodeJS docs. - `from` (static) - Create a new CronJob object providing arguments as an object. See argument names and descriptions above. - `start` - Runs your job. diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..9f07d5bb --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,5 @@ +export class ExclusiveParametersError extends Error { + constructor(param1: string, param2: string) { + super(`You can't specify both ${param1} and ${param2}`); + } +} diff --git a/src/job.ts b/src/job.ts index 1d04c8e6..9659b465 100644 --- a/src/job.ts +++ b/src/job.ts @@ -1,4 +1,5 @@ import { spawn } from 'child_process'; +import { ExclusiveParametersError } from './errors'; import { CronTime } from './time'; import { CronCommand, CronJobParams } from './types/cron.types'; @@ -16,6 +17,28 @@ export class CronJob { // eslint-disable-next-line @typescript-eslint/no-explicit-any private _callbacks: ((...args: any) => void)[] = []; + constructor( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: CronJobParams['timeZone'], + context?: CronJobParams['context'], + runOnInit?: CronJobParams['runOnInit'], + utcOffset?: null, + unrefTimeout?: CronJobParams['unrefTimeout'] + ); + constructor( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: null, + context?: CronJobParams['context'], + runOnInit?: CronJobParams['runOnInit'], + utcOffset?: CronJobParams['utcOffset'], + unrefTimeout?: CronJobParams['unrefTimeout'] + ); constructor( cronTime: CronJobParams['cronTime'], onTick: CronJobParams['onTick'], @@ -28,7 +51,19 @@ export class CronJob { unrefTimeout?: CronJobParams['unrefTimeout'] ) { this.context = context || this; - this.cronTime = new CronTime(cronTime, timeZone, utcOffset); + + // runtime check for JS users + if (timeZone != null && utcOffset != null) { + throw new ExclusiveParametersError('timeZone', 'utcOffset'); + } + + if (timeZone != null) { + this.cronTime = new CronTime(cronTime, timeZone, null); + } else if (utcOffset != null) { + this.cronTime = new CronTime(cronTime, null, utcOffset); + } else { + this.cronTime = new CronTime(cronTime, timeZone, utcOffset); + } if (unrefTimeout != null) { this.unrefTimeout = unrefTimeout; @@ -53,17 +88,49 @@ export class CronJob { } static from(params: CronJobParams) { - return new CronJob( - params.cronTime, - params.onTick, - params.onComplete, - params.start, - params.timeZone, - params.context, - params.runOnInit, - params.utcOffset, - params.unrefTimeout - ); + // runtime check for JS users + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (params.timeZone != null && params.utcOffset != null) { + throw new ExclusiveParametersError('timeZone', 'utcOffset'); + } + + if (params.timeZone != null) { + return new CronJob( + params.cronTime, + params.onTick, + params.onComplete, + params.start, + params.timeZone, + params.context, + params.runOnInit, + params.utcOffset, + params.unrefTimeout + ); + } else if (params.utcOffset != null) { + return new CronJob( + params.cronTime, + params.onTick, + params.onComplete, + params.start, + null, + params.context, + params.runOnInit, + params.utcOffset, + params.unrefTimeout + ); + } else { + return new CronJob( + params.cronTime, + params.onTick, + params.onComplete, + params.start, + params.timeZone, + params.context, + params.runOnInit, + params.utcOffset, + params.unrefTimeout + ); + } } private _fnWrap(cmd: CronCommand | string) { diff --git a/src/time.ts b/src/time.ts index be652eaa..1a32b9f6 100644 --- a/src/time.ts +++ b/src/time.ts @@ -12,7 +12,9 @@ import { TIME_UNITS_LEN, TIME_UNITS_MAP } from './constants'; +import { ExclusiveParametersError } from './errors'; import { + CronJobParams, DayOfMonthRange, MonthRange, Ranges, @@ -23,8 +25,8 @@ import { getRecordKeys } from './utils'; export class CronTime { source: string | DateTime; - zone?: string; - utcOffset?: number | string; + timeZone?: string; + utcOffset?: number; realDate = false; private second: TimeUnitField<'second'> = {}; @@ -35,17 +37,32 @@ export class CronTime { private dayOfWeek: TimeUnitField<'dayOfWeek'> = {}; constructor( - source: string | Date | DateTime, - zone?: string | null, - utcOffset?: string | number | null + source: CronJobParams['cronTime'], + timeZone?: CronJobParams['timeZone'], + utcOffset?: null + ); + constructor( + source: CronJobParams['cronTime'], + timeZone?: null, + utcOffset?: CronJobParams['utcOffset'] + ); + constructor( + source: CronJobParams['cronTime'], + timeZone?: CronJobParams['timeZone'], + utcOffset?: CronJobParams['utcOffset'] ) { - if (zone) { - const dt = DateTime.fromObject({}, { zone }); + // runtime check for JS users + if (timeZone != null && utcOffset != null) { + throw new ExclusiveParametersError('timeZone', 'utcOffset'); + } + + if (timeZone) { + const dt = DateTime.fromObject({}, { zone: timeZone }); if (!dt.isValid) { throw new Error('Invalid timezone.'); } - this.zone = zone; + this.timeZone = timeZone; } if (utcOffset != null) { @@ -122,35 +139,20 @@ export class CronTime { this.realDate && this.source instanceof DateTime ? this.source : DateTime.local(); - if (this.zone) { - date = date.setZone(this.zone); + if (this.timeZone) { + date = date.setZone(this.timeZone); } - if (this.utcOffset != null) { - const offsetHours = parseInt( - // @ts-expect-error old undocumented behavior going to be removed in V3 - this.utcOffset >= 60 || this.utcOffset <= -60 - ? // @ts-expect-error old undocumented behavior going to be removed in V3 - this.utcOffset / 60 - : this.utcOffset - ); + if (this.utcOffset !== undefined) { + const sign = this.utcOffset < 0 ? '-' : '+'; - const offsetMins = - // @ts-expect-error old undocumented behavior going to be removed in V3 - this.utcOffset >= 60 || this.utcOffset <= -60 - ? // @ts-expect-error old undocumented behavior going to be removed in V3 - Math.abs(this.utcOffset - offsetHours * 60) - : 0; - const offsetMinsStr = offsetMins >= 10 ? offsetMins : `0${offsetMins}`; + const offsetHours = Math.trunc(this.utcOffset / 60); + const offsetHoursStr = String(Math.abs(offsetHours)).padStart(2, '0'); - let utcZone = 'UTC'; + const offsetMins = Math.abs(this.utcOffset - offsetHours * 60); + const offsetMinsStr = String(offsetMins).padStart(2, '0'); - // @ts-expect-error old undocumented behavior going to be removed in V3 - if (parseInt(this.utcOffset) < 0) { - utcZone += `${offsetHours === 0 ? '-0' : offsetHours}:${offsetMinsStr}`; - } else { - utcZone += `+${offsetHours}:${offsetMinsStr}`; - } + const utcZone = `UTC${sign}${offsetHoursStr}:${offsetMinsStr}`; date = date.setZone(utcZone); @@ -222,14 +224,14 @@ export class CronTime { * - Check that the chosen time does not equal the current execution. * - Return the selected date object. */ - getNextDateFrom(start: Date | DateTime, zone?: string | Zone) { + getNextDateFrom(start: Date | DateTime, timeZone?: string | Zone) { if (start instanceof Date) { start = DateTime.fromJSDate(start); } let date = start; const firstDate = start.toMillis(); - if (zone) { - date = date.setZone(zone); + if (timeZone) { + date = date.setZone(timeZone); } if (!this.realDate) { if (date.millisecond > 0) { @@ -260,7 +262,7 @@ export class CronTime { `Something went wrong. No execution date was found in the next 8 years. Please provide the following string if you would like to help debug: Time Zone: ${ - zone?.toString() ?? '""' + timeZone?.toString() ?? '""' } - Cron String: ${this.source.toString()} - UTC offset: ${ date.offset } - current Date: ${DateTime.local().toString()}` diff --git a/src/types/cron.types.ts b/src/types/cron.types.ts index 99b465fa..1d799ca5 100644 --- a/src/types/cron.types.ts +++ b/src/types/cron.types.ts @@ -4,18 +4,29 @@ import { CONSTRAINTS, TIME_UNITS_MAP } from '../constants'; import { CronJob } from '../job'; import { IntRange } from './utils'; -export interface CronJobParams { +interface BaseCronJobParams { cronTime: string | Date | DateTime; onTick: CronCommand; onComplete?: CronCommand | null; start?: boolean | null; - timeZone?: string | null; context?: unknown | null; runOnInit?: boolean | null; - utcOffset?: string | number | null; unrefTimeout?: boolean | null; } +export type CronJobParams = + | BaseCronJobParams & + ( + | { + timeZone?: string | null; + utcOffset?: never; + } + | { + timeZone?: never; + utcOffset?: number | null; + } + ); + export type CronCommand = /** * TODO: find out how to type the context correctly, based on diff --git a/tests/cron.test.ts b/tests/cron.test.ts index f996592a..9e75f939 100644 --- a/tests/cron.test.ts +++ b/tests/cron.test.ts @@ -870,17 +870,8 @@ describe('cron', () => { // Current time const t = DateTime.local(); - /** - * in order to avoid the minute offset being treated as hours (when `-60 < utcOffset < 60`) regardless of the local timezone, - * and the maximum possible offset being +14:00, we simply add 80 minutes to that offset. - * this implicit & undocumented behavior is planned to be removed in V3 anyway: - * https://github.com/kelektiv/node-cron/pull/685#issuecomment-1676417917 - */ - const minutesOffset = 14 * 60 + 80; // 920 - - // UTC Offset decreased by minutesOffset - const utcOffset = t.offset - minutesOffset; - + // UTC Offset decreased by 45 minutes + const utcOffset = t.offset - 45; const job = new CronJob( `${t.second} ${t.minute} ${t.hour} * * *`, callback, @@ -892,8 +883,8 @@ describe('cron', () => { utcOffset ); - // tick 1 sec before minutesOffset - clock.tick(1000 * minutesOffset * 60 - 1); + // tick 1 sec before 45 minutes + clock.tick(1000 * 45 * 60 - 1); expect(callback).toHaveBeenCalledTimes(0); clock.tick(1); @@ -902,47 +893,6 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(1); }); - /** - * this still works implicitly (without minute support) because the string conversion - * to integer removes everything after the colon, i.e. '(+/-)HH:mm' becomes (+/-)HH, - * but this is an undocumented behavior that will be removed in V3: - * https://github.com/kelektiv/node-cron/pull/685#issuecomment-1676394391 - */ - it('should run a job using cron syntax with string format utcOffset', () => { - const clock = sinon.useFakeTimers(); - const callback = jest.fn(); - // Current time - const t = DateTime.local(); - // UTC Offset decreased by an hour (string format '(+/-)HH:mm') - // We support only HH support in offset as we support string offset in Timezone. - const minutesOffset = t.offset - Math.floor((t.offset - 60) / 60) * 60; - const utcOffset = t.offset - minutesOffset; - const utcOffsetString = `${utcOffset > 0 ? '+' : '-'}${`0${Math.floor( - Math.abs(utcOffset) / 60 - )}`.slice(-2)}:${`0${utcOffset % 60}`.slice(-2)}`; - - const job = new CronJob( - `${t.second} ${t.minute} ${t.hour} * * *`, - callback, - null, - true, - null, - null, - null, - utcOffsetString - ); - - // tick 1 sec before minutesOffset - clock.tick(1000 * 60 * minutesOffset - 1); - expect(callback).toHaveBeenCalledTimes(0); - - // tick 1 sec - clock.tick(1); - clock.restore(); - job.stop(); - expect(callback).toHaveBeenCalledTimes(1); - }); - it('should run a job using cron syntax with number format utcOffset that is 0', () => { const clock = sinon.useFakeTimers(); const callback = jest.fn(); @@ -1111,4 +1061,32 @@ describe('cron', () => { job.stop(); clock.restore(); }); + + it('should throw when providing both exclusive parameters timeZone and utcOffset', () => { + expect(() => { + // @ts-expect-error testing runtime exception + new CronJob( + `* * * * *`, + function () {}, + null, + true, + 'America/Chicago', + null, + null, + 120 + ); + }).toThrow(); + }); + + it('should throw when providing both exclusive parameters timeZone and utcOffset using the object constructor', () => { + expect(() => { + // @ts-expect-error testing runtime exception + CronJob.from({ + cronTime: '* * * * * *', + onTick: function () {}, + timeZone: 'America/Chicago', + utcOffset: 120 + }); + }).toThrow(); + }); }); diff --git a/tests/crontime.test.ts b/tests/crontime.test.ts index 5dd65fda..daa64ee3 100644 --- a/tests/crontime.test.ts +++ b/tests/crontime.test.ts @@ -746,18 +746,6 @@ describe('crontime', () => { clock.restore(); }); - it('should accept 4 as a valid UTC offset', () => { - const clock = sinon.useFakeTimers(); - - const cronTime = new CronTime('0 11 * * *', null, 5); - const expected = DateTime.local().plus({ hours: 6 }).toSeconds(); - const actual = cronTime.sendAt().toSeconds(); - - expect(actual).toEqual(expected); - - clock.restore(); - }); - it('should detect real date in the past', () => { const clock = sinon.useFakeTimers(); @@ -769,4 +757,11 @@ describe('crontime', () => { }).toThrow(); clock.restore(); }); + + it('should throw when providing both exclusive parameters timeZone and utcOffset', () => { + expect(() => { + // @ts-expect-error testing runtime exception + new CronTime('* * * * *', 'Asia/Amman', 120); + }).toThrow(); + }); });