Jelajahi Sumber

configure biome for some service files

Futa Arai 4 bulan lalu
induk
melakukan
afbcd38802
48 mengubah file dengan 2171 tambahan dan 1173 penghapusan
  1. 14 0
      apps/app/.eslintrc.js
  2. 17 13
      apps/app/src/server/service/access-token/access-token-deletion-cron.ts
  3. 154 92
      apps/app/src/server/service/config-manager/config-definition.ts
  4. 17 10
      apps/app/src/server/service/config-manager/config-loader.spec.ts
  5. 19 14
      apps/app/src/server/service/config-manager/config-loader.ts
  6. 100 53
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  7. 75 42
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  8. 56 36
      apps/app/src/server/service/config-manager/config-manager.ts
  9. 80 42
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  10. 71 38
      apps/app/src/server/service/page-listing/page-listing.ts
  11. 26 17
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  12. 26 10
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  13. 4 6
      apps/app/src/server/service/s2s-messaging/base.ts
  14. 0 2
      apps/app/src/server/service/s2s-messaging/handlable.ts
  15. 3 3
      apps/app/src/server/service/s2s-messaging/index.ts
  16. 38 22
      apps/app/src/server/service/s2s-messaging/nchan.ts
  17. 1 1
      apps/app/src/server/service/s2s-messaging/redis.ts
  18. 5 5
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  19. 23 22
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  20. 67 23
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts
  21. 58 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts
  22. 58 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts
  23. 44 31
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts
  24. 27 17
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts
  25. 245 126
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  26. 40 21
      apps/app/src/server/service/search-delegator/private-legacy-pages.ts
  27. 6 5
      apps/app/src/server/service/search-reconnect-context/reconnect-context.js
  28. 18 6
      apps/app/src/server/service/slack-command-handler/create-page-service.js
  29. 35 15
      apps/app/src/server/service/slack-command-handler/error-handler.ts
  30. 6 6
      apps/app/src/server/service/slack-command-handler/help.js
  31. 156 62
      apps/app/src/server/service/slack-command-handler/keep.js
  32. 60 13
      apps/app/src/server/service/slack-command-handler/note.js
  33. 143 89
      apps/app/src/server/service/slack-command-handler/search.js
  34. 11 4
      apps/app/src/server/service/slack-command-handler/slack-command-handler.js
  35. 135 54
      apps/app/src/server/service/slack-command-handler/togetter.js
  36. 10 4
      apps/app/src/server/service/slack-event-handler/base-event-handler.ts
  37. 101 60
      apps/app/src/server/service/slack-event-handler/link-shared.ts
  38. 4 1
      apps/app/src/server/service/socket-io/helper.ts
  39. 27 22
      apps/app/src/server/service/socket-io/socket-io.ts
  40. 25 15
      apps/app/src/server/service/system-events/sync-page-status.ts
  41. 30 14
      apps/app/src/server/service/user-notification/index.ts
  42. 2 4
      apps/app/src/server/service/yjs/create-indexes.ts
  43. 12 5
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts
  44. 14 8
      apps/app/src/server/service/yjs/extended/mongodb-persistence.ts
  45. 34 24
      apps/app/src/server/service/yjs/sync-ydoc.ts
  46. 26 26
      apps/app/src/server/service/yjs/yjs.integ.ts
  47. 47 42
      apps/app/src/server/service/yjs/yjs.ts
  48. 1 14
      biome.json

+ 14 - 0
apps/app/.eslintrc.js

