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

Merge branch 'master' into feat/sync-latest-revision-body-to-yjs-draft

Shun Miyazawa 1 год назад
Родитель
Сommit
473006be08
31 измененных файлов с 548 добавлено и 256 удалено
  1. 0 1
      .eslintrc.js
  2. 4 1
      apps/app/nodemon.json
  3. 2 2
      apps/app/package.json
  4. 2 0
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  5. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  6. 4 4
      apps/app/src/client/components/PageHistory/PageRevisionTable.tsx
  7. 3 3
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  8. 3 3
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  9. 7 15
      apps/app/src/client/services/side-effects/yjs.ts
  10. 1 1
      apps/app/src/interfaces/websocket.ts
  11. 1 1
      apps/app/src/interfaces/yjs.ts
  12. 1 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  13. 3 4
      apps/app/src/server/crowi/index.js
  14. 1 1
      apps/app/src/server/models/index.ts
  15. 0 71
      apps/app/src/server/models/revision.js
  16. 82 0
      apps/app/src/server/models/revision.ts
  17. 2 2
      apps/app/src/server/routes/apiv3/page/index.ts
  18. 3 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  19. 1 1
      apps/app/src/server/routes/apiv3/revisions.js
  20. 1 1
      apps/app/src/server/routes/tag.js
  21. 10 42
      apps/app/src/server/service/page/index.ts
  22. 0 2
      apps/app/src/server/service/page/page-service.ts
  23. 0 13
      apps/app/src/server/service/page/sync-latest-revision-body-to-yjs-draft.ts
  24. 0 43
      apps/app/src/server/service/socket-io.ts
  25. 135 0
      apps/app/src/server/service/yjs.integ.ts
  26. 247 0
      apps/app/src/server/service/yjs.ts
  27. 4 6
      apps/app/src/stores/yjs.ts
  28. 7 10
      packages/core/src/interfaces/revision.ts
  29. 2 2
      packages/editor/package.json
  30. 1 1
      packages/pluginkit/src/model/growi-plugin-package-data.ts
  31. 19 19
      yarn.lock

+ 0 - 1
.eslintrc.js

@@ -49,7 +49,6 @@ module.exports = {
       },
     ],
     '@typescript-eslint/consistent-type-imports': 'warn',
-    '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     indent: [
       'error',

+ 4 - 1
apps/app/nodemon.json

@@ -5,8 +5,11 @@
     "public/static",
     "package.json",
     "playwright",
+    "src/client",
+    "src/**/client",
     "test",
     "test-with-vite",
-    "tmp"
+    "tmp",
+    "*.mongodb.js"
   ]
 }

+ 2 - 2
apps/app/package.json

@@ -209,9 +209,9 @@
     "validator": "^13.7.0",
     "ws": "^8.17.1",
     "xss": "^1.0.14",
-    "y-mongodb-provider": "^0.1.10",
+    "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.15"
+    "yjs": "^13.6.18"
   },
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",

+ 2 - 0
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -7,6 +7,8 @@ import type {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';

+ 2 - 2
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -93,11 +93,11 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
   const circleColor = useMemo(() => {
-    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+    if ((currentPageYjsData?.awarenessStateSize ?? 0) > 0) {
       return 'bg-primary';
     }
 
-    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+    if (currentPageYjsData?.hasYdocsNewerThanLatestRevision ?? false) {
       return 'bg-secondary';
     }
   }, [currentPageYjsData]);

+ 4 - 4
apps/app/src/client/components/PageHistory/PageRevisionTable.tsx

@@ -2,7 +2,7 @@ import React, {
   useEffect, useRef, useState,
 } from 'react';
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { useSWRxInfinitePageRevisions } from '~/stores/page';
@@ -48,8 +48,8 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
     || (isValidating && data != null && typeof data[size - 1] === 'undefined');
   const isReachingEnd = (revisionPerPage === 0) || !!(data != null && data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE);
 
-  const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
-  const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
+  const [sourceRevision, setSourceRevision] = useState<IRevisionHasId>();
+  const [targetRevision, setTargetRevision] = useState<IRevisionHasId>();
 
   const tbodyRef = useRef<HTMLTableSectionElement>(null);
 
@@ -96,7 +96,7 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
   }, [isLoadingMore, isReachingEnd, setSize, size]);
 
 
