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

Merge branch 'master' into imprv/148445-upgrade-remark-growi-directive

reiji-h 1 год назад
Родитель
Сommit
e4eef84025
100 измененных файлов с 945 добавлено и 420 удалено
  1. 0 1
      .eslintrc.js
  2. 33 1
      CHANGELOG.md
  3. 2 0
      apps/app/config/logger/config.dev.js
  4. 1 1
      apps/app/docker/README.md
  5. 0 1
      apps/app/next-env.d.ts
  6. 4 1
      apps/app/nodemon.json
  7. 6 6
      apps/app/package.json
  8. BIN
      apps/app/public/favicon.ico
  9. 24 0
      apps/app/public/favicon.svg
  10. BIN
      apps/app/public/images/icons/favicon/android-icon-144x144.png
  11. BIN
      apps/app/public/images/icons/favicon/android-icon-192x192.png
  12. BIN
      apps/app/public/images/icons/favicon/android-icon-36x36.png
  13. BIN
      apps/app/public/images/icons/favicon/android-icon-48x48.png
  14. BIN
      apps/app/public/images/icons/favicon/android-icon-72x72.png
  15. BIN
      apps/app/public/images/icons/favicon/android-icon-96x96.png
  16. BIN
      apps/app/public/images/icons/favicon/apple-icon-114x114.png
  17. BIN
      apps/app/public/images/icons/favicon/apple-icon-120x120.png
  18. BIN
      apps/app/public/images/icons/favicon/apple-icon-144x144.png
  19. BIN
      apps/app/public/images/icons/favicon/apple-icon-152x152.png
  20. BIN
      apps/app/public/images/icons/favicon/apple-icon-180x180.png
  21. BIN
      apps/app/public/images/icons/favicon/apple-icon-57x57.png
  22. BIN
      apps/app/public/images/icons/favicon/apple-icon-60x60.png
  23. BIN
      apps/app/public/images/icons/favicon/apple-icon-72x72.png
  24. BIN
      apps/app/public/images/icons/favicon/apple-icon-76x76.png
  25. BIN
      apps/app/public/images/icons/favicon/apple-icon-precomposed.png
  26. BIN
      apps/app/public/images/icons/favicon/apple-icon.png
  27. 0 2
      apps/app/public/images/icons/favicon/browserconfig.xml
  28. BIN
      apps/app/public/images/icons/favicon/favicon-16x16.png
  29. BIN
      apps/app/public/images/icons/favicon/favicon-32x32.png
  30. BIN
      apps/app/public/images/icons/favicon/favicon-96x96.png
  31. BIN
      apps/app/public/images/icons/favicon/ms-icon-144x144.png
  32. BIN
      apps/app/public/images/icons/favicon/ms-icon-150x150.png
  33. BIN
      apps/app/public/images/icons/favicon/ms-icon-310x310.png
  34. BIN
      apps/app/public/images/icons/favicon/ms-icon-70x70.png
  35. 2 2
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  36. 2 0
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  37. 18 10
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  38. 6 6
      apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx
  39. 4 4
      apps/app/src/client/components/PageHistory/PageRevisionTable.tsx
  40. 3 3
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  41. 9 2
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  42. 3 3
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  43. 7 15
      apps/app/src/client/services/side-effects/yjs.ts
  44. 6 4
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  45. 1 1
      apps/app/src/interfaces/websocket.ts
  46. 1 1
      apps/app/src/interfaces/yjs.ts
  47. 1 1
      apps/app/src/migrations/20180927102719-init-serverurl.js
  48. 1 1
      apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js
  49. 1 1
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  50. 1 1
      apps/app/src/migrations/20200420160390-remove-crowi-layout.js
  51. 1 1
      apps/app/src/migrations/20200512005851-remove-behavior-type.js
  52. 1 1
      apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js
  53. 1 1
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  54. 1 1
      apps/app/src/migrations/20200827045151-remove-layout-setting.js
  55. 1 1
      apps/app/src/migrations/20200828024025-copy-aws-setting.js
  56. 1 1
      apps/app/src/migrations/20200901034313-update-mail-transmission.js
  57. 1 1
      apps/app/src/migrations/20200903080025-remove-timeline-type.js.js
  58. 1 1
      apps/app/src/migrations/20200915035234-rename-s3-config.js
  59. 1 1
      apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  60. 1 1
      apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  61. 1 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  62. 2 3
      apps/app/src/migrations/20220311011114-convert-page-delete-config.js
  63. 1 1
      apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js
  64. 1 1
      apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js
  65. 1 1
      apps/app/src/migrations/20230213090921-remove-presentation-configurations.js
  66. 1 3
      apps/app/src/migrations/20230731075753-add_installed_date_to_config.js
  67. 2 0
      apps/app/src/pages/_document.page.tsx
  68. 3 4
      apps/app/src/server/crowi/index.js
  69. 3 3
      apps/app/src/server/models/config.ts
  70. 1 1
      apps/app/src/server/models/index.ts
  71. 4 1
      apps/app/src/server/models/page.ts
  72. 0 71
      apps/app/src/server/models/revision.js
  73. 82 0
      apps/app/src/server/models/revision.ts
  74. 12 7
      apps/app/src/server/routes/apiv3/healthcheck.ts
  75. 6 4
      apps/app/src/server/routes/apiv3/page/index.ts
  76. 3 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  77. 1 1
      apps/app/src/server/routes/apiv3/revisions.js
  78. 1 1
      apps/app/src/server/routes/tag.js
  79. 4 3
      apps/app/src/server/service/config-loader.ts
  80. 5 5
      apps/app/src/server/service/config-manager.spec.ts
  81. 3 3
      apps/app/src/server/service/config-manager.ts
  82. 12 38
      apps/app/src/server/service/page/index.ts
  83. 0 1
      apps/app/src/server/service/page/page-service.ts
  84. 0 43
      apps/app/src/server/service/socket-io.ts
  85. 0 120
      apps/app/src/server/service/yjs-connection-manager.ts
  86. 43 0
      apps/app/src/server/service/yjs/create-indexes.ts
  87. 55 0
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts
  88. 19 0
      apps/app/src/server/service/yjs/extended/mongodb-persistence.ts
  89. 1 0
      apps/app/src/server/service/yjs/index.ts
  90. 81 0
      apps/app/src/server/service/yjs/sync-ydoc.ts
  91. 157 0
      apps/app/src/server/service/yjs/yjs.integ.ts
  92. 220 0
      apps/app/src/server/service/yjs/yjs.ts
  93. 8 0
      apps/app/src/stores-universal/context.tsx
  94. 32 10
      apps/app/src/stores/editor.tsx
  95. 16 9
      apps/app/src/stores/yjs.ts
  96. 1 1
      apps/slackbot-proxy/package.json
  97. 1 1
      package.json
  98. 1 0
      packages/core/src/consts/index.ts
  99. 15 0
      packages/core/src/consts/ydoc-status.ts
  100. 0 1
      packages/core/src/interfaces/index.ts

