Просмотр исходного кода

Merge branch 'feat/pt-dev-1' into feat/pt-dev-master

Taichi Masuyama 4 лет назад
Родитель
Сommit
9313e7c5ff

+ 9 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "Submitting a bug report",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -106,6 +106,7 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
+  "V5 Page Migration": "V5 Page Migration",
   "Site URL settings": "Site URL settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",

+ 9 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "バグを報告する",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "V5 にアップグレード",
+    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "アップグレードを開始",
+    "successfully_started": "正常にマイグレーションが開始されました",
+    "already_upgraded": "アップグレードは既に完了しています"
+  },
   "app_setting": {
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -106,6 +106,7 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
+  "V5 Page Migration": "V5 ページマイグレーション",
   "Site URL settings": "サイトURL設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",

+ 9 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "提交一个错误报告",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -114,6 +114,7 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "V5 Page Migration",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",

+ 19 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -22,6 +22,7 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       fileUpload: '',
 
+      isV5Compatible: null,
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
@@ -81,6 +82,7 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
+      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
@@ -160,6 +162,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fileUpload });
   }
 
+  /**
+   * Change site url
+   */
+  changeIsV5Compatible(isV5Compatible) {
+    this.setState({ isV5Compatible });
+  }
+
   /**
    * Change site url
    */
@@ -440,5 +449,15 @@ export default class AdminAppContainer extends Container {
     return pluginSettingParams;
   }
 
+  /**
+   * Start v5 page migration
+   * @memberOf AdminAppContainer
+   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
+   */
+  async v5PageMigrationHandler(action) {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+    const { isV5Compatible } = response.data;
+    return { isV5Compatible };
+  }
 
 }

+ 2 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       yarnVersion: '',
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
+      isV5Compatible: null,
     };
 
   }