-  const renderRow = (revision: IRevisionHasPageId, previousRevision: IRevisionHasPageId, latestRevision: IRevisionHasPageId,
+  const renderRow = (revision: IRevisionHasId, previousRevision: IRevisionHasId, latestRevision: IRevisionHasId,
       isOldestRevision: boolean, hasDiff: boolean) => {
 
     const revisionId = revision._id;

+ 3 - 3
apps/app/src/client/components/PageHistory/RevisionDiff.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { GrowiThemeSchemeType } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { PresetThemesMetadatas } from '@growi/preset-themes';
@@ -26,8 +26,8 @@ import 'diff2html/bundles/css/diff2html.min.css';
 const moduleClass = styles['revision-diff-container'];
 
 type RevisioinDiffProps = {
-  currentRevision: IRevisionHasPageId,
-  previousRevision: IRevisionHasPageId,
+  currentRevision: IRevisionHasId,
+  previousRevision: IRevisionHasId,
   revisionDiffOpened: boolean,
   currentPageId: string,
   currentPagePath: string,

+ 3 - 3
apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from 'react';
 
-import type { IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -22,8 +22,8 @@ const DropdownItemContents = ({ title, contents }) => (
 );
 
 type RevisionComparerProps = {
-  sourceRevision: IRevisionHasPageId
-  targetRevision: IRevisionHasPageId
+  sourceRevision: IRevisionHasId
+  targetRevision: IRevisionHasId
   currentPageId?: string
   currentPagePath: string
   onClose: () => void

+ 7 - 15
apps/app/src/client/services/side-effects/yjs.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react';
+import { useEffect } from 'react';
 
 import { useGlobalSocket } from '@growi/core/dist/swr';
 
@@ -7,27 +7,19 @@ import { useCurrentPageYjsData } from '~/stores/yjs';
 
 export const useCurrentPageYjsDataEffect = (): void => {
   const { data: socket } = useGlobalSocket();
-  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
-
-  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
-    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
-  }, [updateHasRevisionBodyDiff]);
-
-  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
-    updateAwarenessStateSize(awarenessStateSize);
-  }), [updateAwarenessStateSize]);
+  const { updateHasYdocsNewerThanLatestRevision, updateAwarenessStateSize } = useCurrentPageYjsData();
 
   useEffect(() => {
 
     if (socket == null) { return }
 
-    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
-    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+    socket.on(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
 
     return () => {
-      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
-      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+      socket.off(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
     };
 
-  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
+  }, [socket, updateAwarenessStateSize, updateHasYdocsNewerThanLatestRevision]);
 };

+ 1 - 1
apps/app/src/interfaces/websocket.ts

@@ -51,7 +51,7 @@ export const SocketEventName = {
 
   // Yjs
   YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
-  YjsHasRevisionBodyDiffUpdated: 'yjs:has-revision-body-diff-update',
+  YjsHasYdocsNewerThanLatestRevisionUpdated: 'yjs:has-ydocs-newer-than-latest-revision-update',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 1 - 1
apps/app/src/interfaces/yjs.ts

@@ -1,4 +1,4 @@
 export type CurrentPageYjsData = {
-  hasRevisionBodyDiff?: boolean,
+  hasYdocsNewerThanLatestRevision?: boolean,
   awarenessStateSize?: number,
 }

+ 1 - 2
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

@@ -4,6 +4,7 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
 import getPageModel from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
@@ -18,7 +19,6 @@ module.exports = {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
-    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
 
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);
@@ -69,7 +69,6 @@ module.exports = {
   async down(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
     const Page = getModelSafely('Page') || getPageModel();
-    const Revision = getModelSafely('Revision') || require('~/server/models/revision')();
 
     const pagesStream = await Page.find({ revision: { $ne: null } }, { _id: 1, path: 1 }).cursor({ batch_size: LIMIT });
     const batchStrem = createBatchStream(LIMIT);

+ 3 - 4
apps/app/src/server/crowi/index.js

@@ -36,7 +36,7 @@ import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
-import { instantiateYjsConnectionManager } from '../service/yjs-connection-manager';
+import { initializeYjsService } from '../service/yjs';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
@@ -475,9 +475,8 @@ Crowi.prototype.start = async function() {
   // attach to socket.io
   this.socketIoService.attachServer(httpServer);
 
-  // Initialization YjsConnectionManager
-  instantiateYjsConnectionManager(this.socketIoService.io);
-  this.socketIoService.setupYjsConnection();
+  // Initialization YjsService
+  initializeYjsService(this.socketIoService.io);
 
   await this.autoInstall();
 

+ 1 - 1
apps/app/src/server/models/index.ts

@@ -5,7 +5,6 @@ export const modelsDependsOnCrowi = {
   Page,
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
-  Revision: require('./revision'),
   Bookmark: require('./bookmark'),
   GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
@@ -17,6 +16,7 @@ export const modelsDependsOnCrowi = {
 export * from './attachment';
 export * as Activity from './activity';
 export * as PageRedirect from './page-redirect';
+export * from './revision';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';

+ 0 - 71
apps/app/src/server/models/revision.js

@@ -1,71 +0,0 @@
-import { allOrigin } from '@growi/core';
-
-import loggerFactory from '~/utils/logger';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-module.exports = function(crowi) {
-  // eslint-disable-next-line no-unused-vars
-  const logger = loggerFactory('growi:models:revision');
-
-  const mongoose = require('mongoose');
-  const mongoosePaginate = require('mongoose-paginate-v2');
-
-  // allow empty strings
-  mongoose.Schema.Types.String.checkRequired(v => v != null);
-
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-  const revisionSchema = new mongoose.Schema({
-    // OBSOLETE path: { type: String, required: true, index: true }
-    pageId: { type: ObjectId, required: true, index: true },
-    body: {
-      type: String,
-      required: true,
-      get: (data) => {
-      // replace CR/CRLF to LF above v3.1.5
-      // see https://github.com/weseek/growi/issues/463
-        return data ? data.replace(/\r\n?/g, '\n') : '';
-      },
-    },
-    format: { type: String, default: 'markdown' },
-    author: { type: ObjectId, ref: 'User' },
-    hasDiffToPrev: { type: Boolean },
-    origin: { type: String, enum: allOrigin },
-  }, {
-    timestamps: { createdAt: true, updatedAt: false },
-  });
-  revisionSchema.plugin(mongoosePaginate);
-
-  revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
-    return this.updateMany({ pageId }, { $set: updateData });
-  };
-
-  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, origin, options) {
-    const Revision = this;
-
-    if (!options) {
-      // eslint-disable-next-line no-param-reassign
-      options = {};
-    }
-    const format = options.format || 'markdown';
-
-    if (!user._id) {
-      throw new Error('Error: user should have _id');
-    }
-
-    const newRevision = new Revision();
-    newRevision.pageId = pageData._id;
-    newRevision.body = body;
-    newRevision.format = format;
-    newRevision.author = user._id;
-    newRevision.origin = origin;
-    if (pageData.revision != null) {
-      newRevision.hasDiffToPrev = body !== previousBody;
-    }
-
-    return newRevision;
-  };
-
-  return mongoose.model('Revision', revisionSchema);
-};

+ 82 - 0
apps/app/src/server/models/revision.ts

@@ -0,0 +1,82 @@
+import type {
+  HasObjectId,
+  IRevision,
+  Origin,
+} from '@growi/core';
+import { allOrigin } from '@growi/core';
+import {
+  Schema, Types, type Document, type Model,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+
+import loggerFactory from '~/utils/logger';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { PageDocument } from './page';
+
+const logger = loggerFactory('growi:models:revision');
+
+export interface IRevisionDocument extends IRevision, Document {
+}
+
+type UpdateRevisionListByPageId = (pageId: string, updateData: Partial<IRevision>) => Promise<void>;
+type PrepareRevision = (
+  pageData: PageDocument, body: string, previousBody: string | null, user: HasObjectId, origin?: Origin, options?: { format: string }
+) => IRevisionDocument;
+
+export interface IRevisionModel extends Model<IRevisionDocument> {
+  updateRevisionListByPageId: UpdateRevisionListByPageId,
+  prepareRevision: PrepareRevision,
+}
+
+// Use this to allow empty strings to pass the `required` validator
+Schema.Types.String.checkRequired(v => typeof v === 'string');
+
+const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
+  pageId: {
+    type: Types.ObjectId, ref: 'Page', required: true, index: true,
+  },
+  body: {
+    type: String,
+    required: true,
+    get: (data) => {
+    // replace CR/CRLF to LF above v3.1.5
+    // see https://github.com/weseek/growi/issues/463
+      return data ? data.replace(/\r\n?/g, '\n') : '';
+    },
+  },
+  format: { type: String, default: 'markdown' },
+  author: { type: Types.ObjectId, ref: 'User' },
+  hasDiffToPrev: { type: Boolean },
+  origin: { type: String, enum: allOrigin },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+revisionSchema.plugin(mongoosePaginate);
+
+const updateRevisionListByPageId: UpdateRevisionListByPageId = async function(this: IRevisionModel, pageId, updateData) {
+  await this.updateMany({ pageId }, { $set: updateData });
+};
+revisionSchema.statics.updateRevisionListByPageId = updateRevisionListByPageId;
+
+const prepareRevision: PrepareRevision = function(this: IRevisionModel, pageData, body, previousBody, user, origin, options = { format: 'markdown' }) {
+  if (!user._id) {
+    throw new Error('Error: user should have _id');
+  }
+
+  const newRevision = new this();
+  newRevision.pageId = pageData._id;
+  newRevision.body = body;
+  newRevision.format = options.format;
+  newRevision.author = user._id;
+  newRevision.origin = origin;
+  if (pageData.revision != null) {
+    newRevision.hasDiffToPrev = body !== previousBody;
+  }
+
+  return newRevision;
+};
+revisionSchema.statics.prepareRevision = prepareRevision;
+
+export const Revision = getOrCreateModel<IRevisionDocument, IRevisionModel>('Revision', revisionSchema);

+ 2 - 2
apps/app/src/server/routes/apiv3/page/index.ts

@@ -15,8 +15,9 @@ import type { IPageGrantData } from '~/interfaces/page';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
-import { GlobalNotificationSettingEvent } from '~/server/models';
+import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageModel } from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
@@ -758,7 +759,6 @@ module.exports = (crowi) => {
 
       const revisionIdForFind = revisionId || page.revision;
 
-      const Revision = crowi.model('Revision');
       revision = await Revision.findById(revisionIdForFind);
       pagePath = page.path;
 

+ 3 - 3
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -18,7 +18,7 @@ import {
 } from '~/server/models';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { preNotifyService } from '~/server/service/pre-notify';
-import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
+import { getYjsService } from '~/server/service/yjs';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 
@@ -67,8 +67,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     // Reflect the updates in ydoc
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
-      const yjsConnectionManager = getYjsConnectionManager();
-      await yjsConnectionManager.handleYDocUpdate(req.body.pageId, req.body.body);
+      const yjsService = getYjsService();
+      await yjsService.handleYDocUpdate(req.body.pageId, req.body.body);
     }
 
     // persist activity

+ 1 - 1
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,5 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
+import { Revision } from '~/server/models/revision';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -64,7 +65,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
 
   const {
-    Revision,
     Page,
     User,
   } = crowi.models;

+ 1 - 1
apps/app/src/server/routes/tag.js

@@ -2,6 +2,7 @@ import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
 import PageTagRelation from '../models/page-tag-relation';
+import { Revision } from '../models/revision';
 import ApiResponse from '../util/apiResponse';
 
 /**
@@ -139,7 +140,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;

+ 10 - 42
apps/app/src/server/service/page/index.ts

@@ -41,7 +41,6 @@ import {
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import type { UserGroupDocument } from '~/server/models/user-group';
-import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import { generalXssFilter } from '~/services/general-xss-filter';
@@ -49,11 +48,12 @@ import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
-import { Attachment } from '../../models';
+import { Attachment } from '../../models/attachment';
 import { PathAlreadyExistsError } from '../../models/errors';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
+import { Revision } from '../../models/revision';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
 import Subscription from '../../models/subscription';
@@ -63,11 +63,11 @@ import { divideByType } from '../../util/granted-group';
 import { configManager } from '../config-manager';
 import type { IPageGrantService } from '../page-grant';
 import { preNotifyService } from '../pre-notify';
+import { getYjsService } from '../yjs';
 
 import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
 import type { IPageService } from './page-service';
 import { shouldUseV4Process } from './should-use-v4-process';
-import { syncLatestRevisionBodyToYjsDraft } from './sync-latest-revision-body-to-yjs-draft';
 
 export * from './page-service';
 
@@ -833,7 +833,6 @@ class PageService implements IPageService {
 
   private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
     const {
       isRecursively = false,
       createRedirectPage = false,
@@ -1349,7 +1348,6 @@ class PageService implements IPageService {
     }
 
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
@@ -1357,7 +1355,7 @@ class PageService implements IPageService {
     // Mapping to set to the body of the new revision
     const pageIdRevisionMapping = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[revision.pageId] = revision;
+      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1405,7 +1403,6 @@ class PageService implements IPageService {
 
   private async duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix) {
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const pageIds = pages.map(page => page._id);
     const revisions = await Revision.find({ pageId: { $in: pageIds } });
@@ -1413,7 +1410,7 @@ class PageService implements IPageService {
     // Mapping to set to the body of the new revision
     const pageIdRevisionMapping = {};
     revisions.forEach((revision) => {
-      pageIdRevisionMapping[revision.pageId] = revision;
+      pageIdRevisionMapping[getIdForRef(revision.pageId)] = revision;
     });
 
     // key: oldPageId, value: newPageId
@@ -1710,7 +1707,6 @@ class PageService implements IPageService {
 
   private async deletePageV4(page, user, options = {}, isRecursively = false) {
     const Page = mongoose.model('Page') as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -1873,7 +1869,6 @@ class PageService implements IPageService {
   async deleteCompletelyOperation(pageIds, pagePaths): Promise<void> {
     // Delete Attachments, Revisions, Pages and emit delete
     const Page = this.crowi.model('Page');
-    const Revision = this.crowi.model('Revision');
 
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
@@ -3840,7 +3835,6 @@ class PageService implements IPageService {
     let savedPage = await page.save();
 
     // Create revision
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, options.origin);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
@@ -3902,7 +3896,6 @@ class PageService implements IPageService {
    */
   private async createV4(path, body, user, options: any = {}) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     const format = options.format || 'markdown';
     const grantUserGroupIds = options.grantUserGroupIds || null;
@@ -4038,8 +4031,7 @@ class PageService implements IPageService {
     let savedPage = await page.save();
 
     // Create revision
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const dummyUser = { _id: new mongoose.Types.ObjectId() };
+    const dummyUser: HasObjectId = { _id: new mongoose.Types.ObjectId().toString() };
     const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
@@ -4147,7 +4139,6 @@ class PageService implements IPageService {
       options: IOptionsForUpdate = {},
   ): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
 
     const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -4291,7 +4282,6 @@ class PageService implements IPageService {
 
   async updatePageV4(pageData: PageDocument, body, previousBody, user, options: IOptionsForUpdate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     // use the previous data if absent
     const grant = options.grant || pageData.grant;
@@ -4445,39 +4435,17 @@ class PageService implements IPageService {
   }
 
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
-    const yjsConnectionManager = getYjsConnectionManager();
+    const yjsService = getYjsService();
 
-    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-    const persistedYdoc = await yjsConnectionManager.getPersistedYdoc(pageId);
-
-    const yjsDraft = (currentYdoc ?? persistedYdoc)?.getText('codemirror').toString();
-    const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
+    const currentYdoc = yjsService.getCurrentYdoc(pageId);
+    const hasYdocsNewerThanLatestRevision = await yjsService.hasYdocsNewerThanLatestRevision(pageId);
 
     return {
-      hasRevisionBodyDiff,
+      hasYdocsNewerThanLatestRevision,
       awarenessStateSize: currentYdoc?.awareness.states.size,
     };
   }
 
-  async syncLatestRevisionBodyToYjsDraft(pageId: string): Promise<void> {
-    await syncLatestRevisionBodyToYjsDraft(pageId);
-  }
-
-  async hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean> {
-    if (comparisonTarget == null) {
-      return false;
-    }
-
-    const Revision = mongoose.model<IRevisionHasId>('Revision');
-    const revision = await Revision.findOne({ pageId }).sort({ createdAt: -1 });
-
-    if (revision == null) {
-      return false;
-    }
-
-    return revision.body !== comparisonTarget;
-  }
-
   async createTtlIndex(): Promise<void> {
     const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
     const collection = mongoose.connection.collection('pages');

+ 0 - 2
apps/app/src/server/service/page/page-service.ts

@@ -32,6 +32,4 @@ export interface IPageService {
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
   getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
-  syncLatestRevisionBodyToYjsDraft(pageId: string): Promise<void>,
-  hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean>,
 }

+ 0 - 13
apps/app/src/server/service/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -1,13 +0,0 @@
-import type { IRevisionHasId } from '@growi/core';
-import mongoose from 'mongoose';
-
-import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
-
-export const syncLatestRevisionBodyToYjsDraft = async(pageId: string): Promise<void> => {
-  const yjsConnectionManager = getYjsConnectionManager();
-  const Revision = mongoose.model<IRevisionHasId>('Revision');
-  const revision = await Revision.findOne({ pageId }).sort({ createdAt: -1 });
-  if (revision != null) {
-    await yjsConnectionManager.handleYDocUpdate(pageId, revision.body);
-  }
-};

+ 0 - 43
apps/app/src/server/service/socket-io.ts

@@ -1,12 +1,10 @@
 import type { IncomingMessage } from 'http';
 
 import type { IUserHasId } from '@growi/core/dist/interfaces';
-import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import expressSession from 'express-session';
 import passport from 'passport';
 import type { Namespace } from 'socket.io';
 import { Server } from 'socket.io';
-import type { Document } from 'y-socket.io/dist/server';
 
 import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
@@ -15,7 +13,6 @@ import type Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 import { configManager } from './config-manager';
-import { getYjsConnectionManager, extractPageIdFromYdocId } from './yjs-connection-manager';
 
 
 const logger = loggerFactory('growi:service:socket-io');
@@ -179,46 +176,6 @@ class SocketIoService {
     });
   }
 
-  setupYjsConnection() {
-    const yjsConnectionManager = getYjsConnectionManager();
-
-    this.io.on('connection', (socket) => {
-
-      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(doc: Document) => {
-        const pageId = extractPageIdFromYdocId(doc.name);
-
-        if (pageId == null) return;
-
-        const awarenessStateSize = doc.awareness.states.size;
-
-        // Triggered when awareness changes
-        this.io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
-
-        // Triggered when the last user leaves the editor
-        if (awarenessStateSize === 0) {
-          const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-          const yjsDraft = currentYdoc?.getText('codemirror').toString();
-          const hasRevisionBodyDiff = await this.crowi.pageService.hasRevisionBodyDiff(pageId, yjsDraft);
-          this.io
-            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-            .emit(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiff);
-        }
-      });
-
-      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
-        try {
-          await yjsConnectionManager.handleYDocSync(pageId, initialValue);
-        }
-        catch (error) {
-          logger.warn(error.message);
-          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
-        }
-      });
-    });
-  }
-
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 

+ 135 - 0
apps/app/src/server/service/yjs.integ.ts

@@ -0,0 +1,135 @@
+import { Types } from 'mongoose';
+import type { Server } from 'socket.io';
+import { mock } from 'vitest-mock-extended';
+import type { MongodbPersistence } from 'y-mongodb-provider';
+
+import { Revision } from '../models/revision';
+
+import type { IYjsService } from './yjs';
+import { getYjsService, initializeYjsService } from './yjs';
+
+
+vi.mock('y-socket.io/dist/server', () => {
+  const YSocketIO = vi.fn();
+  YSocketIO.prototype.initialize = vi.fn();
+  return { YSocketIO };
+});
+
+
+const ObjectId = Types.ObjectId;
+
+
+const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
+  // eslint-disable-next-line dot-notation
+  return yjsService['mdb'];
+};
+
+describe('YjsService', () => {
+
+  describe('hasYdocsNewerThanLatestRevision()', () => {
+
+    beforeAll(async() => {
+      const ioMock = mock<Server>();
+
+      // initialize
+      initializeYjsService(ioMock);
+    });
+
+    afterAll(async() => {
+      // flush revisions
+      await Revision.deleteMany({});
+
+      // flush yjs-writings
+      const yjsService = getYjsService();
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.flushDB();
+    });
+
+    it('returns false when neither revisions nor YDocs exists', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      // act
+      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+
+      // assert
+      expect(result).toBe(false);
+    });
+
+    it('returns true when no revisions exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setMeta(pageId.toString(), 'updatedAt', 1000);
+
+      // act
+      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+
+      // assert
+      expect(result).toBe(true);
+    });
+
+    it('returns false when the latest revision is newer than meta data', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '' },
+      ]);
+
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
+
+      // act
+      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+
+      // assert
+      expect(result).toBe(false);
+    });
+
+    it('returns false when no YDocs exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '' },
+      ]);
+
+      // act
+      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+
+      // assert
+      expect(result).toBe(false);
+    });
+
+    it('returns true when the newer YDocs exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '' },
+      ]);
+
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
+
+      // act
+      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+
+      // assert
+      expect(result).toBe(true);
+    });
+
+  });
+});