@@ -74,6 +74,20 @@ module.exports = {
     'src/server/routes/apiv3/*.ts',
     'src/server/routes/apiv3/*.ts',
     'src/server/service/*.ts',
     'src/server/service/*.ts',
     'src/server/service/*.js',
     'src/server/service/*.js',
+    'src/server/service/access-token/**',
+    'src/server/service/config-manager/**',
+    'src/server/service/page/**',
+    'src/server/service/page-listing/**',
+    'src/server/service/revision/**',
+    'src/server/service/s2s-messaging/**',
+    'src/server/service/search-delegator/**',
+    'src/server/service/search-reconnect-context/**',
+    'src/server/service/slack-command-handler/**',
+    'src/server/service/slack-event-handler/**',
+    'src/server/service/socket-io/**',
+    'src/server/service/system-events/**',
+    'src/server/service/user-notification/**',
+    'src/server/service/yjs/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 17 - 13
apps/app/src/server/service/access-token/access-token-deletion-cron.ts

@@ -7,14 +7,15 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:access-token-deletion-cron');
 const logger = loggerFactory('growi:service:access-token-deletion-cron');
 
 
 export class AccessTokenDeletionCronService {
 export class AccessTokenDeletionCronService {
-
   cronJob: nodeCron.ScheduledTask;
   cronJob: nodeCron.ScheduledTask;
 
 
   // Default execution at midnight
   // Default execution at midnight
   accessTokenDeletionCronExpression = '0 15 * * *';
   accessTokenDeletionCronExpression = '0 15 * * *';
 
 
   startCron(): void {
   startCron(): void {
-    const cronExp = configManager.getConfig('accessToken:deletionCronExpression');
+    const cronExp = configManager.getConfig(
+      'accessToken:deletionCronExpression',
+    );
     if (cronExp != null) {
     if (cronExp != null) {
       this.accessTokenDeletionCronExpression = cronExp;
       this.accessTokenDeletionCronExpression = cronExp;
     }
     }
@@ -30,23 +31,26 @@ export class AccessTokenDeletionCronService {
     try {
     try {
       await AccessToken.deleteExpiredToken();
       await AccessToken.deleteExpiredToken();
       logger.info('Expired access tokens have been deleted');
       logger.info('Expired access tokens have been deleted');
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('Failed to delete expired access tokens:', e);
       logger.error('Failed to delete expired access tokens:', e);
     }
     }
   }
   }
 
 
   private generateCronJob() {
   private generateCronJob() {
-    return nodeCron.schedule(this.accessTokenDeletionCronExpression, async() => {
-      try {
-        await this.executeJob();
-      }
-      catch (e) {
-        logger.error('Error occurred during access token deletion cron job:', e);
-      }
-    });
+    return nodeCron.schedule(
+      this.accessTokenDeletionCronExpression,
+      async () => {
+        try {
+          await this.executeJob();
+        } catch (e) {
+          logger.error(
+            'Error occurred during access token deletion cron job:',
+            e,
+          );
+        }
+      },
+    );
   }
   }
-
 }
 }
 
 
 export const startCron = (): void => {
 export const startCron = (): void => {

+ 154 - 92
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,14 +1,18 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
-import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces';
-import {
-  toNonBlankString,
-  defineConfig,
+import type {
+  ConfigDefinition,
+  Lang,
+  NonBlankString,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
+import { defineConfig, toNonBlankString } from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
 import type OpenAI from 'openai';
 
 
 import { ActionGroupSize } from '~/interfaces/activity';
 import { ActionGroupSize } from '~/interfaces/activity';
 import { AttachmentMethodType } from '~/interfaces/attachment';
 import { AttachmentMethodType } from '~/interfaces/attachment';
-import type { IPageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
+import type {
+  IPageDeleteConfigValue,
+  IPageDeleteConfigValueToProcessValidation,
+} from '~/interfaces/page-delete-config';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 
 
@@ -331,10 +335,8 @@ export const CONFIG_KEYS = [
   'accessToken:deletionCronExpression',
   'accessToken:deletionCronExpression',
 ] as const;
 ] as const;
 
 
-
 export type ConfigKey = (typeof CONFIG_KEYS)[number];
 export type ConfigKey = (typeof CONFIG_KEYS)[number];
 
 
-
 export const CONFIG_DEFINITIONS = {
 export const CONFIG_DEFINITIONS = {
   // Auto Install Settings
   // Auto Install Settings
   'autoInstall:adminUsername': defineConfig<string | undefined>({
   'autoInstall:adminUsername': defineConfig<string | undefined>({
@@ -438,7 +440,7 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'FILE_UPLOAD_TOTAL_LIMIT',
     envVarName: 'FILE_UPLOAD_TOTAL_LIMIT',
     defaultValue: Infinity,
     defaultValue: Infinity,
   }),
   }),
-  'app:elasticsearchVersion': defineConfig<7|8|9>({
+  'app:elasticsearchVersion': defineConfig<7 | 8 | 9>({
     envVarName: 'ELASTICSEARCH_VERSION',
     envVarName: 'ELASTICSEARCH_VERSION',
     defaultValue: 9,
     defaultValue: 9,
   }),
   }),
@@ -522,10 +524,12 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'OPENAI_THREAD_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
     envVarName: 'OPENAI_THREAD_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
     defaultValue: 30,
     defaultValue: 30,
   }),
   }),
-  'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest': defineConfig<number>({
-    envVarName: 'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
-    defaultValue: 30,
-  }),
+  'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest':
+    defineConfig<number>({
+      envVarName:
+        'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
+      defaultValue: 30,
+    }),
 
 
   // Security Settings
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
   'security:wikiMode': defineConfig<string | undefined>({
@@ -564,10 +568,12 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'LOCAL_STRATEGY_PASSWORD_RESET_ENABLED',
     envVarName: 'LOCAL_STRATEGY_PASSWORD_RESET_ENABLED',
     defaultValue: true,
     defaultValue: true,
   }),
   }),
-  'security:passport-local:isEmailAuthenticationEnabled': defineConfig<boolean>({
-    envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED',
-    defaultValue: false,
-  }),
+  'security:passport-local:isEmailAuthenticationEnabled': defineConfig<boolean>(
+    {
+      envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED',
+      defaultValue: false,
+    },
+  ),
   'security:passport-saml:isEnabled': defineConfig<boolean>({
   'security:passport-saml:isEnabled': defineConfig<boolean>({
     envVarName: 'SAML_ENABLED',
     envVarName: 'SAML_ENABLED',
     defaultValue: false,
     defaultValue: false,
@@ -646,27 +652,37 @@ export const CONFIG_DEFINITIONS = {
   'security:list-policy:hideRestrictedByGroup': defineConfig<boolean>({
   'security:list-policy:hideRestrictedByGroup': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:pageDeletionAuthority': defineConfig<IPageDeleteConfigValueToProcessValidation | undefined>({
+  'security:pageDeletionAuthority': defineConfig<
+    IPageDeleteConfigValueToProcessValidation | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageCompleteDeletionAuthority': defineConfig<IPageDeleteConfigValueToProcessValidation | undefined>({
+  'security:pageCompleteDeletionAuthority': defineConfig<
+    IPageDeleteConfigValueToProcessValidation | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageRecursiveDeletionAuthority': defineConfig<IPageDeleteConfigValue | undefined>({
+  'security:pageRecursiveDeletionAuthority': defineConfig<
+    IPageDeleteConfigValue | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:pageRecursiveCompleteDeletionAuthority': defineConfig<IPageDeleteConfigValue | undefined>({
+  'security:pageRecursiveCompleteDeletionAuthority': defineConfig<
+    IPageDeleteConfigValue | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:isAllGroupMembershipRequiredForPageCompleteDeletion': defineConfig<boolean>({
-    defaultValue: true,
-  }),
+  'security:isAllGroupMembershipRequiredForPageCompleteDeletion':
+    defineConfig<boolean>({
+      defaultValue: true,
+    }),
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:isRomUserAllowedToComment': defineConfig<boolean>({
   'security:isRomUserAllowedToComment': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
@@ -706,30 +722,39 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-ldap:groupDnProperty': defineConfig<string | undefined>({
   'security:passport-ldap:groupDnProperty': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-saml:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-saml:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-saml:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-google:isEnabled': defineConfig<boolean>({
   'security:passport-google:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'security:passport-google:clientId': defineConfig<NonBlankString | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-google:clientSecret': defineConfig<NonBlankString | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-google:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-google:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-google:clientId': defineConfig<NonBlankString | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-google:clientSecret': defineConfig<
+    NonBlankString | undefined
+  >({
+    defaultValue: undefined,
+  }),
+  'security:passport-google:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-google:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-github:isEnabled': defineConfig<boolean>({
   'security:passport-github:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
@@ -739,12 +764,14 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-github:clientSecret': defineConfig<string | undefined>({
   'security:passport-github:clientSecret': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-github:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-github:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-github:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-github:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'security:passport-oidc:clientId': defineConfig<string | undefined>({
   'security:passport-oidc:clientId': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
@@ -757,36 +784,48 @@ export const CONFIG_DEFINITIONS = {
   'security:passport-oidc:issuerHost': defineConfig<string | undefined>({
   'security:passport-oidc:issuerHost': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:authorizationEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:authorizationEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:tokenEndpoint': defineConfig<string | undefined>({
   'security:passport-oidc:tokenEndpoint': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:revocationEndpoint': defineConfig<string | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-oidc:introspectionEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:revocationEndpoint': defineConfig<string | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-oidc:introspectionEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:userInfoEndpoint': defineConfig<string | undefined>({
   'security:passport-oidc:userInfoEndpoint': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:endSessionEndpoint': defineConfig<string | undefined>({
-    defaultValue: undefined,
-  }),
-  'security:passport-oidc:registrationEndpoint': defineConfig<string | undefined>({
+  'security:passport-oidc:endSessionEndpoint': defineConfig<string | undefined>(
+    {
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-oidc:registrationEndpoint': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:jwksUri': defineConfig<string | undefined>({
   'security:passport-oidc:jwksUri': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-  'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
+  'security:passport-oidc:isSameEmailTreatedAsIdenticalUser':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
 
 
   // File Upload Settings
   // File Upload Settings
   'fileUpload:local:useInternalRedirect': defineConfig<boolean>({
   'fileUpload:local:useInternalRedirect': defineConfig<boolean>({
@@ -1051,7 +1090,9 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION',
     envVarName: 'SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'slackbot:withoutProxy:eventActionsPermission': defineConfig<string | undefined>({
+  'slackbot:withoutProxy:eventActionsPermission': defineConfig<
+    string | undefined
+  >({
     envVarName: 'SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION',
     envVarName: 'SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
@@ -1186,28 +1227,40 @@ export const CONFIG_DEFINITIONS = {
   }),
   }),
 
 
   // External User Group Settings
   // External User Group Settings
-  'external-user-group:ldap:groupMembershipAttributeType': defineConfig<string>({
-    defaultValue: 'DN',
-  }),
+  'external-user-group:ldap:groupMembershipAttributeType': defineConfig<string>(
+    {
+      defaultValue: 'DN',
+    },
+  ),
   'external-user-group:ldap:groupSearchBase': defineConfig<string | undefined>({
   'external-user-group:ldap:groupSearchBase': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupMembershipAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupMembershipAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupChildGroupAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupChildGroupAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig<boolean>(
+    {
+      defaultValue: false,
+    },
+  ),
   'external-user-group:ldap:preserveDeletedGroups': defineConfig<boolean>({
   'external-user-group:ldap:preserveDeletedGroups': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'external-user-group:ldap:groupNameAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupNameAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:ldap:groupDescriptionAttribute': defineConfig<string | undefined>({
+  'external-user-group:ldap:groupDescriptionAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'external-user-group:keycloak:host': defineConfig<string | undefined>({
   'external-user-group:keycloak:host': defineConfig<string | undefined>({
@@ -1216,23 +1269,32 @@ export const CONFIG_DEFINITIONS = {
   'external-user-group:keycloak:groupRealm': defineConfig<string | undefined>({
   'external-user-group:keycloak:groupRealm': defineConfig<string | undefined>({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientRealm': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientRealm': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientID': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientID': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'external-user-group:keycloak:groupSyncClientSecret': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupSyncClientSecret': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
     isSecret: true,
     isSecret: true,
   }),
   }),
-  'external-user-group:keycloak:autoGenerateUserOnGroupSync': defineConfig<boolean>({
-    defaultValue: false,
-  }),
+  'external-user-group:keycloak:autoGenerateUserOnGroupSync':
+    defineConfig<boolean>({
+      defaultValue: false,
+    }),
   'external-user-group:keycloak:preserveDeletedGroups': defineConfig<boolean>({
   'external-user-group:keycloak:preserveDeletedGroups': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
-  'external-user-group:keycloak:groupDescriptionAttribute': defineConfig<string | undefined>({
+  'external-user-group:keycloak:groupDescriptionAttribute': defineConfig<
+    string | undefined
+  >({
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
 
 
@@ -1306,7 +1368,11 @@ export const CONFIG_DEFINITIONS = {
 } as const;
 } as const;
 
 
 export type ConfigValues = {
 export type ConfigValues = {
-  [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition<infer T> ? T : never;
+  [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition<
+    infer T
+  >
+    ? T
+    : never;
 };
 };
 
 
 // Define groups of settings that use only environment variables
 // Define groups of settings that use only environment variables
@@ -1339,11 +1405,7 @@ export const ENV_ONLY_GROUPS: EnvOnlyGroup[] = [
   },
   },
   {
   {
     controlKey: 'env:useOnlyEnvVars:gcs',
     controlKey: 'env:useOnlyEnvVars:gcs',
-    targetKeys: [
-      'gcs:apiKeyJsonPath',
-      'gcs:bucket',
-      'gcs:uploadNamespace',
-    ],
+    targetKeys: ['gcs:apiKeyJsonPath', 'gcs:bucket', 'gcs:uploadNamespace'],
   },
   },
   {
   {
     controlKey: 'env:useOnlyEnvVars:azure',
     controlKey: 'env:useOnlyEnvVars:azure',

+ 17 - 10
apps/app/src/server/service/config-manager/config-loader.spec.ts

@@ -16,7 +16,7 @@ vi.mock('../../models/config', () => ({
 describe('ConfigLoader', () => {
 describe('ConfigLoader', () => {
   let configLoader: ConfigLoader;
   let configLoader: ConfigLoader;
 
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     configLoader = new ConfigLoader();
     configLoader = new ConfigLoader();
     vi.clearAllMocks();
     vi.clearAllMocks();
   });
   });
@@ -30,8 +30,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });
@@ -44,8 +45,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });
@@ -54,13 +56,17 @@ describe('ConfigLoader', () => {
       const validJson = { key: 'value' };
       const validJson = { key: 'value' };
       beforeEach(() => {
       beforeEach(() => {
         const mockDocs = [
         const mockDocs = [
-          { key: 'app:referrerPolicy' as ConfigKey, value: JSON.stringify(validJson) },
+          {
+            key: 'app:referrerPolicy' as ConfigKey,
+            value: JSON.stringify(validJson),
+          },
         ];
         ];
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return parsed value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return parsed value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toEqual(validJson);
         expect(config['app:referrerPolicy'].value).toEqual(validJson);
       });
       });
     });
     });
@@ -73,8 +79,9 @@ describe('ConfigLoader', () => {
         mockExec.mockResolvedValue(mockDocs);
         mockExec.mockResolvedValue(mockDocs);
       });
       });
 
 
-      it('should return null for value', async() => {
-        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+      it('should return null for value', async () => {
+        const config: RawConfigData<ConfigKey, ConfigValues> =
+          await configLoader.loadFromDB();
         expect(config['app:referrerPolicy'].value).toBe(null);
         expect(config['app:referrerPolicy'].value).toBe(null);
       });
       });
     });
     });

+ 19 - 14
apps/app/src/server/service/config-manager/config-loader.ts

@@ -9,7 +9,6 @@ import { CONFIG_DEFINITIONS } from './config-definition';
 const logger = loggerFactory('growi:service:ConfigLoader');
 const logger = loggerFactory('growi:service:ConfigLoader');
 
 
 export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
 export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
-
   async loadFromEnv(): Promise<RawConfigData<ConfigKey, ConfigValues>> {
   async loadFromEnv(): Promise<RawConfigData<ConfigKey, ConfigValues>> {
     const envConfig = {} as RawConfigData<ConfigKey, ConfigValues>;
     const envConfig = {} as RawConfigData<ConfigKey, ConfigValues>;
 
 
@@ -19,7 +18,10 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
       if (metadata.envVarName != null) {
       if (metadata.envVarName != null) {
         const envVarValue = process.env[metadata.envVarName];
         const envVarValue = process.env[metadata.envVarName];
         if (envVarValue != null) {
         if (envVarValue != null) {
-          configValue = this.parseEnvValue(envVarValue, typeof metadata.defaultValue) as ConfigValues[ConfigKey];
+          configValue = this.parseEnvValue(
+            envVarValue,
+            typeof metadata.defaultValue,
+          ) as ConfigValues[ConfigKey];
         }
         }
       }
       }
 
 
@@ -43,15 +45,20 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
 
 
     for (const doc of docs) {
     for (const doc of docs) {
       dbConfig[doc.key as ConfigKey] = {
       dbConfig[doc.key as ConfigKey] = {
-        definition: (doc.key in CONFIG_DEFINITIONS) ? CONFIG_DEFINITIONS[doc.key as ConfigKey] : undefined,
-        value: doc.value != null ? (() => {
-          try {
-            return JSON.parse(doc.value);
-          }
-          catch {
-            return null;
-          }
-        })() : null,
+        definition:
+          doc.key in CONFIG_DEFINITIONS
+            ? CONFIG_DEFINITIONS[doc.key as ConfigKey]
+            : undefined,
+        value:
+          doc.value != null
+            ? (() => {
+                try {
+                  return JSON.parse(doc.value);
+                } catch {
+                  return null;
+                }
+              })()
+            : null,
       };
       };
     }
     }
 
 
@@ -70,13 +77,11 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
       case 'object':
       case 'object':
         try {
         try {
           return JSON.parse(value);
           return JSON.parse(value);
-        }
-        catch {
+        } catch {
           return null;
           return null;
         }
         }
       default:
       default:
         return value;
         return value;
     }
     }
   }
   }
-
 }
 }

+ 100 - 53
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -1,31 +1,26 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
-
 import { Config } from '../../models/config';
 import { Config } from '../../models/config';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
-
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
 describe('ConfigManager', () => {
 describe('ConfigManager', () => {
-
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
   });
 
 
-
   describe("getConfig('app:siteUrl')", () => {
   describe("getConfig('app:siteUrl')", () => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       process.env.APP_SITE_URL = 'http://localhost:3000';
       process.env.APP_SITE_URL = 'http://localhost:3000';
 
 
       // remove config from DB
       // remove config from DB
       await Config.deleteOne({ key: 'app:siteUrl' }).exec();
       await Config.deleteOne({ key: 'app:siteUrl' }).exec();
     });
     });
 
 
-    test('returns the env value"', async() => {
+    test('returns the env value"', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
@@ -36,9 +31,12 @@ describe('ConfigManager', () => {
       expect(value).toEqual('http://localhost:3000');
       expect(value).toEqual('http://localhost:3000');
     });
     });
 
 
-    test('returns the db value"', async() => {
+    test('returns the db value"', async () => {
       // arrange
       // arrange
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('https://example.com'),
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -48,10 +46,13 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual('https://example.com');
       expect(value).toStrictEqual('https://example.com');
     });
     });
 
 
-    test('returns the env value when USES_ONLY_ENV_OPTION is set', async() => {
+    test('returns the env value when USES_ONLY_ENV_OPTION is set', async () => {
       // arrange
       // arrange
       process.env.APP_SITE_URL_USES_ONLY_ENV_VARS = 'true';
       process.env.APP_SITE_URL_USES_ONLY_ENV_VARS = 'true';
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('https://example.com'),
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -60,17 +61,17 @@ describe('ConfigManager', () => {
       // assert
       // assert
       expect(value).toEqual('http://localhost:3000');
       expect(value).toEqual('http://localhost:3000');
     });
     });
-
   });
   });
 
 
   describe("getConfig('security:passport-saml:isEnabled')", () => {
   describe("getConfig('security:passport-saml:isEnabled')", () => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       // remove config from DB
       // remove config from DB
-      await Config.deleteOne({ key: 'security:passport-saml:isEnabled' }).exec();
+      await Config.deleteOne({
+        key: 'security:passport-saml:isEnabled',
+      }).exec();
     });
     });
 
 
-    test('returns the default value"', async() => {
+    test('returns the default value"', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
@@ -81,7 +82,7 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual(false);
       expect(value).toStrictEqual(false);
     });
     });
 
 
-    test('returns the env value"', async() => {
+    test('returns the env value"', async () => {
       // arrange
       // arrange
       process.env.SAML_ENABLED = 'true';
       process.env.SAML_ENABLED = 'true';
       await configManager.loadConfigs();
       await configManager.loadConfigs();
@@ -93,10 +94,13 @@ describe('ConfigManager', () => {
       expect(value).toStrictEqual(true);
       expect(value).toStrictEqual(true);
     });
     });
 
 
-    test('returns the preferred db value"', async() => {
+    test('returns the preferred db value"', async () => {
       // arrange
       // arrange
       process.env.SAML_ENABLED = 'true';
       process.env.SAML_ENABLED = 'true';
-      await Config.create({ key: 'security:passport-saml:isEnabled', value: false });
+      await Config.create({
+        key: 'security:passport-saml:isEnabled',
+        value: false,
+      });
       await configManager.loadConfigs();
       await configManager.loadConfigs();
 
 
       // act
       // act
@@ -108,12 +112,15 @@ describe('ConfigManager', () => {
   });
   });
 
 
   describe('updateConfig', () => {
   describe('updateConfig', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('initial value'),
+      });
     });
     });
 
 
-    test('updates a single config', async() => {
+    test('updates a single config', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -127,21 +134,23 @@ describe('ConfigManager', () => {
       expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
       expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
     });
     });
 
 
-    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+    test('removes config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(config?.value).toEqual(JSON.stringify('initial value'));
       expect(config?.value).toEqual(JSON.stringify('initial value'));
 
 
       // act
       // act
-      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+      await configManager.updateConfig('app:siteUrl', undefined, {
+        removeIfUndefined: true,
+      });
 
 
       // assert
       // assert
       const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(updatedConfig).toBeNull(); // should be removed
       expect(updatedConfig).toBeNull(); // should be removed
     });
     });
 
 
-    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+    test('does not update config when value is undefined and removeIfUndefined is false', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -157,16 +166,21 @@ describe('ConfigManager', () => {
   });
   });
 
 
   describe('updateConfigs', () => {
   describe('updateConfigs', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('value1') });
+      await Config.create({
+        key: 'app:siteUrl',
+        value: JSON.stringify('value1'),
+      });
     });
     });
 
 
-    test('updates configs in the same namespace', async() => {
+    test('updates configs in the same namespace', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const config2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const config2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config2).toBeNull();
       expect(config2).toBeNull();
 
 
@@ -175,34 +189,45 @@ describe('ConfigManager', () => {
         'app:siteUrl': 'new value1',
         'app:siteUrl': 'new value1',
         'app:fileUploadType': 'aws',
         'app:fileUploadType': 'aws',
       });
       });
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
 
 
       // assert
       // assert
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
 
 
-    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+    test('removes config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       expect(config1?.value).toEqual(JSON.stringify('value1'));
       expect(config1?.value).toEqual(JSON.stringify('value1'));
 
 
       // act
       // act
-      await configManager.updateConfigs({
-        'app:siteUrl': undefined,
-        'app:fileUploadType': 'aws',
-      }, { removeIfUndefined: true });
+      await configManager.updateConfigs(
+        {
+          'app:siteUrl': undefined,
+          'app:fileUploadType': 'aws',
+        },
+        { removeIfUndefined: true },
+      );
 
 
       // assert
       // assert
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(updatedConfig1).toBeNull(); // should be removed
       expect(updatedConfig1).toBeNull(); // should be removed
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
 
 
-    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+    test('does not update config when value is undefined and removeIfUndefined is false', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
       const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
@@ -215,37 +240,59 @@ describe('ConfigManager', () => {
       });
       });
 
 
       // assert
       // assert
-      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
-      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      const updatedConfig1 = await Config.findOne({
+        key: 'app:siteUrl',
+      }).exec();
+      const updatedConfig2 = await Config.findOne({
+        key: 'app:fileUploadType',
+      }).exec();
       expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
       expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
     });
   });
   });
 
 
   describe('removeConfigs', () => {
   describe('removeConfigs', () => {
-    beforeEach(async() => {
+    beforeEach(async () => {
       await Config.deleteMany({ key: /app.*/ }).exec();
       await Config.deleteMany({ key: /app.*/ }).exec();
-      await Config.create({ key: 'app:serviceType', value: JSON.stringify(GrowiServiceType.onPremise) });
-      await Config.create({ key: 'app:deploymentType', value: JSON.stringify(GrowiDeploymentType.growiDockerCompose) });
+      await Config.create({
+        key: 'app:serviceType',
+        value: JSON.stringify(GrowiServiceType.onPremise),
+      });
+      await Config.create({
+        key: 'app:deploymentType',
+        value: JSON.stringify(GrowiDeploymentType.growiDockerCompose),
+      });
     });
     });
 
 
-    test('removes configs in the same namespace', async() => {
+    test('removes configs in the same namespace', async () => {
       // arrange
       // arrange
       await configManager.loadConfigs();
       await configManager.loadConfigs();
       const config3 = await Config.findOne({ key: 'app:serviceType' }).exec();
       const config3 = await Config.findOne({ key: 'app:serviceType' }).exec();
-      const config4 = await Config.findOne({ key: 'app:deploymentType' }).exec();
-      expect(config3?.value).toEqual(JSON.stringify(GrowiServiceType.onPremise));
-      expect(config4?.value).toEqual(JSON.stringify(GrowiDeploymentType.growiDockerCompose));
+      const config4 = await Config.findOne({
+        key: 'app:deploymentType',
+      }).exec();
+      expect(config3?.value).toEqual(
+        JSON.stringify(GrowiServiceType.onPremise),
+      );
+      expect(config4?.value).toEqual(
+        JSON.stringify(GrowiDeploymentType.growiDockerCompose),
+      );
 
 
       // act
       // act
-      await configManager.removeConfigs(['app:serviceType', 'app:deploymentType']);
-      const removedConfig3 = await Config.findOne({ key: 'app:serviceType' }).exec();
-      const removedConfig4 = await Config.findOne({ key: 'app:deploymentType' }).exec();
+      await configManager.removeConfigs([
+        'app:serviceType',
+        'app:deploymentType',
+      ]);
+      const removedConfig3 = await Config.findOne({
+        key: 'app:serviceType',
+      }).exec();
+      const removedConfig4 = await Config.findOne({
+        key: 'app:deploymentType',
+      }).exec();
 
 
       // assert
       // assert
       expect(removedConfig3).toBeNull();
       expect(removedConfig3).toBeNull();
       expect(removedConfig4).toBeNull();
       expect(removedConfig4).toBeNull();
     });
     });
   });
   });
-
 });
 });

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

@@ -2,7 +2,6 @@ import type { RawConfigData } from '@growi/core/dist/interfaces';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
-
 import type { ConfigKey, ConfigValues } from './config-definition';
 import type { ConfigKey, ConfigValues } from './config-definition';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
@@ -20,36 +19,34 @@ vi.mock('../../models/config', () => ({
   Config: mocks.ConfigMock,
   Config: mocks.ConfigMock,
 }));
 }));
 
 
-
 type ConfigManagerToGetLoader = {
 type ConfigManagerToGetLoader = {
   configLoader: { loadFromDB: () => void };
   configLoader: { loadFromDB: () => void };
-}
-
+};
 
 
 describe('ConfigManager test', () => {
 describe('ConfigManager test', () => {
-
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
   const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
     process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
     configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
   });
 
 
-
   describe('updateConfig()', () => {
   describe('updateConfig()', () => {
-
     let loadConfigsSpy;
     let loadConfigsSpy;
-    beforeEach(async() => {
+    beforeEach(async () => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       // Reset mocks
       // Reset mocks
       mocks.ConfigMock.updateOne.mockClear();
       mocks.ConfigMock.updateOne.mockClear();
       mocks.ConfigMock.deleteOne.mockClear();
       mocks.ConfigMock.deleteOne.mockClear();
     });
     });
 
 
-    test('invoke publishUpdateMessage()', async() => {
+    test('invoke publishUpdateMessage()', async () => {
       // arrenge
       // arrenge
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', '');
       await configManager.updateConfig('app:siteUrl', '');
@@ -60,10 +57,13 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('skip publishUpdateMessage()', async() => {
+    test('skip publishUpdateMessage()', async () => {
       // arrenge
       // arrenge
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', '', { skipPubsub: true });
       await configManager.updateConfig('app:siteUrl', '', { skipPubsub: true });
@@ -74,26 +74,36 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
 
 
-    test('remove config when value is undefined and removeIfUndefined is true', async() => {
+    test('remove config when value is undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+      await configManager.updateConfig('app:siteUrl', undefined, {
+        removeIfUndefined: true,
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
-      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' });
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({
+        key: 'app:siteUrl',
+      });
       expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
       expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('update config with undefined value when removeIfUndefined is false', async() => {
+    test('update config with undefined value when removeIfUndefined is false', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfig('app:siteUrl', undefined);
       await configManager.updateConfig('app:siteUrl', undefined);
@@ -109,25 +119,28 @@ describe('ConfigManager test', () => {
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
-
   });
   });
 
 
   describe('updateConfigs()', () => {
   describe('updateConfigs()', () => {
-
     let loadConfigsSpy;
     let loadConfigsSpy;
-    beforeEach(async() => {
+    beforeEach(async () => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
       // Reset mocks
       // Reset mocks
       mocks.ConfigMock.bulkWrite.mockClear();
       mocks.ConfigMock.bulkWrite.mockClear();
     });
     });
 
 
-    test('invoke publishUpdateMessage()', async() => {
+    test('invoke publishUpdateMessage()', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' });
+      await configManager.updateConfigs({
+        'app:siteUrl': 'https://example.com',
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -135,13 +148,19 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('skip publishUpdateMessage()', async() => {
+    test('skip publishUpdateMessage()', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
+      await configManager.updateConfigs(
+        { 'app:siteUrl': '' },
+        { skipPubsub: true },
+      );
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -149,10 +168,13 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
     });
 
 
-    test('remove configs when values are undefined and removeIfUndefined is true', async() => {
+    test('remove configs when values are undefined and removeIfUndefined is true', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
       await configManager.updateConfigs(
       await configManager.updateConfigs(
@@ -164,7 +186,9 @@ describe('ConfigManager test', () => {
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
       const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
       expect(operations).toHaveLength(2);
       expect(operations).toHaveLength(2);
-      expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } });
+      expect(operations[0]).toEqual({
+        deleteOne: { filter: { key: 'app:siteUrl' } },
+      });
       expect(operations[1]).toEqual({
       expect(operations[1]).toEqual({
         updateOne: {
         updateOne: {
           filter: { key: 'app:title' },
           filter: { key: 'app:title' },
@@ -176,13 +200,19 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
 
 
-    test('update configs including undefined values when removeIfUndefined is false', async() => {
+    test('update configs including undefined values when removeIfUndefined is false', async () => {
       // arrange
       // arrange
       configManager.publishUpdateMessage = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
-      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+      vi.spyOn(
+        (configManager as unknown as ConfigManagerToGetLoader).configLoader,
+        'loadFromDB',
+      ).mockImplementation(vi.fn());
 
 
       // act
       // act
-      await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' });
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:title': 'GROWI',
+      });
 
 
       // assert
       // assert
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
@@ -205,11 +235,10 @@ describe('ConfigManager test', () => {
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
     });
-
   });
   });
 
 
   describe('getManagedEnvVars()', () => {
   describe('getManagedEnvVars()', () => {
-    beforeAll(async() => {
+    beforeAll(async () => {
       process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin';
       process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin';
       process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password';
       process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password';
 
 
@@ -237,19 +266,22 @@ describe('ConfigManager test', () => {
 
 
   describe('getConfig()', () => {
   describe('getConfig()', () => {
     // Helper function to set configs with proper typing
     // Helper function to set configs with proper typing
-    const setTestConfigs = (dbConfig: Partial<TestConfigData>, envConfig: Partial<TestConfigData>): void => {
+    const setTestConfigs = (
+      dbConfig: Partial<TestConfigData>,
+      envConfig: Partial<TestConfigData>,
+    ): void => {
       Object.defineProperties(configManager, {
       Object.defineProperties(configManager, {
         dbConfig: { value: dbConfig, configurable: true },
         dbConfig: { value: dbConfig, configurable: true },
         envConfig: { value: envConfig, configurable: true },
         envConfig: { value: envConfig, configurable: true },
       });
       });
     };
     };
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Reset configs before each test using properly typed empty objects
       // Reset configs before each test using properly typed empty objects
       setTestConfigs({}, {});
       setTestConfigs({}, {});
     });
     });
 
 
-    test('should fallback to env value when dbConfig[key] exists but its value is undefined', async() => {
+    test('should fallback to env value when dbConfig[key] exists but its value is undefined', async () => {
       // Prepare test data that simulates the issue with proper typing
       // Prepare test data that simulates the issue with proper typing
       const dbConfig: Partial<TestConfigData> = {
       const dbConfig: Partial<TestConfigData> = {
         'app:title': { value: undefined },
         'app:title': { value: undefined },
@@ -266,7 +298,7 @@ describe('ConfigManager test', () => {
       expect(result).toBe('GROWI');
       expect(result).toBe('GROWI');
     });
     });
 
 
-    test('should handle various edge case scenarios correctly', async() => {
+    test('should handle various edge case scenarios correctly', async () => {
       // Setup multiple test scenarios with proper typing
       // Setup multiple test scenarios with proper typing
       const dbConfig: Partial<TestConfigData> = {
       const dbConfig: Partial<TestConfigData> = {
         'app:title': { value: undefined }, // db value is explicitly undefined
         'app:title': { value: undefined }, // db value is explicitly undefined
@@ -287,10 +319,11 @@ describe('ConfigManager test', () => {
 
 
       // Test each scenario
       // Test each scenario
       expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined
       expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined
-      expect(configManager.getConfig('app:siteUrl')).toBe('https://example.com'); // Should fallback to env when db value is undefined
+      expect(configManager.getConfig('app:siteUrl')).toBe(
+        'https://example.com',
+      ); // Should fallback to env when db value is undefined
       expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
       expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
       expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
       expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
     });
     });
   });
   });
-
 });
 });

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

@@ -1,4 +1,8 @@
-import type { IConfigManager, UpdateConfigOptions, RawConfigData } from '@growi/core/dist/interfaces';
+import type {
+  IConfigManager,
+  RawConfigData,
+  UpdateConfigOptions,
+} from '@growi/core/dist/interfaces';
 import { ConfigSource } from '@growi/core/dist/interfaces';
 import { ConfigSource } from '@growi/core/dist/interfaces';
 import { parseISO } from 'date-fns/parseISO';
 import { parseISO } from 'date-fns/parseISO';
 
 
@@ -7,17 +11,17 @@ import loggerFactory from '~/utils/logger';
 import type S2sMessage from '../../models/vo/s2s-message';
 import type S2sMessage from '../../models/vo/s2s-message';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
-
 import type { ConfigKey, ConfigValues } from './config-definition';
 import type { ConfigKey, ConfigValues } from './config-definition';
 import { ENV_ONLY_GROUPS } from './config-definition';
 import { ENV_ONLY_GROUPS } from './config-definition';
 import { ConfigLoader } from './config-loader';
 import { ConfigLoader } from './config-loader';
 
 
 const logger = loggerFactory('growi:service:ConfigManager');
 const logger = loggerFactory('growi:service:ConfigManager');
 
 
-export type IConfigManagerForApp = IConfigManager<ConfigKey, ConfigValues>
-
-export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable {
+export type IConfigManagerForApp = IConfigManager<ConfigKey, ConfigValues>;
 
 
+export class ConfigManager
+  implements IConfigManagerForApp, S2sMessageHandlable
+{
   private configLoader: ConfigLoader;
   private configLoader: ConfigLoader;
 
 
   private s2sMessagingService?: S2sMessagingService;
   private s2sMessagingService?: S2sMessagingService;
@@ -48,11 +52,9 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
   async loadConfigs(options?: { source?: ConfigSource }): Promise<void> {
   async loadConfigs(options?: { source?: ConfigSource }): Promise<void> {
     if (options?.source === 'env') {
     if (options?.source === 'env') {
       this.envConfig = await this.configLoader.loadFromEnv();
       this.envConfig = await this.configLoader.loadFromEnv();
-    }
-    else if (options?.source === 'db') {
+    } else if (options?.source === 'db') {
       this.dbConfig = await this.configLoader.loadFromDB();
       this.dbConfig = await this.configLoader.loadFromDB();
-    }
-    else {
+    } else {
       this.envConfig = await this.configLoader.loadFromEnv();
       this.envConfig = await this.configLoader.loadFromEnv();
       this.dbConfig = await this.configLoader.loadFromDB();
       this.dbConfig = await this.configLoader.loadFromDB();
     }
     }
@@ -60,7 +62,10 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     this.lastLoadedAt = new Date();
     this.lastLoadedAt = new Date();
   }
   }
 
 
-  getConfig<K extends ConfigKey>(key: K, source?: ConfigSource): ConfigValues[K] {
+  getConfig<K extends ConfigKey>(
+    key: K,
+    source?: ConfigSource,
+  ): ConfigValues[K] {
     if (source === ConfigSource.env) {
     if (source === ConfigSource.env) {
       if (!this.envConfig) {
       if (!this.envConfig) {
         throw new Error('Config is not loaded');
         throw new Error('Config is not loaded');
@@ -81,7 +86,7 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     return (
     return (
       this.shouldUseEnvOnly(key)
       this.shouldUseEnvOnly(key)
         ? this.envConfig[key]?.value
         ? this.envConfig[key]?.value
-        : this.dbConfig[key]?.value ?? this.envConfig[key]?.value
+        : (this.dbConfig[key]?.value ?? this.envConfig[key]?.value)
     ) as ConfigValues[K];
     ) as ConfigValues[K];
   }
   }
 
 
@@ -107,15 +112,18 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     return this.envConfig[controlKey].value === true;
     return this.envConfig[controlKey].value === true;
   }
   }
 
 
-  async updateConfig<K extends ConfigKey>(key: K, value: ConfigValues[K], options?: UpdateConfigOptions): Promise<void> {
+  async updateConfig<K extends ConfigKey>(
+    key: K,
+    value: ConfigValues[K],
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
     if (options?.removeIfUndefined && value === undefined) {
     if (options?.removeIfUndefined && value === undefined) {
       // remove the config if the value is undefined and removeIfUndefined is true
       // remove the config if the value is undefined and removeIfUndefined is true
       await Config.deleteOne({ key });
       await Config.deleteOne({ key });
-    }
-    else {
+    } else {
       await Config.updateOne(
       await Config.updateOne(
         { key },
         { key },
         { value: JSON.stringify(value) },
         { value: JSON.stringify(value) },
@@ -130,22 +138,25 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     }
     }
   }
   }
 
 
-  async updateConfigs(updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>, options?: UpdateConfigOptions): Promise<void> {
+  async updateConfigs(
+    updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>,
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
     const operations = Object.entries(updates).map(([key, value]) => {
     const operations = Object.entries(updates).map(([key, value]) => {
-      return (options?.removeIfUndefined && value === undefined)
-        // remove the config if the value is undefined
-        ? { deleteOne: { filter: { key } } }
-        // update
-        : {
-          updateOne: {
-            filter: { key },
-            update: { value: JSON.stringify(value) },
-            upsert: true,
-          },
-        };
+      return options?.removeIfUndefined && value === undefined
+        ? // remove the config if the value is undefined
+          { deleteOne: { filter: { key } } }
+        : // update
+          {
+            updateOne: {
+              filter: { key },
+              update: { value: JSON.stringify(value) },
+              upsert: true,
+            },
+          };
     });
     });
 
 
     await Config.bulkWrite(operations);
     await Config.bulkWrite(operations);
@@ -156,11 +167,14 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     }
     }
   }
   }
 
 
-  async removeConfigs(keys: ConfigKey[], options?: UpdateConfigOptions): Promise<void> {
+  async removeConfigs(
+    keys: ConfigKey[],
+    options?: UpdateConfigOptions,
+  ): Promise<void> {
     // Dynamic import to avoid loading database modules too early
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
     const { Config } = await import('../../models/config');
 
 
-    const operations = keys.map(key => ({
+    const operations = keys.map((key) => ({
       deleteOne: {
       deleteOne: {
         filter: { key },
         filter: { key },
       },
       },
@@ -214,12 +228,16 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
   async publishUpdateMessage(): Promise<void> {
   async publishUpdateMessage(): Promise<void> {
     const { default: S2sMessage } = await import('../../models/vo/s2s-message');
     const { default: S2sMessage } = await import('../../models/vo/s2s-message');
 
 
-    const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
+    const s2sMessage = new S2sMessage('configUpdated', {
+      updatedAt: new Date(),
+    });
     try {
     try {
       await this.s2sMessagingService?.publish(s2sMessage);
       await this.s2sMessagingService?.publish(s2sMessage);
-    }
-    catch (e) {
-      logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+    } catch (e) {
+      logger.error(
+        'Failed to publish update message with S2sMessagingService: ',
+        e.message,
+      );
     }
     }
   }
   }
 
 
@@ -231,9 +249,12 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     if (eventName !== 'configUpdated') {
     if (eventName !== 'configUpdated') {
       return false;
       return false;
     }
     }
-    return this.lastLoadedAt == null // loaded for the first time
-      || !('updatedAt' in s2sMessage) // updatedAt is not included in the message
-      || (typeof s2sMessage.updatedAt === 'string' && this.lastLoadedAt < parseISO(s2sMessage.updatedAt));
+    return (
+      this.lastLoadedAt == null || // loaded for the first time
+      !('updatedAt' in s2sMessage) || // updatedAt is not included in the message
+      (typeof s2sMessage.updatedAt === 'string' &&
+        this.lastLoadedAt < parseISO(s2sMessage.updatedAt))
+    );
   }
   }
 
 
   /**
   /**
@@ -243,7 +264,6 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     logger.info('Reload configs by pubsub notification');
     logger.info('Reload configs by pubsub notification');
     return this.loadConfigs();
     return this.loadConfigs();
   }
   }
-
 }
 }
 
 
 // Export singleton instance
 // Export singleton instance

+ 80 - 42
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -1,9 +1,9 @@
 import type { IPage, IUser } from '@growi/core/dist/interfaces';
 import type { IPage, IUser } from '@growi/core/dist/interfaces';
 import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
 import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
-import mongoose from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
 import type { HydratedDocument, Model } from 'mongoose';
+import mongoose from 'mongoose';
 
 
-import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 import type { IPageOperation } from '~/server/models/page-operation';
 import type { IPageOperation } from '~/server/models/page-operation';
 
 
@@ -56,7 +56,7 @@ describe('page-listing store integration tests', () => {
     }
     }
   };
   };
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     // setup models
     // setup models
     const setupPage = (await import('~/server/models/page')).default;
     const setupPage = (await import('~/server/models/page')).default;
     setupPage(null);
     setupPage(null);
@@ -69,7 +69,7 @@ describe('page-listing store integration tests', () => {
     PageOperation = (await import('~/server/models/page-operation')).default;
     PageOperation = (await import('~/server/models/page-operation')).default;
   });
   });
 
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     // Clean up database
     // Clean up database
     await Page.deleteMany({});
     await Page.deleteMany({});
     await User.deleteMany({});
     await User.deleteMany({});
@@ -96,8 +96,9 @@ describe('page-listing store integration tests', () => {
   });
   });
 
 
   describe('pageListingService.findRootByViewer', () => {
   describe('pageListingService.findRootByViewer', () => {
-    test('should return root page successfully', async() => {
-      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+    test('should return root page successfully', async () => {
+      const rootPageResult =
+        await pageListingService.findRootByViewer(testUser);
 
 
       expect(rootPageResult).toBeDefined();
       expect(rootPageResult).toBeDefined();
       expect(rootPageResult.path).toBe('/');
       expect(rootPageResult.path).toBe('/');
@@ -107,7 +108,7 @@ describe('page-listing store integration tests', () => {
       expect(rootPageResult.descendantCount).toBe(0);
       expect(rootPageResult.descendantCount).toBe(0);
     });
     });
 
 
-    test('should handle error when root page does not exist', async() => {
+    test('should handle error when root page does not exist', async () => {
       // Remove the root page
       // Remove the root page
       await Page.deleteOne({ path: '/' });
       await Page.deleteOne({ path: '/' });
 
 
@@ -115,14 +116,14 @@ describe('page-listing store integration tests', () => {
         await pageListingService.findRootByViewer(testUser);
         await pageListingService.findRootByViewer(testUser);
         // Should not reach here
         // Should not reach here
         expect(true).toBe(false);
         expect(true).toBe(false);
-      }
-      catch (error) {
+      } catch (error) {
         expect(error).toBeDefined();
         expect(error).toBeDefined();
       }
       }
     });
     });
 
 
-    test('should return proper page structure that matches IPageForTreeItem type', async() => {
-      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+    test('should return proper page structure that matches IPageForTreeItem type', async () => {
+      const rootPageResult =
+        await pageListingService.findRootByViewer(testUser);
 
 
       // Use helper function to validate type structure
       // Use helper function to validate type structure
       validatePageForTreeItem(rootPageResult);
       validatePageForTreeItem(rootPageResult);
@@ -134,7 +135,7 @@ describe('page-listing store integration tests', () => {
       expect(rootPageResult.parent).toBeNull(); // Root page has no parent
       expect(rootPageResult.parent).toBeNull(); // Root page has no parent
     });
     });
 
 
-    test('should work without user (guest access) and return type-safe result', async() => {
+    test('should work without user (guest access) and return type-safe result', async () => {
       const rootPageResult = await pageListingService.findRootByViewer();
       const rootPageResult = await pageListingService.findRootByViewer();
 
 
       validatePageForTreeItem(rootPageResult);
       validatePageForTreeItem(rootPageResult);
@@ -146,7 +147,7 @@ describe('page-listing store integration tests', () => {
   describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
   describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
     let childPage1: HydratedDocument<IPage>;
     let childPage1: HydratedDocument<IPage>;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Create child pages
       // Create child pages
       childPage1 = await Page.create({
       childPage1 = await Page.create({
         path: '/child1',
         path: '/child1',
@@ -183,14 +184,15 @@ describe('page-listing store integration tests', () => {
       });
       });
 
 
       // Update root page descendant count
       // Update root page descendant count
-      await Page.updateOne(
-        { _id: rootPage._id },
-        { descendantCount: 2 },
-      );
+      await Page.updateOne({ _id: rootPage._id }, { descendantCount: 2 });
     });
     });
 
 
-    test('should find children by parent path and return type-safe results', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should find children by parent path and return type-safe results', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -200,8 +202,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should find children by parent ID and return type-safe results', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+    test('should find children by parent ID and return type-safe results', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          rootPage._id.toString(),
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -210,8 +216,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should handle nested children correctly', async() => {
-      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+    test('should handle nested children correctly', async () => {
+      const nestedChildren =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/child1',
+          testUser,
+        );
 
 
       expect(nestedChildren).toHaveLength(1);
       expect(nestedChildren).toHaveLength(1);
       const grandChild = nestedChildren[0];
       const grandChild = nestedChildren[0];
@@ -220,15 +230,20 @@ describe('page-listing store integration tests', () => {
       expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
       expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
     });
     });
 
 
-    test('should return empty array when no children exist', async() => {
-      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+    test('should return empty array when no children exist', async () => {
+      const noChildren =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/child2',
+          testUser,
+        );
 
 
       expect(noChildren).toHaveLength(0);
       expect(noChildren).toHaveLength(0);
       expect(Array.isArray(noChildren)).toBe(true);
       expect(Array.isArray(noChildren)).toBe(true);
     });
     });
 
 
-    test('should work without user (guest access)', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+    test('should work without user (guest access)', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       children.forEach((child) => {
       children.forEach((child) => {
@@ -236,8 +251,12 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should sort children by path in ascending order', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should sort children by path in ascending order', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(children).toHaveLength(2);
       expect(children).toHaveLength(2);
       expect(children[0].path).toBe('/child1');
       expect(children[0].path).toBe('/child1');
@@ -248,7 +267,7 @@ describe('page-listing store integration tests', () => {
   describe('pageListingService processData injection', () => {
   describe('pageListingService processData injection', () => {
     let operatingPage: HydratedDocument<IPage>;
     let operatingPage: HydratedDocument<IPage>;
 
 
-    beforeEach(async() => {
+    beforeEach(async () => {
       // Create a page that will have operations
       // Create a page that will have operations
       operatingPage = await Page.create({
       operatingPage = await Page.create({
         path: '/operating-page',
         path: '/operating-page',
@@ -282,11 +301,17 @@ describe('page-listing store integration tests', () => {
       });
       });
     });
     });
 
 
-    test('should inject processData for pages with operations', async() => {
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+    test('should inject processData for pages with operations', async () => {
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       // Find the operating page in results
       // Find the operating page in results
-      const operatingResult = children.find(child => child.path === '/operating-page');
+      const operatingResult = children.find(
+        (child) => child.path === '/operating-page',
+      );
       expect(operatingResult).toBeDefined();
       expect(operatingResult).toBeDefined();
 
 
       // Validate type structure
       // Validate type structure
@@ -299,7 +324,7 @@ describe('page-listing store integration tests', () => {
       }
       }
     });
     });
 
 
-    test('should set processData to undefined for pages without operations', async() => {
+    test('should set processData to undefined for pages without operations', async () => {
       // Create another page without operations
       // Create another page without operations
       await Page.create({
       await Page.create({
         path: '/normal-page',
         path: '/normal-page',
@@ -312,8 +337,14 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
-      const normalPage = children.find(child => child.path === '/normal-page');
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
+      const normalPage = children.find(
+        (child) => child.path === '/normal-page',
+      );
 
 
       expect(normalPage).toBeDefined();
       expect(normalPage).toBeDefined();
       if (normalPage) {
       if (normalPage) {
@@ -322,7 +353,7 @@ describe('page-listing store integration tests', () => {
       }
       }
     });
     });
 
 
-    test('should maintain type safety with mixed processData scenarios', async() => {
+    test('should maintain type safety with mixed processData scenarios', async () => {
       // Create pages with and without operations
       // Create pages with and without operations
       await Page.create({
       await Page.create({
         path: '/mixed-test-1',
         path: '/mixed-test-1',
@@ -346,7 +377,11 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const children =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       // All results should be type-safe regardless of processData presence
       // All results should be type-safe regardless of processData presence
       children.forEach((child) => {
       children.forEach((child) => {
@@ -361,7 +396,7 @@ describe('page-listing store integration tests', () => {
   });
   });
 
 
   describe('PageQueryBuilder exec() type safety tests', () => {
   describe('PageQueryBuilder exec() type safety tests', () => {
-    test('findRootByViewer should return object with correct _id type', async() => {
+    test('findRootByViewer should return object with correct _id type', async () => {
       const result = await pageListingService.findRootByViewer(testUser);
       const result = await pageListingService.findRootByViewer(testUser);
 
 
       // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
       // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
@@ -371,7 +406,7 @@ describe('page-listing store integration tests', () => {
       expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
       expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
     });
     });
 
 
-    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async () => {
       // Create test child page first
       // Create test child page first
       await Page.create({
       await Page.create({
         path: '/test-child',
         path: '/test-child',
@@ -384,7 +419,11 @@ describe('page-listing store integration tests', () => {
         parent: rootPage._id,
         parent: rootPage._id,
       });
       });
 
 
-      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const results =
+        await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          '/',
+          testUser,
+        );
 
 
       expect(Array.isArray(results)).toBe(true);
       expect(Array.isArray(results)).toBe(true);
       results.forEach((result) => {
       results.forEach((result) => {
@@ -402,6 +441,5 @@ describe('page-listing store integration tests', () => {
         }
         }
       });
       });
     });
     });
-
   });
   });
 });
 });

+ 71 - 38
apps/app/src/server/service/page-listing/page-listing.ts

@@ -3,38 +3,48 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import mongoose, { type HydratedDocument } from 'mongoose';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 import type { IPageForTreeItem } from '~/interfaces/page';
-import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
-import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import {
+  type IPageOperationProcessData,
+  type IPageOperationProcessInfo,
+  PageActionType,
+} from '~/interfaces/page-operation';
+import {
+  type PageDocument,
+  type PageModel,
+  PageQueryBuilder,
+} from '~/server/models/page';
 import PageOperation from '~/server/models/page-operation';
 import PageOperation from '~/server/models/page-operation';
 
 
 import type { IPageOperationService } from '../page-operation';
 import type { IPageOperationService } from '../page-operation';
 
 
 const { hasSlash, generateChildrenRegExp } = pagePathUtils;
 const { hasSlash, generateChildrenRegExp } = pagePathUtils;
 
 
-
 export interface IPageListingService {
 export interface IPageListingService {
-  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>;
   findChildrenByParentPathOrIdAndViewer(
   findChildrenByParentPathOrIdAndViewer(
     parentPathOrId: string,
     parentPathOrId: string,
     user?: IUser,
     user?: IUser,
     showPagesRestrictedByOwner?: boolean,
     showPagesRestrictedByOwner?: boolean,
     showPagesRestrictedByGroup?: boolean,
     showPagesRestrictedByGroup?: boolean,
-  ): Promise<IPageForTreeItem[]>,
+  ): Promise<IPageForTreeItem[]>;
 }
 }
 
 
 let pageOperationService: IPageOperationService;
 let pageOperationService: IPageOperationService;
 async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
 async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
   if (pageOperationService == null) {
   if (pageOperationService == null) {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+    pageOperationService = await import('../page-operation').then(
+      (mod) => mod.pageOperationService!,
+    );
   }
   }
   return pageOperationService;
   return pageOperationService;
 }
 }
 
 
 class PageListingService implements IPageListingService {
 class PageListingService implements IPageListingService {
-
   async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
   async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
 
 
     const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
     const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
     await builder.addViewerCondition(user);
     await builder.addViewerCondition(user);
@@ -46,38 +56,56 @@ class PageListingService implements IPageListingService {
   }
   }
 
 
   async findChildrenByParentPathOrIdAndViewer(
   async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user?: IUser,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner = false,
+    showPagesRestrictedByGroup = false,
   ): Promise<IPageForTreeItem[]> {
   ): Promise<IPageForTreeItem[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
     let queryBuilder: PageQueryBuilder;
     let queryBuilder: PageQueryBuilder;
     if (hasSlash(parentPathOrId)) {
     if (hasSlash(parentPathOrId)) {
       const path = parentPathOrId;
       const path = parentPathOrId;
       const regexp = generateChildrenRegExp(path);
       const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
+      queryBuilder = new PageQueryBuilder(
+        Page.find({ path: { $regex: regexp } }),
+        true,
+      );
+    } else {
       const parentId = parentPathOrId;
       const parentId = parentPathOrId;
       // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
       // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+      queryBuilder = new PageQueryBuilder(
+        Page.find({ parent: { $eq: parentId } }),
+        true,
+      );
     }
     }
-    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .select('_id path parent revision descendantCount grant isEmpty wip')
-      .lean()
-      .exec();
-
-    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+    await queryBuilder.addViewerCondition(
+      user,
+      null,
+      undefined,
+      showPagesRestrictedByOwner,
+      showPagesRestrictedByGroup,
+    );
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] =
+      await queryBuilder
+        .addConditionToSortPagesByAscPath()
+        .query.select(
+          '_id path parent revision descendantCount grant isEmpty wip',
+        )
+        .lean()
+        .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(
+      pages,
+      [PageActionType.Rename],
+    );
 
 
     // Type-safe conversion to IPageForTreeItem
     // Type-safe conversion to IPageForTreeItem
-    return injectedPages.map(page => (
-      Object.assign(page, { _id: page._id.toString() })
-    ));
+    return injectedPages.map((page) =>
+      Object.assign(page, { _id: page._id.toString() }),
+    );
   }
   }
 
 
   /**
   /**
@@ -85,17 +113,23 @@ class PageListingService implements IPageListingService {
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
    */
    */
   private async injectProcessDataIntoPagesByActionTypes<T>(
   private async injectProcessDataIntoPagesByActionTypes<T>(
-      pages: HydratedDocument<T>[],
-      actionTypes: PageActionType[],
-  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    pages: HydratedDocument<T>[],
+    actionTypes: PageActionType[],
+  ): Promise<
+    (HydratedDocument<T> & { processData?: IPageOperationProcessData })[]
+  > {
+    const pageOperations = await PageOperation.find({
+      actionType: { $in: actionTypes },
+    });
     if (pageOperations == null || pageOperations.length === 0) {
     if (pageOperations == null || pageOperations.length === 0) {
-      return pages.map(page => Object.assign(page, { processData: undefined }));
+      return pages.map((page) =>
+        Object.assign(page, { processData: undefined }),
+      );
     }
     }
 
 
     const pageOperationService = await getPageOperationServiceInstance();
     const pageOperationService = await getPageOperationServiceInstance();
-    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const processInfo: IPageOperationProcessInfo =
+      pageOperationService.generateProcessInfo(pageOperations);
     const operatingPageIds: string[] = Object.keys(processInfo);
     const operatingPageIds: string[] = Object.keys(processInfo);
 
 
     // inject processData into pages
     // inject processData into pages
@@ -108,7 +142,6 @@ class PageListingService implements IPageListingService {
       return Object.assign(page, { processData: undefined });
       return Object.assign(page, { processData: undefined });
     });
     });
   }
   }
-
 }
 }
 
 
 export const pageListingService = new PageListingService();
 export const pageListingService = new PageListingService();

+ 26 - 17
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -9,14 +9,14 @@ import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
 
 
 describe('normalizeLatestRevisionIfBroken', () => {
 describe('normalizeLatestRevisionIfBroken', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await PageModelFactory(null);
     await PageModelFactory(null);
   });
   });
 
 
-
-  test('should update the latest revision', async() => {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  test('should update the latest revision', async () => {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+      'Page',
+    );
 
 
     // == Arrange
     // == Arrange
     const page = await Page.create({ path: '/foo' });
     const page = await Page.create({ path: '/foo' });
@@ -25,7 +25,10 @@ describe('normalizeLatestRevisionIfBroken', () => {
     page.revision = revision._id;
     page.revision = revision._id;
     await page.save();
     await page.save();
     // break the revision
     // break the revision
-    await Revision.updateOne({ _id: revision._id }, { pageId: new Types.ObjectId() });
+    await Revision.updateOne(
+      { _id: revision._id },
+      { pageId: new Types.ObjectId() },
+    );
 
 
     // spy
     // spy
     const updateOneSpy = vi.spyOn(Revision, 'updateOne');
     const updateOneSpy = vi.spyOn(Revision, 'updateOne');
@@ -48,10 +51,11 @@ describe('normalizeLatestRevisionIfBroken', () => {
     expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
     expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString());
   });
   });
 
 
-
   describe('should returns without any operation', () => {
   describe('should returns without any operation', () => {
-    test('when the page has revisions at least one', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page has revisions at least one', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const page = await Page.create({ path: '/foo' });
       const page = await Page.create({ path: '/foo' });
@@ -66,7 +70,7 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page is not found', async() => {
+    test('when the page is not found', async () => {
       // Arrange
       // Arrange
       const pageIdOfRevision = new Types.ObjectId();
       const pageIdOfRevision = new Types.ObjectId();
       // create an orphan revision
       // create an orphan revision
@@ -82,8 +86,10 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page.revision is null', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page.revision is null', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const page = await Page.create({ path: '/foo' });
       const page = await Page.create({ path: '/foo' });
@@ -100,12 +106,17 @@ describe('normalizeLatestRevisionIfBroken', () => {
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
 
 
-    test('when the page.revision does not exist', async() => {
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    test('when the page.revision does not exist', async () => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+        'Page',
+      );
 
 
       // Arrange
       // Arrange
       const revisionNonExistent = new Types.ObjectId();
       const revisionNonExistent = new Types.ObjectId();
-      const page = await Page.create({ path: '/foo', revision: revisionNonExistent });
+      const page = await Page.create({
+        path: '/foo',
+        revision: revisionNonExistent,
+      });
       // create an orphan revision
       // create an orphan revision
       await Revision.create({ pageId: page._id, body: '' });
       await Revision.create({ pageId: page._id, body: '' });
 
 
@@ -118,7 +129,5 @@ describe('normalizeLatestRevisionIfBroken', () => {
       // Assert
       // Assert
       expect(updateOneSpy).not.toHaveBeenCalled();
       expect(updateOneSpy).not.toHaveBeenCalled();
     });
     });
-
   });
   });
-
 });
 });

+ 26 - 10
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts

@@ -5,31 +5,47 @@ import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
-const logger = loggerFactory('growi:service:revision:normalize-latest-revision');
+const logger = loggerFactory(
+  'growi:service:revision:normalize-latest-revision',
+);
 
 
 /**
 /**
  * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
  * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
  *
  *
  * @ref https://github.com/growilabs/growi/pull/8998
  * @ref https://github.com/growilabs/growi/pull/8998
  */
  */
-export const normalizeLatestRevisionIfBroken = async(pageId: string | Types.ObjectId): Promise<void> => {
-
+export const normalizeLatestRevisionIfBroken = async (
+  pageId: string | Types.ObjectId,
+): Promise<void> => {
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
     return;
     return;
   }
   }
 
 
-  logger.info(`The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`);
+  logger.info(
+    `The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`,
+  );
 
 
-  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-  const page = await Page.findOne({ _id: { $eq: pageId } }, { revision: 1 }).exec();
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+    'Page',
+  );
+  const page = await Page.findOne(
+    { _id: { $eq: pageId } },
+    { revision: 1 },
+  ).exec();
 
 
   if (page == null) {
   if (page == null) {
-    logger.warn(`Normalization has been canceled since the page ('${pageId}') could not be found.`);
+    logger.warn(
+      `Normalization has been canceled since the page ('${pageId}') could not be found.`,
+    );
     return;
     return;
   }
   }
-  if (page.revision == null || !(await Revision.exists({ _id: page.revision }))) {
-    logger.warn(`Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`);
+  if (
+    page.revision == null ||
+    !(await Revision.exists({ _id: page.revision }))
+  ) {
+    logger.warn(
+      `Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`,
+    );
     return;
     return;
   }
   }
 
 

+ 4 - 6
apps/app/src/server/service/s2s-messaging/base.ts

@@ -9,7 +9,6 @@ import type { S2sMessageHandlable } from './handlable';
 const logger = loggerFactory('growi:service:s2s-messaging:base');
 const logger = loggerFactory('growi:service:s2s-messaging:base');
 
 
 export interface S2sMessagingService {
 export interface S2sMessagingService {
-
   uid: number;
   uid: number;
 
 
   uri: string;
   uri: string;
@@ -37,11 +36,11 @@ export interface S2sMessagingService {
    * @param handlable
    * @param handlable
    */
    */
   removeMessageHandler(handlable: S2sMessageHandlable): void;
   removeMessageHandler(handlable: S2sMessageHandlable): void;
-
 }
 }
 
 
-export abstract class AbstractS2sMessagingService implements S2sMessagingService {
-
+export abstract class AbstractS2sMessagingService
+  implements S2sMessagingService
+{
   uid: number;
   uid: number;
 
 
   uri: string;
   uri: string;
@@ -84,7 +83,6 @@ export abstract class AbstractS2sMessagingService implements S2sMessagingService
    * @param handlable
    * @param handlable
    */
    */
   removeMessageHandler(handlable: S2sMessageHandlable): void {
   removeMessageHandler(handlable: S2sMessageHandlable): void {
-    this.handlableList = this.handlableList.filter(h => h !== handlable);
+    this.handlableList = this.handlableList.filter((h) => h !== handlable);
   }
   }
-
 }
 }

+ 0 - 2
apps/app/src/server/service/s2s-messaging/handlable.ts

@@ -2,9 +2,7 @@
  * The interface to handle server-to-server message
  * The interface to handle server-to-server message
  */
  */
 export interface S2sMessageHandlable {
 export interface S2sMessageHandlable {
-
   shouldHandleS2sMessage(s2sMessage): boolean;
   shouldHandleS2sMessage(s2sMessage): boolean;
 
 
   handleS2sMessage(s2sMessage): Promise<void>;
   handleS2sMessage(s2sMessage): Promise<void>;
-
 }
 }

+ 3 - 3
apps/app/src/server/service/s2s-messaging/index.ts

@@ -3,7 +3,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { S2sMessagingService } from './base';
 import type { S2sMessagingService } from './base';
 
 
-const logger = loggerFactory('growi:service:s2s-messaging:S2sMessagingServiceFactory');
+const logger = loggerFactory(
+  'growi:service:s2s-messaging:S2sMessagingServiceFactory',
+);
 
 
 const envToModuleMappings = {
 const envToModuleMappings = {
   redis: 'redis',
   redis: 'redis',
@@ -40,7 +42,6 @@ const envToModuleMappings = {
  * Instanciate server-to-server messaging service
  * Instanciate server-to-server messaging service
  */
  */
 class S2sMessagingServiceFactory {
 class S2sMessagingServiceFactory {
-
   delegator!: S2sMessagingService;
   delegator!: S2sMessagingService;
 
 
   initializeDelegator(crowi: Crowi) {
   initializeDelegator(crowi: Crowi) {
@@ -70,7 +71,6 @@ class S2sMessagingServiceFactory {
     }
     }
     return this.delegator;
     return this.delegator;
   }
   }
-
 }
 }
 
 
 const factory = new S2sMessagingServiceFactory();
 const factory = new S2sMessagingServiceFactory();

+ 38 - 22
apps/app/src/server/service/s2s-messaging/nchan.ts

@@ -1,7 +1,6 @@
-import path from 'path';
-
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging
 // biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging
 import axios from 'axios';
 import axios from 'axios';
+import path from 'path';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';
 import WebSocket from 'ws';
 
 
@@ -9,14 +8,11 @@ import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';
-
 import { AbstractS2sMessagingService } from './base';
 import { AbstractS2sMessagingService } from './base';
 
 
 const logger = loggerFactory('growi:service:s2s-messaging:nchan');
 const logger = loggerFactory('growi:service:s2s-messaging:nchan');
 
 
-
 class NchanDelegator extends AbstractS2sMessagingService {
 class NchanDelegator extends AbstractS2sMessagingService {
-
   /**
   /**
    * A list of S2sMessageHandlable instance
    * A list of S2sMessageHandlable instance
    */
    */
@@ -24,7 +20,12 @@ class NchanDelegator extends AbstractS2sMessagingService {
 
 
   socket: any = null;
   socket: any = null;
 
 
-  constructor(uri, private publishPath: string, private subscribePath: string, private channelId: any) {
+  constructor(
+    uri,
+    private publishPath: string,
+    private subscribePath: string,
+    private channelId: any,
+  ) {
     super(uri);
     super(uri);
   }
   }
 
 
@@ -41,9 +42,10 @@ class NchanDelegator extends AbstractS2sMessagingService {
   subscribe(forceReconnect = false) {
   subscribe(forceReconnect = false) {
     if (forceReconnect) {
     if (forceReconnect) {
       logger.info('Force reconnecting is requested. Try to reconnect...');
       logger.info('Force reconnecting is requested. Try to reconnect...');
-    }
-    else if (this.socket != null && this.shouldResubscribe()) {
-      logger.info('The connection to config pubsub server is offline. Try to reconnect...');
+    } else if (this.socket != null && this.shouldResubscribe()) {
+      logger.info(
+        'The connection to config pubsub server is offline. Try to reconnect...',
+      );
     }
     }
 
 
     // init client
     // init client
@@ -111,9 +113,10 @@ class NchanDelegator extends AbstractS2sMessagingService {
   }
   }
 
 
   constructUrl(basepath) {
   constructUrl(basepath) {
-    const pathname = this.channelId == null
-      ? basepath //                                 /pubsub
-      : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
+    const pathname =
+      this.channelId == null
+        ? basepath //                                 /pubsub
+        : path.join(basepath, this.channelId); //     /pubsub/my-channel-id
 
 
     return new URL(pathname, this.uri);
     return new URL(pathname, this.uri);
   }
   }
@@ -138,7 +141,9 @@ class NchanDelegator extends AbstractS2sMessagingService {
       logger.info('WebSocket client connected.');
       logger.info('WebSocket client connected.');
     });
     });
 
 
-    this.handlableList.forEach(handlable => this.registerMessageHandlerToSocket(handlable));
+    this.handlableList.forEach((handlable) =>
+      this.registerMessageHandlerToSocket(handlable),
+    );
 
 
     this.socket = socket;
     this.socket = socket;
   }
   }
@@ -157,26 +162,31 @@ class NchanDelegator extends AbstractS2sMessagingService {
 
 
       // check uid
       // check uid
       if (s2sMessage.publisherUid === this.uid) {
       if (s2sMessage.publisherUid === this.uid) {
-        logger.debug(`Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, `from ${this.uid}`);
+        logger.debug(
+          `Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`,
+          `from ${this.uid}`,
+        );
         return;
         return;
       }
       }
 
 
       // check shouldHandleS2sMessage
       // check shouldHandleS2sMessage
       const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage);
       const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage);
-      logger.debug(`${handlable.constructor.name}.shouldHandleS2sMessage(`, s2sMessage, `) => ${shouldHandle}`);
+      logger.debug(
+        `${handlable.constructor.name}.shouldHandleS2sMessage(`,
+        s2sMessage,
+        `) => ${shouldHandle}`,
+      );
 
 
       if (shouldHandle) {
       if (shouldHandle) {
         handlable.handleS2sMessage(s2sMessage);
         handlable.handleS2sMessage(s2sMessage);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.warn('Could not handle a message: ', err.message);
       logger.warn('Could not handle a message: ', err.message);
     }
     }
   }
   }
-
 }
 }
 
 
-module.exports = function(crowi: Crowi) {
+module.exports = (crowi: Crowi) => {
   const { configManager } = crowi;
   const { configManager } = crowi;
 
 
   const uri = configManager.getConfig('app:nchanUri');
   const uri = configManager.getConfig('app:nchanUri');
@@ -187,9 +197,15 @@ module.exports = function(crowi: Crowi) {
     return;
     return;
   }
   }
 
 
-  const publishPath = configManager.getConfig('s2sMessagingPubsub:nchan:publishPath');
-  const subscribePath = configManager.getConfig('s2sMessagingPubsub:nchan:subscribePath');
-  const channelId = configManager.getConfig('s2sMessagingPubsub:nchan:channelId');
+  const publishPath = configManager.getConfig(
+    's2sMessagingPubsub:nchan:publishPath',
+  );
+  const subscribePath = configManager.getConfig(
+    's2sMessagingPubsub:nchan:subscribePath',
+  );
+  const channelId = configManager.getConfig(
+    's2sMessagingPubsub:nchan:channelId',
+  );
 
 
   return new NchanDelegator(uri, publishPath, subscribePath, channelId);
   return new NchanDelegator(uri, publishPath, subscribePath, channelId);
 };
 };

+ 1 - 1
apps/app/src/server/service/s2s-messaging/redis.ts

@@ -3,6 +3,6 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:s2s-messaging:redis');
 const logger = loggerFactory('growi:service:s2s-messaging:redis');
 
 
-module.exports = function(crowi: Crowi) {
+module.exports = (crowi: Crowi) => {
   logger.warn('Config pub/sub with Redis has not implemented yet.');
   logger.warn('Config pub/sub with Redis has not implemented yet.');
 };
 };

+ 5 - 5
apps/app/src/server/service/search-delegator/aggregate-to-index.ts

@@ -3,11 +3,11 @@ import type { PipelineStage, Query } from 'mongoose';
 
 
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 
 
-export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Query<PageModel, IPage>): PipelineStage[] => {
-
-  const basePipeline = query == null
-    ? []
-    : [{ $match: query.getQuery() }];
+export const aggregatePipelineToIndex = (
+  maxBodyLengthToIndex: number,
+  query?: Query<PageModel, IPage>,
+): PipelineStage[] => {
+  const basePipeline = query == null ? [] : [{ $match: query.getQuery() }];
 
 
   return [
   return [
     ...basePipeline,
     ...basePipeline,

+ 23 - 22
apps/app/src/server/service/search-delegator/bulk-write.d.ts

@@ -1,7 +1,8 @@
 import type { IPageHasId, PageGrant } from '@growi/core';
 import type { IPageHasId, PageGrant } from '@growi/core';
 
 
-export type AggregatedPage = Pick<IPageHasId,
-  '_id'
+export type AggregatedPage = Pick<
+  IPageHasId,
+  | '_id'
   | 'path'
   | 'path'
   | 'createdAt'
   | 'createdAt'
   | 'updatedAt'
   | 'updatedAt'
@@ -9,34 +10,34 @@ export type AggregatedPage = Pick<IPageHasId,
   | 'grantedUsers'
   | 'grantedUsers'
   | 'grantedGroups'
   | 'grantedGroups'
 > & {
 > & {
-  revision: { body: string },
-  comments: string[],
-  commentsCount: number,
-  bookmarksCount: number,
-  likeCount: number,
-  seenUsersCount: number,
+  revision: { body: string };
+  comments: string[];
+  commentsCount: number;
+  bookmarksCount: number;
+  likeCount: number;
+  seenUsersCount: number;
   creator?: {
   creator?: {
-    username: string,
-    email: string,
-  },
+    username: string;
+    email: string;
+  };
 } & {
 } & {
-  tagNames: string[],
-  revisionBodyEmbedded?: number[],
+  tagNames: string[];
+  revisionBodyEmbedded?: number[];
 };
 };
 
 
 export type BulkWriteCommand = {
 export type BulkWriteCommand = {
   index: {
   index: {
-    _index: string,
-    _type: '_doc' | undefined,
-    _id: string,
-  },
-}
+    _index: string;
+    _type: '_doc' | undefined;
+    _id: string;
+  };
+};
 
 
 export type BulkWriteBodyRestriction = {
 export type BulkWriteBodyRestriction = {
-  grant: PageGrant,
-  granted_users?: string[],
-  granted_groups: string[],
-}
+  grant: PageGrant;
+  granted_users?: string[];
+  granted_groups: string[];
+};
 
 
 export type BulkWriteBody = {
 export type BulkWriteBody = {
   path: string;
   path: string;

+ 67 - 23
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts

@@ -1,16 +1,15 @@
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
 import {
 import {
+  type ApiResponse,
   Client,
   Client,
   type ClientOptions,
   type ClientOptions,
-  type ApiResponse,
-  type RequestParams,
   type estypes,
   type estypes,
+  type RequestParams,
 } from '@elastic/elasticsearch7';
 } from '@elastic/elasticsearch7';
 
 
 import type { ES7SearchQuery } from './interfaces';
 import type { ES7SearchQuery } from './interfaces';
 
 
 export class ES7ClientDelegator {
 export class ES7ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 7 as const;
   delegatorVersion = 7 as const;
@@ -25,53 +24,98 @@ export class ES7ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: RequestParams.CatAliases): Promise<ApiResponse<estypes.CatAliasesResponse>> => this.client.cat.aliases(params),
-    indices: (params: RequestParams.CatIndices): Promise<ApiResponse<estypes.CatIndicesResponse>> => this.client.cat.indices(params),
+    aliases: (
+      params: RequestParams.CatAliases,
+    ): Promise<ApiResponse<estypes.CatAliasesResponse>> =>
+      this.client.cat.aliases(params),
+    indices: (
+      params: RequestParams.CatIndices,
+    ): Promise<ApiResponse<estypes.CatIndicesResponse>> =>
+      this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<ApiResponse<estypes.ClusterHealthResponse>> => this.client.cluster.health(),
+    health: (): Promise<ApiResponse<estypes.ClusterHealthResponse>> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: RequestParams.IndicesCreate): Promise<ApiResponse<estypes.IndicesCreateResponse>> => this.client.indices.create(params),
-    delete: (params: RequestParams.IndicesDelete): Promise<ApiResponse<estypes.IndicesDeleteResponse>> => this.client.indices.delete(params),
-    exists: async(params: RequestParams.IndicesExists): Promise<estypes.IndicesExistsResponse> => {
+    create: (
+      params: RequestParams.IndicesCreate,
+    ): Promise<ApiResponse<estypes.IndicesCreateResponse>> =>
+      this.client.indices.create(params),
+    delete: (
+      params: RequestParams.IndicesDelete,
+    ): Promise<ApiResponse<estypes.IndicesDeleteResponse>> =>
+      this.client.indices.delete(params),
+    exists: async (
+      params: RequestParams.IndicesExists,
+    ): Promise<estypes.IndicesExistsResponse> => {
       return (await this.client.indices.exists(params)).body;
       return (await this.client.indices.exists(params)).body;
     },
     },
-    existsAlias: async(params: RequestParams.IndicesExistsAlias): Promise<estypes.IndicesExistsAliasResponse> => {
+    existsAlias: async (
+      params: RequestParams.IndicesExistsAlias,
+    ): Promise<estypes.IndicesExistsAliasResponse> => {
       return (await this.client.indices.existsAlias(params)).body;
       return (await this.client.indices.existsAlias(params)).body;
     },
     },
-    putAlias: (params: RequestParams.IndicesPutAlias): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => this.client.indices.putAlias(params),
-    getAlias: async(params: RequestParams.IndicesGetAlias): Promise<estypes.IndicesGetAliasResponse> => {
-      return (await this.client.indices.getAlias<estypes.IndicesGetAliasResponse>(params)).body;
+    putAlias: (
+      params: RequestParams.IndicesPutAlias,
+    ): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> =>
+      this.client.indices.putAlias(params),
+    getAlias: async (
+      params: RequestParams.IndicesGetAlias,
+    ): Promise<estypes.IndicesGetAliasResponse> => {
+      return (
+        await this.client.indices.getAlias<estypes.IndicesGetAliasResponse>(
+          params,
+        )
+      ).body;
     },
     },
-    updateAliases: (params: RequestParams.IndicesUpdateAliases['body']): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => {
+    updateAliases: (
+      params: RequestParams.IndicesUpdateAliases['body'],
+    ): Promise<ApiResponse<estypes.IndicesUpdateAliasesResponse>> => {
       return this.client.indices.updateAliases({ body: params });
       return this.client.indices.updateAliases({ body: params });
     },
     },
-    validateQuery: async(params: RequestParams.IndicesValidateQuery<{ query?: estypes.QueryDslQueryContainer }>)
-      : Promise<estypes.IndicesValidateQueryResponse> => {
-      return (await this.client.indices.validateQuery<estypes.IndicesValidateQueryResponse>(params)).body;
+    validateQuery: async (
+      params: RequestParams.IndicesValidateQuery<{
+        query?: estypes.QueryDslQueryContainer;
+      }>,
+    ): Promise<estypes.IndicesValidateQueryResponse> => {
+      return (
+        await this.client.indices.validateQuery<estypes.IndicesValidateQueryResponse>(
+          params,
+        )
+      ).body;
     },
     },
-    stats: async(params: RequestParams.IndicesStats): Promise<estypes.IndicesStatsResponse> => {
-      return (await this.client.indices.stats<estypes.IndicesStatsResponse>(params)).body;
+    stats: async (
+      params: RequestParams.IndicesStats,
+    ): Promise<estypes.IndicesStatsResponse> => {
+      return (
+        await this.client.indices.stats<estypes.IndicesStatsResponse>(params)
+      ).body;
     },
     },
   };
   };
 
 
   nodes = {
   nodes = {
-    info: (): Promise<ApiResponse<estypes.NodesInfoResponse>> => this.client.nodes.info(),
+    info: (): Promise<ApiResponse<estypes.NodesInfoResponse>> =>
+      this.client.nodes.info(),
   };
   };
 
 
   ping(): Promise<ApiResponse<estypes.PingResponse>> {
   ping(): Promise<ApiResponse<estypes.PingResponse>> {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<ApiResponse<estypes.ReindexResponse>> {
-    return this.client.reindex({ wait_for_completion: false, body: { source: { index: indexName }, dest: { index: tmpIndexName } } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<ApiResponse<estypes.ReindexResponse>> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      body: { source: { index: indexName }, dest: { index: tmpIndexName } },
+    });
   }
   }
 
 
   async search(params: ES7SearchQuery): Promise<estypes.SearchResponse> {
   async search(params: ES7SearchQuery): Promise<estypes.SearchResponse> {
     return (await this.client.search<estypes.SearchResponse>(params)).body;
     return (await this.client.search<estypes.SearchResponse>(params)).body;
   }
   }
-
 }
 }

+ 58 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts

@@ -1,7 +1,10 @@
-import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch8';
+import {
+  Client,
+  type ClientOptions,
+  type estypes,
+} from '@elastic/elasticsearch8';
 
 
 export class ES8ClientDelegator {
 export class ES8ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 8 as const;
   delegatorVersion = 8 as const;
@@ -15,24 +18,56 @@ export class ES8ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: estypes.CatAliasesRequest): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
-    indices: (params: estypes.CatIndicesRequest): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
+    aliases: (
+      params: estypes.CatAliasesRequest,
+    ): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
+    indices: (
+      params: estypes.CatIndicesRequest,
+    ): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<estypes.ClusterHealthResponse> => this.client.cluster.health(),
+    health: (): Promise<estypes.ClusterHealthResponse> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: estypes.IndicesCreateRequest): Promise<estypes.IndicesCreateResponse> => this.client.indices.create(params),
-    delete: (params: estypes.IndicesDeleteRequest): Promise<estypes.IndicesDeleteResponse> => this.client.indices.delete(params),
-    exists: (params: estypes.IndicesExistsRequest): Promise<estypes.IndicesExistsResponse> => this.client.indices.exists(params),
-    existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise<estypes.IndicesExistsAliasResponse> => this.client.indices.existsAlias(params),
-    putAlias: (params: estypes.IndicesPutAliasRequest): Promise<estypes.IndicesPutAliasResponse> => this.client.indices.putAlias(params),
-    getAlias: (params: estypes.IndicesGetAliasRequest): Promise<estypes.IndicesGetAliasResponse> => this.client.indices.getAlias(params),
-    updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise<estypes.IndicesUpdateAliasesResponse> => this.client.indices.updateAliases(params),
-    validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise<estypes.IndicesValidateQueryResponse> => this.client.indices.validateQuery(params),
-    stats: (params: estypes.IndicesStatsRequest): Promise<estypes.IndicesStatsResponse> => this.client.indices.stats(params),
+    create: (
+      params: estypes.IndicesCreateRequest,
+    ): Promise<estypes.IndicesCreateResponse> =>
+      this.client.indices.create(params),
+    delete: (
+      params: estypes.IndicesDeleteRequest,
+    ): Promise<estypes.IndicesDeleteResponse> =>
+      this.client.indices.delete(params),
+    exists: (
+      params: estypes.IndicesExistsRequest,
+    ): Promise<estypes.IndicesExistsResponse> =>
+      this.client.indices.exists(params),
+    existsAlias: (
+      params: estypes.IndicesExistsAliasRequest,
+    ): Promise<estypes.IndicesExistsAliasResponse> =>
+      this.client.indices.existsAlias(params),
+    putAlias: (
+      params: estypes.IndicesPutAliasRequest,
+    ): Promise<estypes.IndicesPutAliasResponse> =>
+      this.client.indices.putAlias(params),
+    getAlias: (
+      params: estypes.IndicesGetAliasRequest,
+    ): Promise<estypes.IndicesGetAliasResponse> =>
+      this.client.indices.getAlias(params),
+    updateAliases: (
+      params: estypes.IndicesUpdateAliasesRequest,
+    ): Promise<estypes.IndicesUpdateAliasesResponse> =>
+      this.client.indices.updateAliases(params),
+    validateQuery: (
+      params: estypes.IndicesValidateQueryRequest,
+    ): Promise<estypes.IndicesValidateQueryResponse> =>
+      this.client.indices.validateQuery(params),
+    stats: (
+      params: estypes.IndicesStatsRequest,
+    ): Promise<estypes.IndicesStatsResponse> =>
+      this.client.indices.stats(params),
   };
   };
 
 
   nodes = {
   nodes = {
@@ -43,12 +78,18 @@ export class ES8ClientDelegator {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<estypes.ReindexResponse> {
-    return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<estypes.ReindexResponse> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      source: { index: indexName },
+      dest: { index: tmpIndexName },
+    });
   }
   }
 
 
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
     return this.client.search(params);
     return this.client.search(params);
   }
   }
-
 }
 }

+ 58 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts

@@ -1,7 +1,10 @@
-import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch9';
+import {
+  Client,
+  type ClientOptions,
+  type estypes,
+} from '@elastic/elasticsearch9';
 
 
 export class ES9ClientDelegator {
 export class ES9ClientDelegator {
-
   private client: Client;
   private client: Client;
 
 
   delegatorVersion = 9 as const;
   delegatorVersion = 9 as const;
@@ -15,24 +18,56 @@ export class ES9ClientDelegator {
   }
   }
 
 
   cat = {
   cat = {
-    aliases: (params: estypes.CatAliasesRequest): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
-    indices: (params: estypes.CatIndicesRequest): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
+    aliases: (
+      params: estypes.CatAliasesRequest,
+    ): Promise<estypes.CatAliasesResponse> => this.client.cat.aliases(params),
+    indices: (
+      params: estypes.CatIndicesRequest,
+    ): Promise<estypes.CatIndicesResponse> => this.client.cat.indices(params),
   };
   };
 
 
   cluster = {
   cluster = {
-    health: (): Promise<estypes.ClusterHealthResponse> => this.client.cluster.health(),
+    health: (): Promise<estypes.ClusterHealthResponse> =>
+      this.client.cluster.health(),
   };
   };
 
 
   indices = {
   indices = {
-    create: (params: estypes.IndicesCreateRequest): Promise<estypes.IndicesCreateResponse> => this.client.indices.create(params),
-    delete: (params: estypes.IndicesDeleteRequest): Promise<estypes.IndicesDeleteResponse> => this.client.indices.delete(params),
-    exists: (params: estypes.IndicesExistsRequest): Promise<estypes.IndicesExistsResponse> => this.client.indices.exists(params),
-    existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise<estypes.IndicesExistsAliasResponse> => this.client.indices.existsAlias(params),
-    putAlias: (params: estypes.IndicesPutAliasRequest): Promise<estypes.IndicesPutAliasResponse> => this.client.indices.putAlias(params),
-    getAlias: (params: estypes.IndicesGetAliasRequest): Promise<estypes.IndicesGetAliasResponse> => this.client.indices.getAlias(params),
-    updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise<estypes.IndicesUpdateAliasesResponse> => this.client.indices.updateAliases(params),
-    validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise<estypes.IndicesValidateQueryResponse> => this.client.indices.validateQuery(params),
-    stats: (params: estypes.IndicesStatsRequest): Promise<estypes.IndicesStatsResponse> => this.client.indices.stats(params),
+    create: (
+      params: estypes.IndicesCreateRequest,
+    ): Promise<estypes.IndicesCreateResponse> =>
+      this.client.indices.create(params),
+    delete: (
+      params: estypes.IndicesDeleteRequest,
+    ): Promise<estypes.IndicesDeleteResponse> =>
+      this.client.indices.delete(params),
+    exists: (
+      params: estypes.IndicesExistsRequest,
+    ): Promise<estypes.IndicesExistsResponse> =>
+      this.client.indices.exists(params),
+    existsAlias: (
+      params: estypes.IndicesExistsAliasRequest,
+    ): Promise<estypes.IndicesExistsAliasResponse> =>
+      this.client.indices.existsAlias(params),
+    putAlias: (
+      params: estypes.IndicesPutAliasRequest,
+    ): Promise<estypes.IndicesPutAliasResponse> =>
+      this.client.indices.putAlias(params),
+    getAlias: (
+      params: estypes.IndicesGetAliasRequest,
+    ): Promise<estypes.IndicesGetAliasResponse> =>
+      this.client.indices.getAlias(params),
+    updateAliases: (
+      params: estypes.IndicesUpdateAliasesRequest,
+    ): Promise<estypes.IndicesUpdateAliasesResponse> =>
+      this.client.indices.updateAliases(params),
+    validateQuery: (
+      params: estypes.IndicesValidateQueryRequest,
+    ): Promise<estypes.IndicesValidateQueryResponse> =>
+      this.client.indices.validateQuery(params),
+    stats: (
+      params: estypes.IndicesStatsRequest,
+    ): Promise<estypes.IndicesStatsResponse> =>
+      this.client.indices.stats(params),
   };
   };
 
 
   nodes = {
   nodes = {
@@ -43,12 +78,18 @@ export class ES9ClientDelegator {
     return this.client.ping();
     return this.client.ping();
   }
   }
 
 
-  reindex(indexName: string, tmpIndexName: string): Promise<estypes.ReindexResponse> {
-    return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } });
+  reindex(
+    indexName: string,
+    tmpIndexName: string,
+  ): Promise<estypes.ReindexResponse> {
+    return this.client.reindex({
+      wait_for_completion: false,
+      source: { index: indexName },
+      dest: { index: tmpIndexName },
+    });
   }
   }
 
 
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
   search(params: estypes.SearchRequest): Promise<estypes.SearchResponse> {
     return this.client.search(params);
     return this.client.search(params);
   }
   }
-
 }
 }

+ 44 - 31
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts

@@ -2,55 +2,68 @@ import type { ClientOptions as ES7ClientOptions } from '@elastic/elasticsearch7'
 import type { ClientOptions as ES8ClientOptions } from '@elastic/elasticsearch8';
 import type { ClientOptions as ES8ClientOptions } from '@elastic/elasticsearch8';
 import type { ClientOptions as ES9ClientOptions } from '@elastic/elasticsearch9';
 import type { ClientOptions as ES9ClientOptions } from '@elastic/elasticsearch9';
 
 
-import { type ES7ClientDelegator } from './es7-client-delegator';
-import { type ES8ClientDelegator } from './es8-client-delegator';
-import { type ES9ClientDelegator } from './es9-client-delegator';
+import type { ES7ClientDelegator } from './es7-client-delegator';
+import type { ES8ClientDelegator } from './es8-client-delegator';
+import type { ES9ClientDelegator } from './es9-client-delegator';
 import type { ElasticsearchClientDelegator } from './interfaces';
 import type { ElasticsearchClientDelegator } from './interfaces';
 
 
-type GetDelegatorOptions = {
-  version: 7;
-  options: ES7ClientOptions;
-  rejectUnauthorized: boolean;
-} | {
-  version: 8;
-  options: ES8ClientOptions;
-  rejectUnauthorized: boolean;
-} | {
-  version: 9;
-  options: ES9ClientOptions;
-  rejectUnauthorized: boolean;
-}
+type GetDelegatorOptions =
+  | {
+      version: 7;
+      options: ES7ClientOptions;
+      rejectUnauthorized: boolean;
+    }
+  | {
+      version: 8;
+      options: ES8ClientOptions;
+      rejectUnauthorized: boolean;
+    }
+  | {
+      version: 9;
+      options: ES9ClientOptions;
+      rejectUnauthorized: boolean;
+    };
 
 
-type IsAny<T> = 'dummy' extends (T & 'dummy') ? true : false;
-type Delegator<Opts extends GetDelegatorOptions> =
-  IsAny<Opts> extends true
-    ? ElasticsearchClientDelegator
-    : Opts extends { version: 7 }
-      ? ES7ClientDelegator
-      : Opts extends { version: 8 }
-        ? ES8ClientDelegator
-        : Opts extends { version: 9 }
-          ? ES9ClientDelegator
-          : ElasticsearchClientDelegator
+type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
+type Delegator<Opts extends GetDelegatorOptions> = IsAny<Opts> extends true
+  ? ElasticsearchClientDelegator
+  : Opts extends { version: 7 }
+    ? ES7ClientDelegator
+    : Opts extends { version: 8 }
+      ? ES8ClientDelegator
+      : Opts extends { version: 9 }
+        ? ES9ClientDelegator
+        : ElasticsearchClientDelegator;
 
 
 let instance: ElasticsearchClientDelegator | null = null;
 let instance: ElasticsearchClientDelegator | null = null;
-export const getClient = async<Opts extends GetDelegatorOptions>(opts: Opts): Promise<Delegator<Opts>> => {
+export const getClient = async <Opts extends GetDelegatorOptions>(
+  opts: Opts,
+): Promise<Delegator<Opts>> => {
   if (instance == null) {
   if (instance == null) {
     if (opts.version === 7) {
     if (opts.version === 7) {
       await import('./es7-client-delegator').then(({ ES7ClientDelegator }) => {
       await import('./es7-client-delegator').then(({ ES7ClientDelegator }) => {
-        instance = new ES7ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES7ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }
     if (opts.version === 8) {
     if (opts.version === 8) {
       await import('./es8-client-delegator').then(({ ES8ClientDelegator }) => {
       await import('./es8-client-delegator').then(({ ES8ClientDelegator }) => {
-        instance = new ES8ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES8ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }
     if (opts.version === 9) {
     if (opts.version === 9) {
       await import('./es9-client-delegator').then(({ ES9ClientDelegator }) => {
       await import('./es9-client-delegator').then(({ ES9ClientDelegator }) => {
-        instance = new ES9ClientDelegator(opts.options, opts.rejectUnauthorized);
+        instance = new ES9ClientDelegator(
+          opts.options,
+          opts.rejectUnauthorized,
+        );
         return instance;
         return instance;
       });
       });
     }
     }

+ 27 - 17
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts

@@ -1,4 +1,7 @@
-import type { estypes as ES7types, RequestParams } from '@elastic/elasticsearch7';
+import type {
+  estypes as ES7types,
+  RequestParams,
+} from '@elastic/elasticsearch7';
 import type { estypes as ES8types } from '@elastic/elasticsearch8';
 import type { estypes as ES8types } from '@elastic/elasticsearch8';
 import type { estypes as ES9types } from '@elastic/elasticsearch9';
 import type { estypes as ES9types } from '@elastic/elasticsearch9';
 
 
@@ -6,52 +9,59 @@ import type { ES7ClientDelegator } from './es7-client-delegator';
 import type { ES8ClientDelegator } from './es8-client-delegator';
 import type { ES8ClientDelegator } from './es8-client-delegator';
 import type { ES9ClientDelegator } from './es9-client-delegator';
 import type { ES9ClientDelegator } from './es9-client-delegator';
 
 
-export type ElasticsearchClientDelegator = ES7ClientDelegator | ES8ClientDelegator | ES9ClientDelegator;
-
+export type ElasticsearchClientDelegator =
+  | ES7ClientDelegator
+  | ES8ClientDelegator
+  | ES9ClientDelegator;
 
 
 // type guard
 // type guard
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
-export const isES7ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES7ClientDelegator => {
+export const isES7ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES7ClientDelegator => {
   return delegator.delegatorVersion === 7;
   return delegator.delegatorVersion === 7;
 };
 };
 
 
-export const isES8ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES8ClientDelegator => {
+export const isES8ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES8ClientDelegator => {
   return delegator.delegatorVersion === 8;
   return delegator.delegatorVersion === 8;
 };
 };
 
 
-export const isES9ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES9ClientDelegator => {
+export const isES9ClientDelegator = (
+  delegator: ElasticsearchClientDelegator,
+): delegator is ES9ClientDelegator => {
   return delegator.delegatorVersion === 9;
   return delegator.delegatorVersion === 9;
 };
 };
 
 
-
 // Official library-derived interface
 // Official library-derived interface
 // TODO: https://redmine.weseek.co.jp/issues/168446
 // TODO: https://redmine.weseek.co.jp/issues/168446
 export type ES7SearchQuery = RequestParams.Search<{
 export type ES7SearchQuery = RequestParams.Search<{
-  query: ES7types.QueryDslQueryContainer
-  sort?: ES7types.Sort
-  highlight?: ES7types.SearchHighlight
-}>
+  query: ES7types.QueryDslQueryContainer;
+  sort?: ES7types.Sort;
+  highlight?: ES7types.SearchHighlight;
+}>;
 
 
 export interface ES8SearchQuery {
 export interface ES8SearchQuery {
-  index: ES8types.IndexName
-  _source: ES8types.Fields
+  index: ES8types.IndexName;
+  _source: ES8types.Fields;
   from?: number;
   from?: number;
   size?: number;
   size?: number;
   body: {
   body: {
     query: ES8types.QueryDslQueryContainer;
     query: ES8types.QueryDslQueryContainer;
-    sort?: ES8types.Sort
+    sort?: ES8types.Sort;
     highlight?: ES8types.SearchHighlight;
     highlight?: ES8types.SearchHighlight;
   };
   };
 }
 }
 
 
 export interface ES9SearchQuery {
 export interface ES9SearchQuery {
-  index: ES9types.IndexName
-  _source: ES9types.Fields
+  index: ES9types.IndexName;
+  _source: ES9types.Fields;
   from?: number;
   from?: number;
   size?: number;
   size?: number;
   body: {
   body: {
     query: ES9types.QueryDslQueryContainer;
     query: ES9types.QueryDslQueryContainer;
-    sort?: ES9types.Sort
+    sort?: ES9types.Sort;
     highlight?: ES9types.SearchHighlight;
     highlight?: ES9types.SearchHighlight;
   };
   };
 }
 }

+ 245 - 126
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,10 +1,9 @@
-import { Writable, Transform } from 'stream';
-import { pipeline } from 'stream/promises';
-import { URL } from 'url';
-
 import { getIdStringForRef, type IPage } from '@growi/core';
 import { getIdStringForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import { Transform, Writable } from 'stream';
+import { pipeline } from 'stream/promises';
+import { URL } from 'url';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
@@ -15,27 +14,34 @@ import type { SocketIoService } from '~/server/service/socket-io';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type {
 import type {
-  SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
+  ESQueryTerms,
+  ESTermsKey,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  UnavailableTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { createBatchStream } from '../../util/batch-stream';
 import { configManager } from '../config-manager';
 import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
-
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import type {
 import type {
-  AggregatedPage, BulkWriteBody, BulkWriteCommand, BulkWriteBodyRestriction,
+  AggregatedPage,
+  BulkWriteBody,
+  BulkWriteBodyRestriction,
+  BulkWriteCommand,
 } from './bulk-write';
 } from './bulk-write';
 import {
 import {
+  type ElasticsearchClientDelegator,
+  type ES7SearchQuery,
+  type ES8SearchQuery,
+  type ES9SearchQuery,
   getClient,
   getClient,
   isES7ClientDelegator,
   isES7ClientDelegator,
   isES8ClientDelegator,
   isES8ClientDelegator,
   isES9ClientDelegator,
   isES9ClientDelegator,
   type SearchQuery,
   type SearchQuery,
-  type ES7SearchQuery,
-  type ES8SearchQuery,
-  type ES9SearchQuery,
-  type ElasticsearchClientDelegator,
 } from './elasticsearch-client-delegator';
 } from './elasticsearch-client-delegator';
 
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -56,12 +62,22 @@ const ES_SORT_ORDER = {
   [ASC]: 'asc',
   [ASC]: 'asc',
 } as const;
 } as const;
 
 
-const AVAILABLE_KEYS = ['match', 'not_match', 'phrase', 'not_phrase', 'prefix', 'not_prefix', 'tag', 'not_tag'];
+const AVAILABLE_KEYS = [
+  'match',
+  'not_match',
+  'phrase',
+  'not_phrase',
+  'prefix',
+  'not_prefix',
+  'tag',
+  'not_tag',
+];
 
 
 type Data = any;
 type Data = any;
 
 
-class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQueryTerms> {
-
+class ElasticsearchDelegator
+  implements SearchDelegator<Data, ESTermsKey, ESQueryTerms>
+{
   name!: SearchDelegatorName.DEFAULT;
   name!: SearchDelegatorName.DEFAULT;
 
 
   private socketIoService!: SocketIoService;
   private socketIoService!: SocketIoService;
@@ -85,17 +101,27 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.name = SearchDelegatorName.DEFAULT;
     this.name = SearchDelegatorName.DEFAULT;
     this.socketIoService = socketIoService;
     this.socketIoService = socketIoService;
 
 
-    const elasticsearchVersion = configManager.getConfig('app:elasticsearchVersion');
+    const elasticsearchVersion = configManager.getConfig(
+      'app:elasticsearchVersion',
+    );
 
 
-    if (elasticsearchVersion !== 7 && elasticsearchVersion !== 8 && elasticsearchVersion !== 9) {
-      throw new Error('Unsupported Elasticsearch version. Please specify a valid number to \'ELASTICSEARCH_VERSION\'');
+    if (
+      elasticsearchVersion !== 7 &&
+      elasticsearchVersion !== 8 &&
+      elasticsearchVersion !== 9
+    ) {
+      throw new Error(
+        "Unsupported Elasticsearch version. Please specify a valid number to 'ELASTICSEARCH_VERSION'",
+      );
     }
     }
 
 
     this.isElasticsearchV7 = elasticsearchVersion === 7;
     this.isElasticsearchV7 = elasticsearchVersion === 7;
 
 
     this.elasticsearchVersion = elasticsearchVersion;
     this.elasticsearchVersion = elasticsearchVersion;
 
 
-    this.isElasticsearchReindexOnBoot = configManager.getConfig('app:elasticsearchReindexOnBoot');
+    this.isElasticsearchReindexOnBoot = configManager.getConfig(
+      'app:elasticsearchReindexOnBoot',
+    );
   }
   }
 
 
   /**
   /**
@@ -125,15 +151,23 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async initClient(): Promise<void> {
   async initClient(): Promise<void> {
     const { host, auth, indexName } = this.getConnectionInfo();
     const { host, auth, indexName } = this.getConnectionInfo();
 
 
-    const rejectUnauthorized = configManager.getConfig('app:elasticsearchRejectUnauthorized');
+    const rejectUnauthorized = configManager.getConfig(
+      'app:elasticsearchRejectUnauthorized',
+    );
 
 
     const options = {
     const options = {
       node: host,
       node: host,
       auth,
       auth,
-      requestTimeout: configManager.getConfig('app:elasticsearchRequestTimeout'),
+      requestTimeout: configManager.getConfig(
+        'app:elasticsearchRequestTimeout',
+      ),
     };
     };
 
 
-    this.client = await getClient({ version: this.elasticsearchVersion, options, rejectUnauthorized });
+    this.client = await getClient({
+      version: this.elasticsearchVersion,
+      options,
+      rejectUnauthorized,
+    });
     this.indexName = indexName;
     this.indexName = indexName;
   }
   }
 
 
@@ -178,8 +212,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     if (this.isElasticsearchReindexOnBoot) {
     if (this.isElasticsearchReindexOnBoot) {
       try {
       try {
         await this.rebuildIndex();
         await this.rebuildIndex();
-      }
-      catch (err) {
+      } catch (err) {
         logger.error('Rebuild index on boot failed', err);
         logger.error('Rebuild index on boot failed', err);
       }
       }
       return;
       return;
@@ -245,12 +278,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     // check existence
     // check existence
     const isExistsMainIndex = await client.indices.exists({ index: indexName });
     const isExistsMainIndex = await client.indices.exists({ index: indexName });
-    const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
+    const isExistsTmpIndex = await client.indices.exists({
+      index: tmpIndexName,
+    });
 
 
     // create indices name list
     // create indices name list
     const existingIndices: string[] = [];
     const existingIndices: string[] = [];
-    if (isExistsMainIndex) { existingIndices.push(indexName) }
-    if (isExistsTmpIndex) { existingIndices.push(tmpIndexName) }
+    if (isExistsMainIndex) {
+      existingIndices.push(indexName);
+    }
+    if (isExistsTmpIndex) {
+      existingIndices.push(tmpIndexName);
+    }
 
 
     // results when there is no indices
     // results when there is no indices
     if (existingIndices.length === 0) {
     if (existingIndices.length === 0) {
@@ -261,22 +300,34 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       };
       };
     }
     }
 
 
-    const indicesStats = await client.indices.stats({ index: existingIndices, metric: ['docs', 'store', 'indexing'] });
+    const indicesStats = await client.indices.stats({
+      index: existingIndices,
+      metric: ['docs', 'store', 'indexing'],
+    });
     const { indices } = indicesStats;
     const { indices } = indicesStats;
 
 
     const aliases = await client.indices.getAlias({ index: existingIndices });
     const aliases = await client.indices.getAlias({ index: existingIndices });
 
 
-    const isMainIndexHasAlias = isExistsMainIndex && aliases[indexName].aliases != null && aliases[indexName].aliases[aliasName] != null;
-    const isTmpIndexHasAlias = isExistsTmpIndex && aliases[tmpIndexName].aliases != null && aliases[tmpIndexName].aliases[aliasName] != null;
-
-    const isNormalized = isExistsMainIndex && isMainIndexHasAlias && !isExistsTmpIndex && !isTmpIndexHasAlias;
+    const isMainIndexHasAlias =
+      isExistsMainIndex &&
+      aliases[indexName].aliases != null &&
+      aliases[indexName].aliases[aliasName] != null;
+    const isTmpIndexHasAlias =
+      isExistsTmpIndex &&
+      aliases[tmpIndexName].aliases != null &&
+      aliases[tmpIndexName].aliases[aliasName] != null;
+
+    const isNormalized =
+      isExistsMainIndex &&
+      isMainIndexHasAlias &&
+      !isExistsTmpIndex &&
+      !isTmpIndexHasAlias;
 
 
     return {
     return {
       indices,
       indices,
       aliases,
       aliases,
       isNormalized,
       isNormalized,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -306,21 +357,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
       });
       await this.createIndex(indexName);
       await this.createIndex(indexName);
       await this.addAllPages();
       await this.addAllPages();
-    }
-    catch (error) {
-      logger.error('An error occured while \'rebuildIndex\'.', error);
+    } catch (error) {
+      logger.error("An error occured while 'rebuildIndex'.", error);
       logger.error('error.meta.body', error?.meta?.body);
       logger.error('error.meta.body', error?.meta?.body);
 
 
       const socket = this.socketIoService.getAdminSocket();
       const socket = this.socketIoService.getAdminSocket();
       socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
       socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
 
 
       throw error;
       throw error;
-    }
-    finally {
+    } finally {
       logger.info('Normalize indices.');
       logger.info('Normalize indices.');
       await this.normalizeIndices();
       await this.normalizeIndices();
     }
     }
-
   }
   }
 
 
   async normalizeIndices(): Promise<void> {
   async normalizeIndices(): Promise<void> {
@@ -329,7 +377,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const tmpIndexName = `${indexName}-tmp`;
     const tmpIndexName = `${indexName}-tmp`;
 
 
     // remove tmp index
     // remove tmp index
-    const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
+    const isExistsTmpIndex = await client.indices.exists({
+      index: tmpIndexName,
+    });
     if (isExistsTmpIndex) {
     if (isExistsTmpIndex) {
       await client.indices.delete({ index: tmpIndexName });
       await client.indices.delete({ index: tmpIndexName });
     }
     }
@@ -341,7 +391,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     // create alias
     // create alias
-    const isExistsAlias = await client.indices.existsAlias({ name: aliasName, index: indexName });
+    const isExistsAlias = await client.indices.existsAlias({
+      name: aliasName,
+      index: indexName,
+    });
     if (!isExistsAlias) {
     if (!isExistsAlias) {
       await client.indices.putAlias({
       await client.indices.putAlias({
         name: aliasName,
         name: aliasName,
@@ -371,9 +424,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     if (isES9ClientDelegator(this.client)) {
     if (isES9ClientDelegator(this.client)) {
-      const { mappings } = process.env.CI == null
-        ? await import('./mappings/mappings-es9')
-        : await import('./mappings/mappings-es9-for-ci');
+      const { mappings } =
+        process.env.CI == null
+          ? await import('./mappings/mappings-es9')
+          : await import('./mappings/mappings-es9-for-ci');
 
 
       return this.client.indices.create({
       return this.client.indices.create({
         index,
         index,
@@ -385,9 +439,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * generate object that is related to page.grant*
    * generate object that is related to page.grant*
    */
    */
-  generateDocContentsRelatedToRestriction(page: AggregatedPage): BulkWriteBodyRestriction {
-    const grantedUserIds = page.grantedUsers.map(user => getIdStringForRef(user));
-    const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item));
+  generateDocContentsRelatedToRestriction(
+    page: AggregatedPage,
+  ): BulkWriteBodyRestriction {
+    const grantedUserIds = page.grantedUsers.map((user) =>
+      getIdStringForRef(user),
+    );
+    const grantedGroupIds = page.grantedGroups.map((group) =>
+      getIdStringForRef(group.item),
+    );
 
 
     return {
     return {
       grant: page.grant,
       grant: page.grant,
@@ -396,8 +456,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
     };
   }
   }
 
 
-  prepareBodyForCreate(page: AggregatedPage): [BulkWriteCommand, BulkWriteBody] {
-
+  prepareBodyForCreate(
+    page: AggregatedPage,
+  ): [BulkWriteCommand, BulkWriteBody] {
     const command = {
     const command = {
       index: {
       index: {
         _index: this.indexName,
         _index: this.indexName,
@@ -443,7 +504,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   addAllPages() {
   addAllPages() {
     const Page = this.getPageModel();
     const Page = this.getPageModel();
-    return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
+    return this.updateOrInsertPages(() => Page.find(), {
+      shouldEmitProgress: true,
+      invokeGarbageCollection: true,
+    });
   }
   }
 
 
   updateOrInsertPageById(pageId) {
   updateOrInsertPageById(pageId) {
@@ -462,13 +526,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
    */
-  async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}): Promise<void> {
-    const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
+  async updateOrInsertPages(
+    queryFactory,
+    option: UpdateOrInsertPagesOpts = {},
+  ): Promise<void> {
+    const { shouldEmitProgress = false, invokeGarbageCollection = false } =
+      option;
 
 
     const Page = this.getPageModel();
     const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
-    const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
+    const socket = shouldEmitProgress
+      ? this.socketIoService.getAdminSocket()
+      : null;
 
 
     // prepare functions invoked from custom streams
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -479,26 +549,31 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const countQuery = new PageQueryBuilder(queryFactory()).query;
     const countQuery = new PageQueryBuilder(queryFactory()).query;
     const totalCount = await countQuery.count();
     const totalCount = await countQuery.count();
 
 
-    const maxBodyLengthToIndex = configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex');
+    const maxBodyLengthToIndex = configManager.getConfig(
+      'app:elasticsearchMaxBodyLengthToIndex',
+    );
 
 
     const readStream = Page.aggregate<AggregatedPage>(
     const readStream = Page.aggregate<AggregatedPage>(
       aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
       aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
     ).cursor();
     ).cursor();
 
 
-    const bulkSize: number = configManager.getConfig('app:elasticsearchReindexBulkSize');
+    const bulkSize: number = configManager.getConfig(
+      'app:elasticsearchReindexBulkSize',
+    );
     const batchStream = createBatchStream(bulkSize);
     const batchStream = createBatchStream(bulkSize);
 
 
     const appendTagNamesStream = new Transform({
     const appendTagNamesStream = new Transform({
       objectMode: true,
       objectMode: true,
       async transform(chunk, encoding, callback) {
       async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
+        const pageIds = chunk.map((doc) => doc._id);
 
 
-        const idToTagNamesMap = await PageTagRelation.getIdToTagNamesMap(pageIds);
+        const idToTagNamesMap =
+          await PageTagRelation.getIdToTagNamesMap(pageIds);
         const idsHavingTagNames = Object.keys(idToTagNamesMap);
         const idsHavingTagNames = Object.keys(idToTagNamesMap);
 
 
         // append tagNames
         // append tagNames
         chunk
         chunk
-          .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
+          .filter((doc) => idsHavingTagNames.includes(doc._id.toString()))
           .forEach((doc: AggregatedPage) => {
           .forEach((doc: AggregatedPage) => {
             // append tagName from idToTagNamesMap
             // append tagName from idToTagNamesMap
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
@@ -513,7 +588,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const writeStream = new Writable({
     const writeStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
-        const body: (BulkWriteCommand|BulkWriteBody)[] = [];
+        const body: (BulkWriteCommand | BulkWriteBody)[] = [];
         batch.forEach((doc: AggregatedPage) => {
         batch.forEach((doc: AggregatedPage) => {
           body.push(...prepareBodyForCreate(doc));
           body.push(...prepareBodyForCreate(doc));
         });
         });
@@ -526,13 +601,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
           count += (bulkResponse.items || []).length;
           count += (bulkResponse.items || []).length;
 
 
-          logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
+          logger.info(
+            `Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`,
+          );
 
 
           if (shouldEmitProgress) {
           if (shouldEmitProgress) {
-            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count });
+            socket?.emit(SocketEventName.AddPageProgress, {
+              totalCount,
+              count,
+            });
           }
           }
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('addAllPages error on add anyway: ', err);
           logger.error('addAllPages error on add anyway: ', err);
         }
         }
 
 
@@ -541,8 +620,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
             // First aid to prevent unexplained memory leaks
             // First aid to prevent unexplained memory leaks
             logger.info('global.gc() invoked.');
             logger.info('global.gc() invoked.');
             gc();
             gc();
-          }
-          catch (err) {
+          } catch (err) {
             logger.error('fail garbage collection: ', err);
             logger.error('fail garbage collection: ', err);
           }
           }
         }
         }
@@ -559,18 +637,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       },
       },
     });
     });
 
 
-
-    return pipeline(
-      readStream,
-      batchStream,
-      appendTagNamesStream,
-      writeStream,
-    );
+    return pipeline(readStream, batchStream, appendTagNamesStream, writeStream);
   }
   }
 
 
   deletePages(pages) {
   deletePages(pages) {
     const body = [];
     const body = [];
-    pages.forEach(page => this.prepareBodyForDelete(body, page));
+    pages.forEach((page) => this.prepareBodyForDelete(body, page));
 
 
     logger.debug('deletePages(): Sending Request to ES', body);
     logger.debug('deletePages(): Sending Request to ES', body);
     return this.client.bulk({
     return this.client.bulk({
@@ -585,14 +657,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    *   data: [ pages ...],
    *   data: [ pages ...],
    * }
    * }
    */
    */
-  async searchKeyword(query: SearchQuery): Promise<ISearchResult<ISearchResultData>> {
-
+  async searchKeyword(
+    query: SearchQuery,
+  ): Promise<ISearchResult<ISearchResultData>> {
     // for debug
     // for debug
     if (process.env.NODE_ENV === 'development') {
     if (process.env.NODE_ENV === 'development') {
       logger.debug('query: ', JSON.stringify(query, null, 2));
       logger.debug('query: ', JSON.stringify(query, null, 2));
 
 
-
-      const validateQueryResponse = await (async() => {
+      const validateQueryResponse = await (async () => {
         if (isES7ClientDelegator(this.client)) {
         if (isES7ClientDelegator(this.client)) {
           const es7SearchQuery = query as ES7SearchQuery;
           const es7SearchQuery = query as ES7SearchQuery;
           return this.client.indices.validateQuery({
           return this.client.indices.validateQuery({
@@ -625,12 +697,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         throw new Error('Unsupported Elasticsearch version');
         throw new Error('Unsupported Elasticsearch version');
       })();
       })();
 
 
-
       // for debug
       // for debug
       logger.debug('ES result: ', validateQueryResponse);
       logger.debug('ES result: ', validateQueryResponse);
     }
     }
 
 
-    const searchResponse = await (async() => {
+    const searchResponse = await (async () => {
       if (isES7ClientDelegator(this.client)) {
       if (isES7ClientDelegator(this.client)) {
         return this.client.search(query as ES7SearchQuery);
         return this.client.search(query as ES7SearchQuery);
       }
       }
@@ -682,7 +753,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    * @returns {object} query object
    * @returns {object} query object
    */
    */
   createSearchQuery(): SearchQuery {
   createSearchQuery(): SearchQuery {
-    const fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
+    const fields = [
+      'path',
+      'bookmark_count',
+      'comment_count',
+      'seenUsers_count',
+      'updated_at',
+      'tag_names',
+      'comments',
+    ];
 
 
     // sort by score
     // sort by score
     const query: SearchQuery = {
     const query: SearchQuery = {
@@ -703,7 +782,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     query.size = size || DEFAULT_LIMIT;
     query.size = size || DEFAULT_LIMIT;
   }
   }
 
 
-  appendSortOrder(query: SearchQuery, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER): void {
+  appendSortOrder(
+    query: SearchQuery,
+    sortAxis: SORT_AXIS,
+    sortOrder: SORT_ORDER,
+  ): void {
     if (query.body == null) {
     if (query.body == null) {
       throw new Error('query.body is not initialized');
       throw new Error('query.body is not initialized');
     }
     }
@@ -715,7 +798,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     query.body.sort = {
     query.body.sort = {
       [sort]: { order },
       [sort]: { order },
     };
     };
-
   }
   }
 
 
   initializeBoolQuery(query: SearchQuery): SearchQuery {
   initializeBoolQuery(query: SearchQuery): SearchQuery {
@@ -724,7 +806,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
-    const isInitialized = (query) => { return !!query && Array.isArray(query) };
+    const isInitialized = (query) => {
+      return !!query && Array.isArray(query);
+    };
 
 
     if (!isInitialized(query.body.query.bool.filter)) {
     if (!isInitialized(query.body.query.bool.filter)) {
       query.body.query.bool.filter = [];
       query.body.query.bool.filter = [];
@@ -738,22 +822,34 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     return query;
     return query;
   }
   }
 
 
-  appendCriteriaForQueryString(query: SearchQuery, parsedKeywords: ESQueryTerms): void {
+  appendCriteriaForQueryString(
+    query: SearchQuery,
+    parsedKeywords: ESQueryTerms,
+  ): void {
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
     if (query.body?.query?.bool == null) {
     if (query.body?.query?.bool == null) {
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.must == null || !Array.isArray(query.body?.query?.bool.must)) {
+    if (
+      query.body?.query?.bool.must == null ||
+      !Array.isArray(query.body?.query?.bool.must)
+    ) {
       throw new Error('query.body.query.bool.must is not initialized');
       throw new Error('query.body.query.bool.must is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.must_not == null || !Array.isArray(query.body?.query?.bool.must_not)) {
+    if (
+      query.body?.query?.bool.must_not == null ||
+      !Array.isArray(query.body?.query?.bool.must_not)
+    ) {
       throw new Error('query.body.query.bool.must_not is not initialized');
       throw new Error('query.body.query.bool.must_not is not initialized');
     }
     }
 
 
-    if (query.body?.query?.bool.filter == null || !Array.isArray(query.body?.query?.bool.filter)) {
+    if (
+      query.body?.query?.bool.filter == null ||
+      !Array.isArray(query.body?.query?.bool.filter)
+    ) {
       throw new Error('query.body.query.bool.filter is not initialized');
       throw new Error('query.body.query.bool.filter is not initialized');
     }
     }
 
 
@@ -762,7 +858,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         multi_match: {
         multi_match: {
           query: parsedKeywords.match.join(' '),
           query: parsedKeywords.match.join(' '),
           type: 'most_fields' as const,
           type: 'most_fields' as const,
-          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
+          fields: [
+            'path.ja^2',
+            'path.en^2',
+            'body.ja',
+            'body.en',
+            'comments.ja',
+            'comments.en',
+          ],
         },
         },
       };
       };
       query.body.query.bool.must.push(q);
       query.body.query.bool.must.push(q);
@@ -772,7 +875,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       const q = {
       const q = {
         multi_match: {
         multi_match: {
           query: parsedKeywords.not_match.join(' '),
           query: parsedKeywords.not_match.join(' '),
-          fields: ['path.ja', 'path.en', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
+          fields: [
+            'path.ja',
+            'path.en',
+            'body.ja',
+            'body.en',
+            'comments.ja',
+            'comments.en',
+          ],
           operator: 'or' as const,
           operator: 'or' as const,
         },
         },
       };
       };
@@ -843,32 +953,39 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
   }
   }
 
 
-  async filterPagesByViewer(query: SearchQuery, user, userGroups): Promise<void> {
-    const showPagesRestrictedByOwner = !configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-    const showPagesRestrictedByGroup = !configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+  async filterPagesByViewer(
+    query: SearchQuery,
+    user,
+    userGroups,
+  ): Promise<void> {
+    const showPagesRestrictedByOwner = !configManager.getConfig(
+      'security:list-policy:hideRestrictedByOwner',
+    );
+    const showPagesRestrictedByGroup = !configManager.getConfig(
+      'security:list-policy:hideRestrictedByGroup',
+    );
 
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
-    if (query.body?.query?.bool?.filter == null || !Array.isArray(query.body?.query?.bool?.filter)) {
+    if (
+      query.body?.query?.bool?.filter == null ||
+      !Array.isArray(query.body?.query?.bool?.filter)
+    ) {
       throw new Error('query.body.query.bool is not initialized');
       throw new Error('query.body.query.bool is not initialized');
     }
     }
 
 
     const Page = this.getPageModel();
     const Page = this.getPageModel();
-    const {
-      GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
-    } = Page;
+    const { GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP } =
+      Page;
 
 
-    const grantConditions: any[] = [
-      { term: { grant: GRANT_PUBLIC } },
-    ];
+    const grantConditions: any[] = [{ term: { grant: GRANT_PUBLIC } }];
 
 
     if (showPagesRestrictedByOwner) {
     if (showPagesRestrictedByOwner) {
       grantConditions.push(
       grantConditions.push(
         { term: { grant: GRANT_SPECIFIED } },
         { term: { grant: GRANT_SPECIFIED } },
         { term: { grant: GRANT_OWNER } },
         { term: { grant: GRANT_OWNER } },
       );
       );
-    }
-    else if (user != null) {
+    } else if (user != null) {
       grantConditions.push(
       grantConditions.push(
         {
         {
           bool: {
           bool: {
@@ -890,22 +1007,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
     }
 
 
     if (showPagesRestrictedByGroup) {
     if (showPagesRestrictedByGroup) {
-      grantConditions.push(
-        { term: { grant: GRANT_USER_GROUP } },
-      );
-    }
-    else if (userGroups != null && userGroups.length > 0) {
-      const userGroupIds = userGroups.map((group) => { return group._id.toString() });
-      grantConditions.push(
-        {
-          bool: {
-            must: [
-              { term: { grant: GRANT_USER_GROUP } },
-              { terms: { granted_groups: userGroupIds } },
-            ],
-          },
+      grantConditions.push({ term: { grant: GRANT_USER_GROUP } });
+    } else if (userGroups != null && userGroups.length > 0) {
+      const userGroupIds = userGroups.map((group) => {
+        return group._id.toString();
+      });
+      grantConditions.push({
+        bool: {
+          must: [
+            { term: { grant: GRANT_USER_GROUP } },
+            { terms: { granted_groups: userGroupIds } },
+          ],
         },
         },
-      );
+      });
     }
     }
 
 
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
@@ -913,7 +1027,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   async appendFunctionScore(query, queryString): Promise<void> {
   async appendFunctionScore(query, queryString): Promise<void> {
     const User = this.getUserModel();
     const User = this.getUserModel();
-    const count = await User.count({}) || 1;
+    const count = (await User.count({})) || 1;
 
 
     const minScore = queryString.length * 0.1 - 1; // increase with length
     const minScore = queryString.length * 0.1 - 1; // increase with length
     logger.debug('min_score: ', minScore);
     logger.debug('min_score: ', minScore);
@@ -958,7 +1072,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
     };
   }
   }
 
 
-  async search(data: SearchableData<ESQueryTerms>, user, userGroups, option?): Promise<ISearchResult<ISearchResultData>> {
+  async search(
+    data: SearchableData<ESQueryTerms>,
+    user,
+    userGroups,
+    option?,
+  ): Promise<ISearchResult<ISearchResultData>> {
     const { queryString, terms } = data;
     const { queryString, terms } = data;
 
 
     if (terms == null) {
     if (terms == null) {
@@ -976,7 +1095,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     await this.filterPagesByViewer(query, user, userGroups);
     await this.filterPagesByViewer(query, user, userGroups);
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
 
 
-
     this.appendResultSize(query, from, size);
     this.appendResultSize(query, from, size);
 
 
     this.appendSortOrder(query, sort, order);
     this.appendSortOrder(query, sort, order);
@@ -989,7 +1107,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
     const entries = Object.entries(terms);
     const entries = Object.entries(terms);
 
 
-    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+    return !entries.some(
+      ([key, val]) =>
+        !AVAILABLE_KEYS.includes(key) &&
+        typeof val?.length === 'number' &&
+        val.length > 0,
+    );
   }
   }
 
 
   validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
   validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
@@ -1014,8 +1137,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       if (shoudDeletePages.length !== 0) {
       if (shoudDeletePages.length !== 0) {
         await this.deletePages(shoudDeletePages);
         await this.deletePages(shoudDeletePages);
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1031,8 +1153,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     try {
     try {
       return await this.deletePages(pages);
       return await this.deletePages(pages);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1042,8 +1163,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     try {
     try {
       return await this.deletePages([page]);
       return await this.deletePages([page]);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('deletePages:ES Error', err);
       logger.error('deletePages:ES Error', err);
     }
     }
   }
   }
@@ -1065,7 +1185,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     return this.updateOrInsertPageById(page._id);
     return this.updateOrInsertPageById(page._id);
   }
   }
-
 }
 }
 
 
 export default ElasticsearchDelegator;
 export default ElasticsearchDelegator;

+ 40 - 21
apps/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -4,31 +4,46 @@ import mongoose from 'mongoose';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult } from '~/interfaces/search';
 import type { ISearchResult } from '~/interfaces/search';
-import type { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
+import type {
+  PageDocument,
+  PageModel,
+  PageQueryBuilder,
+} from '~/server/models/page';
 import { serializePageSecurely } from '~/server/models/serializers';
 import { serializePageSecurely } from '~/server/models/serializers';
 
 
 import type {
 import type {
-  QueryTerms, MongoTermsKey,
-  SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
+  MongoQueryTerms,
+  MongoTermsKey,
+  QueryTerms,
+  SearchableData,
+  SearchDelegator,
+  UnavailableTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 
 
-
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
 const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
 
 
-class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms> {
-
+class PrivateLegacyPagesDelegator
+  implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms>
+{
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES;
 
 
   constructor() {
   constructor() {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
   }
 
 
-  async search(data: SearchableData<MongoQueryTerms>, user, userGroups, option): Promise<ISearchResult<IPage>> {
+  async search(
+    data: SearchableData<MongoQueryTerms>,
+    user,
+    userGroups,
+    option,
+  ): Promise<ISearchResult<IPage>> {
     const { terms } = data;
     const { terms } = data;
     const { offset, limit } = option;
     const { offset, limit } = option;
 
 
     if (offset == null || limit == null) {
     if (offset == null || limit == null) {
-      throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).');
+      throw Error(
+        'PrivateLegacyPagesDelegator requires pagination options (offset, limit).',
+      );
     }
     }
     if (user == null && userGroups == null) {
     if (user == null && userGroups == null) {
       throw Error('Either of user and userGroups must not be null.');
       throw Error('Either of user and userGroups must not be null.');
@@ -50,13 +65,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
 
 
     const pages: PageDocument[] = await findQueryBuilder
     const pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .addConditionToPagenate(offset, limit)
-      .query
-      .populate('creator')
+      .query.populate('creator')
       .populate('lastUpdateUser')
       .populate('lastUpdateUser')
       .exec();
       .exec();
 
 
     return {
     return {
-      data: pages.map(page => serializePageSecurely(page)),
+      data: pages.map((page) => serializePageSecurely(page)),
       meta: {
       meta: {
         total,
         total,
         hitsCount: pages.length,
         hitsCount: pages.length,
@@ -64,22 +78,23 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
     };
     };
   }
   }
 
 
-  private addConditionByTerms(builder: PageQueryBuilder, terms: MongoQueryTerms): PageQueryBuilder {
-    const {
-      match, not_match: notMatch, prefix, not_prefix: notPrefix,
-    } = terms;
+  private addConditionByTerms(
+    builder: PageQueryBuilder,
+    terms: MongoQueryTerms,
+  ): PageQueryBuilder {
+    const { match, not_match: notMatch, prefix, not_prefix: notPrefix } = terms;
 
 
     if (match.length > 0) {
     if (match.length > 0) {
-      match.forEach(m => builder.addConditionToListByMatch(m));
+      match.forEach((m) => builder.addConditionToListByMatch(m));
     }
     }
     if (notMatch.length > 0) {
     if (notMatch.length > 0) {
-      notMatch.forEach(nm => builder.addConditionToListByNotMatch(nm));
+      notMatch.forEach((nm) => builder.addConditionToListByNotMatch(nm));
     }
     }
     if (prefix.length > 0) {
     if (prefix.length > 0) {
-      prefix.forEach(p => builder.addConditionToListByStartWith(p));
+      prefix.forEach((p) => builder.addConditionToListByStartWith(p));
     }
     }
     if (notPrefix.length > 0) {
     if (notPrefix.length > 0) {
-      notPrefix.forEach(np => builder.addConditionToListByNotStartWith(np));
+      notPrefix.forEach((np) => builder.addConditionToListByNotStartWith(np));
     }
     }
 
 
     return builder;
     return builder;
@@ -88,7 +103,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
   isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
   isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
     const entries = Object.entries(terms);
     const entries = Object.entries(terms);
 
 
-    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+    return !entries.some(
+      ([key, val]) =>
+        !AVAILABLE_KEYS.includes(key) &&
+        typeof val?.length === 'number' &&
+        val.length > 0,
+    );
   }
   }
 
 
   validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
   validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
@@ -98,7 +118,6 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKe
       .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
       .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
       .map(([key]) => key as UnavailableTermsKey<MongoTermsKey>); // use "as": https://github.com/microsoft/TypeScript/issues/41173
       .map(([key]) => key as UnavailableTermsKey<MongoTermsKey>); // use "as": https://github.com/microsoft/TypeScript/issues/41173
   }
   }
-
 }
 }
 
 
 export default PrivateLegacyPagesDelegator;
 export default PrivateLegacyPagesDelegator;

+ 6 - 5
apps/app/src/server/service/search-reconnect-context/reconnect-context.js

@@ -1,12 +1,12 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:service:search-reconnect-context:reconnect-context');
-
+const logger = loggerFactory(
+  'growi:service:search-reconnect-context:reconnect-context',
+);
 
 
 const RECONNECT_INTERVAL_SEC = 120;
 const RECONNECT_INTERVAL_SEC = 120;
 
 
 class ReconnectContext {
 class ReconnectContext {
-
   constructor() {
   constructor() {
     this.lastEvalDate = null;
     this.lastEvalDate = null;
 
 
@@ -39,7 +39,9 @@ class ReconnectContext {
       return true;
       return true;
     }
     }
 
 
-    const thres = this.lastEvalDate.setSeconds(this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC);
+    const thres = this.lastEvalDate.setSeconds(
+      this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC,
+    );
     return thres < new Date();
     return thres < new Date();
   }
   }
 
 
@@ -54,7 +56,6 @@ class ReconnectContext {
     }
     }
     return false;
     return false;
   }
   }
-
 }
 }
 
 
 async function nextTick(context, reconnectHandler) {
 async function nextTick(context, reconnectHandler) {

+ 18 - 6
apps/app/src/server/service/slack-command-handler/create-page-service.js

@@ -14,7 +14,6 @@ const { pathUtils } = require('@growi/core/dist/utils');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 class CreatePageService {
 class CreatePageService {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -23,7 +22,13 @@ class CreatePageService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user) {
+  async createPageInGrowi(
+    interactionPayloadAccessor,
+    path,
+    contentsBody,
+    respondUtil,
+    user,
+  ) {
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
 
     // sanitize path
     // sanitize path
@@ -31,20 +36,27 @@ class CreatePageService {
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
 
     // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated
     // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated
-    const userOrDummyUser = user != null ? user : { _id: new mongoose.Types.ObjectId() };
+    const userOrDummyUser =
+      user != null ? user : { _id: new mongoose.Types.ObjectId() };
 
 
-    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, userOrDummyUser, {});
+    const page = await this.crowi.pageService.create(
+      normalizedPath,
+      reshapedContentsBody,
+      userOrDummyUser,
+      {},
+    );
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete
     const growiUri = growiInfoService.getSiteUrl();
     const growiUri = growiInfoService.getSiteUrl();
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Page has been created',
       text: 'Page has been created',
       blocks: [
       blocks: [
-        markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
+        markdownSectionBlock(
+          `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+        ),
       ],
       ],
     });
     });
   }
   }
-
 }
 }
 
 
 module.exports = CreatePageService;
 module.exports = CreatePageService;

+ 35 - 15
apps/app/src/server/service/slack-command-handler/error-handler.ts

@@ -1,29 +1,36 @@
-import assert from 'assert';
-
 import type { RespondBodyForResponseUrl } from '@growi/slack';
 import type { RespondBodyForResponseUrl } from '@growi/slack';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { respond } from '@growi/slack/dist/utils/response-url';
 import { respond } from '@growi/slack/dist/utils/response-url';
 import { type ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
 import { type ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
-
+import assert from 'assert';
 
 
 import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
 
 
-function generateRespondBodyForInternalServerError(message): RespondBodyForResponseUrl {
+function generateRespondBodyForInternalServerError(
+  message,
+): RespondBodyForResponseUrl {
   return {
   return {
     text: message,
     text: message,
     blocks: [
     blocks: [
-      markdownSectionBlock(`*GROWI Internal Server Error occured.*\n \`${message}\``),
+      markdownSectionBlock(
+        `*GROWI Internal Server Error occured.*\n \`${message}\``,
+      ),
     ],
     ],
   };
   };
 }
 }
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-async function handleErrorWithWebClient(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse> {
-
+async function handleErrorWithWebClient(
+  error: Error,
+  client: WebClient,
+  body: any,
+): Promise<ChatPostEphemeralResponse> {
   const isInteraction = !body.channel_id;
   const isInteraction = !body.channel_id;
 
 
   // this method is expected to use when system couldn't response_url
   // this method is expected to use when system couldn't response_url
-  assert(!(error instanceof SlackCommandHandlerError) || error.responseUrl == null);
+  assert(
+    !(error instanceof SlackCommandHandlerError) || error.responseUrl == null,
+  );
 
 
   const payload = JSON.parse(body.payload);
   const payload = JSON.parse(body.payload);
 
 
@@ -37,15 +44,23 @@ async function handleErrorWithWebClient(error: Error, client: WebClient, body: a
   });
   });
 }
 }
 
 
-
-export async function handleError(error: SlackCommandHandlerError | Error, responseUrl?: string): Promise<void>;
+export async function handleError(
+  error: SlackCommandHandlerError | Error,
+  responseUrl?: string,
+): Promise<void>;
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function handleError(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse>;
+export async function handleError(
+  error: Error,
+  client: WebClient,
+  body: any,
+): Promise<ChatPostEphemeralResponse>;
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function handleError(error: SlackCommandHandlerError | Error, ...args: any[]): Promise<void|ChatPostEphemeralResponse> {
-
+export async function handleError(
+  error: SlackCommandHandlerError | Error,
+  ...args: any[]
+): Promise<void | ChatPostEphemeralResponse> {
   // handle a SlackCommandHandlerError
   // handle a SlackCommandHandlerError
   if (error instanceof SlackCommandHandlerError) {
   if (error instanceof SlackCommandHandlerError) {
     const responseUrl = args[0] || error.responseUrl;
     const responseUrl = args[0] || error.responseUrl;
@@ -56,11 +71,16 @@ export async function handleError(error: SlackCommandHandlerError | Error, ...ar
   }
   }
 
 
   const secondArg = args[0];
   const secondArg = args[0];
-  assert(secondArg != null, 'Couldn\'t handle Error without the second argument.');
+  assert(
+    secondArg != null,
+    "Couldn't handle Error without the second argument.",
+  );
 
 
   // handle a normal Error with response_url
   // handle a normal Error with response_url
   if (typeof secondArg === 'string') {
   if (typeof secondArg === 'string') {
-    const respondBody = generateRespondBodyForInternalServerError(error.message);
+    const respondBody = generateRespondBodyForInternalServerError(
+      error.message,
+    );
     return respond(secondArg, respondBody);
     return respond(secondArg, respondBody);
   }
   }
 
 

+ 6 - 6
apps/app/src/server/service/slack-command-handler/help.js

@@ -12,22 +12,22 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
-  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
     const appSiteUrl = growiInfoService.getSiteUrl();
     const appSiteUrl = growiInfoService.getSiteUrl();
     // adjust spacing
     // adjust spacing
     let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Commands:\n\n';
     message += 'Commands:\n\n';
-    message += '`/growi note`                          Take a note on GROWI\n\n';
+    message +=
+      '`/growi note`                          Take a note on GROWI\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
+    message +=
+      '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
 
 
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Help',
       text: 'Help',
-      blocks: [
-        markdownSectionBlock(message),
-      ],
+      blocks: [markdownSectionBlock(message)],
     });
     });
   };
   };
 
 

+ 156 - 62
apps/app/src/server/service/slack-command-handler/keep.js

@@ -1,5 +1,8 @@
 import {
 import {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  actionsBlock,
+  buttonElement,
+  inputBlock,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { parse } from 'date-fns/parse';
 import { parse } from 'date-fns/parse';
@@ -17,7 +20,12 @@ module.exports = (crowi) => {
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
   const { User } = crowi.models;
   const { User } = crowi.models;
 
 
-  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+  handler.handleCommand = async function (
+    growiCommand,
+    client,
+    body,
+    respondUtil,
+  ) {
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Select messages to use.',
       text: 'Select messages to use.',
       blocks: this.keepMessageBlocks(body.channel_name),
       blocks: this.keepMessageBlocks(body.channel_name),
@@ -25,21 +33,46 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.cancel = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.createPage = async function (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const user = await User.findUserBySlackMemberId(payload.user.id);
     const user = await User.findUserBySlackMemberId(payload.user.id);
 
 
     // validate form
     // validate form
-    const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
+    const { path, oldest, newest } = await this.keepValidateForm(
+      client,
+      payload,
+      interactionPayloadAccessor,
+    );
     // get messages
     // get messages
     result = await this.keepGetMessages(client, channelId, newest, oldest);
     result = await this.keepGetMessages(client, channelId, newest, oldest);
     // clean messages
     // clean messages
@@ -47,37 +80,66 @@ module.exports = (crowi) => {
 
 
     const contentsBody = cleanedContents.join('');
     const contentsBody = cleanedContents.join('');
     // create and send url message
     // create and send url message
-    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil);
+    await this.keepCreatePageAndSendPreview(
+      client,
+      interactionPayloadAccessor,
+      path,
+      user,
+      contentsBody,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
+  handler.keepValidateForm = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
-    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
-    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
-    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest =
+      interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest =
+      interactionPayloadAccessor.getStateValues()?.newest.newest.value;
 
 
     if (oldest == null || newest == null || path == null) {
     if (oldest == null || newest == null || path == null) {
-      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+      throw new SlackCommandHandlerError(
+        'All parameters are required. (Oldest datetime, Newst datetime and Page path)',
+      );
     }
     }
 
 
     /**
     /**
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * @see https://regex101.com/r/XbxdNo/1
      * @see https://regex101.com/r/XbxdNo/1
      */
      */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+    const regexpDatetime = new RegExp(
+      /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/,
+    );
 
 
     if (!regexpDatetime.test(oldest.trim())) {
     if (!regexpDatetime.test(oldest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
     if (!regexpDatetime.test(newest.trim())) {
     if (!regexpDatetime.test(newest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for newest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    oldest =
+      parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+    newest =
+      parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset +
+      60;
 
 
     if (oldest > newest) {
     if (oldest > newest) {
-      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+      throw new SlackCommandHandlerError(
+        'Oldest datetime must be older than the newest date time.',
+      );
     }
     }
 
 
     return { path, oldest, newest };
     return { path, oldest, newest };
@@ -93,14 +155,13 @@ module.exports = (crowi) => {
     });
     });
   }
   }
 
 
-  handler.keepGetMessages = async function(client, channelId, newest, oldest) {
+  handler.keepGetMessages = async (client, channelId, newest, oldest) => {
     let result;
     let result;
 
 
     // first attempt
     // first attempt
     try {
     try {
       result = await retrieveHistory(client, channelId, newest, oldest);
       result = await retrieveHistory(client, channelId, newest, oldest);
-    }
-    catch (err) {
+    } catch (err) {
       const errorCode = err.data?.errorCode;
       const errorCode = err.data?.errorCode;
 
 
       if (errorCode === 'not_in_channel') {
       if (errorCode === 'not_in_channel') {
@@ -109,12 +170,11 @@ module.exports = (crowi) => {
           channel: channelId,
           channel: channelId,
         });
         });
         result = await retrieveHistory(client, channelId, newest, oldest);
         result = await retrieveHistory(client, channelId, newest, oldest);
-      }
-      else if (errorCode === 'channel_not_found') {
-
-        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
-          + '\nPlease add GROWI bot to this channel.'
-          + '\n';
+      } else if (errorCode === 'channel_not_found') {
+        const message =
+          ":cry: GROWI Bot couldn't get history data because *this channel was private*." +
+          '\nPlease add GROWI bot to this channel.' +
+          '\n';
         throw new SlackCommandHandlerError(message, {
         throw new SlackCommandHandlerError(message, {
           respondBody: {
           respondBody: {
             text: message,
             text: message,
@@ -122,21 +182,23 @@ module.exports = (crowi) => {
               markdownSectionBlock(message),
               markdownSectionBlock(message),
               {
               {
                 type: 'image',
                 type: 'image',
-                image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
+                image_url:
+                  'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
                 alt_text: 'Add app to this channel',
                 alt_text: 'Add app to this channel',
               },
               },
             ],
             ],
           },
           },
         });
         });
-      }
-      else {
+      } else {
         throw err;
         throw err;
       }
       }
     }
     }
 
 
     // return if no message found
     // return if no message found
     if (result.messages.length === 0) {
     if (result.messages.length === 0) {
-      throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.');
+      throw new SlackCommandHandlerError(
+        'No message found from keep command. Try different datetime.',
+      );
     }
     }
     return result;
     return result;
   };
   };
@@ -146,7 +208,7 @@ module.exports = (crowi) => {
    * @param {*} messages (array of messages)
    * @param {*} messages (array of messages)
    * @returns users object with matching Slack Member ID
    * @returns users object with matching Slack Member ID
    */
    */
-  handler.getGrowiUsersFromMessages = async function(messages) {
+  handler.getGrowiUsersFromMessages = async (messages) => {
     const users = messages.map((message) => {
     const users = messages.map((message) => {
       return message.user;
       return message.user;
     });
     });
@@ -157,21 +219,22 @@ module.exports = (crowi) => {
    * Convert slack member ID to growi user if slack member ID is found in messages
    * Convert slack member ID to growi user if slack member ID is found in messages
    * @param {*} messages
    * @param {*} messages
    */
    */
-  handler.injectGrowiUsernameToMessages = async function(messages) {
+  handler.injectGrowiUsernameToMessages = async function (messages) {
     const growiUsers = await this.getGrowiUsersFromMessages(messages);
     const growiUsers = await this.getGrowiUsersFromMessages(messages);
 
 
-    messages.map(async(message) => {
-      const growiUser = growiUsers.find(user => user.slackMemberId === message.user);
+    messages.map(async (message) => {
+      const growiUser = growiUsers.find(
+        (user) => user.slackMemberId === message.user,
+      );
       if (growiUser != null) {
       if (growiUser != null) {
         message.user = `${growiUser.name} (@${growiUser.username})`;
         message.user = `${growiUser.name} (@${growiUser.username})`;
-      }
-      else {
+      } else {
         message.user = `This slack member ID is not registered (${message.user})`;
         message.user = `This slack member ID is not registered (${message.user})`;
       }
       }
     });
     });
   };
   };
 
 
-  handler.keepCleanMessages = async function(messages) {
+  handler.keepCleanMessages = async function (messages) {
     const cleanedContents = [];
     const cleanedContents = [];
     let lastMessage = {};
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
@@ -199,8 +262,21 @@ module.exports = (crowi) => {
     return cleanedContents;
     return cleanedContents;
   };
   };
 
 
-  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user);
+  handler.keepCreatePageAndSendPreview = async (
+    client,
+    interactionPayloadAccessor,
+    path,
+    user,
+    contentsBody,
+    respondUtil,
+  ) => {
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+      respondUtil,
+      user,
+    );
 
 
     // TODO: contentsBody text characters must be less than 3001
     // TODO: contentsBody text characters must be less than 3001
     // send preview to dm
     // send preview to dm
@@ -219,7 +295,7 @@ module.exports = (crowi) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.keepMessageBlocks = function(channelName) {
+  handler.keepMessageBlocks = (channelName) => {
     const tzDateSec = new Date().getTime();
     const tzDateSec = new Date().getTime();
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
 
 
@@ -233,29 +309,47 @@ module.exports = (crowi) => {
 
 
     return [
     return [
       markdownSectionBlock('*The keep command is in alpha.*'),
       markdownSectionBlock('*The keep command is in alpha.*'),
-      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
-      inputBlock({
-        type: 'plain_text_input',
-        action_id: 'oldest',
-        initial_value: initialOldest,
-      }, 'oldest', 'Oldest datetime'),
-      inputBlock({
-        type: 'plain_text_input',
-        action_id: 'newest',
-        initial_value: initialNewest,
-      }, 'newest', 'Newest datetime'),
-      inputBlock({
-        type: 'plain_text_input',
-        placeholder: {
-          type: 'plain_text',
-          text: 'Input page path to create.',
+      markdownSectionBlock(
+        'Select the oldest and newest datetime of the messages to use.',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          action_id: 'oldest',
+          initial_value: initialOldest,
         },
         },
-        initial_value: initialPagePath,
-        action_id: 'page_path',
-      }, 'page_path', 'Page path'),
+        'oldest',
+        'Oldest datetime',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          action_id: 'newest',
+          initial_value: initialNewest,
+        },
+        'newest',
+        'Newest datetime',
+      ),
+      inputBlock(
+        {
+          type: 'plain_text_input',
+          placeholder: {
+            type: 'plain_text',
+            text: 'Input page path to create.',
+          },
+          initial_value: initialPagePath,
+          action_id: 'page_path',
+        },
+        'page_path',
+        'Page path',
+      ),
       actionsBlock(
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
         buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
-        buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }),
+        buttonElement({
+          text: 'Create page',
+          actionId: 'keep:createPage',
+          style: 'primary',
+        }),
       ),
       ),
     ];
     ];
   };
   };

+ 60 - 13
apps/app/src/server/service/slack-command-handler/note.js

@@ -1,5 +1,9 @@
 import {
 import {
-  markdownHeaderBlock, inputSectionBlock, inputBlock, actionsBlock, buttonElement,
+  actionsBlock,
+  buttonElement,
+  inputBlock,
+  inputSectionBlock,
+  markdownHeaderBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
@@ -20,39 +24,82 @@ module.exports = (crowi) => {
   };
   };
   const { User } = crowi.models;
   const { User } = crowi.models;
 
 
-  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     await respondUtil.respond({
     await respondUtil.respond({
       text: 'Take a note on GROWI',
       text: 'Take a note on GROWI',
       blocks: [
       blocks: [
         markdownHeaderBlock('Take a note on GROWI'),
         markdownHeaderBlock('Take a note on GROWI'),
-        inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
+        inputBlock(
+          conversationsSelectElement,
+          'conversation',
+          'Channel name to display in the page to be created',
+        ),
         inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
         inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
-        inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
+        inputSectionBlock(
+          'contents',
+          'Contents',
+          'contents_input',
+          true,
+          'Input with Markdown...',
+        ),
         actionsBlock(
         actionsBlock(
           buttonElement({ text: 'Cancel', actionId: 'note:cancel' }),
           buttonElement({ text: 'Cancel', actionId: 'note:cancel' }),
-          buttonElement({ text: 'Create page', actionId: 'note:createPage', style: 'primary' }),
+          buttonElement({
+            text: 'Create page',
+            actionId: 'note:createPage',
+            style: 'primary',
+          }),
         ),
         ),
-
       ],
       ],
     });
     });
   };
   };
 
 
-  handler.cancel = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
+  handler.cancel = async (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
+  handler.createPage = async (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     const user = await User.findUserBySlackMemberId(interactionPayload.user.id);
     const user = await User.findUserBySlackMemberId(interactionPayload.user.id);
-    const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
-    const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.path.path_input.value;
+    const contentsBody =
+      interactionPayloadAccessor.getStateValues()?.contents.contents_input
+        .value;
     if (path == null || contentsBody == null) {
     if (path == null || contentsBody == null) {
       throw new SlackCommandHandlerError('All parameters are required.');
       throw new SlackCommandHandlerError('All parameters are required.');
     }
     }
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user);
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+      respondUtil,
+      user,
+    );
     await respondUtil.deleteOriginal();
     await respondUtil.deleteOriginal();
   };
   };
 
 

+ 143 - 89
apps/app/src/server/service/slack-command-handler/search.js

@@ -1,5 +1,6 @@
 import {
 import {
-  markdownSectionBlock, divider,
+  divider,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 
 
@@ -7,32 +8,32 @@ import loggerFactory from '~/utils/logger';
 
 
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
 
 
-
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const PAGINGLIMIT = 7;
 const PAGINGLIMIT = 7;
 
 
-
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
   const handler = new BaseSlackCommandHandler(crowi);
 
 
-
   function getKeywords(growiCommandArgs) {
   function getKeywords(growiCommandArgs) {
     const keywords = growiCommandArgs.join(' ');
     const keywords = growiCommandArgs.join(' ');
     return keywords;
     return keywords;
   }
   }
 
 
   function appendSpeechBaloon(mrkdwn, commentCount) {
   function appendSpeechBaloon(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
+    return commentCount != null && commentCount > 0
       ? `${mrkdwn}   :speech_balloon: ${commentCount}`
       ? `${mrkdwn}   :speech_balloon: ${commentCount}`
       : mrkdwn;
       : mrkdwn;
   }
   }
 
 
   function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
   function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
     const url = new URL('/_search', appUrl);
     const url = new URL('/_search', appUrl);
-    url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+'));
+    url.searchParams.append(
+      'q',
+      growiCommandArgs.map((kwd) => encodeURIComponent(kwd)).join('+'),
+    );
     return `<${url.href} | Results page>`;
     return `<${url.href} | Results page>`;
   }
   }
 
 
@@ -45,16 +46,27 @@ module.exports = (crowi) => {
 
 
     const { searchService } = crowi;
     const { searchService } = crowi;
     const options = { limit: PAGINGLIMIT, offset };
     const options = { limit: PAGINGLIMIT, offset };
-    const [results] = await searchService.searchKeyword(keywords, null, {}, options);
+    const [results] = await searchService.searchKeyword(
+      keywords,
+      null,
+      {},
+      options,
+    );
     const resultsTotal = results.meta.total;
     const resultsTotal = results.meta.total;
 
 
     const pages = results.data.map((data) => {
     const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      const {
+        path,
+        updated_at: updatedAt,
+        comment_count: commentCount,
+      } = data._source;
       return { path, updatedAt, commentCount };
       return { path, updatedAt, commentCount };
     });
     });
 
 
     return {
     return {
-      pages, offset, resultsTotal,
+      pages,
+      offset,
+      resultsTotal,
     };
     };
   }
   }
 
 
@@ -62,9 +74,7 @@ module.exports = (crowi) => {
     const appUrl = growiInfoService.getSiteUrl();
     const appUrl = growiInfoService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
 
 
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
+    const { pages, offset, resultsTotal } = searchResult;
 
 
     const keywords = getKeywords(growiCommandArgs);
     const keywords = getKeywords(growiCommandArgs);
 
 
@@ -83,17 +93,20 @@ module.exports = (crowi) => {
       elements: [
       elements: [
         {
         {
           type: 'mrkdwn',
           type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*`
-          + `  |  Total ${resultsTotal} pages`
-          + `  |  Current: ${offset + 1} - ${offset + pages.length}`
-          + `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
+          text:
+            `keyword(s) : *"${keywords}"*` +
+            `  |  Total ${resultsTotal} pages` +
+            `  |  Current: ${offset + 1} - ${offset + pages.length}` +
+            `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
         },
         },
       ],
       ],
     };
     };
 
 
     const now = new Date();
     const now = new Date();
     const blocks = [
     const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      markdownSectionBlock(
+        `:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`,
+      ),
       contextBlock,
       contextBlock,
       { type: 'divider' },
       { type: 'divider' },
       // create an array by map and extract
       // create an array by map and extract
@@ -107,8 +120,9 @@ module.exports = (crowi) => {
           type: 'section',
           type: 'section',
           text: {
           text: {
             type: 'mrkdwn',
             type: 'mrkdwn',
-            text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
+            text:
+              `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}` +
+              `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
           },
           },
           accessory: {
           accessory: {
             type: 'button',
             type: 'button',
@@ -130,45 +144,39 @@ module.exports = (crowi) => {
       elements: [],
       elements: [],
     };
     };
     // add "Dismiss" button
     // add "Dismiss" button
-    actionBlocks.elements.push(
-      {
-        type: 'button',
-        text: {
-          type: 'plain_text',
-          text: 'Dismiss',
-        },
-        style: 'danger',
-        action_id: 'search:dismissSearchResults',
+    actionBlocks.elements.push({
+      type: 'button',
+      text: {
+        type: 'plain_text',
+        text: 'Dismiss',
       },
       },
-    );
+      style: 'danger',
+      action_id: 'search:dismissSearchResults',
+    });
     // show "Prev" button if previous page exists
     // show "Prev" button if previous page exists
     // eslint-disable-next-line yoda
     // eslint-disable-next-line yoda
     if (0 < offset) {
     if (0 < offset) {
-      actionBlocks.elements.push(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: '< Prev',
-          },
-          action_id: 'search:showPrevResults',
-          value: JSON.stringify({ offset, growiCommandArgs }),
+      actionBlocks.elements.push({
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: '< Prev',
         },
         },
-      );
+        action_id: 'search:showPrevResults',
+        value: JSON.stringify({ offset, growiCommandArgs }),
+      });
     }
     }
     // show "Next" button if next page exists
     // show "Next" button if next page exists
     if (offset + PAGINGLIMIT < resultsTotal) {
     if (offset + PAGINGLIMIT < resultsTotal) {
-      actionBlocks.elements.push(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next >',
-          },
-          action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, growiCommandArgs }),
+      actionBlocks.elements.push({
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: 'Next >',
         },
         },
-      );
+        action_id: 'search:showNextResults',
+        value: JSON.stringify({ offset, growiCommandArgs }),
+      });
     }
     }
     blocks.push(actionBlocks);
     blocks.push(actionBlocks);
 
 
@@ -178,7 +186,6 @@ module.exports = (crowi) => {
     };
     };
   }
   }
 
 
-
   async function buildRespondBody(growiCommandArgs) {
   async function buildRespondBody(growiCommandArgs) {
     const firstKeyword = growiCommandArgs[0];
     const firstKeyword = growiCommandArgs[0];
 
 
@@ -187,7 +194,9 @@ module.exports = (crowi) => {
       return {
       return {
         text: 'Input keywords',
         text: 'Input keywords',
         blocks: [
         blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
+          markdownSectionBlock(
+            '*Input keywords.*\n Hint\n `/growi search [keyword]`',
+          ),
         ],
         ],
       };
       };
     }
     }
@@ -202,18 +211,30 @@ module.exports = (crowi) => {
       return {
       return {
         text: `No page found with "${keywords}"`,
         text: `No page found with "${keywords}"`,
         blocks: [
         blocks: [
-          markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
+          markdownSectionBlock(
+            `*No page matches your keyword(s) "${keywords}".*`,
+          ),
           markdownSectionBlock(':mag: *Help: Searching*'),
           markdownSectionBlock(':mag: *Help: Searching*'),
           divider(),
           divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
+          markdownSectionBlock(
+            '`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
+          markdownSectionBlock(
+            '`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
+          markdownSectionBlock(
+            '`-keyword` \n Exclude pages that include keyword in the title or body',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
+          markdownSectionBlock(
+            '`prefix:/user/` \n Search only the pages that the title start with /user/',
+          ),
           divider(),
           divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
+          markdownSectionBlock(
+            '`-prefix:/user/` \n Exclude the pages that the title start with /user/',
+          ),
           divider(),
           divider(),
           markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
           markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
           divider(),
           divider(),
@@ -225,19 +246,34 @@ module.exports = (crowi) => {
     return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
     return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
   }
   }
 
 
-
-  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+  handler.handleCommand = async (growiCommand, client, body, respondUtil) => {
     const { growiCommandArgs } = growiCommand;
     const { growiCommandArgs } = growiCommand;
 
 
     const respondBody = await buildRespondBody(growiCommandArgs);
     const respondBody = await buildRespondBody(growiCommandArgs);
     await respondUtil.respond(respondBody);
     await respondUtil.respond(respondBody);
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+    respondUtil,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+      respondUtil,
+    );
   };
   };
 
 
-  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+  handler.shareSinglePageResult = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => {
     const { user } = payload;
     const { user } = payload;
 
 
     const appUrl = growiInfoService.getSiteUrl();
     const appUrl = growiInfoService.getSiteUrl();
@@ -247,14 +283,13 @@ module.exports = (crowi) => {
     if (value == null) {
     if (value == null) {
       await respondUtil.respond({
       await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
-        blocks: [
-          markdownSectionBlock('Failed to share the result.'),
-        ],
+        blocks: [markdownSectionBlock('Failed to share the result.')],
       });
       });
       return;
       return;
     }
     }
 
 
-    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+    const parsedValue =
+      interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
     // restore page data from value
     // restore page data from value
     const { page, href, pathname } = parsedValue;
     const { page, href, pathname } = parsedValue;
@@ -265,15 +300,18 @@ module.exports = (crowi) => {
     return respondUtil.respondInChannel({
     return respondUtil.respondInChannel({
       blocks: [
       blocks: [
         { type: 'divider' },
         { type: 'divider' },
-        markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(
+          `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`,
+        ),
         {
         {
           type: 'context',
           type: 'context',
           elements: [
           elements: [
             {
             {
               type: 'mrkdwn',
               type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>`
-                + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\``
-                + `  |  Shared by *${user.username}*`,
+              text:
+                `<${decodeURI(appUrl)}|*${appTitle}*>` +
+                `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\`` +
+                `  |  Shared by *${user.username}*`,
             },
             },
           ],
           ],
         },
         },
@@ -281,42 +319,58 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) {
-
+  async function showPrevOrNextResults(
+    interactionPayloadAccessor,
+    isNext = true,
+    respondUtil,
+  ) {
     const value = interactionPayloadAccessor.firstAction()?.value;
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
     if (value == null) {
       await respondUtil.respond({
       await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
-        blocks: [
-          markdownSectionBlock('Failed to show the next results.'),
-        ],
+        blocks: [markdownSectionBlock('Failed to show the next results.')],
       });
       });
       return;
       return;
     }
     }
 
 
-    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+    const parsedValue =
+      interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = isNext
     const newOffsetNum = isNext
       ? offsetNum + PAGINGLIMIT
       ? offsetNum + PAGINGLIMIT
       : offsetNum - PAGINGLIMIT;
       : offsetNum - PAGINGLIMIT;
 
 
-    const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
+    const searchResult = await retrieveSearchResults(
+      growiCommandArgs,
+      newOffsetNum,
+    );
 
 
-    await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+    await respondUtil.replaceOriginal(
+      buildRespondBodyForSearchResult(searchResult, growiCommandArgs),
+    );
   }
   }
 
 
-  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
-  };
-
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
-  };
-
-  handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
-    return respondUtil.deleteOriginal();
-  };
+  handler.showPrevResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
+
+  handler.showNextResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
+
+  handler.dismissSearchResults = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+    respondUtil,
+  ) => respondUtil.deleteOriginal();
 
 
   return handler;
   return handler;
 };
 };

+ 11 - 4
apps/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -1,6 +1,5 @@
 // Any slack command handler should inherit BaseSlackCommandHandler
 // Any slack command handler should inherit BaseSlackCommandHandler
 class BaseSlackCommandHandler {
 class BaseSlackCommandHandler {
-
   /** @type {import('~/server/crowi').default} Crowi instance */
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
   crowi;
 
 
@@ -12,13 +11,21 @@ class BaseSlackCommandHandler {
   /**
   /**
    * Handle /commands endpoint
    * Handle /commands endpoint
    */
    */
-  handleCommand(growiCommand, client, body) { throw new Error('Implement this') }
+  handleCommand(growiCommand, client, body) {
+    throw new Error('Implement this');
+  }
 
 
   /**
   /**
    * Handle interactions
    * Handle interactions
    */
    */
-  handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) { throw new Error('Implement this') }
-
+  handleInteractions(
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+  ) {
+    throw new Error('Implement this');
+  }
 }
 }
 
 
 module.exports = BaseSlackCommandHandler;
 module.exports = BaseSlackCommandHandler;

+ 135 - 54
apps/app/src/server/service/slack-command-handler/togetter.js

@@ -1,7 +1,11 @@
 import {
 import {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
+  actionsBlock,
+  buttonElement,
+  divider,
+  inputBlock,
+  markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
-import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url';
+import { deleteOriginal, respond } from '@growi/slack/dist/utils/response-url';
 import { format, formatDate } from 'date-fns/format';
 import { format, formatDate } from 'date-fns/format';
 import { parse } from 'date-fns/parse';
 import { parse } from 'date-fns/parse';
 
 
@@ -18,7 +22,7 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
-  handler.handleCommand = async function(growiCommand, client, body) {
+  handler.handleCommand = async function (growiCommand, client, body) {
     await respond(growiCommand.responseUrl, {
     await respond(growiCommand.responseUrl, {
       text: 'Select messages to use.',
       text: 'Select messages to use.',
       blocks: this.togetterMessageBlocks(),
       blocks: this.togetterMessageBlocks(),
@@ -26,23 +30,40 @@ module.exports = (crowi) => {
     return;
     return;
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function (
+    client,
+    interactionPayload,
+    interactionPayloadAccessor,
+    handlerMethodName,
+  ) {
+    await this[handlerMethodName](
+      client,
+      interactionPayload,
+      interactionPayloadAccessor,
+    );
   };
   };
 
 
-  handler.cancel = async function(client, payload, interactionPayloadAccessor) {
+  handler.cancel = async (client, payload, interactionPayloadAccessor) => {
     await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
     await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
       delete_original: true,
       delete_original: true,
     });
     });
   };
   };
 
 
-  handler.createPage = async function(client, payload, interactionPayloadAccessor) {
+  handler.createPage = async function (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const userChannelId = payload.user.id;
     const userChannelId = payload.user.id;
 
 
     // validate form
     // validate form
-    const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
+    const { path, oldest, newest } = await this.togetterValidateForm(
+      client,
+      payload,
+      interactionPayloadAccessor,
+    );
     // get messages
     // get messages
     result = await this.togetterGetMessages(client, channelId, newest, oldest);
     result = await this.togetterGetMessages(client, channelId, newest, oldest);
     // clean messages
     // clean messages
@@ -50,37 +71,65 @@ module.exports = (crowi) => {
 
 
     const contentsBody = cleanedContents.join('');
     const contentsBody = cleanedContents.join('');
     // create and send url message
     // create and send url message
-    await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
+    await this.togetterCreatePageAndSendPreview(
+      client,
+      interactionPayloadAccessor,
+      path,
+      userChannelId,
+      contentsBody,
+    );
   };
   };
 
 
-  handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
+  handler.togetterValidateForm = async (
+    client,
+    payload,
+    interactionPayloadAccessor,
+  ) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
-    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
-    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
-    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+    const path =
+      interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest =
+      interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest =
+      interactionPayloadAccessor.getStateValues()?.newest.newest.value;
 
 
     if (oldest == null || newest == null || path == null) {
     if (oldest == null || newest == null || path == null) {
-      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+      throw new SlackCommandHandlerError(
+        'All parameters are required. (Oldest datetime, Newst datetime and Page path)',
+      );
     }
     }
 
 
     /**
     /**
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * @see https://regex101.com/r/XbxdNo/1
      * @see https://regex101.com/r/XbxdNo/1
      */
      */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+    const regexpDatetime = new RegExp(
+      /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/,
+    );
 
 
     if (!regexpDatetime.test(oldest.trim())) {
     if (!regexpDatetime.test(oldest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
     if (!regexpDatetime.test(newest.trim())) {
     if (!regexpDatetime.test(newest.trim())) {
-      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+      throw new SlackCommandHandlerError(
+        'Datetime format for newest must be yyyy/MM/dd-HH:mm',
+      );
     }
     }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    oldest =
+      parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+    newest =
+      parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 +
+      grwTzoffset +
+      60;
 
 
     if (oldest > newest) {
     if (oldest > newest) {
-      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+      throw new SlackCommandHandlerError(
+        'Oldest datetime must be older than the newest date time.',
+      );
     }
     }
 
 
     return { path, oldest, newest };
     return { path, oldest, newest };
@@ -96,14 +145,13 @@ module.exports = (crowi) => {
     });
     });
   }
   }
 
 
-  handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
+  handler.togetterGetMessages = async (client, channelId, newest, oldest) => {
     let result;
     let result;
 
 
     // first attempt
     // first attempt
     try {
     try {
       result = await retrieveHistory(client, channelId, newest, oldest);
       result = await retrieveHistory(client, channelId, newest, oldest);
-    }
-    catch (err) {
+    } catch (err) {
       const errorCode = err.data?.errorCode;
       const errorCode = err.data?.errorCode;
 
 
       if (errorCode === 'not_in_channel') {
       if (errorCode === 'not_in_channel') {
@@ -112,12 +160,11 @@ module.exports = (crowi) => {
           channel: channelId,
           channel: channelId,
         });
         });
         result = await retrieveHistory(client, channelId, newest, oldest);
         result = await retrieveHistory(client, channelId, newest, oldest);
-      }
-      else if (errorCode === 'channel_not_found') {
-
-        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
-          + '\nPlease add GROWI bot to this channel.'
-          + '\n';
+      } else if (errorCode === 'channel_not_found') {
+        const message =
+          ":cry: GROWI Bot couldn't get history data because *this channel was private*." +
+          '\nPlease add GROWI bot to this channel.' +
+          '\n';
         throw new SlackCommandHandlerError(message, {
         throw new SlackCommandHandlerError(message, {
           respondBody: {
           respondBody: {
             text: message,
             text: message,
@@ -125,26 +172,28 @@ module.exports = (crowi) => {
               markdownSectionBlock(message),
               markdownSectionBlock(message),
               {
               {
                 type: 'image',
                 type: 'image',
-                image_url: 'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png',
+                image_url:
+                  'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png',
                 alt_text: 'Add app to this channel',
                 alt_text: 'Add app to this channel',
               },
               },
             ],
             ],
           },
           },
         });
         });
-      }
-      else {
+      } else {
         throw err;
         throw err;
       }
       }
     }
     }
 
 
     // return if no message found
     // return if no message found
     if (result.messages.length === 0) {
     if (result.messages.length === 0) {
-      throw new SlackCommandHandlerError('No message found from togetter command. Try different datetime.');
+      throw new SlackCommandHandlerError(
+        'No message found from togetter command. Try different datetime.',
+      );
     }
     }
     return result;
     return result;
   };
   };
 
 
-  handler.togetterCleanMessages = async function(messages) {
+  handler.togetterCleanMessages = async (messages) => {
     const cleanedContents = [];
     const cleanedContents = [];
     let lastMessage = {};
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
@@ -171,8 +220,18 @@ module.exports = (crowi) => {
     return cleanedContents;
     return cleanedContents;
   };
   };
 
 
-  handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+  handler.togetterCreatePageAndSendPreview = async (
+    client,
+    interactionPayloadAccessor,
+    path,
+    userChannelId,
+    contentsBody,
+  ) => {
+    await createPageService.createPageInGrowi(
+      interactionPayloadAccessor,
+      path,
+      contentsBody,
+    );
 
 
     // send preview to dm
     // send preview to dm
     await client.chat.postMessage({
     await client.chat.postMessage({
@@ -191,15 +250,33 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  handler.togetterMessageBlocks = function() {
+  handler.togetterMessageBlocks = function () {
     return [
     return [
-      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
-      inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),
-      inputBlock(this.plainTextInputElementWithInitialTime('newest'), 'newest', 'Newest datetime'),
-      inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
+      markdownSectionBlock(
+        'Select the oldest and newest datetime of the messages to use.',
+      ),
+      inputBlock(
+        this.plainTextInputElementWithInitialTime('oldest'),
+        'oldest',
+        'Oldest datetime',
+      ),
+      inputBlock(
+        this.plainTextInputElementWithInitialTime('newest'),
+        'newest',
+        'Newest datetime',
+      ),
+      inputBlock(
+        this.togetterInputBlockElement('page_path', '/'),
+        'page_path',
+        'Page path',
+      ),
       actionsBlock(
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
-        buttonElement({ text: 'Create page', actionId: 'togetter:createPage', style: 'primary' }),
+        buttonElement({
+          text: 'Create page',
+          actionId: 'togetter:createPage',
+          style: 'primary',
+        }),
       ),
       ),
     ];
     ];
   };
   };
@@ -208,21 +285,25 @@ module.exports = (crowi) => {
    * Plain-text input element
    * Plain-text input element
    * https://api.slack.com/reference/block-kit/block-elements#input
    * https://api.slack.com/reference/block-kit/block-elements#input
    */
    */
-  handler.togetterInputBlockElement = function(actionId, placeholderText = 'Write something ...') {
-    return {
-      type: 'plain_text_input',
-      placeholder: {
-        type: 'plain_text',
-        text: placeholderText,
-      },
-      action_id: actionId,
-    };
-  };
+  handler.togetterInputBlockElement = (
+    actionId,
+    placeholderText = 'Write something ...',
+  ) => ({
+    type: 'plain_text_input',
+    placeholder: {
+      type: 'plain_text',
+      text: placeholderText,
+    },
+    action_id: actionId,
+  });
 
 
-  handler.plainTextInputElementWithInitialTime = function(actionId) {
+  handler.plainTextInputElementWithInitialTime = (actionId) => {
     const tzDateSec = new Date().getTime();
     const tzDateSec = new Date().getTime();
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
     const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
-    const initialDateTime = format(new Date(tzDateSec - grwTzoffset), 'yyyy/MM/dd-HH:mm');
+    const initialDateTime = format(
+      new Date(tzDateSec - grwTzoffset),
+      'yyyy/MM/dd-HH:mm',
+    );
     return {
     return {
       type: 'plain_text_input',
       type: 'plain_text_input',
       action_id: actionId,
       action_id: actionId,

+ 10 - 4
apps/app/src/server/service/slack-event-handler/base-event-handler.ts

@@ -4,9 +4,15 @@ import type { WebClient } from '@slack/web-api';
 import type { EventActionsPermission } from '../../interfaces/slack-integration/events';
 import type { EventActionsPermission } from '../../interfaces/slack-integration/events';
 
 
 export interface SlackEventHandler<T> {
 export interface SlackEventHandler<T> {
+  shouldHandle(
+    eventType: string,
+    permission: EventActionsPermission,
+    channel?: string,
+  ): boolean;
 
 
-  shouldHandle(eventType: string, permission: EventActionsPermission, channel?: string): boolean
-
-  handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<T>, data?: any): Promise<void>
-
+  handleEvent(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<T>,
+    data?: any,
+  ): Promise<void>;
 }
 }

+ 101 - 60
apps/app/src/server/service/slack-event-handler/link-shared.ts

@@ -1,9 +1,7 @@
-import { PageGrant, type IPage } from '@growi/core';
+import { type IPage, PageGrant } from '@growi/core';
 import type { GrowiBotEvent } from '@growi/slack';
 import type { GrowiBotEvent } from '@growi/slack';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
 import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown';
-import type {
-  MessageAttachment, LinkUnfurls, WebClient,
-} from '@slack/web-api';
+import type { LinkUnfurls, MessageAttachment, WebClient } from '@slack/web-api';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -13,23 +11,30 @@ import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type {
 import type {
-  DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
+  DataForUnfurl,
+  PublicData,
+  UnfurlEventLink,
+  UnfurlRequestEvent,
 } from '../../interfaces/slack-integration/link-shared-unfurl';
 } from '../../interfaces/slack-integration/link-shared-unfurl';
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
-
 import type { SlackEventHandler } from './base-event-handler';
 import type { SlackEventHandler } from './base-event-handler';
 
 
 const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
 const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
 
 
-export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEvent> {
-
+export class LinkSharedEventHandler
+  implements SlackEventHandler<UnfurlRequestEvent>
+{
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean {
+  shouldHandle(
+    eventType: string,
+    permission: EventActionsPermission,
+    channel: string,
+  ): boolean {
     if (eventType !== 'link_shared') return false;
     if (eventType !== 'link_shared') return false;
 
 
     const unfurlPermission = permission.get('unfurl');
     const unfurlPermission = permission.get('unfurl');
@@ -41,7 +46,11 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     return unfurlPermission.includes(channel);
     return unfurlPermission.includes(channel);
   }
   }
 
 
-  async handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>, data?: {origin: string}): Promise<void> {
+  async handleEvent(
+    client: WebClient,
+    growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>,
+    data?: { origin: string },
+  ): Promise<void> {
     const { event } = growiBotEvent;
     const { event } = growiBotEvent;
     const origin = data?.origin || growiInfoService.getSiteUrl();
     const origin = data?.origin || growiInfoService.getSiteUrl();
     const { channel, message_ts: ts, links } = event;
     const { channel, message_ts: ts, links } = event;
@@ -49,49 +58,56 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     let unfurlData: DataForUnfurl[];
     let unfurlData: DataForUnfurl[];
     try {
     try {
       unfurlData = await this.generateUnfurlsObject(links);
       unfurlData = await this.generateUnfurlsObject(links);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error('Failed to generate unfurl data:', err);
       logger.error('Failed to generate unfurl data:', err);
       throw err;
       throw err;
     }
     }
 
 
     // unfurl
     // unfurl
-    const unfurlResults = await Promise.allSettled(unfurlData.map(async(data: DataForUnfurl) => {
-      const toUrl = urljoin(origin, data.id);
-
-      let targetUrl;
-      if (data.isPermalink) {
-        targetUrl = urljoin(origin, data.id);
-      }
-      else {
-        targetUrl = urljoin(origin, data.path);
-      }
-
-      let unfurls: LinkUnfurls;
-
-      if (data.isPublic === false) {
-        unfurls = {
-          [targetUrl]: {
-            text: 'Page is not public.',
-          },
-        };
-      }
-      else {
-        unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl);
-      }
-
-      await client.chat.unfurl({
-        channel,
-        ts,
-        unfurls,
-      });
-    }));
+    const unfurlResults = await Promise.allSettled(
+      unfurlData.map(async (data: DataForUnfurl) => {
+        const toUrl = urljoin(origin, data.id);
+
+        let targetUrl;
+        if (data.isPermalink) {
+          targetUrl = urljoin(origin, data.id);
+        } else {
+          targetUrl = urljoin(origin, data.path);
+        }
+
+        let unfurls: LinkUnfurls;
+
+        if (data.isPublic === false) {
+          unfurls = {
+            [targetUrl]: {
+              text: 'Page is not public.',
+            },
+          };
+        } else {
+          unfurls = this.generateLinkUnfurls(
+            data as PublicData,
+            targetUrl,
+            toUrl,
+          );
+        }
+
+        await client.chat.unfurl({
+          channel,
+          ts,
+          unfurls,
+        });
+      }),
+    );
 
 
     this.logErrorRejectedResults(unfurlResults);
     this.logErrorRejectedResults(unfurlResults);
   }
   }
 
 
   // builder method for unfurl parameter
   // builder method for unfurl parameter
-  generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
+  generateLinkUnfurls(
+    body: PublicData,
+    growiTargetUrl: string,
+    toUrl: string,
+  ): LinkUnfurls {
     const { pageBody: text, updatedAt } = body;
     const { pageBody: text, updatedAt } = body;
 
 
     const appTitle = this.crowi.appService.getAppTitle();
     const appTitle = this.crowi.appService.getAppTitle();
@@ -101,8 +117,9 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
       title: body.path,
       title: body.path,
       title_link: toUrl, // permalink
       title_link: toUrl, // permalink
       text,
       text,
-      footer: `<${decodeURI(siteUrl)}|*${appTitle}*>`
-      + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
+      footer:
+        `<${decodeURI(siteUrl)}|*${appTitle}*>` +
+        `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
     };
     };
 
 
     const unfurls: LinkUnfurls = {
     const unfurls: LinkUnfurls = {
@@ -111,7 +128,9 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     return unfurls;
     return unfurls;
   }
   }
 
 
-  async generateUnfurlsObject(links: UnfurlEventLink[]): Promise<DataForUnfurl[]> {
+  async generateUnfurlsObject(
+    links: UnfurlEventLink[],
+  ): Promise<DataForUnfurl[]> {
     // generate paths array
     // generate paths array
     const pathOrIds: string[] = links.map((link) => {
     const pathOrIds: string[] = links.map((link) => {
       const { url: growiTargetUrl } = link;
       const { url: growiTargetUrl } = link;
@@ -121,8 +140,10 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     });
     });
 
 
     const idRegExp = /^\/[0-9a-z]{24}$/;
     const idRegExp = /^\/[0-9a-z]{24}$/;
-    const paths = pathOrIds.filter(pathOrId => !idRegExp.test(pathOrId));
-    const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash
+    const paths = pathOrIds.filter((pathOrId) => !idRegExp.test(pathOrId));
+    const ids = pathOrIds
+      .filter((pathOrId) => idRegExp.test(pathOrId))
+      .map((id) => id.replace('/', '')); // remove a slash
 
 
     // get pages with revision
     // get pages with revision
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
@@ -131,27 +152,34 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
     const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
     const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
     const pagesByPaths = await pageQueryBuilderByPaths
     const pagesByPaths = await pageQueryBuilderByPaths
       .addConditionToListByPathsArray(paths)
       .addConditionToListByPathsArray(paths)
-      .query
-      .populate('revision')
+      .query.populate('revision')
       .lean()
       .lean()
       .exec();
       .exec();
 
 
     const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
     const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
     const pagesByIds = await pageQueryBuilderByIds
     const pagesByIds = await pageQueryBuilderByIds
       .addConditionToListByPageIdsArray(ids)
       .addConditionToListByPageIdsArray(ids)
-      .query
-      .populate('revision')
+      .query.populate('revision')
       .lean()
       .lean()
       .exec();
       .exec();
 
 
-    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(pagesByPaths, false);
-    const unfurlDataFromPermalinks = this.generateDataForUnfurl(pagesByIds, true);
+    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(
+      pagesByPaths,
+      false,
+    );
+    const unfurlDataFromPermalinks = this.generateDataForUnfurl(
+      pagesByIds,
+      true,
+    );
 
 
     return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
     return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private generateDataForUnfurl(pages: any, isPermalink: boolean): DataForUnfurl[] {
+  private generateDataForUnfurl(
+    pages: any,
+    isPermalink: boolean,
+  ): DataForUnfurl[] {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const unfurlData: DataForUnfurl[] = [];
     const unfurlData: DataForUnfurl[] = [];
 
 
@@ -159,7 +187,10 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
       // not send non-public page
       // not send non-public page
       if (page.grant !== PageGrant.GRANT_PUBLIC) {
       if (page.grant !== PageGrant.GRANT_PUBLIC) {
         return unfurlData.push({
         return unfurlData.push({
-          isPublic: false, isPermalink, id: page._id.toString(), path: page.path,
+          isPublic: false,
+          isPermalink,
+          id: page._id.toString(),
+          path: page.path,
         });
         });
       }
       }
 
 
@@ -167,7 +198,13 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
       const { updatedAt, commentCount } = page;
       const { updatedAt, commentCount } = page;
       const { body } = page.revision;
       const { body } = page.revision;
       unfurlData.push({
       unfurlData.push({
-        isPublic: true, isPermalink, id: page._id.toString(), path: page.path, pageBody: body, updatedAt, commentCount,
+        isPublic: true,
+        isPermalink,
+        id: page._id.toString(),
+        path: page.path,
+        pageBody: body,
+        updatedAt,
+        commentCount,
       });
       });
     });
     });
 
 
@@ -176,11 +213,15 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
 
 
   // Promise util method to output rejected results
   // Promise util method to output rejected results
   private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
   private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
-    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+    const rejectedResults: PromiseRejectedResult[] = results.filter(
+      (result): result is PromiseRejectedResult => result.status === 'rejected',
+    );
 
 
     rejectedResults.forEach((rejected, i) => {
     rejectedResults.forEach((rejected, i) => {
-      logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
+      logger.error(
+        `Error occurred (count: ${i}): `,
+        rejected.reason.toString(),
+      );
     });
     });
   }
   }
-
 }
 }

+ 4 - 1
apps/app/src/server/service/socket-io/helper.ts

@@ -3,6 +3,9 @@ export const RoomPrefix = {
   PAGE: 'page',
   PAGE: 'page',
 };
 };
 
 
-export const getRoomNameWithId = (roomPrefix: string, roomId: string): string => {
+export const getRoomNameWithId = (
+  roomPrefix: string,
+  roomId: string,
+): string => {
   return `${roomPrefix}:${roomId}`;
   return `${roomPrefix}:${roomId}`;
 };
 };

+ 27 - 22
apps/app/src/server/service/socket-io/socket-io.ts

@@ -1,7 +1,6 @@
-import type { IncomingMessage } from 'http';
-
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import expressSession from 'express-session';
 import expressSession from 'express-session';
+import type { IncomingMessage } from 'http';
 import passport from 'passport';
 import passport from 'passport';
 import type { Namespace } from 'socket.io';
 import type { Namespace } from 'socket.io';
 import { Server } from 'socket.io';
 import { Server } from 'socket.io';
@@ -11,20 +10,16 @@ import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
 import { configManager } from '../config-manager';
 import { configManager } from '../config-manager';
-
-import { RoomPrefix, getRoomNameWithId } from './helper';
-
+import { getRoomNameWithId, RoomPrefix } from './helper';
 
 
 const logger = loggerFactory('growi:service:socket-io');
 const logger = loggerFactory('growi:service:socket-io');
 
 
-
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 
 /**
 /**
  * Serve socket.io for server-to-client messaging
  * Serve socket.io for server-to-client messaging
  */
  */
 export class SocketIoService {
 export class SocketIoService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   guestClients: Set<string>;
   guestClients: Set<string>;
@@ -33,14 +28,13 @@ export class SocketIoService {
 
 
   adminNamespace: Namespace;
   adminNamespace: Namespace;
 
 
-
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
     this.guestClients = new Set();
     this.guestClients = new Set();
   }
   }
 
 
   get isInitialized(): boolean {
   get isInitialized(): boolean {
-    return (this.io != null);
+    return this.io != null;
   }
   }
 
 
   // Since the Order is important, attachServer() should be async
   // Since the Order is important, attachServer() should be async
@@ -95,9 +89,13 @@ export class SocketIoService {
    * use loginRequired middleware
    * use loginRequired middleware
    */
    */
   setupLoginRequiredMiddleware() {
   setupLoginRequiredMiddleware() {
-    const loginRequired = require('../../middlewares/login-required')(this.crowi, true, (req, res, next) => {
-      next(new Error('Login is required to connect.'));
-    });
+    const loginRequired = require('../../middlewares/login-required')(
+      this.crowi,
+      true,
+      (req, res, next) => {
+        next(new Error('Login is required to connect.'));
+      },
+    );
 
 
     // convert Connect/Express middleware to Socket.io middleware
     // convert Connect/Express middleware to Socket.io middleware
     this.io.use((socket, next) => {
     this.io.use((socket, next) => {
@@ -109,9 +107,12 @@ export class SocketIoService {
    * use adminRequired middleware
    * use adminRequired middleware
    */
    */
   setupAdminRequiredMiddleware() {
   setupAdminRequiredMiddleware() {
-    const adminRequired = require('../../middlewares/admin-required')(this.crowi, (req, res, next) => {
-      next(new Error('Admin priviledge is required to connect.'));
-    });
+    const adminRequired = require('../../middlewares/admin-required')(
+      this.crowi,
+      (req, res, next) => {
+        next(new Error('Admin priviledge is required to connect.'));
+      },
+    );
 
 
     // convert Connect/Express middleware to Socket.io middleware
     // convert Connect/Express middleware to Socket.io middleware
     this.getAdminSocket().use((socket, next) => {
     this.getAdminSocket().use((socket, next) => {
@@ -175,9 +176,11 @@ export class SocketIoService {
       const clients = await this.getAdminSocket().fetchSockets();
       const clients = await this.getAdminSocket().fetchSockets();
       const clientsCount = clients.length;
       const clientsCount = clients.length;
 
 
-      logger.debug('Current count of clients for \'/admin\':', clientsCount);
+      logger.debug("Current count of clients for '/admin':", clientsCount);
 
 
-      const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForAdmin');
+      const limit = configManager.getConfig(
+        's2cMessagingPubsub:connectionsLimitForAdmin',
+      );
       if (limit <= clientsCount) {
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
         logger.warn(msg);
@@ -190,13 +193,14 @@ export class SocketIoService {
   }
   }
 
 
   async checkConnectionLimitsForGuest(socket, next) {
   async checkConnectionLimitsForGuest(socket, next) {
-
     if (socket.request.user == null) {
     if (socket.request.user == null) {
       const clientsCount = this.guestClients.size;
       const clientsCount = this.guestClients.size;
 
 
       logger.debug('Current count of clients for guests:', clientsCount);
       logger.debug('Current count of clients for guests:', clientsCount);
 
 
-      const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForGuest');
+      const limit = configManager.getConfig(
+        's2cMessagingPubsub:connectionsLimitForGuest',
+      );
       if (limit <= clientsCount) {
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
         logger.warn(msg);
@@ -221,9 +225,11 @@ export class SocketIoService {
     const clients = await this.getDefaultSocket().fetchSockets();
     const clients = await this.getDefaultSocket().fetchSockets();
     const clientsCount = clients.length;
     const clientsCount = clients.length;
 
 
-    logger.debug('Current count of clients for \'/\':', clientsCount);
+    logger.debug("Current count of clients for '/':", clientsCount);
 
 
-    const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimit');
+    const limit = configManager.getConfig(
+      's2cMessagingPubsub:connectionsLimit',
+    );
     if (limit <= clientsCount) {
     if (limit <= clientsCount) {
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       logger.warn(msg);
       logger.warn(msg);
@@ -233,5 +239,4 @@ export class SocketIoService {
 
 
     next();
     next();
   }
   }
-
 }
 }

+ 25 - 15
apps/app/src/server/service/system-events/sync-page-status.ts

@@ -5,9 +5,11 @@ import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
-import { RoomPrefix, getRoomNameWithId } from '../socket-io/helper';
+import { getRoomNameWithId, RoomPrefix } from '../socket-io/helper';
 
 
-const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
+const logger = loggerFactory(
+  'growi:service:system-events:SyncPageStatusService',
+);
 
 
 /**
 /**
  * This service notify page status
  * This service notify page status
@@ -23,7 +25,6 @@ const logger = loggerFactory('growi:service:system-events:SyncPageStatusService'
  *
  *
  */
  */
 class SyncPageStatusService implements S2sMessageHandlable {
 class SyncPageStatusService implements S2sMessageHandlable {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   s2sMessagingService!: S2sMessagingService;
   s2sMessagingService!: S2sMessagingService;
@@ -63,7 +64,9 @@ class SyncPageStatusService implements S2sMessageHandlable {
 
 
     // emit the updated information to clients
     // emit the updated information to clients
     if (socketIoService.isInitialized) {
     if (socketIoService.isInitialized) {
-      socketIoService.getDefaultSocket().emit(socketIoEventName, s2cMessageBody);
+      socketIoService
+        .getDefaultSocket()
+        .emit(socketIoEventName, s2cMessageBody);
     }
     }
   }
   }
 
 
@@ -71,13 +74,18 @@ class SyncPageStatusService implements S2sMessageHandlable {
     const { s2sMessagingService } = this;
     const { s2sMessagingService } = this;
 
 
     if (s2sMessagingService != null) {
     if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, s2cMessageBody });
+      const s2sMessage = new S2sMessage('pageStatusUpdated', {
+        socketIoEventName,
+        s2cMessageBody,
+      });
 
 
       try {
       try {
         await s2sMessagingService.publish(s2sMessage);
         await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
       }
     }
     }
   }
   }
@@ -87,12 +95,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
 
 
     // register events
     // register events
     this.emitter.on('create', (page, user) => {
     this.emitter.on('create', (page, user) => {
-      logger.debug('\'create\' event emitted.');
+      logger.debug("'create' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:create', { s2cMessagePageUpdated });
         .emit('page:create', { s2cMessagePageUpdated });
@@ -100,12 +109,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
     });
     });
     this.emitter.on('update', (page, user) => {
     this.emitter.on('update', (page, user) => {
-      logger.debug('\'update\' event emitted.');
+      logger.debug("'update' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:update', { s2cMessagePageUpdated });
         .emit('page:update', { s2cMessagePageUpdated });
@@ -113,12 +123,13 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
     });
     });
     this.emitter.on('delete', (page, user) => {
     this.emitter.on('delete', (page, user) => {
-      logger.debug('\'delete\' event emitted.');
+      logger.debug("'delete' event emitted.");
 
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
 
 
       // emit to the room for each page
       // emit to the room for each page
-      socketIoService.getDefaultSocket()
+      socketIoService
+        .getDefaultSocket()
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .except(getRoomNameWithId(RoomPrefix.USER, user._id))
         .emit('page:delete', { s2cMessagePageUpdated });
         .emit('page:delete', { s2cMessagePageUpdated });
@@ -126,7 +137,6 @@ class SyncPageStatusService implements S2sMessageHandlable {
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
     });
   }
   }
-
 }
 }
 
 
 module.exports = SyncPageStatusService;
 module.exports = SyncPageStatusService;

+ 30 - 14
apps/app/src/server/service/user-notification/index.ts

@@ -3,10 +3,9 @@ import type { IRevisionHasId } from '@growi/core';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 import { toArrayFromCsv } from '~/utils/to-array-from-csv';
 
 
-
 import {
 import {
-  prepareSlackMessageForPage,
   prepareSlackMessageForComment,
   prepareSlackMessageForComment,
+  prepareSlackMessageForPage,
 } from '../../util/slack';
 } from '../../util/slack';
 import { growiInfoService } from '../growi-info';
 import { growiInfoService } from '../growi-info';
 
 
@@ -14,7 +13,6 @@ import { growiInfoService } from '../growi-info';
  * service class of UserNotification
  * service class of UserNotification
  */
  */
 export class UserNotificationService {
 export class UserNotificationService {
-
   crowi: Crowi;
   crowi: Crowi;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
@@ -34,10 +32,15 @@ export class UserNotificationService {
    * @param {Comment} comment
    * @param {Comment} comment
    */
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: IRevisionHasId }, comment = {}): Promise<PromiseSettledResult<any>[]> {
-    const {
-      appService, slackIntegrationService,
-    } = this.crowi;
+  async fire(
+    page,
+    user,
+    slackChannelsStr,
+    mode,
+    option?: { previousRevision: IRevisionHasId },
+    comment = {},
+  ): Promise<PromiseSettledResult<any>[]> {
+    const { appService, slackIntegrationService } = this.crowi;
 
 
     if (!slackIntegrationService.isSlackConfigured) {
     if (!slackIntegrationService.isSlackConfigured) {
       throw new Error('slackIntegrationService has not been set up');
       throw new Error('slackIntegrationService has not been set up');
@@ -49,18 +52,32 @@ export class UserNotificationService {
     const { previousRevision } = option ?? {};
     const { previousRevision } = option ?? {};
 
 
     // "dev,slacktest" => [dev,slacktest]
     // "dev,slacktest" => [dev,slacktest]
-    const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);
+    const slackChannels: (string | null)[] = toArrayFromCsv(slackChannelsStr);
 
 
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
     const siteUrl = growiInfoService.getSiteUrl();
     const siteUrl = growiInfoService.getSiteUrl();
 
 
-    const promises = slackChannels.map(async(chan) => {
+    const promises = slackChannels.map(async (chan) => {
       let messageObj;
       let messageObj;
       if (mode === 'comment') {
       if (mode === 'comment') {
-        messageObj = prepareSlackMessageForComment(comment, user, appTitle, siteUrl, chan, page.path);
-      }
-      else {
-        messageObj = prepareSlackMessageForPage(page, user, appTitle, siteUrl, chan, mode, previousRevision);
+        messageObj = prepareSlackMessageForComment(
+          comment,
+          user,
+          appTitle,
+          siteUrl,
+          chan,
+          page.path,
+        );
+      } else {
+        messageObj = prepareSlackMessageForPage(
+          page,
+          user,
+          appTitle,
+          siteUrl,
+          chan,
+          mode,
+          previousRevision,
+        );
       }
       }
 
 
       return slackIntegrationService.postMessage(messageObj);
       return slackIntegrationService.postMessage(messageObj);
@@ -68,5 +85,4 @@ export class UserNotificationService {
 
 
     return Promise.allSettled(promises);
     return Promise.allSettled(promises);
   }
   }
-
 }
 }

+ 2 - 4
apps/app/src/server/service/yjs/create-indexes.ts

@@ -4,8 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:yjs:create-indexes');
 const logger = loggerFactory('growi:service:yjs:create-indexes');
 
 
-export const createIndexes = async(collectionName: string): Promise<void> => {
-
+export const createIndexes = async (collectionName: string): Promise<void> => {
   const collection = mongoose.connection.collection(collectionName);
   const collection = mongoose.connection.collection(collectionName);
 
 
   try {
   try {
@@ -35,8 +34,7 @@ export const createIndexes = async(collectionName: string): Promise<void> => {
         },
         },
       },
       },
     ]);
     ]);
-  }
-  catch (err) {
+  } catch (err) {
     logger.error('Failed to create Index', err);
     logger.error('Failed to create Index', err);
     throw err;
     throw err;
   }
   }

+ 12 - 5
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -12,10 +12,12 @@ const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
  * @param mdb
  * @param mdb
  * @returns
  * @returns
  */
  */
-export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => {
+export const createMongoDBPersistence = (
+  mdb: MongodbPersistence,
+): Persistence => {
   const persistece: Persistence = {
   const persistece: Persistence = {
     provider: mdb,
     provider: mdb,
-    bindState: async(docName, ydoc) => {
+    bindState: async (docName, ydoc) => {
       logger.debug('bindState', { docName });
       logger.debug('bindState', { docName });
 
 
       const persistedYdoc = await mdb.getYDoc(docName);
       const persistedYdoc = await mdb.getYDoc(docName);
@@ -25,7 +27,12 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
 
 
       // store the new data in db (if there is any: empty update is an array of 0s)
       // 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) {
+      if (
+        diff.reduce(
+          (previousValue, currentValue) => previousValue + currentValue,
+          0,
+        ) > 0
+      ) {
         mdb.storeUpdate(docName, diff);
         mdb.storeUpdate(docName, diff);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       }
       }
@@ -34,7 +41,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
 
 
       // store updates of the document in db
       // store updates of the document in db
-      ydoc.on('update', async(update) => {
+      ydoc.on('update', async (update) => {
         mdb.storeUpdate(docName, update);
         mdb.storeUpdate(docName, update);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       });
       });
@@ -42,7 +49,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence =
       // cleanup some memory
       // cleanup some memory
       persistedYdoc.destroy();
       persistedYdoc.destroy();
     },
     },
-    writeState: async(docName) => {
+    writeState: async (docName) => {
       logger.debug('writeState', { docName });
       logger.debug('writeState', { docName });
       // This is called when all connections to the document are closed.
       // This is called when all connections to the document are closed.
 
 

+ 14 - 8
apps/app/src/server/service/yjs/extended/mongodb-persistence.ts

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

+ 34 - 24
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -1,20 +1,18 @@
 import { Origin, YDocStatus } from '@growi/core';
 import { Origin, YDocStatus } from '@growi/core';
-import { type Delta } from '@growi/editor';
+import type { Delta } from '@growi/editor';
 import type { Document } from 'y-socket.io/dist/server';
 import type { Document } from 'y-socket.io/dist/server';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
-
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 
 
 const logger = loggerFactory('growi:service:yjs:sync-ydoc');
 const logger = loggerFactory('growi:service:yjs:sync-ydoc');
 
 
-
 type Context = {
 type Context = {
-  ydocStatus: YDocStatus,
-}
+  ydocStatus: YDocStatus;
+};
 
 
 /**
 /**
  * Sync the text and the meta data with the latest revision body
  * Sync the text and the meta data with the latest revision body
@@ -22,30 +20,35 @@ type Context = {
  * @param doc
  * @param doc
  * @param context true to force sync
  * @param context true to force sync
  */
  */
-export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
+export const syncYDoc = async (
+  mdb: MongodbPersistence,
+  doc: Document,
+  context: true | Context,
+): Promise<void> => {
   const pageId = doc.name;
   const pageId = doc.name;
 
 
   // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
   // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
   await normalizeLatestRevisionIfBroken(pageId);
   await normalizeLatestRevisionIfBroken(pageId);
 
 
-  const revision = await Revision
-    .findOne(
-      // filter
-      { pageId },
-      // projection
-      { body: 1, createdAt: 1, origin: 1 },
-      // options
-      { sort: { createdAt: -1 } },
-    )
-    .lean();
+  const revision = await Revision.findOne(
+    // filter
+    { pageId },
+    // projection
+    { body: 1, createdAt: 1, origin: 1 },
+    // options
+    { sort: { createdAt: -1 } },
+  ).lean();
 
 
   if (revision == null) {
   if (revision == null) {
-    logger.warn(`Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`);
+    logger.warn(
+      `Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`,
+    );
     return;
     return;
   }
   }
 
 
-  const shouldSync = context === true
-    || (() => {
+  const shouldSync =
+    context === true ||
+    (() => {
       switch (context.ydocStatus) {
       switch (context.ydocStatus) {
         case YDocStatus.NEW:
         case YDocStatus.NEW:
           return true;
           return true;
@@ -58,7 +61,9 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t
     })();
     })();
 
 
   if (shouldSync) {
   if (shouldSync) {
-    logger.debug(`YDoc for the page ('${pageId}') is synced with the latest revision body`);
+    logger.debug(
+      `YDoc for the page ('${pageId}') is synced with the latest revision body`,
+    );
 
 
     const ytext = doc.getText('codemirror');
     const ytext = doc.getText('codemirror');
     const delta: Delta = [];
     const delta: Delta = [];
@@ -73,11 +78,16 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t
     ytext.applyDelta(delta, { sanitize: false });
     ytext.applyDelta(delta, { sanitize: false });
   }
   }
 
 
-  const shouldSyncMeta = context === true
-    || context.ydocStatus === YDocStatus.NEW
-    || context.ydocStatus === YDocStatus.OUTDATED;
+  const shouldSyncMeta =
+    context === true ||
+    context.ydocStatus === YDocStatus.NEW ||
+    context.ydocStatus === YDocStatus.OUTDATED;
 
 
   if (shouldSyncMeta) {
   if (shouldSyncMeta) {
-    mdb.setMeta(doc.name, 'updatedAt', revision.createdAt.getTime() ?? Date.now());
+    mdb.setMeta(
+      doc.name,
+      'updatedAt',
+      revision.createdAt.getTime() ?? Date.now(),
+    );
   }
   }
 };
 };

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

@@ -4,12 +4,10 @@ import type { Server } from 'socket.io';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
-
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { IYjsService } from './yjs';
 import type { IYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 
 
-
 vi.mock('y-socket.io/dist/server', () => {
 vi.mock('y-socket.io/dist/server', () => {
   const YSocketIO = vi.fn();
   const YSocketIO = vi.fn();
   YSocketIO.prototype.on = vi.fn();
   YSocketIO.prototype.on = vi.fn();
@@ -23,24 +21,21 @@ vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
 
 
 const ObjectId = Types.ObjectId;
 const ObjectId = Types.ObjectId;
 
 
-
 const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
 const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
   // eslint-disable-next-line dot-notation
   // eslint-disable-next-line dot-notation
   return yjsService['mdb'];
   return yjsService['mdb'];
 };
 };
 
 
 describe('YjsService', () => {
 describe('YjsService', () => {
-
   describe('getYDocStatus()', () => {
   describe('getYDocStatus()', () => {
-
-    beforeAll(async() => {
+    beforeAll(async () => {
       const ioMock = mock<Server>();
       const ioMock = mock<Server>();
 
 
       // initialize
       // initialize
       initializeYjsService(ioMock);
       initializeYjsService(ioMock);
     });
     });
 
 
-    afterAll(async() => {
+    afterAll(async () => {
       // flush revisions
       // flush revisions
       await Revision.deleteMany({});
       await Revision.deleteMany({});
 
 
@@ -50,7 +45,7 @@ describe('YjsService', () => {
       await privateMdb.flushDB();
       await privateMdb.flushDB();
     });
     });
 
 
-    it('returns ISOLATED when neither revisions nor YDocs exists', async() => {
+    it('returns ISOLATED when neither revisions nor YDocs exists', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -63,7 +58,7 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.ISOLATED);
       expect(result).toBe(YDocStatus.ISOLATED);
     });
     });
 
 
-    it('returns ISOLATED when no revisions exist', async() => {
+    it('returns ISOLATED when no revisions exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -79,15 +74,13 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.ISOLATED);
       expect(result).toBe(YDocStatus.ISOLATED);
     });
     });
 
 
-    it('returns NEW when no YDocs exist', async() => {
+    it('returns NEW when no YDocs exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -96,18 +89,20 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.NEW);
       expect(result).toBe(YDocStatus.NEW);
     });
     });
 
 
-    it('returns DRAFT when the newer YDocs exist', async() => {
+    it('returns DRAFT when the newer YDocs exist', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2034, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -116,7 +111,7 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.DRAFT);
       expect(result).toBe(YDocStatus.DRAFT);
     });
     });
 
 
-    it('returns SYNCED', async() => {
+    it('returns SYNCED', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
@@ -127,7 +122,11 @@ describe('YjsService', () => {
       ]);
       ]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2025, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2025, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -136,18 +135,20 @@ describe('YjsService', () => {
       expect(result).toBe(YDocStatus.SYNCED);
       expect(result).toBe(YDocStatus.SYNCED);
     });
     });
 
 
-    it('returns OUTDATED when the latest revision is newer than meta data', async() => {
+    it('returns OUTDATED when the latest revision is newer than meta data', async () => {
       // arrange
       // arrange
       const yjsService = getYjsService();
       const yjsService = getYjsService();
 
 
       const pageId = new ObjectId();
       const pageId = new ObjectId();
 
 
-      await Revision.insertMany([
-        { pageId, body: '' },
-      ]);
+      await Revision.insertMany([{ pageId, body: '' }]);
 
 
       const privateMdb = getPrivateMdbInstance(yjsService);
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(
+        pageId.toString(),
+        'updatedAt',
+        new Date(2024, 1, 1).getTime(),
+      );
 
 
       // act
       // act
       const result = await yjsService.getYDocStatus(pageId.toString());
       const result = await yjsService.getYDocStatus(pageId.toString());
@@ -155,6 +156,5 @@ describe('YjsService', () => {
       // assert
       // assert
       expect(result).toBe(YDocStatus.OUTDATED);
       expect(result).toBe(YDocStatus.OUTDATED);
     });
     });
-
   });
   });
 });
 });

+ 47 - 42
apps/app/src/server/service/yjs/yjs.ts

@@ -1,57 +1,53 @@
-import type { IncomingMessage } from 'http';
-
-
 import type { IPage, IUserHasId } from '@growi/core';
 import type { IPage, IUserHasId } from '@growi/core';
 import { YDocStatus } from '@growi/core/dist/consts';
 import { YDocStatus } from '@growi/core/dist/consts';
+import type { IncomingMessage } from 'http';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import type { Server } from 'socket.io';
 import type { Server } from 'socket.io';
 import type { Document } from 'y-socket.io/dist/server';
 import type { Document } from 'y-socket.io/dist/server';
-import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
+import { type Document as Ydoc, YSocketIO } from 'y-socket.io/dist/server';
 
 
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
-import { RoomPrefix, getRoomNameWithId } from '~/server/service/socket-io/helper';
+import {
+  getRoomNameWithId,
+  RoomPrefix,
+} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
-
 import { createIndexes } from './create-indexes';
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
 import { syncYDoc } from './sync-ydoc';
 import { syncYDoc } from './sync-ydoc';
 
 
-
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 
 
-
 const logger = loggerFactory('growi:service:yjs');
 const logger = loggerFactory('growi:service:yjs');
 
 
-
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 
-
 export interface IYjsService {
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
   getYDocStatus(pageId: string): Promise<YDocStatus>;
-  syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody>
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
   getCurrentYdoc(pageId: string): Ydoc | undefined;
   getCurrentYdoc(pageId: string): Ydoc | undefined;
 }
 }
 
 
-
 class YjsService implements IYjsService {
 class YjsService implements IYjsService {
-
   private ysocketio: YSocketIO;
   private ysocketio: YSocketIO;
 
 
   private mdb: MongodbPersistence;
   private mdb: MongodbPersistence;
 
 
   constructor(io: Server) {
   constructor(io: Server) {
-
     const mdb = new MongodbPersistence(
     const mdb = new MongodbPersistence(
       // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'.
       // 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
       // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
+      // @ts-expect-error
       {
       {
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -78,7 +74,7 @@ class YjsService implements IYjsService {
     // register middlewares
     // register middlewares
     this.registerAccessiblePageChecker(ysocketio);
     this.registerAccessiblePageChecker(ysocketio);
 
 
-    ysocketio.on('document-loaded', async(doc: Document) => {
+    ysocketio.on('document-loaded', async (doc: Document) => {
       const pageId = doc.name;
       const pageId = doc.name;
 
 
       const ydocStatus = await this.getYDocStatus(pageId);
       const ydocStatus = await this.getYDocStatus(pageId);
@@ -86,7 +82,7 @@ class YjsService implements IYjsService {
       syncYDoc(mdb, doc, { ydocStatus });
       syncYDoc(mdb, doc, { ydocStatus });
     });
     });
 
 
-    ysocketio.on('awareness-update', async(doc: Document) => {
+    ysocketio.on('awareness-update', async (doc: Document) => {
       const pageId = doc.name;
       const pageId = doc.name;
 
 
       if (pageId == null) return;
       if (pageId == null) return;
@@ -94,24 +90,29 @@ class YjsService implements IYjsService {
       const awarenessStateSize = doc.awareness.states.size;
       const awarenessStateSize = doc.awareness.states.size;
 
 
       // Triggered when awareness changes
       // Triggered when awareness changes
-      io
-        .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-        .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+      io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+        SocketEventName.YjsAwarenessStateSizeUpdated,
+        awarenessStateSize,
+      );
 
 
       // Triggered when the last user leaves the editor
       // Triggered when the last user leaves the editor
       if (awarenessStateSize === 0) {
       if (awarenessStateSize === 0) {
         const ydocStatus = await this.getYDocStatus(pageId);
         const ydocStatus = await this.getYDocStatus(pageId);
-        const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+        const hasYdocsNewerThanLatestRevision =
+          ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
 
 
-        io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
+        io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+          SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+          hasYdocsNewerThanLatestRevision,
+        );
       }
       }
     });
     });
-
   }
   }
 
 
-  private injectPersistence(ysocketio: YSocketIO, mdb: MongodbPersistence): void {
+  private injectPersistence(
+    ysocketio: YSocketIO,
+    mdb: MongodbPersistence,
+  ): void {
     const persistece = createMongoDBPersistence(mdb);
     const persistece = createMongoDBPersistence(mdb);
 
 
     // foce set to private property
     // foce set to private property
@@ -121,7 +122,7 @@ class YjsService implements IYjsService {
 
 
   private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
   private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
     // check accessible page
     // check accessible page
-    ysocketio.nsp?.use(async(socket, next) => {
+    ysocketio.nsp?.use(async (socket, next) => {
       // extract page id from namespace
       // extract page id from namespace
       const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
       const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
       const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
       const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
@@ -139,22 +140,23 @@ class YjsService implements IYjsService {
 
 
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
     const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => {
     const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => {
-      logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
+      logger.debug(
+        `getYDocStatus('${pageId}') detected '${status}'`,
+        args ?? {},
+      );
     };
     };
 
 
     // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
     // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
     await normalizeLatestRevisionIfBroken(pageId);
     await normalizeLatestRevisionIfBroken(pageId);
 
 
     // get the latest revision createdAt
     // get the latest revision createdAt
-    const result = await Revision
-      .findOne(
-        // filter
-        { pageId },
-        // projection
-        { createdAt: 1 },
-        { sort: { createdAt: -1 } },
-      )
-      .lean();
+    const result = await Revision.findOne(
+      // filter
+      { pageId },
+      // projection
+      { createdAt: 1 },
+      { sort: { createdAt: -1 } },
+    ).lean();
 
 
     if (result == null) {
     if (result == null) {
       dumpLog(YDocStatus.ISOLATED, { result });
       dumpLog(YDocStatus.ISOLATED, { result });
@@ -186,7 +188,10 @@ class YjsService implements IYjsService {
     return YDocStatus.OUTDATED;
     return YDocStatus.OUTDATED;
   }
   }
 
 
-  public async syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> {
+  public async syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody> {
     const doc = this.ysocketio.documents.get(pageId);
     const doc = this.ysocketio.documents.get(pageId);
 
 
     if (doc == null) {
     if (doc == null) {
@@ -198,9 +203,10 @@ class YjsService implements IYjsService {
 
 
     return {
     return {
       synced: true,
       synced: true,
-      isYjsDataBroken: editingMarkdownLength != null
-        ? editingMarkdownLength !== ytextLength
-        : undefined,
+      isYjsDataBroken:
+        editingMarkdownLength != null
+          ? editingMarkdownLength !== ytextLength
+          : undefined,
     };
     };
   }
   }
 
 
@@ -208,7 +214,6 @@ class YjsService implements IYjsService {
     const currentYdoc = this.ysocketio.documents.get(pageId);
     const currentYdoc = this.ysocketio.documents.get(pageId);
     return currentYdoc;
     return currentYdoc;
   }
   }
-
 }
 }
 
 
 let _instance: YjsService;
 let _instance: YjsService;

+ 1 - 14
biome.json

@@ -31,8 +31,6 @@
       "!apps/app/src/client",
       "!apps/app/src/client",
       "!apps/app/src/server/middlewares",
       "!apps/app/src/server/middlewares",
       "!apps/app/src/server/routes/apiv3/*.js",
       "!apps/app/src/server/routes/apiv3/*.js",
-      "!apps/app/src/server/service/access-token",
-      "!apps/app/src/server/service/config-manager",
       "!apps/app/src/server/service/file-uploader",
       "!apps/app/src/server/service/file-uploader",
       "!apps/app/src/server/service/global-notification",
       "!apps/app/src/server/service/global-notification",
       "!apps/app/src/server/service/growi-bridge",
       "!apps/app/src/server/service/growi-bridge",
@@ -41,18 +39,7 @@
       "!apps/app/src/server/service/in-app-notification",
       "!apps/app/src/server/service/in-app-notification",
       "!apps/app/src/server/service/interfaces",
       "!apps/app/src/server/service/interfaces",
       "!apps/app/src/server/service/normalize-data",
       "!apps/app/src/server/service/normalize-data",
-      "!apps/app/src/server/service/page",
-      "!apps/app/src/server/service/page-listing",
-      "!apps/app/src/server/service/revision",
-      "!apps/app/src/server/service/s2s-messaging",
-      "!apps/app/src/server/service/search-delegator",
-      "!apps/app/src/server/service/search-reconnect-context",
-      "!apps/app/src/server/service/slack-command-handler",
-      "!apps/app/src/server/service/slack-event-handler",
-      "!apps/app/src/server/service/socket-io",
-      "!apps/app/src/server/service/system-events",
-      "!apps/app/src/server/service/user-notification",
-      "!apps/app/src/server/service/yjs"
+      "!apps/app/src/server/service/page"
     ]
     ]
   },
   },
   "formatter": {
   "formatter": {