+ 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',

+ 33 - 1
CHANGELOG.md

@@ -1,9 +1,41 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.12...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.12](https://github.com/weseek/growi/compare/v7.0.11...v7.0.12) - 2024-07-10
+
+### 🚀 Improvement
+
+* imprv: lang attribute in Html element to correctly reflect locale (#8940) @maeshinshin
+* imprv: Archive importing and exporting (#8943) @yuki-takei
+* imprv: Restrict indexing for full text search when the body length exceeds the threshold (#8937) @yuki-takei
+* imprv: Dark theme support for emoji mart (#8936) @reiji-h
+* imprv: Add env var for set Elasticsearch reindex bulk size (#8933) @yuki-takei
+* imprv: Size for skeleton for tags (#8923) @yuki-takei
+* imprv: Button opacity of TableWithEditButton and DrawioViewerWithEditButton (#8924) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Initialize sanitize option (#8946) @yuki-takei
+* fix: PageTitleHeader rename input status (#8944) @yuki-takei
+* fix: Presentation section tag (#8941) @yuki-takei
+* fix: Page history colorscheme is broken (#8938) @reiji-h
+* imprv: Rename label for bookmark item (#8925) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Refactor Yjs service (#8949) @yuki-takei
+* support: Upgrade y-mongodb-provider (#8953) @yuki-takei
+* support: Typescriptize Revision model (#8954) @yuki-takei
+* support: Typescriptize SocketIoService (#8948) @yuki-takei
+* support: Update GROWI logo type in NoLogin (#8942) @satof3
+* support: Update logo design (#8934) @satof3
+* ci(deps): bump @azure/identity from 4.0.1 to 4.3.0 (#8927) @dependabot
+* support: Upgrade vitest (#8920) @yuki-takei
+* support: Upgrade playwright (#8921) @yuki-takei
+
 ## [v7.0.11](https://github.com/weseek/growi/compare/v7.0.10...v7.0.11) - 2024-06-25
 
 ### 💎 Features

+ 2 - 0
apps/app/config/logger/config.dev.js

@@ -17,6 +17,8 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  'growi:service:yjs': 'debug',
+  'growi:service:yjs:*': 'debug',
   // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.11`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.11/apps/app/docker/Dockerfile)
+* [`7.0.12`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.12/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 0 - 1
apps/app/next-env.d.ts

@@ -1,6 +1,5 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-/// <reference types="next/navigation-types/compat/navigation" />
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.

+ 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"
   ]
 }

+ 6 - 6
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.12-RC.0",
+  "version": "7.0.13-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -148,9 +148,9 @@
     "next-i18next": "^15.2.0",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
-    "nocache": "^3.0.1",
+    "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
-    "nodemailer": "^6.6.2",
+    "nodemailer": "^6.9.14",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
@@ -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",
@@ -230,7 +230,7 @@
     "@swc/jest": "^0.2.36",
     "@testing-library/react": "^14.1.2",
     "@testing-library/user-event": "^14.5.2",
-    "@types/express": "^4.17.11",
+    "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",

BIN
apps/app/public/favicon.ico


+ 24 - 0
apps/app/public/favicon.svg

@@ -0,0 +1,24 @@
+
+<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <style>
+    .logo {
+      fill: #777570;
+    }
+    .bg {
+      fill: white;
+    }
+    @media (prefers-color-scheme: dark) {
+      .logo {
+        fill: #E6E5E3;
+      }
+      .bg {
+        fill: black;
+      }
+    }
+  </style>
+<path class="bg" d="M47.134 22.5C47.6699 23.4282 47.6699 24.5718 47.134 25.5L36.866 43.2846C36.3301 44.2128 35.3397 44.7846 34.2679 44.7846L13.7321 44.7846C12.6603 44.7846 11.6699 44.2128 11.134 43.2846L0.866028 25.5C0.33013 24.5718 0.33013 23.4282 0.866028 22.5L11.134 4.71539C11.6699 3.78719 12.6603 3.21539 13.7321 3.21539L34.268 3.21539C35.3397 3.21539 36.3301 3.78719 36.866 4.71539L47.134 22.5Z"/>
+<path class="logo" d="M16.0962 27.332L12.5626 33.4861C12.4547 33.6759 12.4547 33.9097 12.5626 34.0961L15.2836 38.8337C15.3847 39.0065 15.5904 39.1251 15.7995 39.1251H16.0962L19.4848 33.2286L16.0962 27.332Z"/>
+<path class="logo" d="M33.9938 24.8076L29.3307 32.9272C29.243 33.0763 29.0845 33.2322 28.8148 33.2322H19.4819L16.0933 39.1254H32.2135C32.4226 39.1254 32.5979 39.0203 32.7058 38.8339L40.7642 24.8042H33.9938V24.8076Z"/>
+<path class="logo" d="M40.9127 24.5569C41.024 24.3671 41.0307 24.1536 40.9228 23.9639L38.1985 19.2229C38.0906 19.0331 37.9051 18.9111 37.686 18.9111H21.2892C21.0701 18.9111 20.8712 19.0297 20.7599 19.2127L18.1873 23.686L21.5758 29.5893L24.0676 25.2516C24.226 24.9805 24.516 24.8111 24.8262 24.8111H40.7677L40.9127 24.5603V24.5569Z"/>
+<path class="logo" d="M19.1953 15.2715H35.9292L32.7193 9.68338C32.6114 9.49361 32.426 9.375 32.2068 9.375H15.8101C15.5909 9.375 15.392 9.48344 15.2807 9.67322L7.08068 23.9435C6.97278 24.1333 6.97278 24.3638 7.08068 24.5535L10.2973 30.1519L18.6659 15.5698C18.7738 15.38 18.9761 15.2682 19.1953 15.2682V15.2715Z"/>
+</svg>

BIN
apps/app/public/images/icons/favicon/android-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/android-icon-192x192.png


BIN
apps/app/public/images/icons/favicon/android-icon-36x36.png


BIN
apps/app/public/images/icons/favicon/android-icon-48x48.png


BIN
apps/app/public/images/icons/favicon/android-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/android-icon-96x96.png


BIN
apps/app/public/images/icons/favicon/apple-icon-114x114.png


BIN
apps/app/public/images/icons/favicon/apple-icon-120x120.png


BIN
apps/app/public/images/icons/favicon/apple-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/apple-icon-152x152.png


BIN
apps/app/public/images/icons/favicon/apple-icon-180x180.png


BIN
apps/app/public/images/icons/favicon/apple-icon-57x57.png


BIN
apps/app/public/images/icons/favicon/apple-icon-60x60.png


BIN
apps/app/public/images/icons/favicon/apple-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/apple-icon-76x76.png


BIN
apps/app/public/images/icons/favicon/apple-icon-precomposed.png


BIN
apps/app/public/images/icons/favicon/apple-icon.png


+ 0 - 2
apps/app/public/images/icons/favicon/browserconfig.xml

@@ -1,2 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

BIN
apps/app/public/images/icons/favicon/favicon-16x16.png


BIN
apps/app/public/images/icons/favicon/favicon-32x32.png


BIN
apps/app/public/images/icons/favicon/favicon-96x96.png


BIN
apps/app/public/images/icons/favicon/ms-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/ms-icon-150x150.png


BIN
apps/app/public/images/icons/favicon/ms-icon-310x310.png


BIN
apps/app/public/images/icons/favicon/ms-icon-70x70.png


+ 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]);

+ 2 - 0
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useIsEditable } from '~/stores-universal/context';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
+import { useReservedNextCaretLine } from '~/stores/editor';
 import { useIsLatestRevision } from '~/stores/page';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
@@ -18,6 +19,7 @@ export const DisplaySwitcher = (): JSX.Element => {
   const { data: isLatestRevision } = useIsLatestRevision();
 
   useHashChangedEffect();
+  useReservedNextCaretLine();
 
   return (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>

+ 18 - 10
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -32,6 +32,7 @@ import {
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import {
+  useReservedNextCaretLine,
   useEditorSettings,
   useCurrentIndentSize,
   useEditingMarkdown,
@@ -109,6 +110,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
+  const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -298,19 +300,25 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
-  // set handler to set caret line
+
+  // set caret line if the edit button next to Header is clicked.
   useEffect(() => {
-    const handler = (lineNumber?: number) => {
-      codeMirrorEditor?.setCaretLine(lineNumber);
+    if (codeMirrorEditor?.setCaretLine == null) {
+      return;
+    }
+    if (editorMode === EditorMode.Editor) {
+      codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
+    }
 
-      // TODO: scroll to the caret line
-    };
-    globalEmitter.on('setCaretLine', handler);
+  }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
+
+  // reset caret line if returning to the View.
+  useEffect(() => {
+    if (editorMode === EditorMode.View) {
+      mutateReservedNextCaretLine(0);
+    }
+  }, [editorMode, mutateReservedNextCaretLine]);
 
-    return function cleanup() {
-      globalEmitter.removeListener('setCaretLine', handler);
-    };
-  }, [codeMirrorEditor]);
 
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // // when transitioning to a different page, if the initialValue is the same,

+ 6 - 6
apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx

@@ -121,12 +121,12 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
   newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
   newScrollTop += calcScorllElementByRatio(
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
-      top: editorElements[topEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
+      top: editorElements[topEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
   );
@@ -156,12 +156,12 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
   newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
   newScrollTop += calcScorllElementByRatio(
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
-      top: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
+      top: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
   );

+ 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,

+ 9 - 2
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -9,6 +9,7 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +27,7 @@ declare global {
 
 function setCaretLine(line?: number): void {
   if (line != null) {
-    globalEmitter.emit('setCaretLine', line);
+    globalEmitter.emit('reservedNextCaretLine', line);
   }
 }
 
@@ -66,6 +67,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const router = useRouter();
 
@@ -111,7 +113,12 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
   }, [activateByHash, router.events]);
 
-  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  // TODO: currentPageYjsData?.hasYdocsNewerThanLatestRevision === false make to hide the edit button when a Yjs draft exists
+  // This is because the current conditional logic cannot handle cases where the draft is an empty string.
+  // It will be possible to address this TODO ySyncAnnotation become available for import.
+  // Ref: https://github.com/yjs/y-codemirror.next/pull/30
+  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null
+                            && currentPageYjsData?.hasYdocsNewerThanLatestRevision === false;
 
   return (
     <>

+ 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]);
 };

+ 6 - 4
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -3,19 +3,21 @@ import * as os from 'node:os';
 
 import type { IUserHasId } from '@growi/core';
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { aclService } from '~/server/service/acl';
 import loggerFactory from '~/utils/logger';
 
+import type { IGrowiInfo } from '../../interfaces/growi-info';
 import {
-  GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
+  GrowiWikiType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
 } from '../../interfaces/growi-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
-import QuestionnaireOrder, { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import type { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import QuestionnaireOrder from '../models/questionnaire-order';
 import { isShowableCondition } from '../util/condition';
 
 

+ 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 - 1
apps/app/src/migrations/20180927102719-init-serverurl.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200420160390-remove-crowi-layout.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200512005851-remove-behavior-type.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200620203632-normalize-locale-id.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200827045151-remove-layout-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200828024025-copy-aws-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200903080025-remove-timeline-type.js.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200915035234-rename-s3-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 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);

+ 2 - 3
apps/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -3,8 +3,8 @@ import mongoose from 'mongoose';
 import {
   PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
-import ConfigModel from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { Config } from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:convert-page-delete-config');
@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:migrate:convert-page-delete-config');
 module.exports = {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
     const isNewConfigExists = await Config.count({
       ns: 'crowi',

+ 1 - 1
apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20230213090921-remove-presentation-configurations.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 3
apps/app/src/migrations/20230731075753-add_installed_date_to_config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import ConfigModel from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -12,7 +12,6 @@ module.exports = {
   async up() {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
     const User = getModelSafely('User') || require('~/server/models/user')();
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
@@ -39,7 +38,6 @@ module.exports = {
   async down() {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     if (appInstalled != null) {

+ 2 - 0
apps/app/src/pages/_document.page.tsx

@@ -121,6 +121,8 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
         <Head>
           {this.renderCustomScript(customScript)}
           <link rel="stylesheet" key="link-theme" href={themeHref} />
+          <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+          <link rel="alternate icon" href="/favicon.ico" />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}
         </Head>

+ 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();
 

+ 3 - 3
apps/app/src/server/models/config.ts

@@ -7,7 +7,7 @@ import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-export interface Config {
+export interface IConfig {
   _id: Types.ObjectId;
   ns: string;
   key: string;
@@ -21,7 +21,7 @@ export interface Config {
 interface ModelMethods { any }
 
 
-const schema = new Schema<Config>({
+const schema = new Schema<IConfig>({
   ns: { type: String, required: true },
   key: { type: String, required: true },
   value: { type: String, required: true },
@@ -176,4 +176,4 @@ export const defaultNotificationConfigs: { [key: string]: any } = {
   'slack:token': undefined,
 };
 
-export default getOrCreateModel<Config, ModelMethods>('Config', schema);
+export const Config = getOrCreateModel<IConfig, ModelMethods>('Config', schema);

+ 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';

+ 4 - 1
apps/app/src/server/models/page.ts

@@ -67,7 +67,10 @@ export type CreateMethod = (path: string, body: string, user, options: IOptionsF
 
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
-  findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<PageDocument & HasObjectId>
+  findByIdsAndViewer(
+    pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
+  ): Promise<(PageDocument & HasObjectId)[]>
   findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>

+ 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);

+ 12 - 7
apps/app/src/server/routes/apiv3/healthcheck.js → apps/app/src/server/routes/apiv3/healthcheck.ts

@@ -1,14 +1,17 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
+import nocache from 'nocache';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
+import { Config } from '../../models/config';
 
-const express = require('express');
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
-const router = express.Router();
 
-const noCache = require('nocache');
+const logger = loggerFactory('growi:routes:apiv3:healthcheck');
+
+const router = express.Router();
 
 /**
  * @swagger
@@ -52,7 +55,6 @@ module.exports = (crowi) => {
 
   async function checkMongo(errors, info) {
     try {
-      const Config = crowi.models.Config;
       await Config.findOne({});
 
       info.mongo = 'OK';
@@ -123,8 +125,11 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', noCache(), async(req, res) => {
-    let checkServices = req.query.checkServices || [];
+  router.get('/', nocache(), async(req, res: ApiV3Response) => {
+    let checkServices = (() => {
+      if (req.query.checkServices == null) return [];
+      return Array.isArray(req.query.checkServices) ? req.query.checkServices : [req.query.checkServices];
+    })();
     let isStrictly = req.query.strictly != null;
 
     // for backward compatibility

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

@@ -3,6 +3,7 @@ import path from 'path';
 import type { IPage } from '@growi/core';
 import {
   AllSubscriptionStatusType, PageGrant, SubscriptionStatusType,
+  getIdForRef,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
@@ -15,8 +16,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';
@@ -591,7 +593,8 @@ module.exports = (crowi) => {
     } = page;
     let isGrantNormalized = false;
     try {
-      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
+      const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
+      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
@@ -614,7 +617,7 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+    const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
 
     // user isn't allowed to see parent's grant
     if (parentPage == null) {
@@ -757,7 +760,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.syncWithTheLatestRevisionForce(req.body.pageId);
     }
 
     // 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;

+ 4 - 3
apps/app/src/server/service/config-loader.ts

@@ -4,8 +4,9 @@ import { parseISO } from 'date-fns/parseISO';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import loggerFactory from '~/utils/logger';
 
-import type { Config } from '../models/config';
-import ConfigModel, { defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs } from '../models/config';
+import {
+  Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
+} from '../models/config';
 
 
 const logger = loggerFactory('growi:service:ConfigLoader');
@@ -790,7 +791,7 @@ export default class ConfigLoader {
 
   async loadFromDB(): Promise<any> {
     const config = {};
-    const docs: Config[] = await ConfigModel.find().exec();
+    const docs = await Config.find().exec();
 
     for (const doc of docs) {
       if (!config[doc.ns]) {

+ 5 - 5
apps/app/src/server/service/config-manager.spec.ts

@@ -1,6 +1,6 @@
 import { mock } from 'vitest-mock-extended';
 
-import ConfigModel from '../models/config';
+import { Config } from '../models/config';
 
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
@@ -19,7 +19,7 @@ describe('ConfigManager test', () => {
 
     test('invoke publishUpdateMessage()', async() => {
       // setup
-      ConfigModel.bulkWrite = vi.fn();
+      Config.bulkWrite = vi.fn();
       configManager.loadConfigs = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
 
@@ -28,14 +28,14 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
 
       // then
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
 
     test('does not invoke publishUpdateMessage()', async() => {
       // setup
-      ConfigModel.bulkWrite = vi.fn();
+      Config.bulkWrite = vi.fn();
       configManager.loadConfigs = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
 
@@ -44,7 +44,7 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
 
       // then
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });

+ 3 - 3
apps/app/src/server/service/config-manager.ts

@@ -2,7 +2,7 @@ import { parseISO } from 'date-fns/parseISO';
 
 import loggerFactory from '~/utils/logger';
 
-import ConfigModel from '../models/config';
+import { Config } from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
 
 import type { ConfigObject } from './config-loader';
@@ -204,7 +204,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
         },
       });
     }
-    await ConfigModel.bulkWrite(queries);
+    await Config.bulkWrite(queries);
 
     await this.loadConfigs();
 
@@ -223,7 +223,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
         },
       });
     }
-    await ConfigModel.bulkWrite(queries);
+    await Config.bulkWrite(queries);
 
     await this.loadConfigs();
 

+ 12 - 38
apps/app/src/server/service/page/index.ts

@@ -7,7 +7,7 @@ import type {
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
 } from '@growi/core';
 import {
-  PageGrant, PageStatus, getIdForRef,
+  PageGrant, PageStatus, YDocStatus, getIdForRef,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
@@ -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,6 +63,7 @@ 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';
@@ -832,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,
@@ -1348,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 } });
@@ -1356,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
@@ -1404,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 } });
@@ -1412,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
@@ -1709,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);
@@ -1872,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 } });
@@ -3839,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();
@@ -3901,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;
@@ -4037,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);
 
@@ -4146,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');
@@ -4290,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;
@@ -4444,35 +4435,18 @@ 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 ydocStatus = await yjsService.getYDocStatus(pageId);
+    const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT;
 
     return {
-      hasRevisionBodyDiff,
+      hasYdocsNewerThanLatestRevision,
       awarenessStateSize: currentYdoc?.awareness.states.size,
     };
   }
 
-  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 - 1
apps/app/src/server/service/page/page-service.ts

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

+ 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;
 

+ 0 - 120
apps/app/src/server/service/yjs-connection-manager.ts

@@ -1,120 +0,0 @@
-import type { Server } from 'socket.io';
-import { MongodbPersistence } from 'y-mongodb-provider';
-import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
-import * as Y from 'yjs';
-
-import { getMongoUri } from '../util/mongoose-utils';
-
-const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
-const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
-
-export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
-  const result = ydocId.match(/yjs\/(.*)/);
-  return result?.[1];
-};
-
-class YjsConnectionManager {
-
-  private static instance: YjsConnectionManager;
-
-  private ysocketio: YSocketIO;
-
-  private mdb: MongodbPersistence;
-
-  get ysocketioInstance(): YSocketIO {
-    return this.ysocketio;
-  }
-
-  private constructor(io: Server) {
-    this.ysocketio = new YSocketIO(io);
-    this.ysocketio.initialize();
-
-    this.mdb = new MongodbPersistence(getMongoUri(), {
-      collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
-      flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
-    });
-  }
-
-  public static getInstance(io?: Server) {
-    if (this.instance != null) {
-      return this.instance;
-    }
-
-    if (io == null) {
-      throw new Error("'io' is required if initialize YjsConnectionManager");
-    }
-
-    this.instance = new YjsConnectionManager(io);
-    return this.instance;
-  }
-
-  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);
-    }
-
-    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
-
-    currentYdoc.on('update', async(update) => {
-      await this.mdb.storeUpdate(pageId, update);
-    });
-
-    currentYdoc.on('destroy', async() => {
-      await 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;
-  }
-
-}
-
-export const instantiateYjsConnectionManager = (io: Server): YjsConnectionManager => {
-  return YjsConnectionManager.getInstance(io);
-};
-
-// export the singleton instance
-export const getYjsConnectionManager = (): YjsConnectionManager => {
-  return YjsConnectionManager.getInstance();
-};

+ 43 - 0
apps/app/src/server/service/yjs/create-indexes.ts

@@ -0,0 +1,43 @@
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:yjs:create-indexes');
+
+export const createIndexes = async(collectionName: string): Promise<void> => {
+
+  const collection = mongoose.connection.collection(collectionName);
+
+  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;
+  }
+};

+ 55 - 0
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -0,0 +1,55 @@
+import type { Persistence } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import loggerFactory from '~/utils/logger';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+
+const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
+
+/**
+ * Based on the example by https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#an-other-example
+ * @param mdb
+ * @returns
+ */
+export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => {
+  const persistece: Persistence = {
+    provider: mdb,
+    bindState: async(docName, ydoc) => {
+      logger.debug('bindState', { docName });
+
+      const persistedYdoc = await mdb.getYDoc(docName);
+
+      // get the state vector so we can just store the diffs between client and server
+      const persistedStateVector = Y.encodeStateVector(persistedYdoc);
+      const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
+
+      // store the new data in db (if there is any: empty update is an array of 0s)
+      if (diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0) > 0) {
+        mdb.storeUpdate(docName, diff);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      }
+
+      // send the persisted data to clients
+      Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+      // store updates of the document in db
+      ydoc.on('update', async(update) => {
+        mdb.storeUpdate(docName, update);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      });
+
+      // cleanup some memory
+      persistedYdoc.destroy();
+    },
+    writeState: async(docName) => {
+      logger.debug('writeState', { docName });
+      // This is called when all connections to the document are closed.
+
+      // flush document on close to have the smallest possible database
+      await mdb.flushDocument(docName);
+    },
+  };
+
+  return persistece;
+};

+ 19 - 0
apps/app/src/server/service/yjs/extended/mongodb-persistence.ts

@@ -0,0 +1,19 @@
+import { MongodbPersistence as Original } from 'y-mongodb-provider';
+
+export type MetadataTypesMap = {
+  updatedAt: number,
+}
+type MetadataKeys = keyof MetadataTypesMap;
+
+
+export class MongodbPersistence extends Original {
+
+  async setTypedMeta<K extends MetadataKeys>(docName: string, key: K, value: MetadataTypesMap[K]): Promise<void> {
+    return this.setMeta(docName, key, value);
+  }
+
+  async getTypedMeta<K extends MetadataKeys>(docName: string, key: K): Promise<MetadataTypesMap[K] | undefined> {
+    return await this.getMeta(docName, key) as MetadataTypesMap[K] | undefined;
+  }
+
+}

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

@@ -0,0 +1 @@
+export * from './yjs';

+ 81 - 0
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -0,0 +1,81 @@
+import { Origin, YDocStatus } from '@growi/core';
+import type { Document } from 'y-socket.io/dist/server';
+
+import loggerFactory from '~/utils/logger';
+
+import { Revision } from '../../models/revision';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+
+const logger = loggerFactory('growi:service:yjs:sync-ydoc');
+
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Delta = Array<{insert?:Array<any>|string, delete?:number, retain?:number}>;
+
+type Context = {
+  ydocStatus: YDocStatus,
+}
+
+/**
+ * Sync the text and the meta data with the latest revision body
+ * @param mdb
+ * @param doc
+ * @param context true to force sync
+ */
+export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
+  const pageId = doc.name;
+
+  const revision = await Revision
+    .findOne(
+      // filter
+      { pageId },
+      // projection
+      { body: 1, createdAt: 1, origin: 1 },
+      // options
+      { sort: { createdAt: -1 } },
+    )
+    .lean();
+
+  if (revision == null) {
+    logger.warn(`Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`);
+    return;
+  }
+
+  const shouldSync = context === true
+    || (() => {
+      switch (context.ydocStatus) {
+        case YDocStatus.NEW:
+          return true;
+        case YDocStatus.OUTDATED:
+          // should skip when the YDoc is outdated and the latest revision is created by the editor
+          return revision.origin !== Origin.Editor;
+        default:
+          return false;
+      }
+    })();
+
+  if (shouldSync) {
+    logger.debug(`YDoc for the page ('${pageId}') is synced with the latest revision body`);
+
+    const ytext = doc.getText('codemirror');
+    const delta: Delta = [];
+
+    if (ytext.length > 0) {
+      delta.push({ delete: ytext.length });
+    }
+    if (revision.body != null) {
+      delta.push({ insert: revision.body });
+    }
+
+    ytext.applyDelta(delta, { sanitize: false });
+  }
+
+  const shouldSyncMeta = context === true
+    || context.ydocStatus === YDocStatus.NEW
+    || context.ydocStatus === YDocStatus.OUTDATED;
+
+  if (shouldSyncMeta) {
+    mdb.setMeta(doc.name, 'updatedAt', revision.createdAt.getTime() ?? Date.now());
+  }
+};

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

@@ -0,0 +1,157 @@
+import { YDocStatus } from '@growi/core/dist/consts';
+import { Types } from 'mongoose';
+import type { Server } from 'socket.io';
+import { mock } from 'vitest-mock-extended';
+
+import { Revision } from '../../models/revision';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+import type { IYjsService } from './yjs';
+import { getYjsService, initializeYjsService } from './yjs';
+
+
+vi.mock('y-socket.io/dist/server', () => {
+  const YSocketIO = vi.fn();
+  YSocketIO.prototype.on = 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('getYDocStatus()', () => {
+
+    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 ISOLATED when neither revisions nor YDocs exists', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.ISOLATED);
+    });
+
+    it('returns ISOLATED when no revisions exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', 1000);
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.ISOLATED);
+    });
+
+    it('returns NEW when no YDocs exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '' },
+      ]);
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.NEW);
+    });
+
+    it('returns DRAFT 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.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.DRAFT);
+    });
+
+    it('returns SYNCED', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '', createdAt: new Date(2025, 1, 1) },
+      ]);
+
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2025, 1, 1)).getTime());
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.SYNCED);
+    });
+
+    it('returns OUTDATED 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.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.OUTDATED);
+    });
+
+  });
+});

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

@@ -0,0 +1,220 @@
+import type { IncomingMessage } from 'http';
+
+import type { IPage, IUserHasId } from '@growi/core';
+import { YDocStatus } from '@growi/core/dist/consts';
+import mongoose from 'mongoose';
+import type { Server } from 'socket.io';
+import type { Document } from 'y-socket.io/dist/server';
+import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { RoomPrefix, getRoomNameWithId } from '~/server/util/socket-io-helpers';
+import loggerFactory from '~/utils/logger';
+
+import type { PageModel } from '../../models/page';
+import { Revision } from '../../models/revision';
+
+import { createIndexes } from './create-indexes';
+import { createMongoDBPersistence } from './create-mongodb-persistence';
+import { MongodbPersistence } from './extended/mongodb-persistence';
+import { syncYDoc } from './sync-ydoc';
+
+
+const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
+const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+
+
+const logger = loggerFactory('growi:service:yjs');
+
+
+type RequestWithUser = IncomingMessage & { user: IUserHasId };
+
+
+export interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(pageId: string): Promise<void>;
+  getCurrentYdoc(pageId: string): Ydoc | undefined;
+}
+
+
+class YjsService implements IYjsService {
+
+  private ysocketio: YSocketIO;
+
+  private mdb: MongodbPersistence;
+
+  constructor(io: Server) {
+
+    const 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.mdb = mdb;
+
+    // initialize YSocketIO
+    const ysocketio = new YSocketIO(io);
+    this.injectPersistence(ysocketio, mdb);
+    ysocketio.initialize();
+    this.ysocketio = ysocketio;
+
+    // create indexes
+    createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
+
+    // register middlewares
+    this.registerAccessiblePageChecker(ysocketio);
+
+    ysocketio.on('document-loaded', async(doc: Document) => {
+      const pageId = doc.name;
+
+      const ydocStatus = await this.getYDocStatus(pageId);
+
+      syncYDoc(mdb, doc, { ydocStatus });
+    });
+
+    ysocketio.on('awareness-update', async(doc: Document) => {
+      const pageId = 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 ydocStatus = await this.getYDocStatus(pageId);
+        const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+
+        io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
+      }
+    });
+
+  }
+
+  private injectPersistence(ysocketio: YSocketIO, mdb: MongodbPersistence): void {
+    const persistece = createMongoDBPersistence(mdb);
+
+    // foce set to private property
+    // eslint-disable-next-line dot-notation
+    ysocketio['persistence'] = persistece;
+  }
+
+  private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
+    // check accessible page
+    ysocketio.nsp?.use(async(socket, next) => {
+      // extract page id from namespace
+      const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
+      const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
+
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
+
+      if (!isAccessible) {
+        return next(new Error('Forbidden'));
+      }
+
+      return next();
+    });
+  }
+
+  public async getYDocStatus(pageId: string): Promise<YDocStatus> {
+    const dumpLog = (status: YDocStatus, args?: { [key: string]: number }) => {
+      logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
+    };
+
+    // get the latest revision createdAt
+    const result = await Revision
+      .findOne(
+        // filter
+        { pageId },
+        // projection
+        { createdAt: 1 },
+        { sort: { createdAt: -1 } },
+      )
+      .lean();
+
+    if (result == null) {
+      dumpLog(YDocStatus.ISOLATED);
+      return YDocStatus.ISOLATED;
+    }
+
+    // count yjs-writings documents with updatedAt > latestRevision.updatedAt
+    const ydocUpdatedAt = await this.mdb.getTypedMeta(pageId, 'updatedAt');
+
+    if (ydocUpdatedAt == null) {
+      dumpLog(YDocStatus.NEW);
+      return YDocStatus.NEW;
+    }
+
+    const { createdAt } = result;
+    const lastRevisionCreatedAt = createdAt.getTime();
+
+    if (lastRevisionCreatedAt < ydocUpdatedAt) {
+      dumpLog(YDocStatus.DRAFT, { lastRevisionCreatedAt, ydocUpdatedAt });
+      return YDocStatus.DRAFT;
+    }
+
+    if (lastRevisionCreatedAt === ydocUpdatedAt) {
+      dumpLog(YDocStatus.SYNCED, { lastRevisionCreatedAt, ydocUpdatedAt });
+      return YDocStatus.SYNCED;
+    }
+
+    dumpLog(YDocStatus.OUTDATED, { lastRevisionCreatedAt, ydocUpdatedAt });
+    return YDocStatus.OUTDATED;
+  }
+
+  public async syncWithTheLatestRevisionForce(pageId: string): Promise<void> {
+    const doc = this.ysocketio.documents.get(pageId);
+
+    if (doc == null) {
+      return;
+    }
+
+    syncYDoc(this.mdb, doc, true);
+  }
+
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
+    const currentYdoc = this.ysocketio.documents.get(pageId);
+    return currentYdoc;
+  }
+
+}
+
+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;
+};

+ 8 - 0
apps/app/src/stores-universal/context.tsx

@@ -1,3 +1,7 @@
+import { useCallback, useEffect } from 'react';
+
+import type EventEmitter from 'events';
+
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import { useSWRStatic } from '@growi/core/dist/swr';
@@ -12,6 +16,10 @@ import type { TargetAndAncestors } from '../interfaces/page-listing-results';
 
 import { useContextSWR } from './use-context-swr';
 
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 type Nullable<T> = T | null;
 

+ 32 - 10
apps/app/src/stores/editor.tsx

@@ -1,7 +1,7 @@
-import { useCallback } from 'react';
+import { useCallback, useEffect } from 'react';
 
 import { type Nullable } from '@growi/core';
-import { withUtils, type SWRResponseWithUtils } from '@growi/core/dist/swr';
+import { withUtils, type SWRResponseWithUtils, useSWRStatic } from '@growi/core/dist/swr';
 import type { EditorSettings } from '@growi/editor';
 import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -9,22 +9,21 @@ import useSWRImmutable from 'swr/immutable';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import type { SlackChannels } from '~/interfaces/user-trigger-notification';
-
 import {
   useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
 } from '~/stores-universal/context';
+
 // import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useSWRxTagsInfo } from './page';
-import { useStaticSWR } from './use-static-swr';
 
 
 export const useWaitingSaveProcessing = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR('waitingSaveProcessing', undefined, { fallbackData: false });
+  return useSWRStatic('waitingSaveProcessing', undefined, { fallbackData: false });
 };
 
 
 export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('editingMarkdown', initialData);
+  return useSWRStatic('editingMarkdown', initialData);
 };
 
 
@@ -69,7 +68,7 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
 
 export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
-  return useStaticSWR<number, Error>(
+  return useSWRStatic<number, Error>(
     defaultIndentSize == null ? null : 'currentIndentSize',
     undefined,
     { fallbackData: defaultIndentSize },
@@ -92,7 +91,7 @@ export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResp
 };
 
 export const useIsSlackEnabled = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR(
+  return useSWRStatic(
     'isSlackEnabled',
     undefined,
     { fallbackData: false },
@@ -105,7 +104,7 @@ export type IPageTagsForEditorsOption = {
 
 export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<string[], Error> & IPageTagsForEditorsOption => {
   const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
-  const swrResult = useStaticSWR<string[], Error>('pageTags', undefined);
+  const swrResult = useSWRStatic<string[], Error>('pageTags', undefined);
   const { mutate } = swrResult;
   const sync = useCallback((): void => {
     mutate(tagsInfoData?.tags || [], false);
@@ -118,5 +117,28 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 };
 
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
+  return useSWRStatic<boolean, Error>('isEnabledUnsavedWarning');
+};
+
+
+export const useReservedNextCaretLine = (initialData?: number): SWRResponse<number> => {
+
+  const swrResponse = useSWRStatic('saveNextCaretLine', initialData, { fallbackData: 0 });
+  const { mutate } = swrResponse;
+
+  useEffect(() => {
+    const handler = (lineNumber: number) => {
+      mutate(lineNumber);
+    };
+
+    globalEmitter.on('reservedNextCaretLine', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('reservedNextCaretLine', handler);
+    };
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+  };
 };

+ 16 - 9
apps/app/src/stores/yjs.ts

@@ -10,33 +10,40 @@ 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 { data: currentPageId } = useCurrentPageId();
+
+  const key = currentPageId != null
+    ? `/page/${currentPageId}/yjs-data`
+    : null;
 
-  const updateHasRevisionBodyDiff = useCallback((hasRevisionBodyDiff: boolean) => {
-    swrResponse.mutate({ ...swrResponse.data, hasRevisionBodyDiff });
+  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>(key, undefined);
+
+  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> => {
-  const key = 'currentPageYjsData';
   const { data: currentPageId } = useCurrentPageId();
 
+  const key = currentPageId != null
+    ? `/page/${currentPageId}/yjs-data`
+    : null;
+
   return useSWRMutation(
     key,
-    () => apiv3Get<{ yjsData: CurrentPageYjsData }>(`/page/${currentPageId}/yjs-data`).then(result => result.data.yjsData),
+    ([endpoint]) => apiv3Get<{ yjsData: CurrentPageYjsData }>(endpoint).then(result => result.data.yjsData),
     { populateCache: true, revalidate: false },
   );
 };

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.12-slackbot-proxy.0",
+  "version": "7.0.13-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.12-RC.0",
+  "version": "7.0.13-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 1 - 0
packages/core/src/consts/index.ts

@@ -1,2 +1,3 @@
 export * from './accepted-upload-file-type';
 export * from './growi-plugin';
+export * from './ydoc-status';

+ 15 - 0
packages/core/src/consts/ydoc-status.ts

@@ -0,0 +1,15 @@
+/**
+ * NEW: The document is newly created and not yet synced with the latest revision.
+ * SYNCED: The document is synced with the latest revision.
+ * DRAFT: The document advances as a draft compared to the latest revision
+ * OUTDATED: The document is outdated and needs to be synced with the latest revision.
+ * ISOLATED: The latest revision does not exist and the document is isolated from the page.
+ */
+export const YDocStatus = {
+  NEW: 'new',
+  SYNCED: 'synced',
+  DRAFT: 'draft',
+  OUTDATED: 'outdated',
+  ISOLATED: 'isolated',
+} as const;
+export type YDocStatus = typeof YDocStatus[keyof typeof YDocStatus]

+ 0 - 1
packages/core/src/interfaces/index.ts

@@ -13,4 +13,3 @@ export * from './subscription';
 export * from './tag';
 export * from './user';
 export * from './vite';
-export * from './websocket';

Некоторые файлы не были показаны из-за большого количества измененных файлов