+ 247 - 0
apps/app/src/server/service/yjs.ts

@@ -0,0 +1,247 @@
+import type { IRevisionHasId } from '@growi/core';
+import { GlobalSocketEventName } from '@growi/core';
+import mongoose from 'mongoose';
+import type { Server } from 'socket.io';
+import { MongodbPersistence } from 'y-mongodb-provider';
+import type { Document } from 'y-socket.io/dist/server';
+import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import loggerFactory from '~/utils/logger';
+
+import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+
+
+const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
+const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+
+
+const logger = loggerFactory('growi:service:yjs');
+
+
+export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
+  const result = ydocId.match(/yjs\/(.*)/);
+  return result?.[1];
+};
+
+export interface IYjsService {
+  hasYdocsNewerThanLatestRevision(pageId: string): Promise<boolean>;
+  handleYDocSync(pageId: string, initialValue: string): Promise<void>;
+  handleYDocUpdate(pageId: string, newValue: string): Promise<void>;
+  getCurrentYdoc(pageId: string): Ydoc | undefined;
+  getPersistedYdoc(pageId: string): Promise<Y.Doc>;
+}
+
+class YjsService implements IYjsService {
+
+  private ysocketio: YSocketIO;
+
+  private mdb: MongodbPersistence;
+
+  constructor(io: Server) {
+    const ysocketio = new YSocketIO(io);
+    ysocketio.initialize();
+    this.ysocketio = ysocketio;
+
+    this.mdb = new MongodbPersistence(
+      // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'.
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      {
+        // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        client: mongoose.connection.getClient() as any,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        db: mongoose.connection.db as any,
+      },
+      {
+        collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
+        flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
+      },
+    );
+
+    this.createIndexes();
+
+    io.on('connection', (socket) => {
+
+      ysocketio.on('awareness-update', async(doc: Document) => {
+        const pageId = extractPageIdFromYdocId(doc.name);
+
+        if (pageId == null) return;
+
+        const awarenessStateSize = doc.awareness.states.size;
+
+        // Triggered when awareness changes
+        io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+
+        // Triggered when the last user leaves the editor
+        if (awarenessStateSize === 0) {
+          const hasYdocsNewerThanLatestRevision = await this.hasYdocsNewerThanLatestRevision(pageId);
+          io
+            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+            .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
+        }
+      });
+
+      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
+        try {
+          await this.handleYDocSync(pageId, initialValue);
+        }
+        catch (error) {
+          logger.warn(error.message);
+          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
+        }
+      });
+    });
+  }
+
+  private async createIndexes(): Promise<void> {
+
+    const collection = mongoose.connection.collection(MONGODB_PERSISTENCE_COLLECTION_NAME);
+
+    try {
+      await collection.createIndexes([
+        {
+          key: {
+            version: 1,
+            docName: 1,
+            action: 1,
+            clock: 1,
+            part: 1,
+          },
+        },
+        // for metaKey
+        {
+          key: {
+            version: 1,
+            docName: 1,
+            metaKey: 1,
+          },
+        },
+        // for flushDocument / clearDocument
+        {
+          key: {
+            docName: 1,
+            clock: 1,
+          },
+        },
+      ]);
+    }
+    catch (err) {
+      logger.error('Failed to create Index', err);
+      throw err;
+    }
+  }
+
+  public async hasYdocsNewerThanLatestRevision(pageId: string): Promise<boolean> {
+    // get the latest revision createdAt
+    const Revision = mongoose.model<IRevisionHasId>('Revision');
+    const result = await Revision
+      .findOne(
+        // filter
+        { pageId },
+        // projection
+        { createdAt: 1 },
+        { sort: { createdAt: -1 } },
+      );
+
+    const lastRevisionCreatedAt = (result == null)
+      ? 0
+      : result.createdAt.getTime();
+
+    // count yjs-writings documents with updatedAt > latestRevision.updatedAt
+    const ydocUpdatedAt: number | undefined = await this.mdb.getMeta(pageId, 'updatedAt');
+
+    return ydocUpdatedAt == null
+      ? false
+      : ydocUpdatedAt > lastRevisionCreatedAt;
+  }
+
+  public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
+    const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
+    const persistedYdoc = await this.getPersistedYdoc(pageId);
+    const persistedStateVector = Y.encodeStateVector(persistedYdoc);
+
+    await this.mdb.flushDocument(pageId);
+
+    // If no write operation has been performed, insert initial value
+    const clientsSize = persistedYdoc.store.clients.size;
+    if (clientsSize === 0) {
+      currentYdoc.getText('codemirror').insert(0, initialValue);
+    }
+
+    const diff = Y.encodeStateAsUpdate(currentYdoc, persistedStateVector);
+
+    if (diff.reduce((prev, curr) => prev + curr, 0) > 0) {
+      this.mdb.storeUpdate(pageId, diff);
+      this.mdb.setMeta(pageId, 'updatedAt', Date.now());
+    }
+
+    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+    currentYdoc.on('update', async(update) => {
+      this.mdb.storeUpdate(pageId, update);
+      this.mdb.setMeta(pageId, 'updatedAt', Date.now());
+    });
+
+    currentYdoc.on('destroy', async() => {
+      this.mdb.flushDocument(pageId);
+    });
+
+    persistedYdoc.destroy();
+  }
+
+  public async handleYDocUpdate(pageId: string, newValue: string): Promise<void> {
+    // TODO: https://redmine.weseek.co.jp/issues/132775
+    // It's necessary to confirm that the user is not editing the target page in the Editor
+    const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
+    const currentMarkdownLength = currentYdoc.getText('codemirror').length;
+    currentYdoc.getText('codemirror').delete(0, currentMarkdownLength);
+    currentYdoc.getText('codemirror').insert(0, newValue);
+    Y.encodeStateAsUpdate(currentYdoc);
+  }
+
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
+    const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
+    return currentYdoc;
+  }
+
+  public async getPersistedYdoc(pageId: string): Promise<Y.Doc> {
+    const persistedYdoc = await this.mdb.getYDoc(pageId);
+    return persistedYdoc;
+  }
+
+}
+
+let _instance: YjsService;
+
+export const initializeYjsService = (io: Server): void => {
+  if (_instance != null) {
+    throw new Error('YjsService is already initialized');
+  }
+
+  if (io == null) {
+    throw new Error("'io' is required if initialize YjsService");
+  }
+
+  _instance = new YjsService(io);
+};
+
+export const getYjsService = (): YjsService => {
+  if (_instance == null) {
+    throw new Error('YjsService is not initialized yet');
+  }
+
+  return _instance;
+};

