mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 07:25:15 +00:00
Feat(plugin workflow): schedule trigger (#438)
* feat(plugin-workflow): add schedule type trigger * feat(plugin-workflow): add collection mode for schedule trigger * feat(plugin-workflow): add ui for schedule trigger configuration * fix(plugin-workflow): fix test case * fix(plugin-workflow): fix trigger for sqlite
This commit is contained in:
parent
8f70535217
commit
373c2b9a2d
@ -432,6 +432,34 @@ export default {
|
||||
'Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
|
||||
'Only triggers when match conditions': '满足以下条件才触发',
|
||||
|
||||
'Schedule event': '定时任务',
|
||||
'Trigger mode': '触发模式',
|
||||
'Based on certain date': '自定义时间',
|
||||
'Based on date field of collection': '根据数据表时间字段',
|
||||
'Starts on': '开始于',
|
||||
'Ends on': '结束于',
|
||||
'Exactly at': '当时',
|
||||
'Repeat mode': '重复模式',
|
||||
'Repeat limit': '重复次数',
|
||||
'No limit': '不限',
|
||||
'Seconds': '秒',
|
||||
'Minutes': '分钟',
|
||||
'Hours': '小时',
|
||||
'Days': '天',
|
||||
'Months': '月',
|
||||
|
||||
'No repeat': '不重复',
|
||||
'Every': '每',
|
||||
|
||||
'By minute': '按分钟',
|
||||
'By hour': '按小时',
|
||||
'By date': '按日(月)',
|
||||
'By month': '按月',
|
||||
'By day of week': '按天(周)',
|
||||
|
||||
'By field': '数据表字段',
|
||||
'By custom date': '自定义时间',
|
||||
|
||||
'End': '结束',
|
||||
|
||||
'Trigger context': '触发数据',
|
||||
|
@ -26,7 +26,7 @@ const FieldsSelect = observer((props) => {
|
||||
&& (field.uiSchema ? !field.uiSchema['x-read-pretty'] : true)
|
||||
))
|
||||
.map(field => (
|
||||
<Select.Option value={field.name}>{compile(field.uiSchema?.title)}</Select.Option>
|
||||
<Select.Option key={field.name} value={field.name}>{compile(field.uiSchema?.title)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
@ -8,6 +8,7 @@ import { message, Tag } from "antd";
|
||||
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRecord, useRequest, useResourceActionContext } from '../../';
|
||||
import collection from './collection';
|
||||
import { nodeCardClass, nodeMetaClass } from "../style";
|
||||
import schedule from "./schedule";
|
||||
|
||||
|
||||
function useUpdateConfigAction() {
|
||||
@ -50,6 +51,7 @@ export interface Trigger {
|
||||
export const triggers = new Registry<Trigger>();
|
||||
|
||||
triggers.register(collection.type, collection);
|
||||
triggers.register(schedule.type, schedule);
|
||||
|
||||
export const TriggerConfig = () => {
|
||||
const { t } = useTranslation();
|
||||
|
422
packages/core/client/src/workflow/triggers/schedule.tsx
Normal file
422
packages/core/client/src/workflow/triggers/schedule.tsx
Normal file
@ -0,0 +1,422 @@
|
||||
import React, { useState } from 'react';
|
||||
import { InputNumber, Select } from 'antd';
|
||||
import { observer, useForm, useFormEffects } from '@formily/react';
|
||||
|
||||
import { useCollectionDataSource, useCollectionManager } from '../../collection-manager';
|
||||
import { SchemaComponent, useCompile, DatePicker } from '../../schema-component';
|
||||
|
||||
import { useFlowContext } from '../WorkflowCanvas';
|
||||
import { BaseTypeSet } from '../calculators';
|
||||
import { collection } from '../schemas/collection';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
const DateFieldsSelect: React.FC<any> = observer((props) => {
|
||||
const compile = useCompile();
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
const { values } = useForm();
|
||||
const fields = getCollectionFields(values?.config?.collection);
|
||||
|
||||
return (
|
||||
<Select {...props}>
|
||||
{fields
|
||||
.filter(field => (
|
||||
!field.hidden
|
||||
&& (field.uiSchema ? field.type === 'date' : false)
|
||||
))
|
||||
.map(field => (
|
||||
<Select.Option key={field.name} value={field.name}>{compile(field.uiSchema?.title)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
const OnField = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0);
|
||||
|
||||
return (
|
||||
<fieldset className={css`
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
`}>
|
||||
<DateFieldsSelect value={value.field} onChange={field => onChange({ ...value, field })} />
|
||||
{value.field
|
||||
? (
|
||||
<Select value={dir} onChange={(v) => {
|
||||
setDir(v);
|
||||
onChange({ ...value, offset: Math.abs(value.offset) * v });
|
||||
}}>
|
||||
<Select.Option value={0}>{t('Exactly at')}</Select.Option>
|
||||
<Select.Option value={-1}>{t('Before')}</Select.Option>
|
||||
<Select.Option value={1}>{t('After')}</Select.Option>
|
||||
</Select>
|
||||
)
|
||||
: null}
|
||||
{dir
|
||||
? (
|
||||
<>
|
||||
<InputNumber value={Math.abs(value.offset)} onChange={(v) => onChange({ ...value, offset: v * dir })}/>
|
||||
<Select value={value.unit || 86400000} onChange={unit => onChange({ ...value, unit })}>
|
||||
<Select.Option value={86400000}>{t('Days')}</Select.Option>
|
||||
<Select.Option value={3600000}>{t('Hours')}</Select.Option>
|
||||
<Select.Option value={60000}>{t('Minutes')}</Select.Option>
|
||||
<Select.Option value={1000}>{t('Seconds')}</Select.Option>
|
||||
</Select>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function EndsByField({ value, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
const [type, setType] = useState(typeof value === 'object' && !(value instanceof Date) ? 'field' : 'date');
|
||||
return (
|
||||
<fieldset className={css`
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
`}>
|
||||
<Select value={type} onChange={t => {
|
||||
onChange(t === 'field' ? {} : null);
|
||||
setType(t);
|
||||
}}>
|
||||
<Select.Option value={'field'}>{t('By field')}</Select.Option>
|
||||
<Select.Option value={'date'}>{t('By custom date')}</Select.Option>
|
||||
</Select>
|
||||
{type === 'field'
|
||||
? (
|
||||
<OnField value={value} onChange={onChange} />
|
||||
)
|
||||
: (
|
||||
<DatePicker showTime value={value} onChange={onChange} />
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function parseCronRule(cron: string) {
|
||||
if (!cron) {
|
||||
return {
|
||||
mode: 0
|
||||
}
|
||||
}
|
||||
const rules = cron.split(/\s+/).slice(1).map(v => v.split('/'));
|
||||
let index = rules.findIndex(rule => rule[0] === '*');
|
||||
if (index === -1) {
|
||||
return {
|
||||
mode: 0
|
||||
}
|
||||
}
|
||||
// fix days of week
|
||||
if (index === 3 && rules[4][0] === '*') {
|
||||
index = 4;
|
||||
}
|
||||
return {
|
||||
mode: index + 1,
|
||||
step: rules[index][1] ?? 1
|
||||
};
|
||||
}
|
||||
|
||||
const CronUnits = [
|
||||
{ value: 1, option: 'By minute', unitText: 'Minutes' },
|
||||
{ value: 2, option: 'By hour', unitText: 'Hours' },
|
||||
{ value: 3, option: 'By date', unitText: 'Days', conflict: true, startFrom: 1 },
|
||||
{ value: 4, option: 'By month', unitText: 'Months', startFrom: 1 },
|
||||
{ value: 5, option: 'By day of week', unitText: 'Days', conflict: true },
|
||||
];
|
||||
|
||||
function getChangedCron({ mode, step }) {
|
||||
const m = mode - 1;
|
||||
const left = [0, ...Array(m).fill(null).map((_, i) => {
|
||||
if (CronUnits[m].conflict && CronUnits[i].conflict) {
|
||||
return '?';
|
||||
}
|
||||
return i === 3 ? '*' : CronUnits[i].startFrom ?? 0;
|
||||
})].join(' ');
|
||||
const right = Array(5 - mode).fill(null).map((_, i) => {
|
||||
if (CronUnits[m].conflict && CronUnits[mode + i].conflict || mode === 4) {
|
||||
return '?';
|
||||
}
|
||||
return '*';
|
||||
}).join(' ');
|
||||
return `${left} ${!step || step == 1 ? '*' : `*/${step}`}${right ? ` ${right}` : ''}`;
|
||||
}
|
||||
|
||||
const CronField = ({ value = '', onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const cron = parseCronRule(value);
|
||||
const unit = CronUnits[cron.mode - 1];
|
||||
return (
|
||||
<fieldset className={css`
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
`}>
|
||||
<Select
|
||||
value={cron.mode}
|
||||
onChange={v => onChange(v ? getChangedCron({ step: cron.step, mode: v }) : '')}
|
||||
>
|
||||
<Select.Option value={0}>{t('No repeat')}</Select.Option>
|
||||
{CronUnits.map(item => (
|
||||
<Select.Option key={item.value} value={item.value}>{t(item.option)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
{cron.mode
|
||||
? (
|
||||
<InputNumber
|
||||
value={cron.step}
|
||||
onChange={v => onChange(getChangedCron({ step: v, mode: cron.mode }))}
|
||||
min={1}
|
||||
addonBefore={t('Every')}
|
||||
addonAfter={t(unit.unitText)}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const ModeFieldsets = {
|
||||
0: {
|
||||
startsOn: {
|
||||
type: 'datetime',
|
||||
name: 'startsOn',
|
||||
title: '{{t("Starts on")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': {
|
||||
showTime: true
|
||||
},
|
||||
required: true
|
||||
},
|
||||
cron: {
|
||||
type: 'string',
|
||||
name: 'cron',
|
||||
title: '{{t("Repeat mode")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'CronField',
|
||||
'x-reactions': [
|
||||
{
|
||||
target: 'config.endsOn',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{!!$self.value}}',
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 'config.limit',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{!!$self.value}}',
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
endsOn: {
|
||||
type: 'datetime',
|
||||
name: 'endsOn',
|
||||
title: '{{t("Ends on")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': {
|
||||
showTime: true
|
||||
}
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
name: 'limit',
|
||||
title: '{{t("Repeat limit")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': {
|
||||
placeholder: '{{t("No limit")}}',
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
1: {
|
||||
collection: {
|
||||
...collection,
|
||||
'x-reactions': [
|
||||
...collection['x-reactions'],
|
||||
{
|
||||
// only full path works
|
||||
target: 'config.startsOn',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{!!$self.value}}',
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
startsOn: {
|
||||
type: 'object',
|
||||
title: '{{t("Starts on")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'OnField',
|
||||
required: true
|
||||
},
|
||||
cron: {
|
||||
type: 'string',
|
||||
name: 'cron',
|
||||
title: '{{t("Repeat mode")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'CronField',
|
||||
'x-reactions': [
|
||||
{
|
||||
target: 'config.endsOn',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{!!$self.value}}',
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 'config.limit',
|
||||
fulfill: {
|
||||
state: {
|
||||
visible: '{{!!$self.value}}',
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
endsOn: {
|
||||
type: 'object',
|
||||
title: '{{t("Ends on")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'EndsByField'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
name: 'limit',
|
||||
title: '{{t("Repeat limit")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'InputNumber',
|
||||
'x-component-props': {
|
||||
placeholder: '{{t("No limit")}}',
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ScheduleConfig = () => {
|
||||
const { values = {}, clearFormGraph } = useForm();
|
||||
const { config = {} } = values;
|
||||
const [mode, setMode] = useState(config.mode);
|
||||
useFormEffects(() => {
|
||||
onFieldValueChange('config.mode', (field) => {
|
||||
setMode(field.value);
|
||||
clearFormGraph('config.collection');
|
||||
clearFormGraph('config.startsOn');
|
||||
clearFormGraph('config.cron');
|
||||
clearFormGraph('config.endsOn');
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'number',
|
||||
title: '{{t("Trigger mode")}}',
|
||||
name: 'mode',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
'x-component-props': {
|
||||
options: [
|
||||
{ value: 0, label: '{{t("Based on certain date")}}' },
|
||||
{ value: 1, label: '{{t("Based on date field of collection")}}' },
|
||||
]
|
||||
},
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[`mode-${mode}`]: {
|
||||
type: 'void',
|
||||
'x-component': 'fieldset',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
.ant-select{
|
||||
width: auto;
|
||||
min-width: 4em;
|
||||
}
|
||||
|
||||
.ant-input-number{
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
.ant-picker{
|
||||
width: auto;
|
||||
}
|
||||
`
|
||||
},
|
||||
properties: ModeFieldsets[mode]
|
||||
}
|
||||
}
|
||||
}}
|
||||
components={{
|
||||
DateFieldsSelect,
|
||||
OnField,
|
||||
CronField,
|
||||
EndsByField
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: '{{t("Schedule event")}}',
|
||||
type: 'schedule',
|
||||
fieldset: {
|
||||
config: {
|
||||
type: 'object',
|
||||
name: 'config',
|
||||
'x-component': 'ScheduleConfig',
|
||||
'x-component-props': {
|
||||
}
|
||||
}
|
||||
},
|
||||
scope: {
|
||||
useCollectionDataSource
|
||||
},
|
||||
components: {
|
||||
// FieldsSelect
|
||||
ScheduleConfig
|
||||
},
|
||||
getter({ type, options, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
const { collections = [] } = useCollectionManager();
|
||||
const { workflow } = useFlowContext();
|
||||
const collection = collections.find(item => item.name === workflow.config.collection) ?? { fields: [] };
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={t('Fields')}
|
||||
value={options?.path?.replace(/^data\./, '')}
|
||||
onChange={(path) => {
|
||||
onChange({ type, options: { ...options, path: `data.${path}` } });
|
||||
}}
|
||||
>
|
||||
{collection.fields
|
||||
.filter(field => BaseTypeSet.has(field?.uiSchema?.type))
|
||||
.map(field => (
|
||||
<Select.Option key={field.name} value={field.name}>{compile(field.uiSchema.title)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
};
|
@ -14,7 +14,9 @@
|
||||
"@nocobase/database": "0.7.0-alpha.82",
|
||||
"@nocobase/server": "0.7.0-alpha.82",
|
||||
"@nocobase/utils": "0.7.0-alpha.82",
|
||||
"json-templates": "^4.2.0"
|
||||
"json-templates": "^4.2.0",
|
||||
"cron-parser": "4.4.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nocobase/test": "0.7.0-alpha.82"
|
||||
|
@ -0,0 +1,267 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import Database from '@nocobase/database';
|
||||
import { getApp, sleep } from '..';
|
||||
import { EXECUTION_STATUS } from '../../constants';
|
||||
|
||||
|
||||
|
||||
describe('workflow > triggers > schedule', () => {
|
||||
let app: Application;
|
||||
let db: Database;
|
||||
let PostRepo;
|
||||
let WorkflowModel;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp();
|
||||
|
||||
db = app.db;
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
PostRepo = db.getCollection('posts').repository;
|
||||
});
|
||||
|
||||
afterEach(() => app.stop());
|
||||
|
||||
describe('constant mode', () => {
|
||||
it('no cron configurated', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0
|
||||
}
|
||||
});
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('on every 2 seconds', async () => {
|
||||
const now = new Date();
|
||||
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
||||
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
|
||||
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0,
|
||||
cron: '*/2 * * * * *',
|
||||
}
|
||||
});
|
||||
|
||||
await sleep(4000);
|
||||
// sleep 1.5s at 2s trigger 1st time
|
||||
// sleep 3.5s at 4s trigger 2nd time
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('on every 2 seconds and limit once', async () => {
|
||||
const now = new Date();
|
||||
// NOTE: align to even(0, 2, ...) + 0.5 seconds to start
|
||||
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
|
||||
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0,
|
||||
cron: '*/2 * * * * *',
|
||||
limit: 1
|
||||
}
|
||||
});
|
||||
|
||||
await sleep(5000);
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('on certain second', async () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() + 3);
|
||||
now.setMilliseconds(0);
|
||||
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0,
|
||||
cron: `${now.getSeconds()} * * * * *`,
|
||||
}
|
||||
});
|
||||
|
||||
await sleep(5000);
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(1);
|
||||
expect(executions[0].context.date).toBe(now.toISOString());
|
||||
});
|
||||
|
||||
it('multiple workflows trigger at same time', async () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() + 2);
|
||||
now.setMilliseconds(0);
|
||||
|
||||
const w1 = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0,
|
||||
cron: `${now.getSeconds()} * * * * *`,
|
||||
}
|
||||
});
|
||||
|
||||
const w2 = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 0,
|
||||
cron: `${now.getSeconds()} * * * * *`,
|
||||
}
|
||||
});
|
||||
|
||||
await sleep(3000);
|
||||
await WorkflowModel.update({ enabled: false }, { where: { enabled: true } });
|
||||
|
||||
const [e1] = await w1.getExecutions();
|
||||
expect(e1).toBeDefined();
|
||||
expect(e1.context.date).toBe(now.toISOString());
|
||||
|
||||
const [e2] = await w2.getExecutions();
|
||||
expect(e2).toBeDefined();
|
||||
expect(e2.context.date).toBe(now.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('collection field mode', () => {
|
||||
it('starts on post.createdAt with offset', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
startsOn: {
|
||||
field: 'createdAt',
|
||||
offset: 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' }});
|
||||
|
||||
await sleep(1000);
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(0);
|
||||
|
||||
await sleep(1000);
|
||||
const [execution] = await workflow.getExecutions();
|
||||
expect(execution).toBeDefined();
|
||||
expect(execution.context.data.id).toBe(post.id);
|
||||
|
||||
const triggerTime = new Date(post.createdAt.getTime() + 2000);
|
||||
triggerTime.setMilliseconds(0);
|
||||
expect(execution.context.date).toBe(triggerTime.toISOString());
|
||||
});
|
||||
|
||||
it('starts on post.createdAt and cron', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
startsOn: {
|
||||
field: 'createdAt'
|
||||
},
|
||||
cron: '*/2 * * * * *'
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
|
||||
const startTime = new Date();
|
||||
startTime.setMilliseconds(500);
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' }});
|
||||
|
||||
await sleep(5000);
|
||||
// sleep 1.5s at 2s trigger 1st time
|
||||
// sleep 3.5s at 4s trigger 2nd time
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(2);
|
||||
const d1 = Date.parse(executions[0].context.date);
|
||||
expect(d1 - 1500).toBe(startTime.getTime());
|
||||
const d2 = Date.parse(executions[1].context.date);
|
||||
expect(d2 - 3500).toBe(startTime.getTime());
|
||||
});
|
||||
|
||||
it('starts on post.createdAt and cron with endsOn at certain time', async () => {
|
||||
const now = new Date();
|
||||
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
|
||||
const startTime = new Date();
|
||||
startTime.setMilliseconds(500);
|
||||
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
startsOn: {
|
||||
field: 'createdAt'
|
||||
},
|
||||
cron: '*/2 * * * * *',
|
||||
endsOn: new Date(startTime.getTime() + 2500).toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' }});
|
||||
console.log(startTime);
|
||||
|
||||
await sleep(5000);
|
||||
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(1);
|
||||
const d1 = Date.parse(executions[0].context.date);
|
||||
expect(d1 - 1500).toBe(startTime.getTime());
|
||||
});
|
||||
|
||||
it('starts on post.createdAt and cron with endsOn by offset', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
enabled: true,
|
||||
type: 'schedule',
|
||||
config: {
|
||||
mode: 1,
|
||||
collection: 'posts',
|
||||
startsOn: {
|
||||
field: 'createdAt'
|
||||
},
|
||||
cron: '*/2 * * * * *',
|
||||
endsOn: {
|
||||
field: 'createdAt',
|
||||
offset: 3
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
|
||||
const startTime = new Date();
|
||||
startTime.setMilliseconds(500);
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' }});
|
||||
|
||||
await sleep(5000);
|
||||
const executions = await workflow.getExecutions();
|
||||
expect(executions.length).toBe(1);
|
||||
const d1 = Date.parse(executions[0].context.date);
|
||||
expect(d1 - 1500).toBe(startTime.getTime());
|
||||
});
|
||||
});
|
||||
});
|
@ -47,8 +47,7 @@ export default class extends Plugin {
|
||||
this.toggle(workflow);
|
||||
});
|
||||
|
||||
db.on('workflows.afterCreate', (model: WorkflowModel) => this.toggle(model));
|
||||
db.on('workflows.afterUpdate', (model: WorkflowModel) => this.toggle(model));
|
||||
db.on('workflows.afterSave', (model: WorkflowModel) => this.toggle(model));
|
||||
db.on('workflows.afterDestroy', (model: WorkflowModel) => this.toggle(model, false));
|
||||
});
|
||||
|
||||
@ -56,13 +55,18 @@ export default class extends Plugin {
|
||||
// this.app.on('db.init', async () => {});
|
||||
}
|
||||
|
||||
async toggle(workflow: WorkflowModel, enable?: boolean) {
|
||||
toggle(workflow: WorkflowModel, enable?: boolean) {
|
||||
const type = workflow.get('type');
|
||||
const trigger = this.triggers.get(type);
|
||||
if (typeof enable !== 'undefined' ? enable : workflow.get('enabled')) {
|
||||
await trigger.on(workflow);
|
||||
// NOTE: remove previous listener if config updated
|
||||
const prev = workflow.previous();
|
||||
if (prev.config) {
|
||||
trigger.off({ ...workflow.get(), ...prev });
|
||||
}
|
||||
trigger.on(workflow);
|
||||
} else {
|
||||
await trigger.off(workflow);
|
||||
trigger.off(workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,12 +52,6 @@ export default class CollectionTrigger implements Trigger {
|
||||
}
|
||||
|
||||
on(workflow: WorkflowModel) {
|
||||
// NOTE: remove previous listener if config updated
|
||||
const prev = workflow.previous();
|
||||
if (prev.config) {
|
||||
this.off({ ...workflow.get(), ...prev });
|
||||
}
|
||||
|
||||
const { collection, mode } = workflow.config;
|
||||
const Collection = this.db.getCollection(collection);
|
||||
if (!Collection) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import WorkflowModel from '../models/Workflow';
|
||||
import Collection from './collection';
|
||||
// import Schedule from './schedule';
|
||||
import Schedule from './schedule';
|
||||
|
||||
export interface Trigger {
|
||||
on(workflow: WorkflowModel): void;
|
||||
@ -10,5 +10,5 @@ export interface Trigger {
|
||||
export default function(plugin) {
|
||||
const { triggers } = plugin;
|
||||
triggers.register('collection', new Collection(plugin));
|
||||
// triggers.register('schedule', new Schedule(plugin));
|
||||
triggers.register('schedule', new Schedule(plugin));
|
||||
}
|
||||
|
466
packages/plugins/workflow/src/triggers/schedule.ts
Normal file
466
packages/plugins/workflow/src/triggers/schedule.ts
Normal file
@ -0,0 +1,466 @@
|
||||
import parser from 'cron-parser';
|
||||
import { merge } from 'lodash';
|
||||
import { Trigger } from '.';
|
||||
|
||||
export type ScheduleOnField = string | {
|
||||
field: string;
|
||||
// in seconds
|
||||
offset?: number;
|
||||
unit?: 1000 | 60000 | 3600000 | 86400000;
|
||||
};
|
||||
export interface ScheduleTriggerConfig {
|
||||
// trigger mode
|
||||
mode: number;
|
||||
// how to repeat
|
||||
cron?: string;
|
||||
// limit of repeat times
|
||||
limit?: number;
|
||||
|
||||
startsOn?: ScheduleOnField;
|
||||
endsOn?: ScheduleOnField;
|
||||
}
|
||||
|
||||
export const SCHEDULE_MODE = {
|
||||
CONSTANT: 0,
|
||||
COLLECTION_FIELD: 1
|
||||
} as const;
|
||||
|
||||
interface ScheduleMode {
|
||||
on?(this: ScheduleTrigger, workflow): void;
|
||||
off?(this: ScheduleTrigger, workflow): void;
|
||||
shouldCache(this: ScheduleTrigger, workflow, now: Date): Promise<boolean> | boolean;
|
||||
trigger(this: ScheduleTrigger, workflow, now: Date): any;
|
||||
}
|
||||
|
||||
const ScheduleModes = new Map<number, ScheduleMode>();
|
||||
|
||||
ScheduleModes.set(SCHEDULE_MODE.CONSTANT, {
|
||||
shouldCache(workflow, now) {
|
||||
const { startsOn, endsOn } = workflow.config;
|
||||
const timestamp = now.getTime();
|
||||
if (startsOn) {
|
||||
const startTime = Date.parse(startsOn);
|
||||
if (!startTime || (startTime > timestamp + this.cacheCycle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (endsOn) {
|
||||
const endTime = Date.parse(endsOn);
|
||||
if (!endTime || endTime <= timestamp) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
trigger(workflow, date) {
|
||||
return workflow.trigger({ date });
|
||||
}
|
||||
});
|
||||
|
||||
function getDateRangeFilter(on: ScheduleOnField, now: Date, dir: number) {
|
||||
const timestamp = now.getTime();
|
||||
const op = dir < 0 ? '$lt' : '$gte';
|
||||
switch (typeof on) {
|
||||
case 'string':
|
||||
const time = Date.parse(on);
|
||||
if (!time || (dir < 0 ? (timestamp < time) : (time <= timestamp))) {
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
case 'object':
|
||||
const { field, offset = 0, unit = 1000 } = on;
|
||||
return { [field]: { [op]: new Date(timestamp + offset * unit * dir) } };
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getDataOptionTime(data, on, now: Date, dir = 1) {
|
||||
switch (typeof on) {
|
||||
case 'string':
|
||||
const time = Date.parse(on);
|
||||
return time ? time : null;
|
||||
case 'object':
|
||||
const { field, offset = 0, unit = 1000 } = on;
|
||||
return data[field] ? data[field].getTime() - offset * unit * dir : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getHookId(workflow, type) {
|
||||
return `${type}#${workflow.id}`;
|
||||
}
|
||||
|
||||
ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
|
||||
on(workflow) {
|
||||
const { collection, startsOn, endsOn, cron } = workflow.config;
|
||||
const event = `${collection}.afterSave`;
|
||||
const name = getHookId(workflow, event);
|
||||
if (!this.events.has(name)) {
|
||||
// NOTE: toggle cache depends on new date
|
||||
const listener = (data, options) => {
|
||||
// check if saved collection data in cache cycle
|
||||
// in: add workflow to cache
|
||||
// out: 1. do nothing because if any other data in
|
||||
// 2. another way is always check all data to match cycle
|
||||
// by calling: inspect(workflow)
|
||||
// this may lead to performance issues
|
||||
// so we can only check single row and only set in if true
|
||||
// how to check?
|
||||
// * startsOn only : startsOn in cycle
|
||||
// * endsOn only : invalid
|
||||
// * cron only : invalid
|
||||
// * startsOn and endsOn: equal to only startsOn
|
||||
// * startsOn and cron : startsOn in cycle and cron in cycle
|
||||
// * endsOn and cron : invalid
|
||||
// * all : all rules effect
|
||||
// * none : invalid
|
||||
// this means, startsOn and cron should be present at least one
|
||||
// and no startsOn equals run on cron, and could ends on endsOn,
|
||||
// this will be a little wired, only means the end date should use collection field.
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
const timestamp = now.getTime();
|
||||
const startTime = getDataOptionTime(data, startsOn, now);
|
||||
const endTime = getDataOptionTime(data, endsOn, now, -1);
|
||||
if (!startTime && !cron) {
|
||||
return;
|
||||
}
|
||||
if (startTime && startTime > timestamp + this.cacheCycle) {
|
||||
return;
|
||||
}
|
||||
if (endTime && endTime <= timestamp) {
|
||||
return;
|
||||
}
|
||||
if (!cronInCycle.call(this, workflow, now)) {
|
||||
return;
|
||||
}
|
||||
console.log('set cache', now);
|
||||
|
||||
this.setCache(workflow);
|
||||
};
|
||||
this.events.set(name, listener);
|
||||
this.db.on(`${collection}.afterSave`, listener);
|
||||
}
|
||||
},
|
||||
|
||||
off(workflow) {
|
||||
const { collection } = workflow.config;
|
||||
const event = `${collection}.afterSave`;
|
||||
const name = getHookId(workflow, event);
|
||||
if (this.events.has(name)) {
|
||||
const listener = this.events.get(name);
|
||||
this.events.delete(name);
|
||||
this.db.off(`${collection}.afterSave`, listener);
|
||||
}
|
||||
},
|
||||
|
||||
async shouldCache(workflow, now) {
|
||||
const { startsOn, endsOn, collection } = workflow.config;
|
||||
const starts = getDateRangeFilter(startsOn, now, -1);
|
||||
if (!starts) {
|
||||
return false;
|
||||
}
|
||||
const ends = getDateRangeFilter(endsOn, now, 1);
|
||||
if (!ends) {
|
||||
return false;
|
||||
}
|
||||
const filter = merge(starts, ends);
|
||||
// if neither startsOn nor endsOn is provided
|
||||
if (!Object.keys(filter).length) {
|
||||
// consider as invalid
|
||||
return false;
|
||||
}
|
||||
|
||||
const repo = this.db.getCollection(collection).repository;
|
||||
const count = await repo.count({
|
||||
filter
|
||||
});
|
||||
|
||||
return Boolean(count);
|
||||
},
|
||||
|
||||
async trigger(workflow, date) {
|
||||
const {
|
||||
collection,
|
||||
startsOn,
|
||||
endsOn,
|
||||
cron
|
||||
} = workflow.config;
|
||||
|
||||
if (typeof startsOn !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = date.getTime();
|
||||
const startTimestamp = timestamp - (startsOn.offset ?? 0) * (startsOn.unit ?? 1000);
|
||||
|
||||
let filter
|
||||
if (!cron) {
|
||||
// startsOn exactly equal to now in 1s
|
||||
filter = {
|
||||
[startsOn.field]: {
|
||||
$gte: new Date(startTimestamp),
|
||||
$lt: new Date(startTimestamp + 1000)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// startsOn not after now
|
||||
filter = {
|
||||
[startsOn.field]: {
|
||||
$lt: new Date(startTimestamp)
|
||||
}
|
||||
};
|
||||
|
||||
switch (typeof endsOn) {
|
||||
case 'string':
|
||||
const endTime = Date.parse(endsOn);
|
||||
if (!endTime || endTime <= timestamp) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'object':
|
||||
filter[endsOn.field] = {
|
||||
$gte: new Date(timestamp - (endsOn.offset ?? 0) * (endsOn.unit ?? 1000) + 1000)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const repo = this.db.getCollection(collection).repository;
|
||||
const instances = await repo.find({
|
||||
filter
|
||||
});
|
||||
console.log('trigger at', date);
|
||||
|
||||
instances.forEach(item => {
|
||||
workflow.trigger({
|
||||
date,
|
||||
data: item.get()
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function cronInCycle(this: ScheduleTrigger, workflow, now: Date): boolean {
|
||||
const { cron } = workflow.config;
|
||||
// no cron means no need to rerun
|
||||
// but if in current cycle, should be put in cache
|
||||
// no cron but in current cycle means startsOn or endsOn has been configured
|
||||
// so we need to more info to determine if necessary config items
|
||||
if (!cron) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentDate = new Date(now);
|
||||
currentDate.setMilliseconds(-1);
|
||||
const timestamp = now.getTime();
|
||||
const interval = parser.parseExpression(cron, { currentDate });
|
||||
let next = interval.next();
|
||||
|
||||
// NOTE: cache all workflows will be matched in current cycle
|
||||
if (next.getTime() - timestamp <= this.cacheCycle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
export default class ScheduleTrigger implements Trigger {
|
||||
static CacheRules = [
|
||||
// ({ enabled }) => enabled,
|
||||
({ config, executed }) => config.limit ? executed < config.limit : true,
|
||||
({ config }) => ['cron', 'startsOn'].some(key => config[key]),
|
||||
cronInCycle,
|
||||
function(workflow, now) {
|
||||
const { mode } = workflow.config;
|
||||
const modeHandlers = ScheduleModes.get(mode);
|
||||
return modeHandlers.shouldCache.call(this, workflow, now);
|
||||
}
|
||||
];
|
||||
|
||||
static TriggerRules = [
|
||||
({ config, executed }) => config.limit ? executed < config.limit : true,
|
||||
({ config }) => ['cron', 'startsOn'].some(key => config[key]),
|
||||
function (workflow, now) {
|
||||
const { cron } = workflow.config;
|
||||
if (!cron) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentDate = new Date(now);
|
||||
currentDate.setMilliseconds(-1);
|
||||
const timestamp = now.getTime();
|
||||
const interval = parser.parseExpression(cron, { currentDate });
|
||||
let next = interval.next();
|
||||
|
||||
if (next.getTime() === timestamp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
];
|
||||
|
||||
public readonly db;
|
||||
|
||||
events = new Map();
|
||||
|
||||
private timer: NodeJS.Timeout = null;
|
||||
|
||||
private cache: Map<number | string, any> = new Map();
|
||||
|
||||
// running interval, default to 1s
|
||||
interval: number = 1_000;
|
||||
// caching workflows in range, default to 1min
|
||||
cacheCycle: number = 60_000;
|
||||
|
||||
constructor({ app }) {
|
||||
this.db = app.db;
|
||||
|
||||
app.on('beforeStop', () => {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.timer) {
|
||||
const now = new Date();
|
||||
|
||||
// NOTE: assign to this.timer to avoid duplicated initialization
|
||||
this.timer = setTimeout(
|
||||
() => {
|
||||
this.timer = setInterval(this.run, this.interval);
|
||||
|
||||
// initially trigger
|
||||
// this.onTick(now);
|
||||
|
||||
},
|
||||
// NOTE:
|
||||
// try to align to system time on each second starts,
|
||||
// after at least 1 second initialized for anything to get ready.
|
||||
// so jobs in 2 seconds will be missed at first start.
|
||||
1_000 - now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
run = () => {
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
|
||||
// NOTE: trigger `onTick` for high interval jobs which are cached in last 1 min
|
||||
this.onTick(now);
|
||||
|
||||
// NOTE: reload when second match cache cycle
|
||||
if (!(now.getTime() % this.cacheCycle)) {
|
||||
this.reload();
|
||||
}
|
||||
};
|
||||
|
||||
async onTick(now) {
|
||||
// NOTE: trigger workflows in sequence when sqlite due to only one transaction
|
||||
const isSqlite = this.db.options.dialect === 'sqlite';
|
||||
|
||||
return Array.from(this.cache.values()).reduce((prev, workflow) => {
|
||||
if (!this.shouldTrigger(workflow, now)) {
|
||||
return prev;
|
||||
}
|
||||
if (isSqlite) {
|
||||
return prev.then(() => this.trigger(workflow, now));
|
||||
}
|
||||
this.trigger(workflow, now);
|
||||
return null;
|
||||
}, isSqlite ? Promise.resolve() : null);
|
||||
}
|
||||
|
||||
async reload() {
|
||||
const WorkflowModel = this.db.getCollection('workflows').model;
|
||||
const workflows = await WorkflowModel.findAll({
|
||||
where: { enabled: true, type: 'schedule' },
|
||||
include: [
|
||||
{
|
||||
association: 'executions',
|
||||
attributes: ['id', 'createdAt'],
|
||||
seperate: true,
|
||||
limit: 1,
|
||||
order: [['createdAt', 'DESC']],
|
||||
}
|
||||
],
|
||||
group: ['id'],
|
||||
});
|
||||
|
||||
// NOTE: clear cached jobs in last cycle
|
||||
this.cache = new Map();
|
||||
|
||||
this.inspect(workflows);
|
||||
}
|
||||
|
||||
inspect(workflows) {
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
|
||||
workflows.forEach(async (workflow) => {
|
||||
const should = await this.shouldCache(workflow, now);
|
||||
|
||||
this.setCache(workflow, !should);
|
||||
});
|
||||
}
|
||||
|
||||
setCache(workflow, out = false) {
|
||||
out
|
||||
? this.cache.delete(workflow.id)
|
||||
: this.cache.set(workflow.id, workflow);
|
||||
}
|
||||
|
||||
async shouldCache(workflow, now) {
|
||||
for await (const rule of (<typeof ScheduleTrigger>this.constructor).CacheRules) {
|
||||
if (!(await rule.call(this, workflow, now))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldTrigger(workflow, now): boolean {
|
||||
for (const rule of (<typeof ScheduleTrigger>this.constructor).TriggerRules) {
|
||||
if (!rule.call(this, workflow, now)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async trigger(workflow, date: Date) {
|
||||
const { mode } = workflow.config;
|
||||
const modeHandlers = ScheduleModes.get(mode);
|
||||
return modeHandlers.trigger.call(this, workflow, date);
|
||||
}
|
||||
|
||||
on(workflow) {
|
||||
// NOTE: lazy initialization
|
||||
this.init();
|
||||
|
||||
const { mode } = workflow.config;
|
||||
const modeHandlers = ScheduleModes.get(mode);
|
||||
if (modeHandlers && modeHandlers.on) {
|
||||
modeHandlers.on.call(this, workflow);
|
||||
}
|
||||
this.inspect([workflow]);
|
||||
}
|
||||
off(workflow) {
|
||||
const { mode } = workflow.config;
|
||||
const modeHandlers = ScheduleModes.get(mode);
|
||||
if (modeHandlers && modeHandlers.off) {
|
||||
modeHandlers.off.call(this, workflow);
|
||||
}
|
||||
this.cache.delete(workflow.id);
|
||||
}
|
||||
}
|
12
yarn.lock
12
yarn.lock
@ -8222,6 +8222,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-parser@4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.4.0.tgz#829d67f9e68eb52fa051e62de0418909f05db983"
|
||||
integrity sha512-TrE5Un4rtJaKgmzPewh67yrER5uKM0qI9hGLDBfWb8GGRe9pn/SDkhVrdHa4z7h0SeyeNxnQnogws/H+AQANQA==
|
||||
dependencies:
|
||||
luxon "^1.28.0"
|
||||
|
||||
croner@~4.1.92:
|
||||
version "4.1.97"
|
||||
resolved "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766"
|
||||
@ -14184,6 +14191,11 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
luxon@^1.28.0:
|
||||
version "1.28.0"
|
||||
resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf"
|
||||
integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==
|
||||
|
||||
lz-string@^1.4.4:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
|
||||
|
Loading…
Reference in New Issue
Block a user