diff --git a/.changeset/perfect-moose-appear.md b/.changeset/perfect-moose-appear.md new file mode 100644 index 0000000000..5666fd3630 --- /dev/null +++ b/.changeset/perfect-moose-appear.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-tasks': patch +--- + +Add support for cron syntax to configure task frequency - `TaskScheduleDefinition.frequency` can now be both a `Duration` and a string, where the latter is expected to be on standard cron format (e.g. `'0 */2 * * *'`). diff --git a/packages/backend-tasks/README.md b/packages/backend-tasks/README.md index fe55f37afd..b0bb292313 100644 --- a/packages/backend-tasks/README.md +++ b/packages/backend-tasks/README.md @@ -22,7 +22,7 @@ const scheduler = TaskScheduler.fromConfig(rootConfig).forPlugin('my-plugin'); await scheduler.scheduleTask({ id: 'refresh_things', - frequency: Duration.fromObject({ minutes: 10 }), + cadence: '*/5 * * * *', // every 5 minutes timeout: Duration.fromObject({ minutes: 15 }), fn: async () => { await entityProvider.run(); diff --git a/packages/backend-tasks/api-report.md b/packages/backend-tasks/api-report.md index 98199a55b0..8a299f924b 100644 --- a/packages/backend-tasks/api-report.md +++ b/packages/backend-tasks/api-report.md @@ -36,7 +36,7 @@ export interface TaskRunner { // @public export interface TaskScheduleDefinition { - frequency: Duration; + frequency: string | Duration; initialDelay?: Duration; timeout: Duration; } diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json index b05a740e65..4821d39348 100644 --- a/packages/backend-tasks/package.json +++ b/packages/backend-tasks/package.json @@ -38,6 +38,7 @@ "@backstage/errors": "^0.2.2", "@backstage/types": "^0.1.3", "@types/luxon": "^2.0.4", + "cron": "^1.8.2", "knex": "^1.0.2", "lodash": "^4.17.21", "luxon": "^2.0.2", @@ -47,8 +48,14 @@ "zod": "^3.9.5" }, "devDependencies": { +<<<<<<< HEAD "@backstage/backend-test-utils": "^0.1.21-next.0", "@backstage/cli": "^0.15.2-next.0", +======= + "@backstage/backend-test-utils": "^0.1.20", + "@backstage/cli": "^0.15.0", + "@types/cron": "^1.7.3", +>>>>>>> ab18600147 (Add cron support to `@backstage/backend-tasks`) "jest": "^26.0.1", "wait-for-expect": "^3.0.2" }, diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts index 993041bed4..700bfda872 100644 --- a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts @@ -40,7 +40,7 @@ describe('PluginTaskManagerImpl', () => { // TaskWorker.test.ts describe('scheduleTask', () => { it.each(databases.eachSupportedId())( - 'can run the happy path, %p', + 'can run the v1 happy path, %p', async databaseId => { const { manager } = await init(databaseId); @@ -58,6 +58,26 @@ describe('PluginTaskManagerImpl', () => { }, 60_000, ); + + it.each(databases.eachSupportedId())( + 'can run the v2 happy path, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: '* * * * * *', + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); }); // This is just to test the wrapper code; most of the actual tests are in diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts index 8d50298ffd..2008e3beec 100644 --- a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.ts @@ -40,13 +40,16 @@ export class PluginTaskSchedulerImpl implements PluginTaskScheduler { validateId(task.id); const knex = await this.databaseFactory(); - const worker = new TaskWorker(task.id, task.fn, knex, this.logger); + await worker.start( { - version: 1, + version: 2, + cadence: + typeof task.frequency === 'string' + ? task.frequency + : task.frequency.toISO(), initialDelayDuration: task.initialDelay?.toISO(), - recurringAtMostEveryDuration: task.frequency.toISO(), timeoutAfterDuration: task.timeout.toISO(), }, { diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.test.ts b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts index ce8e797503..c5c349f593 100644 --- a/packages/backend-tasks/src/tasks/TaskScheduler.test.ts +++ b/packages/backend-tasks/src/tasks/TaskScheduler.test.ts @@ -39,7 +39,7 @@ describe('TaskScheduler', () => { } it.each(databases.eachSupportedId())( - 'can return a working plugin impl, %p', + 'can return a working v1 plugin impl, %p', async databaseId => { const database = await createDatabase(databaseId); const manager = new TaskScheduler(database, logger).forPlugin('test'); @@ -58,4 +58,25 @@ describe('TaskScheduler', () => { }, 60_000, ); + + it.each(databases.eachSupportedId())( + 'can return a working v2 plugin impl, %p', + async databaseId => { + const database = await createDatabase(databaseId); + const manager = new TaskScheduler(database, logger).forPlugin('test'); + const fn = jest.fn(); + + await manager.scheduleTask({ + id: 'task2', + timeout: Duration.fromMillis(5000), + frequency: '* * * * * *', + fn, + }); + + await waitForExpect(() => { + expect(fn).toBeCalled(); + }); + }, + 60_000, + ); }); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.test.ts b/packages/backend-tasks/src/tasks/TaskWorker.test.ts index 7c8dad08bf..793e679c39 100644 --- a/packages/backend-tasks/src/tasks/TaskWorker.test.ts +++ b/packages/backend-tasks/src/tasks/TaskWorker.test.ts @@ -21,7 +21,7 @@ import waitForExpect from 'wait-for-expect'; import { migrateBackendTasks } from '../database/migrateBackendTasks'; import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; import { TaskWorker } from './TaskWorker'; -import { TaskSettingsV1 } from './types'; +import { TaskSettingsV2 } from './types'; describe('TaskWorker', () => { const logger = getVoidLogger(); @@ -42,11 +42,11 @@ describe('TaskWorker', () => { const fn = jest.fn( async () => new Promise(resolve => setTimeout(resolve, 50)), ); - const settings: TaskSettingsV1 = { - version: 1, - initialDelayDuration: Duration.fromMillis(1000).toISO(), - recurringAtMostEveryDuration: Duration.fromMillis(2000).toISO(), - timeoutAfterDuration: Duration.fromMillis(60000).toISO(), + const settings: TaskSettingsV2 = { + version: 2, + cadence: '*/2 * * * * *', + initialDelayDuration: Duration.fromObject({ seconds: 1 }).toISO(), + timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO(), }; const worker = new TaskWorker('task1', fn, knex, logger); @@ -62,10 +62,10 @@ describe('TaskWorker', () => { }), ); expect(JSON.parse(row.settings_json)).toEqual({ - version: 1, + version: 2, + cadence: '*/2 * * * * *', initialDelayDuration: 'PT1S', - recurringAtMostEveryDuration: 'PT2S', - timeoutAfterDuration: 'PT60S', + timeoutAfterDuration: 'PT1M', }); await expect(worker.findReadyTask()).resolves.toEqual({ @@ -125,10 +125,10 @@ describe('TaskWorker', () => { await migrateBackendTasks(knex); const fn = jest.fn().mockRejectedValue(new Error('failed')); - const settings: TaskSettingsV1 = { - version: 1, + const settings: TaskSettingsV2 = { + version: 2, initialDelayDuration: undefined, - recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + cadence: '* * * * * *', timeoutAfterDuration: Duration.fromMillis(60000).toISO(), }; @@ -151,18 +151,23 @@ describe('TaskWorker', () => { const fn = jest.fn( async () => new Promise(resolve => setTimeout(resolve, 50)), ); - const settings: TaskSettingsV1 = { - version: 1, - recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', timeoutAfterDuration: Duration.fromMillis(60000).toISO(), }; const worker = new TaskWorker('task1', fn, knex, logger); await worker.persistTask(settings); - await expect(worker.findReadyTask()).resolves.toEqual({ - result: 'ready', - settings, + + await waitForExpect(async () => { + await expect(worker.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); }); + await expect(worker.tryClaimTask('ticket', settings)).resolves.toBe(true); let row = (await knex(DB_TASKS_TABLE))[0]; @@ -203,9 +208,10 @@ describe('TaskWorker', () => { await migrateBackendTasks(knex); const fn = jest.fn(async () => {}); - const settings: TaskSettingsV1 = { - version: 1, - recurringAtMostEveryDuration: Duration.fromMillis(0).toISO(), + const settings: TaskSettingsV2 = { + version: 2, + initialDelayDuration: undefined, + cadence: '* * * * * *', timeoutAfterDuration: Duration.fromMillis(60000).toISO(), }; @@ -218,10 +224,14 @@ describe('TaskWorker', () => { const worker2 = new TaskWorker('task2', fn, knex, logger); await worker2.persistTask(settings); - await expect(worker2.findReadyTask()).resolves.toEqual({ - result: 'ready', - settings, + + await waitForExpect(async () => { + await expect(worker2.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); }); + await knex(DB_TASKS_TABLE).where('id', '=', 'task2').delete(); await expect(worker2.tryClaimTask('ticket', settings)).resolves.toBe( false, @@ -229,10 +239,14 @@ describe('TaskWorker', () => { const worker3 = new TaskWorker('task3', fn, knex, logger); await worker3.persistTask(settings); - await expect(worker3.findReadyTask()).resolves.toEqual({ - result: 'ready', - settings, + + await waitForExpect(async () => { + await expect(worker3.findReadyTask()).resolves.toEqual({ + result: 'ready', + settings, + }); }); + await expect(worker3.tryClaimTask('ticket', settings)).resolves.toBe( true, ); diff --git a/packages/backend-tasks/src/tasks/TaskWorker.ts b/packages/backend-tasks/src/tasks/TaskWorker.ts index 5d33bfa7a1..84f45a7fe6 100644 --- a/packages/backend-tasks/src/tasks/TaskWorker.ts +++ b/packages/backend-tasks/src/tasks/TaskWorker.ts @@ -15,13 +15,14 @@ */ import { Knex } from 'knex'; -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import { AbortSignal } from 'node-abort-controller'; import { v4 as uuid } from 'uuid'; import { Logger } from 'winston'; import { DbTasksRow, DB_TASKS_TABLE } from '../database/tables'; -import { TaskFunction, TaskSettingsV1, taskSettingsV1Schema } from './types'; +import { TaskFunction, TaskSettingsV2, taskSettingsV2Schema } from './types'; import { delegateAbortController, nowPlus, sleep } from './util'; +import { CronTime } from 'cron'; const WORK_CHECK_FREQUENCY = Duration.fromObject({ seconds: 5 }); @@ -43,7 +44,7 @@ export class TaskWorker { this.logger = logger; } - async start(settings: TaskSettingsV1, options?: { signal?: AbortSignal }) { + async start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) { try { await this.persistTask(settings); } catch (e) { @@ -123,22 +124,39 @@ export class TaskWorker { /** * Perform the initial store of the task info */ - async persistTask(settings: TaskSettingsV1) { + async persistTask(settings: TaskSettingsV2) { // Perform an initial parse to ensure that we will definitely be able to // read it back again. - taskSettingsV1Schema.parse(settings); + taskSettingsV2Schema.parse(settings); - const settingsJson = JSON.stringify(settings); - const startAt = settings.initialDelayDuration - ? nowPlus(Duration.fromISO(settings.initialDelayDuration), this.knex) - : this.knex.fn.now(); + const isCron = !settings?.cadence.startsWith('P'); + + let startAt: Knex.Raw; + if (settings.initialDelayDuration) { + startAt = nowPlus( + Duration.fromISO(settings.initialDelayDuration), + this.knex, + ); + } else if (isCron) { + const time = new CronTime(settings.cadence) + .sendAt() + .add({ seconds: -1 }) // immediately, if "* * * * * *" + .toISOString(); + startAt = this.knex.client.config.client.includes('sqlite3') + ? this.knex.raw('datetime(?)', [time]) + : this.knex.raw(`?`, [time]); + } else { + startAt = this.knex.fn.now(); + } + + this.logger.debug(`task: ${this.taskId} configured to run at: ${startAt}`); // It's OK if the task already exists; if it does, just replace its // settings with the new value and start the loop as usual. await this.knex(DB_TASKS_TABLE) .insert({ id: this.taskId, - settings_json: settingsJson, + settings_json: JSON.stringify(settings), next_run_start_at: startAt, }) .onConflict('id') @@ -151,15 +169,14 @@ export class TaskWorker { async findReadyTask(): Promise< | { result: 'not-ready-yet' } | { result: 'abort' } - | { result: 'ready'; settings: TaskSettingsV1 } + | { result: 'ready'; settings: TaskSettingsV2 } > { const [row] = await this.knex(DB_TASKS_TABLE) .where('id', '=', this.taskId) .select({ settingsJson: 'settings_json', ready: this.knex.raw( - ` - CASE + `CASE WHEN next_run_start_at <= ? AND current_run_ticket IS NULL THEN TRUE ELSE FALSE END`, @@ -177,7 +194,8 @@ export class TaskWorker { } try { - const settings = taskSettingsV1Schema.parse(JSON.parse(row.settingsJson)); + const obj = JSON.parse(row.settingsJson); + const settings = taskSettingsV2Schema.parse(obj); return { result: 'ready', settings }; } catch (e) { this.logger.info( @@ -199,7 +217,7 @@ export class TaskWorker { */ async tryClaimTask( ticket: string, - settings: TaskSettingsV1, + settings: TaskSettingsV2, ): Promise { const startedAt = this.knex.fn.now(); const expiresAt = settings.timeoutAfterDuration @@ -220,27 +238,37 @@ export class TaskWorker { async tryReleaseTask( ticket: string, - settings: TaskSettingsV1, + settings: TaskSettingsV2, ): Promise { - const { recurringAtMostEveryDuration } = settings; + const isCron = !settings?.cadence.startsWith('P'); - // We make an effort to keep the datetime calculations in the database - // layer, making sure to not have to perform conversions back and forth and - // leaning on the database as a central clock source - const dbNull = this.knex.raw('null'); - const dt = Duration.fromISO(recurringAtMostEveryDuration).as('seconds'); - const nextRun = this.knex.client.config.client.includes('sqlite3') - ? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`]) - : this.knex.raw(`next_run_start_at + interval '${dt} seconds'`); + let nextRun: Knex.Raw; + if (isCron) { + const time = new CronTime(settings.cadence).sendAt().toISOString(); + this.logger.debug(`task: ${this.taskId} will next occur around ${time}`); + nextRun = this.knex.client.config.client.includes('sqlite3') + ? this.knex.raw('datetime(?)', [time]) + : this.knex.raw(`?`, [time]); + } else { + const dt = Duration.fromISO(settings.cadence).as('seconds'); + this.logger.debug( + `task: ${this.taskId} will next occur around ${DateTime.now().plus({ + seconds: dt, + })}`, + ); + nextRun = this.knex.client.config.client.includes('sqlite3') + ? this.knex.raw('datetime(next_run_start_at, ?)', [`+${dt} seconds`]) + : this.knex.raw(`next_run_start_at + interval '${dt} seconds'`); + } const rows = await this.knex(DB_TASKS_TABLE) .where('id', '=', this.taskId) .where('current_run_ticket', '=', ticket) .update({ next_run_start_at: nextRun, - current_run_ticket: dbNull, - current_run_started_at: dbNull, - current_run_expires_at: dbNull, + current_run_ticket: this.knex.raw('null'), + current_run_started_at: this.knex.raw('null'), + current_run_expires_at: this.knex.raw('null'), }); return rows === 1; diff --git a/packages/backend-tasks/src/tasks/types.ts b/packages/backend-tasks/src/tasks/types.ts index 126bed2908..9855da3744 100644 --- a/packages/backend-tasks/src/tasks/types.ts +++ b/packages/backend-tasks/src/tasks/types.ts @@ -17,6 +17,7 @@ import { Duration } from 'luxon'; import { AbortSignal } from 'node-abort-controller'; import { z } from 'zod'; +import { CronTime } from 'cron'; /** * A function that can be called as a scheduled task. @@ -48,6 +49,7 @@ export interface TaskScheduleDefinition { /** * The amount of time that should pass between task invocation starts. * Essentially, this equals roughly how often you want the task to run. + * The system does its best to avoid overlapping invocations. * * This is a best effort value; under some circumstances there can be * deviations. For example, if the task runtime is longer than the frequency @@ -55,11 +57,24 @@ export interface TaskScheduleDefinition { * invocation of this task will be delayed until after the previous one * finishes. * - * The system does its best to avoid overlapping invocations. + * This value can be a crontab style string (see below), or an ISO period + * string (e.g. 'PT1M'). * * This is a required field. + * + * Cron expressions help: + * + * ┌────────────── second (optional) + * │ ┌──────────── minute + * │ │ ┌────────── hour + * │ │ │ ┌──────── day of month + * │ │ │ │ ┌────── month + * │ │ │ │ │ ┌──── day of week + * │ │ │ │ │ │ + * │ │ │ │ │ │ + * * * * * * * */ - frequency: Duration; + frequency: string | Duration; /** * The amount of time that should pass before the first invocation happens. @@ -68,7 +83,7 @@ export interface TaskScheduleDefinition { * compute jobs. * * If no value is given for this field then the first invocation will happen - * as soon as possible. + * as soon as possible according to the cadence. */ initialDelay?: Duration; } @@ -150,7 +165,21 @@ export interface PluginTaskScheduler { function isValidOptionalDurationString(d: string | undefined): boolean { try { - return !d || Duration.fromISO(d).isValid === true; + return !d || Duration.fromISO(d).isValid; + } catch { + return false; + } +} + +function isValidCronFormat(c: string | undefined): boolean { + try { + if (!c) { + return false; + } + // parse cron format to ensure it's a valid format. + // eslint-disable-next-line no-new + new CronTime(c); + return true; } catch { return false; } @@ -161,16 +190,46 @@ export const taskSettingsV1Schema = z.object({ initialDelayDuration: z .string() .optional() - .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), recurringAtMostEveryDuration: z .string() - .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), - timeoutAfterDuration: z - .string() - .refine(isValidOptionalDurationString, { message: 'Invalid duration' }), + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), }); /** * The properties that control a scheduled task (version 1). */ export type TaskSettingsV1 = z.infer; + +export const taskSettingsV2Schema = z.object({ + version: z.literal(2), + cadence: z + .string() + .refine(isValidCronFormat, { message: 'Invalid cron' }) + .or( + z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + ), + timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), + initialDelayDuration: z + .string() + .optional() + .refine(isValidOptionalDurationString, { + message: 'Invalid duration, expecting ISO Period', + }), +}); + +/** + * The properties that control a scheduled task (version 2). + */ +export type TaskSettingsV2 = z.infer; diff --git a/yarn.lock b/yarn.lock index 80cd4d0951..bdab89c082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5653,6 +5653,14 @@ resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/cron@^1.7.3": + version "1.7.3" + resolved "https://registry.npmjs.org/@types/cron/-/cron-1.7.3.tgz#993db7d54646f61128c851607b64ba4495deae93" + integrity sha512-iPmUXyIJG1Js+ldPYhOQcYU3kCAQ2FWrSkm1FJPoii2eYSn6wEW6onPukNTT0bfiflexNSRPl6KWmAIqS+36YA== + dependencies: + "@types/node" "*" + moment ">=2.14.0" + "@types/d3-color@*": version "3.0.2" resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz#53f2d6325f66ee79afd707c05ac849e8ae0edbb0" @@ -9850,6 +9858,13 @@ create-require@^1.1.0: resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@^1.8.2: + version "1.8.2" + resolved "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz#4ac5e3c55ba8c163d84f3407bde94632da8370ce" + integrity sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg== + dependencies: + moment-timezone "^0.5.x" + cronstrue@^1.122.0: version "1.125.0" resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.125.0.tgz#8030816d033d00caade9b2a9f9b71e69175bcf42" @@ -18041,14 +18056,14 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment-timezone@^0.5.31: - version "0.5.33" - resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c" - integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w== +moment-timezone@^0.5.31, moment-timezone@^0.5.x: + version "0.5.34" + resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" + integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg== dependencies: moment ">= 2.9.0" -"moment@>= 2.9.0", moment@^2.27.0, moment@^2.29.1: +"moment@>= 2.9.0", moment@>=2.14.0, moment@^2.27.0, moment@^2.29.1: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==