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

Merge branch 'feat/page-rename-v5' into feat/page-redirect-model

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

+ 5 - 0
packages/app/src/server/models/obsolete-page.js

@@ -327,6 +327,11 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToFilteringByParentId(parentId) {
+    this.query = this.query.and({ parent: parentId });
+    return this;
+  }
+
 }
 }
 
 
 export const getPageSchema = (crowi) => {
 export const getPageSchema = (crowi) => {

+ 2 - 6
packages/app/src/server/models/page.ts

@@ -163,10 +163,9 @@ schema.statics.createEmptyPage = async function(
  * Replace an existing page with an empty page.
  * Replace an existing page with an empty page.
  * It updates the children's parent to the new empty page's _id.
  * It updates the children's parent to the new empty page's _id.
  * @param exPage a page document to be replaced
  * @param exPage a page document to be replaced
- * @param pageToReplaceWith (optional) a page document to replace with
  * @returns Promise<void>
  * @returns Promise<void>
  */
  */
-schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void> {
   // find parent
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
   if (parent == null) {
@@ -174,10 +173,7 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   }
   }
 
 
   // create empty page at path
   // create empty page at path
-  let newTarget = pageToReplaceWith;
-  if (newTarget == null) {
-    newTarget = await this.createEmptyPage(exPage.path, parent);
-  }
+  const newTarget = await this.createEmptyPage(exPage.path, parent);
 
 
   // find children by ex-page _id
   // find children by ex-page _id
   const children = await this.find({ parent: exPage._id });
   const children = await this.find({ parent: exPage._id });

+ 2 - 4
packages/app/src/server/models/revision.js

@@ -37,10 +37,8 @@ module.exports = function(crowi) {
       .exec();
       .exec();
   };
   };
 
 
-  revisionSchema.statics.updateRevisionListByPath = async function(path, updateData) {
-    const Revision = this;
-
-    return Revision.updateMany({ path }, { $set: updateData });
+  revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
+    return this.updateMany({ pageId }, { $set: updateData });
   };
   };
 
 
   revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
   revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {

+ 4 - 4
packages/app/src/server/routes/page.js

@@ -1156,13 +1156,13 @@ module.exports = function(crowi, app) {
     const previousRevision = req.body.revision_id || null;
     const previousRevision = req.body.revision_id || null;
 
 
     // get completely flag
     // get completely flag
-    const isCompletely = (req.body.completely != null);
+    const isCompletely = req.body.completely;
     // get recursively flag
     // get recursively flag
-    const isRecursively = (req.body.recursively != null);
+    const isRecursively = req.body.recursively;
 
 
     const options = {};
     const options = {};
 
 
-    const page = await Page.findByIdAndViewer(pageId, req.user);
+    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
 
     if (page == null) {
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
@@ -1178,7 +1178,7 @@ module.exports = function(crowi, app) {
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       }
       else {
       else {
-        if (!page.isUpdatable(previousRevision)) {
+        if (!page.isEmpty && !page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
 
 

+ 303 - 127
packages/app/src/server/service/page.ts

@@ -1,9 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import mongoose from 'mongoose';
+import mongoose, { QueryCursor } from 'mongoose';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 import pathlib from 'path';
 import pathlib from 'path';
-import { Writable } from 'stream';
+import { Readable, Writable } from 'stream';
 
 
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
@@ -24,6 +24,78 @@ const {
 
 
 const BULK_REINDEX_SIZE = 100;
 const BULK_REINDEX_SIZE = 100;
 
 
+// TODO: improve type
+class PageCursorsForDescendantsFactory {
+
+  private user: any; // TODO: Typescriptize model
+
+  private rootPage: any; // TODO: wait for mongoose update
+
+  private shouldIncludeEmpty: boolean;
+
+  private initialCursor: QueryCursor<any>; // TODO: wait for mongoose update
+
+  private Page: PageModel;
+
+  constructor(user: any, rootPage: any, shouldIncludeEmpty: boolean) {
+    this.user = user;
+    this.rootPage = rootPage;
+    this.shouldIncludeEmpty = shouldIncludeEmpty;
+
+    this.Page = mongoose.model('Page') as unknown as PageModel;
+  }
+
+  // prepare initial cursor
+  private async init() {
+    const initialCursor = await this.generateCursorToFindChildren(this.rootPage);
+    this.initialCursor = initialCursor;
+  }
+
+  /**
+   * Returns Iterable that yields only descendant pages unorderedly
+   * @returns Promise<AsyncGenerator>
+   */
+  async generateIterable(): Promise<AsyncGenerator> {
+    // initialize cursor
+    await this.init();
+
+    return this.generateOnlyDescendants(this.initialCursor);
+  }
+
+  /**
+   * Returns Readable that produces only descendant pages unorderedly
+   * @returns Promise<Readable>
+   */
+  async generateReadable(): Promise<Readable> {
+    return Readable.from(await this.generateIterable());
+  }
+
+  /**
+   * Generator that unorderedly yields descendant pages
+   */
+  private async* generateOnlyDescendants(cursor: QueryCursor<any>) {
+    for await (const page of cursor) {
+      const nextCursor = await this.generateCursorToFindChildren(page);
+      yield* this.generateOnlyDescendants(nextCursor); // recursively yield
+
+      yield page;
+    }
+  }
+
+  private async generateCursorToFindChildren(page: any): Promise<QueryCursor<any>> {
+    const { PageQueryBuilder } = this.Page;
+
+    const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
+    builder.addConditionToFilteringByParentId(page._id);
+    await this.Page.addConditionToFilteringByViewerToEdit(builder, this.user);
+
+    const cursor = builder.query.lean().cursor({ batchSize: BULK_REINDEX_SIZE }) as QueryCursor<any>;
+
+    return cursor;
+  }
+
+}
+
 class PageService {
 class PageService {
 
 
   crowi: any;
   crowi: any;
@@ -150,33 +222,23 @@ class PageService {
     return result;
     return result;
   }
   }
 
 
-  private shouldNormalizeParent(page) {
+  private shouldUseV4Process(page): boolean {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
-  }
+    const isPageMigrated = page.parent != null;
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isRoot = isTopPage(page.path);
+    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
+    const isTrashed = isTrashPage(page.path);
+    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated || !isTrashed);
 
 
-  /**
-   * go back by using redirectTo and return the paths
-   *  ex: when
-   *    '/page1' redirects to '/page2' and
-   *    '/page2' redirects to '/page3'
-   *    and given '/page3',
-   *    '/page1' and '/page2' will be return
-   *
-   * @param {string} redirectTo
-   * @param {object} redirectToPagePathMapping
-   * @param {array} pagePaths
-   */
-  private prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths: any[] = []) {
-    const pagePath = redirectToPagePathMapping[redirectTo];
+    return shouldUseV4Process;
+  }
 
 
-    if (pagePath == null) {
-      return pagePaths;
-    }
+  private shouldNormalizeParent(page) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    pagePaths.push(pagePath);
-    return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
+    return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
   }
   }
 
 
   /**
   /**
@@ -189,6 +251,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     const builder = new PageQueryBuilder(Page.find(), true)
     const builder = new PageQueryBuilder(Page.find(), true)
+      .addConditionAsNotMigrated() // to avoid affecting v5 pages
       .addConditionToExcludeRedirect()
       .addConditionToExcludeRedirect()
       .addConditionToListOnlyDescendants(targetPagePath);
       .addConditionToListOnlyDescendants(targetPagePath);
 
 
@@ -202,12 +265,12 @@ class PageService {
   async renamePage(page, newPagePath, user, options) {
   async renamePage(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
 
 
+    if (isTopPage(page.path)) {
+      throw Error('It is forbidden to rename the top page');
+    }
+
     // v4 compatible process
     // v4 compatible process
-    const isPageMigrated = page.parent != null;
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isRoot = isTopPage(page.path);
-    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
-    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated);
+    const shouldUseV4Process = this.shouldUseV4Process(page);
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.renamePageV4(page, newPagePath, user, options);
       return this.renamePageV4(page, newPagePath, user, options);
     }
     }
@@ -286,7 +349,6 @@ class PageService {
   private async renamePageV4(page, newPagePath, user, options) {
   private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
-    const path = page.path;
     const updateMetadata = options.updateMetadata || false;
     const updateMetadata = options.updateMetadata || false;
 
 
     // sanitize path
     // sanitize path
@@ -306,7 +368,7 @@ class PageService {
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
 
     // update Rivisions
     // update Rivisions
-    await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
+    await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
 
     /*
     /*
      * TODO: https://redmine.weseek.co.jp/issues/86577
      * TODO: https://redmine.weseek.co.jp/issues/86577
@@ -319,7 +381,7 @@ class PageService {
   }
   }
 
 
 
 
-  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = false) {
+  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
     // v4 compatible process
     // v4 compatible process
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix);
       return this.renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix);
@@ -399,13 +461,14 @@ class PageService {
     this.pageEvent.emit('updateMany', pages, user);
     this.pageEvent.emit('updateMany', pages, user);
   }
   }
 
 
-  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = false) {
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true) {
     // v4 compatible process
     // v4 compatible process
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
     }
     }
 
 
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+    const readStream = await factory.generateReadable();
 
 
     const newPagePathPrefix = newPagePath;
     const newPagePathPrefix = newPagePath;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
@@ -495,11 +558,7 @@ class PageService {
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
 
     // v4 compatible process
     // v4 compatible process
-    const isPageMigrated = page.parent != null;
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isRoot = isTopPage(page.path);
-    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
-    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated);
+    const shouldUseV4Process = this.shouldUseV4Process(page);
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.duplicateV4(page, newPagePath, user, isRecursively);
       return this.duplicateV4(page, newPagePath, user, isRecursively);
     }
     }
@@ -650,7 +709,7 @@ class PageService {
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
   }
   }
 
 
-  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = false) {
+  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
     }
     }
@@ -764,7 +823,8 @@ class PageService {
       return this.duplicateDescendantsWithStreamV4(page, newPagePath, user);
       return this.duplicateDescendantsWithStreamV4(page, newPagePath, user);
     }
     }
 
 
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
+    const iterableFactory = new PageCursorsForDescendantsFactory(user, page, true);
+    const readStream = await iterableFactory.generateReadable();
 
 
     const newPagePathPrefix = newPagePath;
     const newPagePathPrefix = newPagePath;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
     const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
@@ -861,9 +921,15 @@ class PageService {
    * Delete
    * Delete
    */
    */
   async deletePage(page, user, options = {}, isRecursively = false) {
   async deletePage(page, user, options = {}, isRecursively = false) {
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
-    const Revision = this.crowi.model('Revision');
+    const Page = mongoose.model('Page') as PageModel;
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.deletePageV4(page, user, options, isRecursively);
+    }
 
 
     const newPath = Page.getDeletedPageName(page.path);
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -877,19 +943,79 @@ class PageService {
     }
     }
 
 
     if (isRecursively) {
     if (isRecursively) {
-      this.deleteDescendantsWithStream(page, user, options);
+      this.deleteDescendantsWithStream(page, user, shouldUseV4Process); // use the same process in both version v4 and v5
+    }
+    else {
+      // replace with an empty page
+      const shouldReplace = await Page.exists({ parent: page._id });
+      if (shouldReplace) {
+        await Page.replaceTargetWithEmptyPage(page);
+      }
     }
     }
 
 
-    // update Rivisions
-    await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
+    let deletedPage;
+    // update Revisions
+    if (page.isEmpty) {
+      await Page.remove({ _id: page._id });
+    }
+    else {
+      await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
+      deletedPage = await Page.findByIdAndUpdate(page._id, {
+        $set: {
+          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(), parent: null, // set parent as null
+        },
+      }, { new: true });
+      await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
+
+      /*
+       * TODO: https://redmine.weseek.co.jp/issues/86577
+       * bulkWrite PageRedirect documents
+       */
+      // const body = `redirect ${newPath}`;
+      // await Page.create(page.path, body, user, { redirectTo: newPath });
+
+      this.pageEvent.emit('delete', page, user);
+      this.pageEvent.emit('create', deletedPage, user);
+    }
+
+    return deletedPage;
+  }
+
+  private async deletePageV4(page, user, options = {}, isRecursively = false) {
+    const Page = mongoose.model('Page') as PageModel;
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    const newPath = Page.getDeletedPageName(page.path);
+    const isTrashed = isTrashPage(page.path);
+
+    if (isTrashed) {
+      throw new Error('This method does NOT support deleting trashed pages.');
+    }
+
+    if (!Page.isDeletableName(page.path)) {
+      throw new Error('Page is not deletable.');
+    }
+
+    if (isRecursively) {
+      this.deleteDescendantsWithStream(page, user);
+    }
+
+    // update Revisions
+    await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
     const deletedPage = await Page.findByIdAndUpdate(page._id, {
     const deletedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
       $set: {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
       },
     }, { new: true });
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
-    const body = `redirect ${newPath}`;
-    await Page.create(page.path, body, user, { redirectTo: newPath });
+
+    /*
+     * TODO: https://redmine.weseek.co.jp/issues/86577
+     * bulkWrite PageRedirect documents
+     */
+    // const body = `redirect ${newPath}`;
+    // await Page.create(page.path, body, user, { redirectTo: newPath });
 
 
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('create', deletedPage, user);
     this.pageEvent.emit('create', deletedPage, user);
@@ -897,88 +1023,46 @@ class PageService {
     return deletedPage;
     return deletedPage;
   }
   }
 
 
-  private async deleteCompletelyOperation(pageIds, pagePaths) {
-    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    const Bookmark = this.crowi.model('Bookmark');
-    const Comment = this.crowi.model('Comment');
-    const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
-    const ShareLink = this.crowi.model('ShareLink');
-    const Revision = this.crowi.model('Revision');
-    const Attachment = this.crowi.model('Attachment');
-
-    const { attachmentService } = this.crowi;
-    const attachments = await Attachment.find({ page: { $in: pageIds } });
-
-    const pages = await Page.find({ redirectTo: { $ne: null } });
-    const redirectToPagePathMapping = {};
-    pages.forEach((page) => {
-      redirectToPagePathMapping[page.redirectTo] = page.path;
-    });
-
-    const redirectedFromPagePaths: any[] = [];
-    pagePaths.forEach((pagePath) => {
-      redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
-    });
-
-    return Promise.all([
-      Bookmark.deleteMany({ page: { $in: pageIds } }),
-      Comment.deleteMany({ page: { $in: pageIds } }),
-      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
-      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
-      Revision.deleteMany({ path: { $in: pagePaths } }),
-      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
-      attachmentService.removeAllAttachments(attachments),
-    ]);
-  }
-
   private async deleteDescendants(pages, user) {
   private async deleteDescendants(pages, user) {
-    const Page = this.crowi.model('Page');
-
-    const pageCollection = mongoose.connection.collection('pages');
-    const revisionCollection = mongoose.connection.collection('revisions');
+    const Page = mongoose.model('Page') as PageModel;
 
 
-    const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
-    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
-    const newPagesForRedirect: any[] = [];
+    const deletePageOperations: any[] = [];
 
 
     pages.forEach((page) => {
     pages.forEach((page) => {
       const newPath = Page.getDeletedPageName(page.path);
       const newPath = Page.getDeletedPageName(page.path);
-      const revisionId = new mongoose.Types.ObjectId();
-      const body = `redirect ${newPath}`;
 
 
-      deletePageBulkOp.find({ _id: page._id }).update({
-        $set: {
-          path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
-        },
-      });
-      updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
-      createRediectRevisionBulkOp.insert({
-        _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
-      });
+      let operation;
+      // if empty, delete completely
+      if (page.isEmpty) {
+        operation = {
+          deleteOne: {
+            filter: { _id: page._id },
+          },
+        };
+      }
+      // if not empty, set parent to null and update to trash
+      else {
+        operation = {
+          updateOne: {
+            filter: { _id: page._id },
+            update: {
+              $set: {
+                path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(), parent: null, // set parent as null
+              },
+            },
+          },
+        };
+      }
 
 
-      newPagesForRedirect.push({
-        path: page.path,
-        creator: user._id,
-        grant: page.grant,
-        grantedGroup: page.grantedGroup,
-        grantedUsers: page.grantedUsers,
-        lastUpdateUser: user._id,
-        redirectTo: newPath,
-        revision: revisionId,
-      });
+      deletePageOperations.push(operation);
     });
     });
 
 
     try {
     try {
-      await deletePageBulkOp.execute();
-      await updateRevisionListOp.execute();
-      await createRediectRevisionBulkOp.execute();
-      await Page.insertMany(newPagesForRedirect, { ordered: false });
+      await Page.bulkWrite(deletePageOperations);
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
-        throw new Error(`Failed to revert pages: ${err}`);
+        throw new Error(`Failed to delete pages: ${err}`);
       }
       }
     }
     }
     finally {
     finally {
@@ -989,9 +1073,16 @@ class PageService {
   /**
   /**
    * Create delete stream
    * Create delete stream
    */
    */
-  private async deleteDescendantsWithStream(targetPage, user, options = {}) {
+  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true) {
+    let readStream;
+    if (shouldUseV4Process) {
+      readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    }
+    else {
+      const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+      readStream = await factory.generateReadable();
+    }
 
 
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
     const deleteDescendants = this.deleteDescendants.bind(this);
     const deleteDescendants = this.deleteDescendants.bind(this);
     let count = 0;
     let count = 0;
@@ -1000,7 +1091,7 @@ class PageService {
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
         try {
         try {
           count += batch.length;
           count += batch.length;
-          deleteDescendants(batch, user);
+          await deleteDescendants(batch, user);
           logger.debug(`Reverting pages progressing: (count=${count})`);
           logger.debug(`Reverting pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -1021,6 +1112,47 @@ class PageService {
       .pipe(writeStream);
       .pipe(writeStream);
   }
   }
 
 
+  private async deleteCompletelyOperation(pageIds, pagePaths) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    const Bookmark = this.crowi.model('Bookmark');
+    const Comment = this.crowi.model('Comment');
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const ShareLink = this.crowi.model('ShareLink');
+    const Revision = this.crowi.model('Revision');
+    const Attachment = this.crowi.model('Attachment');
+
+    const { attachmentService } = this.crowi;
+    const attachments = await Attachment.find({ page: { $in: pageIds } });
+
+    /*
+     * TODO: https://redmine.weseek.co.jp/issues/86577
+     * deleteMany related PageRedirect documents
+     */
+    // const pages = await Page.find({ redirectTo: { $ne: null } });
+    // const redirectToPagePathMapping = {};
+    // pages.forEach((page) => {
+    //   redirectToPagePathMapping[page.redirectTo] = page.path;
+    // });
+
+    // const redirectedFromPagePaths: any[] = [];
+    // pagePaths.forEach((pagePath) => {
+    //   redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
+    // });
+
+    return Promise.all([
+      Bookmark.deleteMany({ page: { $in: pageIds } }),
+      Comment.deleteMany({ page: { $in: pageIds } }),
+      PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
+      ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
+      Revision.deleteMany({ path: { $in: pagePaths } }),
+      Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { _id: { $in: pageIds } }] }),
+      // TODO: https://redmine.weseek.co.jp/issues/86577
+      // Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
+      attachmentService.removeAllAttachments(attachments),
+    ]);
+  }
+
   // delete multiple pages
   // delete multiple pages
   private async deleteMultipleCompletely(pages, user, options = {}) {
   private async deleteMultipleCompletely(pages, user, options = {}) {
     const ids = pages.map(page => (page._id));
     const ids = pages.map(page => (page._id));
@@ -1036,6 +1168,43 @@ class PageService {
   }
   }
 
 
   async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
   async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+    const Page = mongoose.model('Page') as PageModel;
+
+    if (isTopPage(page.path)) {
+      throw Error('It is forbidden to delete the top page');
+    }
+
+    // v4 compatible process
+    const shouldUseV4Process = this.shouldUseV4Process(page);
+    if (shouldUseV4Process) {
+      return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
+    }
+
+    const ids = [page._id];
+    const paths = [page.path];
+
+    logger.debug('Deleting completely', paths);
+
+    // replace with an empty page
+    const shouldReplace = !isRecursively && !isTrashPage(page.path) && await Page.exists({ parent: page._id });
+    if (shouldReplace) {
+      await Page.replaceTargetWithEmptyPage(page);
+    }
+
+    await this.deleteCompletelyOperation(ids, paths);
+
+    if (isRecursively) {
+      this.deleteCompletelyDescendantsWithStream(page, user, options, shouldUseV4Process);
+    }
+
+    if (!page.isEmpty && !preventEmitting) {
+      this.pageEvent.emit('deleteCompletely', page, user);
+    }
+
+    return;
+  }
+
+  private async deleteCompletelyV4(page, user, options = {}, isRecursively = false, preventEmitting = false) {
     const ids = [page._id];
     const ids = [page._id];
     const paths = [page.path];
     const paths = [page.path];
 
 
@@ -1047,7 +1216,7 @@ class PageService {
       this.deleteCompletelyDescendantsWithStream(page, user, options);
       this.deleteCompletelyDescendantsWithStream(page, user, options);
     }
     }
 
 
-    if (!preventEmitting) {
+    if (!page.isEmpty && !preventEmitting) {
       this.pageEvent.emit('deleteCompletely', page, user);
       this.pageEvent.emit('deleteCompletely', page, user);
     }
     }
 
 
@@ -1061,9 +1230,16 @@ class PageService {
   /**
   /**
    * Create delete completely stream
    * Create delete completely stream
    */
    */
-  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true) {
+    let readStream;
 
 
-    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    if (shouldUseV4Process) { // pages don't have parents
+      readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+    }
+    else {
+      const factory = new PageCursorsForDescendantsFactory(user, targetPage, true);
+      readStream = await factory.generateReadable();
+    }
 
 
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     let count = 0;
     let count = 0;
@@ -1427,7 +1603,7 @@ class PageService {
   /*
   /*
    * returns an array of js RegExp instance instead of RE2 instance for mongo filter
    * returns an array of js RegExp instance instead of RE2 instance for mongo filter
    */
    */
-  async _generateRegExpsByPageIds(pageIds) {
+  private async _generateRegExpsByPageIds(pageIds) {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     let result;
     let result;
@@ -1445,7 +1621,7 @@ class PageService {
     return regexps;
     return regexps;
   }
   }
 
 
-  async _setIsV5CompatibleTrue() {
+  private async _setIsV5CompatibleTrue() {
     try {
     try {
       await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
       await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
         'app:isV5Compatible': true,
         'app:isV5Compatible': true,
@@ -1599,7 +1775,7 @@ class PageService {
 
 
   }
   }
 
 
-  async _v5NormalizeIndex() {
+  private async _v5NormalizeIndex() {
     const collection = mongoose.connection.collection('pages');
     const collection = mongoose.connection.collection('pages');
 
 
     try {
     try {