+ 4 - 6
apps/app/src/stores/yjs.ts

@@ -10,24 +10,22 @@ import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import { useCurrentPageId } from './page';
 
 type CurrentPageYjsDataUtils = {
-  updateHasRevisionBodyDiff(hasRevisionBodyDiff: boolean): void
+  updateHasYdocsNewerThanLatestRevision(hasYdocsNewerThanLatestRevision: boolean): void
   updateAwarenessStateSize(awarenessStateSize: number): void
 }
 
 export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
   const swrResponse = useSWRStatic<CurrentPageYjsData, Error>('currentPageYjsData', undefined);
 
-  const updateHasRevisionBodyDiff = useCallback((hasRevisionBodyDiff: boolean) => {
-    swrResponse.mutate({ ...swrResponse.data, hasRevisionBodyDiff });
+  const updateHasYdocsNewerThanLatestRevision = useCallback((hasYdocsNewerThanLatestRevision: boolean) => {
+    swrResponse.mutate({ ...swrResponse.data, hasYdocsNewerThanLatestRevision });
   }, [swrResponse]);
 
   const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
     swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
   }, [swrResponse]);
 
-  return {
-    ...swrResponse, updateHasRevisionBodyDiff, updateAwarenessStateSize,
-  };
+  return Object.assign(swrResponse, { updateHasYdocsNewerThanLatestRevision, updateAwarenessStateSize });
 };
 
 export const useSWRMUTxCurrentPageYjsData = (): SWRMutationResponse<CurrentPageYjsData, Error> => {

+ 7 - 10
packages/core/src/interfaces/revision.ts

@@ -1,5 +1,6 @@
 import type { Ref } from './common';
 import type { HasObjectId } from './has-object-id';
+import type { IPage } from './page';
 import type { IUser } from './user';
 
 export const Origin = {
@@ -12,24 +13,20 @@ export type Origin = typeof Origin[keyof typeof Origin];
 export const allOrigin = Object.values(Origin);
 
 export type IRevision = {
+  pageId: Ref<IPage>,
   body: string,
   author: Ref<IUser>,
-  hasDiffToPrev: boolean;
+  format: string,
+  hasDiffToPrev?: boolean;
+  origin?: Origin,
   createdAt: Date,
   updatedAt: Date,
-  origin?: Origin,
 }
 
 export type IRevisionHasId = IRevision & HasObjectId;
 
-type HasPageId = {
-  pageId: string,
-};
-
-export type IRevisionHasPageId = IRevisionHasId & HasPageId;
-
 export type IRevisionsForPagination = {
-  revisions: IRevisionHasPageId[], // revisions in one pagination
+  revisions: IRevisionHasId[], // revisions in one pagination
   totalCounts: number // total counts
 }
 export type HasRevisionShortbody = {
@@ -37,7 +34,7 @@ export type HasRevisionShortbody = {
 }
 
 export type SWRInfinitePageRevisionsResponse = {
-  revisions: IRevisionHasPageId[],
+  revisions: IRevisionHasId[],
   totalCount: number,
   offset: number,
 }

+ 2 - 2
packages/editor/package.json

@@ -63,8 +63,8 @@
     "string-width": "=4.2.2",
     "swr": "^2.2.2",
     "ts-deepmerge": "^6.2.0",
-    "y-codemirror.next": "^0.3.3",
+    "y-codemirror.next": "^0.3.5",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.15"
+    "yjs": "^13.6.18"
   }
 }

+ 1 - 1
packages/pluginkit/src/model/growi-plugin-package-data.ts

@@ -1,4 +1,4 @@
-import { GrowiPluginType } from '@growi/core';
+import type { GrowiPluginType } from '@growi/core';
 
 export type GrowiPluginDirective = {
   [key: string]: any,

+ 19 - 19
yarn.lock

@@ -12148,7 +12148,7 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
 
-lib0@^0.2.31, lib0@^0.2.42, lib0@^0.2.52, lib0@^0.2.86, lib0@^0.2.89:
+lib0@^0.2.31, lib0@^0.2.42, lib0@^0.2.52, lib0@^0.2.86, lib0@^0.2.94:
   version "0.2.94"
   resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.94.tgz#fc28b4b65f816599f1e2f59d3401e231709535b3"
   integrity sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==
@@ -13580,10 +13580,10 @@ mongodb@^5.9.1:
   optionalDependencies:
     "@mongodb-js/saslprep" "^1.1.0"
 
-mongodb@^6.3.0:
-  version "6.6.2"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.6.2.tgz#7ecdd788e9162f6c5726cef40bdd2813cc01e56c"
-  integrity sha512-ZF9Ugo2JCG/GfR7DEb4ypfyJJyiKbg5qBYKRintebj8+DNS33CyGMkWbrS9lara+u+h+yEOGSRiLhFO/g1s1aw==
+mongodb@^6.7.0:
+  version "6.8.0"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.8.0.tgz#680450f113cdea6d2d9f7121fe57cd29111fd2ce"
+  integrity sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw==
   dependencies:
     "@mongodb-js/saslprep" "^1.1.5"
     bson "^6.7.0"
@@ -19350,10 +19350,10 @@ xtend@~2.1.1:
   dependencies:
     object-keys "~0.4.0"
 
-y-codemirror.next@^0.3.3:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/y-codemirror.next/-/y-codemirror.next-0.3.3.tgz#5fd77d2041137c70b6e22f9214c9a4d649d1ae26"
-  integrity sha512-rlL/Ax01Ul7W09L75tiV3R03+qJTYYfjy08AeiETtvFVFDUt+yNkvBvI50Kw3Z1Ypn1J+CEPTuFykHD0iwVo2Q==
+y-codemirror.next@^0.3.5:
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/y-codemirror.next/-/y-codemirror.next-0.3.5.tgz#4bb5c94c09cab17b6fd300e5db1d29c65e51e345"
+  integrity sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==
   dependencies:
     lib0 "^0.2.42"
 
@@ -19365,13 +19365,13 @@ y-leveldb@^0.1.1:
     level "^6.0.1"
     lib0 "^0.2.31"
 
-y-mongodb-provider@^0.1.10:
-  version "0.1.10"
-  resolved "https://registry.yarnpkg.com/y-mongodb-provider/-/y-mongodb-provider-0.1.10.tgz#3aa7f1819f7c7b9712b675c0633618923a8c8dcc"
-  integrity sha512-BNMn2uX4PttdxozTLkEIa2cyHc6ZgNxG6xIVFui2awJ8eJ4tdI/7SNEZ9dKq7JCgbNCEXaCsyWpMfLOQzygFpQ==
+y-mongodb-provider@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/y-mongodb-provider/-/y-mongodb-provider-0.2.0.tgz#db31bf35a1a6b6032da0e9d1cd182fe3f9575b06"
+  integrity sha512-l2Qus1lix7TkxemLGzMJn8HYKiUD+vLJpZxYjtPvdBNbM7THhgVuyHvZYzknUDonvnBBjnpbDaZkKPWOMAsSAw==
   dependencies:
-    lib0 "^0.2.89"
-    mongodb "^6.3.0"
+    lib0 "^0.2.94"
+    mongodb "^6.7.0"
 
 y-protocols@^1.0.5:
   version "1.0.5"
@@ -19522,10 +19522,10 @@ yauzl@^2.10.0:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
 
-yjs@^13.6.15:
-  version "13.6.15"
-  resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.15.tgz#5a2402632aabf83e5baf56342b4c82fe40859306"
-  integrity sha512-moFv4uNYhp8BFxIk3AkpoAnnjts7gwdpiG8RtyFiKbMtxKCS0zVZ5wPaaGpwC3V2N/K8TK8MwtSI3+WO9CHWjQ==
+yjs@^13.6.18:
+  version "13.6.18"
+  resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.18.tgz#d1575203478bc99ad1b89c098e7d4bacb7f91c3b"
+  integrity sha512-GBTjO4QCmv2HFKFkYIJl7U77hIB1o22vSCSQD1Ge8ZxWbIbn8AltI4gyXbtL+g5/GJep67HCMq3Y5AmNwDSyEg==
   dependencies:
     lib0 "^0.2.86"