@@ -63,6 +64,7 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
       }));
     }
     catch (err) {

+ 18 - 0
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -10,6 +10,7 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
@@ -33,9 +34,26 @@ class AdminHome extends React.Component {
 
   render() {
     const { t, adminHomeContainer } = this.props;
+    const { isV5Compatible } = adminHomeContainer.state;
+
+    let alertStyle = 'alert-info';
+    if (isV5Compatible == null) alertStyle = 'alert-warning';
 
     return (
       <Fragment>
+        {
+          // not show if true
+          !isV5Compatible
+          && (
+            <div className={`alert ${alertStyle}`}>
+              {t('admin:v5_page_migration.migration_desc')}
+              <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+                <i className="fa fa-link ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+              </a>
+            </div>
+          )
+        }
         <p>
           {t('admin:admin_top.wiki_administrator')}
           <br></br>

+ 25 - 2
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -2,19 +2,36 @@ import React, { Fragment } from 'react';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
+import V5PageMigration from './V5PageMigration';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 class AppSettingsPageContents extends React.Component {
 
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
+    const { isV5Compatible } = adminAppContainer.state;
 
     return (
       <Fragment>
+        {
+          !isV5Compatible
+          && (
+            <div className="row">
+              <div className="col-lg-12">
+                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <V5PageMigration />
+              </div>
+            </div>
+          )
+        }
+
         <div className="row">
           <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
@@ -55,8 +72,14 @@ class AppSettingsPageContents extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
 AppSettingsPageContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(AppSettingsPageContents);
+export default withTranslation()(AppSettingsPageContentsWrapper);

+ 57 - 0
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -0,0 +1,57 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { V5PageMigrationModal } from './V5PageMigrationModal';
+import AdminAppContainer from '../../../client/services/AdminAppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+type Props = {
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+}
+
+const V5PageMigration: FC<Props> = (props: Props) => {
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const { adminAppContainer } = props;
+  const { t } = useTranslation();
+
+  const onConfirm = async() => {
+    setIsV5PageMigrationModalShown(false);
+    try {
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      if (isV5Compatible) {
+
+        return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
+      }
+      toastSuccess(t('admin:v5_page_migration.successfully_started'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <V5PageMigrationModal
+        isModalOpen={isV5PageMigrationModalShown}
+        onConfirm={onConfirm}
+        onCancel={() => setIsV5PageMigrationModalShown(false)}
+      />
+      <p className="card well">
+        {t('admin:v5_page_migration.migration_desc')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>Upgrade to v5</button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default withUnstatedContainers(V5PageMigration, [AdminAppContainer]);

+ 61 - 0
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx

@@ -0,0 +1,61 @@
+import React, { FC } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type V5PageMigrationModalProps = {
+  isModalOpen: boolean
+  onConfirm?: () => Promise<void>;
+  onCancel?: () => void;
+};
+
+export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+  const { t } = useTranslation();
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirm = () => {
+    if (props.onConfirm != null) {
+      props.onConfirm();
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('admin:v5_page_migration.modal_migration_warning')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirm}
+        >
+          {t('admin:v5_page_migration.start_upgrading')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 1 - 0
packages/app/src/server/models/config.ts

@@ -36,6 +36,7 @@ export const generateConfigsForInstalling = (): { [key: string]: any } => {
   config['app:installed'] = true;
   config['app:fileUpload'] = true;
   config['customize:isSavedStatesOfTabChanges'] = false;
+  config['app:isV5Compatible'] = true;
 
   return config;
 };

+ 8 - 1
packages/app/src/server/models/page.js

@@ -37,8 +37,12 @@ const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
 const pageSchema = new mongoose.Schema({
+  parent: {
+    type: ObjectId, ref: 'Page', index: true, default: null,
+  },
+  isEmpty: { type: Boolean, default: false },
   path: {
-    type: String, required: true, index: true, unique: true,
+    type: String, required: true,
   },
   revision: { type: ObjectId, ref: 'Revision' },
   redirectTo: { type: String, index: true },
@@ -67,6 +71,9 @@ const pageSchema = new mongoose.Schema({
 pageSchema.plugin(mongoosePaginate);
 pageSchema.plugin(uniqueValidator);
 
+// TODO: test this after modifying Page.create
+// ensure v4 compatibility using partial index
+pageSchema.index({ path: 1 }, { unique: true, partialFilterExpression: { parent: null } });
 
 /**
  * return an array of ancestors paths that is extracted from specified pagePath

+ 1 - 0
packages/app/src/server/routes/apiv3/admin-home.js

@@ -73,6 +73,7 @@ module.exports = (crowi) => {
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
+      isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
     };
 
     return res.apiv3({ adminHomeParams });

+ 1 - 0
packages/app/src/server/routes/apiv3/app-settings.js

@@ -225,6 +225,7 @@ module.exports = (crowi) => {
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
       isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
+      isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
       isMailerSetup: crowi.mailService.isMailerSetup,

+ 30 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -184,6 +184,9 @@ module.exports = (crowi) => {
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
+    v5PageMigration: [
+      body('action').isString().withMessage('action is required'),
+    ],
   };
 
   async function createPageAction({
@@ -680,5 +683,32 @@ module.exports = (crowi) => {
     }
 
   });
+
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
+    const { action } = req.body;
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    try {
+      switch (action) {
+        case 'initialMigration':
+          if (!isV5Compatible) {
+            const Page = crowi.model('Page');
+            // this method throws and emit socketIo event when error occurs
+            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
+          }
+          break;
+
+        default:
+          logger.error(`${action} action is not supported.`);
+          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      }
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+    }
+
+    return res.apiv3({ isV5Compatible });
+  });
+
   return router;
 };

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -172,6 +172,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  IS_V5_COMPATIBLE: {
+    ns:      'crowi',
+    key:     'app:isV5Compatible',
+    type:    ValueType.BOOLEAN,
+    default: undefined,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',

+ 196 - 0
packages/app/src/server/service/page.js

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const streamToPromise = require('stream-to-promise');
+const pathlib = require('path');
 
 const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
@@ -737,6 +738,201 @@ class PageService {
     }
   }
 
+  async v5InitialMigration(grant) {
+    const socket = this.crowi.socketIoService.getAdminSocket();
+    try {
+      await this._v5RecursiveMigration(grant);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
+    const Page = this.crowi.model('Page');
+    const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
+    const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
+    const isPathIndexExists = pathIndexStatus != null;
+    const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
+
+    if (isUnique || !isPathIndexExists) {
+      try {
+        await this._v5NormalizeIndex(isPathIndexExists);
+      }
+      catch (err) {
+        logger.error('V5 index normalization failed.', err);
+        socket.emit('v5IndexNormalizationFailed', { error: err.message });
+
+        throw err;
+      }
+    }
+
+    await this._setIsV5CompatibleTrue();
+  }
+
+  async _setIsV5CompatibleTrue() {
+    try {
+      await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
+        'app:isV5Compatible': true,
+      });
+      logger.info('Successfully migrated all public pages.');
+    }
+    catch (err) {
+      logger.warn('Failed to update app:isV5Compatible to true.');
+      throw err;
+    }
+  }
+
+  // TODO: use websocket to show progress
+  async _v5RecursiveMigration(grant, rootPath) {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const total = await Page.countDocuments({ grant, parent: null });
+
+    let baseAggregation = Page
+      .aggregate([
+        {
+          $match: {
+            grant,
+            parent: null,
+          },
+        },
+        {
+          $project: { // minimize data to fetch
+            _id: 1,
+            path: 1,
+          },
+        },
+      ]);
+
+    // limit pages to get
+    if (total > PAGES_LIMIT) {
+      baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
+    }
+
+    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE }).exec();
+
+    // use batch stream
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    let countPages = 0;
+
+    // migrate all siblings for each page
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        // make list to create empty pages
+        const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
+        const parentPaths = Array.from(parentPathsSet);
+
+        // find existing parents
+        const builder1 = new PageQueryBuilder(Page.find({}, { _id: 0, path: 1 }));
+        const existingParents = await builder1
+          .addConditionToListByPathsArray(parentPaths)
+          .query
+          .lean()
+          .exec();
+        const existingParentPaths = existingParents.map(parent => parent.path);
+
+        // paths to create empty pages
+        const notExistingParentPaths = parentPaths.filter(path => !existingParentPaths.includes(path));
+
+        // insertMany empty pages
+        try {
+          await Page.insertMany(notExistingParentPaths.map(path => ({ path, isEmpty: true })));
+        }
+        catch (err) {
+          logger.error('Failed to insert empty pages.', err);
+          throw err;
+        }
+
+        // find parents again
+        const builder2 = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
+        const parents = await builder2
+          .addConditionToListByPathsArray(parentPaths)
+          .query
+          .lean()
+          .exec();
+
+        // bulkWrite to update parent
+        const updateManyOperations = parents.map((parent) => {
+          const parentId = parent._id;
+
+          // modify to adjust for RegExp
+          const parentPath = parent.path === '/' ? '' : parent.path;
+
+          return {
+            updateMany: {
+              filter: {
+                // regexr.com/6889f
+                // ex. /parent/any_child OR /any_level1
+                path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
+              },
+              update: {
+                parent: parentId,
+              },
+            },
+          };
+        });
+        try {
+          const res = await Page.bulkWrite(updateManyOperations);
+          countPages += (res.items || []).length;
+          logger.info(`Page migration processing: (count=${countPages}, errors=${res.errors}, took=${res.took}ms)`);
+        }
+        catch (err) {
+          logger.error('Failed to update page.parent.', err);
+          throw err;
+        }
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStream)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    if (await Page.exists({ grant, parent: null, path: { $ne: '/' } })) {
+      return this._v5RecursiveMigration(grant, rootPath);
+    }
+
+  }
+
+  async _v5NormalizeIndex(isPathIndexExists) {
+    const collection = mongoose.connection.collection('pages');
+
+    if (isPathIndexExists) {
+      try {
+        // drop pages.path_1 indexes
+        await collection.dropIndex('path_1');
+        logger.info('Succeeded to drop unique indexes from pages.path.');
+      }
+      catch (err) {
+        logger.warn('Failed to drop unique indexes from pages.path.', err);
+        throw err;
+      }
+    }
+
+    try {
+      // create indexes without
+      await collection.createIndex({ path: 1 }, { unique: false });
+      logger.info('Succeeded to create non-unique indexes on pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to create non-unique indexes on pages.path.', err);
+      throw err;
+    }
+  }
+
 }
 
 module.exports = PageService;